모든 상태를 하나의 방법으로 표현하기: 분자

Aug 15, 2026 / flow

모든 상태를 하나의 방법으로 표현하기: 분자

분자는 제가 이전 회사에서 사용했던 상태 수명 관리 라이브러리예요. 분자 소개로 바로 들어가기 전에, 공유 상태와 수명, 파생 상태 그리고 응집성에 대한 이야기를 먼저 하면서 분자가 어떤 문제를 해결하고 싶었는지부터 짚어 보려고 해요.

공유 상태

상태는 두 가지 축으로 나눠 볼 수 있어요. 첫번째는 소유권이에요. 원본이 서버에 있고 클라이언트는 사본을 캐싱하는 서버 상태와, 프론트엔드가 원본을 직접 소유하는 클라이언트 상태로 나뉘죠. TanStack Query와 함께 널리 퍼진 익숙한 구분이에요. 다른 하나는, 이 글에서 계속 이야기하게 될 수명이에요. 클라이언트 상태는 수명을 기준으로 크게 전역 상태와 지역 상태로 나눌 수 있어요.

서버 상태는 범주로는 클라이언트 상태와 구분되지만, 접근하는 모습만 보면 전역 상태처럼 보여요. 캐시 키만 알면 앱 어디에서든 훅 호출 한 번으로 같은 값을 받아 볼 수 있고, 어디에서 몇 번을 호출하든 같은 캐시를 바라보니 유일성도 자연스럽게 보장되니까요.

React에서 전역 상태를 관리하는 것은 지역 상태만큼 편하지 않기 때문에, 생태계에 수많은 전역 상태 관리 도구가 존재해요. 그런데 정작 전역 상태 관리 도구는 "그렇게까지 필요하지는 않은" 도구로 여겨지곤 해요. 전역처럼 다루는 상태에서 서버 상태를 빼고 나면, 정말 전역이어야 하는 클라이언트 상태는 얼마 남지 않아 보이기 때문이에요.

하지만 지역 상태도 아니고 전역 상태라고 하기도 애매한 상태들이 생각보다 많아요. 여러 컴포넌트가 함께 쓰는 상태, 한 페이지 안에서만 유지되면 되는 상태, 몇몇 페이지에 걸쳐 쓰이는 상태 같은 것들이요.

이런 상태를 한 컴포넌트에서만 사용하는 지역 상태와 구분해서, 이 글에서는 공유 상태라고 부를게요.

지금까지는 지역 상태, 공유 상태, 전역 상태를 표현하는 방법이 제각각이었어요. 지역 상태는 useState로, 공유 상태는 useContext로, 전역 상태는 useContext나 전역 상태 관리 도구로 표현하는 식이죠. 이렇게 상태의 종류에 따라 표현 방법이 달라지면 다음과 같은 문제가 생겨요.

  • 서로 다른 인터페이스를 가지기 때문에, 상태의 종류를 바꾸는 작업이 많은 양의 코드 변경을 수반해요. 지역 상태를 공유 상태로 변경하는 일상적인 작업조차 부담스러운 일이 돼요.
  • 상태의 특성마다 서로 다른 멘탈 모델을 가지기 때문에, 이 상태가 어디까지 공유되는지, 언제 초기화되는지 등을 파악하기 어려워요.
  • 실제 코드에는 전역 상태보다 공유 상태가 훨씬 많다 보니, 전역 상태 관리 도구의 필요성에는 공감하기 어려워요.
  • useContext를 사용하면 각 공유 상태를 사용하는 컴포넌트의 공통 조상을 찾고, 공통 조상에서 값을 채워주는 작업을 매번 수행해야 해요.

수명

