티스토리 뷰

 

리액트를 사용하면 컴포넌트가 상태 변화를 감지하도록 하기 위해 별도로 신경 쓸 필요가 없다.

리액트에서 제공하는 useState라는 편리한 도구가 있기 때문이다.

setState로 상태를 원하는 대로 수정만 하면 컴포넌트가 변경 사항을 자동으로 *감지한다.

*여기서 감지한다는 표현은, 상태 변화가 발생할 때 리렌더를 수행한다는 의미이다.

 

반면, 바닐라 JS로 SPA를 만들면 상태 변화를 UI에 반영하기 위해 일일이 처리해 주어야 한다.

상태가 변경되었을 때 해당 상태를 보여주고 있는 컴포넌트를 다시 렌더해 주어야 한다.

이 작업이 생각보다 골치 아프다.

안 그래도 이것저것 많은 책임을 맡고 있는 우리의 컴포넌트를 더 복잡하게 만든다.

더군다나, 만약 하나의 상태를 여러 곳에서 보여주고 있다면 로직의 복잡도가 급격히 증가한다.

 

분명, 컴포넌트의 상태 변화 감지를 더 멋지게 처리할 방법이 있을 것이라 믿고 구글링을 해보았다.

아니나 다를까 이 상황에 딱맞는 '옵저버 패턴'이라는 디자인 패턴이 존재했다.

 

이번 포스팅에서는 옵저버 패턴으로 컴포넌트의 상태 변화 감지를 처리하는 법을 다룬다.

목차는 다음과 같다.

1. 문제 상황
2. 옵저버 패턴이란
3. 적용 예시
4. 옵저버 패턴의 장단점

1. 문제 상황

우선, 문제가 되는 상황을 조금 더 자세히 살펴보도록 하겠다.

숫자를 +/- 할 수 있는 간단한 숫자 카운팅 페이지를 만들어 보자.

 

예시 코드

(본문 내 코드는 깃헙 레포에서도 살펴볼 수 있다.)

// Component.js

// 참고) private은 "#"으로, protected는 "_(underscore)"로 표현했다
// 참고) 가독성을 위해 import/export 문은 생략했다.

class Component {
  #targetId;

  constructor(targetId) {
    this.#targetId = targetId;
  }

  initialize() {
    this._render();
    this._setEvent();
  }

  _render() {
    const target = document.getElementById(this.#targetId);
    target.innerHTML = this._template();
  }

  _template() {
    throw new Error("not implemented!");
  }

  _setEvent() {}
}

Component는 타겟 요소에 템플릿을 채우고, 자신과 관련된 eventListener를 세팅하는 책임을 수행한다.

// Counter.js
class Counter extends Component {
  _template() {
    return `
      <div>
        <button id="increment-button">+</button>
        <button id="decrement-button">-</button>
      </div>
    `;
  }
}

Counter는 숫자를 더하고 빼는 두 개의 버튼을 포함하는 컴포넌트이다.

// CountViewer.js
class CountViewer extends Component {
  #count = 0;

  _template() {
    return `
      <div>
        <p>💡 Count: ${this.#count}</p>
      </div>
    `;
  }

  _setEvent() {
    const incrementButton = document.getElementById("increment-button");
    const decrementButton = document.getElementById("decrement-button");

    incrementButton.addEventListener("click", () => {
      this.#count += 1;
      this._render();
    });

    decrementButton.addEventListener("click", () => {
      this.#count -= 1;
      this._render();
    });
  }
}

CountViewer는 현재 숫자 현황을 보여주는 컴포넌트이다.

// app.js
const counter = new Counter("counter-target");
const countViewer = new CountViewer("count-viewer-target");

counter.initialize();
countViewer.initialize();
<!-- index.html -->
<div id="app">
  <div id="counter-target"></div>
  <div id="count-viewer-target"></div>
</div>
<script type="module" src="./app.js"></script>

app.js와 index.html은 진입점이 되는 파일들이다.

문제점

1. 리렌더를 직접 처리해 주어야 함

현재 구조 상에선, 상태(카운팅 숫자)가 바뀌었을 때 이를 UI에 반영하기 위해 *직접 리렌더를 해주어야 한다.

아직은 간단한 구조여서 큰 문제가 아닌 것처럼 보이지만, 구조가 복잡해진다면 실수를 유발하는 원인이 될 것이다.

상태를 수정하면 그 상태를 보여주고 있는 UI 단에도 자동으로 반영될 것이라는 우리의 직관적 기대에 반하기 때문이다.

만약 상태를 수정하고 관련 컴포넌트를 다시 렌더해주지 않는다면 상태와 UI가 따로 노는 현상이 벌어지게 될 수 있다.

*CountViewer의 setEvent 메서드의 로직을 참고하자.

 

2. 이벤트 리스너 부착 로직의 위치

현재 구조 상에선, 상태를 변경 및 반영하기 위한 이벤트 리스너 부착 로직이 상태를 보여주는 컴포넌트에 위치해 있다.

이는 두 가지 문제를 시사한다.

