앵귤러 실전 강좌 #4 상태 관리 — SignalStore로 정리
#3에서는 ProductService 안에 signal로 상태를 직접 두고, 거기서 add/update/delete를 호출하면서 optimistic update까지 얹어봤습니다. 화면이 두 개 정도일 때는 그 정도가 딱 알맞고, 사실 작은 앱에서는 그게 정답이기도 합니다.
문제는 페이지가 늘어나는 순간부터 시작됩니다. 목록 페이지, 상세 페이지, 검색,필터 사이드바, 어드민의 빠른 편집 모달까지 — 모두 같은 products 데이터를 다른 모양으로 보여주려고 하면, Service 한 클래스에 시그널과 메서드가 야금야금 쌓이고 “이 값을 누가 언제 어떻게 바꾸지?“가 흐려지기 시작합니다. 이번 글에서는 그걸 @ngrx/signals의 SignalStore로 깔끔하게 정리합니다.
NgRx vs SignalStore — 본 앱에는 어느 쪽? #
고급 #5 NgRx 입문에서 짚었지만 한 번 더 정리하겠습니다. @ngrx/store + @ngrx/effects의 클래식 NgRx는 여러 feature가 같은 도메인 데이터를 공유하고, 협업 인원이 많고, 타임트래블 디버깅의 가치가 큰 앱을 위한 도구입니다. 액션,리듀서,셀렉터,이펙트라는 명시적인 단방향 흐름의 대가로 보일러플레이트가 따라옵니다.
지금 우리가 다루는 상품 관리 앱은 그 임계치를 한참 밑돕니다. 한 도메인(products), 두세 개의 화면, 한 명에서 두세 명이 만지는 규모. 이 경우에 잘 맞는 게 SignalStore입니다. 시그널을 1급으로 두고, 액션/리듀서를 따로 정의하지 않고, 상태,파생값,메서드를 한 곳에 모아 둡니다. 작은 앱에는 가볍고, 그렇다고 그냥 Service보다는 정돈된 — SignalStore는 그 사이를 채워주는 도구입니다.
@ngrx/signals 셋업
#
별도 provider 등록이 필요 없습니다. signalStore에 { providedIn: 'root' }만 적어두면 의존성 주입 트리에 알아서 들어갑니다. 패키지만 설치하면 끝입니다.
ng add @ngrx/signals@latestng add가 여의치 않다면 npm i @ngrx/signals로도 충분합니다. 다른 NgRx 패키지(@ngrx/store, @ngrx/effects)는 필요하지 않습니다. SignalStore는 클래식 NgRx와 별개로 단독으로 동작합니다.
signalStore 4총사 — withState, withComputed, withMethods, withHooks
#
본격 코드에 들어가기 전에 그림 한 장만 머리에 그려두겠습니다. SignalStore는 signalStore(...features) 형태로 만들어지고, feature는 다음 네 가지가 거의 전부입니다.
withState(initial)— 초기 상태. 안의 필드는 모두 자동으로 시그널이 됩니다withComputed(({ ...state }) => ({ ... }))— 파생값. 시그널의computed와 같은 발상withMethods((store, ...deps) => ({ ... }))— 상태를 바꾸는 동작.inject()로 디펜던시 가져오기 가능withHooks({ onInit, onDestroy })— 라이프사이클. 영속화 같은 자동 동기화에 씀
withState로 선언된 필드는 이 시점에서 시그널이 됩니다. state.items가 아니라 store.items()처럼 함수 호출로 읽고, 변경은 patchState(store, { items: ... })로 합니다. 직접 set을 호출할 일은 없습니다.
Product Store 구현 #
#3의 ProductService에 흩어져 있던 상태와 메서드를 옮겨봅시다. 우선 도메인 타입과 초기 상태부터.
export interface Product {
id: number;
name: string;
price: number;
category: string;
stock: number;
}
export interface ProductsState {
list: Product[];
selectedId: number | null;
loading: boolean;
error: string | null;
}
export const initialProductsState: ProductsState = {
list: [],
selectedId: null,
loading: false,
error: null,
};이제 store 본체입니다. 한 파일에 모든 것이 담깁니다.
import { computed, inject } from '@angular/core';
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from '@ngrx/signals';
import { ProductsApi } from './products.api';
import { Product, initialProductsState } from './product.model';
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withState(initialProductsState),
withComputed(({ list, selectedId }) => ({
count: computed(() => list().length),
selected: computed(() =>
list().find((p) => p.id === selectedId()) ?? null,
),
outOfStock: computed(() => list().filter((p) => p.stock === 0)),
})),
withMethods((store, api = inject(ProductsApi)) => ({
async load() {
patchState(store, { loading: true, error: null });
try {
const list = await api.fetchAll();
patchState(store, { list, loading: false });
} catch (err: any) {
patchState(store, { error: err.message ?? '불러오기 실패', loading: false });
}
},
select(id: number | null) {
patchState(store, { selectedId: id });
},
async add(input: Omit<Product, 'id'>) {
const tempId = -Date.now();
const optimistic: Product = { ...input, id: tempId };
patchState(store, ({ list }) => ({ list: [...list, optimistic] }));
try {
const created = await api.create(input);
patchState(store, ({ list }) => ({
list: list.map((p) => (p.id === tempId ? created : p)),
}));
} catch (err: any) {
patchState(store, ({ list }) => ({
list: list.filter((p) => p.id !== tempId),
error: err.message ?? '추가 실패',
}));
}
},
async update(id: number, patch: Partial<Product>) {
const prev = store.list().find((p) => p.id === id);
if (!prev) return;
patchState(store, ({ list }) => ({
list: list.map((p) => (p.id === id ? { ...p, ...patch } : p)),
}));
try {
await api.update(id, patch);
} catch (err: any) {
patchState(store, ({ list }) => ({
list: list.map((p) => (p.id === id ? prev : p)),
error: err.message ?? '수정 실패',
}));
}
},
async remove(id: number) {
const prev = store.list();
patchState(store, { list: prev.filter((p) => p.id !== id) });
try {
await api.remove(id);
} catch (err: any) {
patchState(store, { list: prev, error: err.message ?? '삭제 실패' });
}
},
})),
);흐름을 한 번 따라 읽어보면 — 상태 모양은 withState에 정의돼 있고, 화면에서 자주 묻는 파생값은 withComputed에 모여 있고, 실제 동작은 withMethods에 있습니다. 세 영역이 명확히 갈라져 있습니다.
withMethods의 두 번째 인자에서 inject(ProductsApi)로 API 클라이언트를 끌어옵니다. 클래식 NgRx의 Effects 클래스에서 보던 inject 패턴이 그대로 들어와 있습니다. 메서드 안에서 store.list()처럼 현재 상태를 읽을 수도 있고, patchState로 업데이트할 수도 있습니다.
patchState는 두 가지 모양을 모두 받습니다 — 객체 리터럴 patchState(store, { loading: true })로 통째로 덮어쓰는 방식과, 콜백 patchState(store, ({ list }) => ({ list: [...list, x] }))으로 이전 상태에서 파생하는 방식. 배열,객체처럼 이전 값을 참조해 새 값을 만들 때는 콜백 형태가 안전합니다.컴포넌트에서 사용 #
이제 화면 쪽이 훨씬 가벼워집니다. inject(ProductsStore)로 store를 받아오면, 안의 시그널을 그대로 템플릿에서 호출하면 됩니다.
import { Component, inject, OnInit } from '@angular/core';
import { ProductsStore } from './products.store';
@Component({
selector: 'app-products-page',
standalone: true,
template: `
@if (store.loading()) {
<p>불러오는 중…</p>
} @else if (store.error()) {
<p class="error">{{ store.error() }}</p>
} @else {
<p>총 {{ store.count() }}개 (품절 {{ store.outOfStock().length }}개)</p>
@for (p of store.list(); track p.id) {
<article (click)="store.select(p.id)">
<h3>{{ p.name }}</h3>
<p>{{ p.price | number }}원 , 재고 {{ p.stock }}</p>
<button (click)="store.remove(p.id); $event.stopPropagation()">삭제</button>
</article>
}
@if (store.selected(); as sel) {
<aside>선택됨: {{ sel.name }}</aside>
}
}
`,
})
export class ProductsPageComponent implements OnInit {
protected readonly store = inject(ProductsStore);
ngOnInit() {
this.store.load();
}
}템플릿에서 store.list(), store.count(), store.selected()처럼 그냥 시그널처럼 부릅니다. async 파이프도, Subscription 정리도 없습니다. OnPush 변경 감지와 자연스럽게 맞물려서, 시그널이 가리키는 부분만 정확히 다시 그립니다.
Optimistic update의 위치 옮기기 #
#3에서 ProductService 안에 두었던 optimistic update 로직이 이번에는 store의 withMethods로 이사 왔습니다. 사소해 보이지만 중요한 변화입니다 — 부수효과를 일으키는 영역이 store의 책임이 되면서, 컴포넌트는 “무엇을 해달라"고 호출만 하면 됩니다. 컴포넌트에 흩어졌던 try/catch나 임시 id 관리가 한 곳으로 모입니다.
update/remove도 같은 패턴입니다 — 직전 상태(prev)를 변수에 잡아두고, 낙관적으로 반영하고, 서버가 거절하면 그 변수로 되돌립니다. 컴포넌트는 그저 store.update(id, patch)라고만 적습니다. 어떤 화면이 호출하든 같은 보장을 받습니다.
Filter / Sort — withComputed로 깔끔하게
#
목록을 검색어로 거르고 가격순으로 정렬하는 흐름은 시그널 환경에 정확히 맞아떨어집니다. UI의 검색어를 store에 추가하고, 거른 결과를 withComputed로 파생시키면 됩니다.
withState({
...initialProductsState,
query: '',
sortBy: 'name' as 'name' | 'price',
}),
withComputed(({ list, query, sortBy }) => ({
filtered: computed(() => {
const q = query().trim().toLowerCase();
const base = q
? list().filter((p) => p.name.toLowerCase().includes(q))
: list();
return [...base].sort((a, b) =>
sortBy() === 'price' ? a.price - b.price : a.name.localeCompare(b.name),
);
}),
})),
withMethods((store) => ({
setQuery(q: string) { patchState(store, { query: q }); },
setSortBy(s: 'name' | 'price') { patchState(store, { sortBy: s }); },
// ... 기존 메서드들 ...
})),화면에서는 검색 입력의 (input) 이벤트를 store.setQuery($event.target.value)로 받고, 템플릿에서는 store.list() 대신 store.filtered()를 그리면 끝입니다.
<input
type="search"
[value]="store.query()"
(input)="store.setQuery($any($event.target).value)"
placeholder="상품 이름 검색"
/>
<select [value]="store.sortBy()" (change)="store.setSortBy($any($event.target).value)">
<option value="name">이름순</option>
<option value="price">가격순</option>
</select>filtered는 list/query/sortBy 셋 중 하나라도 바뀔 때만 다시 계산됩니다. 다른 곳에서 selectedId를 바꿔도 filtered는 캐시값을 그대로 돌려줍니다. 이게 시그널 기반 파생값의 가장 큰 이득입니다.
영속화 — withHooks로 sessionStorage에 자동 저장
#
선택한 상품의 id를 새로고침해도 유지하고 싶다면, withHooks의 onInit에서 시그널 변화를 듣고 sessionStorage에 동기화하면 됩니다. effect를 store 안에서 그대로 쓸 수 있습니다.
import { effect } from '@angular/core';
import { signalStore, withHooks } from '@ngrx/signals';
const SELECTED_KEY = 'products.selectedId';
export const ProductsStore = signalStore(
{ providedIn: 'root' },
// ... withState, withComputed, withMethods ...
withHooks({
onInit(store) {
// 1) 부팅 시점에 한 번 복원
const saved = sessionStorage.getItem(SELECTED_KEY);
if (saved) patchState(store, { selectedId: Number(saved) });
// 2) 이후 변화는 effect로 자동 동기화
effect(() => {
const id = store.selectedId();
if (id == null) sessionStorage.removeItem(SELECTED_KEY);
else sessionStorage.setItem(SELECTED_KEY, String(id));
});
},
}),
);withHooks의 콜백은 컴포넌트가 아니라 store의 주입 컨텍스트에서 실행됩니다. 그래서 effect가 그 시점에 자연스럽게 등록되고, store가 살아 있는 동안 — providedIn: 'root'이면 앱 수명 내내 — 자동으로 동기화됩니다. unsubscribe나 cleanup을 따로 적을 일이 없습니다.
같은 패턴으로 query, sortBy도 묶어 두면 검색 상태까지 새로고침에 살아남습니다. 다만 모든 필드를 무지성으로 영속화하면 디버깅이 까다로워지니, 사용자의 의도가 담긴 입력값(검색어, 선택 id, 정렬 기준)만 골라서 저장하는 편이 깔끔합니다.
DevTools — NgRx Store DevTools와의 호환 #
@ngrx/signals도 Redux DevTools 확장을 그대로 활용할 수 있습니다. @ngrx/signals/devtools에서 제공하는 withDevtools('products')를 store에 한 줄 추가하면, 브라우저의 Redux DevTools 패널에서 store 상태 변화를 시간순으로 들여다볼 수 있습니다.
import { withDevtools } from '@ngrx/signals/devtools';
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withDevtools('products'),
withState(initialProductsState),
// ...
);다만 클래식 NgRx의 액션 단위 타임트래블과는 결이 다릅니다. SignalStore는 액션이 따로 없으니, “어떤 메서드 호출이 어떤 변화를 일으켰는지"가 액션 이름처럼 그대로 라벨로 찍힙니다. 작은 앱에서는 충분하고, 그 이상으로 흐름 추적이 필요해지면 그때 클래식 NgRx로 옮겨가는 흐름이 자연스럽습니다.
withMethods에서 다른 store의 메서드를 호출하는 건 문제 없지만, 다른 store의 시그널을 patchState로 직접 바꾸면 안 됩니다. store 사이의 결합은 메서드 호출로만 이루어져야 합니다 — 각 store는 자기 상태의 단일 책임자입니다.마무리 #
이번 글에서는 #3의 ProductService에 정의돼 있던 상태와 동작을 SignalStore로 옮겨 정리했습니다.
- 본 앱 규모에는 클래식 NgRx보다 SignalStore가 적당. 작은 앱에서 보일러플레이트 없이 정돈된 store
withState/withComputed/withMethods/withHooks4총사로 상태,파생값,동작,라이프사이클이 한 파일에- 컴포넌트는
inject(ProductsStore)로 받아 시그널처럼 직접 호출.async파이프 불필요 - Optimistic update의 위치가 service에서 store로 옮겨가면서 컴포넌트가 가벼워짐
- 검색,정렬은
withComputed로 자동 메모이제이션. 의존하는 시그널이 바뀔 때만 재계산 withHooks+effect로 sessionStorage 영속화. cleanup 자동- Redux DevTools 호환. 큰 앱으로 자라면 클래식 NgRx로 옮겨가는 길이 열려 있음
다음 글인 **“앵귤러 실전 강좌 #5 차트와 데이터 테이블”**에서는 지금까지 다듬어온 store 위에 본격적인 시각화를 얹습니다. ng2-charts로 매출 차트를, CDK Table로 정렬,페이지네이션이 붙은 데이터 테이블을 만들고, store의 withComputed로 차트 데이터까지 깔끔하게 파생시키는 흐름을 따라가보겠습니다.