상태 관리 라이브러리 만들기
@superlucky84|September 14, 2025 (3w ago)321 views

- 목차
- 개요
- 시그널 패턴
- Lens 패턴
- Lens 사용법
- 시그널 패턴과 Lens 패턴의 결합
- 구독 함수 등록
- 묶여 있는 참조
- 안 묶여 있는 참조
- 묶여 있는 Outer 참조
- 구독 취소
- 맺으며
개요
- 취미로 평소 궁금했던 개념들을 이용해 라이브러리를 만들고, 그 과정에서 공부한 것들을 공유했었는데요.
이미 다른 시리즈가 있습니다.. 나만의 커스텀 React 제작기, 나만의 SSR 프레임웍 만들기
이번에는 상태 관리 라이브러리(state-ref
)를 만든 제작기를 공유합니다.
상태 관리 라이브러리를 구현하기 위해 몇 가지 개념을 알아야 했는데요, 바로 시그널 패턴과 함수형 프로그래밍에서 사용되는 Lens
입니다.
시그널 패턴
사실 state-ref
를 만든 가장 큰 동기 중 하나는 시그널 패턴을 응용해 유용한 것을 만들어 보고 싶었기 때문입니다.
처음 이걸 만들려고 마음먹었을 때만 해도, 제가 생각하는 패턴이 시그널 패턴이라는 사실은 몰랐습니다.
그냥 옵저버 패턴의 편리한 한 형태라고 생각했는데요, 다 만들고 한참 후에야 이 패턴을 시그널 패턴이라고 부른다는 것을 알게 되었습니다.
시그널 패턴은 사용자가 특정 값을 사용하려는 순간, 코드 실행 과정 속에서 자동으로 구독 관계가 형성되는 방식입니다. 이렇게 수집된 콜백 함수는 해당 값이 갱신될 때마다 실행됩니다. 즉, 기존의 ‘구독 → 실행’ 과정을 따로 명시하지 않고도, 실행 흐름 속에 자연스럽게 ‘구독’을 녹여낸 패턴이라고 할 수 있습니다.
아래 코드는 preact/signal
라이브러리에서 시그널 패턴을 구현한 객체의 핵심 코드만 간략히 발췌한 것입니다. 자바스크립트의 객체 속성 접근자를 활용해 구현되어 있습니다.
특정 값을 속성 접근자를 통해 사용하려고 할 때, addDependency
로 구독 함수가 등록됩니다.
값을 변경할 때는 속성 변경 접근자(set
)를 통해 접근하게 되며, 이 과정에서 특정 값에 바인딩된 구독 함수들이 실행됩니다. node._target._notify();
Object.defineProperty(Signal.prototype, "value", {
get(this: Signal) {
const node = addDependency(this);
...
return this._value;
},
set(this: Signal, value) {
this._value = value;
...
node._target._notify(); // 구독 함수들 실행
...
},
});
preact/signal
의 경우, effect
함수를 통해 구독 함수를 등록할 수 있습니다.
아래 예제에서 구독 함수 내 구현을 보면, matrix 객체의 rowCount와 columnCount를 곱하여 행렬의 개수를 구하고 있습니다.
구독 함수 내에서 matrix 객체의 특정 속성에 접근하면, 해당 값이 구독 의존성에 자동으로 추가됩니다. 이후 값이 변경되면 effect 내에 정의한 함수가 실행됩니다.
effect(() => {
const matrixCount = matrix.rowCount * matrix.columCount;
console.log(matrixCount);
});
간단하죠? 프론트엔드 분야에서는 이 개념이 이미 널리 알려져 있고 친숙합니다. 또한 Vue, MobX, SolidJS와 같은 라이브러리들도 시그널 패턴을 활용하는 것으로 알려져 있습니다.
사실 새로운 개념은 아니며, 예전부터 널리 사용되어 온 개념이라는 것을 저도 최근에 알았습니다.
Lens 패턴
상태 관리 라이브러리를 만들겠다고 생각했을 때, 제가 가장 고민했던 부분은 값을 추적하는 방법이었습니다.
예를 들어 Zustand 로 만든 상태 객체를 특정 컴포넌트에서 사용한다고 할 때, 아래처럼 구독하고자 하는 값을 dot notation
으로 정확히 지정하여 구독할 수 있습니다.
export default function Board() {
// 상태 객체의 중첩된 값에 접근
const squares = useGameStore((state) => state.game.board.squares);
...
}
저는 위 예제처럼 중첩된 객체 안에서 특정 값을 정확히 집어 구독하는 기능을 실제로 구현해보고 싶었습니다. 다른 라이브러리들이 이 문제를 어떻게 해결했는지 살짝 참고해보고 싶은 마음도 있었습니다.
하지만 마음속 계획과 달리, 당시에는 육아 등으로 여가 시간이 부족해 다른 코드를 확인하거나 테스트할 여유가 많지 않았습니다. 그래서 잠깐씩 짬이 날 때마다 머릿속으로 코드를 시뮬레이션하며 '이렇게 구현할 수 있겠다'고 상상하는 것이 최선이었습니다.
이 과정에서 제가 떠올린 것이 바로 Lens
라는 개념입니다.
프론트엔드 개발자들은 보통 데이터의 순수성을 유지하며 처리해야 할 때, 예를 들어 React-Redux 같은 라이브러리를 사용할 경우 immer를 활용해 이러한 문제를 해결합니다.
Lens
역시 목적은 immer
와 같지만, 함수형 프로그래밍 철학에 맞춰 보다 선언적이고 쉽게 작성할 수 있도록 설계되었습니다.
어쨌든 Lens 패턴은 깊게 중첩된 객체에서 특정 값을 추적할 때 매우 편리합니다.
Lens 사용법
Lens
는 함수형 프로그래밍 언어인 하스켈에서 출발한 개념이지만, 자바스크립트에서도 여러 개발자들이 다양한 방식으로 구현해 사용하고 있습니다.
저도 state-ref
를 만들면서, 제가 다루기 편한 형태로 Lens
를 구현해봤습니다. 아래는 사용 예입니다.
const targetObject1 = { a: { b: { c: { k: 1, j: 2, w: 3 } } } };
const targetObject2 = { a: { b: { c: { k: 1, j: 11, w: 111 } } } };
const lensInstance = lens().chain("a").chain("b").chain("c").chain("j");
// 중첩객체에서 값 가져오기
console.log(lensInstance.get(targetObject1)); // 2를 반환합니다.
console.log(lensInstance.get(targetObject2)); // 11를 반환합니다.
// 중첩객체에서 값을 `카피 온 라이트`로 수정하기
lensInstance.set(7)(targetObject2);
위 코드에서 보시다시피, chain
메서드를 사용하면 단순히 ‘어떤 위치의 값을 보고 싶다’는 의도만 표현해둘 수 있습니다. 실제로 어느 객체에서 값을 가져올지는 get()을 호출하는 시점에 결정됩니다.
이 패턴을 이용하면 객체 참조가 바뀌더라도, Lens 인스턴스에 담긴 “속성의 위치”만 알고 있으면 항상 최신 참조의 값을 가져올 수 있습니다.
중첩된 깊이의 특정 값을 구독할 때, 구독 시점에 참조 위치를 기록한 lensInstance 객체와 그 시점의 값을 함께 가지고 있다면, 값이 변경될 때 lensInstance.get()을 호출하여 실제로 값이 변경되었는지 확인할 수 있습니다.
과정은 다음과 같이 진행됩니다.
- 구독 당시의 값과 Lens 인스턴스를 한 쌍으로 저장합니다.
- 옵저버 패턴에 의해 객체의 변경이 감지되면, 저장해둔 원본 값과 변경된 값을 비교합니다. (변경된 위치의 최신 값은
Lens
를 통해 확인할 수 있습니다.) - 값이 달라졌다면 변경으로 판단하여, 시그널 패턴에 등록된 구독 함수가 실행됩니다.
카피 온 라이트
set
을 사용해 값을 수정할 때는 카피-온-라이트(copy-on-write) 방식으로 동작합니다.
카피-온-라이트는 함수형 프로그래밍에서 자주 활용되는 기법으로, 데이터의 불변성(immutability) 을 유지하면서 변경을 처리할 수 있게 해줍니다.
객체의 특정 값을 바꿀 때 원본 객체 자체가 변하는 것이 아니라, 변경이 필요한 부분만 새로운 객체로 복사되고 나머지는 기존 객체를 그대로 참조합니다. 이렇게 하면 프로그램은 객체가 변경되었는지를 참조 비교(reference comparison)만으로 간단히 판별할 수 있어, 성능과 안정성을 동시에 확보할 수 있습니다.
Lens의 이러한 특성을 활용하면, 구독하고 있는 객체의 특정 위치 상태가 변경되었는지 쉽게 추적할 수 있습니다.
시그널 패턴과 Lens 패턴의 결합
제가 만든 state-ref
는 쉽게 말해, 시그널 패턴과 Lens
패턴의 특성을 결합한 객체입니다.
다만 값을 get
하여 가져오는 행위는 .value
를 통해서만 읽거나 설정할 수 있도록 설계했습니다.
예를 들어 특정 객체의 state.a.b
값을 직접 사용하려면 .value
를 붙여야 합니다.
console.log(state.a.b.value);
그리고 state.a.b
에 특정 새 값을 세팅해야 할때는 아래 예처럼 .value =
를 통해 할당합니다.
state.a.b.value = 3;
값의 조회와 할당 과정을 .value
를 통해 추상화한 것입니다.
이는 preact/signal
에서 사용되는 방식을 참고한 것으로, 값을 읽거나 쓸 때 반드시 .value
를 거치도록 강제함으로써 데이터 접근 방식이 명확해지고, 구현과 사용 모두 직관적이고 단순해집니다.
시그널 패턴과 Lens 패턴의 특성을 결합하는 데에는 Proxy를 활용했습니다.
아래는 프록시를 이용해서 시그널패턴과 랜즈패턴을 프록시로 결합한 state-ref
의 핵심 코드를 간략화 한것입니다. (Proxy의 기본용도와 사용방법 따로 설명하지 않겠습니다.)
아래는 프록시를 이용해 시그널 패턴과 Lens
패턴을 결합한 state-ref
의 핵심 코드를 간략화한 예입니다.
export function makeProxy(value, lensInstance, subscribeCallback) {
return new Proxy(
...
{
/**
* 객체에서 값을 가져오거나 참조할 때
*/
get(_: T, prop: keyof T) {
/**
* .value 로 꺼낼때
*/
if (prop === 'value') {
/**
* 구독 콜백 수집
*/
collector(
lensInstance.get(rootValue),
() => lensInstance.get(rootValue),
subscribeCallback,
);
return value;
}
// 점(dot) 표기법으로 연결하면 Lens가 계속 체이닝됩니다.
const chainedLensInstance = lensInstance.chain(prop);
const propertyValue: any = lens.get(rootValue);
return makeProxy(propertyValue, chainedLensInstance, subscribeCallback);
},
/**
* 객체에서 값을 변경하거나 참조를 수정할 때
*/
set(_, prop: string | symbol, value) {
if (prop !== 'value') {
throw new Error('Can only be assigned to a "value".');
} else if (prop === 'value' && value !== lensValue.get(rootValue)) {
const newTree = lensValue.set(value)(rootValue);
rootValue.root = newTree.root;
// 수집된 구독 콜백 실행
// 첫 번째 인자(storeRenderList): 구독 콜백 리스트 (makeProxy 상위 스코프 참조)
// 두 번째 인자(rootProxy): 스토어 루트의 프록시 (makeProxy 상위 스코프 참조)
runner(storeRenderList, rootProxy);
}
return true;
},
}
);
}
값을 꺼내서 소비할 때
이 코드를 보면 console.log(state.a.b.value);
처럼 값을 사용하기 위해 .value
를 호출하면 프록시의 get
이 실행됩니다.
get
이 실행되면 subscribeCallback
, 현재 위치의 값lensValue.get(rootValue)
, 그리고 변경 시점의 값을 확인하기 위한 함수() => lensValue.get(rootValue)
를 변경 시점에 실행할 수 있도록 collector
함수를 통해 저장해 둡니다.
값을 변경할 때
state.a.b.value = 3;
처럼 값을 변경하면 프록시의 set
이 실행됩니다.
set
이 호출되면, 이전에 get
으로 수집해 두었던 값과 새로 변경된 값을 비교합니다. 두 값이 다르면 Lens
인스턴스의 set
을 통해 기존 객체를 카피-온-라이트(copy-on-write) 방식으로 업데이트합니다.
마지막으로, get
시점에 수집해 두었던 subscribeCallback
들을 실행하여 변경 사항을 반영합니다.
구독 함수 등록
앞서 설명드린 preact/signal
의 effect
처럼, state-ref
에는 구독 함수를 등록하는 watch
함수가 있습니다.
아래는 설명을 위해 단순화한 watch
함수의 구현 코드입니다.
function watch(subscribeCallback) {
const lensRootInstance = lens();
const rootProxy = makeProxy({
rootValue,
lensRootInstance,
subscribeCallback,
});
// 구독 수집을 위해 처음 한 번 실행
subscribeCallback(rootProxy, true);
return rootProxy;
}
watch
함수는 스토어가 변경될 때 실행되어야 하는 함수를 인자로 받습니다.
그리고 글에서 먼저 소개했던 makeProxy
함수를 이용해 루트 프록시 객체를 생성합니다.
이 프록시 객체는 Lens
의 역할과 원본 객체의 변경을 구독 및 실행하는 역할을 동시에 수행합니다.
묶여 있는 참조
watch
를 통해 구독 함수를 등록하면, 해당 함수는 의존성 수집을 위해 처음 한 번 실행됩니다.
이때 subscribeCallback(rootProxy, true);
처럼 루트 프록시를 인자로 넘겨 실행하며, 두 번째 인자를 통해 의존성 수집을 위한 첫 실행인지 여부를 구분할 수 있습니다.
아래 예시처럼, 구독 함수를 정의하고 사용하는 모습을 떠올려 보시면 이해가 쉽습니다.
const matrixSubscribeCallback = (innerRef, isFirst) => {
const matrixCount = innerRef.rowCount.value * innerRef.columCount.value;
console.log(matrixCount);
};
// 특정 구독함수에 묶여 있는 참조
const outerRef = watch(matrixSubscribeCallback);
// 아무 구독 함수에도 안묶여 있는 참조
const anotherRef = watch();
위 코드에서 matrixSubscribeCallback
의 첫 번째 인자인 innerRef
는 스토어의 루트 프록시 객체입니다. 그리고 watch
가 반환하는 outerRef
역시 innerRef
와 동일한 참조입니다.
이 innerRef
와 outerRef
는 matrixSubscribeCallback
과 서로 연결되어 있습니다.
따라서 innerRef
나 outerRef
에서 값을 꺼내 사용하는 행위(예: console.log(ref.rowCount.value);
)를 하면, collector
함수를 통해 해당 구독 정보가 등록됩니다. 이후 rowCount
값이 변경되면 연결된 구독 함수가 실행됩니다.
matrixSubscribeCallback
예시에서는 rowCount
나 columnCount
가 변경될 때마다 해당 구독 함수가 실행됩니다.
두 번째
isFirst
인자는 함수가 최초 실행될 때true
, 이후 실행될 때는false
를 반환하며, 이를 통해 실행이 실제 변경에 따른 것인지, 구독 수집을 위한 것인지를 구분합니다.
안 묶여 있는 참조
반면 anotherRef
는 어떤 구독 함수에도 연결되어 있지 않습니다. 이는 참조를 얻을 때 watch
를 실행했지만, 그 안에 구독 함수를 인자로 전달하지 않았기 때문입니다.
따라서 anotherRef
를 통해 값을 가져다 써도 실행될 구독 함수는 없습니다.
아래 예를 보시면, anotherRef
는 matrixSubscribeCallback
구독 함수에서 직접 참조되더라도 collector
에 의해 수집되지 않습니다. 즉, anotherRef
.etcCount는 어떤 콜백 함수와도 연결되어 있지 않으므로 값이 변경되더라도 matrixSubscribeCallback
이 실행되지 않습니다.
// 아무 구독 함수에도 안묶여 있는 참조
const anotherRef = watch();
const matrixSubscribeCallback = (innerRef, isFirst) => {
const matrixCount =
innerRef.rowCount.value *
innerRef.columCount.value *
anotherRef.etcCount.value;
console.log(matrixCount);
};
// 특정 구독함수에 묶여 있는 참조
const outerRef = watch(matrixSubscribeCallback);
이 특성을 활용하면 특정 값을 사용하면서도 구독에 수집되지 않도록 만들 수 있습니다. 즉, 사용자가 구독이 수집되는 경우와 그렇지 않은 경우를 명확히 구분할 수 있게 됩니다.
그 원리는, 프록시 객체를 생성할 때 watch
에 주입된 구독 함수가 collector
를 통해 수집 정보와 연결되기 때문입니다.
return new Proxy(
...
{
get(_: T, prop: keyof T) {
if (prop === 'value') {
/**
* 구독 콜백 수집
*/
collector(
lensInstance.get(rootValue),
() => lensInstance.get(rootValue),
subscribeCallback,
);
return value;
}
}
...
}
...
);
묶여 있는 outer 참조
앞선 예제에서 살펴본 구독 함수 내부의 innerRef
참조 객체 외에도, watch
가 반환하는 outerRef
를 통해 구독 콜백 함수 외부에서도 접근할 수 있도록 했습니다. 이렇게 설계한 이유는 UI 라이브러리의 컴포넌트에서 손쉽게 엮어 사용할 수 있도록 하기 위함입니다.
설명을 위해, 제가 취미로 만든 컴포넌트 기반 UI 라이브러리 lithent 를 예로 들어보겠습니다.
const Component = mount((renew) => {
let count = 1;
const change = () => {
count += 1;
renew();
};
return () => <button onClick={change}>{count}</button>;
});
mount
는 컴포넌트를 생성하는 함수이며, 이때 mount
가 소비하는 함수의 첫 번째 인자로 renew
함수를 제공합니다.
renew
함수는 해당 컴포넌트의 업데이트를 요청하는 역할을 합니다. 아래 예시에서는 count
값을 1
증가시킨 뒤, 컴포넌트를 다시 업데이트합니다.
참고로, 일반적으로
lithent
의renew
와 같은 직접적인 갱신 호출 방식은 상태 변화에 따라 자동으로 처리되는 리액티브 선언형 패러다임을 깨뜨리므로 안티패턴으로 간주될 수 있습니다. 그럼에도 수동 갱신을 주된 인터페이스로 채택한 이유는, 상태 관리를 네이티브 클로저를 활용하여 최대한 단순하게 유지하고 특정 패턴에 고정되지 않도록 설계하기 위함입니다. 또한 제작자가 가볍게 활용하기 위해 만든 만큼, 개인적인 목적과 필요도 자연스럽게 반영되었습니다.
이 컴포넌트의 내부 상태인 count
대신 state-ref
를 이용하여 스토어 값을 공유하고 싶다면 아래처럼 하면 됩니다.
const watch = createStore(1); // watch 생성
const Component = mount((renew) => {
count countRef = watch(renew);
const change = () => {
countRef.value += 1;
};
return () => <button onClick={change}>{count.value}</button>;
});
이렇게 watch
로 프록시 참조를 외부로 반환하면, 객체의 구독 위치를 구독 함수 밖에서도 쉽게 수집할 수 있어 다양한 상황에서 유용하게 활용할 수 있습니다.
outerRef
를 활용하면 컴포넌트와 상태를 손쉽게 연결할 수 있게 됩니다.
이 특성을 바탕으로, React
와 Preact
에서도 간단한 스니펫을 통해 상태를 쉽게 연결할 수 있습니다.
UI 라이브러리를 수정하지 않고 최대한 비침습적으로 동작하도록, 기본 기능인
useState
와 단순한 구독 패턴만으로 설계했습니다. 필요하다면useSyncExternalStore
기반의 스니펫도 손쉽게 커스터마이징할 수 있습니다.
구독 취소
구독을 취소하는 방법은 두 가지로 구현해보았습니다.
AbortSignal을 이용한 취소
첫 번째 방법은 AbortSignal을 활용하는 방식입니다.
보통 이 기능은 fetch API
요청을 취소할 때 많이 사용되지만, 커스텀 이벤트 처리에도 활용할 수 있습니다.
구독 취소를 위해, AbortSignal
을 구독 함수에서 반환하도록 하여 처리하도록 했습니다.
아래는 구체적인 사용 예시입니다.
const abortController = new AbortController();
watch((stateRef) => {
console.log(
"Changed John's Second House Color",
stateRef.john.house[1].color.value
);
return abortController.signal;
});
abortController.abort(); // run abort
구독 취소를 위해 AbortController
를 생성하고, 구독 함수를 실행할 때 이를 반환하도록 합니다.
아래 코드는 구독 값 수집을 위해 처음 실행될 때 함께 실행되는 함수의 간략화된 예시입니다.
// 구독수집을 위해 구독함수가 처음 실행될때 실행
function subscribeFirstRunner(subscribeCallback, storeRenderList) {
const result = subscribeCallback(rootProxy, true);
if (result instanceof AbortSignal) {
result.addEventListener("abort", () => {
storeRenderList.delete(subscribeCallback);
});
}
}
subscribeCallback
함수가 실행된 후 반환된 값이 AbortSignal
일 경우, 이 signal
객체에 abort
이벤트를 바인딩합니다.
그리고 abortController.abort()
가 호출되면 등록된 리스너가 실행되어, 구독 수집 리스트에서 해당 구독 함수를 제거합니다.
리턴값 false를 이용한 취소
AbortSignal
을 이용한 취소가 확실하고 유용하긴 하지만, 다소 번거로울 수 있어 다른 방법도 마련했습니다.
예를 들어, 특정 조건을 만족할 때 구독을 쉽게 취소하고 싶다면, 아래 코드처럼 조건을 확인한 뒤 false를 반환하도록 처리하면 됩니다.
watch((stateRef) => {
const color = stateRef.john.house[1].color.value;
console.log("Changed John's Second House Color", color);
return color === "red" ? false : true;
});
값이 변경되면, 아래 예제 코드의 runner
가 실행됩니다(앞선 예제중 makeProxy
의 setter
에서 호출됩니다).
구독 콜백이 실행된 후 정확히 false
를 반환하면, 해당 구독 함수는 수집된 구독 함수 리스트에서 제외됩니다.
function runner(storeRenderList, rootProxy) {
...
runableRenewList.forEach(subscribeCallback => {
if (subscribeCallback(rootProxy, false) === false) {
storeRenderList.delete(run);
}
});
...
}
맺으며
제작 의도에는 순수한 호기심도 있었지만, 사실 이 상태 관리 라이브러리는 특정 문제를 해결하기 위해 만들었습니다.
제가 몸담고 있는 회사는 여러 파일을 로드해 페이지를 구성하는 전통적인 구조를 가지고 있습니다. 기존에 잘 사용하던 preact/signal
은 여러 파일로 빌드될 경우 런타임 컨텍스트가 달라져, 파일 간 상태를 공유할 수 없는 한계가 있었습니다.
그래서 모든 파일에서 동일한 상태를 자유롭게 참조할 수 있도록 state-ref
를 만들어 봤습니다.
저처럼 레거시 환경에서 가볍고 유연하게 상태 관리를 하고 싶은 분들에게 이 라이브러리가 작은 도움이 되길 바랍니다. 꼭 이 코드를 그대로 사용하지 않더라도, 글을 재미있게 읽으셨다면 개념과 코드가 복잡하지 않아 직접 구현하거나 응용해 활용할 수 있을 것이라 생각합니다.
조금이라도 재미있게 읽으셨다면, state-ref 저장소에 스타를 눌러주세요. 작은 클릭 하나가 제게 큰 기쁨이 됩니다.