이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.
원문: https://indepth.dev/posts/1442/ngrx-bad-practices
저작권 정보: https://indepth.dev
※ RxJS의 용어나 관용적으로 쓰는 표현들은 따로 번역하지 않았습니다. (ex. selector, Observable..)
이전 기사에서 저는 Anauglr 의 나쁜 사례 (두번째) 와 RxJS 의 모범 사례를 다뤘습니다. 6개월 동안 ngrx 를 사용해보면서 그것의 장점들을 모두 이해했고, 이제는 제가 (그리고 종종 전체 커뮤니티에서도) 해롭거나 복잡하다고 생각한 몇가지예제와 패턴들을 공유 해보려 합니다. 자, NGRX 에서 하지 말아야 할 리스트들을 살펴 봅시다.
(대부분의 경우에는)Store 를 구독하지 마세요: selector 를 사용하세요.
이 코드 좀 볼까요.
@Component({
template: `
<span>{{name}}</span>
`
})
export class ComponentWithStore implements OnInit {
name = '';
constructor(store: Store<AppState>) {}
ngOnInit() {
this.store.subscribe(state => this.name = state.name);
}
}
정말 끔찍하지 않나요? 우선, store 를 구독한다는 것은 , 우리가 구독 해제(위 예제에서는 구현되어 있진 않아요.) 를 해야한다는 것을 의미합니다. 이것은 추가적인 번거로움이 있음을 의미하기도 하죠.
두번째로는, 우리의 컴포넌트에 약간의 명령형 코드가 있음을 의미합니다. 마지막으로, 가장 짜증나는 사실은 Ngrx 의 잠재력을 풀로 활용하고 있지 않다는 것입니다. 더 나은 코드로 변경해 볼까요.
@Component({
template: `
<span>{{ name$ | async }}</span>
`
})
export class ComponentWithStore {
name$ = this.store.select(state => state.name);
constructor(store: Store<AppState>) {}
}
이건 분명히 더 낫습니다. unsubscribe 의 필요성을 제거했을 뿐 아니라(async
파이프가 우리를 위해 자동으로 해줍니다.) , 더 좋은 성능을 위해 ChangeDetectionStrategy.OnPush
를 쉽게 사용할수 있습니다. 또다른 보너스로는 코드의 양이 줄고 더 명확하게 만들어줬습니다. 여기 다른 예도 있습니다.
@Component({
template: `
<span>{{name}}</span>
`
})
export class ComponentWithStore implements OnInit {
name = '';
constructor(store: Store<AppState>) {}
ngOnInit() {
this.store.subscribe(state => {
if (state.name === 'ReservedName') {
this.store.dispatch(reservedNameEncountered());
}
});
}
}
이 예제에서는, state 가 특정 값을 가지고 있는지 확인하고 그에 따라 특정 액션을 수행하고 있습니다. 이건 어떤 애플리케이션이든지 마주할 수 있는 시나리오지만, 그걸 구현하기 위한 올바른 방법은 아닙니다. 파생된 상태를 소비하는 것은 상태 변화에 따른 action 에 반응하면 안됩니다. 이런 목적으로 우린 effects 를 가지고 있습니다.
export class Effects {
reservedName$ = createEffect(() => this.actions$.pipe(
ofType(actions.setName),
filter(({payload}) => payload === 'ReservedName'),
map(() => reservedNameEncountered())
));
constructor(actions$: Actions) {}
}
컴포넌트 안에 관련된 코드는 없어집니다. (zero code) 기억하게요, 오직 액션만이 state 변화를 발생시켜야 합니다. 그리고 우리는 파생된 state 가 아닌, Effects 와 Reducers 를 통해서만 그런 액션들에 반응하게 해야 합니다. (역자 주: state 상태를 감지하는 것은 컴포넌트 내부가 아닌 effects 와 reducers 를 통해서만 하라는 뜻으로 보인다.)
하지만 이 섹션의 제목은 “대부분의” 를 언급하고 있죠. 그렇다면 Store를 구독할 수 있는 상황이 존재할 수 있다는 것일까요? 다음의 상황을 상상해봅시다: 우리의 앱이 관리자에 의해 통제되는 복잡한 권한 시스템을 자지고 있다고 해봅시다. 그리고 그 권한들은 실시간으로 변할 수 있죠. 물로, 그런 권한들은 AppState
안에 저당되어 있습니다. 이제 우리가 컴포넌트 안에서 Reactive 폼을 사용하고 있다고 상상해 봅시다. 하지만 만약 우리가 폼을 채우고 있는 동안 관리자가 변경을 하면, 몇몇 필드들은 반드시 비활성화 되어야 합니다. 즉 우리의 state 는 실시간으로 업데이트 되어야 하죠. 그럼 어떻게 우리는 Store 내의 권한에 따라 입력을 비활성화 하려면 어떻게 해야 할까요??
@Component({
template: `
omitted for brevity
`
})
export class ComponentWithStore implements OnInit {
permissions$ = this.store.select(state => state.permissions);
form = this.formBuilder.group({
firstName: ['', Validators.require],
});
constructor(store: Store<AppState>) {}
ngOnInit() {
this.permissions$.subscribe(permissions => {
const control = this.form.get('firstName');
if (permissions.canEditFirstName) {
control.enable();
} else {
control.disable();
}
});
}
}
이건, 우린 Reactive 한 FormControl
을 오직 그것의 method 인 disable
을 통해서만 비활성화 시킬 수 있습니다. disable
은 본질적으로 다른 대안이 없기 때문에 필수적으로 사용할 수 밖에 없습니다. (만약 반드시 Reactive 폼을 상요해야 한다면요) 이 경우에는, 컨트롤의 비활성화된 상태가 우리의 AppState
로 부터 얻은 값에 의존하고 있기 때문에, 우리는 Store를 구독해야 하는 것이 강제 됩니다. 그래서 여기 경험에 바탕한 규칙이 있습니다. (rule of thumb):
Store 를 수동적으로(직접적으로) 구독하지 마세요, 당신이 다른 대안이 없어서 필수적으로 FormControl.disable 같은 third-party 함수를 사용해야 하는 경우가 아니라면요. (만약 그렇다면, 구독 해지하는 것을 잊지 마세요!)
구독 없이 그런 상황들을 다루는 비교적 좋고 새로운 방법은 그것의 컴포넌트 effects 를 @ngrx/component-store 와 함께 사용하는 것입니다.
스토어로부터 선택된 Observable 에 pipe 를 하지 마세요
좋아요, sotre 나 Observables
로부터 파생된 state 를 구독하는 것을 멈췄더니 이제 모든게 빛나네요! 그렇죠? 꼭 그렇진 않아요. 다음 코드를 한번 볼까요.
@Component({
template: `
<span *ngFor="let user of (activeUsers$ | async)">{{ user.name }}</span>
`})
export class ComponentWithStore {
activeUsers$ = this.store.select(state => state.users).pipe(
map(users => users.filter(user => user.isActive)),
);
constructor(store: Store<AppState>) {}
}
이번 예제에서는, 원본 state 가 Appstate
안의 Array
타입으로 저장이 되어져 있네요. 하지만 이 컴포넌트에서 우리가 실제로 필요한 것은 active 한 사용자들의 리스트만 필요합니다. 그래서 state 로 부터 파생된 Observables
을 오퍼레이터와 순수 함수들을 이용해서 바꿨습니다. 확실히 나쁘진 않네요, 그쵸?
문제는, 정말 간단한 RXJS 오퍼레이터라도 간단한 selector 함수들보다는 복잡합니다. 그 결과로 코드는 잡음이 생기고, 또한 만약 우리의 로직이 복잡하다면 디버깅하기는 더 어려워집니다. 그래서 우리는 이 문제를 selector 에 이름을 붙여 해결할 겁니다.
// selectors.ts
const activeUsers = (state: AppState) => state.users.filter(user => user.isActive)
그리고 나서 즉시 파생된 state 를 사용 합니다.
<>Copy
@Component({
template: `
<span *ngFor="let user of (activeUsers$ | async)">{{ user.name }}</span>
`
})
export class ComponentWithStore {
activeUsers$ = this.store.select(activeUsers);
constructor(store: Store<AppState>) {}
}
이게 코드도 더 적어지고, 더 읽기 쉬우며, 어떤게 선택되고 있는지 바로 이래할 수 있습니다, 또한 이건 더 선언적(declarative) 입니다. 그래서 이 나쁜 예제에서의 정답은 Ngrx selector 들 입니다.
파생된 state 에 pipe 를 이용해 오퍼레이터를 사용하지 마세요. 대신에, 이름을 붙인 selector 를 사용하세요.
CombinLatest 를 사용하지 마세요. 대신에 이름이 있는 selecotor 를 사용하세요.
어플리케이션의 상태들은 때때로 매우 복잡해 질 수 있고, 여러개의 state 형태에 의존할 수 있습니다. 가끔, 파생된 state는 하나의 원본 state 뿐 아니라, 두개 이상의 state 들을 조합해서 만들어지기도 합니다. 예를 들어서, 우리가 옷 아이템들을 Clothing
타입 오브젝트의 Array
로 가지고 있다고 생각해 보세요. 그리고 쇼핑 카트도 마찬가지로, Clothing
타입 오브젝트의 Array
로 있구요. 우린 새 Clothing
들을 “Add to cart” 버튼을 사용해 추가 할 수 있습니다. 하지만 Clothing
이 이미 카트에 존재한다면, 우린 “Remove from Cart” 라는 다른 버튼을 보여줘야 해요. 이제, 로직이 너무 복잡하지 않게, 우린 isInShoppingCart
라는 프로퍼티를 Clothing
의 모든 아이템에 추가해주면 됩니다. 그리고 그것은 아이템의 id
가 쇼핑카트의 Array
안에 존재하는지의 여부를 보여주고요. 우린 모든 아이템들과 쇼핑 카트 Array
를 select 하는 두개의 seelctors 를 가지고 있게 됩니다. 여기 우리의 컴포넌트 입니다.
@Component({
template: `
<app-clothing-item
*ngFor="let item of (clothingItems$ | async)" [item]="item">
</app-clothing-item>
`
})
export class ClothingItemListComponent {
clothingItems$ = combineLatest([
this.store.select(state => state.clothingItems),
this.store.select(state => state.cart),
]).pipe(
map(([items, cart]) => items.map(item => ({
...item,
isInShoppingCart: cart.map(cartItem => cartItem.id).includes(item.id),
})))
);
constructor(store: Store<AppState>) {}
}
이제, 이 로직이 컴포넌트 class 에 넣기에는 너무 복잡하다는 것을 쉽게 알 수 있습니다. 또한, 선언이 되어 있다하더라도(?), 우리가 실제로 코드를 깊게 읽고 이해하기 전까지는 무슨 일이 일어나는지 쉽게 드러나지 않습니다. 그럼 이 상황을 어떻게 할 수 있을까요? 답은, 이번에도 selectors 입니다. Ngrx 는 우리에게 createSelector
를 이용해서 다른 selector 들을 결합할 수 있게 해줍니다.
const allItems = (state: AppState) => state.clothingItems;
const shoppingCart = (state: AppState) => state.shoppingCart;
const cartIds = createSelector(shoppingCart, cart => cart.map(item => item.id));
const clothingItems = createSelector(
allItems,
cartIds,
(items, cart) => items.map(item => ({
...item,
isInShoppingCart: cart.includes(item.id),
}),
);
이제 이 함수들은 이해하기 훨씬 쉬워졌습니다. 먼저 우린 모든 아이템과 카트를 select 했습니다. 그리고 나서 우린 카트의 id 들만을 select 하는 selector 를 만들었스니다. (selector 들은 메모이제이션이 됩니다.) 마지막으로, 우린 두개의 selector 들을 결합해서 모든 아이템들을 변경 시켰습니다. 특정 컴포넌트에서는 우린 selector 의 결과만 사용하면 됩니다. 당신은, 왜 4개의 selector 들을 만들어야 하냐고 물을 수도 있습니다. 하지만 중요한 것은, 적지만 복잡한 selector 들 보단, 많지만 간단한 selector 들을 여러개 가지고 있는게 더 좋습니다. 그것은 뛰어난 재사용성과 구성성을 제공해 줍니다.
여러개의 파생된 state 들과 함께 combineLatest 가 사용되고 있다는 것을 본다면, createSelector 를 사용해서 selector 들을 결합시켜 보는 것을 고려해 보세요.
withLatestFrom 을 사용해서 중첩된 stae 들을 수정하는 것을 피하도록 하세요.
가금 Effects 를 실행하려면, Store 에 이미 존재하는 다른 상태들도 고려해야 합니다. 예를 들어: 일부 버튼을 사용해서 필터링 하고 정렬 할 수 있는 테이블이 있다고 상상해 보세요. 이제 우리는 다음과 같은 setSorting
액션을 가지게 됩니다. 해당 액션은 sorting 오브젝트 ({field: string, direction: -1 |1 }
)를 받고 모든 쿼리 정보들(필터들, 페이지네이션과 정렬, 우린 각각에 대해 따로 action 을 가지고 있습니다.)을 포함하고 있는 query
오브젝트에 더해주죠. 그리고 나서, 결과로 나온 오브젝트를 서비스를 이용해 백엔드로 보내줍니다. 백엔드는 별도의 엔티티들이 아닌, 오직 전체 쿼리(모든 소팅, 필터링, 페이지네이션)만을 받습니다.하지만 우리의 action 은 오직 정렬의 부분(중접된 상태를 수정)만 수정하는 것을 유의하세요. 그리고 setSorting
액션을 디스패치한 컴포넌트는 전체 쿼리가 없을 수도 있습니다. 결과적으로 우리의 effect 는 다음과 같은 행동을 할 수 있습니다.
@Injectable()
export class Effects {
getData$ = createEffect(() => this.actions$.pipe(
ofType(setSorting, setFilters, setPagination),
withLatestFrom(this.store.select(state => state.query)),
map(([{payload}, query]) => ({...query, [payload.type]: payload.data})),
exhaustMap(query => this.dataService.getData(query).pipe(
map(response => getDataSuccess(response)),
catchError(error => of(getDataError(error)))
)),
));
constructor(
private readonly actions$: Actions,
private readonly store: Store<AppState>,
private readonly dataService: DataService,
) {}
}
이제 우리가 여기서 컴포넌트 안에서 어떤 일을 하지 않기 위해 필사적으로 피해왔던 많은 부정적인 것들이 생겨버렸습니다. 먼저 우리는 같은 요청을 보내기 위해( 하나는 정렬, 하나는 필터링, 나머지는 페이지네이션) 이제 세가지의 액션을 처리하고 있습니다. 그리고 우린 또한 withLatestFrom
을 이용해서 이미 존재하는 state 를 얻고 있습니다. 이제 이걸로 무엇을 할 수 있을까요? 우선, 세 가지의 분리된 액션들(setSorting
, setFilters
, setPagination
) 을 없애야 합니다. Mike Ryan 이 이 영상에서 설명했듯이, 우리의 actions 들을 클린하고 간결하게 유지하는 것은 중요합니다. 우린 전체 쿼리 오브젝트를 페이로드로 받는 getData
라는 오직 하나의 actioin 만 가질 겁니다. 우리의 컴포넌트에서는 다음을 수행해야 됩니다.
@Component({
template: `
<ng-container *ngIf="query$ | async as query">
<app-sorting (sort)="setSorting($event, query)"></app-sorting>
<app-filters (filter)="setFilters($event, query)"></app-filters>
<app-table-data [data]="data$ | async"></app-table-data>
<app-pagination (paginate)="setPagination($event, query)"></app-pagination>
</ng-container>
`,
})
export class TablePresenterComponent {
query$ = this.store.select(state => state.query);
data$ = this.store.select(state => state.data);
constructor(
private readonly store: Store<AppState>,
) {}
setSorting(sorting: Sorting, query: Query) {
this.store.dispatch(getData({...query, sorting}));
}
setFilters(sorting: Sorting, filters: Filters) {
this.store.dispatch(getData({...query, filters}));
}
setPagination(sorting: Sorting, pagination: Pagination) {
this.store.dispatch(getData({...query, pagination}));
}
}
우리의 컴포넌트가 얼마나 여전히 간단하고 간결하면서 모든 메소드가 같은 action 을 디스패치하는동안 다른 시나리오를 처리하는지 보세요. 그리고 action 이 같기 때문에, 우린 아래 보이는 것처럼 우리의 effect 를 바꿀 수 있습니다.
@Injectable()
export class Effects {
getTableData$ = createEffect(() => this.actions$.pipe(
ofType(getData),
exhaustMap(({payload}) => this.dataService.getData(payload).pipe(
map(response => getDataSuccess(response)),
catchError(error => of(getDataError(error)))
)),
));
constructor(
private readonly actions$: Actions,
private readonly dataService: DataService,
) {}
}
이제 우리는 오직 하나의 action 만 처리하고, 간단한 한가지 일만 하고, withLatestFrom
을 없앴습니다.
effect 안에 있는 withLatestFrom 은 더 간단한 방식으로 행해질 수 있다는 것을 나타내는 냄새를 풍기는 코드가 될 수 있습니다.
Store 안에 파생된 state 를 유지하지 마세요.
Ngrx( 및 그 어떠한 상태 관리 시스템이라도) 를 다룰 때 가장 중요한 점 중 하나는, 원본 state와 파생된 state를 구분하는 것입니다. 원본 state란 우리가 실제로 AppState
안에 저장한 것입니다. 예를들어, 백엔드로부터 받은 즉시 store 로 넣은 데이터는 원본 state 입니다. 파생된 state란 원본 state 로부터 몇번의 변형을 거쳐서 생성된 state 를 말합니다. 이 변형은 보통 selector 를 통해서 발생합니다. 예를들어, selector 들을 결합하는 예제에서 Clothing
아이템들의 리스트는 파생된 state 의 명확한 예입니다.
개발자들은 가끔 스토어 안에 파생된 state 를 원본 state 와 나란히 놓고 싶어 합니다. 그것은 여러 이유로 좋지 않은 관행입니다.
- 우린 우리가 실제로 필요한 것보다 더 많은 데이터를 저장하게 됩니다.
- 두 개의 state 를 동기화 하는게 필요하게 되고, 만약 어떤 action 이 원본 state 를 수정하면, 우린 거기서 파생된 stae 또한 수정해야 합니다.
AppState
가 매우 지저분해 보입니다.
다음 예제를 살펴 볼까요.
const _reducer = createReducer(
initialState,
on(clothingActions.filter, (state, {payload}) => ({
...state,
filteredClothings: state.clothings.filter(
clothing => clothing.name.includes(query),
),
})),
);
이제 우리는 원본 state (모든 옷들의 리스트) 와 파생된 state (필터링된 리스트)를 둘다 가지게 되었습니다. 하지만 만약 우리가 쿼리를 저장하게 하고, 쿼리와 전체 리스트의 selector 를 결합시켜서 필터링된 옷들을 추출하게 했다면, 훨씬 나을 것입니다.
// reducer.ts
const _reducer = createReducer(
initialState,
on(clothingActions.filter, (state, {payload}) => ({
...state,
query: payload,
})),
);
// selectors.ts
const allClothings = (state: AppState) => state.clothings;
const query = (state: AppState) => state.query;
const filteredClothings = createSelector(
allClothings,
query,
(clothings, query) => clothing.filter(
clothing => clothing.name.includes(query),
),
);
그리고 나면 우린 단순히 우리의 컴포넌트에서 파생된 state selector 를 사용하기만 하면 됩니다.
결론
아마 많은 나쁜 관행들에 대한 답이 selector 를 사용하는 것이라는 것을 알아차렸을 겁니다. 이것은 상태 관리 시스템의 주요 문제가 파생된 state 와 원본 state 가 구분되어지면서(diffentiation) 발생하기 때문입니다. 우리가 경우에 맞게 성공적으로 결정하고, 그것들을 선택(selecting) 하고 조작하는 데 간결한 방법들을 만들면, 우린 단순하고, 선언적이며 반응적인 ngrx 기반의 Angular 앱을 만들 수 있을 겁니다.
'Angular' 카테고리의 다른 글
RxJS: Why memory leaks occur when using a Subject (한글) (0) | 2022.02.09 |
---|---|
The Last Guide For Angular Change Detection You'll Ever Need (한글) (0) | 2021.08.25 |