(번역) 컴파일러 이론과 반응성

한정(Han Jung)
8 min readMar 8, 2024

--

원문: https://www.recompiled.dev/blog/ssa/

리액트 컴파일러는 컴파일러 이론에 대한 배경지식 없이 이해하기 어려운 다양한 전통적인 컴파일러 변환(transform)을 구현합니다. 이 글에서는 정적 단일 할당 형식(Static Single Assignment form, SSA) 컴파일러 패스(pass)에 대해 더 쉽게 이해할 수 있도록 예시를 통해 설명해 보려 합니다.

리액트 컴파일러가 무엇인지 궁금하다면 최근 업데이트 글에서 배경지식을 읽어보시기를 바랍니다.

여기 간단한 리액트 컴포넌트를 살펴보세요.

function Component({ colours }) {
let styles = { colours };
return <Item styles={styles} />;
}

이 코드를 간단하게 메모이제이션 할 수 있습니다.

function Component(props) {
const $ = useMemoCache(2);
const { colours } = props;
let t0;
if ($[0] !== colours) {
t0 = { colours };
$[0] = colours;
$[1] = t0;
} else {
t0 = $[1];
}
const styles = t0;
return <Item styles={styles} />;
}

컴파일러는 생성되어 프로퍼티로 전달되는 style 객체를 추적할 수 있습니다.

useMemoCache 훅에 대해 너무 고민하지 마세요. 컴파일러가 값을 캐시 하는 데 사용하는 내부 API입니다. $는 배열로 생각하면 됩니다.

리액트 컴파일러는 JSX도 메모이제이션 할 수 있지만 글의 간결함을 위해 이 글에서는 생략하겠습니다.

이제 조건에 따라 스타일을 적용하도록 리팩토링하고 싶다고 가정해 보겠습니다.

function Component({ colours, hover, hoverColours }) {
let styles;
if (!hover) {
styles = { colours };
} else {
styles = { colours: hoverColours };
}
return <Item styles={styles} />;
}

더 이상 단일 문장이 아니기 때문에 컴파일러가 styles 객체를 메모이제이션 하는 것은 약간 더 어려워집니다. 여러 문장으로 분산되어 있으며 제어 흐름이 포함됩니다. styles은 if 블록과 else 블록 모두에서 생성됩니다.

컴파일러는 다음과 같이 여전히 두 블록에서 styles 생성을 추적하고 메모이제이션 처리할 수 있습니다.

function Component(props) {
const $ = useMemoCache(4);
const { hover, colours, hoverColours } = props;
let styles;
if ($[0] !== hover || $[1] !== colours || $[2] !== hoverColours) {
if (!hover) {
styles = { colours };
} else {
styles = { colours: hoverColours };
}
$[0] = hover;
$[1] = colours;
$[2] = hoverColours;
$[3] = styles;
} else {
styles = $[3];
}
return <Item styles={styles} />;
}

이 방법은 작동하지만 hover, colours 또는 hoverColours 중 하나라도 변경되면 메모이제이션 된 값이 무효가 되기 때문에 이상적으로 보이지는 않습니다. 좀 더 세밀하게 제어할 방법은 없을까요?

변수가 아니라, 값을 추적하기

한 가지 핵심적인 직관은 if 블록과 else 블록의 값을 별개로 메모이제이션 하는 것입니다. 이들은 별개의 (별개의 객체)이며 단지 동일한 변수 식별자인 styles에 의해 참조됩니다.

이전 예시를 참고해서, 서로 다른 식별자를 지정하여 값을 별개로 추적하도록 약간 수정했습니다.

let styles;
if (!hover) {
t0 = { colours }; // <-- value 분리
} else {
t1 = { colours: hoverColours}; // <-- value 분리
}
styles = choose(t0 or t1);

이제 t0t1을 따로 메모이제이션 할 수 있다는 것은 분명해졌습니다. 또한 t0t1 중 하나를 선택하여 styles에 올바르게 할당해야 한다는 사실을 이미 알고 계시겠지만 지금은 넘어가 보겠습니다.

컴파일러는 각각의 블록에 있는 값을 메모이제이션 수 있습니다.

