디자인 패턴, 전략(Strategy Pattern)
개발을 하다 보면 처음에는 단순했던 기능이 점점 복잡해지는 경우가 많습니다. 특히 알림 발송 기능을 개발할 때 이런 경험을 자주 하게 됩니다. 지난 프로젝트에서는 회원 가입, 비밀번호 찾기, 마케팅 메시지 발송, 관리자 알림 등 다양한 상황에서…
개발을 하다 보면 처음에는 단순했던 기능이 점점 복잡해지는 경우가 많습니다. 특히 알림 발송 기능을 개발할 때 이런 경험을 자주 하게 됩니다.
지난 프로젝트에서는 회원 가입, 비밀번호 찾기, 마케팅 메시지 발송, 관리자 알림 등 다양한 상황에서 메시지를 발송해야 했습니다. 처음에는 단순하게 SMS만 전송하면 되었기 때문에 별다른 고민 없이 구현했습니다.
class NotificationService {
async send(phone: string, message: string) {
return this.smsService.send(phone, message);
}
}당시에는 문제 없어 보였습니다. 하지만 서비스가 커지면서 요구사항이 계속 추가되기 시작했습니다.
회원 가입 인증은 SMS로 보내고, 마케팅 메시지는 카카오 알림톡으로 보내고, 관리자 장애 알림은 이메일로 보내야 했습니다.
처음에는 조건문으로 처리했습니다.
class NotificationService {
async send(type: string, target: string, message: string) {
if (type === 'sms') {
return this.smsService.send(target, message);
}
if (type === 'kakao') {
return this.kakaoService.send(target, message);
}
if (type === 'email') {
return this.emailService.send(target, message);
}
}
}개발 당시에는 빠르게 기능을 추가할 수 있어서 괜찮아 보였지만 시간이 지나면서 문제가 생기기 시작했습니다.
새로운 발송 채널이 추가될 때마다 NotificationService를 수정해야 했고, 발송 실패 처리나 재시도 로직도 채널마다 달라지면서 코드가 점점 비대해졌습니다.
특히 운영 중이던 프로젝트에서 문자 발송 업체를 변경해야 했던 적이 있었습니다.
기존에는 한 업체의 API만 사용하고 있었는데 비용 문제와 발송 성공률 이슈로 인해 다른 업체를 함께 사용해야 했습니다. 이때 조건문이 여러 곳에 흩어져 있어서 수정 범위를 찾는 것만 해도 시간이 꽤 걸렸습니다.
그때 적용했던 것이 전략 패턴이었습니다.
전략 패턴은 동일한 목적을 가진 여러 알고리즘을 각각 독립적인 클래스로 분리하고 실행 시점에 필요한 구현체를 선택하는 패턴입니다.
먼저 공통 인터페이스를 정의했습니다.
export interface NotificationStrategy {
send(target: string, message: string): Promise<void>;
}SMS 발송 전략입니다.
export class SmsStrategy implements NotificationStrategy {
async send(target: string, message: string) {
return this.smsProvider.send(target, message);
}
}카카오 알림톡 발송 전략입니다.
export class KakaoStrategy implements NotificationStrategy {
async send(target: string, message: string) {
return this.kakaoProvider.send(target, message);
}
}이메일 발송 전략도 동일하게 구현할 수 있습니다.
export class EmailStrategy implements NotificationStrategy {
async send(target: string, message: string) {
return this.mailer.send(target, message);
}
}그리고 실제 서비스에서는 전략 객체만 교체하도록 구성했습니다.
class NotificationService {
constructor(
private readonly strategy: NotificationStrategy,
) {}
async send(target: string, message: string) {
return this.strategy.send(target, message);
}
}이 구조로 변경한 이후 가장 좋았던 점은 새로운 채널이 추가되어도 기존 코드를 수정할 필요가 없었다는 점입니다.
예를 들어 비즈뿌리오 알림톡을 추가해야 한다면 새로운 전략 클래스만 만들면 됩니다.
export class BizppurioStrategy implements NotificationStrategy {
async send(target: string, message: string) {
return this.bizppurio.send(target, message);
}
}기존 NotificationService는 단 한 줄도 수정하지 않습니다.
실제 운영 환경에서는 발송 채널 선택도 전략 패턴으로 처리했습니다.
switch (notificationType) {
case 'signup':
strategy = new SmsStrategy();
break;
case 'marketing':
strategy = new KakaoStrategy();
break;
case 'admin':
strategy = new EmailStrategy();
break;
}이후에는 NestJS의 DI 컨테이너를 이용해 팩토리와 조합하여 관리하도록 개선했습니다.
운영하면서 특히 도움이 되었던 부분은 장애 대응이었습니다.
예전에 SMS 업체 장애가 발생한 적이 있었는데 전략 구조로 변경한 이후에는 발송 전략만 교체하여 임시 대응이 가능했습니다.
만약 조건문 기반 구조였다면 여러 서비스 로직을 수정하고 테스트해야 했겠지만 전략 패턴 구조에서는 새로운 구현체를 추가하고 설정만 변경하면 되었습니다.
전략 패턴은 단순히 if문을 없애기 위한 패턴이 아닙니다. 변화가 자주 발생하는 비즈니스 로직을 안정적으로 관리하기 위한 설계 방법에 가깝습니다.
결제 수단 선택, 알림 발송, 할인 정책, 인증 방식, 파일 저장소 선택(S3, Local, MinIO) 등 여러 구현 방식이 존재하는 기능이라면 전략 패턴을 적극적으로 고려해볼 만합니다.
실제 서비스 개발에서는 기능을 만드는 것보다 변경에 대응하는 시간이 훨씬 많습니다. 전략 패턴은 미래의 변경 비용을 줄여주는 대표적인 디자인 패턴 중 하나라고 생각합니다.
코드와 비즈니스, 그 사이에서 배운 것들을 기록하고 공유합니다.