본문 바로가기
2024/TIL

[cs] Observer Pattern 왜 알아야 할까?

by ye-jji 2024. 7. 11.

정보처리기사 준비하기 전 나는 옵저버를 사용한 적이 있지만 무엇인지 제대로 모른채 사용했었다. 사용하면 왜 좋은지, 사용하지 않으면 무슨 문제가 생길 수 있는지에 대해 큰 고민 없이 사용하라는 말에 무지성으로 코딩했었다. 그러다가 정보처리기사 시험 준비를 하면서 GoF의 무수히 많은 디자인 패턴을 보며 기가 질림과 동시에 익숙한 단어들을 발견하게 됐는데 그 중하나가 옵저버 패턴이었다.

 

프론트 개발자가 Observer Pattern을 알아야 하는 이유는 뭘까? 

 

프론트엔드 개발에서는 유저와의 직접적인 상호작용이 많다. 유저의 액션 하나하나에 민감하게 반응하고 그 데이터를 효율적으로 관리하는 것은 필수이다. 예를 들어, 사용자가 버튼을 클릭하면 UI가 즉시 업데이트 되어야 하고, 입력 폼의 데이터가 실시간으로 유효성 검사를 받아야 한다. 그 외에도 무수히 많은 일들을 하드코딩으로 미래의 나에게 맡기면 유지보수가 어렵고 가독성이 떨어지며 성능 문제가 발생할 수 있다. 심한 경우 기능추가를 위해  다 갈아 엎어야 하는 상황이 생길수도 있다..ㅎ..

 

예전에 했던 프로젝트들을 리팩토링 하면서 성능이나 클린코드에 대해 고민을 많이 하게 되었다. 생각보다 옵저버만 잘 사용해도 성능이 확 좋아지고 코드가 깔끔해지는 경험을 해보니이 부분에 대한 개념을 깔끔하게 정리하고 가겠다는 생각을 하게 되었다.

 

1. 옵저버 패턴

 

옵저버 패턴은 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다. 객체를 주제라고 부르는데 그 이유는 주제와 객체를 따로 두는 경우도 있기 때문이라고 한다. 주제와 옵저버는 보통 1:n 관계이고 이 결합은 인터페이스를 통해 연결하기 때문에 느슨한 결합으로 이루어진다. 옵저버 패턴을 활용하면 다른 객체의 상태 변화를 별도의 함수 호출 없이 즉각적으로 알 수 있기 때문에 매우 효율적이다.

class Subject {
  constructor() {
    this.observers = [];
  }

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

  removeObserver(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  notifyObservers() {
    for (let observer of this.observers) {
      observer.update(this);
    }
  }
}

class Observer {
  update(subject) {
    console.log(`Observer notified with state: ${subject.state}`);
  }
}

// 주제 생성
const subject = new Subject();

// 옵저버 생성
const observer1 = new Observer();
const observer2 = new Observer();

// 옵저버 등록
subject.addObserver(observer1);
subject.addObserver(observer2);

// 주제의 상태 변화
subject.state = "new state";
subject.notifyObservers();

 

느슨한 결합

옵저버 패턴을 사용하면 객체 간의 결합도가 낮아져 시스템의 유연성과 확장성이 높아진다. 상태 변경을 감지하고 반응하는 로직이 분리되기 때문에, 주제와 옵저버가 독립적으로 변경될 수 있다.

 

실시간 데이터 업데이트

상태 변화가 발생할 때마다 자동으로 관련된 UI나 데이터 모델이 업데이트되므로, 사용자 인터페이스가 항상 최신 상태를 유지할 수 있고, 사용자 경험을 크게 향상시킬 수 있다.

 

코드의 가독성 및 유지보수성 향상

상태 관리와 업데이트 로직이 분리되어 코드가 더 모듈화되고, 각 부분의 역할이 명확해진다. 코드의 가독성을 높이고, 유지보수를 용이하게 만든다.

 

성능 최적화

필요한 부분만 업데이트 해 불필요한 연산을 줄일 수 있다. 전체 애플리케이션의 성능을 향상시키고 비용을 줄이는 데 매우 중요하다.

 

 

2. js 내장 옵저버들

 

1) ResizeObserver

