store와 state가 함께 만드는 상태 미들웨어 파이프라인

작성일:2026.05.11|조회수:4

store와 state가 함께 만드는 상태 미들웨어 파이프라인

상태 관리 코어를 작게 유지하려고 하면 곧바로 한 가지 질문을 만나게 된다. 상태를 읽고, 바꾸고, 구독하게 해주는 것만으로 충분한가. 처음에는 충분해 보인다. 하지만 실제 애플리케이션으로 들어가면 업데이트를 기록하고 싶고, 잘못된 값은 막고 싶고, 들어온 값을 정규화하고 싶고, 상태가 반영된 뒤 localStorage나 DevTools와 동기화하고 싶어진다.

이때 선택지는 크게 두 가지다. 호출부마다 그런 로직을 흩뿌리거나, store 코어 안에 기능을 계속 밀어 넣는 것이다. 전자는 사용 코드를 지저분하게 만들고, 후자는 작은 코어를 금방 무겁게 만든다. @ilokesto/store v1.1.0의 미들웨어는 이 사이에 있는 얇은 지점을 연다. 핵심은 단순하다. setState()가 상태를 곧바로 적용하지 않고, 먼저 미들웨어 체인을 통과하게 만든다.

상태 전환 요청 + 미들웨어 체인 + 최종 적용 = 상태 전환 파이프라인

이 글에서는 @ilokesto/store@ilokesto/state가 어떻게 나뉘어 협응하는지 살펴보려 한다. store는 상태 전환이 실제로 실행되는 가장 작은 코어이고, state는 그 코어 위에 logger, persist, debounce, devtools, validate 같은 사용 경험을 얹는 조합 레이어다. 중요한 것은 기능 목록이 아니라, 두 패키지가 어느 경계에서 만나고 어느 경계에서 서로를 모르는 채로 남는가다.

아래 코드 예시는 설명을 위해 새로 만든 축약 코드가 아니라, 현재 @ilokesto/store@ilokesto/state 소스에서 가져온 형태를 기준으로 정리했다. 일부 긴 타입 보조 함수나 diff 출력처럼 글의 논점과 직접 관련이 적은 부분만 생략했다.

store는 상태를 적용하는 마지막 지점을 소유한다

@ilokesto/store의 핵심은 여전히 작다. 현재 상태를 읽는 getState(), 초기 상태를 읽는 getInitialState(), 상태를 바꾸는 setState(), 변경을 듣는 subscribe()가 있다. v1.1.0에서 달라진 것은 setState() 내부에 미들웨어 실행 경로가 생겼다는 점이다.

개념적으로는 이런 흐름이다.

TS
store.setState(nextState)
  -> middleware A
    -> middleware B
      -> middleware C
        -> applyState(nextState)
          -> notify()

여기서 applyState()는 코어의 마지막 문이다. 들어온 값이 updater 함수라면 현재 상태를 넣어 다음 상태를 계산하고, 이전 상태와 Object.is로 같으면 멈춘다. 다르면 내부 state를 교체하고 listener에게 알린다. 즉, store는 여전히 상태 저장, 상태 교체, 구독 알림이라는 최소 책임을 붙들고 있다.

다만 이제 그 마지막 문 앞에 통로가 하나 생겼다. 미들웨어는 그 통로에서 상태 전환 요청을 관찰하거나, 바꾸거나, 아예 다음 단계로 넘기지 않을 수 있다.

TS
type Listener = () => void;
type Dispatch<A> = (value: A) => void;
type SetStateAction<S> = S | ((prevState: S) => S);
type Middleware<T> = (
  nextState: SetStateAction<T>,
  next: Dispatch<SetStateAction<T>>
) => void;

이 타입은 작지만 많은 것을 말한다. 미들웨어는 상태 자체를 직접 바꾸는 함수가 아니다. 아직 적용되지 않은 상태 전환 요청을 받고, 그 요청을 다음 단계로 보낼지 결정하는 함수다. next(nextState)를 호출하면 체인은 계속 내려가고, 호출하지 않으면 상태 반영은 일어나지 않는다.

