수천 번 실행되는 코드를 최적화하는 방법들
컴파일러가 최적화하기 쉬운 코드
createFiberImplClass - packages/react-reconciler/src/ReactFiber.js
// This is a constructor function, rather than a POJO constructor, still
// please ensure we do the following:
// 1) Nobody should add any instance methods on this. Instance methods can be
// more difficult to predict when they get optimized and they are almost
// never inlined properly in static compilers.
// 2) Nobody should rely on instanceof Fiber for type testing. We should
// always know when it is a fiber.
// 3) We might want to experiment with using numeric keys since they are easier
// to optimize in a non-JIT environment.
// 4) We can easily go from a constructor to a createFiber object literal if that
// is faster.
// 5) It should be easy to port this to a C struct and keep a C implementation
// compatible.
function createFiberImplClass(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
): Fiber {
// $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
return new FiberNode(tag, pendingProps, key, mode);
}
Flow
복사
createFiberImplClass은 새로운 Fiber 인스턴스를 만드는 팩토리 함수이다. 이 함수에 달린 주석을 보면 react 팀은 POJO 생성자 대신에 함수 생성자를 사용하지만 여전히 다음과 같은 규칙을 지키고 있음을 알 수 있다.
1.
this에 인스턴스 메서드를 추가하지 않는다.
JavaScript에서 클래스 인스턴스 메서드는 대부분 프로토타입 체인에 걸려 있는 함수이다.
class Foo {
doSomething() {}
}
const foo = new Foo();
Flow
복사
이 경우 foo.doSomething은 Foo.prototype.doSomething을 참조한다. 문제는 JS 엔진이 이런 메서드들을 JIT 최적화할 때 잘 인라인(inline) 못 한다는 것이다. 때문에 React는 최대한 객체 안에 순수 데이터만 넣고, 로직은 함수로 외부에서 분리해서 쓰는 방식을 선호한다.
인라인(inline)이란?
함수를 호출하는 대신, 함수 내용을 그대로 코드에 ‘붙여넣는’ 최적화 방식이다.
function add(a, b) {
return a + b;
}
const result = add(1, 2); // 함수 호출
Flow
복사
// 인라인된 코드
const result = 1 + 2; // add 함수를 호출하지 않고 내부 내용을 붙여넣음
Flow
복사
이렇게 되면 함수 호출에 드는 비용(스택 관리, 컨텍스트 전환 등)이 줄어들고, 속도가 빨라진다.
그런데 인스턴스 메서드의 경우 프로토타입에 메서드가 추가가 되므로 인스턴스의 메서드를 호출할 경우, js 엔진은 프로토타입 체인을 따라 동적으로 메서드를 찾는다. 따라서 JIT 컴파일러 입장에서는 예측이 어려워 인라인을 포기하게 된다. 특히 this는 계속 바뀔 수 있기 때문에 항상 똑같은 함수라고 확신할 수 없다.
// 예시 코드
function updateFiber(fiber) {
fiber.memoizedProps = fiber.pendingProps;
}
Flow
복사
그래서 React 같은 성능 민감한 라이브러리는 JIT 최적화를 위해 클래스보다는 순수 함수(외부 로직) + POJO 조합을 따르는 것이다. (참고로 POJO는 메서드 없이 데이터만 가지고 있는 순수 객체라 Object Literal과는 차이가 있다)
테스트 코드 및 실험 결과
간단한 테스트 코드인데, 결과적으로는 클래스 인스턴스 메서드가 가장 빨랐다. 짧고 단순하고 일정한 패턴이라면 인스턴스 메서드도 최적화의 대상이 된다. 하지만 리액트 팀이 POJO + 외부 함수 형식을 사용하는 이유는 일정한 성능을 보장하면서 이식성, 구조적 예측을 쉽게 하기 위한 것이다.
2.
type testing을 위해 instanceof를 쓸 필요 없게 하자.
Fiber는 다양한 환경 (예: 브라우저, 테스트, 서버, DevTools, C로 포팅된 버전 등)에서 돌아가야 하고, instanceof는 클래스의 프로토타입 체인에 의존하므로, 구조가 살짝만 바뀌어도 깨지기 때문이다.
예를 들어, React의 DevTools 버전이 다르거나 다른 환경에서 Fiber를 복제했을 때 prototype 체인이 깨질 수 있다. 반면 fiber.tag === FunctionComponent처럼 명시적인 필드를 보면 값만 맞으면 타입 체크가 된다.
따라서 함수 생성자를 쓰더라도 prototype 기반으로 타입 추론하지 말고, 구조적 필드(tag 등)로 구분하라는 게 주석에 적힌 룰의 핵심이다.
4.
클래스를 Object Literal로 바꿀 수 있어야 한다.
(주석 3, 5번은 생략)
new FiberNode(...)처럼 클래스를 쓰면 편하긴 하지만, 클래스는 히든 클래스를 만들고, 메서드와 프로토타입 체인을 갖는 등 JS 엔진들은 클래스를 일반 객체보다 덜 최적화한다. 때문에 나중에 성능을 더 끌어올려야 하는 시점에는 FiberNode를 Object Literal로 바꿀 수 있어야 하기 때문에 클래스를 사용하면서도 Object Literal로 변경하기 쉽게 구현했다. 즉, 클래스를 버리고 Object Literal로 바꿔도 코드 전체가 깨지지 않도록 설계하려는 의미에서 팩토리 함수에 주석을 달아놓은 것이다.
const fiber = {
tag,
pendingProps,
key,
mode,
...
};
Flow
복사
비트 플래그로 분기 처리
renderRootSync - packages/react-reconciler/src/ReactFiberWorkLoop.js
const prevExecutionContext = executionContext;
executionContext |= RenderContext;
Flow
복사
위 코드는 현재 context에 렌더링 컨텍스트에 진입했다는 표시하는 역할을 한다.
export const NoContext = /* */ 0b000; // 0
const BatchedContext = /* */ 0b001; // 1
export const RenderContext = /* */ 0b010; // 2
export const CommitContext = /* */ 0b100; // 4
Flow
복사
샘플 코드
// 초기 상태
executionContext = NoContext; // 0b000
// 렌더링 시작
executionContext |= RenderContext; // 0b000 | 0b010 = 0b010
// 만약 이미 배치 컨텍스트였다면
executionContext = BatchedContext; // 0b001
executionContext |= RenderContext; // 0b001 | 0b010 = 0b011
Flow
복사
이런 식으로 비트 연산을 통해서 현재 컨텍스트에 렌더링 컨텍스트를 복합적으로 표시하는 것이다.
// 여러 컨텍스트가 동시에 활성화되었는지 확인
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
// 렌더링 또는 커밋 중에 있다는 뜻
}
Flow
복사