  • 상태 변경 로직의 위치가 직관적이지 않다: 현재는 상태를 변경하는 로직이 상태를 보여주는 컴포넌트(CountViewer)에 위치해 있다. 그런데 직관적으로 생각했을 때, 상태를 변경하는 로직은 당연히 상태를 변경하는 컴포넌트(Counter)에 있어야 할 것 같다. 이렇게 직관에 위배되는 구조는 코드를 읽는 이에게 혼동을 유발한다.
  • 동일한 이벤트 리스너 부착 로직을 중복 작성해야 할 수 있다: 만약 동일한 상태를 보여주는 컴포넌트가 여러 개가 된다면, 매 컴포넌트마다 동일한 이벤트 리스너 부착 로직을 추가해 주어야 한다. 즉, 동일한 코드를 여러 곳에서 중복 작성해야 하는 비효율이 발생한다.

해결 방법

상태를 변경했을 때 변경 사항이 관련 컴포넌트들에게 자동으로 전파된다면, 위의 문제들이 해결될 것이다.

즉, 상태 변경 로직과 관련 컴포넌트 리렌더 로직이 애초에 하나로 묶여 관리되면, 위의 문제들이 해결될 것이다.

이를 가능하게 해주는 게 바로 옵저버 패턴👀이다.

 

2. 옵저버 패턴이란

Refactoring Guru에 따르면, 옵저버 패턴이란 관찰 대상 객체에 발생하는 이벤트에 대해 관찰 주체 객체에게 알리는 구독 메커니즘을 정의할 수 있도록 하는 행동 디자인 패턴(behavioral design pattern)이다.

유튜브 구독 구조를 떠올려 보면 조금 더 쉽게 이해할 수 있다.

유튜버가 신규 영상을 업로드하면 신규 영상이 업로드됐다는 사실이 구독자들에게 전해진다.

알림을 받은 구독자들은 업로드된 영상을 시청할 수 있다.

 

여기서 '유튜버'가 앞선 정의에서 '관찰 대상 객체'에 해당하고 '영상을 업로드하는 행위'가 '발생하는 이벤트'에 해당한다.

또한 '구독자'는 '관찰 주체 객체'에 해당한다.

이런 구조 덕분에 유튜버가 영상 업로드만 하면 구독자들이 알림을 보고 그 영상을 시청할 수 있다.

 

구조 자체는 쉽게 납득이 되지만, 이를 구현하는 게 매우 복잡할 것 같은 생각이 들기도 한다.

하지만 생각만큼 복잡하지 않고 간단히 구현할 수 있으니 찬찬히 살펴보자.

 

앞서 보았던 Youtube 구독 구조를 프로그래밍 관점에서 추상화해보면 다음과 같다.

Observable이라는 관찰 대상 객체가 존재하고, Observer라는 관찰 주체 객체가 존재한다.

변경 사항 발생 시 Observable.notify()를 실행하면 해당 Observable에 등록된 Observer 들의 update()가 실행된다.

끝이다. 매우 간단하지 않은가.

 

아직 코드를 직접 보지 않아 와닿지 않을 수 있다.

예시 코드를 보며 이해해보자.

 

3. 적용 예시

앞서 구현했던 숫자 카운팅 페이지와 동일한 기능을 하는 페이지를 옵저버 패턴을 이용해 다시 구현해보자.

(블로그에 작성된 코드는 가독성이 떨어지므로, 깃헙 레포에 올려둔 코드를 먼저 살펴보는 것을 추천한다.)

 

예시 코드 

// Observer.js (관찰 주체)
class Observer {
  update() {
    throw new Error("not implemented!");
  }
}

update(): 관찰 대상 객체인 Observable이 신호를 보낼 때 실행되는 메서드이다. 쉽게 생각하면 신호를 받을 때 실행할 로직을 update() 안에 담는다고 보면 된다.

// Observable.js (관찰 대상)
class Observable {
  #observers = [];

