본문 바로가기

React.js

RSC가 비동기 함수를 관리하는 방법 (feat. Observer Pattern)

옵저버 패턴 (Observer Pattern)

비유하면, 구독하는 유튜버의 새 유튜브 콘텐츠 알림을 받는 것과 같습니다. 즉, 객체의 상태(유튜버 콘텐츠)를 지켜보고 싶은 관찰자(구독자)들에게 상태 변화(새 콘텐츠)를 알려주는 디자인 패턴이라고 할 수 있습니다.

 

이 패턴의 핵심은 관찰자들을 등록시키고, 이벤트가 발생하면 변화됨을 통지하는 거죠. 관찰자들은 변화가 일어났는지 직접 확인할 필요가 없고, 변화에 따른 적절한 행동만 수행하면 됩니다. 이는 update 메서드를 정의하여 이벤트 발생 시 동작되도록 합니다.

 

그럼 이 패턴을 어디에 적용하면 좋을까요?

첫 번째로 (사용자에 이벤트 등) 외부에서 발생한 이벤트에 대해 대응하거나,

두 번째로 객체의 속성 값이 변화할 때 사용할 수 있습니다.

 

자, 그럼 첫 번째 외부에서 발생한 이벤트에 대한 예시부터 알아보겠습니다.

 

 

 

옵저버 패턴은 React Server Components에도 존재한다.

async function fetchDataFromDB() {
  const result = await database.query('SELECT * FROM table');
  return <div>{result}</div>;
}

RSC에서는 DB 조회와 같은 비동기 작업을 관리하는 곳에 옵저버 패턴이 적용되었습니다.

 

 

RSC 비동기 작업 관리

 

메커니즘을 도식화시켜보면, 관찰 대상은 pingedTasks입니다. task 추가와 같은 이벤트가 발생하면, 관찰자에게 이 사실을 알려 비동기 작업을 수행하도록 합니다. 비동기 작업이 실패하면, 추후에 다시 작업할 수 있도록 pingedTasks에 작업이 저장됩니다.

 

리액트 서버 소스 코드를 보며 확인해 보겠습니다.

 

// packages > react-server > src > ReactFlightServer.js
export type Request = {
  ...
  pingedTasks: Array<Task>,
  ...
}

관찰 대상인 request.pingedTasks는 비동기 Task 배열입니다. 새로운 Task나 실패한 Task가 모입니다.

 

// packages > react-server > src > ReactFlightServer.js
function pingTask(request: Request, task: Task): void {
  const pingedTasks = request.pingedTasks;
  pingedTasks.push(task);
  if (pingedTasks.length === 1) {
    request.flushScheduled = request.destination !== null;
    scheduleMicrotask(() => performWork(request));
  }
}

관찰 대상인 request.pingedTasks에 지연되거나 실패한 비동기 작업이 추가된다면, 관찰자 역할을 하는 performWork 함수에게 이 사실을 알리게 됩니다. 앞서 작업이 아무것도 없으면 마이크로테스크 큐에 해당 작업을 스케줄링하고 처리합니다. 

 

// packages > react-server > src > ReactFlightServer.js
function performWork(request: Request): void {
  ...
  const pingedTasks = request.pingedTasks;
  request.pingedTasks = []; // 관찰 대상 초기화
  for (let i = 0; i < pingedTasks.length; i++) {
    const task = pingedTasks[i];
    retryTask(request, task); // update
  }
  ...
}

function retryTask(request: Request, task: Task): void {
  ...
  try { ...
  } catch (thrownValue) {
  	...
    const ping = task.ping;
    x.then(ping, ping);
    return;
  } 
  ...
}

관찰자 performWork 함수에서는 관찰 대상인 pingedTasks에 retryTask를 수행합니다. 작업 중에 일부 중단되거나 지연되는 일이 발생하면, pingTask 함수를 호출해서 request.pingedTasks에 추가됩니다. (task.ping에 pingTask를 실행하는 함수가 바인딩되어 있습니다.)

 

이렇게 옵저버 패턴의 개념을 적용하여, 리액트 서버에서 비동기 작업을 관리하고 있습니다.

 

 

 

펫 프로젝트에 옵저버 패턴 적용하기

모래시계 UI

 

