전략 패턴 실전 적용 예시

hs·2026년 4월 5일

들어가며

해당 프로젝트는 기업 내 표준 단어·용어를 사전처럼 관리하는 메타 시스템을 구축하는 프로젝트입니다.

메타 시스템이란 기업 내에서 테이블이나 컬럼을 설계할 때 데이터의 일관성을 지키기 위해 사용하는 일종의 '표준 사전'입니다. 만약 사내에서 '고객 ID'를 두고 어떤 팀은 ’cust_id’ 어떤 팀은 ’customer_no’로 제각각 명명한다면 데이터의 일관성이 무너지게 됩니다.

따라서 개발자, 데이터 분석가 등의 사용자들은 데이터베이스에 새로운 테이블, 컬럼의 생성이 필요한 경우 표준화된 단어·용어를 사용(없는 경우 단어·용어를 신청)하여 신청서를 작성하고 관리자가 해당 신청서를 검토한 후 반영하여 데이터의 일관성(표준)을 준수할 수 있도록 관리합니다.

요구사항

관리자가 신청서를 검토하는 과정에서 보완이 필요하거나 반려 사유가 있을 때, 사용자와 소통할 수 있는 창구가 필요했습니다. 이를 위해 게시판의 댓글처럼 신청 내역에 의견을 남기는 기능을 구현했습니다.

핵심 요구사항

  • 단일 UI 컴포넌트: 프론트엔드에서는 UI 일관성과 재사용성을 위해 단일 의견(댓글) 컴포넌트를 사용합니다.
  • 5가지의 도메인 타겟: 백엔드 관점에서는 의견이 달리는 대상이 5가지(신청서, 테이블, 컬럼, 용어, 단어)로 구분되어 있습니다.
  • 도메인별 맞춤형 알림 발송: 의견이 등록(생성)되면 관련 담당자에게 알림을 발송합니다. 댓글이 달린 도메인에 따라 알림의 종류, 메시지 내용, 수신자 목록을 구성하는 로직이 모두 다릅니다.
  • 시스템 확장성 (이벤트 및 도메인 추가): 향후 기획에 따라 '수정' 및 '삭제' 시에도 알림을 발송할 수도 있고, 시스템이 고도화됨에 따라 새로운 관리 대상 도메인이 추가될 가능성도 있었습니다.

위 요구사항을 고려했을 때 떠오르는 두 가지 방법이 있었습니다.

해결책

방법 1. 분기문(if-else / switch) 처리

가장 직관적인 방법은 서비스 로직 내에서 파라미터로 넘어온 값들을 확인하여 분기 처리를 하는 것입니다.

// 분기문 접근 시 예상되는 구조
public void sendCommentAlrm(ReqCommentEvent event, ReqCommentModel comment) {
    String type = comment.getType();
    
    // 1 depth: 이벤트(생성/수정/삭제) 분기
    if (ReqCommentEvent.CREATED.equals(event)) {
        
        // 2 depth: 도메인(컬럼/테이블/신청서 등) 분기
        if ("col".equals(type)) {
            // 컬럼 도메인 등록 알림 로직...
        } else if ("tbl".equals(type)) {
            // 테이블 도메인 등록 알림 로직...
        } // ... 
        
    } else if (ReqCommentEvent.UPDATED.equals(event)) {
        
        if ("col".equals(type)) {
            // 컬럼 도메인 수정 알림 로직...
        } // ...
        
    }
    // ... 이벤트와 도메인이 결합되어 끝없이 길어지는 중첩 조건문
}

이처럼 다수의 도메인 타입과 이벤트가 결합되면 조건문이 깊게 중첩되어 코드의 복잡도가 크게 증가합니다. 이는 곧 다음과 같은 구조적인 단점으로 이어집니다.

  • OCP(개방-폐쇄 원칙) 위배: 향후 새로운 대상 도메인이 추가되거나 조건이 변경될 때마다 핵심 서비스 클래스의 코드를 직접 찾아 수정해야 합니다. 기능 확장이 일어날 때마다 기존 코드를 건드려야 하므로 시스템의 불안정성이 높아집니다.
  • SRP(단일 책임 원칙) 위배 및 높은 결합도: 단일 서비스 클래스가 모든 도메인과 이벤트에 대한 구체적인 데이터 조회 방식과 비즈니스 로직을 전부 떠안게 되어 유지보수가 어려워집니다.

방법 2. 전략 패턴(Strategy Pattern)

