리스크립트 컴파일러의 JSX v4를 개발한 여정

수정사항 (2022-11-17)
> JSX의 외연 확대의 예로 들었던 Remix 대신 Preact 로 변경


리스크립트 컴파일러에 내장된 JSX v4를 개발하게 된 계기와 여정을 소개합니다. 그리고 v3 대비 어떤 점들이 개선되었는지도 소개합니다.

JSX v3 개선의 움직임 (당시 나는 전혀 몰랐던)

코어팀은 기존의 JSX v3의 개선해야할 부분에 대해 정의하고 v4 개발 계획을 수립했던 것 같습니다. 당시 저는 몰랐지만요.

https://github.com/rescript-lang/syntax/pull/235

  • 컴파일러와의 인터페이스를 정리하자
  • 에러를 일으킬 때, 원본 코드의 위치를 더 잘 표시하자
  • 레거시 ReasonReact를 정리하자
  • 테스트를 더 추가하자
  • 돔 요소의 어트리뷰트를 더 추가하자 (예, aria-*)

현재 개발된 v4의 모습에 비하면 당시 계획은 v3 대비 크게 달라지는 모습을 그리지는 않았던 것 같습니다. 특히 오브젝트로 표현되는 리액트 컴포넌트 props를 레코드로 바꿔야겠다는 생각은 없었던 것 같습니다.

JSX v4 개발에 걸려듬(?)

컴파일러 내부를 들여다보고 코드를 읽다보니, 이렇게 해서는 제대로 이해할 수 없겠다는 생각이 들었습니다. 직접 코드를 수정하고 만들어봐야 익힐 수 있겠다는 마음을 먹고 무엇을 만들까 고민했습니다. 그래서 머리속에 떠올랐던 기능이 Spread props였습니다. 자바스크립트나 타입스크립트에서는 자주 쓰는 표현인데, 리스크립트에서는 지원되지 않았습니다.

{...p} Spread props 표현을 파싱하고, p라는 이름의 레코드를 모듈에서 찾아 각 필드의 값을 prop으로 전달하는 기능을 파서와 JSX PPX에 구현한 PR을 하였습니다.

https://github.com/rescript-lang/syntax/pull/517

PR에 대해 메인테이너는 몇 가지 한계점을 지적하였습니다.

  • 같은 이름의 레코드를 찾아서 필드들을 prop으로 전달하는 방식: alias 된 이름은 찾을 수가 없다.

    1// A.res
    2type p = {a: int, b: int}
    3let p = {a: 1, b: 2}
    4
    5// B.res
    6let q = A.p
    7
    8<Comp {...q} />
    9// q는 찾을 수 있지만, 바인딩된 A.p는 문자 정보일 뿐 레코드라는 타입 정보는 없음.
  • 다른 모듈의 레코드는 참조할 수가 없다.

PPX는 파서가 파싱 결과로 만든 Parsetree를 조작하는 프로그램이기 때문에, 컴파일러가 타입 정보를 추가해준 Typedtree는 PPX가 모르기 때문입니다. PPX가 가진 정보라고는 q는 타입 선언이고, A.p라는 문자와 바인딩되어 있구나 정도밖에 없습니다. 만약 A.p라는 레코드 타입이 다른 모듈에 선언되어있다면, 더더욱 알 길이 없죠. 그 한계를 알고는 있었습니다. 하지만 어떻게 해결할 수 있을 지는 몰랐습니다.

내가 구현하고자 하는 기능이 컴파일러 안에서 작동하게 만들었다는 점에 '그래, 난 성장했어!'라고 만족했거든요.

그런데 메인테이너가 예고없이 v3 개선 논의들과 v4의 청사진을 만들어서 공유해주기 시작합니다. (당황, 나한테 왜 그러는거지..) 그리고 이 한계를 개선할 수 있는 방법은 언어 자체를 활용해야한다는 걸 알려주었습니다. (그게 무슨 말이야..)

