이미지 로딩 중...

Decorator Pattern 핵심 개념 완벽 정리 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 5 Views

Decorator Pattern 핵심 개념 완벽 정리

객체에 동적으로 새로운 기능을 추가하는 Decorator Pattern의 핵심 개념부터 실전 활용까지 완벽하게 정리했습니다. 상속 없이 기능을 확장하는 방법을 배워보세요.


목차

  1. Decorator Pattern 기본 개념
  2. 실전 활용 - API 로깅 데코레이터
  3. 캐싱 데코레이터로 성능 최적화
  4. 인증 및 권한 확인 데코레이터
  5. 재시도 로직 데코레이터
  6. 성능 모니터링 데코레이터
  7. 데이터 변환 및 직렬화 데코레이터
  8. 트랜잭션 관리 데코레이터

1. Decorator Pattern 기본 개념

시작하며

여러분이 커피숍 주문 시스템을 개발한다고 상상해보세요. 기본 커피에 우유, 시럽, 휘핑크림 등을 추가할 때마다 새로운 클래스를 만들어야 한다면 어떨까요?

우유 커피, 시럽 커피, 우유+시럽 커피... 조합이 늘어날수록 클래스가 기하급수적으로 증가하는 문제가 발생합니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 상속을 사용하면 클래스 계층이 복잡해지고, 런타임에 동적으로 기능을 추가하거나 제거하기 어렵습니다.

또한 여러 기능을 조합할 때 클래스 폭발(class explosion) 문제가 발생하죠. 바로 이럴 때 필요한 것이 Decorator Pattern입니다.

이 패턴을 사용하면 기존 객체를 수정하지 않고도 새로운 기능을 동적으로 추가할 수 있어, 유연하고 확장 가능한 코드를 작성할 수 있습니다.

개요

간단히 말해서, Decorator Pattern은 객체에 추가적인 기능을 동적으로 덧붙이는 디자인 패턴입니다. 상속 대신 조합(composition)을 사용하여 기능을 확장하는 것이 핵심이죠.

왜 이 패턴이 필요한지 실무 관점에서 살펴보겠습니다. 로깅, 캐싱, 인증, 데이터 검증 등의 횡단 관심사(cross-cutting concerns)를 처리할 때, 각 기능을 독립적으로 추가하거나 제거할 수 있어야 합니다.

예를 들어, API 응답에 로깅과 캐싱을 동시에 적용하거나, 특정 환경에서만 캐싱을 제거하는 경우에 매우 유용합니다. 전통적인 방법과의 비교를 해보겠습니다.

기존에는 각 조합마다 새로운 서브클래스를 만들었다면, 이제는 기능별로 독립적인 데코레이터를 만들어 필요한 만큼 감싸기만 하면 됩니다. 이 패턴의 핵심 특징은 세 가지입니다.

첫째, 개방-폐쇄 원칙(OCP)을 준수합니다. 기존 코드를 수정하지 않고 확장할 수 있죠.

둘째, 단일 책임 원칙(SRP)을 지킵니다. 각 데코레이터는 하나의 책임만 가집니다.

셋째, 런타임에 동적으로 기능을 조합할 수 있습니다. 이러한 특징들이 코드의 유지보수성과 확장성을 크게 향상시킵니다.

코드 예제

// 기본 인터페이스 정의
interface Coffee {
  cost(): number;
  description(): string;
}

// 기본 구현체
class SimpleCoffee implements Coffee {
  cost(): number {
    return 2000;
  }

  description(): string {
    return "심플 커피";
  }
}

// 데코레이터 베이스 클래스
abstract class CoffeeDecorator implements Coffee {
  constructor(protected coffee: Coffee) {}

  abstract cost(): number;
  abstract description(): string;
}

// 구체적인 데코레이터들
class MilkDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 500;
  }

  description(): string {
    return this.coffee.description() + ", 우유 추가";
  }
}

class SyrupDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 300;
  }

  description(): string {
    return this.coffee.description() + ", 시럽 추가";
  }
}

// 사용 예시
let coffee: Coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SyrupDecorator(coffee);
console.log(`${coffee.description()}: ${coffee.cost()}원`);

설명

이것이 하는 일을 단계별로 살펴보겠습니다. 이 패턴은 기본 객체를 여러 겹의 데코레이터로 감싸서 기능을 추가하는 방식으로 동작합니다.

첫 번째로, 기본 인터페이스와 구현체를 정의합니다. Coffee 인터페이스는 모든 커피 객체가 구현해야 할 메서드를 정의하고, SimpleCoffee는 가장 기본적인 커피를 나타냅니다.

이렇게 하는 이유는 데코레이터와 기본 객체가 동일한 인터페이스를 공유하여 투명하게 사용될 수 있도록 하기 위함입니다. 두 번째로, CoffeeDecorator 추상 클래스가 실행되면서 기본 객체를 감싸는 역할을 합니다.

생성자에서 Coffee 객체를 받아 보관하고, 하위 데코레이터들이 이를 활용할 수 있게 합니다. 내부에서는 원본 객체의 메서드를 호출하면서 추가 기능을 덧붙이는 일이 일어납니다.

세 번째로, MilkDecorator와 SyrupDecorator가 실제 기능을 추가합니다. 각 데코레이터는 원본 객체의 cost()와 description()을 호출한 뒤, 자신만의 추가 비용과 설명을 더합니다.

마지막으로 체인 형태로 여러 데코레이터를 중첩하여 최종적으로 모든 기능이 조합된 객체를 만들어냅니다. 여러분이 이 코드를 사용하면 새로운 토핑을 추가할 때마다 새로운 데코레이터만 만들면 되고, 기존 코드는 전혀 수정하지 않아도 됩니다.

런타임에 원하는 조합을 자유롭게 만들 수 있고, 각 기능이 독립적으로 관리되어 테스트와 유지보수가 쉬워집니다. 또한 데코레이터를 추가하거나 제거하는 순서를 바꿔서 다양한 동작을 구현할 수 있습니다.

실전 팁

💡 데코레이터는 원본 객체와 동일한 인터페이스를 구현해야 합니다. 그래야 클라이언트 코드가 데코레이터인지 원본 객체인지 구분하지 않고 사용할 수 있습니다. 💡 데코레이터를 너무 많이 중첩하면 디버깅이 어려워집니다. 스택 트레이스가 깊어지므로, 정말 필요한 경우에만 사용하세요. 💡 데코레이터의 순서가 중요할 수 있습니다. 예를 들어, 로깅 후 캐싱과 캐싱 후 로깅은 다른 결과를 만들 수 있으니 주의하세요. 💡 불변 객체를 다룰 때 Decorator Pattern이 특히 유용합니다. 원본을 수정하지 않고 새로운 기능을 추가할 수 있기 때문입니다. 💡 TypeScript에서는 실험적 데코레이터 문법(@decorator)도 있지만, 이는 다른 개념입니다. 혼동하지 마세요!


2. 실전 활용 - API 로깅 데코레이터

시작하며

여러분이 마이크로서비스 환경에서 여러 API를 관리하고 있다고 생각해보세요. 각 API 호출마다 요청 시간, 응답 시간, 에러 정보를 로깅해야 한다면 어떻게 하시겠어요?

모든 API 핸들러에 로깅 코드를 복사-붙여넣기 하는 건 유지보수의 악몽입니다. 이런 문제는 실제 개발 현장에서 매우 흔합니다.

