Angular 상태관리: NgRx 이해하기

yerinyerin
8 min read

NgRx 공식문서

참고 블로그

위 블로그의 설명, 코드와 NgRx 공식문서를 참고하여 작성하였습니다.

NgRx란?

  • Redux에서 영감을 받아 만들어진 Angular의 상태관리 라이브러리이다.

  • RxJS의 반응형 프로그래밍을 바탕으로 한다.

  • 앱의 상태를 하나의 출처에서, 중앙 집중식으로 관리하여 데이터가 한 방향으로 흐르게 한다. 따라서 상태 변화를 예측하고 관리하기 쉽다.

NgRx의 기본 개념들

Store

  • 앱의 상태가 저장되는 중앙 저장소.

  • 앱의 상태는 single immutable object로 저장되고 관리된다.

    • single immutable object?

    • single object : 전체 앱의 상태가 하나의 객체로 저장되며, 이 객체가 앱의 모든 상태를 포함하는 단일 진실 공급원(single source of truth)의 역할을 한다.

    • immutable object : 상태 변경 시 상태 객체를 직접 수정하지 않고 새로운 객체를 생성하여 기존 객체를 대체해야 한다.

Actions

  • 앱(주로 컴포넌트나 서비스)에서 스토어로 전달되는 정보.

  • 단방향 데이터 흐름의 시작점 역할.

  • 무엇을 해야 하는지(어떤 변화가 일어나야 하는지)를 나타낸다.

  • type 속성을 가진 객체의 형태. 필요한 경우 추가 데이터(payload)를 포함할 수 있다.

  • Action이 컴포넌트나 서비스에서 디스패치되면, 앱의 상태 변경을 트리거한다.

Reducers

  • 상태를 변경하는 순수 함수.

    • 순수 함수(pure function)?

    • 같은 입력에 대해 항상 같은 출력을 반환하며, 외부 상태를 변경하지 않는다.

    • 부작용이 없다. 따라서 API 호출, 라우팅 변경 등의 부수효과를 일으키는 작업은 리듀서에서 하면 안 되고 Effects에서 해야한다.

  • 스토어는 액션이 디스패치되면 알맞은 reducer를 통해 상태를 변경한다.

  • 현재 상태 + 액션 ➡️ 새로운 상태를 반환

  • 해당하는 액션에 따라 어떻게 변경되어야 하는지를 명시한다.

  • switch문 혹은 on을 사용해서 정의

Selectors

  • 스토어의 전체 상태에서 특정 부분(slice)만 추출하는 함수.

  • 선택된 상태를 기반으로 새로운 파생 상태를 계산할 수도 있다.

  • 메모이제이션을 사용하여 최적화. 변경되지 않으면 이전에 계산된 결과를 재사용한다.

  • selector를 통해 컴포넌트는 스토어의 구체적인 구조를 알 필요가 없는 것.

Effects

  • 부수효과를 일으키는 작업들(HTTP 요청, 로컬 스토리지 접근 등)을 처리한다.

  • Effects 덕분에 리듀서는 순수함수로 유지될 수 있는 것이다.

  • 스토어에 디스패치된 액션을 리듀서처럼 감지하고 반응하여 부수효과를 처리하고 나서 ➡️ 새로운 액션을 디스패치하여 앱의 상태를 업데이트한다.

  • RxJS Observable을 사용하여 구현된다.

  • 순수한 상태 변경은 리듀서에서, 부수 효과는 Effects에서 처리함으로써 관심사를 분리한다!

예시 프로젝트로 NgRx의 흐름 파악하기

예시 프로젝트의 전체 코드는 참고 블로그에서 확인 가능.

프로젝트 코드 뜯어보기

  1. 액션 정의
import { createAction, props } from '@ngrx/store';
import { Item } from './item.model';

export const loadItems = createAction('[Item List] Load Items');
export const loadItemsSuccess = createAction(
  '[Item List] Load Items Success',
  props<{ items: Item[] }>()
);
export const addItem = createAction(
  '[Item List] Add Item',
  props<{ item: Item }>()
);
export const addItemSuccess = createAction(
  '[Item List] Add Item Success',
  props<{ item: Item }>()
);

createAction 함수를 활용하여 액션을 정의한다.

  • 첫번째 파라미터는 액션의 고유한 식별자 또는 타입이다.

    • [기능 영역] 액션 설명 형식으로 작성한다.

    • 일반적으로 대괄호 안에 기능 영역을 명시하고, 그 뒤에 구체적인 액션 설명을 붙인다.

    • e.g. [User] Load User, [Auth] Login Success, [Todo List] Add Todo

  • 두번째 파라미터는 페이로드이다. 페이로드는 반드시 객체 형태일 필요는 없고, 단일 값이나 배열일 수도 있다. 혹은 페이로드가 없을 수도 있다.

  • createActionGroup으로 관련된 여러 액션을 그룹화해서 만들 수 있다.