  addObserver(observer) {
    this.#observers = [...this.#observers, observer];
  }

  notify() {
    this.#observers.forEach((observer) => observer.update());
  }
}

addObserver(): 구독 주체 객체를 등록하는 메서드이다.

notify(): 등록된 구독 주체들에게 신호를 보내는 메서드로, 등록된 관찰 주체 객체들의 update()를 실행시킨다.

// Component.js
class Component extends Observer {
  #targetId;

  constructor(targetId) {
    super();
    this.#targetId = targetId;
  }

  initialize() {
    this._render();
    this._setEvent();
  }

  update() {
    this._render();
  }

  _render() {
    const target = document.getElementById(this.#targetId);
    target.innerHTML = this._template();
  }

  _template() {
    throw new Error("not implemented!");
  }

  _setEvent() {}
}

기존의 Component와 전부 동일하지만, Observer 클래스를 상속하며 update() 구현이 추가됐다.

Obervable로부터 신호를 받을 때 다시 렌더되어야 하므로 update()에 _render() 호출 로직을 담았다.

// State.js
class State extends Observable {
  #value;

  constructor(value) {
    super();
    this.#value = value;
  }

  set(value) {
    this.#value = value;
    this.notify();
  }

  get() {
    return this.#value;
  }
}

set(): 상태 값을 변경하는 메서드이다. 값을 변경한 후에 notify()를 호출한다(신호를 주는 행위). 이로써 상태 값이 변경되었을 때 Component가 자동으로 렌더되는 동작이 가능해진다.

get(): 상태 값을 반환하는 메서드이다.

// Counter.js
class Counter extends Component {
  #countState;

  constructor(targetId, countState) {
    super(targetId);
    this.#countState = countState;
  }

  _template() {
    return `
      <div>
        <button id="increment-button">+</button>
        <button id="decrement-button">-</button>
      </div>
    `;
  }

  _setEvent() {
    const incrementButton = document.getElementById("increment-button");
    const decrementButton = document.getElementById("decrement-button");

    incrementButton.addEventListener("click", () => {
      const currentCount = this.#countState.get();
      this.#countState.set(currentCount + 1);
    });

    decrementButton.addEventListener("click", () => {
      const currentCount = this.#countState.get();
      this.#countState.set(currentCount - 1);
    });
  }
}

여기서 기존과의 차이점은 1)countState를 생성자로 주입받는다는 점과 2)이벤트 리스너를 세팅한다는 점이다.

여기서 세팅한 이벤트 리스너는 버튼 클릭이 발생할 때 countState를 통해 상태 값을 수정하는 로직을 수행한다.

// CountViewer.js
class CountViewer extends Component {
  #countState;

  constructor(targetId, countState) {
    super(targetId);
    this.#countState = countState;
  }

  _template() {
    const count = this.#countState.get();

    return `
      <div>
        <p>💡 Count: ${count}</p>
      </div>
    `;
  }
}

여기서 기존과의 차이점은 1)countState를 생성자로 주입받는다는 점, 2)보여줄 상태 값을 countState를 통해 받아온다는 점, 3) setEvent 구현이 사라졌다는 점이다.

이제 더 이상 state의 변경을 이 컴포넌트에서 다룰 필요가 없어졌다.

또한, 상태가 변경되었을 때 다시 렌더하는 처리를 별도로 신경 쓸 필요가 없어졌다.

// app.js
const countState = new State(0);

const counter = new Counter("counter-target", countState);
const countViewer = new CountViewer("count-viewer-target", countState);

counter.initialize();
countViewer.initialize();

countState.addObserver(countViewer);

countState를 생성하고, countState.addObserver()을 통해 countViewer를 관찰자로 등록하는 로직이 추가되었다.

 

index.html은 기존과 동일하므로 생략한다.

 

