리액트와 Vue의 컴포넌트는 상태가 변화하면 자동으로 그 변경을 감지한 뒤, 화면을 새로 그림으로 사용자에게 변화한 페이지를 보여줍니다. 그런데 리액트나 Vue는 어떻게 상태의 변화를 알고 화면을 다시 그릴 수 있을까요?

원래의 그림을 버리고 새로운 그림을 그리는 사람을 그려달라고 부탁했습니다. 출처: Microsoft copilot으로 제작

 

옵저버 패턴

옵저버 패턴은 객체의 상태가 변경되었을 때 해당 객체에 의존하는 다른 객체들에게 자동으로 알림을 보내는 방식의 디자인 패턴입니다. 이는 객체들 사이의 상호작용을 관리하고 객체 간의 결합도를 낮추는 데 유용합니다. 옵저버 패턴은 객체들 간에 동적으로 의존 관계를 설정하여 느슨한 결합(loose coupling)을 유지할 수 있도록 도와줍니다. 즉 옵저버 패턴은 객체의 동작 방식과 상호작용에 관련된 패턴으로, 객체 간의 상호작용을 조정하고 관리하는 데 사용됩니다.  옵저버 패턴을 더 자세하게 알아볼까요?

 

옵저버 패턴은 관찰 대상(Subject) 객체와 관찰자(Observer) 객체로 구성됩니다. 단어에만 집중하면 관찰자가 관찰 대상의 변화를 지켜보고 있다는 것으로 이해할 수 있습니다. 그런데 이것은 틀렸습니다! 관찰자는 변경 사항에 대한 알림을 받기 위해 구독(subscribe)합니다. 그리고 관찰 대상은 자신의 상태가 변경될 때마다 관찰자들에게 알림을 보냅니다. 관찰자들은 이 알림을 받아 정해진 동작을 수행합니다. 뭔가 뒤집힌 것같다는 생각이 들 수 있겠지만 구독을 통한 알림이 훨씬 더 효율적입니다. 다음 예시를 볼까요?

 

계속 관찰하는 상황, 관찰자는 망원경만 보고 있어야 합니다. 출처: Microsoft copilot으로 제작

만약 관찰자가 관찰 대상을 지켜보고 있는 방식이라면 관찰 대상이 변경되었는지 확인하기 위해 계속해서 작동하고 있어야합니다. A라는 관찰자가 B라는 관찰 대상을 지켜보고 있다고 가정해봅시다. A가 B의 변화를 알기 위해서는 A는 매번 일정한 주기로 B의 상태를 확인해야 합니다. A가 B를 관찰하고 있는 이상 A는 자원을 차지하고 소모하여야 하는 것이죠.

 

예를 들어 B가 0부터 시작하여 랜덤한 주기로 1씩 증가하는 숫자이고 A는 B가 10이 되는 것을 지켜보는 관찰자라고 생각해 보겠습니다. B가 언제 증가할지는 알 수 없기 때문에 A는 주기적으로(가령 500ms를 주기로) B의 값을 확인(요청)하여야 합니다. 이는 B가 10이 될 때까지 반복되어야 합니다.

 

여기서 문제가 발생합니다. 일단 언급했듯이 B의 값을 주기적으로 확인하는 것에 자원을 소모합니다. 그리고 B가 요청과 요청 사이에 이미 10을 지나가버리면(B는 9 → 요청-B는 9로 확인  →  B는 10 →  B는 11 →  요청 -B는 11로 확인), A는 영원히 B가 10인 것을 확인할 수 없습니다. 프로그램이 제대로 동작하지 못할 수도 있습니다. 그렇다고 요청 주기를 극단적으로 줄인다면 자원의 소모량이 엄청나게 늘어나겠죠.

 

구독하면 마치 택배기사처럼 알람을 보내주러 옵니다. 그 전까지는 핸드폰을 보며 놀아도 됩니다. 출처: Microsoft copilot으로 제작

반면 구독을 통한 방식을 사용한다면 B에서 알람을 보내기 때문에 B가 10이 되는 정확한 타이밍을 제외하고는 A와 상호작용을 하지 않습니다. B는 자신의 일은 랜덤한 주기로 숫자를 1씩 증가시키면서 숫자가 10이 되었을 때, A에게 알람을 보냅니다. 반면 A는 B에서 알람이 올때까지 가만히 있으면 됩니다. 이야기만 들어도 자원의 소모량이 훨씬 적어보이죠? 이는 프로그램에서 관찰자가 많을수록 더 큰차이를 만들어 냅니다.

코드의 구현

개인 공부겸 TypeScript로 구현하였습니다. 노드 환경에서 올바르게 실행이 되지 않는다면 ts-node 패키지를 설치하세요. 혹은 코드의  타입스크립트부분을 모두 지워주시거나 JS로 컴파일하세요.

npm install ts-node

 

먼저 관찰자 객체 입니다. 관찰자는 자신의 이름을 가진 객체입니다. 알람을 받으면 자신의 이름과 함께 관찰 대상이 보낸 메세지를 출력합니다.

// Observer.ts

// 관찰자(Observer) 인터페이스
export interface Observer {
  received(from: string, message: string): void;
}

// 관찰자 구현
export class ObserverClass implements Observer {
  constructor(private name: string) {}

  received(from: string, message: string) {
    console.log(`${from}이 ${this.name}에게 보내는 메세지: ${message}`);
  }
}

 

다음은 관찰 대상 객체입니다. addObserver로 자신의 변화를 구독하는 관찰자를 추가합니다. 추가된 관찰자는 외부에서 조작하지 못하고 오직 addObserver로만 추가되어야 하므로 private로 지정하였습니다. addNum을 실행하면 num이 1씩 증가하고 num이 3 또는 5의 배수라면 각각의 관찰자에게 알람을 보냅니다.