export const TodoActions = createActionGroup({
  source: 'Todo List',
  events: {
    'Add Todo': props<{ text: string }>(),
    'Remove Todo': props<{ id: number }>(),
    'Toggle Todo': props<{ id: number }>(),
    'Load Todos': emptyProps(),
    'Load Todos Success': props<{ todos: Todo[] }>(),
    'Load Todos Failure': props<{ error: string }>(),
  }
});
  • source는 액션 그룹의 도메인을 나타낸다. createAction에서 대괄호 안에 넣었던 기능 영역과 유사.

  • events 객체 내에서 각 액션 타입을 정의. 페이로드가 필요하지 않은 경우에는 emptyProps()를 사용한다.

  1. 리듀서 정의
import { createReducer, on, Action } from '@ngrx/store';
import { loadItemsSuccess, addItemSuccess } from './item.actions';
import { State, initialState } from './item.model';

const _itemReducer = createReducer(
  initialState,
  on(loadItemsSuccess, (state, { items }) => ({ ...state, items })),
  on(addItemSuccess, (state, { item }) => ({ ...state, items: [...state.items, item] }))
);

export function itemReducer(state: State | undefined, action: Action) {
  return _itemReducer(state, action);
}
  • createReducer의 첫 번째 파라미터는 초기 상태.

  • 그 다음 파라미터부터는 on 함수로 정의된 액션-리듀서 페어를 전달한다.

  • on 함수는 특정 액션에 대한 상태 변경 로직을 순수함수로 정의한다.

  1. Effects 정의
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { loadItems, loadItemsSuccess, addItem, addItemSuccess } from './item.actions';
import { map, mergeMap } from 'rxjs/operators';
import { ItemService } from './item.service';
import { Item } from './item.model';

@Injectable()
export class ItemEffects {
  constructor(
    private actions$: Actions,
    private itemService: ItemService
  ) {}
  loadItems$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadItems),
      mergeMap(() => this.itemService.getAll().pipe(
        map((items: Item[]) => loadItemsSuccess({ items }))
      ))
    )
  );
  addItem$ = createEffect(() =>
    this.actions$.pipe(
      ofType(addItem),
      mergeMap((action) => this.itemService.addItem(action.item).pipe(
        map((item: Item) => addItemSuccess({ item }))
      ))
    )
  );
}
  • Effects는 부수 효과를 처리하기 위해 다른 서비스나 의존성을 주입받아 사용한다.

  • @Injectable()을 붙이는 이유

    • NgRx는 Injectable() 데코레이터가 붙은 클래스만을 유효한 Effects로 취급하고 내부적으로 관리, 실행한다.

    • 이를 통해 NgRx는 어떤 클래스가 Effects인지 구분하고, 적절히 초기화하고 실행할 수 있게 된다.

  • action$는 NgRx의 Actions 서비스의 인스턴스로, 앱에서 디스패치되는 모든 액션의 스트림.

  • ofType을 사용해서 그 중에서도 특정 액션만 필터링.

  • Effect 내부에서 새로운 액션(loadItemsSuccess, addItemSuccess)을 디스패치.

  1. Selectors 생성
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { State } from './item.model';

export const selectItemState = createFeatureSelector<State>('items');
export const selectAllItems = createSelector(
  selectItemState,
  (state: State) => state.items
);
  • createFeatureSelector: 피처 상태 선택자. 커다란 Store 내부에 여러 피처 상태가 있을 수 있는데, 그 중에서 'items' 키에 해당하는 부분을 선택한다.

    • 피처 상태?

    • 앱의 전체 상태 중 특정 기능(feature)에 관련된 부분을 말한다.

    • 큰 앱의 상태를 관리하기 쉽게 나누어 각 기능별로 관련 상태를 그룹화한 것.

  • createSelector

    • 전달되는 첫번째 파라미터는 앞서 정의한 피처 상태 셀렉터.

    • 그 중에서 items 배열만 반환.

  • 단순히 상태값을 선택하는 것 이상을 할 수도 있다.

// 기본 피처 셀렉터
const selectItemState = createFeatureSelector<ItemState>('items');
const selectUserState = createFeatureSelector<UserState>('users');

// 1. 단순 상태 선택
export const selectAllItems = createSelector(
  selectItemState,
  (state: ItemState) => state.items
);

// 2. 상태 변환 (데이터 가공)
export const selectItemNames = createSelector(
  selectAllItems,
  (items: Item[]) => items.map(item => item.name)
);

// 3. 여러 상태 조합 -> 이때 파라미터의 순서를 맞추는 게 중요!
// 여러 상태를 조합하여 새로운 객체를 반환한다.
export const selectItemsWithUserInfo = createSelector(
  selectAllItems,
  selectUserState,
  (items: Item[], userState: UserState) => items.map(item => ({
    ...item,
    isOwnedByCurrentUser: item.ownerId === userState.currentUser?.id
  }))
);

// 4. 계산된 값 도출
export const selectTotalItemsCount = createSelector(
  selectAllItems,
  (items: Item[]) => items.length
);