JSX v3의 문제점과 개선 방향

v3를 개선해보자 했던 초기 계획보다 훨씬 큰 계획을 메인테이너가 공유해주었습니다. 저는 '음...이게 다 무슨 말이야?'라고 생각을 했습니다.

https://github.com/rescript-lang/syntax/issues/521

  • @react.component 어트리뷰트 없이 함수만으로 리액트 컴포넌트를 표현할 수 있도록 하자.
  • 내부 표현에서 makeProps 같은 요술을 제거하자.
  • makeProps에서 사용하는 @obj 대신 레코드의 표현력을 개선해보자.
  • React v17에 소개된 새로운 JSX transform API를 도입하자.
  • v3와의 하위호환: 가능한 점진적 마이그레이션이 가능한 방법을 찾자.

그리고 제가 이해할 수 있는 팁도 줍니다. "레코드를 업데이트하는 문법이 있으니 레코드를 이용하면 Spread props을 구현할 수 있을거야."

1let q = {...p, a: 1} // 오!!

https://github.com/rescript-lang/syntax/pull/517
https://github.com/rescript-lang/syntax/pull/547

첫번째 PR에서는 총 246개의 댓글을 통해 구현에 대해 논의하였고, PR의 제목도 JSX v4 WIP로 변경하였습니다. 본격적인 개발을 위해 새로운 PR을 만들고 메인테이너와 논의하면서 개발을 이어갔습니다.

본격적인 v4 개발이 시작되었습니다.

JSX v4 개발

makeProps는 왜 필요한 것일까?

@obj external makeProps: ...은 리액트 컴포넌트 props에 해당하는 오브젝트를 생성하고 각 prop의 타입을 체크해주는 역할을 합니다. 오브젝트 대신 레코드를 사용하려면 makeProps없이 props를 표현할 수 있어야 합니다.

컴파일러는 리액트 컴포넌트를 정의(Definition)한 원본 코드를 이렇게 변환합니다.

1// 원본 코드
2module C = {
3 @react.component
4 let make = (~x, ~y) => React.string(x ++ y)
5}
6
7// v3로 변환한 결과
8module C = {
9 @obj
10 external makeProps: (
11 ~x: 'x,
12 ~y: 'y,
13 ~key: string=?, // key 타입 체크를 위한 인자 추가
14 unit,
15 ) => {"x": 'x, "y": 'y} = "" // 생성된 오브젝트
16
17 @react.component let make = (~x, ~y) => React.string(x ++ y)
18 let make = {
19 let \"C" = (\"Props": {"x": 'x, "y": 'y}) =>
20 make(~y=\"Props"["y"], ~x=\"Props"["x"])
21 \"C"
22 }
23}

리액트 컴포넌트를 사용(application)하는 곳에서 makeProps 함수를 호출해서 xy의 타입을 체크하고, {"x": "x", "y": "y"} 오브젝트를 생성합니다.

1// 원본 코드
2<C x="x" y="y" />
3
4// v3가 변환한 결과
5React.createElement(C.make, C.makeProps(~x="x", ~y="y", ()))
6 // 타입 체크 & 오브젝트 생성 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

애초에 props 타입을 레코드 대신 makeProps가 생성하는 오브젝트로 대신하는 이유가 있습니다. 왜냐하면, 리액트 컴포넌트의 prop들은 일부만 사용할 수 있기 때문에, 레코드로 표현하면 필드의 타입이 option 타입을 사용해야하기 때문입니다. option 타입을 사용하면 <C z=Some("z")>라고 표현할 수 밖에 없으니까요. 그래서 v3는 레코드 대신 makeProps 함수가 생성하는 오브젝트를 사용하여 구현할 수 밖에 없었을 것입니다.


오브젝트 대신 레코드

makeProps 함수를 대신하려면 레코드는 표현력이 더 좋아져야만 했습니다.

