Vanilla TypeScript로 전역 상태 관리 구현해 보기

김가희·2026년 3월 27일

이 글의 목적은 Vanilla JavaScript(TypeScript)로 전역 상태 관리의 기본 구조를 직접 구현해 보며, 여러 컴포넌트가 공통 상태를 공유하고 상태 변화에 따라 다시 렌더링되는 흐름을 학습하는 것이다.

기존에는 각 컴포넌트 내부의 state만 관리하고 있었지만, 로그인 정보처럼 여러 화면에서 함께 참조해야 하는 값은 컴포넌트 단위 상태만으로 다루기 어렵다. 그래서 이번에는 특정 컴포넌트에 종속되지 않는 Store를 만들고, 컴포넌트가 이를 구독하여 상태 변화에 반응하도록 구성해 보았다.


전역 상태 관리

현재 구조에는 이미 Component.ts 안에 setState를 통한 컴포넌트 단위 상태 관리가 구현되어 있었다. 하지만 이런 방식은 자기 자신의 상태만 관리할 때는 적절해도, 로그인 사용자 정보나 전역 테마처럼 여러 컴포넌트가 동시에 알아야 하는 값을 다루기에는 한계가 있다. 이 지점에서 “어딘가에서 상태가 바뀌었을 때, 그것을 구독하고 있는 컴포넌트들만 다시 그려야 한다”는 요구가 생긴다.

이 요구를 해결하기 위해 이번에는 다음과 같은 구조를 만들기로 했다.

  • 전역 상태를 보관하는 Store
  • 상태 변화를 감지하는 listeners
  • 상태가 바뀌면 구독 중인 컴포넌트에게 알리는 notify
  • 컴포넌트가 전역 스토어를 구독하고 해제하는 구조
  • 페이지 이동 시 구독이 남지 않도록 정리하는 unmount

이번 글에서 다루는 전역 상태 관리는 단순한 값 보관소가 아니라, 상태 변경을 알리고 그에 따라 화면이 반응하도록 만드는 구조 전체에 가깝다.


구현 방법

Store 설계

먼저 모든 전역 상태 저장소의 기반이 될 Store 클래스를 만들었다.
이 클래스는 특정 기능에 종속되지 않고, 공통으로 사용할 수 있는 베이스 역할을 한다.

export type Listener = () => void;

export class Store<T extends object> {
  private state: T;
  protected listeners: Set<Listener> = new Set();

  constructor(initialState: T) {
    this.state = initialState;
  }

  getState(): T {
    return this.state;
  }

  setState(newState: Partial<T>) {
    this.state = { ...this.state, ...newState };
    this.notify();
  }

  subscribe(listener: Listener) {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }

  private notify() {
    this.listeners.forEach((listener) => listener());
  }
}

이 구현에서 중요했던 포인트는 두 가지였다.

첫 번째는 불변성이다. 실제로 구현 과정에서도 "값을 변경할 때 원본값 자체를 훼손시키면 안 될 것 같다"는 생각에서 출발했고, 그에 따라 setState에서는 기존 상태를 직접 수정하지 않고 전개 연산자로 새 객체를 만들어 교체하는 방식을 택했다.

두 번째는 캡슐화다. 외부에서 store.state = ...처럼 직접 상태를 바꾸지 못하게 막고, getState, setState, subscribe 같은 정해진 통로로만 접근하도록 설계했다.

subscribe는 단순히 리스너를 등록하는 데서 끝나는 것이 아니라, 이후 구독을 해제할 수 있도록 해제 함수를 반환하도록 구성했다.


Component와 Store 연결

Store 혼자만으로는 아무 일도 일어나지 않는다.
스토어의 상태가 바뀌었을 때, 이를 사용하는 컴포넌트가 “이제 다시 그려야겠다”라고 반응해야 한다.
이 역할을 위해 Component 클래스에 subscribe(store) 메서드를 추가했다.

subscribe(store: Store<any>) {
  const unsub = store.subscribe(() => {
    this.setState({} as Partial<S>);
  });
  this.unsubs.push(unsub);
}

subscribe는 구독만 하는 게 아니라, 반환되는 구독 해제 함수를 unsubs 배열에 저장해 둔다. 그래야 나중에 컴포넌트가 화면에서 사라질 때 한꺼번에 정리할 수 있다.

여기서 setState({})는 실제 상태 값을 바꾸기 위한 호출이라기보다, 현재 컴포넌트 구조에서 리렌더링을 유도하기 위한 트리거 역할에 가깝다.


구독 해제를 위한 unmount

구독 구조를 만들었다면, 그 반대편도 필요하다. 컴포넌트가 사라질 때는 반드시 구독도 해제해야 한다.

unmount() {
  this.componentWillUnmount();
  this.unsubs.forEach((unsub) => unsub());
  this.unsubs = [];
  this.target.innerHTML = '';
}