횡단 관심사인 로깅, 모니터링, 에러 핸들링을 각 비즈니스 로직에 섞어 넣으면 코드가 복잡해지고, 같은 코드가 반복되며, 나중에 로깅 정책이 바뀌면 모든 곳을 수정해야 합니다. 바로 이럴 때 필요한 것이 로깅 데코레이터입니다.

비즈니스 로직과 로깅을 완전히 분리하여, 필요한 곳에만 데코레이터를 적용하면 자동으로 로깅이 되도록 만들 수 있습니다.

개요

간단히 말해서, 로깅 데코레이터는 API 호출을 감싸서 자동으로 로그를 기록하는 래퍼입니다. 원본 API 로직은 전혀 건드리지 않고 로깅 기능만 추가하는 것이죠.

왜 이 개념이 필요한지 실무 관점에서 설명하겠습니다. 프로덕션 환경에서는 API 성능 모니터링, 에러 추적, 사용자 행동 분석이 필수입니다.

하지만 이런 코드를 비즈니스 로직에 섞으면 가독성이 떨어지고 테스트가 어려워집니다. 예를 들어, 결제 API 같은 경우에 로깅과 결제 로직이 섞이면 나중에 로깅 라이브러리를 바꾸거나 로그 포맷을 변경할 때 결제 코드를 건드려야 하는 위험이 발생합니다.

전통적인 방법과의 비교를 해보겠습니다. 기존에는 각 API 함수 시작과 끝에 console.log()나 logger.info()를 직접 작성했다면, 이제는 데코레이터로 한 번 감싸기만 하면 자동으로 로깅됩니다.

이 개념의 핵심 특징은 세 가지입니다. 첫째, 관심사의 분리(Separation of Concerns)를 실현합니다.

둘째, 재사용 가능한 로깅 로직을 만들 수 있습니다. 셋째, 로깅을 켜고 끄는 것이 데코레이터 추가/제거만으로 가능합니다.

이러한 특징들이 코드의 유지보수성을 극대화하고 팀 협업을 원활하게 만듭니다.

코드 예제

// API 서비스 인터페이스
interface ApiService {
  fetchData(endpoint: string): Promise<any>;
}

// 실제 API 서비스
class RealApiService implements ApiService {
  async fetchData(endpoint: string): Promise<any> {
    const response = await fetch(endpoint);
    return response.json();
  }
}

// 로깅 데코레이터
class LoggingApiService implements ApiService {
  constructor(private service: ApiService) {}

  async fetchData(endpoint: string): Promise<any> {
    const startTime = Date.now();
    console.log(`[요청 시작] ${endpoint}`);

    try {
      const result = await this.service.fetchData(endpoint);
      const duration = Date.now() - startTime;
      console.log(`[요청 성공] ${endpoint} (${duration}ms)`);
      return result;
    } catch (error) {
      const duration = Date.now() - startTime;
      console.error(`[요청 실패] ${endpoint} (${duration}ms)`, error);
      throw error;
    }
  }
}

// 사용 예시
let api: ApiService = new RealApiService();
api = new LoggingApiService(api); // 로깅 추가
const data = await api.fetchData('/api/users');

설명

이것이 하는 일은 API 호출 전후에 자동으로 로그를 기록하는 것입니다. 원본 API 서비스를 수정하지 않고도 모든 호출에 대한 추적이 가능해집니다.

첫 번째로, RealApiService는 실제 네트워크 요청을 담당합니다. 이 클래스는 비즈니스 로직에만 집중하며 로깅이나 모니터링에 대해서는 전혀 알지 못합니다.

이렇게 분리하는 이유는 단일 책임 원칙을 지키고, 나중에 API 구현을 바꾸더라도 로깅 로직은 영향받지 않도록 하기 위함입니다. 두 번째로, LoggingApiService가 실행되면서 원본 서비스를 감싸고 추가 기능을 제공합니다.

내부에서는 먼저 시작 시간을 기록하고, 원본 서비스의 fetchData()를 호출한 뒤, 성공하면 성공 로그와 실행 시간을 출력합니다. try-catch 블록을 사용하여 에러가 발생해도 에러 로그를 남기고 예외를 다시 던집니다.

세 번째로, 클라이언트 코드에서는 단순히 RealApiService를 LoggingApiService로 감싸기만 하면 됩니다. 이후 api.fetchData()를 호출하면 자동으로 로깅이 되지만, 클라이언트는 이를 의식할 필요가 없습니다.

최종적으로 투명한 로깅 시스템이 구축됩니다. 여러분이 이 코드를 사용하면 모든 API 호출에 대한 통일된 로깅을 얻을 수 있습니다.

성능 병목을 찾기 위해 실행 시간을 측정하거나, 에러 발생률을 추적하거나, 특정 엔드포인트의 사용 빈도를 분석하는 것이 쉬워집니다. 또한 개발 환경에서는 로깅 데코레이터를 추가하고, 프로덕션에서는 제거하거나 다른 로깅 전략으로 교체하는 것도 간단합니다.

테스트 시에는 로깅 없이 순수한 API 서비스만 테스트할 수 있어 테스트가 빨라지고 명확해집니다.

실전 팁

💡 로깅 데코레이터는 여러 겹으로 중첩할 수 있습니다. 예를 들어, 로깅 → 캐싱 → 재시도 순으로 감싸서 각 계층의 동작을 모니터링할 수 있습니다. 💡 프로덕션에서는 console.log 대신 실제 로깅 라이브러리(Winston, Pino 등)를 사용하세요. 데코레이터 내부만 수정하면 모든 곳의 로깅 방식이 바뀝니다. 💡 성능에 민감한 API는 로깅 레벨(DEBUG, INFO, ERROR)을 설정하여 필요한 정보만 기록하세요. 모든 것을 로깅하면 I/O 비용이 증가합니다. 💡 로깅 데코레이터에서 민감한 정보(비밀번호, 토큰 등)는 마스킹 처리하세요. 보안 이슈를 방지할 수 있습니다. 💡 타임스탬프, 요청 ID, 사용자 ID 등 컨텍스트 정보를 함께 로깅하면 나중에 로그를 추적하기 훨씬 쉽습니다.


3. 캐싱 데코레이터로 성능 최적화

시작하며

여러분의 애플리케이션이 같은 데이터를 반복적으로 데이터베이스에서 조회하느라 느려진 경험이 있나요? 사용자 프로필, 상품 목록, 설정 정보 같은 자주 바뀌지 않는 데이터를 매번 DB에서 가져오는 건 낭비입니다.

이런 문제는 실제 개발 현장에서 성능 병목의 주요 원인입니다. 특히 트래픽이 많은 서비스에서는 같은 쿼리가 초당 수천 번 실행되어 DB에 과부하를 주고, 응답 시간이 길어지며, 인프라 비용이 증가합니다.

바로 이럴 때 필요한 것이 캐싱 데코레이터입니다. 데이터 조회 로직을 수정하지 않고도 자동으로 결과를 캐싱하여, 같은 요청이 오면 캐시에서 즉시 반환하도록 만들 수 있습니다.

개요

간단히 말해서, 캐싱 데코레이터는 함수 호출 결과를 저장해두었다가 같은 인자로 다시 호출되면 저장된 결과를 반환하는 패턴입니다. 메모이제이션(Memoization)의 실전 응용이라고 볼 수 있죠.

