Post

디자인 패턴을 이용하여 반복되는 유사한 서비스를 유연하게 설계하기

디자인 패턴을 이용하여 반복되는 유사한 서비스를 유연하게 설계하기

1.개요

  • 반복되는 패턴의 이벤트 구현에 템플릿 메소드 패턴을 적용하여 코드의 통일성을 향상시킨다
  • Spring 프레임워크의 빈의 특성과 팩토리 패턴을 결합하여 객체 간 결합도를 낮춘다


2.배경

저희 회사에서는 소비자들의 만족도와 구매촉진을 위해 다양한 이벤트를 제공하고 있습니다. 그 중에서도 가장 인기있는 이벤트는 단연 선착순 당첨 이벤트입니다. 무료 혹은 저렴한 비용으로 매력적인 아이템을 습득할 수 있어 많은 회원 분들이 적극적으로 참여하십니다.

선착순 당첨 이벤트는 공통적으로 아래 2단계를 거칩니다

  • 1)회원의 응모 가능 여부 판단
  • 2)응모 가능 시 상품 증정

반면 이벤트 별 구체적인 프로세스는 천차만별입니다. 응모 방법의 경우 ‘하루에 특정 횟수만 응모할 수 있는 이벤트’부터 ‘포인트를 사용해야 응모 가능한 이벤트’까지 다양한 방법이 존재하며 당첨상품도 할인쿠폰, 무료음료쿠폰 등 다양한 형태가 존재합니다.


3.문제상황

분명 이벤트 간 핵심적인 로직은 동일하지만 디테일한 비즈니스 로직이 다른 상황입니다. 때문에 기존 레거시 코드는 공통된 템플릿 없이 각각의 이벤트를 따로 구현했습니다. 이는 아래 2가지 문제점을 가지고 있습니다

  • 여러 이벤트 간 공통로직이 명시적으로 드러나지 않는다
    • 별도의 문서 없이 다른 개발자가 코드를 보았을 때 직관적으로 공통로직이 보이지 않습니다
  • 구체적인 비즈니스 로직을 담고 있는 Service layer와 이를 사용하는 Controller layer가 강하게 결합되어 있다
    • 추후 기존 이벤트를 삭제하거나 신규 이벤트를 등록 할 때 Controller layer도 변경되어야 합니다. →수정에 닫혀 있음
  • Service 클래스가 지나치게 무거워지는 단점이 있음
    • 이벤트가 지속적으로 추가됨에 따라 1개의 클래스가 지나치게 많은 역할을 맡고 있음
    • 이는 테스트코드 혹은 코드 수정을 어렵게 하는 요소임

아래는 당시 상황을 재현한 psedo code입니다. 설명을 위해 간략히 적었기 때문에 위 psedo코드가 간결해 보일 수도 있습니다. 하지만 실제로는 온갖 비즈니스 로직이 섞여 있어 Service 클래스의 길이가 대단히 길고 복잡하며 많은 객체와 강하게 결합되어 있었습니다


리팩토링 이전 코드

1)비즈니스 로직을 호출하는 Controller layer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
@RequestMapping("/event")
@RequiredArgsConstructor
public class BadExampleEventController {

    private final BadExampleEventService badExampleEventService;

    @PostMapping("/eventA/apply")
    public ResponseEntity<EventResponse> applyEventA() {
        EventResponse eventResponse = badExampleEventService.applyEventA();

        return ResponseEntity.ok(eventResponse);
    }

    @PostMapping("/eventB/apply")
    public ResponseEntity<EventResponse> applyEventB() {
        EventResponse eventResponse = badExampleEventService.applyEventB();

        return ResponseEntity.ok(eventResponse);
    }

    @PostMapping("/eventC/apply")
    public ResponseEntity<EventResponse> applyEventC() {
        EventResponse eventResponse = badExampleEventService.applyEventC();

        return ResponseEntity.ok(eventResponse);
    }
}

2)비즈니스 로직을 수행하는 Service layer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class BadExampleEventService {

    public EventResponse applyEventA() {
        if (checkApplyAbleEventA()) {
            issueGoodsA();
        }
        return new EventResponse();
    }

    public EventResponse applyEventB() {
        if (checkApplyAbleEventB()) {
            issueGoodsB();
        }
        return new EventResponse();
    }

    public EventResponse applyEventC() {
        if (checkApplyAbleEventC()) {
            issueGoodsC();
        }
        return new EventResponse();
    }
}

4.리팩토링 결과

위 두 문제를 해결하기 위해 저는 ‘템플릿 메소드 패턴’ 과 ‘팩토리 패턴’을 이용하여 리팩토링을 진행하였습니다.

1)템플릿 메소드 패턴 적용

추상클래스

  • 응모과정에 꼭 필요한 추상메소드를 선언하였습니다
  • 구상클래스가 구현한 메소드를 이용하여 응모 메소드를 구현하였습니다 (아래 코드에서는 ‘applyEvent()’)

구상클래스

  • 추상클래스를 상속받으므로 추상메소드를 반드시 구현해야 합니다
  • 실제 응모에 필요한 비즈니스 로직을 구현합니다
  • protected 접근제한자로 구체적인 메소드를 은닉했습니다
    • 이에 따라 오직 추상클래스를 통해서만 구상클래스의 메소드를 호출할 수 있습니다

템플릿 메소드 패턴 적용

