[Vanilla JS] observer 패턴으로 상태 관리해보기

세하·2025년 9월 11일

JavaScript

목록 보기
7/10

옵저버 패턴이란?

옵저버 패턴(observer pattern)은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다. 주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용된다. 발행/구독 모델로 알려져 있기도 하다.
즉, 한 객체의 상태가 변하면 그 객체에 의존하는 다른 객체들에게 자동으로 알려주고 업데이트하는 디자인 패턴이다.

유튜브 채널 구독으로 예시를 들어보자면 아래와 같다.

  • 주체 (Subject): 유튜버 (유튜브 채널)
  • 옵저버 (Observer): 구독자들
  • 상태 변화: 유튜버가 새로운 영상을 올림
  • 알림: 구독자들에게 "새 영상 올라왔어요!" 알림이 감
  • 업데이트: 구독자들은 새 영상을 시청함

여기서 중요한 점은, 유튜버는 누가 자신을 구독하는지, 구독자가 몇 명인지 일일이 알 필요가 없다. 그냥 새 영상을 올리면 '구독'이라는 시스템이 알아서 모든 구독자에게 알림을 보내준다.
구독자들도 유튜버가 새 영상을 올렸는지 계속 확인할 필요 없이, 알림을 받으면 된다.

이처럼 주체(Subject)와 옵저버(Observer)가 느슨하게 결합(Loose Coupling) 되어 있어, 서로에게 미치는 영향을 최소화하고 유연한 관계를 유지하는 것이 이 패턴의 핵심이다.

observer 적용 전

모델 (issueModel.js)

상태를 관리하는 store 역할

export const state = {
    issues: [],      				// 현재 화면에 보여줄 이슈 목록
    counts: { open: 0, closed: 0 }, // 전체 이슈 개수
};

// 모델의 상태를 업데이트하는 함수
export const setState = ({ issues, counts }) => {
    state.issues = issues;
    state.counts = counts;
};

export const getState = () => {
    return state;
}

컨트롤러 (issueListController.js)

import { fetchIssues } from '../service/issues.js';
import { render } from '../views/issueListPage/index.js';
import { getState, setState, setFilter } from '../models/issueModel.js';

// Controller의 메인 로직. 화면을 다시 그려야 할 때마다 호출됨
const renderPage = () => {
    render(getState());
}

// Controller를 초기화하는 함수. 애플리케이션 시작 시 한 번만 호출됨
export const initIssueListPage = async () => {
    const initialFilter = getState().filter === 'open' ? '1' : '0';
    const data = await fetchIssues(initialFilter);
    setState(data);

    renderPage(); // 🚨 observer 패턴 적용 전이므로 수동으로 controller가 뷰 렌더링을 해줌

    addEventListeners(); 
};

const addEventListeners = () => {
    const main = document.querySelector('main');

    main.addEventListener('click', async (e) => {
        const target = e.target;

        // 열린 이슈 탭 클릭
        if (target.closest('.open-issue-tab')) {
            setFilter('open');
            const data = await fetchIssues('1');
            setState(data);
            renderPage(); // 🚨 observer 패턴 적용 전이므로 수동으로 controller가 뷰 렌더링을 해줌
            return;
        }

        // 닫힌 이슈 탭 클릭
        if (target.closest('.close-issue-tab')) {
            setFilter('closed');
            const data = await fetchIssues('0');
            setState(data);
            renderPage(); // 🚨 observer 패턴 적용 전이므로 수동으로 controller가 뷰 렌더링을 해줌
            return;
        }
    });
}

현재는 컨트롤러가 상태 변경 후 renderPage()를 직접 호출하며 뷰의 렌더링을 책임지고 있다.
지금 구조에서 Observer 패턴을 도입하면 컨트롤러와 뷰의 결합도를 낮출 수 있다.

모델의 상태가 바뀌면, 모델이 스스로 자신을 구독(관찰)하고 있는 뷰에게 '나 바뀌었어!'라고 알려줘서 뷰가 스스로 리렌더링하게 만들 수 있다. 컨트롤러는 그저 모델의 상태를 바꾸는 역할만 하고 렌더링에는 관여하지 않게 된다.

Observer 패턴 적용 (기본)

