[알아두면 쓸데 있는 스프링 잡기술] 전략 패턴과 위임 패턴으로 해결하는 다중 빈 주입

3

간단하게 요구사항 정리부터 들어가보자.

발권 시스템 요구사항

1. 발권 기능

  • 시스템은 발권(ticketing) 기능을 제공해야 한다.
  • 발권은 티켓을 발급하는 프로세스를 의미한다.

2. 발권 유형

발권에는 두 가지 유형이 존재한다.

  • 자동 발권 (Automatic Ticketing)
    - 시스템이 조건에 따라 자동으로 발권을 수행한다.
    - 별도의 사용자 개입 없이 발권이 진행된다.
  • 수동 발권 (Manual Ticketing)
    - 사용자가 명시적으로 발권을 요청하여 수동으로 진행된다.
    - 발권 시점과 조건을 사용자가 직접 제어한다.

3. 구현 방향

발권 유형에 따라 발권 방식을 다르게 처리할 수 있도록, 전략 패턴(Strategy Pattern)을 적용한다.

interface TicketingStrategyV1 {
    fun ticketing()
}

@Service
class AutomaticTicketingStrategy : TicketingStrategyV1 {
    override fun ticketing() {
    }
}

@Service
class ManualTicketingStrategy : TicketingStrategyV1 {
    override fun ticketing() {
    }
}

@Service
class TicketingService(
    private val ticketingStrategy: TicketingStrategyV1,
) {
    // ....
}

TicketingStrategyV1 : 발권 로직을 정의하는 전략 패턴(Strategy Pattern) 기반 인터페이스
AutomaticTicketingStrategy : 자동 발권 로직을 작성할 클래스
ManualTicketingStrategy : 수동 발권 로직을 작성할 클래스
TicketingService : 요청으로부터 티켓을 발권하는 로직을 작성할 클래스

TicketingStrategyV1 인터페이스는 엄밀히 말하면 Strategy Pattern 보다는 Composite Pattern 에 더 부합한다. TicketingStrategyV2 에서 전략 패턴으로 변경해보겠다.

4. 문제점

스프링을 다루시는분들은 벌써 눈치채셨겠지만 TicketingService 의 DI는 이뤄지지 않는다.
TicketingStrategyV1 주입을 시도하였지만 서브 클래싱된 구현체가 2개 존재하기 때문이다.

@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class ApplicationTests(
    private val applicationContext: ApplicationContext,
) {
    @Test
    fun ticketingStrategyV1Check() {
        val beans = applicationContext.getBeansOfType(TicketingStrategyV1::class.java)
        assertThat(beans)
            .hasSize(2)
            .satisfies({ it ->
                it["automaticTicketingStrategy"]?.let { assertThat(it).isInstanceOf(AutomaticTicketingStrategy::class.java) }
                it["manualTicketingStrategy"]?.let { assertThat(it).isInstanceOf(ManualTicketingStrategy::class.java) }
            })
    }

    @Test
    @DisplayName("NoUniqueBeanDefinitionException 발생")
    fun noUniqueBeanDefinitionException() {
        assertThatThrownBy { applicationContext.getBean(TicketingStrategyV1::class.java) }
            .isInstanceOf(NoUniqueBeanDefinitionException::class.java)
    }
}

5. 해결 방법

5.1 명시적으로 타입을 지정해서 DI

@Service
class TicketingService(
    private val automaticTicketingStrategy: AutomaticTicketingStrategy,
    private val manualTicketingStrategy: ManualTicketingStrategy,
) {
    // ....
}
  1. 문제점 및 한계
    • DIP(Dependency Inversion Principle, 의존성 역전 원칙) 위반
      • TicketingService가 추상(TicketingStrategyV1)이 아닌 구체 클래스(AutomaticTicketingStrategy, ManualTicketingStrategy)에 의존한다.
      • 구현이 변경되거나 추가될 때 TicketingService도 함께 수정해야 한다.
    • ISP(Interface Segregation Principle, 인터페이스 분리 원칙) 위반
      • TicketingService는 각각의 구체 전략 클래스에 대한 상세한 지식을 갖게 된다.
      • 필요 이상으로 많은 구현 세부사항에 의존하게 되어, 인터페이스의 분리 원칙을 위반한다.
      • 확장성 및 테스트 용이성 저하
      • 새로운 전략 추가 시, TicketingService 생성자나 로직을 직접 수정해야 한다.
      • 테스트 시 Mock 객체를 주입하기가 어려워진다.