요소의 크기가 변경될 때 이를 감지하여 반응형 레이아웃을 구현하거나 특정 작업을 수행할 수 있는 옵저버로 창 크기 변경 시 요소의 크기에 맞춰 레이아웃을 재조정할 때 많이 사용한다.

const callback = (entries) => {
  for (let entry of entries) {
    console.log('Element size changed:', entry.contentRect);
  }
};
const observer = new ResizeObserver(callback);
const target = document.querySelector('#target');
observer.observe(target);

 

2) IntersectionObserver

요소가 뷰포트에 들어오거나 나가는 시점을 감지하여 특정 작업을 할 때 사용하는 옵저버이다. 무한 스크롤 구현, 이미지 지연 로딩, 특정 섹션이 화면에 나타날 때 애니메이션을 시작하는 등의 기능을 구현할 때 사용한다.

const callback = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('Element is in view.');
    }
  });
};
const observer = new IntersectionObserver(callback);
const target = document.querySelector('#target');
observer.observe(target);

 

3) MutationObserver

웹 애플리케이션에서 DOM 구조가 동적으로 변경될 때 이를 감지하고 적절히 반응해야 할 때 사용하는 옵저버이다. 새로운 요소가 추가되거나 기존 요소가 삭제될 때 이를 감지하여 UI를 업데이트하거나, 특정 속성 변경을 감시하여 스타일을 변경할 때 사용한다.

const targetNode = document.getElementById('app');
const config = { childList: true, subtree: true };
const callback = function(mutationsList, observer) {
  for (let mutation of mutationsList) {
    if (mutation.type === 'childList') {
      console.log('A child node has been added or removed.');
    }
  }
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);

 

 

그 외에도 PerformanceObserver, ReportingObserver가 있지만 많이 사용하는 것만 자세하게 정리했다.

 

3. React에서 찾아본 Observer Pattern

리액트 생태계의 다양한 상태 관리 라이브러리와 패턴이 옵저버 패턴을 활용한다. 그 중 사용해봤던 것들도 있어서 반가운 것들도 있었다.

 

1) Context API

리액트의 내장 기능인 Context API는 옵저버 패턴의 개념을 사용하여 상태를 전역적으로 관리하고 전달한다. Context API를 사용하면 컴포넌트 트리 전체에서 상태를 공유할 수 있다. 리액트를 처음 사용할 때 상태관리로 이 API를 사용했었다.

import React, { createContext, useContext, useState } from 'react';

const CountContext = createContext();

const Counter = () => {
  const [count, setCount] = useContext(CountContext);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
    </div>
  );
};

const App = () => {
  const countState = useState(0);

  return (
    <CountContext.Provider value={countState}>
      <Counter />
    </CountContext.Provider>
  );
};

export default App;

 

2) Recoil

리액트 상태 관리 라이브러리로, 상태를 atom 단위로 나누어 관리하는데 옵저버 패턴을 활용하여 atom의 상태 변화를 구독하고 반응한다. 프로젝트 할 때 제일 많이 사용한 라이브러리다.

import React from 'react';
import { atom, useRecoilState } from 'recoil';

const countState = atom({
  key: 'countState',
  default: 0,
});

const Counter = () => {
  const [count, setCount] = useRecoilState(countState);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
    </div>
  );
};

const App = () => (
  <div>
    <Counter />
  </div>
);

export default App;

 

그 외에도 Redux와 MobX가 있다. Redux는 엄격하게 옵저버 패턴을 사용하지는 않지만, 상태 변화를 구독하고 반응하는 기능을 제공한다. 특히, 미들웨어를 통해 비동기 액션을 처리하는 경우 옵저버 패턴의 개념을 일부 활용한다. MobX도 리액트와 함께 사용되는 상태 관리 라이브러리로, 옵저버 패턴을 기반으로 한다. MobX는 상태 변화가 발생할 때 자동으로 컴포넌트를 업데이트하여, 데이터 흐름을 간단하고 예측 가능하게 만든다.

 

결론

프론트엔드 개발에서 옵저버 패턴은 상태관리의 핵심개념이었다. 이 개념을 이해하고 활용하는것이 프론트엔드 개발자로서의 역량을 높이는데 필수적이라는 생각을 가지게 되었고 역시 cs지식이 필요하다는 것을 느꼈다. 정처기 생각보다 도움이 많이 되는군!