컴포넌트의 props을 레코드로 표현했을 때, 각 필드는 하나의 prop 입니다. xy는 필수 prop이고, z는 선택 prop입니다.

1type props = {
2 x: string,
3 y: string,
4 z: option<string>,
5}
  1. props를 사용하지 않은 경우: let props = {}
  2. props 중 일부만 사용하는 경우: let props = {x: "x", y: "y"}
  3. props 전부를 사용하는 경우: let props = {x: "x", y: "y", z: Some("z")}
  4. 선택 필드 z를 Some 없이 사용하는 경우: let props = {x: "x", y: "y", z: "z"}

이 중 컴파일러 v9에서 레코드로 가능한 표현은 3번 뿐 입니다. 하지만 4번의 경우 리액트 컴포넌트를 표현할 때 꼭 필요합니다.

1// 선택 prop에 Some?? 이건 좀 별론데..
2<Profile name="정식" nickname=Some("리스크립트 고수")>

표현력의 한계를 해결하기 위한 방법이 필요했고, 이미 레코드의 구조를 타입 체크할 수 있는 RFC가 있었습니다. 이 RFC는 레코드의 필드 중 일부만 작성해도 안전하게 타입 체크를 해주는 기능입니다. 2번의 경우죠.

JSX v4를 위해 이 RFC를 개선해나갑니다. 1번과 4번을 해결해야 했습니다. 특히 4번 표현은 리액트 컴포넌트를 표현할 때 꼭 필요했기 때문에 RFC를 바탕으로 메인테이너와 기능 설계를 해서 이런 문법을 추가 합니다.

https://github.com/rescript-lang/rescript-compiler/pull/5423

개선의 결과로 레코드는 1 ~ 4번 모두에 해당하는 표현력을 갖게 됩니다.

1type t = {
2 name: string,
3 nickname?: string, // ?를 붙이면 선택 필드
4}
5
6let woonki = {name: "운기"}
7let jeongsik = {
8 name: "정식",
9 nickname: "리스크립트 고수" // Some("...") 하지 않아도 됩니다.
10}

@optional 대신 ? 표현이 추가되고, 옵션 생성자를 사용하지 않고 표현할 수 있도록 개선되어 갑니다. 그래서 리액트 컴포넌트를 이렇게 표현할 수 있게 됐습니다.

1<C name="운기" />
2<C name="정식" nickname="리스크립트 고수" />

명목적 타입(Nominal type)을 사용하고 안전한(Sound) 타입 시스템에서 레코드가 이런 표현력을 갖게 되는 것은 놀라운 일입니다.


React의 새로운 jsx runtime API 도입

리액트 17버젼에서 소개된 API 입니다. 앞서 보여드렸던 내부 표현을 보면서 짐작하셨겠지만, 리스크립트는 JSX를 변환하기 위해 Babel이 필요하지 않습니다. 컴파일러가 변환하기 때문이죠. 그래서 새로운 jsx runtime API를 사용하려면 JSX v4에 구현을 해야했습니다.

rescript-react 바인딩 모듈에 PR을 하고, 추가한 API를 v4가 호출했습니다.

https://github.com/rescript-lang/rescript-react/pull/49
https://github.com/rescript-lang/syntax/pull/614

1// 원본 코드
2<C x="x" y="y" />
3<C key="c" x="x" y="y" />
4
5// 내부 표현
6React.jsx(C.make, {x: "x", y: "y"})
7React.jsxKeyed(C.make, {x: "x", y: "y"}, "c") // key가 있는 경우

JSX의 외연 확대

v3는 React ppx의 역할이었습니다. rescript-react에 바인딩된 리액트 API를 호출해서 내부 표현을 만듭니다. v4 작업 중 코어팀과 논의하는 과정에서 JSX는 리액트만의 표현이 아니기 때문에, Solid.js, RemixPreact 와 같은 라이브러리나 프레임워크에도 대응할 수 있게 하자는 의견이 나왔습니다.