5.2 Delegate Pattern + Application Bean 설정

class DelegatingTicketingStrategy(
    private val delegates: List<TicketingStrategyV1>,
) : TicketingStrategyV1 {
    override fun ticketing() {
        delegates.forEach { it.ticketing() }
    }
}

@Bean
fun ticketingStrategyV1(
	automaticTicketingStrategy: AutomaticTicketingStrategy,
	manualTicketingStrategy: ManualTicketingStrategy,
): TicketingStrategyV1 = DelegatingTicketingStrategy(listOf(automaticTicketingStrategy, manualTicketingStrategy))
  1. 장점
    • TicketingService는 이제 구체 클래스가 아닌 인터페이스(TicketingStrategyV1)에만 의존하게 되어 DIP를 준수한다.
    • DelegatingTicketingStrategy 안에서 여러 발권 전략들을 통합 관리할 수 있다.
    • TicketingService는 발권 전략의 세부 구현을 몰라도 되므로 캡슐화가 향상된다.
  2. 문제점 및 한계
  • OCP(Open-Closed Principle, 개방-폐쇄 원칙) 미달성
    OCP는 “기존 코드를 수정하지 않고 확장할 수 있어야 한다”는 원칙인데, 여기서는 새로운 전략을 추가하려면 기존 설정 코드를 반드시 수정해야 하므로 OCP를 달성하지 못한 것이다. 새로운 발권 전략을 추가하고 싶을 때, 새로운 TicketingStrategyV1 구현체를 만들고, DelegatingTicketingStrategy에 명시적으로 추가해야 한다. 즉, 기존의 ticketingStrategyV1() Bean 설정 코드(listOf(...))를 수정해야 한다.

5.3 스프링 @Primary 활용

@Service
@Primary
class DelegatingTicketingStrategy(
    private val delegates: List<TicketingStrategyV1>,
) : TicketingStrategyV1 {
    override fun ticketing() {
        delegates.forEach { it.ticketing() }
    }
}

DelegatingTicketingStrategy를 @Primary로 등록하면, 스프링 DI 메커니즘은 TicketingStrategyV1 타입을 주입할 때 우선적으로 DelegatingTicketingStrategy를 선택한다.
이때, List<TicketingStrategyV1>로 주입받은 전략 리스트에는 자기 자신(DelegatingTicketingStrategy) 은 포함되지 않고,
실제 구현체(AutomaticTicketingStrategy, ManualTicketingStrategy)만 주입된다. 스프링은 DI 주입 시, 순환 참조를 방지하기 위해 같은 타입의 자기 자신을 주입 리스트에서 자동으로 제외하는 특성이 있다. 결과적으로, 새로운 전략 클래스가 추가되더라도, 별도로 DelegatingTicketingStrategy나 TicketingService의 코드를 수정할 필요가 없다. 새 전략 클래스를 @Service로 등록하기만 하면 된다.

SOLID 원칙 준수

  • DIP: 추상화에 의존
  • OCP: 새로운 전략 추가 시 기존 코드 수정 불필요
  • SRP: 각 전략이 단일 책임을 가짐
  • ISP: 인터페이스 분리가 잘 이루어짐
  • LSP: 전략 구현체가 인터페이스 계약을 지킴

추가 팁

delegate가 아니라 Method Chaining Pattern, Composite Pattern 등 사용할 때 순서를 보장하고 싶다면 @Order annoation을 사용하면 된다.

@Service
@Order(1)  // 우선순위 지정
class AutomaticTicketingStrategy : TicketingStrategyV1 {
    override fun ticketing() {
        // 자동 발권 로직
    }
}

@Service
@Order(2)  // 후순위 지정
class ManualTicketingStrategy : TicketingStrategyV1 {
    override fun ticketing() {
        // 수동 발권 로직
    }
}

위임패턴은 개인적으로는 자주 사용하는 디자인 패턴 중 하나이다.
위임패턴과 스프링의 DI 메커니즘을 통해 어떻게 객체지향의 원칙들을 준수하면서 개발 할 수 있는지에 대해 정리해보았다.
다음엔 전략 패턴에 의거하여 TicketingStrategyV2로 최적화할 여러 방법들을 정리해보겠다.

후속편

0개의 댓글