(번역) 리액트 컴파일러의 타입 시스템

Jung Han
10 min readMar 28, 2024

--

원문: https://www.recompiled.dev/blog/type-system/

리액트 컴파일러가 무엇인지 궁금하다면 최근 업데이트 글에서 배경지식을 읽어보시기를 바랍니다. 이 글은 컴파일러의 이론이 궁금한 분들을 위한 글입니다. 컴파일러를 사용하기 위해 이 글의 모든 내용을 이해해야 한다는 부담감은 갖지 마세요.

프로퍼티 메모이제이션

리액트에서 React.memo로 래핑 된 컴포넌트는 프로퍼티가 변경될 때만 다시 렌더링 됩니다.

const Greeting = memo(function Greeting({ user }) {
return (
<h1>
Hello, {user.firstName} {user.lastName}!
</h1>
);
});

Greeting은 프로퍼티인 user변경될 때마다 다시 렌더링 됩니다. 리액트는 프로퍼티가 변경되었는지 확인하기 위해 얕은 비교를 사용합니다.

자바스크립트에서 객체는 얕은 비교를 위해 자신의 동일성(identity)을 유지해야 합니다. 그렇기 때문에 메모이제이션(memoization)이 매우 중요할 수 있습니다. 반면 원시값은 동일성과 연관되어 있지 않기 때문에 직접 비교할 수 있습니다.

Object.is({}, {}); // false
Object.is(3, 3); // true

프로퍼티를 기반으로 합계를 계산하는 간단한 컴포넌트를 예로 들어 보겠습니다.

function Price({ items, state }) {
const subTotal = calculateSubTotal(items);
const tax = calculateTax(subTotal, state);
const total = subTotal + tax;
return <Text text={total} />;
}

Text 컴포넌트가 불필요하게 다시 렌더링 되는 것을 방지하는 가장 단순한 방법 중 하나는 아래 코드처럼 모든 것을 메모이제이션 하는 것입니다.

function Price({ items, state }) {
const subTotal = useMemo(() => calculateSubTotal(items), [items]);
const tax = useMemo(() => calculateTax(subTotal, state), [subTotal, state]);
const total = useMemo(() => subTotal + tax, [subTotal, tax]);
return <Text text={total} />;
}

그러나 얕은 비교를 위해 원시값을 굳이 기억할 필요는 없습니다. 여기서 메모이제이션 하는 것은 메모리와 번들 크기 측면에서 모두 낭비입니다.

이 값이 원시값이라는 것을 리액트 컴파일러에게 가르칠 수 있을까요? 리액트 컴파일러는 calculateSubTotalcalculateTax가 포함된 모든 파일을 컴파일하여 숫자를 반환한다는 것을 이해함으로써 전체 프로그램 분석을 수행할 수 있습니다. 하지만 단일 파일 분석의 성능 향상, 점진적 롤 아웃, 컴파일러 복잡성 감소 같은 좋은 장점이 사라지게 됩니다.

그럼, 컴파일러가 사용 방식에서 이 값이 숫자임을 추론할 수 있을까요?

타입 추론

힌들리 밀너 타입 시스템은 함수형 언어에서 일반적으로 사용되는 가장 고전적인 타입 시스템 중 하나입니다. 리액트 컴파일러의 타입 추론은 이 타입 시스템의 알고리즘 W에서 영감을 받았지만, 자바스크립트는 안정성 타이핑(sound typing)을 하기에는 너무 동적이기 때문에 훨씬 더 단순합니다. 리액트 컴파일러가 구현하는 여러 단계를 간략하게 설명하겠습니다.

타입 변수 초기화하기

자바스크립트 소스코드에서 컴파일러의 중간 표현(Intermediate Representation, IR)으로 초기 변환하는 과정에서, 모든 식별자는 타입을 저장하기 위한 연결된 Type 변수를 얻습니다. Type 변수는 다른 변수와 비슷하지만, 값을 저장하는 대신 타입을 저장합니다.

type Type = { kind: "type"; id: number } | { kind: "Primitive" };
// { kind: "Type", id: number } represents a type variable
// { kind: "Primitive" } represents a primitive type

let total; // identifier: { name: 'total', type: { kind: "Type", id: 0 } }

{ kind: "Type", id: 0 }total 식별자와 연결된 Type 변수입니다.

타입 방정식 생성하기

먼저 이해하기 어려운 형식 표기법으로 타입 시스템을 명시하기보다는, 앞서 설명한 예시를 활용해 타입 규칙 중 하나를 설명해 보겠습니다.

const total = subTotal + tax;

위 선언은 Arithmetic 연산자를 가진 BinaryExpression의 피연산자들이 원시값이며, 결괏값 또한 원시값이라고 타입을 지정할 수 있습니다. 예시로 다시 살펴보면 subTotaltax가 피연산자이고, 연산자는 +, 그리고 반환 값은 total입니다.

const total = subTotal + tax;
// subTotal -> primitive
// tax -> primitive
// total -> primitive

자바스크립트에서 BinaryExpression의 피연산자로 원시값이 아닌 값을 사용할 수 있지만, 이는 예제를 위한 안전한 가정입니다.

타입 추론 과정의 첫 번째 단계는 컴파일러가 정의한 타이핑 규칙에 기반해 타입 방정식을 생성하는 것입니다. 타입 방정식은 두 타입 간의 동등성을 나타내는 문장으로, 방정식과 유사합니다. 간단한 타입 방정식은 "left = right" 형태일 수 있으며, 여기서 leftright는 타입입니다.

