
상태 관리 라이브러리를 볼 때 먼저 눈에 들어오는 것은 보통 기능 목록이다. selector가 있는지, devtools와 연결되는지, persistence를 지원하는지, React 훅이 준비되어 있는지 같은 것들 말이다. 그런데 @ilokesto/store의 Store 클래스를 보면 질문의 순서가 조금 바뀐다. 상태 관리의 출발점은 많은 기능을 제공하는 것이 아니라, 상태를 하나의 값으로 보관하고, 변경 경로를 좁히고, 변경 사실을 필요한 곳에만 알리는 데 있다는 사실이 먼저 보인다.
@ilokesto/store는 거대한 프레임워크가 아니다. React 전용 상태 관리 도구도 아니고, 모든 문제를 한 번에 해결하겠다는 패키지도 아니다. 오히려 반대에 가깝다. 프레임워크 바깥에서도 동작할 수 있는 아주 작은 vanilla store core를 먼저 세우고, React나 Vue 같은 UI 바인딩은 그 위에 얹을 수 있다는 태도를 가진다.
그래서 이 패키지를 읽을 때 흥미로운 지점은 "무엇이 들어 있나"보다 "무엇만 남겼나"에 있다. 현재 상태를 읽는 방법, 다음 상태를 넣는 방법, 변경을 구독하는 방법, 구독을 해제하는 방법. 이 네 가지가 분명하면 상태 관리의 가장 작은 뼈대는 이미 만들어진다.
현재 스냅샷 + 단일 변경 경로 + 변경 구독 + 구독 해제 = 작은 store core
이번 글에서는 @ilokesto/store 패키지가 제공하는 Store 클래스를 하나의 작은 설계 사례로 읽어보려 한다. 초점은 "이 라이브러리를 써야 한다"가 아니라, 작은 상태 컨테이너가 어떤 계약을 분명히 해야 오래 버틸 수 있는지에 있다.
import { Store } from "@ilokesto/store";
type CounterState = {
count: number;
};
const counterStore = new Store<CounterState>({ count: 0 });
const unsubscribe = counterStore.subscribe(() => {
console.log("changed:", counterStore.getState());
});
counterStore.setState((prev) => ({
...prev,
count: prev.count + 1,
}));
unsubscribe();이 예시에서 API는 거의 설명을 요구하지 않는다. getState()로 읽고, setState()로 바꾸고, subscribe()로 변경을 듣고, 반환된 함수로 구독을 끊는다. 작은 API가 좋은 이유는 이처럼 사용자의 머릿속에 모델을 빨리 만든다는 데 있다.
Store는 상태를 객체가 아니라 스냅샷으로 다룬다
이 Store를 이해할 때 가장 먼저 잡아야 하는 감각은 상태를 mutable object가 아니라 immutable snapshot처럼 다룬다는 점이다. 상태가 객체나 배열일 수는 있지만, store가 기대하는 변경 방식은 "기존 객체를 안에서 조금 고치는 것"이 아니라 "다음 상태를 새 값으로 교체하는 것"에 가깝다.
type TodoState = {
items: string[];
updatedAt: number;
};
const todoStore = new Store<TodoState>({
items: [],
updatedAt: Date.now(),
});
todoStore.setState((prev) => ({
...prev,
items: [...prev.items, "write release note"],
updatedAt: Date.now(),
}));이런 업데이트는 store 입장에서 다루기 쉽다. 이전 스냅샷이 있고, 다음 스냅샷이 있다. 두 값이 같으면 굳이 알릴 필요가 없고, 다르면 구독자에게 변경을 알리면 된다. 복잡한 deep diff나 필드 단위 추적 없이도 store의 계약이 단순해진다.
반대로 아래와 같은 코드는 이 모델과 잘 맞지 않는다.
todoStore.setState((prev) => {
prev.items.push("write release note");
return prev;
});이 코드는 같은 객체를 직접 변경한 뒤 같은 참조를 돌려준다. 실제 구현이 Object.is를 기준으로 이전 상태와 다음 상태가 같은지 확인한다면, store는 이 변경을 "새 상태가 들어왔다"고 보기 어렵다. 값의 내부는 바뀌었지만 스냅샷의 경계는 바뀌지 않았기 때문이다.
이 제한은 단점이라기보다 정직한 계약에 가깝다. 작은 store가 모든 mutation 스타일을 받아들이려고 하면 내부 구현이 복잡해지고, 사용자도 어떤 업데이트가 안전한지 헷갈리기 쉽다. 차라리 "새 스냅샷을 만들어 넣으세요"라고 말하는 편이 훨씬 오래 간다.
setState()는 값과 updater 함수를 모두 받아들인다
작은 API라고 해서 표현력이 낮아야 하는 것은 아니다. Store의 setState()는 다음 상태 값을 직접 받을 수도 있고, 이전 상태를 받아 다음 상태를 계산하는 updater 함수를 받을 수도 있다.
type SessionState = {
loggedIn: boolean;
retries: number;
};
const sessionStore = new Store<SessionState>({
loggedIn: false,
retries: 0,
});
sessionStore.setState({
loggedIn: true,
retries: 0,
});
sessionStore.setState((prev) => ({
...prev,
retries: prev.retries + 1,
}));값을 직접 넣는 방식은 상태 전체를 명확하게 교체할 때 좋다. updater 함수는 현재 상태를 기준으로 다음 상태를 계산할 때 좋다. 특히 카운터 증가, 재시도 횟수 누적, 목록 추가처럼 이전 값에 의존하는 업데이트는 updater 형태가 훨씬 안전하다.
중요한 것은 변경의 입구가 하나라는 점이다. 외부 코드는 store 내부 필드를 직접 만지지 않는다. 모두 setState()를 지나간다. 이 덕분에 store는 같은 값인지 확인하고, 필요할 때만 notify하고, 구독자에게 일관된 방식으로 변경을 전달할 수 있다.
다만 이 선택에도 경계는 있다. 함수가 들어오면 updater로 해석되기 때문에, 상태 값 자체가 함수인 형태에는 어울리지 않는다. 작은 코어에서는 이런 제약을 숨기지 않는 편이 좋다. 지원하지 않는 형태를 무리하게 포용하기보다, API가 어떤 사용 방식을 기대하는지 분명히 말하는 것이 더 낫다.
initialState는 reset을 특별한 기능이 아니라 조합으로 만든다
Store가 현재 상태만 들고 있었다면 API는 더 작아질 수 있었을 것이다. 하지만 이 구현은 생성 시점의 값을 initialState로 따로 보관하고, getInitialState()로 다시 읽을 수 있게 한다. 이 선택은 생각보다 실용적이다. 초기 상태를 기억한다는 것은 reset을 별도 기능으로 만들지 않아도 된다는 뜻이기 때문이다.
type FormState = {
name: string;
email: string;
agreed: boolean;
};
const formStore = new Store<FormState>({
name: "",
email: "",
agreed: false,
});
formStore.setState({
name: "Ayden",
email: "ayden@example.com",
agreed: true,
});
formStore.setState(formStore.getInitialState());여기서 reset은 새로운 메서드가 아니다. 이미 있는 getInitialState()와 setState()의 조합이다. 이런 API가 오래 버티는 이유는 흔한 요구를 새로운 개념 없이 해결하기 때문이다. 기능을 하나 더 추가하지 않고도 사용자가 자연스럽게 원하는 일을 할 수 있다.
서버 렌더링이나 외부 상태 어댑터를 생각해도 초기 스냅샷은 좋은 기준점이 된다. 예를 들어 React의 useSyncExternalStore는 서버에서 사용할 snapshot을 따로 요구한다. 이때 store가 생성 시점의 값을 알고 있다면, 서버와 클라이언트의 첫 렌더링 기준을 맞추는 출발점으로 사용할 수 있다.
useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.getState(),
() => store.getInitialState(),
);물론 모든 상태가 초기값만으로 서버 snapshot을 설명할 수 있는 것은 아니다. 브라우저 width, media query, localStorage처럼 서버에서 알 수 없는 값은 별도의 기본값이 필요하다. 그래도 store가 초기 상태를 명시적으로 보관한다는 것은 어댑터를 만들 때 사용할 수 있는 기준을 하나 더 제공한다.
구독 모델은 짧지만 위험한 경계를 알고 있다
상태 관리에서 subscribe()는 단순해 보인다. listener를 등록하고, 상태가 바뀌면 호출하면 된다. 하지만 실제로는 미묘한 문제가 많다. 같은 listener가 여러 번 등록되면 어떻게 할지, 알림 중에 구독이 해제되면 순회가 깨지지 않는지, cleanup은 누가 책임지는지 같은 질문이 바로 나온다.
이 Store는 listener를 Set으로 관리한다. 이 선택은 작지만 의미가 있다. Set은 같은 listener가 중복으로 등록되는 것을 자연스럽게 막아주고, listener를 제거하는 의미도 명확하다.
또 하나 좋은 점은 notify 시점에 listener 목록을 스냅샷으로 만들어 순회한다는 것이다. 알림 도중 구독자가 자기 자신이나 다른 listener를 해제하더라도, 현재 알림 루프는 안정적으로 끝낼 수 있다.
type CounterState = { count: number };
const store = new Store<CounterState>({ count: 0 });
const unsubscribeB = store.subscribe(() => {
console.log("listener B");
});
store.subscribe(() => {
console.log("listener A: unsubscribe B");
unsubscribeB();
});
store.setState({ count: 1 });
store.setState({ count: 2 });첫 번째 변경에서 A가 B를 해제하더라도, 알림 루프 자체가 무너질 필요는 없다. 다음 변경부터는 B가 더 이상 호출되지 않으면 된다. 이런 세부 사항은 코드 줄 수로는 작지만, 실제 사용성에는 큰 영향을 준다.
작은 구현이 좋은 이유는 단지 짧아서가 아니다. 짧은 코드 안에서도 어디가 위험한 경계인지 알고 있기 때문이다. 구독 컬렉션을 어떻게 보관하고, 어떤 시점의 목록을 순회할지 결정하는 일은 상태 관리 코어에서 결코 사소하지 않다.
unsubscribe는 lifecycle을 외부로 넘기는 가장 작은 방법이다
subscribe()가 unsubscribe 함수를 반환하는 패턴은 익숙하지만 여전히 훌륭하다. 별도의 id나 token을 만들지 않아도 되고, 구독을 등록한 쪽이 그 구독을 정리할 책임을 자연스럽게 갖게 된다.
const store = new Store({ online: false });
const unsubscribe = store.subscribe(() => {
console.log("online state changed:", store.getState());
});
store.setState({ online: true });
unsubscribe();
store.setState({ online: false });이 패턴은 프레임워크 어댑터와도 잘 맞는다. React의 useEffect는 cleanup 함수를 반환할 수 있고, Vue의 onScopeDispose는 scope가 사라질 때 정리 함수를 호출할 수 있으며, Svelte readable store도 start 함수에서 stop 함수를 반환한다. 외부 상태를 UI에 연결할 때 결국 필요한 것은 같은 흐름이다.
구독을 만들고, 변경을 반영하고, 더 이상 필요 없을 때 구독을 끊는다.
@ilokesto/store의 subscribe()는 이 흐름을 과하게 추상화하지 않는다. listener를 넣으면 해제 함수를 돌려준다. 그래서 다른 프레임워크나 런타임으로 옮겨도 어댑터가 얇게 유지된다.
없는 기능이 오히려 경계를 분명하게 만든다
이 패키지에는 없는 것이 많다. selector도 없고, equality helper도 없고, persistence도 없고, devtools 연결도 없다. 처음에는 이것이 부족함처럼 보일 수 있다. 하지만 코어 라이브러리의 관점에서는 오히려 장점이 되기도 한다.
작은 core는 책임을 좁힌다. 상태를 저장한다. 상태를 교체한다. 변경을 알린다. 초기 상태를 기억한다. 여기까지가 core의 책임이라면, React hook, Vue composable, localStorage persistence, logger, devtools bridge 같은 것은 별도 계층으로 나눌 수 있다.
이 분리는 라이브러리 설계에서 중요하다. 코어가 모든 프레임워크를 직접 알아야 하면 패키지는 빠르게 무거워진다. 반대로 코어가 아주 작은 계약만 제공하면, 각 프레임워크 어댑터는 그 계약을 자기 반응성 모델에 맞게 번역하면 된다.
function toReadableStore<T>(store: Store<T>) {
return readable(store.getState(), (set) => {
const unsubscribe = store.subscribe(() => {
set(store.getState());
});
return unsubscribe;
});
}이런 어댑터가 얇게 유지되는 이유는 store의 core contract가 이미 충분히 작고 명확하기 때문이다. 현재 값을 읽을 수 있고, 변경을 들을 수 있고, 구독을 해제할 수 있다. 그 이상은 어댑터가 필요한 만큼만 덧붙이면 된다.
작은 Store가 먼저 가르쳐주는 것
@ilokesto/store 패키지의 Store 클래스는 화려한 상태 관리 경험을 제공하는 라이브러리라기보다, 상태 관리의 최소 단위를 잘 보여주는 예제에 가깝다. 그리고 바로 그 점 때문에 배울 것이 있다.
상태 관리의 첫 질문은 "어떤 기능이 있나"가 아니다. 먼저 물어야 할 것은 다음과 같다.
- 상태는 어디에 저장되는가?
- 상태는 어떤 경로로만 바뀌는가?
- 같은 상태를 다시 넣었을 때는 어떻게 되는가?
- 변경 사실은 누구에게, 어떤 방식으로 전달되는가?
- 구독은 누가, 언제 정리하는가?
이 질문에 답할 수 있으면 작은 store는 이미 자기 역할을 하고 있다. 그 위에 selector를 얹을 수도 있고, persistence를 붙일 수도 있고, React나 Vue 어댑터를 만들 수도 있다. 하지만 그 모든 확장은 먼저 작은 core가 안정적으로 서 있을 때 의미가 있다.
마지막으로 이 Store 클래스가 남기는 감각은 단순하다. 좋은 코어는 많은 일을 하지 않는다. 대신 꼭 해야 하는 일을 예측 가능하게 한다. 상태를 하나의 스냅샷으로 다루고, 변경 경로를 좁히고, 구독 lifecycle을 분명히 하는 것. 이 정도만으로도 상태 관리 라이브러리의 출발점은 충분히 단단해진다.
좋은 작은 패키지는 읽는 사람에게 "기능이 적다"는 인상보다 "경계가 선명하다"는 인상을 남긴다. 적어도 나는 그래야 한다고 믿는 편이다.
더 읽어보기
2026.04.23
너만의 월남쌈을 싸
이 포스트의 제목은 유튜브 영상 <너만의 월남쌈을 싸 - 아이네 INE>에서 따왔다. 4월 초부터 @ilokesto 네임스페이스에 속한 거의 모든 라이브러리를 리뉴얼 하고 있다. 이미 deprecated 처리는 끝났고, 새로운 기준 위에서 라이브러리의 구조를 재정의하는 단계를 거치고 있…
2026.05.10
프레임워크 밖의 상태를 UI 안으로 들이는 법
외부 상태는 프레임워크 내부에서 만들어진 상태가 아니다. React의 useState, Vue의 ref, Svelte의 $state, Solid의 createSignal처럼 프레임워크가 직접 소유하고 추적하는 값이 아니라, 프레임워크 바깥에서 먼저 존재하고 바깥에서 변하는 값이다. We…
2026.05.10
Scope & Disposal — 인스턴스의 생애주기와 정리
이전 포스트를 통해 우리는 인스턴스가 어떻게 만들어지는지 살펴봤다. 이번 파트는 그 반대 방향, 인스턴스가 어떻게 살고 어떻게 사라지는가를 다룬다. fluo DI의 세 가지 스코프(singleton, request, transient)의 생애주기와 dispose()의 역순 정리 메커니즘…
2026.05.10
Instance Creation — resolve에서 new까지
이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막…
2026.05.09
TanStack Router 실무 가이드, 파일 기반 라우팅과 타입 안전하게 React 구조 잡기
React 애플리케이션이 커질수록 라우팅은 단순한 화면 전환 문제가 아니라, URL 설계, 데이터 로딩, 권한 처리, 상태 동기화까지 함께 다뤄야 하는 구조의 문제가 된다. TanStack Router는 이 지점을 정면으로 다룬다. 파일 기반 라우팅을 중심에 두고, URL 파라미터와 검…
댓글
댓글을 불러오는 중...