만들면서 배우는 Reagent Part 1

clojurewebclojurescript
Back

ClojureScript의 React wrapper 라이브러리인 Reagent의 핵심 원리만 뽑아 간략하게 재구현하면서 그 구조를 설명하는 글이다. A DIY guide to build your own React를 보고 영감을 받아서 제작하였다. 본문 내용은 좀 더 가다듬으며 업데이트할 예정이다. 마지막 수정일: 2022-07-06

Clojurescript and React

나만의 Reagent 만들기

Reagent는 ClojureScript(CLJS) 진영에서 널리 쓰이는 React wrapper 라이브러리다. 단순히 React 인터페이스를 재공하는 것을 넘어서 코드를 "클로져"스럽게 짤 수 있도록 많은 기능을 제공해준다. 특히 상태관리를 위해 ratom이라는 독자적인 도구를 제공하는데, 이를 이용하면 useState hook과 비슷한 느낌으로 functional하게 컴포넌트의 상태 관리를 할 수 있다. (그런데 Reagent가 React hook 보다 훨씬 먼저 나왔다. React hook은 2019년에(v16.8.0) 추가되었는데, Reagent는 2014년에 v0.1.0이 출시되었다.) 필자는 처음 Reagent를 사용해보고 도대체 어떻게 이런 일이 가능한건가 놀랐었고, 내부 코드를 살펴본 후 생각보다 훨씬 코드가 짧고 간단해서 한번 더 놀랐다.

이 글에서 마법같은 Reagent의 비밀이 무엇인지 처음부터 차근차근 만들어가면서 살펴본다. 설명에서 사용하는 단어와 코드 구조는 Reagent의 소스코드에서 따왔다. 설명과 이해의 편의를 위해 핵심 기능만을 구현하며 코드도 최대한 간결하게 만들었다. 전체 소스코드는 여기에서 볼 수 있다.

목차:

  1. Step 0. React를 직접 사용하기
  2. Step 1. JSX 대용으로 Hiccup 적용
  3. Step 2. Array 자식 요소 지원
  4. Step 3. 사용자 정의 컴포넌트 지원
  5. Step 4. Ratom을 이용한 상태 변경 지원
  6. Step 5. Local ratom을 위한 form-2 지원

Step 0. React를 직접 사용하기

기본적인 React 사용법은 React 컴포넌트를 화면 상의 특정 DOM element에 마운트(react-dom/render) 해주는 것이다. React 컴포넌트는 내가 원하는 최종적인 UI 모양을 표현하는 Fiber(가상 DOM)1를 반환하는 사용자 함수이다. (또는 Fiber 자체) React는 주어진 Fiber를 참고하여 사용자 대신에 실제 DOM를 조작하여 사용자의 의도를 표현해주는 역할을 한다.

보통 React를 사용할 때는 JSX 문법 확장을 이용해 Fiber 생성 부분을 선언적으로 간결하게 표현한다. 우리는 해당 기능은 사용할 수 없기 때문에 Fiber를 직접 생성해야 한다. Fiber는 react/createElement 를 호출하여 생성할 수 있다. 사실 이게 JSX 문법 확장이 해주는 일이다.

이로서 가장 기본 인티그레이션은 끝났다! 🎉

Step 1. JSX 대용으로 Hiccup 적용

하지만 JSX 같은 문법 확장이 없으면 생산성이 너무 낮아 쓸만한 것이 못 된다. Boilerplate 코드가 너무 많아 읽기도 힘들고 쓰는건 고역이다. 개인적으로 JSX 문법 확장은 React 성공의 일등 공신이 아닐까 한다.

다행히 문법 확장은 Clojure의 특기 분야이다. 매크로와 EDN을 활용하면 얼마든지 언어 문법을 확장해 나갈 수 있다.

CLJS 생태계에서 JSX와 비슷한 역할을 하는 프로젝트로 이미 Hiccup이 있는데, 이 아이디어를 가져와 JSX 역할을 대신하도록 하자.