next()는 관찰과 통제를 같은 자리에서 가능하게 한다

가장 단순한 미들웨어는 로깅이다. 아래 예시에서 미들웨어는 next() 앞뒤에 코드를 둘 수 있다. next() 전에는 아직 상태가 반영되지 않았고, next() 뒤에는 아래쪽 체인과 applyState()가 실행된 뒤다. 그래서 같은 함수 안에서 before와 after를 모두 볼 수 있다.

TS
const applyLogger = <T>(
  initialState: T | Store<T>,
  options: LoggerOptions = DEFAULT_LOGGER_OPTIONS,
) => {
  const store = getStore(initialState);
  const isProduction = typeof process !== 'undefined' && process.env.NODE_ENV === 'production';

  store.pushMiddleware((nextState: SetStateAction<T>, next) => {
    if (isProduction) {
      next(nextState);
      return;
    }

    const prevState = store.getState();
    const time = new Date().toLocaleTimeString();

    if (options.timestamp) {
      console.log('Time:', time);
    }

    console.log('Previous state:', prevState);

    next(nextState);
    const newState = store.getState();

    console.log('Next state:', newState);
  });

  return store;
};

이 구조가 중요한 이유는 미들웨어가 단순한 이벤트 listener가 아니라는 점에 있다. listener는 보통 일이 끝난 뒤 알림을 받는다. 반면 미들웨어는 일이 적용되기 전에 요청을 손에 쥔다. 그래서 로그를 남기는 것뿐 아니라 검증, 정규화, 지연, 저장, 외부 도구 동기화 같은 일을 같은 인터페이스로 처리할 수 있다.

실제 validate() 미들웨어는 이 성질을 그대로 사용한다. 들어온 요청을 먼저 실제 후보 상태로 풀고, Standard Schema 검증에 실패하면 next()를 호출하지 않는다. 검증을 통과한 경우에만 schema가 돌려준 값을 아래 체인으로 넘긴다.

TS
const applyValidate = <T>(initialState: T | Store<T>, schema: StandardSchemaV1<T, T>): Store<T> => {
  const store = getStore(initialState);

  store.pushMiddleware((nextState: SetStateAction<T>, next) => {
    const resolvedState =
      typeof nextState === 'function'
        ? (nextState as (prev: Readonly<T>) => T)(store.getState() as T)
        : nextState;

    const result = schema['~standard'].validate(resolvedState);

    if (isPromiseLike(result)) {
      console.error(
        '[Validation Error] Async Standard Schema is not supported in validate middleware.',
      );
      return;
    }

    if ('issues' in result) {
      console.error('[Validation Error] Invalid state:', result.issues);
      return;
    }

    next(result.value);
  });

  return store;
};

호출부 입장에서는 여전히 store.setState()만 부른다. 하지만 store와 실제 상태 반영 사이에는 정책을 걸 수 있는 파이프라인이 생긴다. 이 지점이 작은 store가 단순한 상태 상자에서 상태 전환 파이프라인으로 바뀌는 순간이다.

실행 순서는 배열과 reduceRight()가 만든다

미들웨어는 순서가 중요하다. 어떤 로직은 가장 바깥에서 전체 흐름을 감싸야 하고, 어떤 로직은 실제 상태 반영에 가까운 안쪽에서 실행되어야 한다. @ilokesto/store는 이 순서를 복잡한 설정 객체로 풀지 않는다. 내부에는 미들웨어 배열이 있고, setState() 시점에 그 배열을 복사한 뒤 reduceRight()로 하나의 runner를 만든다.

먼저 등록된 미들웨어가 바깥 래퍼가 되고, 나중에 등록된 미들웨어가 더 안쪽에 놓인다.

TS
setState(nextState: SetStateAction<T>): void {
  const runner = [...this.middlewares].reduceRight<
    Dispatch<SetStateAction<T>>
  >(
    (next, middleware) => {
      return (state: SetStateAction<T>) => middleware(state, next);
    },
    (state: SetStateAction<T>) => this.applyState(state)
  );

  runner(nextState);
}