if (!hover) {
if ($[0] !== colours) {
t0 = {
colours,
};
$[0] = colours;
$[1] = t0;
} else {
t0 = $[1];
}
} else {
if ($[2] !== hoverColours) {
t1 = {
colours: hoverColours,
};
$[2] = hoverColours;
$[3] = t1;
} else {
t1 = $[3];
}
}
styles = choose(t0 or t1)

이전 예시보다 더 세밀하고 구체적으로 표현되었습니다.

복잡성은 어디에 있을까요?

하지만 잠깐만요. ‘생성된 스코프의 값을 메모이제이션 하는 것뿐인데 그게 뭐가 그렇게 어렵나요?’라고 생각할 수 있습니다.

다른 예를 생각해 보죠.

function Component({ colours, hover, hoverColours }) {
let styles;
if (!hover) {
styles = { colours };
} else {
styles = { colours: hoverColours };
}
styles.height = "large"; // <-- styles 객체 수정
return <Item styles={styles} />;
}

위의 예에서는 height라는 새 프로퍼티를 추가하여 if-else 블록 뒤에서 styles 객체를 수정합니다. 이제 if 블록과 else 블록 내부의 값을 따로 메모이제이션 해 두는 것은 더 이상 안전해 보이지 않습니다.

값을 메모이제이션 한 후에는 값을 수정할 수 없습니다. 성능 측면에서 최적이 아니기 때문이 아니라 리렌더링할 때 잘못된 동작이 발생하기 때문입니다. 이 동작이 실제로 어떻게 나타날 수 있는지 잠시 생각해 보세요.

단순히 생성된 스코프의 값을 메모이제이션 하는 것이 아니라 값이 흐르는 대로 추적할 방법이 필요합니다.

이런 코드를 작성해서는 안 된다고 주장할 수도 있습니다. 하지만 로컬 변이(mutation)는 자바스크립트에서 매우 자연스러운 현상입니다. 또한, 효율적으로 컴파일하기 위해 이런 식으로 작성된 리액트 코드도 많습니다.

흐름 추적하기

앞서 넘어갔던 “choose” 함수를 기억하시나요? 이 함수를 사용하면 컴파일러가 if-else 블록에 걸친 값을 추적할 수 있습니다!

if (!hover) {
t0 = { colours };
} else {
t1 = { colours: hoverColours};
}
styles = choose(t0 or t1); // <-- 제어 흐름 이후 값 추적
styles.height = 'large';

이제 이 코드(더 정확히 말하면 컴파일러의 중간 표현(Intermediate representation))는 컴파일러에 styles 객체는 t0 또는 t1이며 styles를 수정하는 것은 t0t1 값을 수정하는 것과 같다고 알려줍니다.

이제 컴파일러는 styles 객체가 이처럼 더 세밀하지 않은 방식으로만 메모이제이션 할 수 있다고 추론할 수 있습니다.

if ($[0] !== hover || $[1] !== colours || $[2] !== hoverColours) {
if (!hover) {
styles = {
colours,
};
} else {
styles = {
colours: hoverColours,
};
}

styles.height = "large";
$[0] = hover;
$[1] = colours;
$[2] = hoverColours;
$[3] = styles;
} else {
styles = $[3];
}

컴파일러 이론

요약하자면, 임시 식별자를 사용하여 값을 개별적으로 추적하고 “choose” 함수를 사용하여 제어 흐름에서 값을 추적하는 방법을 살펴봤습니다.

흥미롭게도, 전통적인 컴파일러 변환인 정적 단일 할당 형식(SSA)은 정확히 이 작업을 수행합니다! 새로운 값과 재할당을 추적하여 새로운 임시 값을 만드는 것은 SSA 변환의 핵심입니다. 앞서 이야기한 “choose” 함수는 단순히 SSA 형식에서 정의된 “phi” (Φ) 함수일 뿐입니다.

리액트 컴파일러가 사용하는 정확한 SSA 변환은 훌륭한 ‘정적 단일 할당 형식의 간단하고 효율적인 구성’ 논문에서 나온 것입니다.

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

--

--