脱 React, Solid.js를 써보다.

Junyoung Choi·2024년 3월 3일
26
post-thumbnail

최근 Solid.js를 써보았다. React와 비슷하지만, 조금은 다른, 좀 더 강력함을 느꼈다.

Solid는 뭐야?

React와 같은 프론트엔드 프레임워크이다. 2021년 정식 출시된 새로우면서도, 안정적인 툴이다.

기본적으로 선언적으로 쓸 수 있는 점도 동일하고, JSX를 쓴다.

function MyComponent(props) {
  return <div>Hello {props.name}</div>;
}

<MyComponent name="Solid" />;

상태 관리는 조금 다르다. 리액트의 상태 훅처럼 읽기, 쓰기가 나뉘어져있지만, Reactivity(반응성)을 가진 Signal이란 녀석이 존재하여, 이게 은근 커다란 차이를 만들어낸다.

// React
const [status, setStatus] = useState()
const processStatus = useCallback(() => {
	doSomethingWith(status)
}, [status])

// Solid
const [status, setStatus] = createSignal()

// 의존성따위 필요없다.
const processStatus = () => {
  doSomethingWith(status())
}

// 이펙트 훅도 이하 동문.
createEffect(() => {
  doSomethingWith(status())
})

의존성이 없는데 어떻게 값이 바뀐 걸인지하냐? 어떻게 콜백을 갱신하냐? 라고 의문이 생길 건데, Solid의 Signal은 순수한 오브젝트가 아니라 Proxy로 만들어진 특수한 오브젝트다. Proxy는 오브젝트의 Getter와 Setter를 하이잭킹해서 오브젝트의 속성 값을 읽어질 때와 값이 변경될 때, Effect를 넣어 추가로 일을 해준다.
고로, 당신이 특정 상태를 쓰기만 한다면, Signal은 Effect를 작동시켜 이 상태값이 어디에 쓰였는지를 정확히 기억해, 그 값이 변경시 쓰였던 컴포넌트만 정확히 업데이트를 해준다.

그래서 왜 脱React?

성능

옛날 한참 리액트를 쓰던 시절이라면 쓰지 않았을 것이다. 당시엔 Proxy가 제대로 구현되지 않았다.
하지만 지금은 다르다. Proxy는 대부분의 메이저 브라우저에 이미 구현되었고, 안정적이다. 성능 또한 문제 없다. Vue.js도 Proxy를 쓰고있고, 당연히 성능상 더 뛰어나다.

React는 왜 뒤쳐지는가?

VDOM을 쓰기 때문이다. 리액트가 출시한 당시엔 VDOM을 쓰는 게 더 빠를 수도 있었다. 실제 HTML Element에 변형을 가하기 전에, VDOM으로 변경이 필요한지를 확인하고, 데이터 변경이 있는 경우에만 HTML Element를 조작한다. 지금과 비교하면 React출시 당시의 옛날 브라우저는 여전히 구닥다리라, 렌더링에 대한 코스트가 상대적으로 컸다. 이에 Javascript에서 모든 걸 다 준비하고 적용하는 게 더 빠를 수 있었다. 동시에, 당시엔 ES5가 제대로 도입되기도 전이라 브라우저별로 API의 파편화가 여전히 큰 시절이었다. 이에 VDOM의 도입은 개발자가 이런 파편화에 신경쓰지 않도록 해주었다.
하지만, 지금은 상황이 다르다. 지금의 브라우저는 렌더링도 최적화되었고, 파편화도 거의 없어졌다. 이젠 가짜 트리를 따로 만들어두고, 변경 값을 비교한 다음에서야 DOM을 조작한다는 건, 메모리적인 측면에서도, 컴퓨팅적인 비효율적인 방법이 되고 말았다.
하지만 Solid.js는 Signal을 통해 돔을 직접 조작한다. Proxy를 통해 값이 변경되면, 해당 Proxy와 연결된 DOM을 직접 조작해버린다.
React는 옛날 전화처럼 교환원을 둬서 관리하지만, Solid는 직통 핫라인을 깔아버린다.

React의 방향성에 대한 실망