// Observed.ts
// Observed.ts
import { Observer } from "./Observer";

// 관찰 대상(Observed) 인터페이스
export interface Observed {
  addThreeObserver(observer: Observer): void;
  addFiveObserver(observer: Observer): void;
  addNum(): void;
}

export class ObservedClass implements Observed {
  private threeObservers: Observer[] = [];
  private fiveObservers: Observer[] = [];
  private num = 1;
  constructor(private name: string) {}

  addThreeObserver(observer: Observer) {
    this.threeObservers.push(observer);
  }

  addFiveObserver(observer: Observer) {
    this.fiveObservers.push(observer);
  }

  addNum() {
    this.num += 1;
    if (this.num % 5 === 0) {
      this.threeObservers.forEach((observer) =>
        observer.received(this.name, `숫자가 ${this.num}입니다.`)
      );
    }
    if (this.num % 3 === 0) {
      this.fiveObservers.forEach((observer) =>
        observer.received(this.name, `숫자가 ${this.num}입니다.`)
      );
    }
  }
}

 

자 그럼 main.ts에서 관찰자와 관찰 대상을 만들고 관찰 대상이 관찰자에게 알람을 보내도록 해볼까요?

// Main.ts
import { ObservedClass } from "./Observed";
import { ObserverClass } from "./Observer";

// 관찰 대상 생성
const observed = new ObservedClass("관찰 대상");

// 관찰자 생성
const observer1 = new ObserverClass("1번 관찰자");
const observer2 = new ObserverClass("2번 관찰자");

// 관찰 대상에 관찰자 등록
observed.addThreeObserver(observer1);
observed.addFiveObserver(observer2);

// 관찰 대상의 상태 변경 알림 발송
for (let i = 0; i < 16; i++) {
  observed.addNum();
}

observed라는 관찰 대상이 있습니다. 그리고 observer1과 observer2라는 관찰자를 생성하였습니다. 그 다음 addObserver 메서드를 통해 관찰 대상에 자신을 관찰할 관찰자들을 등록합니다. observer1과 observer2 모두 observed를 관찰할 것입니다. 그리고나서 관찰 대상이 알림을 보내도록 하였습니다. main.ts를 실행하면 observed의 숫자가 5의 배수 혹은 3의 배수일 때마다 observer1과 observer2 모두 조건에 맞게 알람을 받아 메세지를 출력합니다. 우리는 다음과 같은 결과를 볼 수 있습니다.

출처: 글쓴이

리액트와 옵저버 패턴

그렇다면 리액트의 상태 변화시 리렌더링 되는 과정을 어느정도 짐작할 수 있겠죠? 실제로 옵저버 패턴만을 사용하지는 않을 수도 있습니다. 이 글을 쓰게 된 계기는 (번역) 자바스크립트에서의 옵서버 패턴 - 반응형 동작의 핵심 게시글을 보고 정리 해보고 싶은 마음이었습니다. 글을 쓰기 위해 자료를 더 조사하다가 발행-구독 패턴(Publish–subscribe pattern)에 대해서도 알게 되었고 개인적으로 옵저버 패턴은 하나의 관찰 대상에 여러 관찰자가 있는 경우에 더 적합하고 리액트처럼 여러 개의 관찰 대상이 있는 경우는 발행-구독 패턴이 더 적합하지 않을까라는 생각도 하였습니다. 리액트가 상태 변화 감지에 정확히 어떤 패턴을 쓰는지에 대한 정보는 찾아볼 수 없었지만 하위 컴포넌트로의 Props전달 혹은 Context API 그리고 Redux 등의 상태 관리 라이브러리에서 옵저버 패턴을 사용한다는 것을 알 수 있었습니다.

 

리액트와 자바스크립트에서 옵저버 패턴의 실제 예시들

React에서 상위 컴포넌트가 하위 컴포넌트에 데이터를 전달하고, 해당 데이터가 변경될 때마다 하위 컴포넌트가 업데이트됩니다. 이는 부모 컴포넌트가 관찰 대상이 되고, 자식 컴포넌트가 관찰자가 되는 패턴입니다. Context API는 Provider와 Consumer로 구성되어 있습니다. Provider는 상태를 제공하는 관찰 대상, Consumer는 해당 상태를 구독하여 상태 변경을 감지하고 반응하는 관찰자라고 볼 수 있습니다. Redux에서는 컴포넌트는 상태 저장소(관찰 대상)에 구독하고, 상태가 업데이트되면 상태를 구독하고 있는 모든 컴포넌트(관찰자)가 업데이트됩니다. 이는 옵저버 패턴의 구현과 유사하며, 상태를 관찰하는 구독자가 상태 변경을 감지하고 반응하는 패턴입니다.

 

document.addEventListener는 바닐라 자바스크립트에서 볼 수 있는 옵저버 패턴입니다. 이벤트가 관찰 대상이 되며 이벤트 리스너가 관찰자가 됩니다. 이에 따라 리액트의 이벤트 처리 시스템도 옵저버 패턴을 사용합니다. 컴포넌트는 일반적인 자바스크립트와 마찬가지로 특정 이벤트에 대한 이벤트 리스너를 등록하고, 해당 이벤트가 발생하면 구독된 컴포넌트가 업데이트를 받고 그에 따라 응답합니다.

참고 자료

(번역) 자바스크립트에서의 옵서버 패턴 - 반응형 동작의 핵심

Design patterns in React/JavaScript with real use cases. Part 2

+ Recent posts