이미지 로딩 중...

Observer Pattern 테스트 전략 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 7 Views

Observer Pattern 테스트 전략 완벽 가이드

Observer Pattern을 실무에서 안정적으로 활용하기 위한 테스트 전략을 다룹니다. Mock 객체 활용부터 비동기 이벤트 검증, 메모리 누수 방지까지 실전에서 바로 적용할 수 있는 테스팅 노하우를 제공합니다.


목차

  1. Observer Pattern 기본 구조 테스트
  2. Mock Observer를 활용한 단위 테스트
  3. 다중 Observer 시나리오 테스트
  4. 비동기 이벤트 알림 테스트
  5. Observer 등록 해제 테스트
  6. 이벤트 순서 보장 테스트
  7. 에러 처리 및 복구 테스트
  8. 성능 테스트와 최적화 검증

1. Observer Pattern 기본 구조 테스트

시작하며

여러분이 Observer Pattern을 구현했을 때 이런 고민을 해본 적 있나요? "Subject에 Observer를 등록하면 정말 알림이 제대로 전달될까?" "내 코드가 패턴의 원칙을 제대로 따르고 있을까?" 실무에서 디자인 패턴을 구현할 때 가장 중요한 것은 패턴의 핵심 계약(contract)이 제대로 지켜지는지 확인하는 것입니다.

Observer Pattern의 경우, Subject의 상태가 변경되면 모든 등록된 Observer에게 알림이 전달되어야 합니다. 이 기본 계약이 깨지면 전체 시스템의 신뢰성이 무너집니다.

바로 이럴 때 필요한 것이 기본 구조 테스트입니다. Subject와 Observer 간의 연결 관계를 검증하고, 알림 메커니즘이 정확히 작동하는지 확인하는 것이 첫 번째 단계입니다.

개요

간단히 말해서, 이 테스트는 Observer Pattern의 가장 기본적인 동작 원리를 검증하는 것입니다. Subject에 Observer를 등록하고, Subject의 상태가 변경될 때 Observer의 update 메서드가 호출되는지 확인합니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, Observer Pattern을 사용하는 많은 시스템에서 알림 누락이 치명적인 문제를 일으킬 수 있기 때문입니다. 예를 들어, 주식 가격 모니터링 시스템에서 가격 변동 알림이 누락되면 거래 기회를 놓치거나 손실을 입을 수 있습니다.

기존에는 수동으로 콘솔 로그를 찍어가며 확인했다면, 이제는 자동화된 테스트로 모든 시나리오를 체계적으로 검증할 수 있습니다. 이 테스트의 핵심 특징은 첫째, Subject와 Observer 간의 계약 준수를 명확히 검증하고, 둘째, 등록/해제 메커니즘이 정확히 작동하는지 확인하며, 셋째, 알림 전달의 완전성을 보장한다는 점입니다.

이러한 특징들이 시스템의 신뢰성을 높이고 리팩토링 시에도 안전성을 보장해줍니다.

코드 예제

// Subject 인터페이스 정의
interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(): void;
}

// Observer 인터페이스 정의
interface Observer {
  update(subject: Subject): void;
}

// 구체적인 Subject 구현
class ConcreteSubject implements Subject {
  private observers: Observer[] = [];
  private state: number = 0;

  // Observer 등록
  attach(observer: Observer): void {
    this.observers.push(observer);
  }

