이미지 로딩 중...
AI Generated
2025. 11. 5. · 3 Views
Decorator Pattern 테스트 전략 완벽 가이드
Decorator Pattern을 사용한 코드의 효과적인 테스트 전략을 배워보세요. 단위 테스트부터 통합 테스트까지, 실무에서 바로 적용할 수 있는 테스트 기법과 모범 사례를 다룹니다.
목차
- Decorator Pattern 기본 개념
- 단순 Decorator 단위 테스트
- Decorator 체인 테스트
- Mock과 Spy를 활용한 테스트
- 통합 테스트 전략
- 성능 테스트 작성법
- 에러 처리 테스트
- 테스트 더블 패턴
1. Decorator Pattern 기본 개념
시작하며
여러분이 커피 주문 시스템을 개발한다고 상상해보세요. 기본 커피에 우유를 추가하고, 시럽을 넣고, 휘핑크림을 올리는 등 다양한 옵션을 동적으로 추가해야 합니다.
이런 상황에서 각 조합마다 새로운 클래스를 만들면 어떻게 될까요? 클래스가 수십, 수백 개로 폭발적으로 늘어나게 됩니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 기능을 확장해야 하지만 기존 코드를 수정하고 싶지 않을 때, 또는 런타임에 동적으로 기능을 추가해야 할 때 말이죠.
상속을 남용하면 클래스 계층이 복잡해지고 유지보수가 어려워집니다. 바로 이럴 때 필요한 것이 Decorator Pattern입니다.
객체에 기능을 동적으로 추가하면서도 코드의 유연성을 유지할 수 있습니다.
개요
간단히 말해서, Decorator Pattern은 객체를 감싸서 새로운 기능을 추가하는 디자인 패턴입니다. 실무에서는 로깅, 캐싱, 인증, 데이터 변환 같은 횡단 관심사(cross-cutting concerns)를 처리할 때 매우 유용합니다.
예를 들어, API 호출에 로깅을 추가하고, 그 위에 캐싱을 추가하고, 다시 에러 처리를 추가하는 식으로 기능을 레이어링할 수 있습니다. 기존에는 모든 기능을 하나의 클래스에 몰아넣거나 복잡한 상속 구조를 만들었다면, 이제는 작은 Decorator들을 조합하여 필요한 기능을 구성할 수 있습니다.
Decorator Pattern의 핵심 특징은 첫째, 단일 책임 원칙(SRP)을 지키면서 기능을 분리할 수 있고, 둘째, 개방-폐쇄 원칙(OCP)을 따라 기존 코드 수정 없이 확장 가능하며, 셋째, 런타임에 동적으로 기능을 조합할 수 있다는 점입니다. 이러한 특징들이 코드를 유연하고 테스트 가능하게 만들어줍니다.
코드 예제
// 기본 인터페이스 정의
interface Coffee {
cost(): number;
description(): string;
}
// 기본 구현
class SimpleCoffee implements Coffee {
cost(): number {
return 2000;
}
description(): string {
return "Simple Coffee";
}
}
// Decorator 추상 클래스
abstract class CoffeeDecorator implements Coffee {
constructor(protected coffee: Coffee) {}
abstract cost(): number;
abstract description(): string;
}
// 구체적인 Decorator
class MilkDecorator extends CoffeeDecorator {
cost(): number {
return this.coffee.cost() + 500;
}
description(): string {
return this.coffee.description() + ", Milk";
}
}
설명
이것이 하는 일: Decorator Pattern은 기존 객체의 인터페이스를 유지하면서 새로운 기능을 추가하는 구조를 제공합니다. 첫 번째로, Coffee 인터페이스는 모든 커피 객체가 구현해야 할 메서드를 정의합니다.
SimpleCoffee는 가장 기본적인 커피 구현체로, 기본 가격과 설명을 반환합니다. 이렇게 인터페이스를 분리하면 나중에 다양한 커피 타입을 추가할 수 있습니다.
그 다음으로, CoffeeDecorator 추상 클래스가 실행되면서 Coffee 인터페이스를 구현하면서 동시에 다른 Coffee 객체를 감쌉니다. 이것이 Decorator 패턴의 핵심입니다.
protected 키워드를 사용하여 하위 클래스에서 감싸진 객체에 접근할 수 있도록 합니다. 마지막으로, MilkDecorator가 기존 커피의 가격에 우유 가격을 더하고, 설명에 우유를 추가하여 최종적으로 확장된 기능을 가진 커피 객체를 만들어냅니다.
여러분이 이 코드를 사용하면 new MilkDecorator(new SimpleCoffee())처럼 간단하게 기능을 조합할 수 있고, 새로운 Decorator를 추가할 때 기존 코드를 전혀 수정하지 않아도 됩니다. 테스트도 각 Decorator를 독립적으로 테스트할 수 있어 유지보수가 쉽습니다.
실전 팁
💡 인터페이스를 먼저 정의하고 시작하세요. 이렇게 하면 Decorator와 기본 객체가 동일한 타입으로 취급되어 조합이 자유로워집니다.
💡 Decorator 생성자에서 null 체크를 추가하세요. 감싸는 객체가 null이면 런타임 에러가 발생할 수 있습니다.
💡 Decorator 체인이 너무 길어지면 디버깅이 어려워집니다. 필요한 경우 팩토리 패턴과 결합하여 복잡한 조합을 캡슐화하세요.
💡 메모리 누수를 방지하기 위해 순환 참조가 생기지 않도록 주의하세요. 특히 양방향 참조가 필요한 경우 WeakReference 사용을 고려하세요.
💡 타입스크립트에서는 제네릭을 활용하여 타입 안전성을 높일 수 있습니다. CoffeeDecorator<T extends Coffee> 형태로 정의하면 더욱 강력합니다.
2. 단순 Decorator 단위 테스트
시작하며
여러분이 MilkDecorator를 만들었는데 실제로 제대로 작동하는지 어떻게 확인하시나요? 그냥 실행해보고 콘솔에 찍어보는 것만으로는 부족합니다.
나중에 코드를 수정했을 때 의도치 않게 기능이 깨질 수 있기 때문이죠. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
단위 테스트 없이 개발하다 보면 리팩토링이 두렵고, 작은 변경에도 전체 시스템을 수동으로 테스트해야 합니다. 특히 Decorator는 여러 개를 조합해서 사용하기 때문에 각각이 제대로 작동하는지 검증이 필수입니다.
바로 이럴 때 필요한 것이 단위 테스트입니다. 각 Decorator를 독립적으로 테스트하여 정확성을 보장할 수 있습니다.
개요
간단히 말해서, 단위 테스트는 하나의 Decorator가 제대로 작동하는지 검증하는 자동화된 테스트입니다. 실무에서는 Jest, Mocha, Vitest 같은 테스팅 프레임워크를 사용하여 Decorator의 동작을 검증합니다.
예를 들어, MilkDecorator가 실제로 500원을 추가하는지, 설명에 "Milk"가 정확히 붙는지 확인하는 식입니다. 이런 테스트가 있으면 코드 변경 시 즉시 문제를 발견할 수 있습니다.
기존에는 통합 테스트나 수동 테스트에 의존했다면, 이제는 각 컴포넌트를 독립적으로 빠르게 검증할 수 있습니다. 단위 테스트의 핵심은 첫째, 테스트가 빠르게 실행되어야 하고(밀리초 단위), 둘째, 외부 의존성 없이 독립적으로 실행되어야 하며, 셋째, 예상 동작을 명확하게 검증해야 합니다.
이러한 특징들이 개발 속도를 높이고 버그를 조기에 발견하게 해줍니다.
코드 예제
// Jest를 사용한 MilkDecorator 테스트
describe('MilkDecorator', () => {
let baseCoffee: Coffee;
beforeEach(() => {
// 각 테스트 전에 새로운 기본 커피 생성
baseCoffee = new SimpleCoffee();
});
test('should add milk cost to base coffee', () => {
// Arrange: 테스트 준비
const milkCoffee = new MilkDecorator(baseCoffee);
// Act: 실제 동작 실행
const actualCost = milkCoffee.cost();
// Assert: 결과 검증
expect(actualCost).toBe(2500); // 2000 + 500
});
test('should append milk to description', () => {
const milkCoffee = new MilkDecorator(baseCoffee);
expect(milkCoffee.description()).toBe("Simple Coffee, Milk");
});
test('should delegate to wrapped coffee', () => {
// 기본 커피의 메서드가 호출되는지 확인
const costSpy = jest.spyOn(baseCoffee, 'cost');
const milkCoffee = new MilkDecorator(baseCoffee);
milkCoffee.cost();
expect(costSpy).toHaveBeenCalledTimes(1);
});
});
설명
이것이 하는 일: 이 테스트 코드는 MilkDecorator가 정확히 500원을 추가하고 설명을 올바르게 수정하는지 자동으로 검증합니다. 첫 번째로, beforeEach 훅에서 각 테스트가 독립적으로 실행되도록 새로운 baseCoffee 인스턴스를 생성합니다.
이렇게 하면 한 테스트의 상태가 다른 테스트에 영향을 주지 않습니다. 테스트 격리는 안정적인 테스트의 핵심입니다.
그 다음으로, 각 test 블록이 실행되면서 Arrange-Act-Assert 패턴을 따릅니다. Arrange에서 테스트 대상을 준비하고, Act에서 실제 메서드를 호출하며, Assert에서 결과를 검증합니다.
이 패턴은 테스트를 읽기 쉽고 이해하기 쉽게 만듭니다. 마지막으로, jest.spyOn을 사용한 세 번째 테스트가 Decorator가 실제로 감싸진 객체의 메서드를 호출하는지 확인하여 최종적으로 위임 패턴이 올바르게 구현되었는지 검증합니다.
여러분이 이 테스트를 작성하면 코드 변경 시 즉시 피드백을 받을 수 있고, 리팩토링할 때 자신감을 가질 수 있습니다. 또한 테스트 자체가 코드의 사용법을 보여주는 문서 역할도 합니다.
CI/CD 파이프라인에 통합하면 자동으로 품질을 검증할 수 있습니다.
실전 팁
💡 테스트 이름은 "should + 동작 + 결과" 형식으로 작성하세요. 이렇게 하면 테스트가 실패했을 때 무엇이 문제인지 바로 알 수 있습니다.
💡 beforeEach와 afterEach를 활용하여 테스트 간 상태를 격리하세요. 특히 데이터베이스 연결이나 파일 시스템을 사용하는 경우 정리가 필수입니다.
💡 테스트 하나당 하나의 개념만 검증하세요. 여러 것을 한 번에 테스트하면 실패 원인을 찾기 어렵습니다.
💡 경계값(boundary values)을 테스트하세요. 0원, 음수, 매우 큰 값 등 극단적인 경우도 검증해야 합니다.
💡 테스트 커버리지 도구(jest --coverage)를 사용하여 빠진 부분이 없는지 확인하세요. 하지만 100% 커버리지가 목표가 아니라 중요한 로직의 검증이 목표입니다.
3. Decorator 체인 테스트
시작하며
여러분이 우유도 추가하고, 시럽도 넣고, 휘핑크림까지 올린 커피를 주문한다고 생각해보세요. 각 Decorator는 개별적으로 잘 작동하는데, 이것들을 여러 개 조합했을 때도 제대로 작동할까요?
순서가 바뀌면 결과가 달라질까요? 이런 문제는 실제 개발 현장에서 매우 중요합니다.
로깅 → 캐싱 → API 호출 순서와 캐싱 → 로깅 → API 호출 순서는 완전히 다른 결과를 만들 수 있습니다. Decorator 체인에서 버그가 하나라도 있으면 전체 기능이 망가집니다.
바로 이럴 때 필요한 것이 체인 테스트입니다. 여러 Decorator를 조합했을 때의 동작을 검증할 수 있습니다.
개요
간단히 말해서, 체인 테스트는 여러 Decorator를 연결했을 때 전체 시스템이 예상대로 작동하는지 검증하는 테스트입니다. 실무에서는 Decorator를 2개, 3개, 때로는 5개 이상 연결해서 사용합니다.
예를 들어, API 클라이언트에 로깅 → 재시도 → 캐싱 → 에러 핸들링을 순서대로 추가하는 경우가 흔합니다. 각 단계가 제대로 작동하고, 순서도 올바른지 검증해야 합니다.
기존에는 통합 테스트로만 검증했다면, 이제는 단위 테스트 수준에서 체인의 동작을 빠르게 검증할 수 있습니다. 체인 테스트의 핵심은 첫째, 조합된 결과가 각 Decorator의 기여도를 모두 반영하는지 확인하고, 둘째, 실행 순서가 올바른지 검증하며, 셋째, 예외 상황에서도 체인이 안전하게 작동하는지 확인하는 것입니다.
이러한 검증이 복잡한 시스템의 안정성을 보장합니다.
코드 예제
describe('Decorator Chain', () => {
test('should apply multiple decorators correctly', () => {
// 기본 커피에 우유와 시럽을 순서대로 추가
const coffee = new SimpleCoffee();
const withMilk = new MilkDecorator(coffee);
const withSyrup = new SyrupDecorator(withMilk);
// 가격: 2000 + 500(우유) + 300(시럽) = 2800
expect(withSyrup.cost()).toBe(2800);
expect(withSyrup.description()).toBe("Simple Coffee, Milk, Syrup");
});
test('should work with different ordering', () => {
// 순서를 바꿔서 테스트
const coffee = new SimpleCoffee();
const withSyrup = new SyrupDecorator(coffee);
const withMilk = new MilkDecorator(withSyrup);
// 가격은 동일하지만 설명 순서가 다름
expect(withMilk.cost()).toBe(2800);
expect(withMilk.description()).toBe("Simple Coffee, Syrup, Milk");
});
test('should handle long chains', () => {
// 긴 체인 테스트
let coffee: Coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SyrupDecorator(coffee);
coffee = new WhipDecorator(coffee);
coffee = new CaramelDecorator(coffee);
// 모든 가격이 누적되는지 확인
const expectedCost = 2000 + 500 + 300 + 400 + 350;
expect(coffee.cost()).toBe(expectedCost);
});
});
설명
이것이 하는 일: 이 테스트는 Decorator들이 체인으로 연결되었을 때 각각의 기능이 올바르게 누적되고 실행 순서도 정확한지 검증합니다. 첫 번째로, 첫 번째 테스트에서 SimpleCoffee를 MilkDecorator로 감싸고, 그것을 다시 SyrupDecorator로 감쌉니다.
이것이 Decorator 체인의 핵심 구조입니다. 각 Decorator는 이전 Decorator를 감싸면서 자신의 기능을 추가합니다.
가격과 설명이 모두 누적되는 것을 확인하여 체인이 제대로 작동하는지 검증합니다. 그 다음으로, 두 번째 테스트가 실행되면서 순서를 바꿔서 동일한 Decorator들을 적용합니다.
이렇게 하면 Decorator의 순서가 결과에 어떤 영향을 미치는지 알 수 있습니다. 가격은 덧셈이므로 순서와 무관하지만, 설명은 순서대로 붙기 때문에 달라집니다.
이런 동작이 의도한 것인지 확인하는 것이 중요합니다. 마지막으로, 세 번째 테스트가 4개의 Decorator를 연결하여 긴 체인에서도 모든 기능이 올바르게 누적되는지 검증합니다.
실무에서는 5개 이상의 Decorator를 사용하는 경우도 있으므로, 긴 체인에 대한 테스트가 필수입니다. 여러분이 이런 테스트를 작성하면 Decorator를 추가하거나 제거할 때 전체 시스템이 여전히 작동하는지 자신 있게 확인할 수 있습니다.
특히 리팩토링할 때 이런 테스트가 안전망 역할을 해줍니다. 또한 새로운 팀원이 코드를 이해할 때 이 테스트들이 시스템의 동작을 설명하는 문서가 됩니다.
실전 팁
💡 Decorator 순서가 중요한 경우 명시적으로 테스트하세요. 예를 들어 로깅은 가장 바깥쪽에 있어야 모든 동작을 기록할 수 있습니다.
💡 체인의 깊이 제한을 테스트하세요. 너무 긴 체인은 성능 문제를 일으킬 수 있으므로 합리적인 최대 깊이를 설정하고 검증하세요.
💡 중간에 null이나 undefined가 들어가는 경우를 테스트하세요. 방어적 프로그래밍을 위해 이런 예외 상황도 처리해야 합니다.
💡 파라미터화된 테스트(test.each)를 사용하여 다양한 조합을 효율적으로 테스트하세요. 같은 로직을 반복하지 말고 데이터만 바꿔서 테스트하세요.
💡 체인의 메모리 사용량도 모니터링하세요. 각 Decorator가 이전 인스턴스를 참조하므로 메모리 누수가 발생할 수 있습니다.
4. Mock과 Spy를 활용한 테스트
시작하며
여러분이 DataLoggerDecorator를 테스트하는데 실제로 파일 시스템에 로그를 쓰고 싶지 않다면 어떻게 해야 할까요? 데이터베이스에 접근하는 Decorator를 테스트할 때 실제 DB 연결 없이 테스트할 수 있을까요?
이런 문제는 실제 개발 현장에서 매우 흔합니다. 외부 시스템에 의존하는 코드를 테스트하면 테스트가 느려지고, 불안정해지며, 환경 설정이 복잡해집니다.
또한 네트워크 장애나 DB 상태에 따라 테스트 결과가 달라질 수 있습니다. 바로 이럴 때 필요한 것이 Mock과 Spy입니다.
외부 의존성을 가짜 객체로 대체하여 빠르고 안정적인 테스트를 작성할 수 있습니다.
개요
간단히 말해서, Mock은 실제 객체를 대신하는 가짜 객체이고, Spy는 실제 객체의 호출을 감시하는 도구입니다. 실무에서는 API 호출, 데이터베이스 접근, 파일 시스템 작업, 타임스탬프 생성 등 외부 의존성을 테스트할 때 Mock과 Spy를 필수적으로 사용합니다.
예를 들어, 로깅 Decorator를 테스트할 때 실제로 파일을 쓰지 않고 Mock Logger를 사용하여 로깅 메서드가 올바른 인자로 호출되었는지만 검증합니다. 기존에는 실제 의존성을 모두 설정해야 했다면, 이제는 Mock을 사용하여 테스트 환경을 간단하게 구성할 수 있습니다.
Mock과 Spy의 핵심 차이는 Mock은 완전한 가짜 구현을 제공하고 호출 여부를 검증하는 반면, Spy는 실제 객체를 래핑하여 호출을 기록하면서도 원래 동작을 수행한다는 점입니다. Mock은 완전한 제어가 필요할 때, Spy는 부분적인 모니터링이 필요할 때 사용합니다.
코드 예제
// Logger 인터페이스
interface Logger {
log(message: string): void;
}
// 로깅 기능을 추가하는 Decorator
class LoggingDecorator extends CoffeeDecorator {
constructor(coffee: Coffee, private logger: Logger) {
super(coffee);
}
cost(): number {
const cost = this.coffee.cost();
this.logger.log(`Coffee cost calculated: ${cost}`);
return cost;
}
description(): string {
return this.coffee.description();
}
}
// Mock을 사용한 테스트
describe('LoggingDecorator', () => {
test('should log cost calculation', () => {
// Mock Logger 생성
const mockLogger: Logger = {
log: jest.fn()
};
const coffee = new SimpleCoffee();
const loggingCoffee = new LoggingDecorator(coffee, mockLogger);
loggingCoffee.cost();
// Mock이 올바른 인자로 호출되었는지 검증
expect(mockLogger.log).toHaveBeenCalledWith('Coffee cost calculated: 2000');
expect(mockLogger.log).toHaveBeenCalledTimes(1);
});
test('should spy on wrapped coffee methods', () => {
const mockLogger: Logger = { log: jest.fn() };
const coffee = new SimpleCoffee();
const costSpy = jest.spyOn(coffee, 'cost');
const loggingCoffee = new LoggingDecorator(coffee, mockLogger);
loggingCoffee.cost();
// Spy로 메서드 호출 확인
expect(costSpy).toHaveBeenCalled();
});
});
설명
이것이 하는 일: 이 코드는 외부 의존성인 Logger를 Mock으로 대체하여 LoggingDecorator가 올바르게 로깅하는지 검증하면서도 실제 파일 시스템에 접근하지 않습니다. 첫 번째로, Logger 인터페이스를 정의하여 의존성을 추상화합니다.
이것이 의존성 역전 원칙(DIP)의 핵심입니다. LoggingDecorator는 구체적인 Logger 구현이 아니라 인터페이스에 의존하므로, 테스트에서 Mock으로 쉽게 대체할 수 있습니다.
생성자에서 Logger를 주입받는 것도 의존성 주입(DI) 패턴입니다. 그 다음으로, 첫 번째 테스트가 실행되면서 jest.fn()을 사용하여 Mock 함수를 생성합니다.
이 Mock은 실제로 아무 일도 하지 않지만, 호출 횟수와 인자를 모두 기록합니다. loggingCoffee.cost()를 호출한 후, toHaveBeenCalledWith와 toHaveBeenCalledTimes로 정확히 언제, 어떻게 호출되었는지 검증합니다.
마지막으로, 두 번째 테스트가 jest.spyOn을 사용하여 실제 SimpleCoffee 객체의 cost 메서드를 감시합니다. Spy는 원래 메서드를 그대로 실행하면서도 호출을 기록하므로, Decorator가 감싸진 객체의 메서드를 올바르게 호출하는지 확인할 수 있습니다.
여러분이 Mock과 Spy를 사용하면 외부 시스템 없이도 완전한 단위 테스트를 작성할 수 있습니다. 테스트가 밀리초 단위로 실행되고, CI/CD 파이프라인에서 안정적으로 동작하며, 환경 설정이 필요 없습니다.
또한 네트워크 장애나 DB 문제 같은 외부 요인으로부터 테스트가 독립적입니다. API 호출 실패 같은 예외 상황도 Mock을 사용하면 쉽게 시뮬레이션할 수 있습니다.
실전 팁
💡 jest.fn() 대신 jest.mock()을 사용하면 모듈 전체를 자동으로 Mock할 수 있습니다. 큰 의존성을 다룰 때 유용합니다.
💡 mockReturnValue()와 mockResolvedValue()를 사용하여 Mock이 반환할 값을 지정하세요. 다양한 시나리오를 테스트할 수 있습니다.
💡 Spy를 사용한 후에는 jest.restoreAllMocks()로 원래 상태로 복원하세요. 그렇지 않으면 다른 테스트에 영향을 줄 수 있습니다.
💡 Mock은 최소한으로 사용하세요. 너무 많이 사용하면 테스트가 구현에 강하게 결합되어 리팩토링이 어려워집니다.
💡 toHaveBeenCalledWith 대신 expect.objectContaining()을 사용하면 부분적인 매칭도 가능합니다. 큰 객체의 일부만 검증할 때 유용합니다.
5. 통합 테스트 전략
시작하며
여러분이 단위 테스트를 완벽하게 작성했는데도 실제 환경에서 Decorator가 제대로 작동하지 않는다면 어떻게 될까요? 각 부품은 완벽한데 조립하면 문제가 생기는 경우가 있습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 단위 테스트는 개별 컴포넌트를 검증하지만, 실제 데이터베이스, 네트워크, 파일 시스템과 함께 작동하는지는 확인하지 못합니다.
Mock으로 대체한 부분이 실제 구현과 다르게 동작할 수도 있습니다. 바로 이럴 때 필요한 것이 통합 테스트입니다.
실제 환경에 가깝게 전체 시스템을 검증할 수 있습니다.
개요
간단히 말해서, 통합 테스트는 여러 컴포넌트가 실제 의존성과 함께 작동하는지 검증하는 테스트입니다. 실무에서는 단위 테스트로 로직을 검증하고, 통합 테스트로 실제 환경에서의 동작을 확인하는 두 단계 접근법을 사용합니다.
예를 들어, 캐싱 Decorator의 경우 단위 테스트에서는 Mock Redis를 사용하지만, 통합 테스트에서는 실제 Redis 인스턴스(Docker로 실행)를 사용하여 검증합니다. 기존에는 수동 테스트나 E2E 테스트에만 의존했다면, 이제는 빠르게 실행되는 통합 테스트로 중요한 시나리오를 자동으로 검증할 수 있습니다.
통합 테스트의 핵심은 첫째, 실제 의존성을 사용하지만 프로덕션 환경을 건드리지 않고(테스트 DB, 로컬 서비스 등), 둘째, 주요 사용 시나리오를 end-to-end로 검증하며, 셋째, 단위 테스트보다 느리지만 E2E 테스트보다 빠르게 실행됩니다. 이러한 균형이 효율적인 테스트 전략을 만듭니다.
코드 예제
// 실제 Redis를 사용하는 캐싱 Decorator
class RedisCacheDecorator extends CoffeeDecorator {
constructor(
coffee: Coffee,
private redis: RedisClient,
private ttl: number = 3600
) {
super(coffee);
}
async cost(): Promise<number> {
const cacheKey = 'coffee:cost';
// 캐시 확인
const cached = await this.redis.get(cacheKey);
if (cached) {
return parseInt(cached);
}
// 캐시 미스 시 계산하고 저장
const cost = this.coffee.cost();
await this.redis.setex(cacheKey, this.ttl, cost.toString());
return cost;
}
description(): string {
return this.coffee.description();
}
}
// 통합 테스트
describe('RedisCacheDecorator Integration', () => {
let redis: RedisClient;
beforeAll(async () => {
// 테스트용 Redis 연결
redis = await createRedisClient({ host: 'localhost', port: 6379 });
});
afterAll(async () => {
await redis.quit();
});
beforeEach(async () => {
// 각 테스트 전 캐시 초기화
await redis.flushdb();
});
test('should cache cost in real Redis', async () => {
const coffee = new SimpleCoffee();
const cached = new RedisCacheDecorator(coffee, redis);
// 첫 호출: 캐시 미스
const cost1 = await cached.cost();
expect(cost1).toBe(2000);
// Redis에 저장되었는지 확인
const cachedValue = await redis.get('coffee:cost');
expect(cachedValue).toBe('2000');
// 두 번째 호출: 캐시 히트
const cost2 = await cached.cost();
expect(cost2).toBe(2000);
});
});
설명
이것이 하는 일: 이 테스트는 실제 Redis 인스턴스를 사용하여 RedisCacheDecorator가 캐싱을 올바르게 수행하는지 검증합니다. 첫 번째로, beforeAll과 afterAll 훅에서 Redis 연결의 생명주기를 관리합니다.
모든 테스트 전에 한 번 연결하고, 모든 테스트 후에 한 번 종료합니다. 이렇게 하면 각 테스트마다 연결을 반복하지 않아 성능이 향상됩니다.
beforeEach에서 flushdb()로 캐시를 초기화하여 테스트 간 격리를 보장합니다. 그 다음으로, RedisCacheDecorator가 실행되면서 실제 Redis에 데이터를 읽고 씁니다.
첫 번째 cost() 호출 시 캐시 미스가 발생하여 실제 계산을 수행하고 결과를 Redis에 저장합니다. redis.get()으로 직접 확인하여 데이터가 올바른 형식으로 저장되었는지 검증합니다.
마지막으로, 두 번째 cost() 호출이 캐시에서 값을 가져오는지 확인합니다. 실제 환경에서는 원본 coffee.cost()가 다시 호출되지 않아야 하므로, 필요하다면 Spy를 추가하여 호출 횟수를 검증할 수도 있습니다.
이렇게 캐시의 전체 라이프사이클이 올바르게 작동하는지 확인합니다. 여러분이 통합 테스트를 작성하면 실제 프로덕션 환경에서 발생할 수 있는 문제를 조기에 발견할 수 있습니다.
예를 들어, 데이터 직렬화 문제, 네트워크 타임아웃, 동시성 이슈 등은 단위 테스트로는 찾기 어렵지만 통합 테스트에서는 드러납니다. Docker Compose를 사용하면 Redis, PostgreSQL 같은 의존성을 로컬에서 쉽게 실행할 수 있습니다.
CI/CD 파이프라인에서도 동일한 환경을 구성하여 자동화할 수 있습니다.
실전 팁
💡 Docker를 사용하여 테스트 의존성을 격리하세요. testcontainers 라이브러리를 사용하면 코드에서 직접 컨테이너를 관리할 수 있습니다.
💡 통합 테스트는 별도의 폴더(예: tests/integration)에 분리하세요. 단위 테스트와 독립적으로 실행할 수 있어야 합니다.
💡 테스트 데이터를 fixture나 factory 패턴으로 관리하세요. 복잡한 테스트 데이터를 재사용 가능하게 만들 수 있습니다.
💡 병렬 실행을 고려하여 테스트를 설계하세요. 각 테스트가 고유한 데이터나 키를 사용하면 동시 실행이 가능합니다.
💡 환경 변수로 통합 테스트를 제어하세요. CI 환경에서는 실행하지만 로컬에서는 선택적으로 실행할 수 있게 합니다.
6. 성능 테스트 작성법
시작하며
여러분이 Decorator를 5개 연결했더니 응답 시간이 눈에 띄게 느려졌다면 어떻게 하시겠어요? 각 Decorator가 약간의 오버헤드를 추가하는데, 이것이 쌓이면 성능 문제가 될 수 있습니다.
이런 문제는 실제 개발 현장에서 매우 중요합니다. 특히 고성능이 요구되는 API나 실시간 시스템에서 Decorator 패턴의 오버헤드가 허용 범위 내인지 확인해야 합니다.
로깅이나 캐싱 같은 Decorator는 오히려 성능을 개선해야 하는데, 잘못 구현하면 역효과가 날 수 있습니다. 바로 이럴 때 필요한 것이 성능 테스트입니다.
Decorator의 오버헤드를 측정하고 성능 목표를 달성하는지 검증할 수 있습니다.
개요
간단히 말해서, 성능 테스트는 코드의 실행 시간, 메모리 사용량, 처리량 같은 성능 지표를 측정하고 검증하는 테스트입니다. 실무에서는 벤치마킹, 프로파일링, 부하 테스트 등 다양한 방법으로 성능을 검증합니다.
예를 들어, 캐싱 Decorator가 실제로 응답 시간을 단축시키는지, 로깅 Decorator의 오버헤드가 1ms 미만인지 측정합니다. 성능 목표를 코드로 명시하여 회귀를 방지할 수 있습니다.
기존에는 수동으로 시간을 측정하거나 프로파일러를 돌려봤다면, 이제는 자동화된 성능 테스트로 지속적으로 성능을 모니터링할 수 있습니다. 성능 테스트의 핵심은 첫째, 반복 측정을 통해 정확한 평균값을 얻고, 둘째, 베이스라인과 비교하여 성능 변화를 감지하며, 셋째, 실제 사용 패턴을 시뮬레이션하는 것입니다.
이러한 측정이 성능 문제를 조기에 발견하게 해줍니다.
코드 예제
describe('Performance Tests', () => {
test('decorator overhead should be minimal', () => {
const iterations = 10000;
// 기본 구현 측정
const start1 = performance.now();
for (let i = 0; i < iterations; i++) {
const coffee = new SimpleCoffee();
coffee.cost();
}
const baseTime = performance.now() - start1;
// Decorator 포함 측정
const start2 = performance.now();
for (let i = 0; i < iterations; i++) {
const coffee = new SimpleCoffee();
const decorated = new MilkDecorator(coffee);
decorated.cost();
}
const decoratedTime = performance.now() - start2;
// 오버헤드 계산
const overhead = decoratedTime - baseTime;
const overheadPerCall = overhead / iterations;
console.log(`Base time: ${baseTime}ms`);
console.log(`Decorated time: ${decoratedTime}ms`);
console.log(`Overhead per call: ${overheadPerCall}ms`);
// 오버헤드가 0.01ms 미만이어야 함
expect(overheadPerCall).toBeLessThan(0.01);
});
test('caching decorator should improve performance', async () => {
// 느린 연산을 시뮬레이션
class SlowCoffee implements Coffee {
cost(): number {
// 의도적으로 느린 연산
for (let i = 0; i < 1000000; i++) {}
return 2000;
}
description(): string { return "Slow Coffee"; }
}
const coffee = new SlowCoffee();
const cached = new MemoryCacheDecorator(coffee);
// 첫 호출: 느림
const start1 = performance.now();
await cached.cost();
const firstCallTime = performance.now() - start1;
// 두 번째 호출: 캐시에서 가져옴
const start2 = performance.now();
await cached.cost();
const secondCallTime = performance.now() - start2;
// 캐시 히트가 최소 10배 빨라야 함
expect(secondCallTime).toBeLessThan(firstCallTime / 10);
});
});
설명
이것이 하는 일: 이 테스트는 Decorator 패턴의 오버헤드를 정량적으로 측정하고, 캐싱 같은 최적화가 실제로 효과가 있는지 검증합니다. 첫 번째로, 첫 번째 테스트에서 10000번 반복 실행하여 정확한 측정값을 얻습니다.
한두 번의 측정은 노이즈가 많지만, 수천 번 반복하면 평균적인 성능을 알 수 있습니다. performance.now()는 고해상도 타이머로 밀리초보다 정밀한 측정이 가능합니다.
먼저 기본 구현을 측정한 후, Decorator를 추가한 버전을 측정하여 차이를 계산합니다. 그 다음으로, 오버헤드 계산이 실행되면서 전체 시간 차이를 반복 횟수로 나누어 호출당 오버헤드를 구합니다.
이 값이 0.01ms(10마이크로초) 미만이어야 한다는 것이 성능 목표입니다. 이렇게 구체적인 숫자로 목표를 명시하면 성능 회귀를 자동으로 감지할 수 있습니다.
마지막으로, 두 번째 테스트가 캐싱 Decorator의 효과를 측정합니다. SlowCoffee로 의도적으로 느린 연산을 만들어 캐싱의 효과를 극대화합니다.
첫 호출과 두 번째 호출의 시간을 비교하여 캐시가 최소 10배 이상의 성능 향상을 제공하는지 검증합니다. 실제 프로덕션에서는 데이터베이스 쿼리나 외부 API 호출이 이런 느린 연산에 해당합니다.
여러분이 성능 테스트를 작성하면 성능 회귀를 즉시 발견할 수 있습니다. 누군가 코드를 변경하여 성능이 나빠지면 CI에서 테스트가 실패합니다.
또한 성능 최적화의 효과를 객관적으로 증명할 수 있습니다. "캐싱을 추가했더니 10배 빨라졌어요"가 아니라 "테스트 결과 12.5배 향상되었습니다"라고 말할 수 있습니다.
다만 성능 테스트는 환경에 민감하므로 동일한 머신에서 실행해야 하고, 워밍업 단계를 추가하는 것이 좋습니다.
실전 팁
💡 워밍업(warm-up) 단계를 추가하세요. JIT 컴파일러나 캐시 때문에 첫 실행은 느릴 수 있으므로 몇 번 실행 후 측정을 시작하세요.
💡 통계적 의미를 위해 여러 번 측정하고 평균, 중간값, 표준편차를 계산하세요. 단일 측정값은 신뢰할 수 없습니다.
💡 메모리 사용량도 측정하세요. process.memoryUsage()로 힙 메모리 사용량을 확인할 수 있습니다.
💡 성능 테스트는 격리된 환경에서 실행하세요. 다른 프로세스의 영향을 최소화하기 위해 CI에서 전용 러너를 사용하세요.
💡 임계값을 너무 엄격하게 설정하지 마세요. 하드웨어 차이나 시스템 부하로 인한 변동을 고려하여 여유를 두세요.
7. 에러 처리 테스트
시작하며
여러분이 만든 Decorator가 정상 동작할 때는 완벽한데, 네트워크 오류나 잘못된 입력이 들어오면 어떻게 될까요? 에러가 발생했을 때 우아하게 처리하지 못하면 전체 애플리케이션이 중단될 수 있습니다.
이런 문제는 실제 개발 현장에서 치명적입니다. 프로덕션 환경에서는 네트워크 장애, 타임아웃, 잘못된 데이터, 권한 오류 등 수많은 예외 상황이 발생합니다.
Decorator 체인 중간에서 에러가 발생하면 어떻게 전파되는지, 리소스는 제대로 정리되는지 확인해야 합니다. 바로 이럴 때 필요한 것이 에러 처리 테스트입니다.
예외 상황에서도 시스템이 안정적으로 작동하는지 검증할 수 있습니다.
개요
간단히 말해서, 에러 처리 테스트는 예외 상황에서 코드가 올바르게 동작하고 적절하게 에러를 처리하는지 검증하는 테스트입니다. 실무에서는 Happy Path(정상 흐름)뿐만 아니라 Unhappy Path(예외 흐름)도 철저히 테스트해야 합니다.
예를 들어, 데이터베이스 연결이 끊어졌을 때, API 호출이 타임아웃됐을 때, 잘못된 데이터가 들어왔을 때 각각 어떻게 대응하는지 검증합니다. 에러 메시지가 명확한지, 로깅이 제대로 되는지, 리소스가 누수되지 않는지 확인합니다.
기존에는 에러 처리를 간과하거나 수동으로만 테스트했다면, 이제는 모든 에러 시나리오를 자동화된 테스트로 커버할 수 있습니다. 에러 처리 테스트의 핵심은 첫째, 예상 가능한 에러를 모두 식별하고, 둘째, 각 에러가 적절하게 처리되는지 검증하며, 셋째, 에러 발생 후에도 시스템이 일관된 상태를 유지하는지 확인하는 것입니다.
이러한 검증이 시스템의 회복탄력성(resilience)을 보장합니다.
코드 예제
// 에러를 던질 수 있는 Decorator
class ErrorHandlingDecorator extends CoffeeDecorator {
constructor(
coffee: Coffee,
private logger: Logger,
private fallbackCost: number = 0
) {
super(coffee);
}
cost(): number {
try {
return this.coffee.cost();
} catch (error) {
this.logger.log(`Error calculating cost: ${error.message}`);
return this.fallbackCost;
}
}
description(): string {
return this.coffee.description();
}
}
describe('Error Handling Tests', () => {
test('should handle errors from wrapped object', () => {
// 에러를 던지는 Mock 객체
const errorCoffee: Coffee = {
cost: () => { throw new Error('Database connection failed'); },
description: () => 'Error Coffee'
};
const mockLogger: Logger = { log: jest.fn() };
const decorator = new ErrorHandlingDecorator(errorCoffee, mockLogger, 1000);
// 에러가 발생해도 fallback 값을 반환해야 함
const cost = decorator.cost();
expect(cost).toBe(1000);
// 에러가 로깅되어야 함
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Database connection failed')
);
});
test('should propagate critical errors', () => {
// 치명적인 에러는 전파되어야 함
const criticalErrorCoffee: Coffee = {
cost: () => { throw new TypeError('Critical system error'); },
description: () => 'Critical Error Coffee'
};
const mockLogger: Logger = { log: jest.fn() };
const decorator = new ErrorHandlingDecorator(criticalErrorCoffee, mockLogger);
// TypeError는 잡지 말고 전파해야 함 (필요시 구현 수정)
expect(() => decorator.cost()).toThrow(TypeError);
});
test('should clean up resources on error', async () => {
// 리소스 정리가 필요한 경우
const mockConnection = {
close: jest.fn(),
query: jest.fn().mockRejectedValue(new Error('Query failed'))
};
const decorator = new DatabaseDecorator(mockConnection);
try {
await decorator.fetchData();
} catch (error) {
// 에러 발생 예상
}
// 에러가 발생해도 연결이 닫혀야 함
expect(mockConnection.close).toHaveBeenCalled();
});
});
설명
이것이 하는 일: 이 테스트는 Decorator가 예외 상황을 올바르게 처리하고, 시스템이 에러 발생 후에도 안전한 상태를 유지하는지 검증합니다. 첫 번째로, ErrorHandlingDecorator는 try-catch 블록으로 감싸진 객체의 에러를 잡아서 처리합니다.
에러가 발생하면 로깅하고 fallback 값을 반환하여 시스템이 계속 동작할 수 있게 합니다. 이것이 Fail-Safe 패턴입니다.
첫 번째 테스트는 이 동작을 검증하여 에러가 발생해도 애플리케이션이 멈추지 않는지 확인합니다. 그 다음으로, 두 번째 테스트가 실행되면서 모든 에러를 잡아야 하는 것은 아님을 보여줍니다.
TypeError 같은 프로그래밍 오류는 잡지 말고 전파해야 개발자가 문제를 인식하고 수정할 수 있습니다. expect().toThrow()를 사용하여 특정 에러가 전파되는지 검증합니다.
실무에서는 비즈니스 예외는 처리하고, 시스템 예외는 전파하는 전략을 많이 사용합니다. 마지막으로, 세 번째 테스트가 리소스 정리를 검증합니다.
데이터베이스 연결, 파일 핸들, 네트워크 소켓 같은 리소스는 에러가 발생해도 반드시 정리되어야 합니다. 그렇지 않으면 리소스 누수가 발생합니다.
mockConnection.close가 호출되었는지 확인하여 finally 블록이나 cleanup 로직이 제대로 작동하는지 검증합니다. 여러분이 에러 처리 테스트를 작성하면 프로덕션에서 발생할 수 있는 문제를 개발 단계에서 발견할 수 있습니다.
특히 네트워크 서비스나 외부 API를 다루는 경우 에러 처리가 매우 중요합니다. Mock을 사용하면 에러 상황을 쉽게 시뮬레이션할 수 있습니다.
또한 에러 메시지와 로깅도 테스트하여 운영 중 문제 진단이 쉽도록 만들 수 있습니다. Circuit Breaker, Retry, Fallback 같은 회복탄력성 패턴도 이런 테스트로 검증합니다.
실전 팁
💡 에러 경계(error boundaries)를 명확히 정의하세요. 어떤 레이어에서 에러를 처리할지 미리 설계하세요.
💡 사용자 정의 에러 타입을 만들어 사용하세요. 일반 Error보다 구체적인 정보를 담을 수 있고 처리하기 쉽습니다.
💡 에러 메시지에 컨텍스트를 포함하세요. "Error occurred"보다 "Failed to fetch user 123 from database"가 훨씬 유용합니다.
💡 재시도(retry) 로직을 테스트할 때는 Mock의 호출 횟수를 확인하세요. 무한 재시도를 방지하는 것도 중요합니다.
💡 에러 핸들링 코드도 커버리지에 포함시키세요. 테스트하지 않은 에러 처리 코드는 실제로 작동하지 않을 가능성이 높습니다.
8. 테스트 더블 패턴
시작하며
여러분이 복잡한 결제 시스템을 가진 Decorator를 테스트하는데, 실제 결제를 할 수는 없잖아요? 테스트마다 실제 카드로 결제하면 비용도 들고, 테스트 데이터도 엉망이 됩니다.
이런 문제는 실제 개발 현장에서 항상 발생합니다. 외부 API, 결제 시스템, 이메일 서비스, SMS 발송 같은 실제 효과를 일으키는 작업은 테스트에서 직접 호출할 수 없습니다.
또한 테스트 속도도 느려지고, 외부 서비스의 상태에 따라 테스트가 불안정해집니다. 바로 이럴 때 필요한 것이 테스트 더블(Test Double) 패턴입니다.
Dummy, Stub, Spy, Mock, Fake 같은 다양한 대역을 사용하여 의존성을 대체할 수 있습니다.
개요
간단히 말해서, 테스트 더블은 실제 객체를 테스트 목적으로 대체하는 모든 종류의 가짜 객체를 통칭하는 용어입니다. 실무에서는 상황에 따라 적절한 테스트 더블을 선택합니다.
Dummy는 인자를 채우기 위해 사용하고, Stub은 미리 정해진 값을 반환하며, Spy는 호출을 기록하고, Mock은 예상되는 호출을 검증하며, Fake는 실제와 유사하지만 간단한 구현을 제공합니다. 예를 들어, 인메모리 데이터베이스는 Fake의 좋은 예입니다.
기존에는 테스트 더블을 구분하지 않고 모두 Mock이라고 불렀다면, 이제는 각각의 목적과 용도를 이해하고 적절히 사용할 수 있습니다. 테스트 더블의 핵심은 첫째, 의존성을 제어 가능하게 만들어 테스트를 독립적으로 실행할 수 있게 하고, 둘째, 테스트 속도를 크게 향상시키며, 셋째, 드문 상황이나 에러 케이스를 쉽게 시뮬레이션할 수 있게 하는 것입니다.
이러한 기법들이 효과적인 단위 테스트의 기반입니다.
코드 예제
// Stub: 미리 정해진 응답을 반환
class StubPaymentGateway implements PaymentGateway {
charge(amount: number): PaymentResult {
return { success: true, transactionId: 'STUB_TX_123' };
}
}
// Spy: 호출을 기록하면서 실제 동작도 수행
class SpyLogger implements Logger {
public messages: string[] = [];
log(message: string): void {
this.messages.push(message);
console.log(message); // 실제 로깅도 수행
}
}
// Fake: 실제와 유사하지만 간단한 구현
class FakeDatabase implements Database {
private data: Map<string, any> = new Map();
async save(key: string, value: any): Promise<void> {
this.data.set(key, value);
}
async get(key: string): Promise<any> {
return this.data.get(key);
}
}
// 테스트 더블 사용 예시
describe('Test Doubles', () => {
test('using Stub for predictable responses', () => {
const stubGateway = new StubPaymentGateway();
const decorator = new PaymentDecorator(new SimpleCoffee(), stubGateway);
const result = decorator.purchase();
// Stub이 항상 성공을 반환하므로 테스트가 예측 가능
expect(result.success).toBe(true);
expect(result.transactionId).toBe('STUB_TX_123');
});
test('using Spy to verify interactions', () => {
const spyLogger = new SpyLogger();
const decorator = new LoggingDecorator(new SimpleCoffee(), spyLogger);
decorator.cost();
// Spy로 호출 기록 확인
expect(spyLogger.messages).toHaveLength(1);
expect(spyLogger.messages[0]).toContain('cost calculated');
});
test('using Fake for lightweight integration', async () => {
const fakeDb = new FakeDatabase();
const decorator = new CacheDecorator(new SimpleCoffee(), fakeDb);
await decorator.cost();
// Fake DB에 저장되었는지 확인
const cached = await fakeDb.get('coffee:cost');
expect(cached).toBe(2000);
});
});
설명
이것이 하는 일: 이 코드는 Stub, Spy, Fake의 차이를 보여주고, 각각이 어떤 상황에 적합한지 실제 예제로 설명합니다. 첫 번째로, StubPaymentGateway는 항상 동일한 응답을 반환합니다.
실제 결제를 하지 않고, 네트워크 호출도 하지 않으며, 단순히 미리 정해진 값을 돌려줍니다. 이것이 Stub의 핵심입니다.
외부 서비스의 응답을 시뮬레이션하여 테스트를 빠르고 예측 가능하게 만듭니다. 결제가 성공하는 경우, 실패하는 경우, 타임아웃되는 경우 등을 다른 Stub으로 쉽게 테스트할 수 있습니다.
그 다음으로, SpyLogger가 실행되면서 messages 배열에 모든 로그를 기록합니다. Spy는 Stub과 달리 실제 동작도 수행하면서 호출 정보를 추적합니다.
이렇게 하면 메서드가 몇 번 호출되었는지, 어떤 인자로 호출되었는지 나중에 검증할 수 있습니다. Jest의 jest.spyOn()도 같은 원리로 작동합니다.
마지막으로, FakeDatabase가 Map을 사용하여 간단한 인메모리 저장소를 구현합니다. 실제 데이터베이스의 동작을 모방하지만 훨씬 간단하고 빠릅니다.
Fake는 Mock이나 Stub보다 복잡하지만, 실제 구현보다는 단순합니다. SQLite 인메모리 DB나 간단한 파일 시스템 구현이 Fake의 좋은 예입니다.
통합 테스트와 단위 테스트의 중간 지점에서 유용합니다. 여러분이 테스트 더블을 적절히 사용하면 테스트의 목적에 맞게 의존성을 제어할 수 있습니다.
단순히 값만 필요하면 Stub을, 상호작용을 검증하려면 Spy를, 복잡한 동작이 필요하면 Fake를 사용하세요. Mock 프레임워크(Jest, Sinon)는 이런 패턴들을 쉽게 구현할 수 있는 도구를 제공합니다.
과도한 Mock 사용은 테스트를 구현에 강하게 결합시키므로, 가능하면 실제 객체를 사용하고 꼭 필요한 경우에만 테스트 더블을 사용하세요.
실전 팁
💡 Dummy는 아무것도 하지 않는 가장 단순한 더블입니다. 인터페이스를 만족시키기 위해 null 대신 사용하세요.
💡 Mock은 기대하는 호출을 미리 정의하고 검증합니다. "이 메서드가 이 인자로 정확히 한 번 호출되어야 한다"는 식의 엄격한 검증에 사용하세요.
💡 테스트 더블을 너무 많이 사용하면 테스트가 깨지기 쉽습니다. 리팩토링할 때 내부 구현이 바뀌면 테스트도 수정해야 합니다.
💡 Builder 패턴으로 테스트 더블을 만들면 다양한 시나리오를 쉽게 구성할 수 있습니다. StubBuilder로 다양한 응답을 설정하세요.
💡 테스트 더블도 테스트하세요. 특히 Fake는 실제 구현과 동작이 다르면 안 되므로, Contract Test로 일관성을 보장하세요.