그래서 컴파일러 내부에 Jsx, JsxDOM 등의 모듈을 추가하고, Jsx.element, Jsx.component 등의 타입을 rescript-react에 정의된 타입에 바인딩 하였습니다. 앞으로 rescript-solid, rescript-remixrescript-preact 바인딩이 나온다면, Jsx 모듈을 바인딩할 수 있습니다. 그럼, JSX 표현을 공유할 수 있게 됩니다. 예를 들어, 리액트 프로젝트에서 만든 모듈을 Solid.js 프로젝트에서 사용할 수 있습니다.

1// React.res
2type element = Jsx.element // Jsx.element는 컴파일러 내부에 정의된 타입입니다.

https://github.com/rescript-lang/rescript-compiler/pull/5484
https://github.com/rescript-lang/rescript-react/pull/49


점진적 마이그레이션: v3와의 하위 호환

JSX v4의 구현보다 더 어려운 작업이었습니다. 왜냐하면 작업해야하는 레포지토리가 3개 입니다. 컴파일러, syntax, rescript-react에 걸쳐 의존성이 있는 작업이었습니다.

우선, bsconfig.json에 v3와 v4를 설정할 수 있는 설정값을 추가하고, 컴파일러에서 그 설정값을 읽고 JSX ppx를 작동시키는 로직을 추가했습니다. 설정에 따라 v3 혹은 v4를 활성화 시킵니다. 기존의 "reason"."react-jsx"와 같은 속성은 제거하고, 새로운 "jsx" 속성을 추가했습니다.

또, v3와 v4는 서로 다른 내부 표현을 생성하기 때문에, rescript-react의 리액트 바인딩도 ReactV3 모듈을 추가해서 v3를 계속 사용하는 프로젝트에서 사용이 가능하도록 하였습니다. 최신 버젼의 컴파일러와 rescript-react를 설치한 프로젝트에서 v3와 v4를 모두 사용할 수 있습니다.

https://github.com/rescript-lang/rescript-compiler/pull/5484


bsconfig.json 설정

  • V3
1{
2 "jsx": {
3 "version": 3,
4 "v3-dependencies": [
5 "rescript-relay" // v3를 사용할 의존 모듈을 추가해줍니다.
6 ]
7 },
8 "bsc-flags": ["-open ReactV3"]
9}
  • V4
1{
2 "jsx": {
3 "version": 4,
4 "mode": "classic", // "automatic" 는 jsx runtime API 사용
5 "module": "react" // 생략 가능. 향후 "solidjs", "preact" 등 가능할지도..
6 }
7}

이 무렵 컴파일러, syntax에 걸쳐있는 작업들을 효율적으로 하기 위해 리스크립트 GitHub 조직에 초대를 받았습니다. ✌️

JSX v4의 개선점

새로운 JSX v4는 v3와 비교했을 때 어떤 점이 개선되고 어떤 장점이 있는 지 살펴보겠습니다.

스펙 문서
https://github.com/rescript-lang/syntax/blob/master/cli/JSXV4.md


@react.component 없이 표현할 수 있습니다


1module C = {
2 type props = {x: string, y: string}
3 let make = {x, y} => React.string(x ++ y)
4}

타입스크립트 코드와 비슷하게 표현하지만, 강력한 타입 시스템이 타입을 추론해주고 체크해줍니다. v3 대비 표현력이 좋아지고, 코드가 간결해졌습니다.

특히 Context API를 사용할 때, @react.component를 사용하지 않고 표현하면 훨씬 간결해집니다.

1// v3
2module Context = {
3 let context = React.createContext(() => ())
4
5 module Provider = {
6 let provider = React.Context.provider(context)
7
8 @react.component
9 let make = (~value, ~children) => {
10 React.createElement(provider,
11 {"value": value, "children": children} // Error
12 )
13 }
14 }
15}
16
17// v4
18module Context = {
19 let context = React.createContext(() => ())
20
21 module Provider = {
22 let make = React.Context.provider(context)
23 }
24}