실제 구현으로 채택한 방법으로 다형성을 활용하여 각 타입별 알림 생성 로직을 독립적인 클래스로 분리하고, 실행 시점에 적절한 구현체를 찾아 실행하는 전략 패턴을 사용하는 것입니다.

  • 인터페이스 정의: 먼저 알림 발송에 필요한 데이터(Payload)를 생성하는 공통 인터페이스를 정의합니다.
    public interface ReqCommentAlrmResolver {
        ReqCommentType supportsType(); // 지원하는 대상 타입 (req, tbl, col 등)
        ReqCommentEvent supportsEvent(); // 지원하는 이벤트 (생성, 수정, 삭제)
        ReqCommentAlrmPayload resolve(ReqCommentModel comment); // 실제 알림 페이로드 조립
    }
  • 전략 구현체 분리: 각 도메인(컬럼, 테이블, 신청서 등)에 맞는 구체적인 로직을 각각의 구현체로 격리합니다.
    // 컬럼 도메인 구현체
    @Component
    @RequiredArgsConstructor
    public class ColumnCommentCreatedAlrmResolver implements ReqCommentAlrmResolver {
        private final ReqCommentAlrmUserService reqCommentAlrmUserService;
    
        @Override
        public ReqCommentAlrmPayload resolve(ReqCommentModel comment) {
            return ReqCommentAlrmPayload.builder()
                    .alrmType(CommonConstants.ALRM.STD_REVIEW_COL_COMMENT_REG) // 컬럼 전용 알림 타입 지정
                    .alarmParam(ReqCommentAlrmParamService.buildParam(comment))
                    .receivers(reqCommentAlrmUserService.getReceivers(comment))
                    .build();
        }
    
        @Override
        public ReqCommentEvent supportsEvent() { return ReqCommentEvent.CREATED; }
    
        @Override
        public ReqCommentType supportsType() { return ReqCommentType.col; }
    }
    신청서(Request) 타입의 경우에는 신청서 상세 정보를 조회하는 등 복잡한 과정을 거치게 되는데, 이러한 도메인 특화 로직이 전략 클래스 내부로 안전하게 격리됩니다.
  • Registry(매니저) 도입: 여러 개의 전략 클래스 중 현재 상황(타입, 이벤트)에 딱 맞는 전략을 찾아줄 Registry 클래스를 만듭니다. 스프링의 @Component를 통해 List<ReqCommentAlrmResolver>로 모든 전략 구현체를 한 번에 주입받은 뒤, 빠른 조회를 위해 EnumMap에 캐싱해둡니다.
    @Component
    public class ReqCommentArlmResolverRegistry {
        private final EnumMap<ReqCommentType, EnumMap<ReqCommentEvent, ReqCommentAlrmResolver>> registry;
    
        public ReqCommentArlmResolverRegistry(List<ReqCommentAlrmResolver> resolvers) {
            registry = new EnumMap<>(ReqCommentType.class);
    
            // 등록된 모든 전략을 순회하며 EnumMap에 매핑
            for (ReqCommentAlrmResolver r : resolvers) {
                registry.computeIfAbsent(r.supportsType(), t -> new EnumMap<>(ReqCommentEvent.class));
                var byEvent = registry.get(r.supportsType());
                byEvent.put(r.supportsEvent(), r);
            }
        }
    
        // 타입과 이벤트에 맞는 전략을 O(1)로 반환
        public ReqCommentAlrmPayload resolve(ReqCommentType type, ReqCommentEvent event, ReqCommentModel c) {
            return registry.get(type).get(event).resolve(c);
        }
    }

if 문이 없는 비즈니스 로직

실제 알림 발송을 통제하는 ReqCommentAlrmService에서는 if-else 분기문이 사라졌습니다.

@Service
@RequiredArgsConstructor
public class ReqCommentAlrmService {
    private final AlrmService alrmService;
    private final ReqCommentArlmResolverRegistry registry;

    private void send(ReqCommentEvent event, ReqCommentModel comment) {
        ReqCommentType type = ReqCommentType.fromCode(comment.getType());
        
        // Registry에게 알림 데이터(Payload) 조립을 위임 (다형성 활용)
        ReqCommentAlrmPayload payload = registry.resolve(type, event, comment);
        
        // 조립된 페이로드로 공통 알림 발송
        alrmService.sendDpAlarm(payload.getAlrmType(), null, payload.getAlarmParam(), payload.getReceivers());
    }

    public void sendCreateCommentAlrm(ReqCommentModel c) { 
        send(ReqCommentEvent.CREATED, c); 
    }
}

마무리

복잡하게 얽혀있던 분기 로직을 전략 패턴으로 리팩토링하여 각 도메인과 로직의 결합도를 획기적으로 낮출 수 있었습니다. '새로운 신청 도메인'이 생기거나 '댓글 수정/삭제 알림' 등의 요구사항이 추가되더라도 기존 코드를 수정할 필요가 없어졌습니다.ReqCommentAlrmResolver 인터페이스를 구현한 새로운 클래스 하나만 추가하면, 스프링에서 알아서 빈으로 등록하고 Registry가 이를 매핑해 주기 때문입니다. 객체지향 설계의 핵심 원칙을 실무에 적용해 시스템의 유연성을 확보한 뜻깊은 경험이었습니다.

profile
sh

0개의 댓글