Fiber를 생성하는 부분에서 react/createElement를 직접 호출하는 대신에 Hiccup 스타일의 백터로 내용을 표현하도록 하자. 구분을 위해 이렇게 생성된 컴포넌트는 Reagent 컴포넌트라고 부르도록 하자. 이로서 JSX급의 생산성을 다시 되찾았다!

  • React 컴포넌트: Fiber를 반환하는 사용자 함수. (또는 Fiber)
  • Reagent 컴포넌트: Hiccup 백터를 반환하는 사용자 함수. (또는 Hiccup 백터)

이제 남은 일은 react-dom/render가 Reagent 컴포넌트를 인식할 수 있도록 하는 것이다.

물론 해당 함수를 수정할 수는 없기 때문에 render라는 wrapper 함수를 만들어 호환을 위한 코드를 추가한다.

전략은 간단하다. 인자로 받은 Reagent 컴포넌트를 React 컴포넌트로 변환하여 react-dom/render로 넘기자. Reagent에서는 이 변환 작업을 compile이라 부른다.

Reagent 컴포넌트는 Hiccup 백터를 반환하는 함수이니, 실행하여 Hiccup 백터를 반환 받은 다음, 이를 다시 Fiber로 변환하면 된다. Hiccup 백터를 Fiber로 변환하는 작업은 as-element에서 마법같이 이뤄진다고 하자.

이제 공은 as-element에게로 넘어왔다.

as-element 함수에 들어올 수 인자는 숫자, 문자열, 벡터가 있다. 우리가 관심 있는 부분은 Hiccup 벡터이기 때문에, 백터만 처리하도록 하고 나머지는 그대로 React에게 넘긴다.

백터는 Hiccup 백터가 맞는지를 먼저 검사한다. Hiccup 문법에서 백터의 첫번째 요소는 키워드여야 한다.2 키워드일 경우만 hiccup-element로 처리하고, 그 외의 경우는 일단 에러 처리해두자.

이제 마지막이다. Hiccup 백터에서 Fiber를 생성하는 작업은 단순 변환 작업이다.

태그 이름 변환: 태그는 React가 알아볼 수 있도록 문자열로 변경해준다. (:div -> "div")

속성 맵 변환: Hiccup 백터는 두번째 요소에 속성(property) 맵이 있을 수 있다. React가 알아볼 수 있도록 이를 JS object로 변환해준다.

Reagent는 이 단계에서 키 이름 관련하여 편의 기능을 제공한다. (kebab-case를 camelCase로 바꾸거나, class키를 className로 바꿔준다.) 간결성을 위해 과감히 모두 생략하자.

자식 노드 변환: Hiccup 백터는 재귀구조라 자식들 중에 다시 Hiccup 백터가 있을 수 있다. as-element 함수를 재귀호출해서 자식들을 모두 React 컴포넌트로 변환한다.

끝으로 준비된 인자들로 react/createElement를 호출해주면 된다.

실제 Reagent 코드에서는 react/createElement를 호출하는 부분이 make-element로 분리되어 있다.

이제 div, span, p 같은 기본 태그들을 그릴 수 있게 되었다.

Step 2. Array 자식 요소 지원

다음으로 아래와 같이 컬렉션을 인자로 받아서 내용을 동적으로 만들어내는 컴포넌트를 지원해보자. React는 자식에 React 컴포넌트 Array를 넣을 수 있는데, 이 기능을 그대로 활용하자.


const renderChild = (list) => (
<ul>
{list.map((elem, idx) => (<li key={idx}>{elem}</li>))}
</ul>
)

이를 위해 as-element에 sequence를 처리하는 코드를 추가한다. sequence를 Reagent 컴포넌트 sequence로 가정하고, 이를 React 컴포넌트 array로 변환한다.

  1. CLJS에서 sequence는 list, vector, set, lazyseq 등 여러가지가 있기 때문에 seq?를 이용해 체크해준다.
  2. Reagent 컴포넌트를 React 컴포넌트로 바꾸는 일은 as-element가 해주기 때문에 sequence의 각 요소들에 대해 단순 재귀 호출을 해주면 된다.
  3. 마지막에 into-array를 이용해서 JS array로 변환해준다.3

이것으로 Array child 지원이 완료되었다. for 함수를 사용하여 아래와 같이 사용할 수 있다.