코드에서는 방정식의 좌항과 우항을 나타내는 두 개의 필드를 가진 객체로 간단히 표현할 수 있습니다. 예를 들면 다음과 같습니다.

type TypeEquation = {
left: Type;
right: Type;
};

조금 더 구체적으로 살펴보면, 위에서 정의한 타입 규칙은 다음과 같이 생성할 수 있습니다.

function* generateTypeEquationsForBinaryExpression(instruction) {
const { operands, lvalue } = instruction;

yield { left: operands[0].type, right: { kind: "Primitive" } };
// subTotal -> primitive
yield { left: operands[1].type, right: { kind: "Primitive" } };
// tax -> primitive
yield { left: { lvalue.type }, right: { kind: "Primitive" } };
// total -> primitive
}

마찬가지로 자바스크립트의 다른 구문들에 대해서도 타입 방정식을 생성할 수 있습니다. 예를 들어 함수(즉, 함수 호출)나 if 문과 같은 구문에 대해서도 타입 방정식을 생성할 수 있습니다.

방정식 풀기

이러한 타입 방정식들을 풀어내는 과정을 통일화(unification)라고 합니다. 통일화 과정은 모든 타입 방정식을 참으로 만드는 타입 변수의 대체를 찾으려고 시도합니다.

예제에 대한 타입 방정식은 꽤 직관적이게 해결할 수 있습니다. subTotal, tax, total에 대한 타입 변수들을 직접 원시 타입으로 대체할 수 있습니다.

하지만 subTotal을 정의하고 초기화하는 앞선 문장을 고려해 봅시다.

const subTotal = calculateSubTotal(items);

subTotal을 정의하는 시점에서는 그 타입을 알 수 없습니다. subTotal 사용법을 살펴본 후에야 이것이 원시값이라는 것을 유추할 수 있습니다.

하지만 이 타입 추론에서는 타입이 정의까지 역행합니다. 정의로 다시 돌아가 보면 calculateSubTotal 함수의 반환 타입이 subTotal과 같은 타입이어야 한다는 것을 알게 됩니다. 그리고 이를 해결함으로써, 이제 calculateSubTotal의 반환 타입이 원시값이어야 한다는 것을 추론할 수 있습니다.

이것은 타입 추론이 얼마나 강력한지를 보여주는 예시입니다! 우리는 구현을 보지 않고도 별도의 컴파일 단위에 존재하는 함수의 반환 타입을 추론할 수 있었습니다. 타입 시스템은 종종 이러한 추론을 사용하여 타입이 지정되지 않은 코드베이스에 대한 추론을 빠르게 확장합니다.

하지만 이에는 상당한 단점이 따릅니다. 만약 추론이 잘못되면 예기치 않은 “원격 작용(action-at-a-distance)” 동작이 발생합니다. 이것이 Flow가 더 나은 에러를 위해 타입 명시를 늘리는 대신 로컬 타입 추론으로 전환한 이유입니다.

타입스크립트나 Flow의 타입 정보를 컴파일러에서 사용할 수도 있었지만, 우리는 타입이 지정되지 않은 자바스크립트에서도 잘 동작하는지 확인하고 싶었습니다. 그러나 향후 더 최적화된 메모이제이션을 위해 이러한 타입 시스템에 대한 지원을 추가할 계획입니다.

프로퍼티를 메모이제이션 하지 마세요

이제 Price 컴포넌트의 원래 예시로 돌아가 보죠. 이제 컴파일러는 모든 값이 원시값임을 추론할 수 있습니다. 컴파일러는 Price 컴포넌트에서 total, subTotal 그리고 tax를 메모이제이션 할 필요가 없으므로 번들 크기와 메모리를 절약할 수 있습니다!

function Price(t0) {
const $ = useMemoCache(2);
const { items, state } = t0;
const subTotal = calculateSubTotal(items);
const tax = calculateTax(subTotal, state);
const total = subTotal + tax;
let t1;

if ($[0] !== total) {
t1 = <Text text={total} />;
$[0] = total;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}

타입 시스템을 갖게 되니 곧바로 이를 다양한 다른 분석을 위한 플랫폼으로 사용할 수 있다는 것이 명확해졌습니다.

리액트의 특정 규칙에 대한 유효성 검사를 추가하는 것은 타입 시스템에 타이핑 규칙을 몇 개 추가하는 것만큼이나 쉬워졌습니다. 예를 들어, 각각의 리액트 API에 대해 별도의 유효성 검사를 구축하는 대신, useState 훅에 대한 타이핑 규칙만 추가하면 이러한 유효성 검사를 얻을 수 있습니다.

  1 | const [state, setState] = useState({ foo: { bar: {} } });
2 | const foo = state.foo;
> 3 | foo.bar = 1;
| ^^^ InvalidReact: Mutating a value returned from 'useState()',
which should not be mutated. Use the setter function to
update instead.

단순히 state를 수정하는 것뿐만 아니라 별칭 변수(foo)를 통해 state.foo를 수정하는 등 내부 변경 가능성도 포착된다는 점에 주목하세요.

더 읽을거리

타입 시스템에 대해 더 알고 싶으시다면 힌들리 밀너의 타입 시스템 논문과 최근 로컬 타입 추론 논문에서 시작하시면 좋을 것 같습니다.

리액트 컴파일러의 컴파일러 이론에 대해 더 자세히 알고 싶다면 다른 태그가 지정된 게시물을 살펴보세요.

--

--