공유 상태를 전역 상태 관리 도구로 표현하면, 표현 방법의 문제와 별개로 수명의 문제가 따라와요. 모듈 스코프에 선언한 상태는 앱 전체와 수명을 같이하기 때문이에요.

  • 한 페이지 안에서만 쓰려고 만든 상태인데, 페이지를 떠나도 값이 그대로 남아요. 페이지에 다시 들어왔을 때 이전에 입력한 값이 그대로 보이는, 한 번쯤 겪어 봤을 스테일 상태 버그를 만나게 돼요.
  • 그래서 페이지를 떠날 때 상태를 초기화하는 코드를 직접 챙겨야 해요. 이런 코드는 잊기 쉽고, 잊어도 당장은 티가 나지 않다가 한참 뒤에 버그로 돌아와요.
  • 웹소켓 연결처럼 시작과 끝이 있는 리소스는 더 까다로워요. "처음 쓰는 곳이 생기면 연결하고, 아무도 쓰지 않게 되면 끊는다"를 표현할 방법이 전역 상태 관리 도구에는 마땅치 않아요.

사실 다른 상태들은 이미 저마다의 수명을 갖고 있어요. 지역 상태는 컴포넌트가 마운트될 때 만들어지고 언마운트될 때 사라져요. 서버 상태도 구독하는 곳이 모두 사라지고 gcTime이 지나면 TanStack Query가 캐시를 정리해 줘요. 유독 클라이언트의 공유 상태만 수명 없이 방치되어 있는 거예요.

제가 느끼는 문제점은 클라이언트의 공유 상태도 서버 상태처럼 선언과 사용이 편해야 한다는 것이에요. 어디서든 훅 호출 한 번으로 같은 값에 접근하고, 아무도 쓰지 않게 되면 알아서 정리되어야 해요. 그러기 위해서는 상태에 수명을 정할 수 있어야 해요. 렌더 트리 어딘가에서 처음 참조될 때 시작되고, 참조하는 곳이 모두 사라지면 끝나는 수명. 상태와 리소스가 이 수명을 따라간다면, 초기화와 정리는 직접 챙겨야 하는 일이 아니라 저절로 일어나는 일이 돼요.

파생 상태

프론트엔드에서 작성하는 상태의 대부분은 다른 상태로부터 파생되는 상태예요. 여러 서버 상태를 조합해서 새로운 상태를 만들기도 하고, 서버 상태와 다른 상태들을 함께 조합하기도 하죠. 그래서 상태를 응집성 있게 관리하면서도 목적에 맞게 분리하려면, 파생 상태를 잘 다루는 것이 매우 중요해요.

그런데 훅 단위로 상태를 만들고 관리하는 방식에서는 파생 상태를 관리하기 어려워요.

  • 서버 상태 자체는 라이브러리가 캐싱해 주더라도, 파생 상태의 계산은 기본적으로 훅을 호출하는 곳마다, 렌더가 일어날 때마다 다시 이루어져요.
  • 계산 결과가 객체나 배열이라면 매 렌더마다 새로운 참조로 만들어져요. 이 값을 useEffect의 의존성 배열에 넣으면 내용이 그대로여도 이펙트가 렌더마다 다시 실행돼요. 요청이 반복해서 나가고, 구독이 끊겼다 붙기를 반복하고, 이벤트 안에서 상태를 바꾸고 있었다면 무한 루프로 이어지죠. 이를 막으려면 모든 파생 상태에 useMemo를 챙기는 규율이 필요해요.
  • React Compiler를 도입하면 컴포넌트 안에서의 메모이제이션은 자동으로 해결돼요. 하지만 훅을 호출하는 컴포넌트가 열 개라면 같은 계산이 처음 열 곳에서 각각 이루어진다는 사실은 그대로예요. 앱 내에서 여러번 계산해도 동일한 값이 나온다는 것은 보장되더라도, 계산이 일어난다는 것은 변하지 않아요.

서버 상태와 다른 상태를 함께 조합해서 파생 상태를 만드는 경우에는 문제가 더 복잡해졌어요.

  • 지역/공유 상태의 정의를 파생 상태 훅과 함께 두면, 훅을 호출하는 곳마다 상태가 새로 정의되기 때문에 어디에서도 같은 값을 볼 수 없어요.
  • 결국 파생 상태를 위해 지역/공유 상태를 Context에 올리거나, prop drilling으로 훅의 인자에 넘겨줘야 해요.
  • 서버 상태는 훅 호출 한 번으로 받아올 수 있는데, 지역/공유 상태만 유독 다른 경로로 전달해야 하는 거예요.

