Angular @ngrx/component-store

라코마코·2021년 9월 25일
1

Angular

목록 보기
3/6

Ngrx Component Store는 Ngrx Store가 컴포넌트에 독립적으로 붙어 상태를 관리하는 라이브러리 입니다.

Ngrx Component Store에 대해서 설명하기에 앞서서 왜 이 라이브러리가 필요한지에 대해서 먼저 설명을 해볼까 합니다.

먼저 저희 모듈에 NGRX Store가 있고 부모 컴포넌트에서 Depth가 6 이상 떨어진 자식 컴포넌트가 있는 상황을 가정해보겠습니다.

부모 컴포넌트에선 text 상태를 가지고 있고 자식 컴포넌트에서 이 text 상태를 input으로 받아 어떤 연산을 처리하고 있는 상황입니다.

이렇게 Depth가 길게 늘어져있다면 props drilling을 피하기 위해서 상태를 Store로 끌어올리곤 합니다.

text 상태를 Store에 끌어올려 props drilling을 깔끔하게 피하고 부모,자식이 직접 text 상태를 구독해서 사용하게끔 구조가 변경되었습니다.

이렇게 상태와 컴포넌트가 1:1로 딱 떨어진다면 별다른 문제가 없습니다. 정말 심플하게 관리할 수 있죠.

하지만 컴포넌트가 여러개로 늘어나면 조금 복잡해집니다.

컴포넌트가 N개이상 동적으로 생성되는 상황으로 변경되었다고 가정해보겠습니다.

NGRX Store는 모듈의 꼭대기에 생성되기 때문에 복수개의 Store를 생성하는건 어렵습니다.

따라서 각 컴포넌트의 상태는 Store 내에서 배열로써 관리하게 됩니다.

배열의 데이터를 읽어오는건 좋지만, 컴포넌트가 수정,삭제 되는 상황이면 복잡도가 한츰 더 올라가집니다.

잘 사용하던 2번 컴포넌트가 삭제되었습니다.

컴포넌트가 삭제되면 싱크를 맞추기 위해 Store의 상태도 삭제해줘야 하는데요. 이때 삭제할 컴포넌트가 사용하던 상태가 Store배열에서 몇번째 index인지 알아내어 Action을 통해서 삭제를 하는 로직이 추가되야합니다.

즉 각 컴포넌트들은 자신이 Store의 몇번째 index 상태를 구독하고 있는지에 대해서 항상 알고 있어야 하고 이를 처리하기 위한 로직도 추가되어야합니다.

복잡도가 올라가고 있습니다.

왜 이런 상황이 나타나고 있을까?

각 컴포넌트의 상태는 컴포넌트에서 관리를 해야하는데요
컴포넌트간 props drilling을 피하기 위해 상태를 Store로 끌어올려서 관리하고 있기 때문에 이런 일이 발생하고 있습니다.

더 정확하게는 컴포넌트의 생명주기에 맞춰서 Store의 상태도 싱크를 맞춰줘야 하기 때문에 코드의 복잡도가 증가하게 된겁니다.

ngrx-component-store

Ngrx Component Store는 Component 내에 NGRX Store를 붙여 상태를 관리하게끔 하는 라이브러리입니다.

Component가 생성되면 Store도 생성되고, Component가 삭제되면 Store도 함께 삭제됩니다.

Store가 Component의 생명주기와 함께하기 때문에 위와 같은 문제가 깔끔하게 해결됩니다. (대박이죠?)

사용법

ngrx/component-store는 npm을 통해서 설치하는것이 가능합니다.

npm install @ngrx/component-store --save

컴포넌트 초기화

ComponentStore를 초기화 하는 방법으론 2가지 방법이 있는데

  1. ComponentStore를 상속하여 초기화 하는 방법
  2. ComponentStore를 providers에 넣어 초기화 하는 방법
export interface MovieState {
  movie: Movie[];
}