왜 이 개념이 필요한지 실무 관점에서 살펴보겠습니다. API 응답 시간을 개선하고, 데이터베이스 부하를 줄이며, 외부 API 호출 비용을 절감해야 하는 상황이 많습니다.

예를 들어, 날씨 정보를 외부 API에서 가져오는 경우, 같은 도시의 날씨를 1분 내에 여러 번 요청한다면 첫 번째 요청만 실제로 API를 호출하고 나머지는 캐시에서 반환하는 것이 효율적입니다. 전통적인 방법과의 비교를 해보겠습니다.

기존에는 각 함수 내부에 캐싱 로직을 직접 구현했다면(if문으로 캐시 확인 → 있으면 반환 → 없으면 조회 후 저장), 이제는 데코레이터로 감싸기만 하면 자동으로 캐싱됩니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, TTL(Time To Live)을 설정하여 캐시 유효 기간을 관리할 수 있습니다. 둘째, 캐시 키 생성 전략을 커스터마이징할 수 있습니다.

셋째, 캐시 저장소(메모리, Redis, Memcached)를 쉽게 교체할 수 있습니다. 이러한 특징들이 유연하고 확장 가능한 캐싱 시스템을 만들어줍니다.

코드 예제

// 데이터 서비스 인터페이스
interface DataService {
  getUserById(userId: string): Promise<any>;
}

// 실제 DB 접근 서비스
class DatabaseService implements DataService {
  async getUserById(userId: string): Promise<any> {
    console.log(`DB 조회: ${userId}`);
    // 실제로는 DB 쿼리 실행
    await new Promise(resolve => setTimeout(resolve, 1000));
    return { id: userId, name: 'John Doe', email: 'john@example.com' };
  }
}

// 캐싱 데코레이터
class CachingDataService implements DataService {
  private cache = new Map<string, { data: any; expiry: number }>();
  private ttl = 60000; // 60초

  constructor(private service: DataService) {}

  async getUserById(userId: string): Promise<any> {
    const now = Date.now();
    const cached = this.cache.get(userId);

    // 캐시가 있고 유효한 경우
    if (cached && cached.expiry > now) {
      console.log(`캐시 히트: ${userId}`);
      return cached.data;
    }

    // 캐시 미스 또는 만료
    console.log(`캐시 미스: ${userId}`);
    const data = await this.service.getUserById(userId);
    this.cache.set(userId, { data, expiry: now + this.ttl });
    return data;
  }
}

// 사용 예시
let service: DataService = new DatabaseService();
service = new CachingDataService(service);

await service.getUserById('123'); // DB 조회
await service.getUserById('123'); // 캐시에서 반환 (빠름!)

설명

이것이 하는 일은 데이터 조회 결과를 메모리에 저장해두었다가 재사용하는 것입니다. 전체적인 동작을 보면, 요청이 들어오면 먼저 캐시를 확인하고, 있으면 즉시 반환하며, 없으면 실제 서비스를 호출한 후 결과를 캐시에 저장합니다.

첫 번째로, DatabaseService는 실제 데이터베이스 조회를 담당합니다. 이 클래스는 캐싱에 대해 전혀 알지 못하며 순수하게 데이터 조회 책임만 가집니다.

이렇게 하는 이유는 나중에 캐싱 전략이 바뀌거나 캐싱을 제거하더라도 데이터 조회 로직은 영향받지 않도록 하기 위함입니다. 또한 단위 테스트할 때 캐싱 없이 순수한 데이터 조회만 테스트할 수 있습니다.

두 번째로, CachingDataService가 실행되면서 Map 자료구조를 사용하여 캐시를 관리합니다. getUserById가 호출되면 먼저 현재 시간을 확인하고, 캐시에서 해당 키를 찾습니다.

내부에서는 캐시된 데이터의 만료 시간을 현재 시간과 비교하여 유효성을 판단합니다. 만약 유효한 캐시가 있다면 즉시 반환하고, 그렇지 않으면 원본 서비스를 호출합니다.

세 번째로, 원본 서비스 호출 결과를 받으면 현재 시간에 TTL을 더한 만료 시간과 함께 캐시에 저장합니다. 마지막으로 결과를 반환하여 최종적으로 클라이언트는 캐싱 여부와 관계없이 동일한 데이터를 받게 됩니다.

여러분이 이 코드를 사용하면 DB 조회가 1초 걸리는 작업이 캐시 히트 시 1ms 이내로 줄어듭니다. 같은 사용자 정보를 100번 조회하는 경우, 캐싱 없이는 100초가 걸리지만 캐싱을 적용하면 1초만 걸립니다.

DB 부하도 1/100로 줄어들어 다른 쿼리들의 성능도 개선됩니다. 또한 외부 API 호출이 유료인 경우 비용을 대폭 절감할 수 있으며, API rate limit에 걸리는 것도 방지할 수 있습니다.

서비스 간 네트워크 호출이 많은 마이크로서비스 환경에서 특히 효과적입니다.

실전 팁

💡 TTL은 데이터의 특성에 따라 다르게 설정하세요. 사용자 프로필은 길게(5-10분), 실시간 주가는 짧게(1-5초) 설정하는 것이 좋습니다. 💡 캐시 키 생성 시 함수 인자를 직렬화할 때 주의하세요. 객체를 키로 사용하면 참조가 달라 항상 캐시 미스가 발생할 수 있으니 JSON.stringify()를 사용하세요. 💡 메모리 캐시는 서버 재시작 시 사라지고 다중 서버 환경에서 공유되지 않습니다. 프로덕션에서는 Redis 같은 외부 캐시를 고려하세요. 💡 캐시 무효화(invalidation) 전략도 함께 고려하세요. 데이터가 업데이트되면 관련 캐시를 제거하는 메서드를 추가하는 것이 좋습니다. 💡 캐시 히트율을 모니터링하세요. 히트율이 낮다면 TTL이 너무 짧거나 캐시할 필요가 없는 데이터일 수 있습니다.


4. 인증 및 권한 확인 데코레이터

시작하며

여러분이 관리자 전용 API를 만들 때 모든 핸들러 함수 시작 부분에 "사용자가 로그인했는가?", "관리자 권한이 있는가?"를 체크하는 코드를 매번 작성하고 있나요? 10개, 20개의 API가 있다면 같은 인증 코드가 계속 반복될 것입니다.

이런 문제는 실제 개발 현장에서 보안과 유지보수 측면 모두에서 위험합니다. 인증 로직이 중복되면 한 곳에서 버그를 수정해도 다른 곳에는 여전히 취약점이 남을 수 있고, 인증 방식을 변경할 때(예: JWT에서 OAuth로) 모든 API를 수정해야 하는 번거로움이 있습니다.

바로 이럴 때 필요한 것이 인증 데코레이터입니다. 보안 검사 로직을 한 곳에 모아두고, 보호가 필요한 함수에만 데코레이터를 적용하면 자동으로 인증과 권한 확인이 이루어집니다.

개요

간단히 말해서, 인증 데코레이터는 함수 실행 전에 사용자의 인증 상태와 권한을 자동으로 검증하는 보안 계층입니다. 비즈니스 로직과 보안 로직을 완전히 분리하는 것이 핵심이죠.

왜 이 개념이 필요한지 실무 관점에서 설명하겠습니다. 보안은 애플리케이션의 모든 계층에서 일관되게 적용되어야 합니다.