정리하면, 파생 상태를 잘 관리하기 위해서는 다음 두 가지가 필요해요.

  • 지역/공유 상태도 서버 상태를 캐시에서 가져오듯 쉽게 가져올 수 있어야 하고, 유일성을 보장하기 쉬워야 해요.
  • 그렇게 만들어진 파생 상태 또한 유일성이 보장되어서, 호출하는 곳마다 불필요한 계산이 반복되지 않아야 해요.

응집성

코드가 잘게 흩어지지 않고 기능 단위로 응집성 있게 관리되는 것은 매우 중요해요. 기능에는 지켜야 하는 제약들이 있고, 제약이 항상 지켜지기 위해서는 기능의 내부/외부 인터페이스를 잘 관리해야 하기 때문이에요.

그런데 기능을 구성하는 요소들이 모두 잘게 쪼개져 있으면, 기능을 사용하는 쪽에서 매번 제약을 스스로 챙기면서 코드를 작성해야 해요.

  • 기능이 노출하는 인터페이스는 제약이 지켜진 입력만 받을 수 있어야 해요.
  • 특정한 입력이 항상 온다는 것이 보장되고 그 입력만 허용해야 하는 상황이라면, 조합의 여지를 열어 두는 것은 큰 의미가 없고 휴먼 에러의 가능성만 키워요.

코드를 읽을 때도 마찬가지예요. 기능 단위로 묶인 코드는 그 안에 담긴 제약까지 함께 이해할 수 있지만, 사용하는 예제를 전부 찾아봐야 알 수 있는 제약은 쉽게 드러나지 않아요.

문제는, 파생 상태에서 이야기했듯 상태를 정의하는 방법의 한계 때문에 기능 단위의 조합을 만드는 것 자체가 매우 어렵다는 거예요.

  • 어떤 상태의 유일성을 보장하려면 불필요하게 인자나 Context로 상태를 받아야 해요.
    • 인자를 뚫으면 불필요한 연결처가 노출되어서, 실제로 어떤 값이 들어오는지 알려면 호출부를 전부 조사해야 해요.
    • Context를 뚫으면 상태를 선언하는 쪽에 Provider를 세우고 주입하는 불필요한 수정이 생겨요.
  • 기능들의 조합으로 새로운 기능을 표현하고 싶어도, 훅마다 인자와 Context 같은 선택이 뒤섞여 있으면 분리된 훅을 조합하는 것도 매우 어려워요.
  • 기능에 필요한 useEffect가 있더라도, effect의 유일성을 보장하기 위해서는 기능 훅과 useEffect 훅이 분리되어 있어야 해요.
    • effect가 가져야 하는 수명도 개발자가 직접 챙겨야 하고, 누락이나 중복의 여지가 있어요.

응집성 있게 기능을 작성하기 위해서는 다음이 필요해요.

  • 더 쪼개지지 않는 단위의 기능을 어떤 상황에서도 쉽게 호출할 수 있어야 해요.
  • 기능들을 쉽게 조합해서 새로운 기능을 표현할 수 있어야 해요.

해결

이제 앞에서 쌓아 온 문제들을 오늘 소개할 Jotai + Bunja 조합으로 하나씩 풀어 볼게요.

파생 상태: 의존성 그래프에 맡기기

파생 상태 문제는 Jotai 같은 의존성 그래프 기반 상태 관리 라이브러리로 해결할 수 있어요. 간단하게 설명하면 상태들 사이의 의존성 그래프를 만드는 거예요.

이렇게 어떤 상태들을 조합해서 어떤 상태를 만드는지 그래프로 표현해 두면, totalAtompriceAtom이나 qtyAtom이 바뀔 때만 다시 계산되고 그 결과는 그래프 안에서 공유돼요. 앞에서 말한 유일성이 자연스럽게 생기는 거죠. 호출부마다 반복되던 계산도, 계산마다 useMemo를 챙기던 규율도 사라져요.

리렌더도 함께 풀려요. 훅이 상태 값 대신 atom을 노출하면, 하나의 훅이 아무리 많은 atom을 노출하더라도 각 컴포넌트는 그중 실제로 구독한 atom이 변할 때만 리렌더돼요.

수명: 쓰이는 동안만 살아 있기