// 5. 매개변수화된 선택
export const selectItemById = (id: string) => createSelector(
  selectAllItems,
  (items: Item[]) => items.find(item => item.id === id)
);
  1. 컴포넌트에서 사용
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { loadItems, addItem } from './item.actions';
import { Item } from './item.model';
import { selectAllItems } from './item.selectors';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-item-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './item-list.component.html',
  styleUrls: ['./item-list.component.css'],
})
export class ItemListComponent implements OnInit {
  items$: Observable<Item[]>;
  constructor(private store: Store) {
    this.items$ = this.store.select(selectAllItems);
  }
  ngOnInit() {
    console.log('Component initialized');
    this.store.dispatch(loadItems());
    this.items$.subscribe(items => console.log(items));
  }
  addItem(name: string) {
    const newItem: Item = { id: Date.now(), name };
    this.store.dispatch(addItem({ item: newItem }));
  }
}

컴포넌트에서 사용할 때는 store를 주입 받아 사용한다. 그리고 알맞게 액션을 dispatch한다.

  1. app.config.ts에 provider 설정하기
import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { itemReducer } from './item/item.reducer';
import { ItemEffects } from './item/item.effects';
import { InMemoryDataService } from './item/in-memory-data.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideHttpClient(),
    importProvidersFrom(HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService)),
    provideStore({ items: itemReducer }), // 📍
    provideEffects([ItemEffects]), // 📍
    provideStoreDevtools({
      maxAge: 25,
      logOnly: true,
    }),
  ],
};

다만,

Although you can register reducers in the provideStore function, we recommend keeping provideStore empty and using the providedState function to register feature states in the root providers array. 라고 한다.

provideStore()를 비워두고 개별 상태를 provideState()로 등록하는 것을 추천한다는 말인데, 이렇게 하면:

  • 각 기능의 상태를 독립적으로 관리 가능

    • 이는 애플리케이션이 커질 때 상태 관리를 더 모듈화하고 유지보수하기 쉽게 만든다.
  • 지연 로딩 (Lazy Loading) 지원

    • 개별 상태를 provideState()로 등록하면 필요에 따라 상태를 지연 로딩할 수 있다.

    • 이는 초기 로딩 시간을 줄이고 애플리케이션의 성능을 향상시킬 수 있다.

  • 확장성

    • 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 새로운 provideState() 호출을 추가하기만 하면 된다.
  • 최적화

    • NgRx가 내부적으로 각 상태의 변경을 더 효율적으로 추적하고 관리할 수 있다.
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideHttpClient(),
    importProvidersFrom(HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService)),
    provideStore(), // 📍
    provideState({ name: 'items', reducer: itemReducer }), // 📍
    provideEffects([ItemEffects]),
    provideStoreDevtools({
      maxAge: 25,
      logOnly: true,
    }),
  ],
};

흐름 파악하기

  • Action Dispatch

    • 컴포넌트나 서비스에서 loadItems 액션을 디스패치
  • Effect Triggering

    • loadItems 액션이 관련 Effect를 트리거
  • Side Effect Execution

    • Effect가 ItemServicegetAll() 메소드를 호출하여 아이템 목록을 가져옴
  • Success Action Dispatch

    • 데이터 로드가 성공하면 Effect가 loadItemsSuccess 액션을 디스패치
  • Reducer Execution

    • loadItemsSuccess 액션에 대응하는 리듀서가 실행되어 상태 업데이트
  • State Update

    • 리듀서가 새로운 아이템 목록으로 상태를 업데이트
  • Component Update

    • 상태 변경을 구독하고 있는 컴포넌트들이 새로운 데이터로 업데이트

정리해보자면,

  1. Effects 없는 경우: 액션 ➡️ 리듀서 ➡️ 상태 업데이트

  2. Effects 있는 경우: 액션 ➡️ Effects ➡️ 새 액션 디스패치 ➡️ 리듀서 ➡️ 상태 업데이트

Redux와의 차이

NgRx는 React의 Redux에서 영향을 받아 만들어졌다고 한다. 이 둘을 비교해보자.

  1. 부수효과 처리

    • Redux는 미들웨어(redux-thunk, redux-saga)를 사용한다.

    • NgRx는 Effects를 사용하여 부작용을 처리한다.

  2. 불변성 관리 용이

    • NgRx는 RxJS를 활용하여 불변성을 관리하기 더 쉽다.

    • Store의 상태를 Observable로 제공.

    • Selector에서 pipe, RxJS 연산자를 사용하여 상태 변환.

    • Effects에서 비동기 작업을 처리하고 새 액션 디스패치.

마무리

NgRx로 Angular의 상태 관리 방법에 대해 자세히 알아보았다.

  • 복잡한 앱의 상태를 어떻게 효과적으로 관리할 수 있는지 알 수 있었다. Redux와 Zustand에 대한 부가적인 이해는 덤! 🫢

  • 상태 관리라는 공통된 문제를 해결하기 위해 서로 다른 라이브러리들이 각기 다른 접근 방식을 가지고 있지만, 그 핵심에는 예측 가능하고 추적 가능한 상태 변화를 추구한다는 공통점이 있다. 이것만 이해하면 또 다른 상태 관리 툴도 쉽게 이해할 수 있을 것 같다.

다음으로는 Angular의 새로운 상태 관리 방식인 Signals에 대해 공부하고 글을 쓸 예정이다.

0
Subscribe to my newsletter

Read articles from yerin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

yerin
yerin