자, 이어서 두 번째 케이스인 객체의 속성 값이 변화할 때 추적하는 방법을 보겠습니다. 저는 펫 프로젝트로 타이머를 만들고 있는데요. 타이머의 남은 시간에 따라 색이 모래시계의 모래처럼 아래로 빠져나가는 UI를 그리려고 합니다. 이를 위해서 남은 시간을 추적해야 하는데, 클래스로 구현된 타이머가 UI를 렌더링할 수 있도록 만들어야 했습니다.

 

타이머 남은 시간으로 UI 렌더링

 

이 경우도 도식화시켜보면, 타이머의 남은 시간이 줄어들면 이를 useSyncExternalStore 훅에 알리고, 훅은 남은 시간의 상태 변화를 감지하여 UI를 리렌더링합니다. 

 

export type Listener = () => void;

export interface ObserverInterface {
    listeners: Set<Listener>;
    subscribe(listener: Listener): void;
    unsubscribe(listener: Listener): void;
    notifyListeners(): void;
}

export class Observer implements ObserverInterface {
    listeners: Set<Listener>;

    constructor() {
        this.listeners = new Set();
    }

    subscribe(listener: Listener): void {
        this.listeners.add(listener);
    }

    unsubscribe(listener: Listener): void {
        this.listeners.delete(listener);
    }

    notifyListeners(): void {
        this.listeners.forEach((listener) => listener());
    }
}


export class Pomodoro {
    ...
    private remainingTime: number;
    ...
    constructor(...) {
    	...
        this.remainingTime = Pomodoro.focusSessionDuration;
        this.remainingTimeObserver = new Observer();
        ...
    }
    
}

옵저버 클래스는 관찰자 목록인 listeners와 관찰자 추가/삭제, 그리고 그들에게 상태 변화를 알리는 notifyListeners 메서드가 있습니다. 남은 시간을 관찰하기 위해, 타이머(Pomodoro) 클래스의 생성자에서 옵저버 클래스 인스턴스를 생성했습니다.

 

    public timerStart(duration: number, timeoutCallback: Function) {
        this.remainingTime = duration;

        const timer = () => {
            this.timerId = setTimeout(() => {
                this.remainingTime -= 1 * SECOND;
                this.remainingTimeObserver.notifyListeners(); // notify

                if (this.remainingTime > 0) {
                    timer();
                } else {
                    timeoutCallback();
                }
            }, 1 * SECOND);
        };

        timer();
    }

타이머의 시간이 매 초 줄어들 때마다, notifyListeners 메서드는 관찰자(useSyncExternalStore)들에게 관찰 대상(remainingTime)의 상태가 변경되었음을 알려줍니다. 

 

import { Listener } from "@/lib/observer";
import { Pomodoro } from "@/lib/pomodoro";
import { useSyncExternalStore } from "react";

export function useRemainTime(pomodoro: Pomodoro) {
    const subscribe = (callback: Listener) => {
        pomodoro.getRemainingTimeObserver.subscribe(callback);
        return () => pomodoro.getRemainingTimeObserver.unsubscribe(callback);
    };

    return useSyncExternalStore(
        subscribe,
        () => pomodoro.getRemainingTime,
        () => pomodoro.getRemainingTime,
    );
}

리액트가 remainingTime의 변화를 감지할 수 있도록 useSyncExternalStore 훅을 옵저버와 연결시켜 줍니다.

 

useSyncExternalStore 훅은 외부 store를 구독할 수 있는 리액트 훅입니다.

훅의 첫 번째 인수인 subscribe 함수는 콜백 함수를 인자로 가지고, 옵저버 클래스에 콜백 함수를 등록합니다. 이 콜백 함수는 타이머의 notifyListeners 메서드가 호출될 때 실행됩니다. 그래서 매 초 남은 시간이 변경될 때마다, 훅은 이를 알 수 있습니다. 

두 번째 인수는 외부 store의 데이터 스냅샷입니다. 남은 시간의 스냅샷이 변경되었는지 확인되면, 해당 훅이 사용되는 컴포넌트를 리렌더링합니다.

 

 

이상으로 옵저버 패턴의 개념을 알아보고 적용해 보았습니다.

 

 

 

참고

wikipedia - 옵저버 패턴

Refactoring guru - 옵저버 패턴