Next.js가 뜬 이후로 리액트의 방향성은 이상하게 넘어간다. Meta가 메타버스로 삽질하는 동안, 오픈소스 관련 동력이 줄어드는 와중에 점차 리액트 개발팀에 Vercel의 입김이 커져가는 느낌이다.
이에 자연스래 서버사이드로의 비중이 더욱 커지게 되며, 본연의 역할은 뒤쳐지게 된다.
렌더링 성능도 떨어지고, DX도 나쁜데, React 팀은 이쪽엔 관심이 없다.
물론 엄청난 트래픽이 발생하는 기업들(Vercel, Meta 등) 입장에서는 이런 개선으로 인한 수익에 흥미를 가질지도 모르겠다. 하지만 빠른 앱 로딩이 정말 당신의 고객들에게 문제가 되나?


https://twitter.com/tomus_sherman/status/1681355056950525963

이제 5G가 나오고 다운로드 속도만 20gb/s이다.
겨우 킬로바이트 따위가 정말 유저를 정말 구속하는가?
한국(필자는 일본에 거주)의 유저에게 정말 60kb짜리 컨텐츠가 8kb가 됐다고 성능이 체감이 되는가?
대부분의 경우, 이를 위해 계속해서 망가지는 Next.js의 AppRouter등을 붙들며, 매번 다음 버전이 나올때마다 마이그레이션으로 1~2주를 통째로 날려버리는 개발팀 인건비가 서버 트래픽으로 인해 발생하는 비용보다 훨씬 클 것이다.

물론 땅덩어리가 넓어서 전파도 잘 안터지고, 인터넷 선도 제대로 안깔린 미국이나, 전세계의 인터넷 인프라가 부실한 국가에서 서비스를 제공하는 서비스면 모르겠다.
근데 당신 서비스는 어떤가? 당신의 고객들은 정말 유튜브도 못 볼 정도로 인프라가 열악한 환경에 있는가?

게다가 Next.js는 아예 서버사이드 렌더링 단에서 Node.js에 통쨰로 액세스하도록 코드를 짜게하고 있다. 이에 걱정되는 건 보안 문제다. 서버단에서만 쓰여야 할 코드가 클라이언트 단에서 쓰였을때.
Next.js App Router를 보면 출시부터 버그 투성이었는데, 과연 여기서 문제가 일어나지 않을 거란 보장을 할 수 있는가? 혹시나 Next.js가 완벽하다고 치자. 그럼 그걸 사용하는 개발자는 실수하지 않을 수 있는가? 소규모 개발팀, 1인 개발자라면 어떻게 막을 수 있겠지, 하지만 CTO인 입장에서 생각하면 끔찍하다. 수십, 수백, 수천의 개발자 중 이들이 실수를 하지 않을 거라는 보장을 어떻게 할 수 있나? 현실적으로 불가능하다. 언젠가는 ESLint와, SAST 툴(Static Application Security Testing, 소스 코드 보안 분석툴, Github Code QL, Snyk Code, Semgrep 등이 유명)이 언젠가는 이런 문제를 잘 눌러줄 테지만, 그건 미래의 얘기고, 이런 툴이 있다한들, 당신을 도와줄 수는 있지, 완벽한 보안을 보장해주지는 않는다. 결국 안정성이 확보되기까지(Battle Tested) 앞으로 수년은 더 걸린다.

덜 성숙한 테크스택 사용으로 인해 발생하는 마이그레이션 비용과 잠재적인 보안 문제를, 과연 당신은 겨우 몇kb 페이로드 줄이는 이득을 위해 정당화 시킬 수 있는가?

개발 경험

React Hook의 출시는 정말 대단했다. 하지만 수년간 쓰면서 Exhausted Dependencies문제는 정말 개발 경험상 최악이었다. 이벤트 핸들러등은 항상 메모이즈 훅, useCallback을 쓰지 않으면 렌더링 퍼포먼스는 나락으로 가고, 만약 핸들러를 깊게 내려줄 경우, 디버깅 경험은 정말 최악으로 치닫는다.
그래서 여기에 무슨 개선이 있었는가?
외부에선 Immer, MobX, Preact Signal 등 다양한 시도가 이뤄졌지만, 앞서 말한듯 현재 리액트팀은 이런 방향엔 흥미가 없다. 서버사이드 렌더링에만 집중하고, 본연의 목적인 클라이언트 렌더링의 개선은 요원하다. 물론 최근 글에선 React Compiler를 개발하여 훅 디펜던시 등의 문제를 해결하려는 움직임이 보이지만... 그래서... 당신은 지금 개발 환경을 언젠가 React Compiler로 다시 갈아엎을 준비가 되어있나?
https://react.dev/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024