(defn app []
[:ul
(for [i (range 10)]
[:li
{:key i}
(str "Hi from " i)])])

Step 3. 사용자 정의 컴포넌트 지원

다음으로 React의 꽃, 사용자 정의 컴포넌트 지원 기능을 넣자. 사용자 정의 컴포넌트를 정의하는 방법은 React의 함수형 컴포넌트와 마찬가지로, Hiccup 백터를 반환하는 함수를 정의하는 것으로 한다. 사용하는 방법은 JSX와 마찬가지로 Hiccup 백터의 첫번째 자리에 함수를 넣을 수 있도록 한다. 최종적으로 아래와 같은 코드를 사용할 수 있게된다.


(defn- child-comp [{:keys [msg]}]
[:div
"Hi, I am 'child2-comp"
[:div
"I've got '" msg "'"]])
(defn app []
[:div
{:style {:color "red"}}
"Hello, I am 'app'"
[child-comp
{:msg "message from app"}]])

반환값 변환

여기서 가장 중요한 작업은 사용자 정의 함수가 React와 잘 동작할 수 있도록 호환 코드를 추가하는 작업이다. CLJS 함수는 그 자체로 JS 함수이기 때문에, React가 사용자가 만든 함수를 호출하는 대에는 문제가 없다. 하지만 이 함수는 Fiber(React element)가 아닌 Hiccup 백터(Reagent element)를 반환하기 때문에 React와 호환되지 않는다.

해결 방법으로 이전 스탭에서 했던 것과 마찬가지로, 반환값을 React element로 변환해줄 Wrapper를 만들어서 사용자가 작성한 함수를 감싸줘야 한다. 이 Wrapper 구현은 단순 함수(=함수형 컴포넌트)여도 되고 React 클래스 컴포넌트여도 되는데, Reagent는 기본값으로 React 클래스 컴포넌트를 사용하고 있다.4 여기서는 React 클래스 컴포넌트 방식을 따라가보자.

먼저 vec-to-elem 함수를 확장하여 Hiccup 백터의 첫번째 자리에 함수가 올 수 있도록 수정한다. 그리고 첫번째 자리에 함수가 올 경우 이 함수를 React 클래스 컴포넌트로 변환한다. 이 변환 작업은 create-class라는 함수가 처리해준다고 가정한다.

인자 형식

다음으로는 동적으로 생성된 클래스 컴포넌트를 이용해 Fiber(React Element)를 생성하면 되는데, 이 때 props와 children을 어떻게 넘길지 결정해야 한다.

hiccup-element에서 처럼 주어진 prop을 변환해서 넣는 방법도 있겠지만, 이 인자는 어짜피 CLJS 함수에서 사용할 것이므로 JS Object로 변환하는 것은 불필요하다. 또한 children도 사용자 정의 함수에게 마음대로 수정할 수 있는 자유도를 주기 위해 별다른 처리는 하지 않기로 한다.

결론적으로 사용자 정의 함수에 인자들을 그대로 전달하기 위해, 여기서는 argv라는 이름으로 hiccup 백터 원본을 그대로 넘기자. 그러면 우리가 생성할 Wrapper 클래스 컴포넌트에서는 this.props.argv를 통해 원본 hiccup 벡터에 접근할 수 있다.

create-class 구현

마지막 단계로 주어진 렌더 함수를 React 클래스 컴포넌트로 변경해줄 create-class 구현만 남았다. 해야할 일은 React에서 High-Order-Component를 만드는 것과 동일하다. React.Component를 상속하는 클래스를 동적으로 생성하되, 몇가지 메소드를 재정의해서 반환하면 된다.

  • render: props.argv로 넘겨 받은 hiccup 백터와 renderFn를 이용해 React Element를 생성해서 반환하면 된다.
  • shouldComponentUpdate: 성능을 위해 튜닝해준다. React는 기본적으로 컴포넌트의 propsstate가 바뀌면 해당 컴포넌트를 새로 그린다. 그런데 우리가 넘기는 props.argv는 CLJS 백터로, 의미상 값이 바뀌지 않아도 JS 세상에서 볼 때는 바뀐 것으로 볼 수 있다. CLJS의 equality 체크 함수를 이용해 변경 여부 판단 로직을 튜닝해준다. 문서
  • displayName: React 개발자 도구에서 컴포넌트를 구분할 수 있도록 이름을 지어준다.