이로써 옵저버 패턴을 적용하여 리팩토링을 완료했다. 👏🏻

이제 상태를 변경하는 컴포넌트에서는 State.set()을 이용해 상태 값을 수정해주기만 하면 된다.

상태를 보여주는 컴포넌트에서는 State.get()을 이용해 상태 값을 받아서 사용하기만 하면 된다.

 

구조가 직관에 부합하며(쉽게 이해할 수 있으며), 상태 변경에 따른 리렌더 처리를 일일이 해 줄 필요가 없어졌다.

지금 당장은 큰 개선으로 보이지 않아도, 상태의 종류가 많아지거나 상태와 컴포넌트의 관계가 일대다가 되면 그 효과를 톡톡히 누릴 것이다.

 

4. 장단점

장점

  1. 관찰 대상의 상태를 주기적으로 조회하지 않고도 상태 변경을 감지할 수 있다: 관찰 대상 상태에 변경이 생겼을 때 notify()를 이용하여 관찰 주체에게 신호를 줄 수 있다. 이 덕분에 관찰 대상의 상태를 주기적으로 조회하는 동작 없이도, 상태를 감시하고 있는 듯한 구현이 가능해진다.
  2. 관찰 주체(Observer를 상속한 클래스)와 관찰 대상(Observable를 상속한 클래스)이 서로 느슨하게 결합된다: 느슨한 결합은 쉽게 말하면, 상호작용은 할 수 있는데 서로를 잘 모르는 관계를 의미한다. Observer를 상속한 클래스와 Observable을 상속한 클래스는 직접적인 의존 관계에 있지 않다. 즉 서로를 잘 모른다. 그럼에도 Observer의 update()와 Observable의 notify()를 통해 소통할 수 있다. 이 덕분에 클래스의 다른 세부 구현 사항이 변하더라도 관찰/피관찰 관계는 유지가 될 수 있다.
  3. 개방 폐쇄 원칙을 준수한다: 개방 폐쇄 원칙은 쉽게 말하면 코드를 수정하지 않고도 기능을 추가할 수 있어야 한다는 원칙이다.  Observable의 addObserver()를 이용하면 코드를 수정하지 않고도 쉽게 관찰 주체를 추가 등록할 수 있다.

단점

  1. 보일러 플레이트 코드가 생긴다: 기본 세팅을 위한 코드가 필요하다 보니, 단기적으로 코드의 양이 많아질 수 있다.
  2. 관찰자를 등록해주는 절차가 별도로 필요하다: 현재는 관찰 관계를 형성하기 위해 Observable.addObserver()를 통해 관찰 주체를 등록해주는 절차가 필요해 번거롭다. 단, 이를 개선할 수 있는 Reactive 시스템을 만드는 법도 존재한다.

짧은 소감

디자인 패턴은 매우 유용하다.

선배 개발자들이 우리가 겪을 고통을 미리 겪어보고 그에 대한 해결책을 만들어 놓은 것이기 때문이다.

우리는 그들이 열심히 고민해 만들어낸 결과물을 손쉽게 사용할 수 있다.

 

그런데 이는 양날의 검이기도 하다.

충분히 고통을 겪어 보지 않고, 열심히 고민해보지 않고 섣불리 디자인 패턴을 적용하는 건 오히려 독이 되기 때문이다.

스스로 고민할 수 있는 깊이가 얕아질 수 있다.

주체적으로 도구를 이용하는 게 아니라 도구에 끌려다닐 수 있다.

더 비효율적인 길을 택하게 될 수도 있다.

 

그렇기 때문에 디자인 패턴을 적용하기 전에 고민해볼 몇 가지 사항이 있다.

디자인 패턴 없이 충분히 고통받고 또 충분히 고민해보았는가.

디자인 패턴을 응용할 수 있을 정도로 충분히 이해했는가.

디자인 패턴을 적용하는 게 현재 상황에서 더 좋은 결과를 낳을 수 있는 게 맞는가.

 

항상 더 나은 방법을 고민하되, 당장 멋지게 해결하고 싶다는 욕구에 매몰되어 더 중요한 가치들을 놓치지 말자 💡

'프론트엔드' 카테고리의 다른 글

이벤트 전파(Event Propagation)  (1) 2024.04.14
DOM, Node, Element  (1) 2024.04.11