Signal에는 이런 문제가 없다. 맨 처음에 소개한 것처럼 의존성을 수동으로 넣을 필요가 전혀 없다. 모든 건 당신이 작성한 코드대로 알아서 이루어진다.
게다가 Signal은 React의 Hook과는 달리 딱히 규칙이 없다. 글로벌에 시그널을 만들면 글로벌 스테이트가 되어버린다. 리액트처럼 컨텍스트 만들고 프로바이더를 제공할 필요따윈 없다.
더욱이, 조건문 안에선 만들면 안되는 React Hook의 룰이, Signal에선 아무런 상관이 없다. 조건문 안에 만들든, 렌더마다 몇개의 시그널이 만들어지든 알아서 정리해준다.
심지어 콜백 안에서도 Signal을 만들어도, 아무런 문제없이 작동하고, 깔끔하게 정리해준다.

// 훅은 함수 컴포넌트 안에서만 써야하지만, 시그널은 그럴 필요가 없다.
const [globalState, setGlobalState] = createSignal()

const MyComp = () => {

  if (...) {
  // 조건문 안에 시그널을 만들어도 아무런 문제 없다. 리액트 훅은 불가능하다.
    const [todos, setTodos] = createSignal([])

	const addTodo = (text) => {
	  // 심지어 콜백안에서 시그널을 만들어 다른 Signal의 상태에 넣어줘도 문제없이 작동한다!(Nested Signal)
      const [done, setDone] = createSignal()
		
	  setTodos([...todos(), {id: uuid(), text, done, setDone}])
	}

	const toggleTodo = (id) => {
	  const todo = todos().find(todo => todo.id === id)
	  if (todo != null) {
	    // Nested Signal은 상태와 함께 그대로 가져다 쓸 수 있다.
		todo.setDone(!todo.done())
	  }
	}
	return (...)
  }

  return (...)
}

Solid는 API 설계부터 개발자들이 실수할 여지를 주지 않지만,
React Hook은 구닥다리 API 설계에 개발자들이 실수하지 않도록 규칙을 강제한다.
https://react.dev/warnings/invalid-hook-call-warning

동시에 배열을 렌더링할때 필요한 key도 솔리드엔 전혀 필요없다.
React의 경우, 오브젝트의 속성 값이 변경되면 항상 새로운 오브젝트를 만들어야 하는지라, 이게 정확히 어디서 엘러먼트에서 변경이 됐는지를 알려주기 위해 key를 넣어줘야 했지만,
Solid는 이게 필요가 없다. key값은 그냥 그 오브젝트의 레퍼런스로, 해당 오브젝트의 속성 값이 바뀌면, 해당 속성값이 쓰인 항목만 바로 바꿔버려서, 아예 배열을 다시 이터레이션 할 필요조차 없다.

// React
// item 중 하나가 바꾸려면 list도 바뀌어야하기에 map이 다시 실행된다.
return <>{list.map(item => <div key={item.id}>{item.name}</div>)}</>
// Solid
// 어느 항목이 변경시 해당 <div>를 직접 조작하기 때문에 map 메소드가 작동할 일이 없다.
// 이러니 개발 경험과 성능 모두 압도적일 수 밖에 없다.
return <>{list().map(item => <div>{item.name}</div>)}</>