@Injectable()
export class MovieStore extends ComponentStore<MovieState>{
  constructor(){
    super({movies:[]}); // 컴포넌트 스토어 초기화
  }
}
@Component({
  providers:[ComponentStore]
})
export class MoviesPageComponent{
  constructor(private readonly componentStore: ComponentStore<{movies:Movie[]}>())
  
  ngOnInit(){
    this.componentStore.setState({movies:[]}) // ngOnInit에서 초기화
  }
}

Store에서 값을 읽어내는 방법

ngrx와 마찬가지로 ComponentStore도 select 를 사용하여 값을 읽어냅니다.

하지만 ComponentStore의 select는 method라는 점이 차이점입니다.

export interface MoviesState {
  movies: Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesStore> { 
  readonly movies$: Observable<Movie[]> = this.select(state => state.movies)
  constructor() {
    super({movies:[]});
  }
}

...

@Component({
  ...
  providers: [MoviesStore],
})
export class MoviesPageComponent { 
  movies$ = this.moviesStore.movies$; // select 한 값을 가져와 사용
  
  constructor(private readonly moviesStore: MoviesStore) {}
}

또한 select는 select를 사용하여 조합하는것도 가능합니다. select method는 ngrx의 createSelector와 동일하게 동작합니다.

export interface MoviesStore {
  movies: Movie[];
  userPreferredMoviesIds: string[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesStore> {
  readonly movies$ = this.select(state => state.movies);
  readonly userPreferredMovieIds$ = this.select(state => state.userPreferredMoviesIds);
  
  readonly userPreferredMovies$ = this.select(this.movies$, this.userPreferredMovieIds$,(movies,ids)=>movies.filter(movie => ids.includes(movie.id));
  
  constructor(){
    super({ movies: [], userPreferredMoviesIds: []});
  }
}

select 메소드는 BehaviorSubject처럼 동작합니다. ( 구독 하자마자 값을 방출합니다. )

Store는 한개인데 여러 컴포넌트에서 사용하기 때문에 상태가 변경될 때 마다 값을 방출하게 되기 때문에 만약 최종적으로 변경된 상태만 받고 싶을 경우엔 debounce 속성을 사용면 됩니다.

readonly movies$ = this.select(state => state.movies, {debounce: true});

Store에 값을 쓰는 방법

컴포넌트에 값을 쓰는 방법은 3가지가 있습니다.

  1. updater 사용하기
  2. setState 사용하기
  3. patchState 사용하기

updater를 사용하는 방법은 ngrx의 reducer내의 on 메소드를 사용하는것과 비슷합니다.

ComponentStore updater 함수를 사용하면 상태를 변경하는 함수가 리턴됩니다.

사용법은 updater에 첫번째 인자에 현재 Store의 state를 받고 2번째 인자에 변경할 값을 받는 인자를 가진 함수를 넣고 리턴 값으로 변경될 state를 리턴하면 됩니다.

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  
  // updater 함수를 사용하여 Store 상태를 변경하는 함수를 만든다.
  readonly addMovie = this.updater((state,movie: Movie)=> ({
    movies: [...state.movies,movie],
  }));
  
  constructor() {
    super({movies: []});
  }
  
}

---

@Component({
  ...
  providers: [MoviesStore]
})
export class MoviesPageComponent {
  constructor(private readonly moviesStore: MoviesStore) {}
  
  add(movie: string) {
    this.moviesStore.addMovie({name: movie, id: generateId() });
  }
}

setState를 사용하는 방법은 React의 setState와 비슷합니다.

setState을 사용하여 전체 Store의 값을 변경하면 됩니다.

@Component({
  providers: [ComponentStore],
})
export class MoviesPageComponent implements OnInit {
  constructor(private readonly componentStore: ComponentStore<MoviesState>){}
  
  ngOnInit() {
    this.componentStore.setState({movies:[]});
  }
  
  addMovie(movie: Movie) {
    this.componentStore.setState((state) => {
      return {
        ...state,
        movies: [...state.movies, movie]
      }
    });
  }
}

patchState는 Store 상태의 일부만 변경할때 사용하는 메소드입니다.

interface MoviesState {
  movies: Movie[];
  selectedMovie: string | null;
}

@Component({
  providers: [ComponentStore],
})
export class MoviesPageComponent implements OnInit {
  constructor(private readonly componentStore: ComponentStore<MoviesState>){}
  
  ngOnInit() {
    this.componentStore.setState({movies: [], selectedMovie: null});
  }
  
  updatedSelectedMovie(selectedMovieName: string) {
    this.componentStore.patchState({ selectedMovie: selectedMovieName });
  }
}

Effects

ComponentStore에도 ngrx/effect와 같은 기능을 수행하는 effect가 있습니다.

effect는 ComponentStore의 effect 메소드를 이용하여 만드는데 인자로는 effect에 들어오는 값을 Observable 형식으로 받아 사용합니다.

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  constructor(private readonly moviesService: MoviesService) {
    super({movies: []});
  }
  
  readonly getMovie = this.effect((movieId$: Observable<string>) => {
    return movieId$.pipe(
      switchMap((id) => this.moviesService.fetchMovie(id).pipe(
        tap({
          next: movie => this.addMovie(movie),
          error: (e) => this.logError(e),
        }),
        catchError(() => EMPTY),
    )
  });
      
  readonly addMovie = this.updater((state, movie:Movie) => ({
      movies: [...state.movies, movie],
  }));
}
                                  
---
                                  
@Component({
                                  
  @Input
  set movieId(value: string) {
    this.movieStore.getMovie(value);
  }

  constructor(private readonly moviesStore: MoviesStore) {}
  
});

effect에 넣는 인자가 effect의 Callback 함수인 Observable로 push 되어서 발동되는 형식입니다.


Component Store vs Ngrx Store

Component Store가 더 간결하다.

보시면 아시겠지만 Component Store가 Ngrx Store보다 더 간결합니다.

Ngrx Store는 reducer, ngrx effect 까지 사용하면

  1. reducer 액션 정의
  2. effect 트리거용으로 사용할 액션 정의
  3. reducer 생성
  4. effect와 reducer 연결
  5. reducer, effect를 모듈에 연결

5가지 절차를 밟아야 한다면

Component Store의 경우

  1. Component Store Service 생성
  2. 내부 State 생성
  3. updater, effect를 사용하여 함수 생성

절차가 더 간결하고 Component Store Service에 모든 로직을 담을 수 있기 때문에 간결해보입니다.

Component의 라이프 사이클에 맞춰 Store를 초기화할 수 있다.

Component Store는 서비스이기 때문에 providers에 배치하면 Component가 destory되는 시점에 store를 제거할 수 있고, 생성하는 시점에 store를 생성할 수 있습니다.

ngrx를 사용할때 페이지 초기 진입시 상태값을 초기화 하기 위한 액션을 종종 만들었는데 Component Store를 바탕으로 만들면 그런 부분을 제거할 수 있습니다.


결론

ComponentStore의 Component 라이프 사이클에 맞춰서 Store를 초기화할 수 있다는 점과 ngRx보다 더 집약적인 코드를 작성할 수 있다는 장점은 local UI State를 저장하기에 적합합니다.

백엔드에서 받아온 데이터는 ngRx Store에 저장하고, Component에서 사용하는 UI State인데 전역적으로 사용해야할 케이스가 있다면 ComponentStore에 두어 저장하면 관리가 용이해 보입니다.

한줄로 요약하면 ngRx Store에는 Global Data를, Local Data들은 ComponentStore에 저장하는것이죠.

이 2개를 적절히 조합하면 더 구조적이고 깔끔한 코드를 작성할 수 있을것 같습니다. 이만 포스팅을 맞치겠습니다.

0개의 댓글