이 메서드는 세 가지를 처리한다.

  • 기존 생명주기 메서드 실행
  • 저장해 둔 구독 해제 함수 전부 호출
  • 화면 정리

실제 구현 로그에서도 Component.tsprivate unsubs: (() => void)[] = [];가 추가되고, subscribe(store)로 구독 해제 함수를 모아둔 뒤, unmount()에서 이를 전부 실행하도록 바뀌었다.


실제 전역 스토어 적용: AuthStore

interface AuthState {
  isLoggedIn: boolean;
  user: { name: string } | null;
}

class AuthStore extends Store<AuthState> {
  constructor() {
    super({
      isLoggedIn: false,
      user: null,
    });
  }

  login(name: string) {
    this.setState({
      isLoggedIn: true,
      user: { name },
    });
  }

  logout() {
    this.setState({
      isLoggedIn: false,
      user: null,
    });
  }
}

export const authStore = new AuthStore();

이번에는 로그인 상태를 예시로 AuthStore를 만들었고, LoginPage에서 authStore를 구독하도록 연결했다.

핵심 흐름은 이렇다.

  • init()에서 this.subscribe(authStore) 호출
  • render()에서 authStore.getState()로 현재 상태 읽기
  • 로그인 여부에 따라 다른 UI 렌더링
  • 버튼 클릭 시 authStore.login() / authStore.logout() 호출

실제 구현 로그에서도 LoginPageinit()에서 authStore를 구독하고, render()에서 현재 로그인 상태를 읽어 로그인/로그아웃 UI를 분기하도록 바뀌었다. 결과적으로 버튼 클릭 시 authStore의 상태가 바뀌고, 이를 구독 중인 LoginPage가 자동으로 다시 그려지는 구조가 완성됐다.


Gemini를 교육 방식으로 사용한 점

Store를 설계할 때 받은 질문

  • “전역 Store 클래스가 가져야 할 필수 속성은 무엇일까요?”
  • “상태가 변경되었을 때, 이를 지켜보는 컴포넌트들에게 알리기 위해 어떤 패턴을 사용하는 것이 좋을까요?”
  • “여러 종류의 상태를 유연하게 담기 위해 TypeScript의 Generic <T>를 어떻게 활용하면 좋을까요?”

이 질문 덕분에 Store는 단순히 값을 저장하는 객체가 아니라, 상태 보관 + 변경 알림 + 구독 구조를 함께 가진 클래스여야 한다는 점을 정리할 수 있었다.


ComponentStore를 연결할 때 받은 질문

  • “스토어의 notify()가 실행될 때마다 컴포넌트에서는 무엇을 호출해 주면 좋을까요?”
  • “구독 해제 함수는 어디에 저장해 두었다가, 컴포넌트가 사라질 때 실행해야 할까요?”

이 질문들을 통해 상태 관리 구조는 Store만 만드는 것으로 끝나는 것이 아니라, 컴포넌트가 상태 변화를 구독하고, 필요할 때 다시 렌더링되며, 화면에서 사라질 때는 구독도 함께 해제해야 한다는 점까지 함께 고려해야 한다는 것을 이해할 수 있었다.


실행 결과

이번 구현의 결과는 단순하다.

  1. 로그인 버튼을 누른다
  2. authStore의 상태가 바뀐다
  3. notify()가 실행된다
  4. 구독 중인 LoginPage가 setState({})를 통해 다시 렌더링된다
  5. UI가 로그인 상태에 맞게 바뀐다

Store.ts가 범용 스토어 베이스 역할을 하고, Component.tsthis.subscribe(store)unmount()를 통해 구독과 해제를 처리하며, Router.ts는 페이지 이동 시 unmount()를 호출해 구독 정리를 보장하고, AuthStoreLoginPage는 이를 실제 로그인 UI에 적용하는 구조로 마무리되었다.

첫 번째 움짤은 전역 상태를 구독한 컴포넌트가 상태 변화에 따라 다시 렌더링되는 모습을 보여 준다.
두 번째 움짤은 페이지 전환 시 unmount()가 호출되어 구독이 정상적으로 해제되는지 확인한 장면이다.


배운 점

  • 전역 상태 관리는 단순히 값을 한 곳에 저장하는 것이 아니라, 상태 변경을 구독자에게 알리고 그에 따라 UI가 반응하게 만드는 구조라는 점을 이해할 수 있었다.
  • 페이지 전환 시 Router가 unmount()를 호출하도록 수정한 부분은 예상보다 중요했다. 전역 상태 관리 구조는 값 보관뿐 아니라 구독 해제 시점까지 포함해야 한다는 점을 배웠다.

앞선 가상 DOM 글이 “무엇이 바뀌었는지 판단하고 필요한 부분만 반영하는 구조”를 이해하는 과정이었다면, 이번 글은 그 위에서 “그 상태 변화가 누구에게 전달되고, 누가 다시 그려지는가”를 정리하는 과정에 가까웠다.

0개의 댓글