  // Observer 제거
  detach(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  // 모든 Observer에게 알림
  notify(): void {
    for (const observer of this.observers) {
      observer.update(this);
    }
  }

  // 상태 변경 및 알림
  setState(state: number): void {
    this.state = state;
    this.notify();
  }

  getState(): number {
    return this.state;
  }
}

// 테스트: 기본 Observer 등록 및 알림 검증
describe('Observer Pattern 기본 구조 테스트', () => {
  it('Observer를 등록하면 상태 변경 시 알림을 받는다', () => {
    const subject = new ConcreteSubject();
    const mockUpdate = jest.fn();

    const observer: Observer = {
      update: mockUpdate
    };

    subject.attach(observer);
    subject.setState(10);

    // update가 정확히 1번 호출되었는지 검증
    expect(mockUpdate).toHaveBeenCalledTimes(1);
    expect(mockUpdate).toHaveBeenCalledWith(subject);
  });
});

설명

이것이 하는 일: Observer Pattern의 가장 핵심적인 동작인 "상태 변경 → 알림 전달"이 정확히 작동하는지 검증합니다. Subject와 Observer 간의 기본 계약이 지켜지는지 확인하는 것이 목적입니다.

첫 번째로, Subject와 Observer의 인터페이스를 정의합니다. 이는 타입스크립트의 강력한 타입 시스템을 활용하여 계약을 명시적으로 표현하는 것입니다.

Subject는 attach, detach, notify 메서드를 반드시 구현해야 하며, Observer는 update 메서드를 구현해야 합니다. 이렇게 하면 컴파일 타임에 구조적 오류를 잡을 수 있습니다.

그 다음으로, ConcreteSubject 클래스가 실제 Observer 목록을 관리하고 알림을 전달하는 로직을 구현합니다. observers 배열에 등록된 모든 Observer의 update 메서드를 순회하며 호출하는 것이 핵심입니다.

setState 메서드는 상태를 변경한 후 자동으로 notify를 호출하여 모든 Observer에게 변경 사항을 알립니다. 테스트 코드에서는 Jest의 jest.fn()을 사용하여 Mock 함수를 만들고, 이를 Observer의 update 메서드로 사용합니다.

subject.setState(10)을 호출했을 때 mockUpdate가 정확히 1번 호출되었는지, 그리고 subject 자신을 인자로 받았는지 검증합니다. 여러분이 이 테스트를 사용하면 리팩토링 시에도 기본 동작이 깨지지 않았음을 즉시 확인할 수 있고, 새로운 팀원이 코드를 수정할 때도 패턴의 계약을 지키도록 강제할 수 있습니다.

또한 CI/CD 파이프라인에 통합하여 자동으로 회귀 테스트를 수행할 수 있습니다.

실전 팁

💡 테스트 시에는 항상 실제 Observer 대신 Mock 객체를 사용하세요. 의존성을 격리하면 테스트가 더 빠르고 안정적이며, 외부 요인에 영향받지 않습니다. 💡 toHaveBeenCalledWith를 사용하여 전달된 인자를 검증하세요. Subject가 자기 자신을 전달하는지 확인하면 Observer가 최신 상태에 접근할 수 있음을 보장합니다. 💡 각 테스트는 독립적으로 실행되어야 합니다. beforeEach를 사용하여 매번 새로운 Subject 인스턴스를 생성하고, 테스트 간 상태 공유를 방지하세요. 💡 타입스크립트의 인터페이스를 활용하면 컴파일 타임에 구조적 오류를 잡을 수 있습니다. 런타임 오류보다 컴파일 타임 오류가 훨씬 발견하고 수정하기 쉽습니다. 💡 테스트 커버리지 도구를 사용하여 모든 브랜치가 테스트되는지 확인하세요. notify 메서드의 for 루프가 실제로 실행되는지, observers 배열이 비어있을 때도 오류가 없는지 검증해야 합니다.


2. Mock Observer를 활용한 단위 테스트

시작하며

여러분이 복잡한 Observer를 테스트할 때 이런 어려움을 겪어본 적 있나요? "이 Observer는 데이터베이스에 접근하고, API를 호출하고, 파일 시스템을 사용하는데...

테스트하려면 모든 걸 설정해야 하나?" 실무에서 Observer는 종종 외부 시스템과 연동됩니다. 이메일 발송, 로깅, 데이터베이스 저장, 캐시 무효화 등 다양한 부수 효과(side effect)를 발생시키죠.

이런 Observer를 테스트하려면 모든 외부 의존성을 설정해야 하고, 테스트는 느리고 불안정해집니다. 바로 이럴 때 필요한 것이 Mock Observer 전략입니다.

실제 Observer의 복잡한 로직 대신 간단한 Mock 객체를 사용하여 Subject의 알림 메커니즘만을 집중적으로 테스트할 수 있습니다.

개요

간단히 말해서, Mock Observer는 실제 Observer를 대신하는 테스트용 객체로, 메서드 호출을 추적하고 검증할 수 있게 해줍니다. Jest의 Mock 함수를 활용하면 호출 횟수, 전달된 인자, 호출 순서 등을 정밀하게 검증할 수 있습니다.

왜 이 방법이 필요한지 실무 관점에서 설명하자면, 단위 테스트는 빠르고 독립적이며 반복 가능해야 하기 때문입니다. 예를 들어, 이메일을 발송하는 Observer를 테스트할 때 실제로 이메일을 보내면 테스트가 느려지고, 외부 서비스에 의존하게 되며, 비용도 발생합니다.

Mock을 사용하면 이런 문제를 모두 해결할 수 있습니다. 기존에는 테스트 환경에서 복잡한 설정과 Mocking 라이브러리를 사용했다면, 이제는 Jest의 내장된 Mock 기능으로 간단하게 해결할 수 있습니다.

이 방법의 핵심 특징은 첫째, 외부 의존성을 완전히 격리하여 테스트 속도와 안정성을 높이고, 둘째, 메서드 호출의 모든 측면을 검증할 수 있으며, 셋째, 테스트 더블(Test Double) 패턴을 활용하여 다양한 시나리오를 쉽게 시뮬레이션할 수 있다는 점입니다.

코드 예제

// Mock Observer를 사용한 테스트
describe('Mock Observer 단위 테스트', () => {
  let subject: ConcreteSubject;
  let mockObserver1: Observer;
  let mockObserver2: Observer;
  let updateSpy1: jest.Mock;
  let updateSpy2: jest.Mock;

  beforeEach(() => {
    // 각 테스트마다 새로운 인스턴스 생성
    subject = new ConcreteSubject();

    // Mock 함수 생성
    updateSpy1 = jest.fn();
    updateSpy2 = jest.fn();

    // Mock Observer 객체 생성
    mockObserver1 = { update: updateSpy1 };
    mockObserver2 = { update: updateSpy2 };
  });

  it('여러 Observer에게 순차적으로 알림을 전달한다', () => {
    subject.attach(mockObserver1);
    subject.attach(mockObserver2);

    subject.setState(20);

    // 두 Observer 모두 호출되었는지 검증
    expect(updateSpy1).toHaveBeenCalledTimes(1);
    expect(updateSpy2).toHaveBeenCalledTimes(1);

    // 올바른 Subject가 전달되었는지 검증
    expect(updateSpy1).toHaveBeenCalledWith(subject);
    expect(updateSpy2).toHaveBeenCalledWith(subject);
  });

  it('등록되지 않은 Observer는 알림을 받지 않는다', () => {
    subject.attach(mockObserver1);
    // mockObserver2는 등록하지 않음

    subject.setState(30);

    expect(updateSpy1).toHaveBeenCalledTimes(1);
    expect(updateSpy2).not.toHaveBeenCalled();
  });

  it('상태 변경이 없으면 알림도 없다', () => {
    subject.attach(mockObserver1);

    // setState를 호출하지 않음

    expect(updateSpy1).not.toHaveBeenCalled();
  });
});

설명

이것이 하는 일: Mock Observer를 사용하여 Subject의 알림 메커니즘을 격리된 환경에서 테스트합니다. 실제 Observer의 복잡한 로직 없이 순수하게 알림 전달 기능만을 검증하는 것이 핵심입니다.

첫 번째로, beforeEach 훅에서 각 테스트마다 새로운 Subject와 Mock Observer를 생성합니다. 이는 테스트 간의 독립성을 보장하는 중요한 패턴입니다.

jest.fn()은 호출 정보를 추적하는 Mock 함수를 생성하고, 이를 Observer 객체의 update 메서드로 사용합니다. 이렇게 하면 실제 구현 없이도 메서드가 호출되었는지 추적할 수 있습니다.

그 다음으로, 다양한 시나리오를 테스트합니다. 첫 번째 테스트는 여러 Observer가 모두 알림을 받는지 검증하고, 두 번째 테스트는 등록되지 않은 Observer는 알림을 받지 않는지 확인하며, 세 번째 테스트는 상태 변경이 없으면 불필요한 알림이 발생하지 않는지 검증합니다.

이런 다양한 경계 조건을 테스트하는 것이 견고한 시스템을 만드는 비결입니다. toHaveBeenCalledTimes와 not.toHaveBeenCalled 같은 Jest 매처를 사용하면 정확한 호출 횟수를 검증할 수 있습니다.

이는 Observer가 중복으로 호출되거나 누락되는 버그를 잡는 데 매우 효과적입니다. toHaveBeenCalledWith는 전달된 인자가 올바른지 확인하여 Subject가 자신의 참조를 제대로 전달하는지 검증합니다.

여러분이 이 패턴을 사용하면 테스트 실행 시간이 크게 단축되고, 외부 서비스의 장애나 네트워크 문제에 영향받지 않으며, CI/CD 파이프라인에서 안정적으로 실행될 수 있습니다. 또한 Mock의 동작을 커스터마이징하여 에러 상황이나 특수한 케이스도 쉽게 시뮬레이션할 수 있습니다.

실전 팁

💡 beforeEach를 사용하여 각 테스트의 초기 상태를 일관되게 유지하세요. 테스트 간 상태 공유는 디버깅을 어렵게 만드는 주요 원인입니다. 💡 Mock 함수의 mockReturnValue나 mockResolvedValue를 사용하면 Observer의 반환값이나 비동기 동작을 시뮬레이션할 수 있습니다. 이는 더 복잡한 시나리오를 테스트할 때 유용합니다. 💡 not.toHaveBeenCalled()로 네거티브 테스트를 반드시 작성하세요. "호출되지 않아야 한다"는 것도 중요한 요구사항이며, 이를 검증하지 않으면 불필요한 알림이 발생하는 버그를 놓칠 수 있습니다. 💡 updateSpy.mock.calls를 사용하면 모든 호출의 인자를 배열로 접근할 수 있습니다. 호출 순서나 누적된 인자를 검증할 때 유용합니다. 💡 테스트 이름은 "무엇을 테스트하는지"보다 "어떤 동작이 일어나야 하는지"를 명확히 표현하세요. "Observer를 테스트한다" 대신 "등록되지 않은 Observer는 알림을 받지 않는다"처럼 구체적으로 작성하면 테스트가 실패했을 때 원인을 빠르게 파악할 수 있습니다.


3. 다중 Observer 시나리오 테스트

시작하며

여러분이 실무에서 Observer Pattern을 사용할 때 이런 상황을 마주한 적 있나요? "Subject에 10개의 Observer가 등록되어 있는데, 모두 정확한 순서로 알림을 받을까?" "중간에 하나가 실패하면 나머지는 어떻게 될까?" 실제 프로덕션 환경에서는 하나의 Subject에 수십, 수백 개의 Observer가 등록되는 경우가 흔합니다.

예를 들어, 전자상거래 시스템에서 주문 상태가 변경되면 재고 관리, 결제 처리, 이메일 발송, 로깅, 분석, 푸시 알림 등 여러 Observer가 동시에 반응해야 합니다. 이런 복잡한 상황에서 하나라도 빠지면 시스템의 일관성이 깨집니다.

바로 이럴 때 필요한 것이 다중 Observer 시나리오 테스트입니다. 여러 Observer가 동시에 등록되어 있을 때 알림이 모두에게 정확히 전달되는지, 순서가 보장되는지, 그리고 한 Observer의 실패가 다른 Observer에게 영향을 주지 않는지 검증해야 합니다.

개요

간단히 말해서, 다중 Observer 테스트는 하나의 Subject에 여러 Observer가 등록된 복잡한 상황을 시뮬레이션하고 검증하는 것입니다. 각 Observer가 독립적으로 작동하면서도 모두 알림을 받는지 확인합니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, 대부분의 실제 시스템은 단일 Observer만 사용하지 않기 때문입니다. 예를 들어, 사용자 프로필이 업데이트되면 캐시 무효화, UI 갱신, 감사 로그 기록, 검색 인덱스 업데이트 등 여러 작업이 동시에 일어나야 합니다.

하나라도 누락되면 데이터 불일치가 발생합니다. 기존에는 수동으로 여러 Observer를 하나씩 확인했다면, 이제는 자동화된 테스트로 모든 Observer가 정확히 알림을 받았는지 한 번에 검증할 수 있습니다.

이 테스트의 핵심 특징은 첫째, 확장성 검증 - Observer 수가 증가해도 시스템이 안정적인지 확인하고, 둘째, 독립성 보장 - 한 Observer의 동작이 다른 Observer에게 영향을 주지 않는지 검증하며, 셋째, 완전성 확인 - 모든 Observer가 빠짐없이 알림을 받는지 보장한다는 점입니다.

코드 예제

describe('다중 Observer 시나리오 테스트', () => {
  it('10개의 Observer가 모두 알림을 받는다', () => {
    const subject = new ConcreteSubject();
    const observers: Observer[] = [];
    const spies: jest.Mock[] = [];

    // 10개의 Mock Observer 생성 및 등록
    for (let i = 0; i < 10; i++) {
      const spy = jest.fn();
      spies.push(spy);
      observers.push({ update: spy });
      subject.attach(observers[i]);
    }

    // 상태 변경
    subject.setState(100);

    // 모든 Observer가 정확히 1번씩 호출되었는지 검증
    spies.forEach((spy, index) => {
      expect(spy).toHaveBeenCalledTimes(1);
      expect(spy).toHaveBeenCalledWith(subject);
    });
  });

  it('Observer를 동적으로 추가하고 제거해도 정확히 작동한다', () => {
    const subject = new ConcreteSubject();
    const spy1 = jest.fn();
    const spy2 = jest.fn();
    const spy3 = jest.fn();

    const observer1: Observer = { update: spy1 };
    const observer2: Observer = { update: spy2 };
    const observer3: Observer = { update: spy3 };

    // 첫 번째 알림: observer1, observer2만 등록
    subject.attach(observer1);
    subject.attach(observer2);
    subject.setState(10);

    expect(spy1).toHaveBeenCalledTimes(1);
    expect(spy2).toHaveBeenCalledTimes(1);
    expect(spy3).not.toHaveBeenCalled();

    // observer2 제거, observer3 추가
    subject.detach(observer2);
    subject.attach(observer3);
    subject.setState(20);

    // 누적 호출 횟수 검증
    expect(spy1).toHaveBeenCalledTimes(2); // 2번 호출
    expect(spy2).toHaveBeenCalledTimes(1); // 여전히 1번 (제거됨)
    expect(spy3).toHaveBeenCalledTimes(1); // 1번 호출
  });

  it('같은 Observer를 중복 등록하면 중복 알림을 받는다', () => {
    const subject = new ConcreteSubject();
    const spy = jest.fn();
    const observer: Observer = { update: spy };

    // 같은 Observer를 2번 등록
    subject.attach(observer);
    subject.attach(observer);

    subject.setState(50);

    // 2번 호출되어야 함 (중복 등록 허용)
    expect(spy).toHaveBeenCalledTimes(2);
  });
});

설명

이것이 하는 일: 복잡한 실무 환경을 시뮬레이션하여 다수의 Observer가 동시에 관리될 때 Subject가 안정적으로 작동하는지 검증합니다. 확장성과 견고성을 확인하는 것이 목적입니다.

첫 번째로, for 루프를 사용하여 10개의 Mock Observer를 생성하고 모두 Subject에 등록합니다. 배열에 spy와 observer를 저장하여 나중에 검증할 수 있도록 합니다.

이는 실제 시스템에서 여러 모듈이 동일한 Subject를 구독하는 상황을 재현한 것입니다. forEach를 사용하여 모든 spy가 정확히 1번씩 호출되었는지 검증하면, 어떤 Observer도 누락되지 않았음을 확인할 수 있습니다.

그 다음으로, 동적 추가/제거 시나리오를 테스트합니다. 실무에서는 런타임에 Observer가 등록되고 해제되는 경우가 많습니다.

예를 들어, 사용자가 화면을 떠나면 UI Observer를 해제하고, 새로운 기능이 활성화되면 새로운 Observer를 추가합니다. 이 테스트는 subject.setState를 여러 번 호출하면서 누적 호출 횟수를 검증하여, detach가 제대로 작동하는지 확인합니다.

세 번째 테스트는 의도적으로 중복 등록 시나리오를 다룹니다. 일부 구현에서는 같은 Observer를 여러 번 등록하는 것을 허용하고, 이 경우 중복 알림을 받게 됩니다.

이런 동작이 의도된 것인지 버그인지는 요구사항에 따라 다르므로, 명시적으로 테스트하여 동작을 문서화하는 것이 중요합니다. 만약 중복 등록을 방지하고 싶다면 attach 메서드에서 이미 등록된 Observer인지 확인하는 로직을 추가해야 합니다.

여러분이 이 테스트를 사용하면 시스템이 복잡해져도 안정성을 유지할 수 있고, Observer 수가 증가해도 성능 저하나 버그가 발생하지 않는다는 확신을 얻을 수 있습니다. 또한 새로운 Observer를 추가할 때마다 기존 Observer들이 여전히 정상 작동하는지 자동으로 검증할 수 있습니다.

실전 팁

💡 대량의 Observer를 테스트할 때는 성능도 함께 측정하세요. 1000개 이상의 Observer를 등록했을 때 알림 시간이 합리적인지 확인하면 확장성 문제를 미리 발견할 수 있습니다. 💡 중복 등록을 방지하려면 Set 자료구조를 사용하거나 attach 메서드에서 indexOf로 이미 존재하는지 확인하세요. 요구사항에 따라 중복을 허용할지 말지 명확히 결정하고 테스트에 반영해야 합니다. 💡 동적 추가/제거 테스트에서는 누적 호출 횟수를 신중하게 검증하세요. spy.mock.calls.length로 직접 확인하거나, 매 단계마다 toHaveBeenCalledTimes로 정확한 횟수를 명시하는 것이 혼란을 방지합니다. 💡 forEach 대신 every나 some 같은 배열 메서드를 사용하면 더 간결한 검증 로직을 작성할 수 있습니다. 예를 들어, spies.every(spy => spy.mock.calls.length === 1)로 모든 spy가 1번씩 호출되었는지 한 줄로 확인할 수 있습니다. 💡 실무에서는 Observer 타입별로 우선순위를 부여하는 경우가 있습니다. 이 경우 알림 순서를 테스트해야 하므로, spy.mock.invocationCallOrder를 사용하여 호출 순서를 검증하세요.


4. 비동기 이벤트 알림 테스트

시작하며

여러분이 Observer Pattern을 구현하면서 이런 난관에 부딪힌 적 있나요? "Observer가 API 호출이나 파일 I/O 같은 비동기 작업을 수행할 때 어떻게 테스트하지?" "모든 비동기 작업이 완료되었는지 어떻게 확인하지?" 실무에서 Observer는 종종 비동기 작업을 수행합니다.

데이터베이스에 저장하거나, 외부 API를 호출하거나, 파일을 읽고 쓰는 등의 작업은 모두 시간이 걸립니다. 이런 비동기 Observer를 제대로 테스트하지 않으면, 테스트는 통과했지만 실제로는 작업이 완료되지 않은 상황이 발생할 수 있습니다.

바로 이럴 때 필요한 것이 비동기 이벤트 알림 테스트입니다. async/await를 활용하여 모든 비동기 작업이 완료될 때까지 기다리고, 그 결과를 정확히 검증하는 것이 핵심입니다.

개요

간단히 말해서, 비동기 이벤트 알림 테스트는 Observer의 update 메서드가 Promise를 반환할 때, 모든 비동기 작업이 완료되고 올바른 결과를 반환하는지 검증하는 것입니다. Jest의 비동기 테스팅 기능을 활용합니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, 동기 테스트로는 비동기 작업의 완료 여부를 확인할 수 없기 때문입니다. 예를 들어, 주문 생성 시 이메일을 발송하는 Observer가 있다면, 이메일 발송 API 호출이 실제로 완료되었는지, 에러는 없었는지 확인해야 합니다.

테스트가 비동기 작업을 기다리지 않으면 가짜 통과(false positive)가 발생합니다. 기존에는 setTimeout이나 done 콜백을 사용하여 복잡하게 처리했다면, 이제는 async/await와 Promise를 사용하여 훨씬 직관적이고 간결하게 작성할 수 있습니다.

이 테스트의 핵심 특징은 첫째, 비동기 작업의 완료를 명시적으로 기다리고, 둘째, Promise의 resolve/reject를 정확히 검증하며, 셋째, 타임아웃과 에러 처리를 포함한다는 점입니다.

코드 예제

// 비동기 Observer 인터페이스
interface AsyncObserver {
  update(subject: Subject): Promise<void>;
}

// 비동기 Subject 구현
class AsyncSubject implements Subject {
  private observers: AsyncObserver[] = [];
  private state: number = 0;