이를 ES6 스타일의 Javascript로 표현하면 오른쪽 코드와 같다.

아쉽게 cljs.core API에 JS 클래스 상속을 위한 기능은 아직 없다. create-react-class를 이용하는 방법도 있겠지만, 의존성을 줄이기 위해서 ES5 스타일로 직접 구현한다.5

render 메소드 함수

this.props.argv로 넘겨받은 원본 hiccup 백터에서 첫번째 요소는 render-fn 자기 자신이므로 버리고, 두번째 요소부터 함수 호출 인자로 생각해서 render-fn 함수를 불러준다. 반환되는 값은 Reagent 컴포넌트일테니, as-element를 호출해서 React 컴포넌트로 변환하여 반환하자.

shouldComponentUpdate 메소드 함수

state는 사용하지 않으므로 무시하고, props가 바뀌었는지를 체크한다.

TODO: 이전이나 이후 argv가 nil일 경우 무조건 업데이트한다. 왜 그런가? argv는 원본 hiccup 백터인데 nil인 경우가 있나?

displayName 멤버 변수

마지막으로 render-fn의 함수 이름을 그대로 사용하자. render-fn이 익명 함수일 경우 이름이 없을 수 있기 때문에 when-let으로 체크를 해준다. 함수 이름은 CLJS 컴파일러에 의해 변경되어 있지만6 여기서는 그대로 쓰자.

이제 사용자 정의 컴포넌트도 문제 없이 잘 그려준다!

Step 4. Ratom을 이용한 상태 변경 지원

사용자 정의 컴포넌트도 지원할 수 있게 되었지만, 좀 더 쓸모있는 프로그램을 만들기 위해서는 컴포넌트가 상태(state)를 다룰 수 있어야 한다. 첫번째 대안으로는 React의 setState를 사용하는 방법이 있다. React의 기능을 그대로 활용하여 호환성을 유지한다는 면에서 훌륭한 접근법이다. 하지만 좀 더 클로저스러운 방법은 없을까? 클로저에서는 상태 관리(side-effect)를 위해 atom을 사용한다. 여기서도 atom을 동일하게 사용할 수 없을까?

click-counter를 예를 들어보자. 현재까지 클릭한 횟수를 보여주는 버튼이다. 클릭 횟수는 실행 중에 값이 바뀌는 값으로, 여느 클로저 프로그램과 같이 counter_ 라는 atom으로 관리한다. 뷰를 그리기 위해 현재 값을 읽을 때는 counter_deref(@)하고, 이벤트 핸들러에서 값을 바꿀 때는 counter_reset!한다. 구현에 사용되는 React는 모두 감춰지고, 일반적인 클로저 프로그램 그 자체가 될 수 있다!

잠깐, 그런데 새로고침은?

counter_ 상태 값이 바뀌면 click-counter 컴포넌트가 새로 그려져야 하는데, 어떻게 그럴 수 있을까? 이 질문은 다음 두 개의 질문으로 나뉜다.

Q. atom의 상태 값이 바뀔 때 신호를 받을 수 있나?

A. 클로저의 atom에는 이미 watch 기능이 있어서, 상태 값이 바뀔 때 마다 불릴 콜백 함수를 등록할 수 있다!

Q. 컴포넌트는 어떻게 새로 그릴 수 있나?

A. 우리는 위에서 사용자 정의 컴포넌트를 React 클래스 컴포넌트로 변환하였다. React 클래스 컴포넌트 API 중에는 해당 컴포넌트를 강제로 새로 그릴 수 있는 forceUpdate 메소드가 있다.

이 둘을 조합하면 해결의 실마리가 보인다. 사용자 정의 컴포넌트가 내부적으로 사용하는 atom들이 deps-ratoms으로 주어졌다고 하자. 그러면 이 atom들 각각에 watch 콜백 함수를 등록하는데, 이 콜백 함수에서 사용자 정의 컴포넌트의 forceUpdate 메소드를 불러주는 것이다. (정확히는 사용자 정의 컴포넌트를 감싸고 있는 Wrapper React 클래스 컴포넌트의 forceUpdate 메소드를 불러준다.)

