Dynamic Factory Pattern 을 이용한 리팩토링

김동훈·2024년 6월 9일
0
post-thumbnail

내가 마주한 문제를 해결 하기 위해 고려한 디자인 패턴은 총 3가지로 Dynamic Factory Pattern, Strategy Pattern, Facade Pattern 이 있었다.
각각의 패턴에 대해서는 이미 너무 많은 정보가 있기 때문에 과감히 생략한다. 이제부터 얘기할 문제상황에 대해 읽기전에 위 3가지 패턴의 어떤 특징으로 인해 어떤 상황속에서 유용하게 쓰일지 한 번 고민해보면 좋을 것 같다.


어떤 도메인으로 예시를 들까하다가, 카페 알바생 분이 주스를 만드는 모습을 보고 Cafe 도메인으로 구성해보려한다.

먼저 실제 코드에서는 Dynamic Factory Pattern + Facade Pattern의 조합으로 리팩토링을 진행했지만, 실제 도메인 (문제점)을 Cafe도메인으로 완벽히 설명할 수 없는 점으로 인해 이 글 에서는 Dynamic Factory Pattern만을 적용해보겠다.

Cafe Domain

[1] 카페에 가면 키오스크를 통해 음료를 주문하고 주문번호를 받는다. [1-1] 이 때 1개 또는 N개의 음료를 선택 후 주문한다.
[2] 주문이 접수되면 직원은 주문을 처리하기 시작한다. [2-1] 직원은 각 음료에 맞는 제조법에 따라 제조한다.[3] 주문번호를 통해 주문정보를 확인한다. [4] 제조가 완료되면 각 음료는 음료에 맞는 컵에 담겨 손님에게 전달될 것이다.

위 도메인을 웹서비스로 풀어서 얘기해보자.

[1] 음료를 주문하는 POST API가 된다. /api/beverages/order
[1-1] 각자의 선택(취향?) 이겠지만, 나는 2개의 api로 분리한다. /order, /bulk-order
[2] api를 서버가 받은 후 처리하는 service 로직이 된다. fun executeOrder() {}
[2-1] 각 음료를 만드는 servcice 로직이다. fun makeCoffee(), fun makeJuice()
[3] 해당 주문의 정보를 확인하는 GET API이다. /api/beverages/order/{orderId}
[4] controller단의 응답구조가 된다. BeverageReponse()

코드로도 확인해보자


@RestController()
@RequestMapping("/api/cafe")
class CafeController(
    private val cafeService: CafeService,
) {
    @PostMapping("/order")
    fun order(
        @RequestBody() request: OrderRequest,
    ): Cup {
        val beverage = cafeService.executeOrder(request.type)
        return beverage
    }

    @GetMapping("/order/{orderId}")
    fun getOrder(
        @PathVariable("orderId") orderId: Long,
    ): Cup {
        return cafeService.getOrder(orderId)
    }
}

data class OrderRequest(
    val type: BeverageType,
)

@Service
class CafeService(
    private val repository: CafeRepository,
    private val coffeeRepository: CoffeeRepository,
    private val juiceRepository: JuiceRepository,
    private val smoothieRepository: SmoothieRepository,
) {

    fun executeOrder(type: BeverageType): Cup {
        if (type == BeverageType.COFFEE) return makeCoffee()
        if (type == BeverageType.JUICE) return makeJuice()
        if (type == BeverageType.SMOOTHIE) return makeSmoothie()

        throw RuntimeException()
    }

    fun makeCoffee(): CoffeeCup {

        val coffee = CoffeeCup("Coffee", 300, BeverageType.COFFEE)
        return coffeeRepository.save(coffee)
    }

    fun makeJuice(): JuiceCup {
        val juice = JuiceCup("Juice", 400, BeverageType.JUICE)
        return juiceRepository.save(juice)
    }

    fun makeSmoothie(): SmoothieCup {
        val smoothie = SmoothieCup("Smoothie", 500, BeverageType.SMOOTHIE)
        return smoothieRepository.save(smoothie)
    }

    fun getOrder(orderId: Long): Cup {
        val beverage = repository.findById(orderId).orElseThrow { throw RuntimeException() }
        // 여기서는 cast 를 통해 Entity를 직접 내려주지만 실제로는 DTO클래스로의 변환을 의미한다.
        if (beverage.type == BeverageType.COFFEE) return beverage as CoffeeCup
        if (beverage.type == BeverageType.JUICE) return beverage as JuiceCup
        if (beverage.type == BeverageType.SMOOTHIE) return beverage as SmoothieCup
        throw RuntimeException("Unknown type")
    }
}