  attach(observer: Observer): void {
    this.observers.push(observer as AsyncObserver);
  }

  detach(observer: Observer): void {
    const index = this.observers.indexOf(observer as AsyncObserver);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  // 모든 Observer의 비동기 작업을 기다림
  async notify(): Promise<void> {
    const promises = this.observers.map(observer => observer.update(this));
    await Promise.all(promises);
  }

  async setState(state: number): Promise<void> {
    this.state = state;
    await this.notify();
  }

  getState(): number {
    return this.state;
  }
}

// 비동기 테스트
describe('비동기 이벤트 알림 테스트', () => {
  it('비동기 Observer의 작업이 완료될 때까지 기다린다', async () => {
    const subject = new AsyncSubject();
    const mockUpdate = jest.fn().mockResolvedValue(undefined);

    const observer: AsyncObserver = {
      update: mockUpdate
    };

    subject.attach(observer);
    await subject.setState(10);

    // 비동기 작업 완료 후 검증
    expect(mockUpdate).toHaveBeenCalledTimes(1);
  });

  it('여러 비동기 Observer가 병렬로 실행된다', async () => {
    const subject = new AsyncSubject();
    const results: number[] = [];

    // 비동기 작업을 시뮬레이션하는 Observer
    const observer1: AsyncObserver = {
      update: async () => {
        await new Promise(resolve => setTimeout(resolve, 100));
        results.push(1);
      }
    };

    const observer2: AsyncObserver = {
      update: async () => {
        await new Promise(resolve => setTimeout(resolve, 50));
        results.push(2);
      }
    };

    subject.attach(observer1);
    subject.attach(observer2);

    const startTime = Date.now();
    await subject.setState(20);
    const duration = Date.now() - startTime;

    // 병렬 실행이므로 100ms 정도 소요 (150ms가 아님)
    expect(duration).toBeLessThan(150);
    expect(results).toHaveLength(2);
    expect(results).toContain(1);
    expect(results).toContain(2);
  });

  it('Observer에서 에러가 발생하면 예외를 던진다', async () => {
    const subject = new AsyncSubject();
    const error = new Error('Observer 에러');

    const observer: AsyncObserver = {
      update: jest.fn().mockRejectedValue(error)
    };

    subject.attach(observer);

    // 에러 발생을 검증
    await expect(subject.setState(30)).rejects.toThrow('Observer 에러');
  });
});

설명

이것이 하는 일: Observer가 비동기 작업을 수행할 때 Subject가 모든 작업이 완료될 때까지 기다리고, 에러를 적절히 처리하는지 검증합니다. 실무에서 흔히 발생하는 비동기 패턴을 정확히 테스트하는 것이 목적입니다.

첫 번째로, AsyncObserver 인터페이스를 정의하여 update 메서드가 Promise<void>를 반환하도록 명시합니다. AsyncSubject의 notify 메서드는 모든 Observer의 update를 호출하고, Promise.all로 모든 작업이 완료될 때까지 기다립니다.

이렇게 하면 Subject가 상태를 변경한 후 모든 Observer의 처리가 끝날 때까지 제어권을 반환하지 않습니다. 그 다음으로, mockResolvedValue를 사용하여 비동기 Mock 함수를 만듭니다.

이는 실제 비동기 작업 없이도 Promise를 반환하는 동작을 시뮬레이션할 수 있게 해줍니다. 테스트 함수를 async로 선언하고 await subject.setState()를 사용하여 모든 비동기 작업이 완료될 때까지 기다린 후에 검증을 수행합니다.

병렬 실행 테스트에서는 실제로 setTimeout을 사용하여 비동기 작업을 시뮬레이션합니다. observer1은 100ms, observer2는 50ms가 걸리지만, Promise.all이 병렬로 실행하므로 전체 시간은 약 100ms만 소요됩니다.

Date.now()로 실제 소요 시간을 측정하여 병렬 실행을 검증하는 것이 핵심입니다. 만약 순차 실행이었다면 150ms가 걸렸을 것입니다.

에러 처리 테스트는 매우 중요합니다. mockRejectedValue를 사용하여 Observer가 에러를 던지는 상황을 시뮬레이션하고, expect().rejects.toThrow()로 Subject가 이 에러를 적절히 전파하는지 검증합니다.

실무에서는 한 Observer의 에러가 다른 Observer에게 영향을 주지 않도록 에러를 격리하는 전략도 필요할 수 있습니다. 여러분이 이 테스트를 사용하면 비동기 작업의 완료를 확신할 수 있고, 타이밍 이슈나 경쟁 조건(race condition)을 조기에 발견할 수 있으며, 프로덕션 환경에서 발생할 수 있는 비동기 버그를 미리 차단할 수 있습니다.

실전 팁

💡 Jest의 기본 타임아웃은 5초입니다. 긴 비동기 작업을 테스트할 때는 jest.setTimeout()으로 타임아웃을 늘리세요. 하지만 테스트가 너무 오래 걸리면 Mock을 사용하는 것을 고려해야 합니다. 💡 Promise.all 대신 Promise.allSettled를 사용하면 한 Observer의 에러가 다른 Observer를 중단시키지 않습니다. 요구사항에 따라 적절한 전략을 선택하세요. 💡 fake timer(jest.useFakeTimers())를 사용하면 setTimeout을 즉시 실행하여 테스트 속도를 높일 수 있습니다. 단, 실제 비동기 동작과 차이가 있을 수 있으므로 주의하세요. 💡 비동기 테스트에서는 반드시 await를 빼먹지 마세요. await 없이 Promise를 반환하면 테스트가 비동기 작업을 기다리지 않고 통과할 수 있습니다. ESLint의 @typescript-eslint/no-floating-promises 규칙을 활성화하면 이런 실수를 방지할 수 있습니다. 💡 실제 네트워크 호출이나 데이터베이스 쿼리는 절대 테스트에 포함하지 마세요. 대신 axios나 fetch를 Mock하거나, 테스트용 In-memory 데이터베이스를 사용하세요. 외부 의존성은 테스트를 느리고 불안정하게 만듭니다.


5. Observer 등록 해제 테스트

시작하며

여러분이 장기 실행되는 애플리케이션을 개발할 때 이런 문제를 겪어본 적 있나요? "메모리 사용량이 계속 증가하는데 어디서 누수가 발생하는지 모르겠어" "Observer를 해제했는데도 여전히 알림을 받고 있어" 실무에서 메모리 누수는 가장 찾기 어려운 버그 중 하나입니다.

특히 Observer Pattern에서는 Subject가 Observer에 대한 참조를 계속 유지하기 때문에, 명시적으로 해제하지 않으면 가비지 컬렉션이 되지 않아 메모리가 계속 쌓입니다. 장기 실행되는 서버나 SPA(Single Page Application)에서는 치명적입니다.

바로 이럴 때 필요한 것이 Observer 등록 해제 테스트입니다. detach 메서드가 정확히 작동하는지, 해제 후에는 알림을 받지 않는지, 그리고 메모리가 제대로 해제되는지 검증해야 합니다.

개요

간단히 말해서, Observer 등록 해제 테스트는 Subject에서 Observer를 제거한 후 더 이상 알림을 받지 않는지 확인하는 것입니다. 이는 메모리 누수를 방지하고 시스템 리소스를 효율적으로 관리하는 데 필수적입니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, 사용자가 특정 화면을 떠나거나 기능을 비활성화할 때 관련 Observer를 해제해야 하기 때문입니다. 예를 들어, React 컴포넌트가 unmount될 때 useEffect의 cleanup 함수에서 Observer를 해제하지 않으면, 컴포넌트는 사라졌지만 Subject는 여전히 해당 Observer를 참조하여 메모리 누수가 발생합니다.

기존에는 메모리 프로파일러로 수동으로 메모리 사용량을 추적했다면, 이제는 자동화된 테스트로 detach 동작을 검증하고 메모리 누수를 예방할 수 있습니다. 이 테스트의 핵심 특징은 첫째, 해제 후 알림 차단을 검증하고, 둘째, 존재하지 않는 Observer 해제 시 에러가 발생하지 않는지 확인하며, 셋째, 다중 등록/해제 시나리오를 커버한다는 점입니다.

코드 예제

describe('Observer 등록 해제 테스트', () => {
  it('해제된 Observer는 더 이상 알림을 받지 않는다', () => {
    const subject = new ConcreteSubject();
    const spy = jest.fn();
    const observer: Observer = { update: spy };

    // 등록 및 첫 번째 알림
    subject.attach(observer);
    subject.setState(10);
    expect(spy).toHaveBeenCalledTimes(1);

    // 해제 후 두 번째 알림
    subject.detach(observer);
    subject.setState(20);

    // 여전히 1번만 호출됨 (해제 후 호출 안 됨)
    expect(spy).toHaveBeenCalledTimes(1);
  });

  it('등록되지 않은 Observer를 해제해도 에러가 발생하지 않는다', () => {
    const subject = new ConcreteSubject();
    const observer: Observer = { update: jest.fn() };

    // 에러 없이 실행되어야 함
    expect(() => {
      subject.detach(observer);
    }).not.toThrow();
  });

  it('여러 Observer 중 하나만 해제할 수 있다', () => {
    const subject = new ConcreteSubject();
    const spy1 = jest.fn();
    const spy2 = jest.fn();
    const spy3 = jest.fn();

    const observer1: Observer = { update: spy1 };
    const observer2: Observer = { update: spy2 };
    const observer3: Observer = { update: spy3 };

    subject.attach(observer1);
    subject.attach(observer2);
    subject.attach(observer3);

    // observer2만 해제
    subject.detach(observer2);
    subject.setState(30);

    // observer1과 observer3만 호출됨
    expect(spy1).toHaveBeenCalledTimes(1);
    expect(spy2).not.toHaveBeenCalled();
    expect(spy3).toHaveBeenCalledTimes(1);
  });

  it('중복 등록된 Observer를 한 번 해제하면 하나만 제거된다', () => {
    const subject = new ConcreteSubject();
    const spy = jest.fn();
    const observer: Observer = { update: spy };

    // 같은 Observer를 2번 등록
    subject.attach(observer);
    subject.attach(observer);

    // 한 번 해제
    subject.detach(observer);
    subject.setState(40);

    // 여전히 1번 호출됨 (하나는 남아있음)
    expect(spy).toHaveBeenCalledTimes(1);

    // 다시 해제
    subject.detach(observer);
    subject.setState(50);

    // 더 이상 호출 안 됨
    expect(spy).toHaveBeenCalledTimes(1);
  });

  it('모든 Observer를 해제하면 알림이 발생하지 않는다', () => {
    const subject = new ConcreteSubject();
    const observers: Observer[] = [];
    const spies: jest.Mock[] = [];

    for (let i = 0; i < 5; i++) {
      const spy = jest.fn();
      spies.push(spy);
      observers.push({ update: spy });
      subject.attach(observers[i]);
    }

    // 모두 해제
    observers.forEach(observer => subject.detach(observer));
    subject.setState(60);

    // 아무도 호출되지 않음
    spies.forEach(spy => {
      expect(spy).not.toHaveBeenCalled();
    });
  });
});

설명

이것이 하는 일: Observer를 Subject에서 제거하는 detach 메서드가 정확히 작동하는지 검증합니다. 해제 후 더 이상 알림을 받지 않는지 확인하여 메모리 누수와 불필요한 연산을 방지하는 것이 목적입니다.

첫 번째로, 기본 해제 시나리오를 테스트합니다. Observer를 등록하고 첫 번째 알림을 받은 후, detach로 해제하고 다시 알림을 보냅니다.

이때 spy의 호출 횟수가 여전히 1인지 확인하여 해제 후 알림이 차단되었음을 검증합니다. 이는 가장 기본적이면서도 중요한 테스트로, detach의 핵심 기능을 확인합니다.

그 다음으로, 에러 처리를 테스트합니다. 존재하지 않는 Observer를 해제하려고 할 때 에러가 발생하지 않는지 확인합니다.

이는 방어적 프로그래밍의 일환으로, detach 메서드에서 indexOf로 인덱스를 찾고 -1이 아닐 때만 제거하는 로직이 제대로 작동하는지 검증합니다. 실무에서는 실수로 같은 Observer를 여러 번 해제하려는 경우가 있으므로 이런 예외 상황을 안전하게 처리해야 합니다.

부분 해제 테스트는 여러 Observer가 등록된 상황에서 특정 Observer만 선택적으로 해제할 수 있는지 확인합니다. observer2만 detach했을 때 spy2는 호출되지 않고 spy1과 spy3는 정상적으로 호출되는지 검증합니다.

이는 Observer 간의 독립성을 보장하고, 한 Observer의 해제가 다른 Observer에게 영향을 주지 않음을 확인합니다. 중복 등록 해제 테스트는 흥미로운 시나리오입니다.

Array.splice는 첫 번째로 발견된 요소만 제거하므로, 중복 등록된 Observer를 한 번 detach하면 하나만 제거됩니다. 이런 동작을 명확히 테스트하여 예상치 못한 버그를 방지합니다.

만약 모든 중복을 한 번에 제거하고 싶다면 filter를 사용하는 등 다른 구현이 필요합니다. 여러분이 이 테스트를 사용하면 메모리 누수를 조기에 발견할 수 있고, 리팩토링 시 detach 로직이 깨지지 않았음을 확인할 수 있으며, 프로덕션 환경에서 장기 실행 시에도 안정적인 메모리 관리를 보장할 수 있습니다.

실전 팁

💡 React나 Vue 같은 프레임워크를 사용할 때는 컴포넌트의 cleanup 함수에서 반드시 Observer를 해제하세요. useEffect의 return 문이나 onBeforeUnmount 훅을 활용하여 메모리 누수를 방지할 수 있습니다. 💡 WeakMap이나 WeakSet을 사용하면 명시적으로 detach를 호출하지 않아도 Observer가 더 이상 참조되지 않을 때 자동으로 가비지 컬렉션됩니다. 하지만 이는 명시적 관리보다 디버깅이 어려울 수 있으므로 신중하게 사용하세요. 💡 중복 등록을 원천적으로 방지하려면 Set 자료구조를 사용하거나, attach 메서드에서 이미 등록된 Observer인지 확인하세요. 이렇게 하면 중복 해제 문제도 함께 해결됩니다. 💡 크롬 개발자 도구의 Memory 프로파일러로 실제 메모리 누수를 확인할 수 있습니다. 여러 번 등록/해제를 반복한 후 힙 스냅샷을 비교하여 Observer 객체가 제대로 해제되는지 검증하세요. 💡 대규모 애플리케이션에서는 Observer 등록 시 고유 ID를 부여하고, Map으로 관리하는 것이 더 효율적입니다. 이렇게 하면 O(1) 시간 복잡도로 해제할 수 있고, 중복 등록도 쉽게 방지할 수 있습니다.


6. 이벤트 순서 보장 테스트

시작하며

여러분이 복잡한 비즈니스 로직을 구현할 때 이런 문제에 직면한 적 있나요? "데이터베이스에 저장하기 전에 유효성 검사를 해야 하는데, 순서가 뒤바뀌면 어떡하지?" "로깅은 항상 마지막에 해야 하는데 보장할 수 있을까?" 실무에서 Observer Pattern을 사용할 때 알림 순서가 중요한 경우가 많습니다.

예를 들어, 결제 처리 시스템에서는 "유효성 검증 → 재고 차감 → 결제 처리 → 영수증 발행 → 로깅" 순서가 보장되어야 합니다. 순서가 뒤바뀌면 재고는 차감되었는데 결제가 실패하거나, 로그에는 성공으로 기록되었는데 실제로는 실패하는 등의 데이터 불일치가 발생합니다.

바로 이럴 때 필요한 것이 이벤트 순서 보장 테스트입니다. Observer가 등록된 순서대로 알림을 받는지, 그리고 이 순서가 일관되게 유지되는지 검증해야 합니다.

개요

간단히 말해서, 이벤트 순서 보장 테스트는 Subject가 Observer에게 알림을 보낼 때 등록 순서를 지키는지 확인하는 것입니다. 특히 상태 의존적인 작업이나 트랜잭션 처리에서 필수적입니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, 비즈니스 로직에서 작업 순서가 결과에 영향을 미치는 경우가 많기 때문입니다. 예를 들어, 사용자 등록 시 "데이터베이스 저장 → 환영 이메일 발송 → 분석 이벤트 전송" 순서가 중요합니다.

데이터베이스 저장 전에 이메일을 보내면 사용자 정보가 없어 에러가 발생할 수 있습니다. 기존에는 순서를 보장하기 위해 복잡한 의존성 관리나 우선순위 시스템을 구축했다면, 이제는 간단한 배열 기반 구현과 명확한 테스트로 순서를 보장할 수 있습니다.

이 테스트의 핵심 특징은 첫째, 등록 순서와 호출 순서의 일치를 검증하고, 둘째, 여러 알림에 걸쳐 순서 일관성을 확인하며, 셋째, 순서 의존적인 비즈니스 로직의 정확성을 보장한다는 점입니다.

코드 예제

describe('이벤트 순서 보장 테스트', () => {
  it('Observer는 등록된 순서대로 알림을 받는다', () => {
    const subject = new ConcreteSubject();
    const callOrder: number[] = [];

    const observer1: Observer = {
      update: () => callOrder.push(1)
    };

    const observer2: Observer = {
      update: () => callOrder.push(2)
    };

    const observer3: Observer = {
      update: () => callOrder.push(3)
    };

    // 순서대로 등록
    subject.attach(observer1);
    subject.attach(observer2);
    subject.attach(observer3);

    subject.setState(10);

    // 등록 순서와 호출 순서가 일치하는지 검증
    expect(callOrder).toEqual([1, 2, 3]);
  });

  it('여러 번 알림을 보내도 순서가 유지된다', () => {
    const subject = new ConcreteSubject();
    const callOrder: number[] = [];

    const observer1: Observer = {
      update: () => callOrder.push(1)
    };

    const observer2: Observer = {
      update: () => callOrder.push(2)
    };

    subject.attach(observer1);
    subject.attach(observer2);

    // 첫 번째 알림
    subject.setState(10);
    expect(callOrder).toEqual([1, 2]);

    // 두 번째 알림
    subject.setState(20);
    expect(callOrder).toEqual([1, 2, 1, 2]);

    // 세 번째 알림
    subject.setState(30);
    expect(callOrder).toEqual([1, 2, 1, 2, 1, 2]);
  });

  it('Jest의 mock.invocationCallOrder로 호출 순서를 검증할 수 있다', () => {
    const subject = new ConcreteSubject();
    const spy1 = jest.fn();
    const spy2 = jest.fn();
    const spy3 = jest.fn();

    subject.attach({ update: spy1 });
    subject.attach({ update: spy2 });
    subject.attach({ update: spy3 });

    subject.setState(40);

    // invocationCallOrder는 호출 순서를 나타내는 숫자
    const order1 = spy1.mock.invocationCallOrder[0];
    const order2 = spy2.mock.invocationCallOrder[0];
    const order3 = spy3.mock.invocationCallOrder[0];

    expect(order1).toBeLessThan(order2);
    expect(order2).toBeLessThan(order3);
  });

  it('중간에 Observer를 추가하면 마지막에 호출된다', () => {
    const subject = new ConcreteSubject();
    const callOrder: number[] = [];

    const observer1: Observer = {
      update: () => callOrder.push(1)
    };

    const observer2: Observer = {
      update: () => callOrder.push(2)
    };

    const observer3: Observer = {
      update: () => callOrder.push(3)
    };

    subject.attach(observer1);
    subject.attach(observer2);

    subject.setState(10);
    expect(callOrder).toEqual([1, 2]);

    // 중간에 observer3 추가
    callOrder.length = 0; // 배열 초기화
    subject.attach(observer3);

    subject.setState(20);
    // observer3이 마지막에 호출됨
    expect(callOrder).toEqual([1, 2, 3]);
  });
});

설명

이것이 하는 일: Subject가 Observer 목록을 배열로 관리할 때, 알림이 배열 순서대로 전달되는지 검증합니다. 순서 의존적인 비즈니스 로직이 정확히 작동하도록 보장하는 것이 목적입니다.

첫 번째로, 가장 직관적인 방법인 callOrder 배열을 사용합니다. 각 Observer의 update 메서드에서 고유한 숫자를 배열에 추가하고, 최종 배열이 예상한 순서와 일치하는지 toEqual로 검증합니다.

이 방법은 구현이 간단하고 직관적이며, 순서를 시각적으로 확인할 수 있다는 장점이 있습니다. [1, 2, 3]이라는 결과를 보면 즉시 순서가 올바름을 알 수 있습니다.

그 다음으로, 여러 번 알림을 보내도 순서가 일관되게 유지되는지 테스트합니다. callOrder 배열이 [1, 2, 1, 2, 1, 2]처럼 반복 패턴을 보이면, 매번 같은 순서로 호출되었음을 증명합니다.

이는 Subject의 내부 상태가 변경되어도 Observer 목록의 순서가 유지됨을 보장합니다. Jest의 invocationCallOrder를 사용하는 방법은 더 강력합니다.

이 속성은 전역적으로 증가하는 호출 카운터로, 모든 Mock 함수 호출에 대해 고유한 순서 번호를 부여합니다. spy1, spy2, spy3의 invocationCallOrder를 비교하여 어떤 함수가 먼저 호출되었는지 명확히 알 수 있습니다.

이 방법은 callOrder 배열을 수동으로 관리할 필요가 없고, Jest가 자동으로 추적해준다는 장점이 있습니다. 중간 추가 테스트는 동적 시나리오를 다룹니다.

처음에 observer1과 observer2를 등록하고, 나중에 observer3를 추가하면 observer3은 마지막에 호출됩니다. 이는 배열의 push 메커니즘이 순서를 보장함을 확인합니다.

callOrder.length = 0으로 배열을 초기화하는 것은 두 번째 알림의 결과만 깔끔하게 검증하기 위한 테크닉입니다. 여러분이 이 테스트를 사용하면 순서 의존적인 버그를 조기에 발견할 수 있고, 리팩토링 시 순서가 깨지지 않았음을 확인할 수 있으며, 복잡한 비즈니스 로직에서 작업 순서를 문서화하고 보장할 수 있습니다.

실전 팁

💡 순서가 중요한 경우 Observer 인터페이스에 priority 속성을 추가하고, notify 메서드에서 정렬하는 방법도 있습니다. 하지만 이는 복잡도를 높이므로 정말 필요한 경우에만 사용하세요. 💡 callOrder 배열 대신 Date.now()나 performance.now()를 사용하여 실제 호출 시간을 기록할 수도 있습니다. 이는 비동기 작업에서 특히 유용합니다. 💡 순서를 보장하고 싶지 않은 경우(예: 독립적인 Observer들)에는 명시적으로 "순서를 보장하지 않는다"는 테스트를 작성하세요. 이렇게 하면 나중에 다른 개발자가 순서에 의존하는 코드를 작성하는 것을 방지할 수 있습니다. 💡 이벤트 소싱(Event Sourcing) 패턴을 사용한다면 순서가 매우 중요합니다. 이벤트 로그에 순서 번호나 타임스탬프를 함께 저장하여 재생 시에도 정확한 순서를 보장하세요. 💡 Observer의 실행 순서가 결과에 영향을 준다면, 이는 Observer 간에 의존성이 있다는 신호입니다. 가능하면 Observer를 독립적으로 만들고, 불가피한 경우 명시적으로 순서를 관리하세요.


7. 에러 처리 및 복구 테스트

시작하며

여러분이 프로덕션 환경에서 이런 끔찍한 상황을 경험한 적 있나요? "한 Observer에서 에러가 발생했는데 나머지 Observer들도 모두 알림을 받지 못했어" "에러 로그도 없어서 원인을 찾을 수가 없어" 실무에서 Observer는 외부 API 호출, 데이터베이스 접근, 파일 I/O 등 실패할 수 있는 작업을 수행합니다.

한 Observer의 에러가 전체 알림 체인을 중단시키면, 중요한 Observer들이 알림을 받지 못해 시스템이 불일치 상태에 빠집니다. 예를 들어, 결제 완료 후 이메일 발송 Observer에서 에러가 발생했는데 로깅 Observer까지 실행되지 않으면, 결제는 성공했지만 기록이 없는 상황이 발생합니다.

바로 이럴 때 필요한 것이 에러 처리 및 복구 테스트입니다. 한 Observer의 에러가 다른 Observer에게 영향을 주지 않도록 격리하고, 에러를 적절히 로깅하며, 필요하다면 재시도 메커니즘을 구현하는 것이 중요합니다.

개요

간단히 말해서, 에러 처리 및 복구 테스트는 Observer에서 예외가 발생했을 때 Subject가 어떻게 대응하는지 검증하는 것입니다. 에러를 적절히 처리하고, 다른 Observer의 실행을 보장하며, 에러 정보를 수집하는 것이 핵심입니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, 분산 시스템과 마이크로서비스 환경에서는 부분적인 실패가 일상적이기 때문입니다. 예를 들어, 외부 이메일 서비스가 일시적으로 다운되었을 때 이메일 발송 Observer는 실패하지만, 로깅과 데이터베이스 저장은 반드시 성공해야 합니다.

이런 부분 실패를 우아하게 처리하는 것이 견고한 시스템의 핵심입니다. 기존에는 try-catch를 남발하여 코드가 복잡해지고 에러가 은폐되는 문제가 있었다면, 이제는 체계적인 에러 처리 전략과 테스트로 투명하고 예측 가능한 에러 관리를 할 수 있습니다.

이 테스트의 핵심 특징은 첫째, 에러 격리 - 한 Observer의 실패가 다른 Observer에게 전파되지 않고, 둘째, 에러 수집 - 모든 에러를 수집하여 로깅하거나 반환하며, 셋째, 재시도 메커니즘 - 일시적 실패에 대한 복구 전략을 제공한다는 점입니다.

코드 예제

// 에러를 수집하는 Subject 구현
class ResilientSubject implements Subject {
  private observers: Observer[] = [];
  private state: number = 0;
  private errors: Array<{ observer: Observer; error: Error }> = [];