여기서 이런 질문이 나올 수 있어요. Jotai의 atom은 보통 모듈 스코프에 선언하니 결국 모두 전역 상태가 되는 것 아니냐, 앞서 말한 지역/공유 상태와 수명은 어떻게 표현하느냐는 거죠. Provider로 스토어를 나누거나 atomFamily를 쓰는 우회로도 있지만, Provider는 하위 트리의 스토어가 통째로 갈라져서 결이 너무 굵고, atomFamily는 파라미터 전달과 인스턴스 정리를 직접 챙겨야 해요.

이 빈자리를 채우는 것이 수명 관리 라이브러리예요. 이 글에서 소개하는 분자가 그중 하나이고, 분자가 많은 영감을 받은 Bunshi도 같은 역할을 해요. 어느 쪽을 골라도 좋아요. 요지는 수명 관리 라이브러리를 함께 쓰자는 것이니까요.

분자로 정의한 상태는 렌더 트리 어딘가에서 처음 참조될 때 만들어지고, 참조하는 곳이 모두 사라지면 자동으로 정리돼요. 수명 섹션에서 원했던 바로 그 수명이죠. 페이지를 떠나면 상태도 함께 사라지니 스테일 상태 버그와 초기화 코드가 사라져요. 그리고 수명이 시작되고 끝나는 시점에 실행할 이펙트를 등록할 수 있어서, 웹소켓 연결 같은 리소스도 같은 방식으로 표현할 수 있어요.

"처음 쓰는 곳이 생기면 연결하고, 아무도 쓰지 않게 되면 끊는다"가 그대로 코드가 된 거예요.

공유 상태: 하나의 방법으로 표현하기

atom은 자신이 속한 분자의 수명을 따라가요. 그리고 분자의 인스턴스는 스코프로 나뉘어요. 스코프 없이 정의한 분자는 앱 어디에서든 같은 인스턴스를 바라보고(전역), 스코프를 Context로 지정하면 그 범위 안에서만 같은 인스턴스를 바라봐요(공유/지역). 지역 상태, 공유 상태, 전역 상태가 모두 "분자 안의 atom"이라는 하나의 방법으로 표현되는 거예요.

코드에서 보듯 사용하는 쪽은 어떤 종류든 훅 호출 하나로 같아요. 그래서 지역 상태를 공유 상태로 승격하는 일이 사용처를 갈아엎는 일이 아니라 분자의 스코프 선언을 조정하는 일로 줄어들고, "처음 참조될 때 만들어지고 참조가 사라지면 정리된다"는 하나의 멘탈 모델로 모든 상태를 이해할 수 있어요. 지역/공유 상태를 서버 상태 캐시처럼 가져와 파생 상태의 재료로 쓰는 일도 자연스러워지고요. 공유 상태가 비로소 서버 상태와 같은 대우를 받게 되는 거예요.

응집성: 분자 단위로 묶기

모든 상태를 Jotai의 atom으로 표현하면 보통 모듈 단위로 선언하게 되는데, 이렇게 하면 응집성에서 이야기했던 문제가 그대로 생겨요. 대신 기능을 분자 단위로 묶고 그 안의 상태들을 atom으로 표현하면, 분자의 인터페이스만 관리하는 것으로 기능을 응집성 있게 작성할 수 있어요. 분자가 유일성을 스스로 보장하니, 인자나 Context를 뚫을 필요 없이 어디에서든 훅 호출 하나로 기능을 통째로 불러올 수 있고요.

기능의 조합도 자연스러워요. 분자는 다른 분자를 의존할 수 있거든요. 같은 기능에서도 서로 다른 영역의 세부 기능들을 분자로 쪼갠 뒤에, 더 큰 범위의 분자에서 의존하도록 선언할 수 있어요. 스코프가 있는 분자도 자연스럽게 사용할 수 있어요.

이때 수명도 함께 전파돼요. featureBunja를 쓰는 곳이 하나라도 있으면 featureSharedBunja도 살아 있고, 모두 사라지면 구독과 연결이 함께 정리돼요. 기능들을 조합해서 새로운 기능을 표현하자는 요구가 그대로 이루어지는 거예요.