enum class BeverageType {
    COFFEE,
    JUICE,
    SMOOTHIE
}
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "dtype")
open class Cup(
    open var isTakeOut: Boolean,
    open val type:BeverageType
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    open var id: Long? = null
}

// Cup 클래스를 상속받는 CoffeeCup 데이터 클래스를 정의
@Entity
@DiscriminatorValue("coffee")
open class CoffeeCup(
    var memo: String,
    val size: Int,
    type: BeverageType,
) : Cup(true, type)


@Entity
@DiscriminatorValue("juice")
open class JuiceCup(
    var memo: String,
    val size: Int,
    type:BeverageType
) : Cup(true, type)


@Entity
@DiscriminatorValue("smoothie")
open class SmoothieCup(
    var memo: String,
    val size: Int,
    type: BeverageType,
) : Cup(false, type)

개선사항

위 service는 OCP를 만족할까?

우선 확장에 대해서 열려있지 않다고 생각했다. 신 메뉴가 출시되면 예를 들어 Latte 가 출시된다면 makeLatte() 라는 메서드는 당연히 새롭게 생성되어야 한다. 하지만 executeOrder() 메서드에서도 if조건문을 통해 makeLatte() 를 호출해야한다. 또한 makeLatte() 호출 결과를 LatteDTO ( 위 코드에서는 beverage as LatteCup 이 될 것이다) 로 변환 후 응답해야 한다.

여기서 3가지 문제가 발생할 수 있다고 본다.
1. if 조건문의 조건식 에러 -> 잘못된 BeverageType 비교
2. casting 에러 -> 상위클래스 로의 캐스팅이라서 컴파일에러가 발생하지 않을 것
3. executeOrder 메서드에 로직 추가 빠짐

위 문제점은 test code를 작성하면 적절하게 해소할 수는 있다.

하지만 executeOrder 메서드에 라떼 메뉴가 출시되면 makeLatte() 를 호출해야하는 구조를 개선하고자 했다. 또한 주문표를 통해 주문상태를 확인하는 GET api에 문제가 있다. api를 호출해보면 아래 쿼리를 발견할 수 있다.

select
    c1_0.id,
    c1_0.dtype,
    c1_0.is_take_out,
    c1_0.type,
    c1_1.memo,
    c1_1.size,
    c1_2.memo,
    c1_2.size,
    c1_3.memo,
    c1_3.size 
from
    Cup c1_0 
left join
    Coffee_cup c1_1 
        on c1_0.id=c1_1.id 
left join
    Juice_cup c1_2 
        on c1_0.id=c1_2.id 
left join
    Smoothie_cup c1_3 
        on c1_0.id=c1_3.id 
where
    c1_0.id=?

그럼 왜 이렇게 join절이 많이 발생했을까??

CoffeeCup, JuiceCup, SmoothieCup 등의 entity는 Cup이라는 상위 Entity를 상속받고 있다. 하지만 서버에서는 10번 이라는 orderId 만이 들어왔을 때, 어느 repository, 어느 table에서 10번 tuple을 찾아야 하는 지 알 수 없다. 그래서 모든 Cup 의 하위 Entity와 join 쿼리로 수행하게 된다. join 절이 하위 entity의 수 만큼 발생하고 있다. 앞으로 신 메뉴가 출시될 수록 join되는 테이블의 수는 더 많이 질 것이다. 이 쿼리는 구체적인 Cup 종류를 알 수 있다면(전달받는 다면) 최적화 시킬 수 있다.

그럼 이제 Dynamic Factory Pattern 을 적용해보자.

Dynamic Factory Pattern 리팩토링

@Service
class CoffeeService(
    private val coffeeRepository: CoffeeRepository,
) : BeverageService {
    companion object {
        const val COFFEE_CAPACITY = 300
    }

    // ... some other methods specific to coffee...

    override fun getBeverageType(): BeverageType {
        return BeverageType.COFFEE
    }

    override fun makeBeverage(): CoffeeCup {
        val coffee = CoffeeCup("Coffee", COFFEE_CAPACITY, BeverageType.COFFEE)
        return coffeeRepository.save(coffee)
    }

    override fun getOrder(orderId: Long): Cup {
        return coffeeRepository.findById(orderId).orElseThrow { throw RuntimeException() }
    }
}