모델 (issueModel.js) 수정 (Subject 역할)

모델이 자신을 관찰하는 "관찰자(Observer)"들을 등록하고, 상태가 변경될 때 그들에게 알려주는 기능을 추가한다.

  1. 관찰자 목록 추가: observers = [] 와 같이 관찰자(렌더링 함수)들을 저장할 배열을 만든다.
  2. 구독(subscribe) 함수 추가: 외부에서 관찰자를 등록할 수 있는 subscribe(observer) 함수를 만든다. 이 함수는 observers 배열에 관찰자를 추가함
  3. 알림(notify) 함수 추가: observers 배열을 순회하며 등록된 모든 관찰자 함수를 실행하는 notify() 함수를 만든다.
  4. 상태 변경 함수 수정: 기존의 setState, setFilter 함수가 끝날 때 notify() 함수를 호출하도록 수정한다.
const state = {
    issues: [],      // 현재 화면에 보여줄 이슈 목록
    counts: { open: 0, closed: 0 }, // 전체 이슈 개수
};

const observers = []; // ‼️

export const subscribe = (observer) => { // ‼️
    observers.push(observer);
}

// API 응답 데이터로 모델의 상태를 업데이트하는 함수
export const setState = ({ issues, counts }) => {
    state.issues = issues;
    state.counts = counts;
    notify(); // ‼️
};

export const getState = () => {
    return state;
}

const notify = () => { // ‼️
    for (const observer of observers) {
        observer();
    }
}

컨트롤러 (issueListController.js) 수정 (Client 역할)

컨트롤러는 더 이상 renderPage()를 직접 호출하지 않고, 초기에 "이 함수를 관찰자로 등록해줘" 라고 모델에게 알려주기만 하면 된다.

  1. 구독 등록: initIssueListPage 함수에서 모델의 subscribe(renderPage)를 호출하여 renderPage 함수를 관찰자로 등록한다.
  2. 직접 호출 제거: 이벤트 리스너 안에 있던 모든 renderPage() 호출 코드를 제거
import { fetchIssues } from '../service/issues.js';
import { render } from '../views/issueListPage/index.js';
import { getState, setState, setFilter, subscribe } from '../models/issueModel.js';

// Controller의 메인 로직. 화면을 다시 그려야 할 때마다 호출됨
const renderPage = () => {
    render(getState());
}

// Controller를 초기화하는 함수. 애플리케이션 시작 시 한 번만 호출됨
export const initIssueListPage = async () => {
    subscribe(renderPage); // ‼️ issueModel을 구독하여 상태 변경 시 자동으로 renderPage가 호출되도록 설정

    const initialFilter = getState().filter === 'open' ? '1' : '0';
    const data = await fetchIssues(initialFilter);
    setState(data); // ‼️ 상태를 업데이트하면 notify가 호출되어 렌더링이 일어남

    addEventListeners(); 
};

const addEventListeners = () => {
    const main = document.querySelector('main');

    main.addEventListener('click', async (e) => {
        const target = e.target;

        // 열린 이슈 탭 클릭
        if (target.closest('.open-issue-tab')) {
            const data = await fetchIssues('1');
            setState(data); // ‼️ 상태를 업데이트하면 notify가 호출되어 렌더링이 일어남
            return;
        }

        // 닫힌 이슈 탭 클릭
        if (target.closest('.close-issue-tab')) {
            const data = await fetchIssues('0');
            setState(data); // ‼️ 상태를 업데이트하면 notify가 호출되어 렌더링이 일어남
            return;
        }
    });
}

변경 후 데이터 흐름

  1. 초기화: initIssueListPage가 실행될 때 subscribe(renderPage)가 호출되어, renderPage가 모델의 관찰자로 등록됨
  2. 이벤트 발생: 사용자가 '닫힌 이슈' 탭을 클릭
  3. 컨트롤러: 이벤트 리스너가 setFilter('closed')와 setState(data)를 순서대로 호출
  4. 모델:
    • setFilter가 실행되어 state.filter가 바뀜
    • setFilter의 마지막 줄에 있는 notify()가 호출됨
    • notify는 자신을 구독하고 있는 renderPage를 호출하여 화면이 첫 번째로 리렌더링됨
    • 그다음, setState가 실행되어 state.issues와 state.counts가 바뀜
    • setState의 마지막 줄에 있는 notify()가 또 호출됨
    • notify는 renderPage를 다시 호출하여 화면이 두 번째로 리렌더링됨