그런데 deps-ratoms 어떻게 알아내나?

마지막으로 가장 중요한 질문으로 사용자 정의 컴포넌트가 내부적으로 사용하는 atom들은 어떻게 알아 낼 수 있을까? 첫번째 방법은 React의 useEffect hook 처럼 사용자 정의 컴포넌트를 작성하는 개발자가 직접 기술하는 방법이 있을 것이다. 이는 사용성이나 안전성 측면에서 좋지 않다. 사람의 실수를 배재할 수 있는 안정적이고 확실한 방법이 필요하다.

결론부터 이야기하면 deref 함수를 후킹하면 deps-ratoms들을 알아낼 수 있다. 사용자 정의 컴포넌트가 최종적으로 그려지는 과정을 다시 떠올려보자. 사용자 정의 컴포넌트는 백터를 반환하는 함수이다. 랜더링 과정은 사용자 함수를 실행하여 Hiccup 백터를 반환받은 후, 이를 React Element로 변환하는 것이다. 어떤 atom이 이 반환되는 Hiccup 백터에 영향을 미치려면 이 함수를 실행하는 도중 어딘가에서는 참조(deref) 될 수 밖에 없다. atom을 deref하지 않고 상태 값을 읽어오는 방법은 CLJS에 없기 때문이다. 물론 참조된 값이 실제로는 전혀 사용되지 않을 수도 있다. 이 경우에도 해당 atom 값이 바뀔 때 사용자 정의 컴포넌트를 새로 그린다고 하더라도, 여전히 영향을 주지 않을 것이기 때문에 문제는 없다.

함수 실행 중에 참조(deref)되지 않은 atom은 정말 아무런 영향을 못주는걸까?

CLJS의 불변성 덕분에 이게 보장된다. 먼저 함수 실행 후에 참조되는 atom 경우를 살펴보자. 이 값이 영향을 미치려면 반환된 Hiccup 백터를 바꿀 수 있어야 하는데, 한번 만들어진 백터의 값은 바뀌지 않기 때문에 영향을 미칠 수 없다. 그러면 역으로 함수 실행 전에 참조되는 atom은 어떨까? 이 값이 최종적으로 반환될 Hiccup 백터에 영향을 미치려면 사용자 정의 함수의 동작을 바꾸거나, 사용자 정의 함수가 참조하는 변수(binding)의 값을 바꿀 수 있어야 하는데, 이 또한 불가능하다. CLJS에선 함수 또한 불변이며, 모든 변수 또한 불변이기 때문이다. 물론 외부 JS 변수를 참조하거나, CLJS가 최종적으로 JS로 컴파일 되는 특성을 이용해 CLJS 값을 JS 오브젝트로 취급하여 값을 바꾸는 Hacky한 경우는 재외한다.

RAtom

deref 함수를 후킹하기 위해 atom과 동일한 인터페이스를 재공하는 RAtom이라는 데이터를 정의하자. 먼저 reset!deref 함수에 사용될 수 있도록 IResetIDeref 프로토콜을 구현해준다. 또한 watch 기능을 사용할 수 있도록 IWatchable 프로토콜도 구현해준다. 내부 구현은 atom 타입인 atom_을 가지도록 하고, atom의 기본 동작을 그대로 쓰도록 한다.

대신 deref 될 때 전역 변수인 *captured*에 자신을 등록하여 자진신고 하는 기능을 추가한다. 전역 변수는 좀 더 안전하게 쓸 수 있도록 dynamic binding 기능을 이용하여 사용하는 함수에서 재정의할 수 있도록 하자.

마지막으로 편리한 사용을 위해 ratom이라는 factory 함수를 정의한다. 이제 사용자 정의 함수에서 atom 대신에 ratom을 쓰면, 아래 예제와 같이 랜더링 함수에서 사용자 정의 함수가 내부적으로 쓰는 ratom들을 계산해낼 수 있다.


