이제는 마무리를 해야할 때가 된 것 같다.
그 동안 개발했던 Sapa 이야기를 정리해보자.
그 동안 적었던 Sapa 와 관련된 이야기들
개발자 트렌드를 버리다. : https://easylogic.medium.com/%EA%B0%9C%EB%B0%9C%EC%9E%90-%ED%8A%B8%EB%A0%8C%EB%93%9C%EB%A5%BC-%EB%B2%84%EB%A6%AC%EB%8B%A4-b5cbbf9ba958
개발자, 트렌드를 버리다 — 1년 후 소감 :
https://easylogic.medium.com/%EA%B0%9C%EB%B0%9C%EC%9E%90-%ED%8A%B8%EB%A0%8C%EB%93%9C%EB%A5%BC-%EB%B2%84%EB%A6%AC%EB%8B%A4-1%EB%85%84-%ED%9B%84-%EC%86%8C%EA%B0%90-e75a859280e9
개발자, 트렌드를 버리다 — 2년후 소감 : https://easylogic.medium.com/%EA%B0%9C%EB%B0%9C%EC%9E%90-%ED%8A%B8%EB%A0%8C%EB%93%9C%EB%A5%BC-%EB%B2%84%EB%A6%AC%EB%8B%A4-2%EB%85%84%ED%9B%84-%EC%86%8C%EA%B0%90-a0d9809588b3
개발자, UI 라이브러리를 만들다. : https://velog.io/@easylogic/%EA%B0%9C%EB%B0%9C%EC%9E%90-UI-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%8B%A4
원래 작년에 개발자, 트렌드를 버리다 - 3년 후 소감
을 쓰고 싶었으나 개인적으로 너무 바쁘기도 했고 정신이 없었다.
하지만 이상한것에 정신이 팔려 있었던 것은 아니고 sapa 의 업그레이드 버전을 개발하고 있었다.
오늘 문득 마무리 글을 적어야 할 듯 해서 적어본다.
Sapa는?
Sapa 는 summernote 플러그인을 대체하기 위해서 만들었던 코드를 베이스로 발전된 리액트같은 UI 만드는 라이브러리이다.
처음에는 문자열 템플릿과 이벤트 시스템만 잘 조합해보면 하나를 만들 수 있을줄 알고 시작했었다.
그러다가 컬러피커를 만들게 되고, 그라디언트 에디터를 만들게 되고, 최종적으로 easylogic studio 라는 디자인 에디터를 만들게 되면서 조금씩 틀을 갖추게 되었다.
글 상단에 있는 Sapa 와 관련된 이야기들을 보면 좀 더 자세히 알 수 있다.
Sapa 를 왜 계속 만들고 있는가?
리액트 같은 UI 라이브러리이지만 아무도 모르는 Sapa 를 왜 계속 만들고 있는가?
사실 나조차도 그게 의문이긴 한데,
나는 사파니깐!
리액트와 동일한 패턴의 라이브러리를 만드는게 목표가 아니라 리액트나 기타 다른 라이브러리와 전혀 연관성이 없이 나만의 라이브러리로 나만의 프로젝트를 완성하는게 나름의 목표였다.
처음 시작할 때부터 리액트나 기타 다른 라이브러리의 의존성을 가지면 안되었기 때문에 선택했던 길이었다.
그렇게 한참을 하다가 나중에는 제대로 해보자는 마음이 점점 더 커져갔다.
경력 16년 동안 회사 일 말고 이렇게 집중 해본일이 있었을까 생각해본다.
사이드 프로젝트를 하다보면 한 달 두달 잠깐 하다가 멈추는 경우가 많은데 Sapa 는 4년 5년을 만들어 가다보니 나름의 애정이 생겼달까?
그래서 그냥 보면 재밌다.
이상하게 만들어진 코드도 많고, 내가 왜 그 때 그렇게 짰는지 이해 못할 코드도 많지만 그냥 그 자체로 재밌었었던 것 같다.
Sapa의 지금은?
Sapa 는 elf-framework 라는 에디터 프레임워크의 메인 UI 라이브러리로 같이 관리하고 있다.
Sapa 로 프로그램을 계속 만들다 보니 단순히 UI 라이브러리만 있으면 되는게 아니라는 것을 알았다.
거꾸로 리액트가 왜 그렇게 인기가 많을 수 있는지 몸소 깨닫게 되었다.
왜 그랬을까?
Sapa 도 나름의 상태를 가지고 있었는데, 개발코드를 고칠 때면 항상 새로고침해서 상태가 사라졌다. 그래서 마지막 상태를 항상 local storage 에 집어넣고 새로고침되도 해당 데이타를 부르도록 구조를 맞추고 있었다.
그냥 듣다 보면 이상할 것이다.
나도 이상했다.
리액트나 다른 라이브러리를 개발하다보면 너무 자연스럽게 HMR 지원되기 때문에 모르고 있었던 것이다.
Sapa 도 하고 싶다라는 생각과 함께 HMR 을 하려면 본격적으로 컴파일러에 코드를 추가할 수 있도록 해야한다는 것을 알게되었다. (흠, 이런건 한번도 해본적이 없는데…..)
Sapa 는 무엇을 해야했을까?
자, 한번 고민해보자.
자신이 UI 라이브러리를 처음부터 개발한다고 치고, 무엇을 해야 어느 정도 평행선을 맞출 수 있을까?
몇가지 생각해보면
- HMR 지원
- JSX 지원
- Function Component 지원
- Hook 지원
이 정도는 아주 사소한 것들일 수 있다.
Fragment 개념도 지원해야하고, 요근래는 한참 또 이슈가 되는 Server Component 도 지원도 고민해봐야한다.
Suspense 개념도 있고, Hydrate 로 지원해야할 수도 있다.
이런 개념 하나 하나가 거의 UI 라이브러리 하나를 설계하는 느낌에 가깝다.
뭐랄까 체계적으로 하나씩 맞추지 않으면 못할 것들이다.
하지만 혼자 개발하는데 저걸 다 할 수 있을까?
Sapa 의 선택은?
일단 Sapa 를 에디터 프레임워크인 elf 의 하나로 만들기 위해서는 제대로된 스펙이 필요했다.
이미 익숙한 다른 라이브러리 개념을 가지고 올 필요가 있었다.
위에 명시한 4가지 정도는 있어야 그 다음을 할 수 있을 것 같았다.
그래서 과감하게 리액트처럼 만들어 보기로 해본다.
상당히 많은 관문들이 있는데 하나씩 보도록 하자.
템플릿 선정하기
먼저 문자열로 되어 있던 템플릿 개념을 jsx 로 바꿔야 했다. 이렇게 한 이유는 몇가지 있는데 그중에 가장 큰 것이 dom 의 이벤트를 지정할 때 사용하는 함수의 참조를 유지할 수가가 없는 문제가 있었다.
그래서 약간 우회해서 함수의 글로벌 키를 두고 그것을 참조하는 방향으로 구조를 맞췄다.
이렇게 하다 보니 나는 알지만 이걸 사용하는 사람들은 매번 글로벌 키를 다시 만들어야 하는 상태가 된 것이다.
그래서 과감히 jsx 로 넘어가기로 했다.
jsx 는 js 그 자체라 함수 참조를 그대로 가지고 있을 수 있었으니깐.
그래서 이왕 jsx 로 넘어간김에 React 와 비슷하게 만들어 보기로 한다.
리액트 개념 가지고 오기
jsx 를 템플릿으로 하려고 보니 jsx 는 컴파일 되면서 순수 js 함수로 랩핑되기 때문에 문자열 템플릿이 되지 않는다.
그렇다는 것은 문자열을 다시 재구성 하던가, jsx 가 생성한 데이타 구조를 그대로 만들던가 해야한다.
일단 문자열로 돌아가면 참조문제를 다시 겪어야 하기 때문에 jsx 데이타 구조를 그대로 사용하기로 한다.
아 ~ 이러면 react 의 근간을 생각해볼 수 밖에 없다. vdom 을 ~
처음부터 문자열로 시작하지 않았기 때문에 vdom 형태를 고민해야한다.
vdom 구조 만들기
jsx 가 지원하는 몇가지 구조중에 element 와 component 가 있다.
기본적으로 태그를 소문자로 시작하면 element 가 되고 대문자로 시작하거나 외부 함수나 클래스 참조로 시작하면 컴포넌트가 된다.
이 점을 이용해서 동일한 몇가지 랩핑 객체를 만들어서 사용한다.
Text, Component, Comment, Fragment, Element
자, vdom 을 만들었으니 이제 무엇을 해야할까?
당연히 Diff 를 통한 Reconcile(재조정) 개념을 만들어야한다.
리액트도 이 diff 가 핵심이니 나도 diff 를 만들어서 적용해야한다.
하지만 이 diff 가 만만치 않다.
몇가지 지점에서 고려해야할게 많아진다.
- Fragment 라는 개념 적용하기
- vdom 끼리 diff 할지 dom — vdom 을 diff 할지 선택
- 리스트 상태에서 Reconcile(재조정)을 어떻게 할지
사실 더 많은게 있지만 하지 않았다. 이것만 해도 구조가 맞지 않았으니깐.
Fragment 개념 만들기
Fragment 개념자체가 너무 어려운 개념이다. 눈에 보이지 않는 가상의 공간을 만들어줘야 하는데 React 는 그걸 너무 쉽게 쓸 수 있도록 해놨다.
React 이외의 다른 라이브러리들은 React 처럼 Fragment 를 동작시킬 수 없다.
중첩이 안된다던가 하나에서 여러번 표시가 안된다던가 등등의 문제를 가지고 있는데 React 는 너무 자연스럽게 표현해놨다.
암튼, Fragment 라는 개념을 실제로 구성할려면 렌더링 하기 위한 VNode의 최상위 부터 매번 다시 재조정해야할 수도 있다.
diff 가 거의 모든 것
vdom 상태에서 diff 는 상당히 중요하다. diff 가 되지 않으면 성능을 낼 수 없기 때문에 그렇다.
그런데 이 diff 가 쉽지 않다.
일단 jsx 라는 틀안에서는 diff 가 너무 힘들다.
일례로 함수 컴포넌트 안에 jsx 구문이 엄청 길게 놓여있는데 그 중에 딱 하나의 text node 가 바뀐다고 생각해보자.
리액트는 그 node 하나만 변경할 수가 없다.
그래서 solidjs나 preact 의 signal 이 나온다.
왜냐하면 기본 컨셉이 컴포넌트의 모든 vnode 를 다시 비교하기 때문에 그렇다.
vue 는 컴파일 단계에서 변경되는 변수인지, 상수인지 구분해서 약간의 회피를 하고 있지만 여전히 전체 템플릿을 생성하는건 비슷하다.
그러다 보니 까딱 잘못하다가는 성능이 기하급수적으로 떨어진다. 왜냐하면 매번 비교해야하는 대상이 엄청 많기 때문에 그렇다.
그래서 컴포넌트는 최대한 작게 해서 쪼개는게 제일 좋다. 반응을 할 부분과 안하는 부분을 나눠서 할 부분에만 변수를 넣고 나머지는 그냥 한번만 렌더링 하는게 지금으로써는 최선이다.
리스트 렌더링 하기
jsx 의 vnode 가 많아지면 무슨 일이 생길까?
vnode 를 생성하는 비용이 든다. 매번 메모리에 만들어야 한다. (이정도는 괜찮다.)
메모리에 만든것을 diff 를 해야한다. (여기까지도 머 나름 괜찮다.)
리스트 아이템이 100만개다. (이러면 큰일난다.)
리스트 개수가 많아지면 엄청 힘들어진다.
사실 이러면 react 에 맡기는 것보다 react-window 같은 라이브러리를 써서 원하는 부분만 출력하는게 제일 좋다.
리액트는 key 라는 특수한 속성을 써서 해당 리스트 아이템이 이미 생성이 된 것인지 체크하고 비교를 최소화 하는데 Sapa 는 그러지 않았다.
왜냐하면 일단 구조가 만들어지지 않았는데 성능부터 고민하는건 뭔가 맞지 않았다.
전체 렌더링 구조부터 잡는게 우선이었다.
vnode 렌더링 하기
jsx 로 생성된 vnode 들은 모두 렌더링하는 구조를 따로 가진다.
특히 DOM 형태로 렌더링 할지, 서버에서 렌더링을 할지에 따라 달라진다.
서버에서 렌더링 하는것을 처음에는 생각지 못했다가 나중에 다시 만들게 되었다.
SSR 개념을 만들려면 서버렌더링을 하고 hydrate 형태로 UI 를 재구성해줘야 했다.
DOM 으로 렌더링 할 때는 2가지가 필요하다.
- 최초 element 가 만들어지는 상태
- diff 를 통한 update 가 되는 상태
보통 1번을 mount, 2번을 update 라고 정의했다.
그냥 처음부터 element 를 중첩으로 생성할 때랑 diff 를 통해서 update 할때가 구조가 다르다보니 엄청 헷갈렸다.
Function Component
기존에 Sapa 는 클래스형태의 컴포넌트만 존재했는데, 함수 컴포넌트가 이제 본격적으로 필요하게 되었다.
react 에서도 대세가 된 것처럼 사용하는 방법이 다르기 때문에 지원을 하는 수 밖에 없었다.
하지만 이러면 항상 고민인 부분이 생기는데 함수형 Hook 을 지원해야하는 것이다.
클래스 컴포넌트도 몇가지 Hook이 있지만 함수형 만큼 어렵진 않다.
Function 은 기본적으로 내부에 상태를 가질 수가 없기 때문에 Hook 을 통해서 상태를 가진다.
초기에 잡은 구조가 리액트 구조랑 많이 다르다 보니 이 상태를 어떻게 관리할까 고민을 엄청 많이 하다가 그냥 React 랑 비슷한 형태로 가보자고 생각하고 진행했다.
덕분에 Sapa 는 지금 React 와 상당히 유사한 문법을 가진다.
하지만 이것은 결코 쉬운 일이 아니였으니….
계속 쉬운일이 아니라고 하는 이유는
위에 적어가면서 설명하는 기본적인 내용들이 모두 jsx 기반으로 4개월 정도만에 만들어야 했기 때문에 그렇다.
고민할 시간이 별로 없었다.
Hook과 함께 간단한 함수 컴포넌트를 그려보자.
function MyComponent() {
const [count, setCount] = useState(0);
return <div>{count}</div>
}
MyComponent 라는 함수 컴포넌트를 하나 만들었다. useState 라는 Hook 을 사용해서 상태를 저장한다.
이 구조만 보면 useState
는 그냥 외부함수를 부르는 것이다.
MyComponent 는 useState
와 전혀 연관성이 없고, useState
또한 MyComponent 와 연관성이 없다.
하지만 이 둘은 연관성이 있어야 한다.
왜냐하면 setCount
를 통해서 상태를 다시 설정하고,
MyComponent 를 다시 랜더링 해야하기 때문에 그렇다.
그렇다는 이야기는 useState 가 리턴해주는 setCount 라는 변경자가 무엇인가를 가지고 있다는 것을 의미했다.
맞다. 리액트와 비슷하게 렌더링 하는 컴포넌트의 참조를 사전에 가지고 있다.
이렇게 생각하면 엄청 쉬울 수 있는데 막상 구현할려니 쉽지가 않았다.
하나의 컴포넌트를 렌더링 하는데 그 하나의 컴포넌트의 참조를 글로벌이 가지고 있어야 한다고?
그렇게 useState 가 그걸 참조 할 수 있어야 하고 ?
기존의 Sapa 는 그냥 컴포넌트를 중첩해서 실행했기 때문에 글로벌 상태를 전혀 쓰지 않았는데, 이제는 당연하게 써야 하는 시점이 됐다.
Hook 구현하기
useState 말고도 기본적으로 다양한 Hook을 구현해야했다.
대표적으로 useCallback, useMemo 2가지
이 2가지를 구현하는건 diff 성능과 연관이 있기 때문에 리액트 처럼 순수하게 함수 형태로 실행되는 곳은 꼭 구현해야한다.
function MyComponent() {
const [count, setCount] = useState(0);
function onClick (e) {
}
return <div onClick={onClick}>{count}</div>
}
위 구문을 잠시 보자.
얼핏 봐서는 딱히 문제되는 부분이 없다. onClick 에 함수를 잘 지정해놨기 때문이다.
하지만 여기서 문제가 하나 있는데 js 에서 함수안에 함수를 재정의 해놓게 되면 함수가 실행할 때마다 새로운 참조로써 만들어짐을 의미한다.
즉, onClick 은 매번 새로운 함수참조가 들어가게 된다.
이것은 곧 diff 를 할 때마다 onClick 이벤트를 매번 다시 설정해야하는 것과 같다.
별거 아닌 것 같지만 컴포넌트를 매번 다시 그리도록하는건 성능에 나쁜 영향을 미치게 된다.
그래서 함수 참조를 일정하게 유지하기 위해서 useCallback 이라는 Hook 을 만들어서 제공해준다.
const onClick = useCallback(() => {
});
useCallback 을 통해서 새로운 함수를 하나 만들었다. 아이러니 하게도 이함수는 MyComponent 가 렌더링 할 때마다 여전히 새로 생성되고 할당된다.
즉, 이렇게만 할 순 없다. 참조를 변경하지 않게 하기 위해서는 뒤에 하나를 더 붙여준다.
const onClick = useCallback(() => {
}, [count]);
대략 이런식으로 배열 형태로 dependencies 를 생성해준다.
기존에 있던 값이랑 배열 값이 동일하면 함수 참조를 다시 셋팅하지 않는다.
그렇게 함으로써 diff 할 때 이득을 얻을 수 있다.
그래도 여전히 의문은 남는다. useCallback(() => {}) 요 부분은 계속 실행되는건 아닌지.
맞다. 계속 실행되고있다. 그건 그냥 감수 하는것이다.
왜냐하면 컴포넌트 렌더링 끝나면 바로 버려지기 때문에 그렇다.
실제 렌더링과는 특별히 연관이 없는 상태
암튼 이렇게 해서 Hook 도 구현한다.
Sapa Hook : https://www.elf-framework.com/pages/sapa/hook/
이로써 얼핏 보면 리액트처럼 생긴 Sapa 의 개념을 잡게 된다.
HMR(Hot Module Reload) 지원하기
HMR은 vite 기반으로 작성되었다. 즉, webpack 기반은 없는 상태이다.
vite 는 개발서버를 띄울 때 플러그인을 통해서 코드에 변화를 줄 수 있는데 jsx 파일을 만날 때 특정 코드를 삽입해서 HMR 기능을 맞춰줄 수 있다.
리액트는 react-fresh 라는 라이브러리를 제공하는데 이것이 상당히 복잡하다.
복잡한 이유는 react 와 별개의 라이브러리 형태로 만들어져있기 때문에 그렇다.
jsx 형태로 실행되는 코드들에서 특정 영역을 replace 할 수 있도록 한땀 한땀 구조를 다시 맞추는 작업이 포함이 되어 있기 때문에 생각보다 많이 복잡하다.
sapa 도 개발의 편의성과 상태를 계속 유지하기 위해서 HMR 적용하기로 결정했다.
가장 큰 고민은 2가지
- 특정 모듈만 재로드 해야한다.
- 특정 모듈이 재로드 됐을 때 다시 렌더링 하면 해당 변경된 모듈로 렌더링 해야한다.
이 개념을 처음에는 이해를 못했다.
import A from "./A";
브라우저에서 저 코드를 실행하게 되면 "./A";
가 cache 가 되게 된다../A
가 수정이 되고 난 이후에 vite 를 통해서 import 를 다시 실행하게 되는데 이때도 여전히 기존의 './A' 에서 cache 된 module을 부르게 되어있다.
그렇다면 나는 어떻게 모듈을 교체할 수 있는가? 기본 컨셉은 이렇다.
모듈 로드 한 것을 Sapa 에서 바로 쓰지 않는 것이다.
아래 코드는 vite-plugin-sapa 라이브러리를 통해서 code
를 대체하기 위한 코드이다.
import { registerModule, renderFromRoot, uuidShort, setGlobalForceRender } from "@elf-framework/sapa";
${code};
if (import.meta.hot) {
const TEMP = {${names.join(", ")} }
Object.keys(TEMP).forEach((key) => {
// unique key string
TEMP[key].__timestamp = [Date.now(), uuidShort()].join("-");
}) registerModule("${id}", TEMP); import.meta.hot.accept((m) => {
console.log("hot reload");
setGlobalForceRender(true); setTimeout(() => {
renderFromRoot();
}, 30);
});
}
registerModule을 통해서 특정 id 에서 참조된 컴포넌트 리스트를 가지고 있다가 sapa 가 렌더링 되는 시점에 변경된 로드점을 찾고 마지막 변경된 것으로 렌더링 해준다.
그렇게 하면 변경된 형태로 실행할 수 있다.
보통은 리액트나 vue 같은 UI 라이브러리 레벨에서만 지원한다. 일반코드에서도 비슷한 방법으로 할순 있지만 규칙을 만들기 쉽지 않다.
react-refresh 같은 경우는 해당 기능을 수행하기 위해서 컴파일러에서 상당히 많은 부분을 수정하게 되어 있는데 Sapa 는 기본 기능들을 그냥 라이브러리에 넣어버려서 최종 코드는 좀 짧은 편이다.
그 외 특징들?
https://www.elf-framework.com/pages/sapa/getting-started/
Sapa의 나머지 특징들은 여기서 직접 확인 해봐도 좋다.
Sapa의 다음은?
서비스 사이트나 문서 사이트 또는 복잡한 어플리케이션을 만들다 보면 단순히 UI 라이브러리 하나만 가지고는 어떻게 할 수가 없다.
그래서 여러가지 라이브러리를 다 합쳐야 하는 상태가 된다.
몇가지를 나열하자면
- 디자인 시스템
- 상태 시스템
- 메세징 시스템
- 다국어 시스템
- 커맨드 시스템
- 플러그인 시스템
단순히 UI 라이브러리만 만들면 되는 것이 아니고 저런 것들이 합쳐져야 하나의 어플리케이션을 만들 수 있는 준비가 되었다고 보면 된다.
아쉽게도 리액트의 경우는 그렇게 하지 않았다. 모두 다른 라이브러리에게 넘겨버렸다.
덕분에 항상 고민을 하게 만들게 된다. 기술이 발전하는 만큼 선택지도 많아지지만 그걸 선택하는데 드는 비용도 무시할 수 없게 된 것이다.
그래서 Sapa 는 저걸 하나씩 다 만들어보기로 한다.
온전히 하나만 써도 다되는 all in one 프레임워크가 되는 것이다.
그렇게 해서 만들어진 것이 elf-framework 이다.
elf-framework : https://www.elf-framework.com/
elf framework
elf framework 는 sapa 를 기반으로 위에 나열한 시스템들을 합쳐 놓은 것이다.
최종적으로 Editor 를 잘 만들기 위한 라이브러리화 되었다.
import start from '@elf-framework/sapa';
import { BaseEditor } from '@elf-framework/base-editor';
const MyEditor = () => (
<BaseEditor
configs={{
key: "value",
}}
plugins={[
function (editorContext) {
// ...
}
]}
/>
);
start(MyEditor, {
title: 'My App',
icon: 'my-icon'
})
이름은 BaseEditor 처럼 되어 있지만 실질적으로는 거대한 어플리케이션 만들기 위한 구조라고 보면 된다.
이로써 Sapa 는 sapa 로도 존재하긴 하지만 elf-framework 의 일원이 되었다.
그래서 설치도 npm install @elf-framework/sapa
형태로 해야한다.
현재는 이 것을 기반으로 에디터를 하나 만들어보고 있다.
에필로그
Sapa는 계속 가고 있다.
에디터 또는 어플리케이션 자체를 만들 수 있는 도구로 진화할 것이다.
최종적으로 어디로 갈진 모르겠지만 아직까지는 개발의 끈을 놓진 않았으니 좋은 것 같다.
재미 난 것 하나.
sapa 만들면서 React 를 계속 파다보니
React 를 더 많이 알게 되어서
사실 지금은 React 라이브러리 쓰는게 편하다. ^^