pushMiddleware(middleware: Middleware<T>): void {
  this.middlewares.push(middleware);
}

unshiftMiddleware(middleware: Middleware<T>): void {
  this.middlewares.unshift(middleware);
}

흐름은 이렇게 이해할 수 있다.

TXT
A before
B before
[applyState]
B after
A after

이것은 단순한 hook 목록이 아니라 중첩 호출 구조다. Express나 Koa, Redux middleware에서 익숙한 양파형 모델과 닿아 있다. 작지만 실행 순서가 예측 가능하고, before/after 패턴이 자연스럽다.

현재 구현이 pushMiddleware()unshiftMiddleware()를 나누는 것도 이 순서 때문이다. 일반 기능은 뒤에 붙일 수 있고, reducer처럼 항상 앞에서 action을 상태로 변환해야 하는 로직은 앞에 넣을 수 있다. 미들웨어에서 자료구조는 단순한 보관 방식이 아니라 실행 의미를 만든다.

state는 store를 직접 대체하지 않고 조합한다

여기서 @ilokesto/state의 역할이 나온다. state 패키지는 store 코어를 다시 구현하지 않는다. 대신 getStore()를 통해 이미 있는 Store를 재사용하거나, plain initial state를 받아 새 Store를 만든다. 그 다음 필요한 기능을 store의 middleware 체인에 얹는다.

TS
import { Store } from '@ilokesto/store';

export const pipe = <T>(
  initialState: T,
  ...middlewares: Array<(store: Store<T>) => Store<T>>
): Store<T> => {
  return middlewares.reduce((store, middleware) => middleware(store), new Store(initialState));
};

pipe()의 모양은 이 협업 관계를 잘 보여준다. 시작점은 plain state다. 그것을 Store로 만들고, middleware helper들이 차례로 같은 store를 받아 같은 store를 반환한다. 결과적으로 사용자는 다음처럼 읽을 수 있다.

TS
import { create } from "@ilokesto/state/react";
import { logger, persist } from "@ilokesto/state/middleware";
import { pipe } from "@ilokesto/state/utils";

const counterStore = pipe(
  { count: 0 },
  logger({ timestamp: true }),
  persist({ local: "counter" }),
);

export const useCounter = create(counterStore);

이 코드에서 logger()persist()는 별개의 상태 관리 시스템을 만드는 것이 아니다. 둘 다 결국 같은 Store 인스턴스에 pushMiddleware()를 호출한다. state는 기능의 이름과 옵션, 조합 방식을 제공하고, store는 그 기능들이 끼어드는 실행 지점을 제공한다.

이 분리가 좋다. store는 localStorage, Redux DevTools, schema validator, debounce queue를 몰라도 된다. state는 상태 적용의 마지막 규칙을 다시 만들 필요가 없다. 둘 사이의 계약은 Store 인스턴스와 middleware 함수 하나면 충분하다.

built-in middleware는 같은 규약을 다른 방식으로 사용한다

@ilokesto/state/middleware의 helper들은 모두 같은 문을 통과하지만, 쓰임새는 조금씩 다르다. 그래서 설명만 이어가기보다, 각 middleware가 호출부에서 어떤 모양으로 붙는지 함께 보는 편이 이해하기 쉽다. 예시는 모두 같은 pipe() 규약을 사용한다. plain state에서 Store를 만들고, helper가 그 store에 middleware를 얹은 뒤 다시 같은 store를 반환한다.

먼저 logger()는 가장 직관적인 before/after middleware다. 이전 상태를 읽고, next(nextState)를 호출한 뒤, 새 상태를 다시 읽는다. diff 옵션이 켜져 있다면 두 snapshot을 비교해 변경된 필드를 출력할 수 있다.

TS
import { logger } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const counterStore = pipe(
  { count: 0 },
  logger({ collapsed: true, diff: true, timestamp: true }),
);