다만, @react.component가 하는 일 중 하나가 함수의 이름을 대문자로 만들어주는 것인데요. 리스크립트에서 대문자 이름은 모듈이나 배리언트 생성자에게 허용되어 있고, let 바인딩 값에는 허용되지 않기 때문입니다. 리액트 컴포넌트 이름은 대문자로 시작하지 않으면 React Fast Refresh가 작동하지 않는다는 이슈가 보고되어서, 이 부분은 메인테이너와 함께 더 살펴볼 예정 입니다. 그래서 React Fast Refresh 이슈가 있는 경우에는 @react.component를 사용하시는 편이 좋을 것 같습니다.


Spread props을 사용할 수 있습니다.

특히, DOM 요소의 어트리뷰트에 해당하는 props를 전달할 때 유용하게 사용할 수 있을 것 같습니다.


1// 사용자 정의 컴포넌트
2<Comp {...props} x="x" />
3
4// DOM 요소 컴포넌트
5let props: Jsx.domProps = {id: "id", name: "그린랩스"}
6<div {...props} />

새로운 jsx runtime API를 활용할 수 있습니다.

리액트를 import 하지 않아도 리액트 컴포넌트를 생성할 수 있는 API를 활용해서 번들 사이즈를 조금 더 줄일 수 있습니다. 리액트 팀 감사합니다. 😉


컴포넌트들 간 props 타입을 공유할 수 있습니다.


1type sharedProps = {
2 x: string,
3 y: string,
4}
5
6module A = {
7 @react.component(:sharedProps)
8 let make = (~x, ~y) => React.string(x ++ y)
9}
10
11module B = {
12 @react.component(:sharedProps)
13 let make = (~x, ~y) => React.string(x ++ y)
14}

리스크립트는 명목적 타입(Nominal type)을 사용하고 있습니다. A 모듈의 props와 B 모듈의 props는 다른 타입이죠. 예를 들어, React Native Navigation의 Screen 컴포넌트에 component prop에 A, B 컴포넌트를 전달하는 경우 타입 에러가 발생합니다.

1type screenProps = { navigation: navigation, route: route }
2
3module Screen: {
4 @react.component
5 let make: (
6 ~name: string,
7 ~component: React.component<screenProps>,
8 ...
9 ) => React.element
10}
11
12// 타입 에러
13// component: React.component<screenProps>
14// A.make: React.component<A.props>
15<Screen name="A" component={A.make} />
16<Screen name="B" component={B.make} />

이런 경우 shared props 기능을 사용할 수 있습니다.

1module A = {
2 @react.component(:screenProps)
3 let make = (
4 ~navigation: navigation,
5 ~route: route
6 ) => ...
7}
8
9module B = {
10 @react.component(:screenProps)
11 let make = (
12 ~navigation: navigation,
13 ~route: route
14 ) => ...
15}

기여를 하며 배운 점

돌이켜보면 어쩌다 여기까지 오게 됐는지 잘 알 수 없지만, 필요한 건 직접 PR을 만들어서 제안해보면 좋겠다는 시도에서 시작된 것 같습니다. 그 시작은 보잘 것 없어서 머지가 되지 못할 Spread props 문법의 구현이었지만, 운좋게도 코어팀의 필요와 맞물려서 컨트리뷰터가 될 수 있었습니다.

컴파일러 8개, syntax 22개, rescript-react 9개, 총 39개의 PR을 만들면서 배운점과 느낀점을 정리해보았습니다.

1차 마무리 후 메인테이너의 감사 코멘트. 덕분에 제가 많이 배웠습니다, 근데 이제 시작이었다..


RFC -> 기술 스펙 -> 구현 -> 테스트, TDD