  attach(observer: Observer): void {
    this.observers.push(observer);
  }

  detach(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  // 에러를 수집하며 모든 Observer를 호출
  notify(): void {
    this.errors = []; // 에러 배열 초기화

    for (const observer of this.observers) {
      try {
        observer.update(this);
      } catch (error) {
        // 에러를 수집하고 계속 진행
        this.errors.push({
          observer,
          error: error as Error
        });
      }
    }
  }

  setState(state: number): void {
    this.state = state;
    this.notify();
  }

  getState(): number {
    return this.state;
  }

  // 발생한 에러 목록 반환
  getErrors(): Array<{ observer: Observer; error: Error }> {
    return this.errors;
  }

  hasErrors(): boolean {
    return this.errors.length > 0;
  }
}

describe('에러 처리 및 복구 테스트', () => {
  it('한 Observer의 에러가 다른 Observer의 실행을 막지 않는다', () => {
    const subject = new ResilientSubject();
    const spy1 = jest.fn();
    const spy2 = jest.fn(() => {
      throw new Error('Observer 2 에러');
    });
    const spy3 = jest.fn();

    subject.attach({ update: spy1 });
    subject.attach({ update: spy2 });
    subject.attach({ update: spy3 });

    subject.setState(10);

    // 모든 Observer가 호출됨
    expect(spy1).toHaveBeenCalledTimes(1);
    expect(spy2).toHaveBeenCalledTimes(1);
    expect(spy3).toHaveBeenCalledTimes(1);

    // 에러가 수집됨
    expect(subject.hasErrors()).toBe(true);
    expect(subject.getErrors()).toHaveLength(1);
    expect(subject.getErrors()[0].error.message).toBe('Observer 2 에러');
  });

  it('여러 Observer에서 에러가 발생하면 모두 수집한다', () => {
    const subject = new ResilientSubject();

    subject.attach({
      update: () => { throw new Error('에러 1'); }
    });
    subject.attach({
      update: jest.fn() // 정상 실행
    });
    subject.attach({
      update: () => { throw new Error('에러 2'); }
    });

    subject.setState(20);

    expect(subject.getErrors()).toHaveLength(2);
    expect(subject.getErrors()[0].error.message).toBe('에러 1');
    expect(subject.getErrors()[1].error.message).toBe('에러 2');
  });

  it('에러가 없으면 빈 배열을 반환한다', () => {
    const subject = new ResilientSubject();

    subject.attach({ update: jest.fn() });
    subject.attach({ update: jest.fn() });

    subject.setState(30);

    expect(subject.hasErrors()).toBe(false);
    expect(subject.getErrors()).toHaveLength(0);
  });
});

설명

이것이 하는 일: Subject의 notify 메서드에서 각 Observer를 try-catch로 감싸서 에러가 발생해도 다른 Observer의 실행을 보장합니다. 발생한 모든 에러를 배열에 수집하여 나중에 분석하거나 로깅할 수 있도록 합니다.

첫 번째로, ResilientSubject 클래스는 errors 배열을 관리하여 알림 과정에서 발생한 모든 에러를 추적합니다. notify 메서드 시작 시 errors 배열을 초기화하고, 각 Observer의 update 메서드를 try-catch로 감쌉니다.

에러가 발생하면 catch 블록에서 observer와 error를 함께 저장하여 어떤 Observer에서 에러가 발생했는지 추적할 수 있습니다. 이렇게 하면 에러가 throw되어 나머지 Observer의 실행이 중단되는 것을 방지합니다.

그 다음으로, getErrors()와 hasErrors() 메서드를 제공하여 에러 정보에 쉽게 접근할 수 있게 합니다. 이는 Subject를 사용하는 코드에서 알림 후 에러를 확인하고 적절히 대응할 수 있게 해줍니다.

예를 들어, 에러가 있으면 로그를 남기거나, 특정 Observer를 재시도하거나, 관리자에게 알림을 보낼 수 있습니다. 테스트 코드에서는 spy2가 의도적으로 에러를 던지지만, spy1과 spy3는 정상적으로 호출됩니다.

toHaveBeenCalledTimes로 모든 spy가 호출되었음을 확인하고, subject.getErrors()로 에러가 정확히 수집되었는지 검증합니다. 이는 에러 격리가 제대로 작동함을 증명합니다.

여러 Observer에서 동시에 에러가 발생하는 시나리오도 테스트합니다. 두 개의 Observer가 에러를 던지면 getErrors() 배열에 두 개의 에러가 모두 포함되어야 합니다.

이는 notify 메서드가 첫 번째 에러에서 중단되지 않고 끝까지 실행됨을 보장합니다. 여러분이 이 패턴을 사용하면 부분적 실패를 우아하게 처리할 수 있고, 에러 발생 시에도 시스템의 나머지 부분은 정상 작동하며, 에러 정보를 중앙에서 관리하여 모니터링과 디버깅을 쉽게 할 수 있습니다.

실전 팁

💡 에러를 수집하는 것만으로는 부족합니다. 수집된 에러를 로깅 시스템(Sentry, LogRocket 등)에 전송하여 프로덕션 환경에서도 에러를 추적할 수 있도록 하세요. 💡 일시적 실패(transient failure)에 대응하려면 재시도 로직을 추가하세요. 예를 들어, 네트워크 타임아웃이 발생하면 exponential backoff로 3번 재시도하는 등의 전략을 구현할 수 있습니다. 💡 에러 타입에 따라 다르게 처리하세요. 복구 가능한 에러(네트워크 오류)는 재시도하고, 복구 불가능한 에러(잘못된 인자)는 즉시 실패 처리하여 리소스를 낭비하지 않도록 합니다. 💡 Circuit Breaker 패턴을 적용하면 지속적으로 실패하는 Observer를 일시적으로 비활성화하여 시스템 전체의 성능 저하를 방지할 수 있습니다. 💡 async/await를 사용하는 비동기 Observer의 경우 Promise.allSettled를 사용하면 모든 Promise의 성공/실패 결과를 수집할 수 있습니다. 이는 비동기 환경에서 에러 수집을 구현하는 표준 방법입니다.


8. 성능 테스트와 최적화 검증

시작하며

여러분이 시스템을 확장하면서 이런 성능 문제를 겪어본 적 있나요? "Observer가 100개를 넘어가니까 알림이 너무 느려져" "이벤트가 초당 1000번 발생하는데 시스템이 버티지 못해" 실무에서 Observer Pattern은 시작은 몇 개의 Observer로 간단하지만, 시스템이 성장하면서 수백, 수천 개의 Observer가 등록될 수 있습니다.

예를 들어, 대규모 실시간 대시보드 시스템에서는 수많은 UI 컴포넌트와 데이터 처리 모듈이 동일한 데이터 소스를 구독합니다. 이때 알림 메커니즘이 비효율적이면 시스템 전체가 느려지고, CPU 사용률이 치솟으며, 사용자 경험이 나빠집니다.

바로 이럴 때 필요한 것이 성능 테스트와 최적화 검증입니다. 대량의 Observer를 처리할 때 성능이 선형적으로 증가하는지, 불필요한 알림이 발생하지 않는지, 그리고 최적화가 실제로 효과가 있는지 측정해야 합니다.

개요

간단히 말해서, 성능 테스트는 Observer Pattern 구현이 실제 프로덕션 환경의 부하를 견딜 수 있는지 검증하는 것입니다. 실행 시간, 메모리 사용량, CPU 사용률 등을 측정하고 병목 지점을 찾아냅니다.

왜 이 테스트가 필요한지 실무 관점에서 설명하자면, 기능 테스트만으로는 확장성 문제를 발견할 수 없기 때문입니다. 예를 들어, Observer가 10개일 때는 문제없지만 1000개가 되면 알림이 10초 이상 걸린다면 실시간 시스템으로서의 가치를 잃게 됩니다.

성능 테스트로 이런 문제를 조기에 발견하고 최적화할 수 있습니다. 기존에는 수동으로 성능을 측정하고 추측으로 최적화했다면, 이제는 자동화된 성능 테스트로 정량적으로 측정하고 최적화 효과를 검증할 수 있습니다.

이 테스트의 핵심 특징은 첫째, 확장성 검증 - 부하 증가 시 성능 저하 정도를 측정하고, 둘째, 병목 지점 발견 - 어느 부분이 느린지 식별하며, 셋째, 최적화 효과 측정 - 개선 전후의 성능을 비교한다는 점입니다.

코드 예제

describe('성능 테스트와 최적화 검증', () => {
  it('1000개의 Observer에게 알림을 보내는 시간을 측정한다', () => {
    const subject = new ConcreteSubject();
    const observers: Observer[] = [];

    // 1000개의 Observer 생성 및 등록
    for (let i = 0; i < 1000; i++) {
      observers.push({ update: jest.fn() });
      subject.attach(observers[i]);
    }

    const startTime = performance.now();
    subject.setState(100);
    const duration = performance.now() - startTime;

    // 1000개의 Observer 알림이 100ms 이내에 완료되어야 함
    expect(duration).toBeLessThan(100);

    console.log(`1000 observers 알림 시간: ${duration.toFixed(2)}ms`);
  });

  it('불필요한 알림을 방지하는 최적화를 검증한다', () => {
    // 값이 실제로 변경될 때만 알림을 보내는 최적화된 Subject
    class OptimizedSubject implements Subject {
      private observers: Observer[] = [];
      private state: number = 0;

      attach(observer: Observer): void {
        this.observers.push(observer);
      }

      detach(observer: Observer): void {
        const index = this.observers.indexOf(observer);
        if (index > -1) {
          this.observers.splice(index, 1);
        }
      }

      notify(): void {
        for (const observer of this.observers) {
          observer.update(this);
        }
      }

      // 값이 변경될 때만 알림
      setState(state: number): void {
        if (this.state !== state) {
          this.state = state;
          this.notify();
        }
      }

      getState(): number {
        return this.state;
      }
    }

    const subject = new OptimizedSubject();
    const spy = jest.fn();
    subject.attach({ update: spy });

    // 같은 값으로 여러 번 설정
    subject.setState(10);
    subject.setState(10);
    subject.setState(10);

    // 실제로 값이 변경된 것은 1번뿐
    expect(spy).toHaveBeenCalledTimes(1);

    // 다른 값으로 변경
    subject.setState(20);
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('대량의 알림을 연속으로 처리할 수 있다', () => {
    const subject = new ConcreteSubject();
    const spy = jest.fn();
    subject.attach({ update: spy });

    const iterations = 10000;
    const startTime = performance.now();

    for (let i = 0; i < iterations; i++) {
      subject.setState(i);
    }

    const duration = performance.now() - startTime;
    const avgTime = duration / iterations;

    expect(spy).toHaveBeenCalledTimes(iterations);
    expect(avgTime).toBeLessThan(1); // 평균 1ms 이하

    console.log(`${iterations}번 알림 평균 시간: ${avgTime.toFixed(4)}ms`);
  });

  it('메모리 효율적으로 Observer를 관리한다', () => {
    const subject = new ConcreteSubject();
    const initialMemory = process.memoryUsage().heapUsed;

    // 10000개의 Observer 등록
    const observers: Observer[] = [];
    for (let i = 0; i < 10000; i++) {
      observers.push({ update: jest.fn() });
      subject.attach(observers[i]);
    }

    const afterAttachMemory = process.memoryUsage().heapUsed;
    const attachMemoryIncrease = (afterAttachMemory - initialMemory) / 1024 / 1024;

    // 모두 해제
    observers.forEach(observer => subject.detach(observer));

    // 가비지 컬렉션 강제 실행 (테스트 환경에서만)
    if (global.gc) {
      global.gc();
    }

    const afterDetachMemory = process.memoryUsage().heapUsed;
    const finalMemoryIncrease = (afterDetachMemory - initialMemory) / 1024 / 1024;

    console.log(`10000 observers 등록 시 메모리 증가: ${attachMemoryIncrease.toFixed(2)}MB`);
    console.log(`해제 후 메모리 증가: ${finalMemoryIncrease.toFixed(2)}MB`);

    // 해제 후 메모리가 크게 감소해야 함
    expect(finalMemoryIncrease).toBeLessThan(attachMemoryIncrease * 0.5);
  });
});

설명

이것이 하는 일: 실제 프로덕션 환경의 부하를 시뮬레이션하여 Observer Pattern 구현의 성능과 확장성을 검증합니다. 병목 지점을 찾고, 최적화 전후의 성능을 비교하여 개선 효과를 측정하는 것이 목적입니다.

첫 번째로, performance.now()를 사용하여 1000개의 Observer에게 알림을 보내는 데 걸리는 시간을 정밀하게 측정합니다. toBeLessThan(100)으로 100ms 이내에 완료되어야 한다는 성능 요구사항을 명시합니다.

이는 실시간 시스템에서 사용자가 느끼는 지연이 없도록 보장하는 기준입니다. console.log로 실제 측정값을 출력하여 성능 추이를 모니터링할 수 있습니다.

그 다음으로, 불필요한 알림을 방지하는 최적화를 구현하고 검증합니다. OptimizedSubject는 setState에서 새로운 값이 기존 값과 다를 때만 notify를 호출합니다.

이는 React의 setState나 Vue의 반응성 시스템이 사용하는 최적화 기법입니다. 같은 값으로 여러 번 setState를 호출해도 실제 알림은 1번만 발생하므로, 불필요한 CPU 사용과 재렌더링을 방지할 수 있습니다.

대량 알림 테스트는 10000번의 연속적인 상태 변경을 처리할 수 있는지 검증합니다. 평균 알림 시간을 계산하여 각 알림이 1ms 이하로 처리되는지 확인합니다.

이는 초당 1000개 이상의 이벤트를 처리할 수 있는 고성능 시스템임을 증명합니다. 만약 이 테스트가 실패하면 notify 메서드의 루프를 최적화하거나, 알림을 배치로 묶는 등의 개선이 필요합니다.

메모리 테스트는 process.memoryUsage()로 힙 메모리 사용량을 추적합니다. 10000개의 Observer를 등록한 후 메모리 증가량을 측정하고, 모두 해제한 후 메모리가 감소하는지 확인합니다.

global.gc()로 가비지 컬렉션을 강제로 실행하여 정확한 측정을 시도합니다(Node.js를 --expose-gc 플래그로 실행해야 함). 해제 후 메모리가 50% 이상 감소하지 않으면 메모리 누수가 있을 가능성이 높습니다.

여러분이 이 테스트를 사용하면 성능 회귀(performance regression)를 조기에 발견할 수 있고, 최적화의 효과를 객관적으로 측정할 수 있으며, 시스템이 실제 프로덕션 부하를 견딜 수 있다는 확신을 얻을 수 있습니다.

실전 팁

💡 성능 테스트는 여러 번 실행하여 평균값을 구하세요. 첫 실행은 JIT 컴파일이나 캐싱으로 인해 느릴 수 있으므로, 워밍업 후 측정하는 것이 정확합니다. 💡 CI/CD 파이프라인에서 성능 테스트를 실행하고 결과를 추적하세요. 시간이 지남에 따라 성능이 저하되는지 모니터링하면 성능 회귀를 빠르게 발견할 수 있습니다. 💡 실제 프로덕션 환경과 유사한 조건에서 테스트하세요. 개발 PC와 서버의 성능 차이를 고려하여 임계값을 설정해야 합니다. 💡 프로파일링 도구(Chrome DevTools, Node.js profiler)를 사용하여 정확한 병목 지점을 찾으세요. 추측으로 최적화하지 말고, 측정한 후 개선해야 합니다. 💡 대량의 Observer를 관리할 때는 Set이나 Map 자료구조를 사용하면 O(n) 시간 복잡도를 O(1)로 개선할 수 있습니다. 특히 detach 연산이 빈번하다면 큰 성능 향상을 얻을 수 있습니다.


#TypeScript#ObserverPattern#Testing#Jest#UnitTest

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.