(def ^:private counter_ (ratom 0))
(defn- click-counter []
[:button
"Clicked "
@counter_
" time(s)"])
(let [captured_ (atom [])]
(binding [*captured* captured_]
(click-counter)
(assert (= @captured_ [counter_]))))

RAtom 적용

이제 모든 조각은 갖춰졌으니, 이를 이용해서 create-classrender 함수를 수정해보자.

일단 원본 render 내용은 do-render로 뺀다.

ratom의 capture 기능을 사용하여 render-fn이 내부적으로 사용하는 ratom들을 계산한다.

이제 이 ratom들이 변경될 때 마다 이 컴포넌트를 다시 그리도록 콜백을 등록한다. 그런데 이 컴포넌트를 새로 그려질 때에는, 이전에 사용되었던 ratom이 더 이상 사용되지 않을 수도 있다. 그래서 매번 새로그려질 때 마다 이전에 등록해 두었던 콜백은 모두 해지하도록 한다. 마지막으로 이 컴포넌트가 삭제될 때에도 콜백 함수들을 재거해야한다.

콜백 함수를 등록하는 함수와 제거하는 함수는 특별한 점은 없다. 다만 콜백 함수를 제거하기 위해서는 이전에 등록해 두었던 ratom 목록을 알아야만 한다. 이를 위해 add-watcher에서 리엑트 클래스 컴포넌트 인스턴스에 _ratoms 속성으로 몰래 설정해 두었다.

이제 버튼을 눌러보면 숫자가 1씩 증가하는 것을 볼 수 있을 것이다!

lazyseq(for, map, etc.) 관련 한계점

우리 구현에는 사실 함정이 하나 있다. 함수 안에 ratom 참조(deref) 코드가 존재하면 그 시점에 바로 계산이 실행되어 deref 함수가 불린다고 가정했다. 그런데 클로저스크립트는 lazy sequence라는 Lazy evaluation 기능을 지원하여, 코드 실행을 필요한 순간까지 지연시키는 기능이 있다. lazy sequence가 구현에 사용되면 코드상으로는 분명 함수 안에서 ratom을 참조하지만, 실제 실행은 지연되여 함수 실행이 종료될 때까지도 ratom 참조 코드가 실행되지 않을 수 있다. Side-effect가 없는 대부분의 경우 이와 같은 실행 순서 재조정은 문제가 없지만, 우리 구현은 Side-effect를 사용하고 있기 때문에 실행 순서가 재조정되면 정상적인 동작을 하지 못한다.

실행 순서 재조정이 문제의 원인이니, 실행 순서를 강제하면 문제를 회피할 수 있을 것 처럼 보인다. 사용자 정의 컴포넌트를 실행시킬 때 지연된 계산을 강제로 실행시키는 doall 함수를 추가하면된다. 이러면 반환되는 Hiccup 백터 안에 숨어있는 lazy sequence도 모두 계산되어, 우리가 원하는 대로 함수가 종료되는 시점에 ratom 참조가 모두 계산되어 문제를 회피할 수 있다.

하지만 이 방법은 실행해선 안되는 lazy sequence도 모두 실행시키기 때문에 문제가 발생한다. 왼편 예제와 같이 컴포넌트 인자로 Infinity sequence (또는 generator)를 넘기는 경우가 있을 수 있는데, 원래 전혀 문제가 없어야 한다. 하지만 수정된 우리 구현에서는 top-ranker-test를 렌더링하기 위해 그 반환값인 [top-ranker {:ranker-list (cycle ["A" "B" "C"])}]를 실행시키게 되고, 그 안에 있는 (cycle ["A" "B" "C"])도 실행시키게 된다. cycle은 infinity sequence를 반환하기 때문에 계산이 끝나지가 않아 전체 프로그램이 멈추게 된다. 이 외에도 사용자가 실행을 지연시킬 목적으로 의도적으로 lazy sequence를 사용한 경우, 우리 코드가 사용자의 의도와 반하게 엉뚱한 위치에서 lazy sequence를 실행시켜버림으로써 각종 문제를 발생시킬 수 있다.

Reagent에서는 이 문제를 해결하지 않고 사용자의 몫으로 남겨두고 있다. 대신 개발 모드에서는 실제 문제가 발생하는 경우를 감지하여 경고를 띄워준다. (코드 참조) 이런 이유로 Reagent로 웹 페이지를 개발할 때에는 항상 개발자 콘솔을 띄워놓고 테스트할 것을 추천한다.