이렇게 변경하면 컨트롤러는 상태 변경만 책임지고, 뷰 렌더링은 모델의 상태 변경에 따라 자동으로 반응하게 되어 각자의 역할이 더 명확해진다.

Model은 발행하고, View는 구독하는 관계이다. 다시말해 View는 Model을 구독한다.
'이벤트' -> 상태변경(Model) -> 화면의변화(View)
Model이 Observable이 되고, View가 Observer가 된다.

🌟 Observable 상속을 통해 재사용성 높이기

지금까지의 구현은 옵저버 패턴의 역할을 모델이 직접 수행하도록 만들었다.
하지만 애플리케이션이 복잡해져서 여러 모델과 여러 뷰가 생기면, issues를 관리하는 모델 외에 labels나 users를 관리하는 모델이 추가된다면 모든 모델 파일마다 observers, subscribe, notify 함수를 중복해서 구현해야 할까?

이는 코드의 중복일 뿐만 아니라, 모델이 너무 많은 책임을 갖게 만든다.
모델의 본질적인 역할은 데이터를 관리하는 것인데, 여기에 구독자를 관리하고 알림을 보내는 방송국(Observable)의 역할까지 떠안게 되어 코드가 복잡해진다.

이 문제를 해결하기 위해 관찰 가능한 객체(Observable)의 역할을 별도의 클래스로 분리하고, 모델이 이 클래스를 상속받도록 만들 수 있다.

관찰 대상 (Subject, Observable): 변화가 일어나는 주체, '방송국'

  • 모델 (Model): 데이터가 변경되는 곳

관찰자 (Observer): 변화를 지켜보고 알림을 받는 존재

  • 뷰 (View): 데이터의 변화를 화면에 그려야 하는 곳

Observable 클래스 만들기

먼저 구독자 관리와 알림 기능을 전담하는 Observable 클래스를 만든다.

// Observable.js
export default class Observable {
  constructor() {
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter(sub => sub !== observer);
  }

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

이제 subscribe, notify 같은 방송국의 기능은 이 Observable 클래스가 모두 책임진다.

모델(Model)이 Observable을 상속받도록 수정

이제 issueModel이 이 Observable 클래스를 상속(extends)받게 하여, 방송국의 기능을 물려받도록 수정한다.

// issueModel.js
import Observable from './Observable.js';

class IssueModel extends Observable { // ‼️ Observable을 상속
  constructor() {
    super(); // ‼️ 부모 클래스의 constructor 호출
    this.state = {
      issues: [],
      counts: { open: 0, closed: 0 },
    };
  }

  setState({ issues, counts }) {
    this.state.issues = issues;
    this.state.counts = counts;
    this.notify(); // ‼️ 부모에게 물려받은 notify() 호출
  }

  getState() {
    return this.state;
  }
}

// 모델을 싱글턴 인스턴스로 만들어 내보냄
export const issueModel = new IssueModel();

issueModel의 코드가 훨씬 깔끔해졌다. 더 이상 observers 배열이나 subscribe, notify 함수를 직접 들고 있을 필요 없이 부모 클래스로부터 물려받은 기능을 사용하기만 하면 된다. 모델은 다시 데이터 관리라는 자신의 핵심 역할에만 집중할 수 있게 되었다.

controller

컨트롤러에서는 이제 issueModel 인스턴스의 메서드를 호출하면 된다.

// issueListController.js
import { issueModel } from '../models/issueModel.js';

// ...
issueModel.subscribe(renderPage);
// ...
issueModel.setState(data);

이처럼 상속을 활용하면, 관찰 대상(Subject)이 되어야 하는 모든 모델은 Observable 클래스를 상속받기만 하면 깔끔하게 방송국의 기능을 갖출 수 있다. 이를 통해 코드의 중복을 제거하고, 각 객체가 하나의 책임만 갖도록 하는 단일 책임 원칙(Single Responsibility Principle) 을 지킬 수 있다.

0개의 댓글