앵귤러 고급 강좌 #5 NgRx 입문 — Store, Action, Reducer, Effect
지난 시간에는 RxJS 깊이 — operator의 결을 한 번 들여다봤습니다. 이번 시간은 그 위에서 함께 쓰는 상태 관리 라이브러리, NgRx입니다.
기초 #5 Service/DI에서 봤듯, 앵귤러는 별도 라이브러리 없이도 Service + Signals만으로 작은 앱의 상태를 꽤 깔끔하게 다룰 수 있습니다. 사실 많은 앱에서 그게 정답입니다. 그럼에도 NgRx를 따로 한 강의로 다루는 이유는, 앱이 일정 규모를 넘기는 순간 “상태가 어디서 어떻게 바뀌고 있는지"를 추적하는 일 자체가 별도의 도구를 요구하기 시작하기 때문입니다.
이번 글에서는 NgRx가 등장한 배경인 Redux 패턴부터 시작해, 패키지 구성, Store / Action / Reducer / Selector / Effect의 역할, 모던 standalone 셋업, 그리고 더 가벼운 대안인 Component Store와 SignalStore까지 둘러보겠습니다.
Service + Signal로 안 되는 지점 #
기초 #5에서 만들었던 그 패턴 — providedIn: 'root' Service에 signal + computed, 메서드로만 변경을 허용하는 작은 store — 은 작은 앱에서는 이미 충분합니다. 문제가 시작되는 지점은 대체로 다음과 같습니다.
- 같은 데이터를 여러 feature가 서로 다른 모양으로 바라보기 시작한다
- “이 값을 누가 언제 바꿨지?” 추적이 안 된다 — 여러 컴포넌트가 직접
update를 호출하기 때문에 - 비동기 흐름(로딩/에러/취소)이 한 페이지에 동시에 여러 개 떠 있다
이쯤 되면 **“상태 변경을 한 곳으로 모으고, 그 변경에 이름을 붙이고, 흐름을 로그/타임트래블로 들여다볼 수 있는 도구”**가 필요해집니다. 그게 Redux이고, 앵귤러판이 NgRx입니다.
Redux 패턴 한 번 — 세 가지 원칙 #
- Single source of truth — 앱 전체의 상태를 하나의 store 안에 둔다
- Immutable state — 상태는 직접 수정하지 않고 항상 새 객체로 교체한다
- 단방향 흐름 — 컴포넌트가 Action을 dispatch하면, Reducer가 새로운 state를 만들고, 컴포넌트는 Selector를 통해 읽기만 한다
흐름을 한 그림으로 보면 이렇습니다.
Component ──dispatch(Action)──► Store ──► Reducer(prev, action) ──► newState
▲ │ │
└──── select(Selector) ────────┴────────────────────────────────────┘비동기 부수효과(API 호출, 라우팅)가 끼어드는 단계가 Effect입니다. Effect는 액션을 듣고 → 비동기 작업을 수행하고 → 결과를 또 다른 액션으로 dispatch합니다. 부수효과조차 “액션 → 액션” 이라는 같은 언어로 표현됩니다. 이 일관성이 큰 앱에서 빛나는 이유입니다.
NgRx 패키지 구성 #
NgRx는 한 덩어리가 아니라 여러 패키지로 나뉘어 있습니다. 필요한 만큼만 골라 씁니다.
@ngrx/store— Redux 스타일 글로벌 store. Action / Reducer / Selector@ngrx/effects— 비동기 부수효과를 액션 흐름으로 다루는 도구@ngrx/entity— 컬렉션 상태(목록, id 매핑)를 위한 어댑터. CRUD 보일러플레이트 줄이기@ngrx/component-store— feature/컴포넌트 단위의 가벼운 로컬 store@ngrx/signals— 모던 앵귤러용 새 모델. SignalStore. signal 친화적 설계@ngrx/router-store— 라우터 상태를 store에 동기화
설치는 ng add @ngrx/store@latest, ng add @ngrx/effects@latest. ng add가 standalone 셋업까지 같이 잡아줍니다. 이 글에서는 store + effects를 중심에 두고 흐름을 보고, 마지막에 Component Store와 SignalStore를 짧게 비교하겠습니다.
예제 도메인 — Todos #
본격 진입 전에 도메인을 하나 잡습니다. 할 일(todo) 목록 — 사용자가 todo를 추가/삭제하고, 서버에서 처음 리스트를 받아옵니다.
export interface Todo { id: number; title: string; done: boolean; }
export interface TodosState {
items: Todo[];
loading: boolean;
error: string | null;
}
export const initialTodosState: TodosState = {
items: [],
loading: false,
error: null,
};Action — 무엇이 일어났는지에 이름을 붙인다 #
Action은 “무엇이 일어났는가"를 표현하는 평범한 객체입니다. createAction 헬퍼로 타입과 payload를 깔끔하게 정의합니다.
import { createAction, props } from '@ngrx/store';
import { Todo } from './todos.state';
export const loadTodos = createAction('[Todos] Load');
export const loadTodosSuccess = createAction(
'[Todos] Load Success',
props<{ items: Todo[] }>(),
);
export const loadTodosFailure = createAction(
'[Todos] Load Failure',
props<{ error: string }>(),
);
export const addTodo = createAction('[Todos] Add', props<{ title: string }>());
export const removeTodo = createAction('[Todos] Remove', props<{ id: number }>());[Todos] prefix는 DevTools 로그에서 어디서 일어난 액션인지 한눈에 보기 위한 컨벤션입니다. [화면/feature 이름] 동사 형태가 표준이고, props<{ ... }>()가 페이로드 타입을 부여해주기 때문에 reducer/effect/component 어디에서 액션을 다루든 타입이 끝까지 따라옵니다.
Reducer — 새 state를 만든다 #
Reducer는 (이전 state, action) => 새 state 형태의 순수 함수입니다.
import { createReducer, on } from '@ngrx/store';
import { initialTodosState } from './todos.state';
import * as TodosActions from './todos.actions';
export const todosReducer = createReducer(
initialTodosState,
on(TodosActions.loadTodos, (state) => ({ ...state, loading: true, error: null })),
on(TodosActions.loadTodosSuccess, (state, { items }) => ({
...state, loading: false, items,
})),
on(TodosActions.loadTodosFailure, (state, { error }) => ({
...state, loading: false, error,
})),
on(TodosActions.addTodo, (state, { title }) => ({
...state,
items: [...state.items, { id: Date.now(), title, done: false }],
})),
on(TodosActions.removeTodo, (state, { id }) => ({
...state,
items: state.items.filter((t) => t.id !== id),
})),
);핵심은 immutable 업데이트입니다. state.items.push(...)처럼 mutate 하지 않고 항상 spread로 새 객체/새 배열을 만들어 돌려줍니다. OnPush와 시그널이 변화 감지에 쓰는 것이 모두 참조 비교라는 점을 떠올리면 자연스러운 규칙입니다.
switch 문으로 작성했습니다. 지금은 createReducer + on() 조합이 표준이고, 새 코드라면 옛 스타일을 굳이 따라할 필요 없습니다. 내부적으로는 같지만 타입 추론과 가독성이 훨씬 좋습니다.Selector — 컴포넌트는 select로만 데이터를 본다 #
컴포넌트가 store에서 데이터를 꺼낼 때는 직접 state.todos.items 라고 읽지 않고 Selector 라는 작은 함수를 거칩니다. 이 한 겹이 두 가지를 해줍니다 — 상태 모양이 바뀌어도 selector만 고치면 컴포넌트는 손대지 않아도 되고, memoization으로 입력이 같으면 이전 결과를 그대로 돌려줍니다.
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodosState } from './todos.state';
export const selectTodosState = createFeatureSelector<TodosState>('todos');
export const selectTodos = createSelector(selectTodosState, (s) => s.items);
export const selectLoading = createSelector(selectTodosState, (s) => s.loading);
export const selectRemainingCount = createSelector(
selectTodos,
(items) => items.filter((t) => !t.done).length,
);createSelector는 위쪽 selector의 출력이 참조상 같으면 아래 함수를 다시 실행하지 않습니다. 파생 계산이 비싸도 안전합니다.
Effects — 부수효과를 액션 흐름으로 #
Reducer는 순수 함수라서 거기에 HTTP 호출을 끼울 수 없습니다. API 호출, 라우팅, 로컬 스토리지 같은 부수효과는 Effects의 영역입니다.
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { TodosApi } from './todos.api';
import * as TodosActions from './todos.actions';
@Injectable()
export class TodosEffects {
private readonly actions$ = inject(Actions);
private readonly api = inject(TodosApi);
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(TodosActions.loadTodos),
switchMap(() =>
this.api.fetchAll().pipe(
map((items) => TodosActions.loadTodosSuccess({ items })),
catchError((err) =>
of(TodosActions.loadTodosFailure({ error: err.message })),
),
),
),
),
);
}흐름은 한 줄로 읽힙니다 — loadTodos 액션이 들어오면, API를 호출하고, 결과를 loadTodosSuccess 또는 loadTodosFailure로 다시 dispatch합니다. 컴포넌트는 그저 loadTodos를 dispatch하고 selectLoading / selectTodos만 보면 끝입니다.
switchMap은 새 요청이 들어오면 이전 요청을 취소합니다. 검색창처럼 빠르게 연속 호출되는 케이스에 자연스럽고, 지난 글의 RxJS operator 분류가 여기서 그대로 이어집니다.
셋업 — 모던 standalone 패턴 #
NgRx도 standalone 시대에 맞춰 provideStore / provideEffects 형태의 함수형 provider를 제공합니다. app.config.ts 한 곳에서 모두 잡아줍니다.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { todosReducer } from './todos/todos.reducer';
import { TodosEffects } from './todos/todos.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideStore({ todos: todosReducer }),
provideEffects([TodosEffects]),
provideStoreDevtools({ maxAge: 50, connectInZone: false }),
],
};feature가 늘어나면 provideState('feature-name', featureReducer)를 라우트 단위로 붙여 lazy load 할 수도 있습니다. 큰 앱에서는 처음부터 feature 단위로 store를 쪼개 두는 편이 유지보수에 유리합니다.
컴포넌트에서 — dispatch와 select #
컴포넌트는 두 가지만 합니다. 액션을 dispatch하고, selector로 state를 본다.
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Store } from '@ngrx/store';
import * as TodosActions from './todos.actions';
import { selectLoading, selectRemainingCount, selectTodos } from './todos.selectors';
@Component({
selector: 'app-todos-page',
standalone: true,
imports: [AsyncPipe],
template: `
@if (loading$ | async) {
<p>불러오는 중…</p>
} @else {
@for (todo of (todos$ | async) ?? []; track todo.id) {
<p>{{ todo.title }} <button (click)="remove(todo.id)">삭제</button></p>
}
<p>남은 할 일: {{ remaining$ | async }}</p>
}
`,
})
export class TodosPageComponent {
private readonly store = inject(Store);
protected readonly todos$ = this.store.select(selectTodos);
protected readonly loading$ = this.store.select(selectLoading);
protected readonly remaining$ = this.store.select(selectRemainingCount);
ngOnInit() { this.store.dispatch(TodosActions.loadTodos()); }
remove(id: number) { this.store.dispatch(TodosActions.removeTodo({ id })); }
}selector의 결과는 Observable이라 async 파이프로 흘려보냈습니다. 시그널 친화적인 코드를 쓰고 싶다면 store.selectSignal(selectTodos)로 받아 signal()처럼 쓸 수도 있습니다. OnPush와 잘 맞물립니다.
Component Store — 더 가볍고, 더 가까운 #
전체 글로벌 store가 부담스럽고 그냥 Service보다는 좀 더 정돈된 도구가 필요할 때 쓰는 것이 @ngrx/component-store 입니다. 컴포넌트의 providers에 등록하면 그 컴포넌트가 살아 있는 동안만 살아 있는 로컬 store가 됩니다. 페이지를 떠나면 함께 사라지는, 로컬 상태에 어울리는 모델입니다. 액션/리듀서 보일러플레이트 없이 updater / effect 메서드만으로 구성하고, 한 컴포넌트의 임시 상태(편집 폼, 위저드 단계 등)에 잘 맞습니다.
SignalStore — 모던 앵귤러의 새 표준 #
가장 최근에 나온 모델이 @ngrx/signals — 줄여서 SignalStore입니다. signal을 1급으로 두고 RxJS 의존을 최소화한 새 설계입니다. 액션/리듀서/셀렉터를 따로 정의할 필요 없이, 상태/계산값/메서드가 한 곳에 모입니다.
import { computed, inject } from '@angular/core';
import {
signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';
import { TodosApi } from './todos.api';
import { Todo } from './todos.state';
export const TodosStore = signalStore(
{ providedIn: 'root' },
withState({ items: [] as Todo[], loading: false, error: null as string | null }),
withComputed(({ items }) => ({
remaining: computed(() => items().filter((t) => !t.done).length),
})),
withMethods((store, api = inject(TodosApi)) => ({
async load() {
patchState(store, { loading: true, error: null });
try {
const items = await api.fetchAllAsPromise();
patchState(store, { items, loading: false });
} catch (err: any) {
patchState(store, { error: err.message, loading: false });
}
},
add(title: string) {
patchState(store, ({ items }) => ({
items: [...items, { id: Date.now(), title, done: false }],
}));
},
})),
);컴포넌트에서는 inject(TodosStore)로 받아 시그널처럼 그대로 사용합니다 — store.items(), store.loading(), store.remaining(). 모던 앵귤러의 새 프로젝트라면 SignalStore를 첫 후보로 두는 흐름이 점점 강해지고 있습니다.
다만 DevTools 타임트래블과 Action 단위 감사 로그는 여전히 클래식 NgRx의 강점입니다. 큰 팀에서 “어떤 액션이 어떤 순서로 일어났는지"를 끝까지 추적해야 하는 환경이라면 그쪽이 여전히 무게가 있습니다.
언제 NgRx를 써야 하나 — 임계치 가이드 #
세 가지 모델을 한 줄씩 정리하면 이렇습니다.
- Service + Signal — 작은 앱, 한두 화면이 공유하는 단순 상태
- Component Store / SignalStore — feature 단위 상태, 비동기 흐름이 어느 정도 있는 화면
- NgRx Store + Effects — 여러 feature가 같은 도메인 데이터를 공유, 협업 규모가 크고 흐름 추적/감사 가치가 큰 앱
**“NgRx를 써야 하나?”**를 빠르게 가늠하는 휴리스틱입니다.
- 같은 데이터를 보는 feature가 3개 이상, 화면 간 실시간 동기화가 필요하다
- 비동기 흐름(로딩/에러/취소)이 한 페이지에 동시에 여러 개 떠 있다
- QA에서 재현 어려운 버그를 타임트래블 디버깅으로 추적할 가치가 있다
- 팀이 4명 이상이고, “상태가 어디서 어떻게 바뀌는지” 컨벤션을 강제하고 싶다
세 개 이상에 해당하면 NgRx Store를 도입할 만합니다. 한두 개라면 SignalStore가 더 가볍고 빠릅니다. 작은 앱에 NgRx를 들이는 것은 거의 항상 과한 선택입니다.
마무리 #
이번 글에서는 NgRx의 입문을 한 번에 훑었습니다.
- 상태 흐름이 복잡해지면 Redux 패턴의 명시성이 가치를 만든다
- Single source of truth / immutable / 단방향 세 원칙
- Action은 무엇이 일어났는지, Reducer는 새 state, Selector는 읽기, Effect는 부수효과를 액션 흐름으로
- 모던 셋업은
provideStore+provideEffectsstandalone 패턴 - Component Store는 feature 단위 가벼운 store, SignalStore는 signal 친화적 새 모델
- 작은 앱은 Service + Signal로 충분하고, NgRx는 협업 규모와 도메인 복잡도가 임계치를 넘었을 때 도입합니다.
다음 글인 “앵귤러 고급 강좌 #6 SSR과 하이드레이션"에서는 시선을 클라이언트 바깥으로 돌립니다. 앵귤러 앱을 서버에서 미리 그려 보내는 SSR, 그리고 Angular 17 이후 표준이 된 non-destructive hydration, incremental hydration까지 — 첫 페인트와 SEO를 동시에 잡는 방법을 차근차근 살펴보겠습니다.