Step 5. Local ratom을 위한 form-2 지원

모듈 전역 변수로 ratom을 두고 사용하면 사용자 정의 컴포넌트를 singleton으로 밖에 사용할 수 없는 문제가 있고, 컴포넌트 재사용성 측면에서도 별로 좋지않다.

이런 문제를 완화하기 위해 reagent는 Form-2라는 형태의 컴포넌트를 지원한다. Form-2는 내부 상태를 가지는 함수여야 하는데, 클로저에서 이는 atom과 고차 함수로 구현된다. 다만 여기서는 atom 대신에 ratom을 쓴다는 점이 다를 뿐이다.

구현을 수정하여 함수를 반환하는 함수를 지원해보자. 이제 do-render에서 반환값 res가 함수일 수 있다. 그런데 바로 이 함수가 실질적으로 Hiccup 백터를 반환할 함수 (render-fn)인 것이다. 그럼 이 함수를 이용해 do-render를 다시 재귀호출 하면 랜더링이 될 것이다. 그리고 이 컴포넌트가 새로 그려질 때에도 do-render함수는 원본 render-fn이 아닌 이 반환된 함수로 불려야 한다. 내부 상태 ratom은 이 반환된 함수 클로저(closure) 안에 숨어있기 때문이다. 원본 render-fn로 부르면 매번 새로운 클로저(closure)를 생성하기 때문에 실질적으로 상태 업데이트가 안 될 것이다.

render-fn을 갈아칠 수 있도록 하기 위해 render-fn을 React 클래스 컴포넌트의 맴버 변수(reagentRender)에 저장해두고 do-render에서는 이를 참조하도록 수정한다. 또한 반환값이 함수일 경우 이 멤버 변수를 업데이트하고 do-render를 재귀호출한다. 이로서 기본적인 구현은 끝났다.

지금까지 구현의 원리를 다시 정리해보자.

  1. Reagent 사용자 정의 컴포넌트는 React 클래스 컴포넌트로 변환된다. 하지만 실질적인 변환, 즉 Reagent 사용자 정의 컴포넌트 Body에 대한 변환은 이 단계에서는 이루어 지지 않는다.
  2. 실질적 변환은 React 클래스 컴포넌트의 render 메소드 안에서 진행된다. 1번 과정을 컴파일이라고 부를 수 있는데, 이 시점으로 보면 실질적 변환은 런타임 때 진행된다고 볼 수 있다. prop으로 전달받은 인자를 이용해 실질적 렌더 함수(Reagent 사용자 정의 컴포넌트)를 호출하여 Hiccup 백터를 계산하고, 이를 Reagent Element로 변환한다.
  3. form-2 형일 경우 최초 렌더링이 될 때 실제 랜더 함수가 생성된다. 최초 렌더링에 사용할 렌더 함수와 이후 사용할 렌더 함수가 달라야 한다. 이를 위해 클래스 컴포넌트 인스턴스reagentRender 맴버 변수에 실제 렌더 함수를 저장하고, 렌더 과정에 이를 바꿔칠 수 있도록 한다.

눈치 빠른 독자는 이미 위화감을 느꼈을 수 있는데, 현 과정에는 한가지 문제가 있다. 사용자 정의 컴포넌트를 랜더링할 때 마다 매번 동적으로 새로운 React 클래스 컴포넌트를 만들어내기 때문에, 내부 상태(인스턴스 또는 fiber)가 유지되지 않는 점이다. React 입장에서 보면 매번 새로운 클래스 컴포넌트로 바뀌는 것이기 때문에 기존 fiber를 지우고 새로운 fiber를 생성하게 되고, 그 과정에서 해당 컴포넌트의 내부 상태가 모두 초기화된다. 우리 구현에서는 내부 상태를 클로저(Closure) 형태로 멤버 변수에 저장하기 때문에(reagentRender) 같이 초기화 되버린다.