@Service
class JuiceService(
    private val juiceRepository: JuiceRepository,
) : BeverageService {
    companion object {
        const val JUICE_CAPACITY = 400
    }

    // ... some other methods specific to juice ...

    override fun getBeverageType(): BeverageType {
        return BeverageType.JUICE
    }

    override fun makeBeverage(): JuiceCup {
        val juice = JuiceCup("Juice", JUICE_CAPACITY, BeverageType.JUICE)
        return juiceRepository.save(juice)
    }

    override fun getOrder(orderId: Long): Cup {
        return juiceRepository.findById(orderId).orElseThrow { throw RuntimeException() }
    }
}

@Service
class SmoothieService(
    private val smoothieRepository: SmoothieRepository,
) : BeverageService {
    companion object {
        const val SMOOTHIE_CAPACITY = 500
    }

    // ... some other methods specific to smoothie ...

    override fun getBeverageType(): BeverageType {
        return BeverageType.SMOOTHIE
    }

    override fun makeBeverage(): SmoothieCup {
        val smoothie = SmoothieCup("Smoothie", SMOOTHIE_CAPACITY, BeverageType.SMOOTHIE)
        return smoothieRepository.save(smoothie)
    }

    override fun getOrder(orderId: Long): Cup {
        return smoothieRepository.findById(orderId).orElseThrow { throw RuntimeException() }
    }
}

@Service
class CafeService(
    private val repository: CafeRepository,
    private val beverageFactory: DynamicBeverageFactory,
) {
    fun executeOrder(type: BeverageType): Cup {
        return beverageFactory.makeBeverage(type)
    }

    fun getOrder(orderId: Long, type: BeverageType): Cup {
        return beverageFactory.getOrder(orderId, type)
    }
}

@Component
class DynamicBeverageFactory(
    private val beverageServices: List<BeverageService>,
) {
    private fun getServiceImpl(type: BeverageType): BeverageService {
        return beverageServices.first { it.getBeverageType() == type }
    }

    fun makeBeverage(type: BeverageType): Cup {
        val serviceImpl = getServiceImpl(type)
        return serviceImpl.makeBeverage()
    }

    fun getOrder(orderId: Long, type: BeverageType): Cup {
        val serviceImpl = getServiceImpl(type)
        return serviceImpl.getOrder(orderId)
    }
}

interface BeverageService {
    fun getBeverageType(): BeverageType

    fun makeBeverage(): Cup

    fun getOrder(orderId: Long): Cup
}

기존의 CafeService에서 처리하던 것들이 각 음료에 맞는 책임만을 가진 BeverageService로 분리되었다.

DynamicBeverageFactory를 살펴보자.
각 BeverageService는 BeverageService라는 인터페이스의 구현체이다. 인터페이스의 규약 중 getBeverageType이 중심이 된다. 이 메서드를 구현함으로써 각 구현체들이 어떤 음료에 대한 책임을 가지는지 알 수 있게된다. Factory 클래스는 Bean으로 등록하게 되면서 beverageServices 를 통해 모든 BeverageService구현체들을 주입 받을 수 있다.

그럼 개선하고자 했던 점을 살펴보자

  1. executeOrder 메서드에 라떼 메뉴가 출시되면 makeLatte() 를 호출해야하는 구조를 개선
  2. 주문표를 통해 주문상태를 확인하는 GET api에서 join 쿼리 개선

1번문제는 Latte 메뉴가 출시된다면 우리는 LatteService를 당연히 구현하게 된다. 단순히 LatteService를 작성해주는 것 만으로, Factory 클래스에 구현체가 등록되니 makeLatte()호출 과 같은 추가작업을 생략할 수 있다.

그럼 쿼리는 최적화가 되었을까??

select
    c1_0.id,
    c1_1.is_take_out,
    c1_1.type,
    c1_0.memo,
    c1_0.size 
from
    Coffee_cup c1_0 
join
    Cup c1_1 
        on c1_0.id=c1_1.id 
where
    c1_0.id=?
    

커피를 조회한 쿼리이다. from절이 실제 Coffee로 바뀌었고, join절이 상위 Entity의 정보를 가져오기 위한 Cup Entity와만 join하고 있음을 볼 수 있다.


이렇게 Design Pattern을 통해 개선을 해보았다. 위 예제들은 실제 Cafe Domain이라고 생각한다면 맞지 않는 부분이 있을 것이다. 하지만 Dynamic Factory Pattern 을 이용한 리팩토링 과정을 보여주기 위함이고, 개선방향에 초점을 맞춰 보면 좋을 것 같다.

profile
董訓은 영어로 mentor

0개의 댓글