게다가 렌더링단에 세세한 컨트롤을 제공해준다.
Hook 사용시, 다른 라이브러리로 만들어진 컴포넌트(Wyswyg 에디터 등)를 제어할 경우, 고유의 라이프 사이클로 인해 State는 못 쓰고, Ref등으로 세세한 컨트롤을 해야하는 경우가 생기는데, Solid는 언제든 이런 통제를 개발자가 가져갈 수 있게 해준다.
untrack 함수는 스테이터스가 바뀌어도 렌더링을 일어나지 않게 해주고,
batch 함수는 복수의 스테이터스 변경이 어디까지 동시에 일어나야하는지 세세한 컨트롤이 가능하다. (옛날 리액트는 setState마다 렌더링을 했고, 지금은 setState를 언제나 몰아서 업데이트 하기에 Effect 훅이 매번 정확히 작동되지 않는 경우가 생긴다.)
on 함수는 Effect가 사용중인 모든 Getter 변경시 실행되는 게 아니라 특정 값만 변경시 실행할 수 있도록(React Effect 훅의 의존성처럼) 세세한 컨트롤이 가능하게 해준다.
게다가 Redux등 Reactivity가 없는 제3 상태관리 라이브러리를 쓰는 사람들을 위해, reconcile 등의 기능도 제공한다.(Redux을 헤비하게 쓰면 쉽게 마이그레이션이 가능할 수도 있다.)

거기에 실제 개발에 필요한 헬퍼나 모듈등도 직접 개발하는지라 이곳저곳에서 세심함이 느껴진다.
createResource는 HTTP Request등의 비동기적인 방식으로 데이터를 가져오고 갱신하는데 필요한 스테이트와 메소드를 미리 제공하여 매번 프로젝트마다 새롭게 삽질해야할 여지를 상당히 줄여주고,
Solid의 공식 라우터 모듈은 React Router나 Next.js처럼 <Link>를 임포트할 필요가 없다. 그냥 <a>를 쓰면 바로 작동하게 해준다.

트레이드오프

코드의 차이

단, Solid.js를 도입하기엔 허들이 조금 있다. Reactivity를 적극적으로 사용하는 만큼, JSX로 컴포넌트를 만듬에도, React와 코드 호환성이 떨어진다.

이 경우엔 Preact를 써도 되지만 Preact는 리액트와의 호환성을 유지하는 걸 목표로 두고 있어서 Solid보다는 다소 성능이 떨어지게 된다.

Reactivity를 위해 스테이터스는 모두 Signal로 관리되므로, 값을 불러오려면 Getter함수를 써서 불러와야한다.

// React
const [msg, setMsg] = useState('Hello')

handleMsg(msg)

return (
  <div>{msg}</div>
)

// Solid.js
const [msg, setMsg] = createSignal('Hello')

// msg는 Signal의 getter이므로 실행을 해야 값을 얻을 수 있다.
handleMsg(msg())

// 단, JSX Element에 넘겨주는 곳은 그냥 Getter함수 통쨰로 넘겨줘도 된다. Solid가 렌더링 시에 알아서 Signal인지 파악해서 값을 가져와준다.
return (
  <div>{msg}</div>
  // 물론, getter를 쓰는 것도 역시 올바르게 동작한다.
  // <div>{msg()}</div>
)

또한 프로퍼티 스플리팅, 머징에 제약이 생긴다.
부모 컴포넌트로 받는 props는 더 이상 순수한 오브젝트가 아니게 된다. Reactivity를 위해 Proxy를 쓰고 있기에, ({ msg }) => ...로 오브젝트 인수의 값을 쪼개거나, {...someObj, msg}처럼 머징을 하면, Proxy는 더 이상 해당 값에 대해 Reacitivity를 보장해주지 않는다.

// React에선 쪼개도 되지만,
const MyComponent = ({ msg }) => { handleMsg(msg) }
// Solid에선 쪼개면 안 된다.
const MyComponent = (props) => { handleMsg(props.msg)}

물론, 이를 해결하기 위해 Solid는 splitProps, mergeProps라는 메소드를 제공하지만, 함수를 불러와야하므로 조금 불편해진다.

그리고 같은 이유로 JSX의 children 활용에도 약간의 제약이 생긴다.

// Children을 바로 다른 컴포넌트에 넘겨주거나, 렌더링 시킨다면 신경 쓸 필요 없다.
const Comp = (props) => {
  return (
    <div>{props.children}</div>
  )
}

import { children } from 'solid-js'

const FilteredList = (props) => {
// 하지만 props.children이 여러 곳에서 동시에 쓰일 경우, 메모이즈 헬퍼인 children으로 한번 더 감싸줄 필요가 생긴다.
// 단, 하나 이상의 Effect가 props.children를 사용한다거나, 렌더링을 여러번 할 경우에만 필요가 있기에 대부분의 경우는 무시해도 된다.
  const c = children(() => props.children)
  
  createEffect(() => c())
  
  return <div>{c()}</div>
}