예를 통해 이 문제를 살펴보자. click-counter가 내부적으로 nested-click-counter를 사용하고 있다. 최초 랜더링이 끝나면 각각에 대응되는 React 클래스 컴포넌트가 생성되는데, click-counter에 대응되는 React 클래스 컴포넌트를 OuterComp, nested-click-counter에 대응되는 컴포넌트를 InnerComp라고 하자. (정확히는 클래스 컴포넌트와 그 인스턴스이다.) nested-click-counter의 버튼을 누르면 InnerCompforceUpdate가 불리면서 해당 컴포넌트만 다시 그려진다. 이 때에는 nested-click-counter의 안쪽 함수가 불릴 뿐이라서 클래스 동적 생성은 발생하지 않고 기능도 의도대로 작동한다. 반면에 바깥 쪽 click-counter의 버튼을 누르면, OuterComp를 다시 그리는데, 이 과정에서 nested-click-counter를 다시 컴파일하여 새로운 React 클래스 컴포넌트 OuterComp'를 생성해낸다. 이전에 있던 OuterComp(의 인스턴스)는 재거되고, OuterComp'(의 인스턴스)가 새로 생성된다. 따라서 내부 상태가 유지되지 않아 카운트도 0으로 초기화된다.

해결방법은 사용자 정의 컴포넌트를 변환할 때 매번 새로운 React 클래스 컴포넌트를 만들지 말고 재사용하는 것이다. 사용자 정의 컴포넌트틀 React 클래스 컴포넌트로 변환하는 로직에 memoize 기법을 적용하여, 같은 함수에 대해서는 동일한 결과가 반환되도록 하자.

atom을 이용한 memoize 구현을 사용해도 되지만, 여기서는 실제 Reagent의 구현을 가져왔다. 클로저 스크립트의 함수는 JS의 함수이고, 이는 결국은 JS Object이기 때문에 임의의 맴버 변수를 추가할 수 있다. cached라는 맴버 변수에 변환된 React 클래스 컴포넌트를 저장해두고 읽어오게 한다. 이 방법을 쓰면 메모리 leak 문제를 피할 수 있다.

이제 상태가 유지되지 않는 문제도 해결되어, 우리의 조그만 Reagent가 완성되었다.


(ns core
(:require ["react" :as react]
["react-dom" :as react-dom]))
(defn app []
(react/createElement "div"
#js {:style #js {:color "red"}}
"Hello, I am 'app'"))
(defn ^:export init []
(react-dom/render (app)
(js/document.getElementById "app")))
(defn ^:dev/after-load reload []
(let [dom (js/document.getElementById "app")]
(react-dom/unmountComponentAtNode dom)
(react-dom/render (app)
dom)))

닫으며

학습용으로 작성한 우리 코드는 실제 Reagent 구현과 여러가지 차이가 있다.

이 글에서 생략한 아래 기능들을 구현해보는 것도 좋은 연습이 될 것이다.

끝으로 Reagent 소스코드를 직접 읽어보길 추천한다. 전체 코드량이 많지 않기도 하고, 이 글을 모두 읽었다면 어렵지 않게 이해할 수 있을 것이다.

다음 글에서는 아래 주제 중 하나를 다뤄 볼 예정이다.


Footnotes

  1. Fiber는 React 내부 코드에서 사용하는 용어로, Virtual DOM이라는 용어로 널리 알려져 있다.

  2. 실제로 Hiccup은 문자열이나 심볼도 지원하지만 간단한 구현을 위해 무시하자.

  3. 이 부분은 안해주더라도 동작은 한다. CLJS의 sequence는 그 자체로 자바스크립트 iterable 객체이기도 한데, React가 내부적으로 iterable 자식을 지원하도록 구현되어 있기 때문인 것 같다. 임의의 JS object가 iterable 인지를 확인하는 방법은 블로그를 참고하자.

  4. 1.0.0 부터 에서 기본값을 함수형 컴포넌트로 바꿀 수 있도록 설정할 수 있다.

  5. ES5에서 클래스 상속을 구현하는 방법은 Stackoverflow 답변이나 블로그 글을 참고하자.

  6. namespace 구분을 위한 '.'은 '$'로, '-'는 '_'로, '?'는 _QMARK 등으로 변경되어 있다.