한 곳이라도 빠뜨리면 보안 구멍이 생기죠. 예를 들어, 사용자 삭제 API, 결제 환불 API, 시스템 설정 변경 API 등 민감한 작업들은 반드시 관리자 권한을 확인해야 하는데, 각 API마다 이 로직을 작성하면 실수하기 쉽습니다.

전통적인 방법과의 비교를 해보겠습니다. 기존에는 각 API 핸들러 함수 내부에서 if (!user.isAdmin) throw new Error() 같은 코드를 직접 작성했다면, 이제는 @RequireAdmin 같은 데코레이터 하나로 해결됩니다.

이 개념의 핵심 특징은 세 가지입니다. 첫째, 선언적 보안(Declarative Security)을 구현합니다.

코드를 보면 어떤 권한이 필요한지 명확히 알 수 있죠. 둘째, DRY(Don't Repeat Yourself) 원칙을 지킵니다.

셋째, 보안 정책 변경 시 한 곳만 수정하면 됩니다. 이러한 특징들이 안전하고 유지보수하기 쉬운 애플리케이션을 만들어줍니다.

코드 예제

// 사용자 컨텍스트 (실제로는 요청에서 가져옴)
interface User {
  id: string;
  role: 'admin' | 'user' | 'guest';
}

// API 서비스 인터페이스
interface AdminService {
  deleteUser(userId: string, currentUser: User): Promise<void>;
}

// 실제 서비스
class RealAdminService implements AdminService {
  async deleteUser(userId: string, currentUser: User): Promise<void> {
    console.log(`사용자 ${userId} 삭제됨`);
    // 실제 삭제 로직
  }
}

// 인증 데코레이터
class AuthenticatedService implements AdminService {
  constructor(private service: AdminService) {}

  async deleteUser(userId: string, currentUser: User): Promise<void> {
    // 로그인 확인
    if (!currentUser || !currentUser.id) {
      throw new Error('인증이 필요합니다');
    }

    // 관리자 권한 확인
    if (currentUser.role !== 'admin') {
      throw new Error('관리자 권한이 필요합니다');
    }

    // 권한 확인 후 실제 서비스 호출
    return this.service.deleteUser(userId, currentUser);
  }
}

// 사용 예시
let service: AdminService = new RealAdminService();
service = new AuthenticatedService(service);

const adminUser: User = { id: '1', role: 'admin' };
const normalUser: User = { id: '2', role: 'user' };

await service.deleteUser('123', adminUser); // 성공
// await service.deleteUser('123', normalUser); // Error: 관리자 권한이 필요합니다

설명

이것이 하는 일은 보안 게이트 역할입니다. 전체적으로 보면, 함수가 호출되면 먼저 사용자 인증 상태를 확인하고, 필요한 권한이 있는지 검증한 후, 모든 조건을 만족할 때만 실제 비즈니스 로직을 실행합니다.

첫 번째로, RealAdminService는 순수한 비즈니스 로직만 담당합니다. 사용자 삭제라는 핵심 기능에만 집중하며, 누가 호출하는지, 권한이 있는지는 신경 쓰지 않습니다.

이렇게 하는 이유는 관심사를 분리하여 테스트를 쉽게 하고, 나중에 인증 방식이 바뀌어도 비즈니스 로직은 영향받지 않도록 하기 위함입니다. 두 번째로, AuthenticatedService가 실행되면서 보안 검사를 수행합니다.

먼저 currentUser 객체가 존재하고 유효한 ID를 가지고 있는지 확인하여 로그인 여부를 판단합니다. 내부에서는 사용자의 role 필드를 확인하여 admin인지 검증하고, 만약 권한이 부족하면 예외를 던져서 함수 실행을 중단합니다.

모든 검사를 통과한 경우에만 원본 서비스의 deleteUser를 호출합니다. 세 번째로, 클라이언트 코드에서는 AuthenticatedService로 감싸진 서비스를 사용하면 자동으로 보안이 적용됩니다.

마지막으로 관리자 사용자로 호출하면 정상 실행되고, 일반 사용자로 호출하면 예외가 발생하여 최종적으로 안전한 API가 완성됩니다. 여러분이 이 코드를 사용하면 모든 민감한 작업에 일관된 보안 정책을 적용할 수 있습니다.

새로운 관리자 API를 추가할 때마다 AuthenticatedService로 감싸기만 하면 되므로 실수로 인증을 빠뜨릴 위험이 줄어듭니다. 인증 방식을 변경할 때(예: 세션 기반에서 JWT로) AuthenticatedService만 수정하면 모든 API의 인증이 업데이트됩니다.

또한 역할 기반 접근 제어(RBAC)를 쉽게 확장할 수 있어, 예를 들어 'admin', 'moderator', 'user' 등 세분화된 권한 시스템을 구축할 수 있습니다. 감사 로그(audit log)를 추가하여 누가 언제 어떤 작업을 했는지 기록하는 것도 데코레이터에 추가하면 됩니다.

실전 팁

💡 권한 체크를 여러 단계로 나누세요. 먼저 인증 데코레이터로 로그인을 확인하고, 그 다음 권한 데코레이터로 역할을 확인하면 더 유연합니다. 💡 권한 정보를 하드코딩하지 말고 설정 파일이나 데이터베이스에서 관리하세요. 권한 구조가 복잡해질수록 중요합니다. 💡 인증 실패 시 에러 메시지를 너무 자세히 주지 마세요. "인증 실패"만으로 충분하며, 구체적인 이유(사용자 없음, 비밀번호 틀림)를 노출하면 보안 위험이 있습니다. 💡 토큰 기반 인증(JWT)을 사용한다면 토큰 만료도 데코레이터에서 확인하세요. 만료된 토큰으로 접근하는 것을 차단할 수 있습니다. 💡 단위 테스트에서는 인증 데코레이터를 제거하고 순수 비즈니스 로직만 테스트하세요. 통합 테스트에서 인증까지 함께 테스트하면 됩니다.


5. 재시도 로직 데코레이터

시작하며

여러분의 애플리케이션이 외부 API를 호출할 때 일시적인 네트워크 오류나 서버 타임아웃 때문에 실패하는 경우가 있죠? 한 번 실패했다고 바로 포기하지 않고 몇 초 후 다시 시도하면 성공할 수 있는데, 매번 재시도 로직을 작성하는 건 번거롭습니다.

이런 문제는 실제 개발 현장에서, 특히 마이크로서비스나 클라우드 환경에서 매우 흔합니다. 네트워크는 본질적으로 불안정하며, 외부 서비스도 일시적으로 과부하 상태가 될 수 있습니다.

이런 일시적 오류(transient failure)를 처리하지 않으면 사용자 경험이 나빠지고, 불필요한 에러 알림이 쏟아집니다. 바로 이럴 때 필요한 것이 재시도 데코레이터입니다.

함수 호출이 실패하면 자동으로 지정된 횟수만큼 재시도하고, 각 시도 사이에 대기 시간을 두어 지능적으로 복구를 시도합니다.

개요

간단히 말해서, 재시도 데코레이터는 함수 호출이 실패했을 때 자동으로 여러 번 재시도하는 회복 탄력성(resilience) 패턴입니다. 지수 백오프(exponential backoff)를 적용하여 점진적으로 대기 시간을 늘릴 수도 있죠.

왜 이 개념이 필요한지 실무 관점에서 살펴보겠습니다. 분산 시스템에서는 부분적 실패(partial failure)가 정상입니다.

결제 게이트웨이, 이메일 발송 서비스, 외부 데이터 API 등은 때때로 응답하지 않거나 타임아웃이 발생합니다. 예를 들어, 결제 요청이 네트워크 지연으로 실패했다면, 1초 후 재시도하면 성공할 가능성이 높습니다.

재시도 로직 없이는 사용자가 수동으로 다시 시도해야 하죠. 전통적인 방법과의 비교를 해보겠습니다.

기존에는 각 API 호출마다 for 루프와 try-catch로 재시도 로직을 직접 구현했다면, 이제는 데코레이터로 감싸기만 하면 자동으로 재시도됩니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 재시도 횟수와 대기 시간을 유연하게 설정할 수 있습니다. 둘째, 특정 에러 타입만 재시도하고 다른 에러는 즉시 실패시킬 수 있습니다(예: 404는 재시도 불필요, 503은 재시도).

셋째, 서킷 브레이커(Circuit Breaker) 패턴과 결합하여 연속 실패 시 더 이상 시도하지 않도록 할 수 있습니다. 이러한 특징들이 안정적이고 탄력적인 시스템을 만들어줍니다.

코드 예제

// 외부 API 서비스 인터페이스
interface PaymentService {
  processPayment(amount: number): Promise<string>;
}

// 불안정한 외부 서비스 (시뮬레이션)
class UnstablePaymentService implements PaymentService {
  private attempts = 0;

  async processPayment(amount: number): Promise<string> {
    this.attempts++;
    console.log(`결제 시도 #${this.attempts}`);

    // 처음 두 번은 실패하고 세 번째에 성공
    if (this.attempts < 3) {
      throw new Error('일시적 네트워크 오류');
    }

    return `결제 완료: ${amount}원`;
  }
}

// 재시도 데코레이터
class RetryPaymentService implements PaymentService {
  private maxRetries = 3;
  private delay = 1000; // 1초

  constructor(private service: PaymentService) {}

  async processPayment(amount: number): Promise<string> {
    let lastError: Error;

    for (let i = 0; i <= this.maxRetries; i++) {
      try {
        return await this.service.processPayment(amount);
      } catch (error) {
        lastError = error as Error;
        console.log(`실패 (${i + 1}/${this.maxRetries + 1}): ${error.message}`);

        // 마지막 시도가 아니면 대기 후 재시도
        if (i < this.maxRetries) {
          await new Promise(resolve => setTimeout(resolve, this.delay * (i + 1)));
        }
      }
    }

    throw lastError!;
  }
}

// 사용 예시
let service: PaymentService = new UnstablePaymentService();
service = new RetryPaymentService(service);

const result = await service.processPayment(10000);
console.log(result);

설명

이것이 하는 일은 실패한 작업을 포기하지 않고 여러 번 재시도하는 것입니다. 전체적인 동작을 보면, 함수를 호출하고 실패하면 잠시 대기한 후 다시 시도하며, 최대 재시도 횟수에 도달하거나 성공할 때까지 반복합니다.

첫 번째로, UnstablePaymentService는 실제 외부 서비스의 불안정성을 시뮬레이션합니다. 이 예제에서는 의도적으로 처음 두 번은 실패하고 세 번째에 성공하도록 만들었습니다.

이렇게 하는 이유는 실제 외부 API의 일시적 오류를 재현하여 재시도 로직이 제대로 동작하는지 보여주기 위함입니다. 두 번째로, RetryPaymentService가 실행되면서 for 루프를 사용하여 최대 4번(초기 시도 1번 + 재시도 3번) 시도합니다.

내부에서는 try-catch로 각 시도를 감싸고, 성공하면 즉시 결과를 반환하며, 실패하면 에러를 기록하고 다음 시도로 넘어갑니다. 각 재시도 사이에는 delay * (i + 1) 밀리초만큼 대기하여 지수 백오프를 구현합니다(1초, 2초, 3초...).

세 번째로, 마지막 재시도마저 실패하면 마지막으로 발생한 에러를 던집니다. 최종적으로 클라이언트는 재시도 과정을 전혀 의식하지 않고 결과만 받거나, 모든 시도가 실패했을 때만 에러를 처리하면 됩니다.

여러분이 이 코드를 사용하면 일시적 네트워크 오류로 인한 실패율을 크게 줄일 수 있습니다. 예를 들어, 외부 API의 성공률이 70%라도 3번 재시도하면 최종 성공률은 97%로 올라갑니다(1 - 0.3^4).

사용자는 간헐적인 오류를 거의 경험하지 않게 되어 UX가 개선됩니다. 또한 운영 팀은 일시적 오류와 진짜 문제를 구분할 수 있어, 불필요한 알림이 줄어들고 진짜 중요한 이슈에 집중할 수 있습니다.

클라우드 환경에서 auto-scaling이나 배포 중에 발생하는 일시적 오류도 자동으로 복구됩니다.

실전 팁

💡 지수 백오프를 사용하여 재시도 간격을 점진적으로 늘리세요. 서버에 부하를 주지 않으면서 복구 시간을 확보할 수 있습니다. 💡 모든 에러를 재시도하지 마세요. 4xx 에러(잘못된 요청, 권한 없음)는 재시도해도 소용없으니 즉시 실패시키고, 5xx나 네트워크 에러만 재시도하세요. 💡 재시도에도 타임아웃을 설정하세요. 무한정 재시도하면 리소스 낭비와 다른 작업 지연을 초래할 수 있습니다. 💡 재시도 로그를 남기되, 마지막 실패만 에러로 기록하세요. 모든 재시도를 에러로 기록하면 로그가 지저분해집니다. 💡 재시도 횟수와 간격은 환경 변수로 설정 가능하게 만드세요. 개발/스테이징/프로덕션 환경마다 다른 값을 사용할 수 있습니다.


6. 성능 모니터링 데코레이터

시작하며

여러분이 애플리케이션의 성능 병목을 찾으려고 할 때, 어떤 함수가 얼마나 오래 걸리는지 일일이 측정하고 기록하는 작업을 해본 적 있나요? 각 함수마다 Date.now()로 시작 시간을 측정하고, 끝날 때 경과 시간을 계산하는 코드를 추가하는 건 지루하고 실수하기 쉽습니다.

이런 문제는 실제 개발 현장에서 성능 최적화를 어렵게 만듭니다. 어떤 부분이 느린지 데이터 없이 추측만으로 최적화하면 잘못된 곳에 시간을 낭비할 수 있고, 성능 개선 효과를 정량적으로 측정하기도 어렵습니다.

바로 이럴 때 필요한 것이 성능 모니터링 데코레이터입니다. 함수 실행 시간, 호출 빈도, 평균/최대/최소 시간 등을 자동으로 측정하고 기록하여, 성능 병목을 쉽게 찾아낼 수 있습니다.

개요

간단히 말해서, 성능 모니터링 데코레이터는 함수의 실행 시간과 통계 정보를 자동으로 수집하는 관찰(observability) 패턴입니다. APM(Application Performance Monitoring) 도구의 기본 원리이기도 하죠.

왜 이 개념이 필요한지 실무 관점에서 설명하겠습니다. 프로덕션 환경에서는 사용자가 "느리다"고 불평하기 전에 먼저 문제를 발견하고 해결해야 합니다.

어떤 API가 평균 2초 걸리는데 목표가 500ms라면 최적화 대상입니다. 예를 들어, 상품 검색 API, 결제 처리 함수, 이미지 변환 작업 등 주요 기능의 성능을 지속적으로 모니터링하면 서비스 품질을 유지할 수 있습니다.

전통적인 방법과의 비교를 해보겠습니다. 기존에는 console.time/timeEnd나 수동으로 Date.now() 계산을 각 함수에 추가했다면, 이제는 데코레이터로 감싸기만 하면 자동으로 측정됩니다.

이 개념의 핵심 특징은 세 가지입니다. 첫째, 비침투적(non-intrusive)입니다.

비즈니스 로직을 수정하지 않고 측정할 수 있습니다. 둘째, 통계 정보를 축적하여 평균, 백분위수(percentile), 최댓값 등을 계산할 수 있습니다.

셋째, 측정 데이터를 Prometheus, Datadog 같은 모니터링 시스템으로 전송할 수 있습니다. 이러한 특징들이 데이터 기반의 성능 최적화를 가능하게 합니다.

코드 예제

// 비즈니스 서비스 인터페이스
interface SearchService {
  searchProducts(query: string): Promise<any[]>;
}

// 실제 검색 서비스
class RealSearchService implements SearchService {
  async searchProducts(query: string): Promise<any[]> {
    // 시뮬레이션: 200-800ms 걸리는 검색
    const delay = Math.random() * 600 + 200;
    await new Promise(resolve => setTimeout(resolve, delay));
    return [{ id: 1, name: `Product for ${query}` }];
  }
}

// 성능 모니터링 데코레이터
class PerformanceMonitoringService implements SearchService {
  private metrics = new Map<string, { count: number; total: number; min: number; max: number }>();

  constructor(private service: SearchService) {}

  async searchProducts(query: string): Promise<any[]> {
    const methodName = 'searchProducts';
    const startTime = performance.now();

    try {
      const result = await this.service.searchProducts(query);
      const duration = performance.now() - startTime;

      this.recordMetric(methodName, duration);
      console.log(`${methodName}: ${duration.toFixed(2)}ms`);

      return result;
    } catch (error) {
      const duration = performance.now() - startTime;
      this.recordMetric(methodName, duration);
      throw error;
    }
  }

  private recordMetric(method: string, duration: number): void {
    const existing = this.metrics.get(method) || { count: 0, total: 0, min: Infinity, max: 0 };

    this.metrics.set(method, {
      count: existing.count + 1,
      total: existing.total + duration,
      min: Math.min(existing.min, duration),
      max: Math.max(existing.max, duration)
    });
  }

  getStatistics(method: string) {
    const metric = this.metrics.get(method);
    if (!metric) return null;

    return {
      count: metric.count,
      average: metric.total / metric.count,
      min: metric.min,
      max: metric.max
    };
  }
}

// 사용 예시
let service: SearchService = new RealSearchService();
const monitoredService = new PerformanceMonitoringService(service);

for (let i = 0; i < 5; i++) {
  await monitoredService.searchProducts('laptop');
}

console.log('통계:', monitoredService.getStatistics('searchProducts'));

설명

이것이 하는 일은 함수 실행 전후의 시간을 측정하여 성능 메트릭을 수집하는 것입니다. 전체적인 동작을 보면, 함수 호출 전에 타이머를 시작하고, 함수가 완료되면 경과 시간을 계산하여 통계에 반영합니다.

첫 번째로, RealSearchService는 순수한 검색 로직만 담당합니다. 이 클래스는 자신의 성능이 측정되는지 전혀 알지 못하며, 오직 검색 기능에만 집중합니다.

이렇게 하는 이유는 성능 측정 로직과 비즈니스 로직을 분리하여, 나중에 모니터링 도구를 바꾸거나 제거해도 검색 로직은 영향받지 않도록 하기 위함입니다. 두 번째로, PerformanceMonitoringService가 실행되면서 각 메서드 호출을 감시합니다.

performance.now()를 사용하여 고정밀도 타이머로 시작 시간을 기록하고, 원본 서비스를 호출한 후 종료 시간을 측정합니다. 내부에서는 성공 여부와 관계없이 항상 메트릭을 기록하기 위해 try-catch를 사용하며, recordMetric 메서드로 호출 횟수, 총 실행 시간, 최솟값, 최댓값을 업데이트합니다.

세 번째로, Map 자료구조를 사용하여 메서드별로 독립적인 통계를 관리합니다. getStatistics 메서드는 축적된 데이터를 기반으로 평균 실행 시간을 계산하여 반환합니다.

마지막으로 클라이언트는 여러 번 함수를 호출한 후 통계를 확인하여 성능 트렌드를 파악할 수 있습니다. 여러분이 이 코드를 사용하면 어떤 함수가 성능 병목인지 명확히 알 수 있습니다.

예를 들어, searchProducts의 평균 시간이 500ms인데 다른 API는 50ms라면 searchProducts를 우선적으로 최적화해야 합니다. 또한 최댓값을 보면 간헐적으로 발생하는 느린 호출(outlier)을 발견할 수 있고, 최솟값과 비교하여 성능 편차를 분석할 수 있습니다.

최적화 작업 전후의 통계를 비교하여 개선 효과를 정량적으로 측정할 수 있으며, 이 데이터를 Grafana 같은 대시보드에 시각화하면 실시간 모니터링도 가능합니다. 프로덕션에서는 이 메트릭을 CloudWatch나 Prometheus로 전송하여 알림을 설정할 수도 있습니다.

실전 팁

💡 performance.now()는 Date.now()보다 정확합니다. 밀리초 이하 단위까지 측정 가능하며, 시스템 시계 변경의 영향을 받지 않습니다. 💡 백분위수(percentile)를 계산하세요. 평균만 보면 outlier에 의해 왜곡될 수 있으니, p50, p95, p99를 함께 보는 것이 좋습니다. 💡 프로덕션에서는 샘플링을 고려하세요. 모든 호출을 측정하면 오버헤드가 발생할 수 있으니, 10% 또는 1%만 샘플링하는 것도 방법입니다. 💡 메모리 누수에 주의하세요. metrics Map이 무한정 커질 수 있으니, 일정 시간마다 초기화하거나 슬라이딩 윈도우 방식을 사용하세요. 💡 개발 환경에서만 모니터링하거나, 프로덕션에서는 외부 APM 도구(New Relic, Datadog)를 사용하는 것도 고려하세요.


7. 데이터 변환 및 직렬화 데코레이터

시작하며

여러분이 API 응답을 JSON으로 변환하거나, 데이터베이스 모델을 DTO(Data Transfer Object)로 변환하는 작업을 반복하고 있나요? 각 API 핸들러마다 JSON.stringify()를 호출하고, 불필요한 필드를 제거하고, 날짜 포맷을 변환하는 코드를 작성하는 건 중복이 많습니다.

이런 문제는 실제 개발 현장에서 데이터 계층과 표현 계층을 분리할 때 자주 발생합니다. 내부 데이터 구조와 외부 API 응답 형식이 다른 경우, 비밀번호나 내부 ID 같은 민감한 정보를 제거해야 하는 경우, 날짜를 ISO 8601 형식으로 통일해야 하는 경우 등이 있죠.

바로 이럴 때 필요한 것이 데이터 변환 데코레이터입니다. 함수가 반환하는 데이터를 자동으로 원하는 형식으로 변환하고, 민감한 정보를 제거하며, 일관된 구조로 직렬화할 수 있습니다.

개요

간단히 말해서, 데이터 변환 데코레이터는 함수의 반환값을 자동으로 가공하여 원하는 형식으로 변환하는 파이프라인입니다. DTO 패턴의 자동화 버전이라고 볼 수 있죠.

왜 이 개념이 필요한지 실무 관점에서 살펴보겠습니다. API 응답 형식을 일관되게 유지하고, 클라이언트에게 필요한 정보만 노출하며, 내부 구현 세부사항을 숨겨야 합니다.

예를 들어, 사용자 정보를 반환할 때 데이터베이스의 모든 컬럼을 그대로 보내면 보안 위험이 있고, 프론트엔드가 기대하는 필드명과 다를 수 있습니다. 전통적인 방법과의 비교를 해보겠습니다.

기존에는 각 API 핸들러에서 수동으로 { id: user.id, name: user.name } 같은 객체를 만들었다면, 이제는 데코레이터가 자동으로 변환 규칙을 적용합니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 선언적 변환 규칙을 정의할 수 있습니다. 어떤 필드를 포함/제외할지 명시적으로 지정하죠.

둘째, 중첩된 객체나 배열도 재귀적으로 변환할 수 있습니다. 셋째, 타입 안전성을 유지하면서 변환할 수 있습니다.

이러한 특징들이 안전하고 유지보수하기 쉬운 API를 만들어줍니다.

코드 예제

// 사용자 엔티티 (데이터베이스 모델)
interface UserEntity {
  id: number;
  email: string;
  password: string; // 민감한 정보
  name: string;
  createdAt: Date;
  internalId: string; // 내부 전용
}

// 사용자 서비스 인터페이스
interface UserService {
  getUser(id: number): Promise<UserEntity>;
}

// 실제 서비스
class RealUserService implements UserService {
  async getUser(id: number): Promise<UserEntity> {
    // DB 조회 시뮬레이션
    return {
      id,
      email: 'user@example.com',
      password: 'hashed_password_123',
      name: 'John Doe',
      createdAt: new Date(),
      internalId: 'internal-uuid-456'
    };
  }
}

// 데이터 변환 데코레이터
class SerializingUserService implements UserService {
  private excludeFields = ['password', 'internalId'];

  constructor(private service: UserService) {}

  async getUser(id: number): Promise<any> {
    const user = await this.service.getUser(id);
    return this.serialize(user);
  }

  private serialize(data: any): any {
    if (data instanceof Date) {
      return data.toISOString();
    }

    if (Array.isArray(data)) {
      return data.map(item => this.serialize(item));
    }

    if (typeof data === 'object' && data !== null) {
      const result: any = {};

      for (const [key, value] of Object.entries(data)) {
        // 제외 필드는 건너뛰기
        if (this.excludeFields.includes(key)) {
          continue;
        }

        // 재귀적으로 변환
        result[key] = this.serialize(value);
      }

      return result;
    }

    return data;
  }
}

// 사용 예시
let service: UserService = new RealUserService();
service = new SerializingUserService(service);

const user = await service.getUser(123);
console.log(user); // password와 internalId 제외됨, createdAt은 ISO 문자열

설명

이것이 하는 일은 내부 데이터 구조를 외부에 노출하기 적합한 형태로 자동 변환하는 것입니다. 전체적으로 보면, 원본 데이터를 가져온 후 변환 규칙을 적용하여 안전하고 일관된 형식으로 만듭니다.

첫 번째로, RealUserService는 데이터베이스에서 완전한 사용자 엔티티를 가져옵니다. 이 서비스는 변환에 대해 전혀 알지 못하며, 순수하게 데이터 조회만 담당합니다.

이렇게 하는 이유는 데이터 접근 로직과 표현 로직을 분리하여, 같은 서비스를 다른 컨텍스트(관리자 페이지에서는 모든 필드 필요)에서 재사용할 수 있도록 하기 위함입니다. 두 번째로, SerializingUserService가 실행되면서 원본 데이터를 serialize 메서드로 처리합니다.

내부에서는 데이터 타입을 확인하여 Date 객체는 ISO 문자열로 변환하고, 배열은 각 요소를 재귀적으로 변환하며, 객체는 각 필드를 순회하면서 제외 목록에 있는 필드는 건너뜁니다. 중첩된 객체도 재귀 호출로 처리하여 깊은 구조의 데이터도 완전히 변환됩니다.

세 번째로, 변환된 결과는 password와 internalId가 제거되고, createdAt은 "2024-01-15T10:30:00.000Z" 같은 표준 형식으로 변환됩니다. 마지막으로 클라이언트는 안전하게 가공된 데이터만 받게 되어 최종적으로 보안과 일관성이 확보됩니다.

여러분이 이 코드를 사용하면 민감한 정보 노출을 자동으로 방지할 수 있습니다. password나 토큰 같은 필드를 실수로 API 응답에 포함시키는 보안 사고를 예방하죠.

API 응답 형식이 일관되어 프론트엔드 개발이 쉬워지고, 날짜 포맷이 통일되어 클라이언트에서 파싱 오류가 줄어듭니다. 또한 내부 데이터 구조를 변경해도 변환 규칙만 수정하면 API 응답은 동일하게 유지할 수 있어, 하위 호환성을 지키기 쉽습니다.

관리자 API와 일반 사용자 API에서 다른 변환 규칙을 적용하여 컨텍스트별로 다른 정보를 노출할 수도 있습니다.

실전 팁

💡 클래스 기반 변환 라이브러리(class-transformer)를 사용하면 더 강력한 변환 규칙을 선언적으로 정의할 수 있습니다. @Exclude(), @Expose() 데코레이터로 제어하세요. 💡 민감한 필드 목록을 하드코딩하지 말고 설정 파일에서 관리하세요. 새로운 민감 필드가 추가될 때 코드 수정 없이 대응할 수 있습니다. 💡 순환 참조를 조심하세요. 예를 들어, User → Post → User 같은 관계는 무한 재귀를 일으킬 수 있으니 깊이 제한을 두세요. 💡 성능이 중요하다면 변환 결과를 캐싱하세요. 같은 객체를 여러 번 직렬화하는 경우 성능 향상이 큽니다. 💡 GraphQL을 사용한다면 필드 리졸버에서 자동으로 처리되므로 이런 데코레이터가 덜 필요합니다. REST API에서 특히 유용합니다.


8. 트랜잭션 관리 데코레이터

시작하며

여러분이 데이터베이스 작업을 할 때 여러 테이블을 업데이트하다가 중간에 오류가 발생하면 어떻게 되나요? 일부는 저장되고 일부는 저장되지 않아 데이터 불일치가 발생합니다.

각 함수마다 트랜잭션 시작, 커밋, 롤백 코드를 작성하는 건 번거롭고 빠뜨리기 쉽습니다. 이런 문제는 실제 개발 현장에서 데이터 무결성을 위협하는 심각한 이슈입니다.

예를 들어, 결제 처리 중 주문은 생성되었는데 재고 차감이 실패하면 돈만 받고 상품은 안 보내는 상황이 발생할 수 있습니다. 송금 시스템에서 A 계좌에서는 돈이 빠져나갔는데 B 계좌로 들어가지 않으면 큰 문제죠.

바로 이럴 때 필요한 것이 트랜잭션 데코레이터입니다. 함수가 성공하면 자동으로 커밋하고, 실패하면 자동으로 롤백하여 원자성(Atomicity)을 보장합니다.

개요

간단히 말해서, 트랜잭션 데코레이터는 함수 실행을 데이터베이스 트랜잭션으로 감싸서 "모두 성공" 또는 "모두 실패"를 보장하는 패턴입니다. ACID 속성 중 원자성을 자동화하는 것이죠.

왜 이 개념이 필요한지 실무 관점에서 설명하겠습니다. 복잡한 비즈니스 로직은 여러 단계의 데이터 변경을 포함하며, 중간에 어느 하나라도 실패하면 전체를 취소해야 합니다.

예를 들어, 이커머스에서 주문 생성 → 재고 차감 → 포인트 적립 → 이메일 발송 예약이 한 트랜잭션 안에서 이루어져야 일관성이 유지됩니다. 전통적인 방법과의 비교를 해보겠습니다.

기존에는 각 함수 시작에서 transaction.begin(), 끝에서 transaction.commit(), catch 블록에서 transaction.rollback()을 수동으로 작성했다면, 이제는 데코레이터가 자동으로 처리합니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 선언적 트랜잭션(Declarative Transaction)을 구현합니다. 코드에서 트랜잭션 관리가 명확히 보이죠.

둘째, 중첩 트랜잭션이나 세이브포인트도 지원할 수 있습니다. 셋째, 트랜잭션 격리 수준(Isolation Level)을 설정할 수 있습니다.

이러한 특징들이 데이터 무결성을 보장하고 동시성 문제를 해결합니다.

코드 예제

// 주문 서비스 인터페이스
interface OrderService {
  createOrder(userId: string, items: Array<{ productId: string; quantity: number }>): Promise<string>;
}

// 데이터베이스 연결 (시뮬레이션)
class Database {
  private inTransaction = false;
  private operations: string[] = [];

  async beginTransaction(): Promise<void> {
    console.log('[DB] 트랜잭션 시작');
    this.inTransaction = true;
    this.operations = [];
  }

  async commit(): Promise<void> {
    console.log('[DB] 커밋:', this.operations);
    this.inTransaction = false;
    this.operations = [];
  }

  async rollback(): Promise<void> {
    console.log('[DB] 롤백:', this.operations);
    this.inTransaction = false;
    this.operations = [];
  }

  async execute(sql: string): Promise<void> {
    this.operations.push(sql);
    console.log(`[DB] 실행: ${sql}`);
  }
}

// 실제 주문 서비스
class RealOrderService implements OrderService {
  constructor(private db: Database) {}

  async createOrder(userId: string, items: any[]): Promise<string> {
    await this.db.execute(`INSERT INTO orders (user_id) VALUES ('${userId}')`);

    for (const item of items) {
      await this.db.execute(`INSERT INTO order_items (product_id, quantity) VALUES ('${item.productId}', ${item.quantity})`);
      await this.db.execute(`UPDATE products SET stock = stock - ${item.quantity} WHERE id = '${item.productId}'`);
    }

    return 'order-123';
  }
}

// 트랜잭션 데코레이터
class TransactionalOrderService implements OrderService {
  constructor(private service: OrderService, private db: Database) {}

  async createOrder(userId: string, items: any[]): Promise<string> {
    await this.db.beginTransaction();

    try {
      const result = await this.service.createOrder(userId, items);
      await this.db.commit();
      console.log('✓ 트랜잭션 성공');
      return result;
    } catch (error) {
      await this.db.rollback();
      console.log('✗ 트랜잭션 실패, 롤백 완료');
      throw error;
    }
  }
}

// 사용 예시
const db = new Database();
let service: OrderService = new RealOrderService(db);
service = new TransactionalOrderService(service, db);

const orderId = await service.createOrder('user-1', [
  { productId: 'product-1', quantity: 2 },
  { productId: 'product-2', quantity: 1 }
]);

설명

이것이 하는 일은 여러 데이터베이스 작업을 하나의 원자적 단위로 묶는 것입니다. 전체적인 동작을 보면, 함수 시작 전에 트랜잭션을 시작하고, 모든 작업이 성공하면 커밋하며, 하나라도 실패하면 모두 롤백합니다.

첫 번째로, RealOrderService는 순수한 비즈니스 로직을 구현합니다. 주문을 생성하고, 주문 항목을 추가하며, 재고를 차감하는 일련의 작업을 수행하지만 트랜잭션 관리에 대해서는 전혀 모릅니다.

이렇게 하는 이유는 비즈니스 로직을 트랜잭션 관리와 분리하여, 같은 로직을 다른 컨텍스트(배치 작업에서는 큰 트랜잭션, 실시간 API에서는 작은 트랜잭션)에서 재사용할 수 있도록 하기 위함입니다. 두 번째로, TransactionalOrderService가 실행되면서 먼저 db.beginTransaction()을 호출하여 트랜잭션을 시작합니다.

내부에서는 try-catch 블록으로 원본 서비스를 감싸서, 정상 실행되면 db.commit()으로 변경사항을 확정하고, 예외가 발생하면 db.rollback()으로 모든 변경을 취소합니다. Database 클래스는 트랜잭션 중 실행된 모든 SQL 문을 추적하여 롤백 시 어떤 작업이 취소되는지 보여줍니다.

세 번째로, 클라이언트는 트랜잭션의 존재를 의식하지 않고 서비스를 사용합니다. 마지막으로 성공 시에는 모든 데이터 변경이 영구적으로 저장되고, 실패 시에는 아무 변경도 일어나지 않은 것처럼 깔끔하게 롤백되어 최종적으로 데이터 무결성이 보장됩니다.

여러분이 이 코드를 사용하면 부분적 업데이트로 인한 데이터 불일치를 완전히 방지할 수 있습니다. 주문 생성 중 재고 차감에서 에러가 나도 주문 자체가 롤백되어 깨진 상태가 남지 않습니다.

동시성 문제도 해결됩니다. 두 사용자가 동시에 마지막 재고 1개를 주문해도, 트랜잭션 격리 수준에 따라 한 명은 성공하고 한 명은 실패하여 재고가 마이너스가 되는 일이 없습니다.

또한 복잡한 비즈니스 로직에서 트랜잭션 관리 코드가 사라져 가독성이 크게 향상되고, 트랜잭션을 빠뜨리는 실수도 줄어듭니다. 테스트 시에는 트랜잭션 데코레이터를 제거하고 비즈니스 로직만 단위 테스트할 수 있습니다.

실전 팁

💡 트랜잭션은 가능한 한 짧게 유지하세요. 긴 트랜잭션은 락을 오래 잡아 다른 요청을 블로킹하고 데드락 위험을 높입니다. 💡 읽기 전용 작업에는 트랜잭션이 불필요할 수 있습니다. 읽기 전용 트랜잭션 데코레이터를 따로 만들어 성능을 개선하세요. 💡 중첩 트랜잭션(nested transaction)을 조심하세요. 대부분의 DB는 실제로 중첩을 지원하지 않으며, 세이브포인트로 시뮬레이션합니다. 💡 외부 API 호출은 트랜잭션 안에 포함하지 마세요. 롤백할 수 없는 작업(이메일 발송, 결제 등)은 트랜잭션 커밋 후 실행하세요. 💡 ORM(TypeORM, Prisma)을 사용한다면 내장된 트랜잭션 기능을 활용하세요. 이미 데코레이터나 콜백 방식을 제공합니다.


#TypeScript#Decorator#DesignPattern#OOP#FunctionalProgramming

댓글 (0)

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