1) 템플릿을 정의한 추상 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class EventService {

    abstract boolean isTarget(String eventCode);

    protected abstract boolean isApplyAble();

    protected abstract void issueGoods();

    public final EventResponse applyEvent() {
        if (isApplyAble()) {
            issueGoods();
        }
        return new EventResponse();
    }
}

2) 실제 비즈니스 로직을 구현한 구상 클래스 (추상 클래스 상속)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class EventAService extends EventService {

    private final String EVENT_CODE = "EVENT_A";

    @Override
    boolean isTarget(String eventCode) {
        return eventCode.equals(EVENT_CODE);
    }

    @Override
    protected boolean isApplyAble() {
        // do business logic
        return false;
    }

    @Override
    protected void issueGoods() {
        // do business logic
    }
}

2)팩토리 패턴으로 구상클래스 주입

Factory 클래스

  • Spring framework의 문법 상 특정 클래스의 리스트의 주입을 선언하면 특정 클래스 혹은 인터페이스를 상속받는 모든 Bean을 주입받을 수 있습니다
  • 이전 추상클래스에서 강제했던 isTarget 메소드를 이용하여 eventCode에 해당하는 구상클래스를 반환합니다.
  • for문 덕분에 템플릿 메소드 패턴이 적용된 구상클래스들의 구성이 바뀌어도 팩토리 클래스는 변경이 필요 없습니다

Controller 클래스

  • 추상클래스와 Factory 클래스에만 의존합니다
  • 추상클래스의 applyEvent() 메소드만 사용하므로 구체적인 구현에는 신경쓰지 않아도 됩니다.

✅ Factory 패턴으로 구상클래스 주입

1) 구상 클래스를 리턴하는 팩토리 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
@RequiredArgsConstructor
public class EventServiceFactory {

    private final List<EventService> eventServices;

    public EventService getConcreteEventService(String eventCode) throws Exception {
        for (EventService eventService : eventServices) {
            if (eventService.isTarget(eventCode)) {
                return eventService;
            }
        }
        throw new Exception();
    }
}

2) Factory 객체를 통해 구상 클래스를 주입받는 Controller 객체

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/event")
@RequiredArgsConstructor
public class GoodExampleEventController {

    private final EventServiceFactory eventServiceFactory;

    @PostMapping("/{eventCode}/apply")
    public ResponseEntity<EventResponse> applyEventA(@PathVariable String eventCode) throws Exception {

        EventService eventService = eventServiceFactory.getConcreteEventService(eventCode);

        EventResponse eventResponse = eventService.applyEvent();

        return ResponseEntity.ok(eventResponse);
    }
}

3-1)장점

  • 템플릿 강제
    • 구상클래스는 추상메소드를 반드시 구현해야 하므로 모든 이벤트가 동일한 템플릿을 공유합니다
  • OCP 및 DIP 원칙 만족
    • Controller객체와 Factory 객체는 구상클래스의 변경에 영향을 받지 않습니다
    • Controller 객체는 추상클래스에 의존합니다
      • 덕분에 컨트롤러와 서비스 레이어 간의 결합이 느슨해졌습니다.
      • 또한 코드의 재사용성을 늘려 중복코드가 제거되었습니다
  • 특정 이벤트를 추가하거나 삭제해야 할 경우 구상 클래스 (Service 클래스)를 추가 혹은 삭제하는 것 만으로 간단히 구현이 가능합니다.

3-2)단점

이벤트 마다 배정되는 이벤트 코드는 Unique해야 한다

  • Factory 클래스에서는 for 문을 통해 구상클래스를 반환합니다. 이 때 다른 이벤트들이 동일한 이벤트코드를 가질 경우 예상하지 못한 결과를 초래하며 컴파일 단계에서는 문제를 발견할 수 없습니다
  • 저희 회사에서는 DB의 컬럼에 UNIQUE 제약조건을 걸고 관리하고 있습니다. 그러나 DB레벨의 데이터와 구상클래스 간의 관계는 필연적이지 않습니다
    • 따라서 구상클래스에 이벤트 코드를 선언할 때는 기존 코드와 중복되지 않도록 유의해야 합니다
  • (24.07) 위 문제는 Factory 클래스에서 eventServices를 주입받을 때 map을 이용하여 해결했습니다.
  • 위 방법으로 컨테이너에 빈이 등록되는 시점에 unique 제약조건을 검증할 수 있도록 개선했습니다

✅ 수정된 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class PaymentInitStrategyFactory {

    private final HashMap<String, PaymentInitStrategy> strategyMap = new HashMap<>();

    public PaymentInitStrategyFactory(List<PaymentInitStrategy> paymentInitStrategyList) {
        for (PaymentInitStrategy paymentInitStrategy : paymentInitStrategyList) {
            if (!strategyMap.containsKey(paymentInitStrategy.getEventType())) {
                strategyMap.put(paymentInitStrategy.getEventType(), paymentInitStrategy);
            } else {
                throw new IllegalArgumentException("Duplicated PaymentInitStrategy is not allowed");
            }
        }
    }

    public PaymentInitStrategy getInitStrategy(String eventType){
        return strategyMap.get(eventType);
    }
}

추후목표

  • Factory 클래스가 빈 컨테이너에 등록되는 시점에 구상클래스 간 중복되는 이벤트코드가 없는지 검증하는 로직을 추가하고자 합니다.
    • 이를 구현하면 최소한 빈 등록 시점에 문제를 발견할 수 있습니다.
This post is licensed under CC BY 4.0 by the author.