생태계

이외엔 정식 버전이 2021년 출시된지라 생태계 구축이 조금 덜 됐단 점 정도?

가령 React엔 Next.js가 있지만, Solid는 아직 이에 대응하는 Solid Start가 베타 상태이다.
하지만, Next.js 따위 없어도 생각보다 SSR 구현은 딱히 어렵지 않다... Next.js가 조금 더 편한 건 맞지만, 겁쟁이가 될 필요는 없다! SSR 구현에 실제 당신이 할 필요가 있는건 그저, 번들 파일들 호스팅해줄 Static 파일 라우트와, 페이지별로 번들해서 뱉어주는 renderToString를 쓰는 라우트만 만들면 된다. 이후 앱의 기능이 갖춰지고 나면, 성능 개선을 위해 일부 무거운 페이지나 컴포넌트들을 Dynamic import로 빼면 된다. 별다른 마법이 필요한 게 아니다.

import express from "express";
import url from "url";

import { renderToString } from "solid-js/web";
import App from "../shared/src/components/App";

const app = express();
const port = 8080;

// 번들 호스팅하기
app.use(express.static(url.fileURLToPath(new URL("../public", import.meta.url))));

app.get("*", (req, res) => {
  let html;
  try {
    // 그냥 App을 렌더링하여 리스폰스로 넘겨주면 끝.
    html = renderToString(() => <App url={req.url} />);
  } catch (err) {
    console.error(err);
  } finally {
    res.send(html);
  }
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

https://github.com/solidjs/solid/blob/main/packages/solid-ssr/examples/ssr/index.js


여튼 이상의 이유로, 리액트와는 당분간 거리를 둘 생각이다.
난 대머리가 되고 싶지 않다.

(Japanese, 日本語) https://zenn.dev/rokt33r/articles/b8e5051c000dbc

profile
CTO of IssueHunt, Living in Tokyo

8개의 댓글

comment-user-thumbnail
2024년 3월 4일

이런 뛰어난 라이브러리가 있음에도 귀차니즘 때문에 저는 아직도 React를 고집하게 되네요... 언젠간 탈 리액트를 하는 날이 오면 Solid.js 고려해 보겠습니다! 좋은 글 감사드립니다 👍

답글 달기
comment-user-thumbnail
2024년 3월 6일

흥미롭군요. 한 방법에만 멈춰있는 건 시대에 도태할 수 있죠.
ReactJS에 메타보다 vercel이 개입한다는 말에 공감합니다.

답글 달기
comment-user-thumbnail
2024년 3월 7일

동지여...

근데 정작 실무에서도 여느 메신저로 이동하려 해도 결국엔 카톡으로 가는 이유가 있는 현실이 개탄스럽습니다.

답글 달기
comment-user-thumbnail
2024년 3월 8일

감사합니다. 흥미로운 글 잘 보고 갑니다!

답글 달기
comment-user-thumbnail
2024년 3월 14일

React 프로젝트를 충분히 성공해본 그룹에서, Solid 검토를 생각해보는게 좋을 거 같아요. 결국 모든건 비지니스 이해관계가 없으면 운영하기 어려우니까요! 다만, Vue 나 Svelte 에 비해 React 와 비슷한 부분이 많아서 도입하는데 큰 어려움은 없어보이네요.

답글 달기
comment-user-thumbnail
2024년 3월 15일

귀하의 기사에 감사드립니다. 이해가 더 잘 되었습니다. Solid.js 를 paper minecraft사용해 보겠습니다.

답글 달기
comment-user-thumbnail
2024년 3월 16일

준영님 글로 오랜만에 뵙네요. ㅎㅎ Solid가 실사용이 너무 적은 점은 아쉽지만 흥미로운 점이 많아서 늘 주목하고 있습니다. Vue에도 Solid처럼 동작하는 Vapor를 개발하고 있는데요. 아무래도 JSX나 Suspense같은 기능 등에서, React를 쓰던 사람들 입장에서는 Solid만큼의 기능 완결성을 보여주진 못하는 점이 아쉽습니다.

1개의 답글