들어가며
지난 글에서는 바닐라 자바스크립트로 컴포넌트 기반의 구조를 만들어보았다. Component라는 코어 클래스를 만들고, 이를 상속과 오버라이딩을하여 반복적인 로직의 문제를 해결하고 패턴들을 통일할 수 있었다.
문제점
이 Component클래스를 사용하다보니 문제가 생겼다. 상태를 변경하면 자식 컴포넌트에서 모두 리렌더링이 일어나는 것이다.
아래와 같은 코드가 있다.
export default class App extends Component<null, AppState> {
constructor() {
super();
}
override setup() {
this.state = {
page: 1,
moviesResponse: null,
movies: null,
error: null,
search: '',
movieDetailResponse: null,
movieRate: LocalStorage.get<LocalStorageMovieRateValueType>('movieRate') ?? {},
};
}
App은 모든 상태를 관리하는 최상단 컴포넌트이다.
왜 이런 구조가 만들어졌는지 설명하면, 상태들이 서로를 필요로 하고 종속되어있기 때문이다. 예를들어 사용자가 검색은 한다. 그럼 search가 변경될 것이다. 그와 동시에 page는 1로 초기화되고, moviesResponse는 검색된 데이터로 변경하고, movies에는 새로 검색된 데이터를 추가해야한다. 한 컴포넌트에서 하나의 상태만 다루는 것이 아니라 다른 컴포넌트에서 사용되는 상태 또한 사용하기에 결국 최상단 컴포넌트인 App에서 모든 상태를 관리할 수밖에 없게되었다.
위 방식의 치명적인 문제점이 있다. 한 상태가 변경되면 모든 하위 컴포넌트가 리렌더링된다는 것이다.
옵저버 패턴
위 문제점을 옵저버 패턴으로 해결해보자. 옵저버 패턴에는 Observer와 Obserable이 있다. Obserable은 구독하고자 하는 상태를 관리하는 객체이고, Observer는 이 Obserable을 관찰하는 객체이다. Obserable에 미리 Observer를 등록해놓는다. 그리고 Obserable의 상태에 변화가 생기면 Observer에게 알림을 주는 방식이다. 여기서 Obserable에 Observer를 등록하는 과정을 subcribe, 알림을 주는 과정을 notify, 이 알림에 따라 행동을 취하는 것을 update라고 부른다.
Obserable
관찰 대상인 Obserable을 추상 클래스로 구현해보자.
// Obserable.ts
type TListener<TState> = (state: TState) => void;
export default abstract class Obserable<TState> {
#listeners: TListener<TState>[] = [];
subscribe(listener: TListener<TState>) {
this.#listeners = [...this.#listeners, listener];
}
notify(nextState: TState) {
forEach((listener) => {
listener(nextState);
}, this.#listeners);
}
}
Obserable에는 두 가지 행동이 있다. Observer를 등록하는 suscribe와 Observer에게 이를 알리는 notify.
여기서 구독(subscribe)할 때 객체(Observer)가 아니라 콜백 함수를 등록하는 것을 확인할 수 있다. 이는 확장성을 위해서이다. 만약 Observer객체를 Obserable에 등록할 경우, Observer객체는 update메서드를 가진 객체여야한다. 또한 Observer의 update메서드에는 한계가 생긴다. 예시로 Observer를 update메서드를 가진 객체가 아니라, 그저 콜백 함수만 전달하고 싶은 경우가 있다. 구체적인 예시로, Obserable의 상태가 변경되면 로컬 스토리지의 내용이 변경되도록 하고 싶다고 해보자. 그럼 이 때 (state) ⇒ localStorage.setItem(state)이라는 콜백함수를 subscribe의 인자로 전달하면 된다. 따로 update메서드를 가진 객체를 만들지 않아도 된다.
그 다음으로, notify메서드는 미리 등록한 Observer의 update함수들(listener)를 순회하며 하나씩 실행한다.
추상 클래스는 구현체에서 실제로 구현할 메서드나 속성을 정의만 해놓은 클래스이다. Obserable추상 클래스는 상태 변화를 관찰하는 기본적인 기능만 정의해놓고, 이를 상속받는 클래스에서 실제 상태와 그에 따른 동작을 구현하게 된다. 이제 Obserable 클래스를 실제로 구현할 Store 클래스를 만들어보자.
export default class Store<TState> extends Obserable<TState> {
#state = {} as TState;
#initialState: TState;
constructor(initialState: TState) {
super();
this.#state = initialState;
this.#initialState = initialState;
}
getState() {
return this.#state;
}
setState(nextState: TState) {
if (isEqual(this.#state, nextState)) return;
this.#state = nextState;
this.notify(this.#state);
}
reset(value?: TState) {
this.setState(value ?? this.#initialState);
}
}
extends로 Obserable 클래스를 상속한다.
또한 상태를 변경하는 setState에서는 상태를 변경한 후, Obserable 클래스의 notify메서드를 실행해 이를 Observer들에게 알린다.
이제 이 알림을 듣고 반응할 Observer를 구현해보자.
Observer
export default abstract class Observer<TState> {
abstract update(state: TState): void;
}
Observer는 Obsable에 비해 상당히 간단하다. Obserable에 변화가 생겼을 때 수행할 행동인 update만 작성하면 된다. 이는 추상 메서드로, Observer를 구현하는 클래스에서 반드시 구현해야 하는 메서드이다. Store의 상태가 변경될 때마다 Observer의 update메서드가 실행되며, 이를 통해 상태 변화에 따른 동작을 정의할 수 있다.
Observer 클래스를 상속받는 구현체는 각자의 목적에 맞게 update 메서드를 구현하면 된다. 예를 들어 UI를 담당하는 컴포넌트라면 상태 변화에 따라 화면을 다시 그리는 로직을, LocalStorage를 담당하는 클래스라면 상태를 저장하는 로직을 구현할 수 있다.
export default abstract class Component<TProps extends Props = {}, TState extends State = {}> implements Observer<any> {
update() {
this.render();
}
render() {
const element = document.createElement('div');
element.innerHTML = this.template();
const elementFirstChild = element.firstElementChild as HTMLElement;
if (!this.#element) this.#element = elementFirstChild;
else this.#element.innerHTML = elementFirstChild.innerHTML;
this.onRender();
return this.#element;
}
// ..
}
상태가 변경이 되었을 때 컴포넌트가 취할 행동은 하나이다. 리렌더링한다.
또한 Observer인 Component는 Obserable을 구독해야한다. 이를 위한 메서드를 만들어보자.
subsribe(stores: Store<any>[]) {
forEach((store) => {
store.subscribe(this.update.bind(this));
}, stores);
}
활용 방법은 다음과 같다.
export default class MovieSection extends Component {
override setup() {
this.subsribe([moviesStore, errorStore, searchStore]);
// ..
// ..
구독할 상태를 직접 등록한다.
이 방식이 옵저버 패턴을 사용하지 않던 기존 방식과 어떤 차이점이 있을까? 컴포넌트가 직접 구독한 상태가 변경되었을 때만 리렌더링이 일어난다는 것이다. 물론 여전히 부모 컴포넌트가 리렌더링이 되었을 때 자식 컴포넌트가 리렌더링되는 것을 막을 수는 없다. 그럼에도 상태를 외부로 분리한다면, 다른 상태의 변경이 컴포넌트의 불필요한 리렌더링을 만들지 않을 수 있다.
Decorator
데코레이터 패턴은 객체의 기능을 동적으로 확장하거나 변경할 수 있게 해주는 디자인 패턴이다. 기존 객체의 코드를 수정하지 않고도 새로운 기능을 추가할 수 있다는 장점이 있다.
데코레이터 패턴을 활용해서, 상태가 변경되면 로컬 스토리지에 데이터를 영구적으로 저장하는 로직을 구현해보자.
const movieRateStore = new Store<LocalStorageMovieRateValueType>({});
이런 상태가 있다.
export const persisted = <TState>(key: LocalStorageKeyType, store: Store<TState>) => {
store.setState(LocalStorage.get(key) ?? store.getState());
store.subscribe((state) => LocalStorage.set(key, state));
return store;
};
위 persisted 함수는 로컬 스토리지에 저장할 키값과 상태를 전달받는다. 그리고 상태를 구독해, 상태가 변경되면 로컬 스토리지에 값을 저장하도록 한다. 그리고 이 상태를 반환한다. 즉 여기서는 store가 Obserable, 로컬 스토리지가 Observer가 된다.
그리고 이제 위에서 만든 movieRateStore를 persisted의 인자에 넘겨준다.
export const persistedMovieRateStore = persisted('movieRate', movieRateStore);
이제, 상태가 변경되면 로컬 스토리지에 자동으로 저장이 된다.
느낀 점
상태를 컴포넌트에서 직접 관리하던 방식에서, 외부로 상태를 분리하고 이 상태를 컴포넌트가 구독하는 방식으로 변경을 해보았다. 사실은 아직 리렌더링이 줄어드는 효과는 아직 체감을 못하고 있다. 왜냐하면 기존 방식에서 문제가 되었던 컴포넌트가 서로 공유하는 상태가 많다는 것은 여전히 유호하기 때문이다.
그럼에도 ‘상태 분리’의 효과는 확실히 본 듯하다. 기존에는 서로가 공유하는 상태가 많기 때문에 App.ts에서 모든 상태를 관리하고 있었다. 또한 상태를 활용한 이벤트 리스너도 App.ts에서 이벤트 위임을 활용해 모두 설정하고 있었다. 각 컴포넌트에 상태 혹은 상태를 관리하는 메서드를 props로 전달할 수도 있지만, 코드가 너무 난잡해져서 차라리 App.ts에서 관리하는 것이 더 용이하다고 판단했다. 이러한 이벤트 핸들러가 한 곳에 몰려있는 문제점을 옵저버 패턴을 도입하며 깔끔히 해결이 되었다. 컴포넌트가 상태를 구독하게 되니 상태를 외부로 분리할 수 있게 되었고, 이에 따라 각 컴포넌트에서 이벤트 리스너를 달 수 있게되었다.
정리하면 리렌더링 측면에서 큰 효과를 보지는 못했지만 코드의 가독성 측면에서는 큰 이점을 본 리팩토링이였다.
여전히 모든 컴포넌트에서 상태들을 수정할 수 있다는 단점이 존재한다. 이는 상태 변경의 책임이 명확하지 않다는 것을 의미한다. 각 컴포넌트가 자신의 역할을 벗어나 다른 컴포넌트의 상태까지 변경할 수 있어 코드가 어떻게 동작할지 예측하기 어려워진다. 그래서 다음엔 상태 변경 책임을 한 곳으로 모으는 구조를 더 고민해봐야겠다.