오픈 소스에 기여한 적이 처음은 아니지만, 긴 기간(약 4개월) 동안 꾸준히 개발하고 같이 협업을 한 적은 처음이었습니다. RFC를 만들어서 공개하고, 커뮤니티에 의견을 묻고, 코어팀 뿐만 아니라 커뮤니티 개발자들과 함께 기능과 구현을 논의하는 과정을 경험해볼 수 있었습니다. 기술 스펙 문서를 작성하고, 구현하고, 테스트 후 알파, RC 버젼을 배포해서 다시 테스트하고 테스트를 커뮤니티에 요청하는 하나의 개발 싸이클 속에 있어 본 좋은 기회였습니다.

그리고 TDD라고 거창하게 말할 수준은 아니지만, 구현하거나 수정해야할 테스트 샘플을 추가한 뒤 원본 코드가 파싱된 AST(Parsetree), Typedtree, Lambda 표현, 그리고 자바스크립트 결과물을 prettier printer로 출력하면서 원하는 구현을 해나가는 방식은 인상적이었습니다.


영어가 한계가 될 수 있지만, 코드가 좋은 소통 수단

구현의 방향을 논의하고 리뷰를 주고 받는 과정은 모두 영어로 해야했습니다. 영어 글쓰기가 편할리만은 없었고, 내 생각과 맥락을 충분히 표현하기는 쉽지 않았습니다. 오히려 구현하는데 드는 시간보다 설명하는데 시간이 더 필요한 경우도 종종 있었습니다. 하지만 코드가 훌륭한 소통 수단이어서, 오픈 소스에 기여하기 위해서 물론 영어 공부를 하는 것도 중요하지만, 코드를 더 잘 짜고 기여를 많이 하는 편이 더 나을 수 있겠다는 생각을 했습니다.


리스크립트 컴파일러 내부를 더 잘 이해하는 계기

리스크립트 컴파일러 내부와 소스 코드를 이해하고 싶어서 기웃 기웃 레포를 뒤지고 코드를 읽어보긴 했지만, 직접 개발에 참여하는 것만큼 이해하는데 좋은 계기는 없는 것 같습니다. 컴파일러의 설정 값을 파싱하여 사용하는 모듈, syntax 모듈의 인터페이스와 호출되는 방식, 내장 PPX들의 구현, 테스트 방법, 타입 체커, 람다 표현, 자바스크립트 코드 생성이 어떤 모듈에서 어떻게 작동하는지 더 많이 알게 된 좋은 기회였습니다.


리스크립트, 오캐믈에 대한 믿음

컴파일러 코드는 대부분 오캐믈로 작성되어있습니다. 코드를 더 잘 읽고, 구현을 하는데 있어서 오캐믈의 타입 시스템이 큰 도움이 되었습니다. 그리고 배리언트로 표현된 AST인 Parsetree, Typedtree, Lambda, 그리고 모듈 시스템은 이해하기 쉽고 안전한 코드를 작성하는데 든든한 길잡이었습니다.

그만큼 오캐믈과 동일한 타입시스템을 갖고 있는 리스크립트에 대한 믿음과 기대도 더 커졌습니다.

마치며

JSX v4 개발에 기여하면서 느낀 점 중 하나가 리스크립트는 자바스크립트, 타입스크립트 개발자들에게 더 친숙한 문법과 표현력을 가지는 방향으로 개발되어가고 있다는 점 입니다. 현재 진행 중인 Uncurried as default도 같은 목표 아래에서 개발되고 있는 기능이구요. 더 많은 자바스크립트, 타입스크립트 개발자들에게 더 친숙하게 접근할 수 있는 언어가 되어서, 훌륭하고 안전한 타입 시스템의 혜택을 더 많은 분들이 즐길 수 있게 되면 좋겠습니다.


그린랩스 Dev Dive 2022, 발표 영상





Gravatar for woonki.moon@gmail.com
문운기프론트엔드 개발자
2022. 10. 30.

추천 콘텐츠