counterStore.setState((prev) => ({ count: prev.count + 1 }));

이 호출부가 실행되면 middleware 내부에서는 대략 이런 흐름이 돈다.

TS
store.pushMiddleware((nextState: SetStateAction<T>, next) => {
  if (isProduction) {
    next(nextState);
    return;
  }

  const prevState = store.getState();
  const time = new Date().toLocaleTimeString();

  if (options.timestamp) {
    console.log('Time:', time);
  }

  console.log('Previous state:', prevState);

  next(nextState);
  const newState = store.getState();

  console.log('Next state:', newState);
});

persist()는 상태 반영 뒤의 부가 작업을 보여준다. 먼저 storage에서 값을 읽어 초기 상태를 복원하고, 이후에는 next()가 끝난 다음 현재 snapshot을 storage에 저장한다.

TS
import { persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const counterStore = pipe(
  { count: 0 },
  persist({ local: 'counter' }),
);

counterStore.setState((prev) => ({ count: prev.count + 1 }));

여기서 중요한 점은 저장 대상이 들어온 nextState가 아니라 실제로 반영된 store.getState()라는 것이다. 앞쪽 middleware가 값을 바꿨을 수도 있고, applyState()가 bail-out 했을 수도 있기 때문이다. 그래서 persist()는 요청을 저장하지 않고 결과 snapshot을 저장한다. 저장소는 local, session, cookie 중 하나를 고르는 방식이고, localStorage와 cookie 경로에서는 migration도 붙일 수 있다.

validate()는 반대로 next()를 호출하기 전의 middleware다. 들어온 상태 전환 요청을 실제 값으로 풀어 Standard Schema-compatible schema에 넣고, 문제가 있으면 그대로 멈춘다.

TS
import * as v from 'valibot';
import { validate } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const counterSchema = v.object({
  count: v.pipe(v.number(), v.minValue(0)),
});

const counterStore = pipe(
  { count: 0 },
  validate(counterSchema),
);

counterStore.setState({ count: 1 });
counterStore.setState({ count: -1 }); // validation error, next() is not called

상태 변경을 막는 정책이 호출부가 아니라 store의 관문에 모인다는 점이 핵심이다. 호출부는 여전히 setState()만 부르지만, 실제 반영 여부는 middleware 체인 안에서 결정된다.

debounce()는 시간 제어를 담당한다. 들어온 update들을 배열에 쌓아두고, 일정 시간이 지난 뒤 누적된 update를 하나의 상태로 계산해 다음 단계로 보낸다.

TS
import { debounce } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const searchStore = pipe(
  { keyword: '' },
  debounce(300),
);

searchStore.setState({ keyword: 'i' });
searchStore.setState({ keyword: 'il' });
searchStore.setState({ keyword: 'ilo' });

이 middleware는 next()를 즉시 부르지 않는다. 대신 나중에 저장해둔 next를 호출한다. 같은 인터페이스로 동기 검증뿐 아니라 비동기적인 지연 적용도 표현할 수 있다는 점이 흥미롭다. 여러 updater 함수가 들어온 경우에도 마지막 값만 단순히 고르는 것이 아니라, 쌓인 update를 순서대로 계산해 최종 상태를 만든다.

devtools()는 외부 도구와의 양방향 연결이다. 상태가 바뀐 뒤에는 Redux DevTools에 현재 snapshot을 보내고, DevTools에서 RESET이나 ROLLBACK 같은 명령이 들어오면 다시 store.setState()를 호출한다.

TS
import { devtools, logger } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const counterStore = pipe(
  { count: 0 },
  logger({ diff: true }),
  devtools('counter-store'),
);

counterStore.setState((prev) => ({ count: prev.count + 1 }));

이때도 핵심은 같다. 외부 도구는 store 내부를 직접 바꾸지 않는다. 결국 변경은 setState() 경로로 돌아온다. 그래서 DevTools에서 되돌리기를 하더라도 framework adapter나 subscriber가 보는 계약은 그대로 유지된다.

이 예시들을 나란히 놓고 보면 helper들의 차이가 더 분명해진다. logger()persist()next() 이후의 snapshot을 활용하고, validate()next() 이전에 요청을 막을 수 있으며, debounce()next() 호출 시점을 뒤로 미룬다. devtools()는 상태 반영 뒤 외부 도구에 알리면서도, 외부 명령을 다시 setState()로 되돌려 같은 파이프라인을 타게 만든다.

reducer도 middleware로 들어온다

state 패키지에서 흥미로운 지점은 reducer mode도 middleware로 표현된다는 점이다. reducer를 사용하면 사용자는 상태 값을 직접 넣는 대신 action을 dispatch하는 감각으로 쓸 수 있다. 내부에서는 getStore()가 reducer middleware를 unshiftMiddleware()로 넣는다.

TS
store.unshiftMiddleware((nextState: any, next) => {
  const action: Action = nextState as unknown as Action;
  store.actionName = action.type;
  const currentState = store.getState();
  next(reduceFn(currentState, action));
  store.actionName = undefined;
});

이 middleware는 action을 받아 현재 상태와 함께 reducer에 넣고, reducer가 돌려준 다음 상태를 아래 체인으로 넘긴다. 그래서 reducer는 가장 바깥쪽에서 먼저 실행되는 것이 자연스럽다. 사용자 입장에서는 action을 보냈지만, store 코어가 받는 최종 입력은 여전히 다음 상태다.

이 구조는 storestate의 관계를 다시 보여준다. store는 action이 무엇인지 몰라도 된다. reducer라는 개념도 몰라도 된다. state가 action을 상태 전환 요청으로 번역해주면, store는 평소처럼 미들웨어 체인을 통과시킨 뒤 상태를 적용한다.

프레임워크 어댑터는 middleware를 몰라도 된다

React, Vue, Svelte, Angular, Solid 어댑터는 middleware 구현을 특별히 알 필요가 없다. 이들은 모두 이미 만들어진 store를 받아 현재 snapshot을 읽고, 변경을 구독하고, 필요할 때 setState()를 호출한다.

React에서는 useSyncExternalStore가 이 계약을 거의 그대로 사용한다.

TS
useSyncExternalStore(
  (listener) => store.subscribe(listener),
  () => store.getState(),
  () => store.getInitialState(),
);

Vue 어댑터도 같은 계약을 Vue식 primitive로 옮긴다. 실제 createSelection() 구현은 현재 snapshot을 shallowRef에 담고, store 변경 알림이 오면 다시 getState()를 읽어 snapshot.value를 교체한다. 그리고 composable이 속한 effect scope가 정리될 때 onScopeDispose(unsubscribe)로 구독을 해제한다.

TS
function createSelection<T, S>(store: Store<T>, selector: Selector<T, S>) {
  if (!getCurrentScope()) {
    throw new Error(
      '[@ilokesto/state/vue] create() returned composables must run inside setup() or an active effectScope(). Use readOnly() for synchronous reads outside Vue scope.',
    );
  }

  const snapshot = shallowRef(store.getState());

  const unsubscribe = store.subscribe(() => {
    snapshot.value = store.getState();
  });

  onScopeDispose(unsubscribe);

  return computed(() => selector(snapshot.value as T));
}

그래서 Vue에서는 shallowRef에 현재 snapshot을 담고, store가 알려줄 때 다시 getState()를 읽어 교체한다. Svelte는 readable store contract로 번역하고, Solid는 signal setter를 호출하고, Angular는 Signal 또는 Observable과 연결한다. 문법은 다르지만 모두 같은 세 가지를 사용한다.

현재 snapshot 읽기 + 변경 구독 + lifecycle cleanup

middleware가 붙어 있어도 어댑터 코드는 크게 달라지지 않는다. 왜냐하면 middleware는 setState() 안쪽의 전환 과정에 관여할 뿐, 외부에 드러나는 store contract를 깨지 않기 때문이다. 어댑터는 “상태가 어떻게 처리되었는가”를 몰라도 된다. 최종적으로 store가 알려주는 최신 snapshot만 읽으면 된다.

이 점이 설계상 중요하다. state가 middleware를 늘려도 React hook이나 Vue composable이 middleware별 분기를 가질 필요가 없다. logger가 있든, persist가 있든, validate가 있든, debounce가 있든, 프레임워크 어댑터는 같은 store를 같은 방식으로 바인딩한다.

상태 전환 파이프라인은 경계를 흐리지 않는다

middleware라는 단어는 때로 코어를 복잡하게 만들 것처럼 보인다. 하지만 이 구현에서는 오히려 경계를 더 분명하게 만든다.

이 경계가 유지되기 때문에 기능을 붙여도 코어가 쉽게 커지지 않는다. persistence가 필요하면 persist()를 붙이고, 검증이 필요하면 validate()를 붙이고, 디버깅이 필요하면 logger()devtools()를 붙인다. 하지만 마지막 상태 적용 규칙은 여전히 Store 하나에 남아 있다.

물론 한계도 있다. 이 middleware는 setState() 경로를 감싸지만 notify()만 따로 가로채는 구조는 아니다. nextState는 값일 수도 있고 updater 함수일 수도 있으므로 middleware 작성자는 둘 다 고려해야 한다. 현재 구조에서는 등록된 middleware를 제거하는 API도 없다. 그리고 같은 참조를 반환하면 Object.is에 의해 알림이 생략될 수 있다.

이런 한계는 결함이라기보다 현재 코어의 범위를 보여준다. 작은 코어가 모든 확장을 직접 제공하려 하지 않고, 가장 중요한 전환 경로 하나만 열어둔 결과다.

작은 코어와 조합 레이어가 만나는 방식

@ilokesto/store v1.1.0의 미들웨어는 기능 하나가 추가된 정도의 변화가 아니다. store가 상태를 보관하는 객체에서 상태 전환을 통과시키는 파이프라인으로 확장된 변화다. 그런데 그 확장은 코어를 무겁게 만드는 방식이 아니라, 코어의 가장 중요한 경로 하나를 얇게 여는 방식으로 이루어진다.

@ilokesto/state는 그 열린 경로를 사용해 실제 개발자가 원하는 기능을 조합한다. logger는 관찰하고, validate는 막고, debounce는 늦추고, persist는 저장하고, devtools는 외부 도구와 연결한다. 하지만 이 모든 기능은 결국 같은 Store 인스턴스의 같은 setState() 경로를 지나간다.

그래서 이 구조에서 배울 만한 점은 middleware 자체보다 경계의 배치다. 코어는 마지막 적용 지점을 소유한다. 조합 레이어는 그 앞뒤에 의미 있는 기능을 붙인다. 어댑터는 최종 snapshot만 읽는다. 각자가 서로의 내부를 많이 알지 않아도 같은 상태 흐름 위에서 협력한다.

좋은 작은 패키지는 모든 기능을 직접 들고 있지 않아도 된다. 대신 사용자가 기능을 붙일 수 있는 정확한 지점을 제공해야 한다. @ilokesto/store@ilokesto/state의 middleware 구조는 그 지점을 setState()applyState() 사이에서 찾는다. 상태가 바뀌는 바로 그 순간을 열어두면, 작은 코어 위에서도 꽤 많은 사용 경험을 조합할 수 있다.

더 읽어보기

  • 2026.04.23

    너만의 월남쌈을 싸

    이 포스트의 제목은 유튜브 영상 <너만의 월남쌈을 싸 - 아이네 INE>에서 따왔다. 4월 초부터 @ilokesto 네임스페이스에 속한 거의 모든 라이브러리를 리뉴얼 하고 있다. 이미 deprecated 처리는 끝났고, 새로운 기준 위에서 라이브러리의 구조를 재정의하는 단계를 거치고 있…

댓글

댓글을 불러오는 중...