올리브영 테크블로그 포스팅 배송최적화 시스템 구축기 Part 02. 복잡한 비즈니스 로직에 유연한 디자인 패턴 입히기
Tech

배송최적화 시스템 구축기 Part 02. 복잡한 비즈니스 로직에 유연한 디자인 패턴 입히기

전략 패턴을 활용한 확장성 있는 아키텍처 설계

2026.09.04

안녕하세요! 올리브영 배송최적화 스쿼드의 백엔드 개발을 담당하고 있는 로미입니다.🌠

지난 Part 01에서는 멀티 센터 체제 전환의 배경과 배송최적화 시스템의 전체 아키텍처를 소개했는데요. 이번 Part 02에서는 그 핵심인 최적의 출고 센터를 결정하는 로직을 어떤 설계 패턴으로 풀어냈는지 코드 레벨로 다뤄보며, 배송최적화 시스템 오픈 과정에서 고민했던 부분과 해결 경험을 공유하고자 합니다.


1. 문제: 조건이 늘어날수록 깊어지는 if 문의 늪

배송최적화 시스템을 설계하며 가장 고민했던 부분은 다양한 배송 판별 조건들을 확장성과 유지보수성을 고려하여 어떻게 간결한 로직으로 풀어낼 것인가였습니다. 올리브영에서 고객에게 상품이 배송되기까지 '어떤 물류 센터에서 출고하는 것이 가장 효율적일까?'를 판단하려면 생각보다 복잡한 의사결정 과정을 거쳐야 합니다. 최적의 센터를 찾기 위해 아래의 분배 조건들을 단순한 if-else문으로만 처리한다고 상상해 보겠습니다.

아래와 같이 코드가 길고 복잡해질 뿐만 아니라 조건이 서로 얽히고 중첩되기 시작하면 우리가 테스트하고 확인해야 할 경우의 수가 기하급수적으로 증가하게 됩니다. 결국 코드의 가독성이 떨어지고 코드를 수정하더라도 어디서 사이드 이펙트가 터질지 모르는 유지보수의 늪에 빠지게 됩니다.

// 상상만 해도 머리가 아픈 코드 예시
if (주문 분배 예외 조건) { ... }
else if (고객 권역 확인) {
    if (배송 타입 확인) {
        if (택배사 확인) {
            if (재고 확인 && Capacity 확인) {
                // 비즈니스 로직 처리...
            }
        }
    }
}
// ...

2. 고민: 아직 오지 않은 요구사항까지 설계해야 할까

앞서 본 'if 문의 늪'을 피해야 한다는 건 알겠지만 당장 눈앞에 주어진 '최적의 센터 결정'이라는 목표를 달성하기에도 벅찬 것이 현실입니다. 그럼에도 시스템의 뼈대를 잡는 초기 설계 단계에서 아래와 같은 질문들이 계속 떠올랐습니다.

  • 비즈니스가 확장되면서 새로운 배송 규칙(Rule)이 계속 추가된다면?
  • 향후 AI/ML 기반의 스코어링(가중치) 모델이 도입되어 규칙마다 가중치를 적용해야 한다면?
  • 정책상 배송 리드타임(Shipping Lead Time)이 아닌 물류비가 가장 적은 센터로 배정이 필요하다면?
  • 반대로 물류비가 아닌 배송 리드타임이 가장 짧은 센터로 배정이 필요하도록 정책이 바뀐다면?

‘아니… 지금은 설계 단계일 뿐인데 아직 일어나지도 않은 미래의 요구사항까지 고민을 해야 하나…?’라는 의구심이 들 수도 있습니다. 하지만 핵심 비즈니스 로직을 설계할 때는 최대한 다양한 시각에서 요구사항을 분석하고 변화의 가능성을 열어두어야 한다고 생각합니다. 과도한 미래 대비는 오버엔지니어링이 될 수 있지만 올리브영의 배송 도메인은 새로운 배송 규칙이 추가될 여지가 크기 때문에 어떠한 비즈니스 로직이 추가되더라도 시스템이 흔들리지 않고 문제를 쉽게 풀어낼 수 있는 유연한 아키텍처가 필요했습니다.

새로운 배송 규칙이 생길 때마다 코드는 최소한만 손대고, 사이드 이펙트 걱정 없이 배포할 수 있는 방법은 없을까요?

3. 해결: 전략 패턴(Strategy Pattern)의 도입과 응용

이러한 상황을 이미 여러 번 겪어 온 선배 개발자들이 다양한 디자인 패턴을 고안해 냈고, 이를 다룬 책과 아티클도 많습니다. 저는 그 패턴들의 핵심 아이디어에서 힌트를 얻어 배송최적화 시스템에 알맞게 커스텀해서 사용해 보기로 했습니다.

그중에서도 각 배송 규칙 조건을 세분화하고 이를 각각의 전략으로 구성하는 전략 패턴을 적용해 보면 배송 로직을 훨씬 간결하게 구현할 수 있을 것 같다는 생각이 들었습니다. 그렇다면 전략 패턴이란 무엇이고, 이것이 왜 'if 문의 늪'에서 벗어나게 해 주는지 기본형부터 살펴보겠습니다.


기본형: 변하는 것을 클래스로 캡슐화

기본형은 비슷한 동작을 하는 전략을 각각 하나의 클래스로 캡슐화하고 런타임에 교체할 수 있도록 만드는 것입니다. 만약 올리브영 배송 정책이 단순히 ‘거리 우선’과 ‘CAPA(Capacity, 출고 가능 용량)’ 두 가지 중 하나를 택하는 것이라면 아래와 같이 예시를 들어볼 수 있습니다.

눈여겨볼 곳은 마지막 main() 부분입니다. if로 분기하는 대신 전략 객체 하나만 갈아 끼우면 동작이 바뀌기 때문에 조건이 늘어나도 기존 코드를 수정할 필요 없이 SimpleCenterStrategy 구현 클래스를 하나 더 만들면 됩니다.

// 1. 공통 알고리즘(전략)을 정의하는 인터페이스
interface SimpleCenterStrategy {
    fun selectCenter(orderInfo: String): String
}

// 2. 구체적인 전략 구현체들
class DistanceStrategy : SimpleCenterStrategy {
    override fun selectCenter(orderInfo: String): String {
        return "회원 배송지와 가장 가까이 있는 센터"
    }
}

class CapacityStrategy : SimpleCenterStrategy {
    override fun selectCenter(orderInfo: String): String {
        return "출고 처리 가능 용량이 가장 큰 센터"
    }
}

// 3. 전략을 보유하고 실행을 위임하는 컨텍스트(Context)
class DeliveryManager(var strategy: SimpleCenterStrategy) {
    fun processDelivery(orderInfo: String) {
        val center = strategy.selectCenter(orderInfo)
    }
}

// 실행 예시
fun main() {
    val manager = DeliveryManager(DistanceStrategy())
    manager.processDelivery("주문정보 1") // 회원 배송지와 가장 가까이 있는 센터 할당

    // 상황에 따라 if문 없이 전략만 교체
    manager.strategy = CapacityStrategy()
    manager.processDelivery("주문정보 2") // 출고 처리 가능 용량이 가장 큰 센터 할당
}

한계: 단일 전략으로는 결정할 수 없는 문제

하지만 올리브영의 배송 로직은 이것보다 훨씬 복잡하기 때문에 전략 패턴의 기본형을 그대로 적용할 수는 없었습니다. 단순히 하나의 룰로 센터를 결정하는 것이 아니라 여러 룰의 조건들을 복합적으로 판단해야 했기 때문입니다.

단일 전략을 채택하는 것이 아니라 ‘CAPA도 확인하고, 권역도 확인하고, 재고도 확인한 결과’를 종합해야 하기 때문에 각 조건을 독립적으로 판단하는 여러 개의 검증 전략을 두고, 전략별로 추려낸 후보군을 모아 최적의 센터를 결정하는 과정이 필요했습니다. 최적의 센터를 선택하는 과정은 아래와 같이 두 단계로 나눌 수 있습니다.

  1. 후보군 추리기(Selection): 조건에 부합하여 출고가 가능한 센터들을 모두 찾아내야 합니다. (ex. 배송 권역에 해당하면서, 해당 상품의 재고가 남아 있고, 당일 출고 CAPA에도 여유가 있는 센터들)
  2. 최종 확정하기(Decision): 추려진 후보 센터들 중에서 현재 비즈니스 정책상 가장 적합한 단 하나의 센터를 최종 선택해야 합니다. (ex. 모든 선택 전략 조건에 부합하는 센터를 우선적으로 배정)
배송최적화 시스템의 출고 센터 결정 플로우 — Selection(후보 필터링)과 Decision(최종 결정) 구조
배송최적화 시스템 출고 센터 결정 플로우

적용: 선택과 결정의 분리, 그리고 조합

앞서 나눈 두 단계를 각각 선택 전략과 결정 전략으로 부르고, 이를 조합하는 방식으로 아키텍처를 설계했습니다. 지금부터는 예시 코드를 통해 어떻게 세분화했는지 살펴보겠습니다.


Step 1. 두 가지 역할의 인터페이스 분리

먼저 조건에 맞는 후보 센터들을 추려내는 CenterSelectionStrategy와 추려진 결과들을 모아 최종 하나를 결정하는 DecisionStrategy로 책임을 분리했습니다.

// 후보군을 선택(Selection)하는 전략 인터페이스
interface CenterSelectionStrategy<T> {
    fun select(context: T): CenterSelectionResult
}

// 여러 Selection 결과를 모아 최종 확정(Decision)하는 전략 인터페이스
interface DecisionStrategy {
    fun decide(results: List<CenterSelectionResult>): CenterSelectionResult
}
// CenterSelectionStrategy 구현체 예시 (재고 확인 전략)
class InventoryCenterSelectionStrategy : CenterSelectionStrategy<DeliveryOptimizationDto> {
    override fun select(context: DeliveryOptimizationDto): CenterSelectionResult {
        // 재고 확인 후 조건에 맞는 센터를 반환
        return CenterSelectionResult(..)
    }
}

Step 2. 여러 전략을 하나로 묶어주는 조합 클래스 도입

이제 CenterSelectionStrategy 구현체들을 리스트로 받아 순차적으로 실행하고, 그 결과를 DecisionStrategy에 넘기는 조합 클래스가 필요합니다. 여기에는 개별 객체와 객체의 묶음을 같은 인터페이스로 다루는 컴포지트 패턴(Composite Pattern)의 개념을 빌렸습니다. 조합 클래스가 CenterSelectionStrategy를 그대로 구현하기 때문에, 전략들의 묶음도 바깥에서는 하나의 전략처럼 동작합니다.

// 여러 전략을 조합하여 실행하는 Composite 클래스
class CompositeCenterSelectionStrategy<T>(
    private val strategies: List<CenterSelectionStrategy<T>>, // 실행할 전략 리스트
    private val decisionStrategy: DecisionStrategy,           // 최종 결정 전략
) : CenterSelectionStrategy<T> {

    override fun select(context: T): CenterSelectionResult {
        // 1. 등록된 모든 Selection 전략을 실행하여 결과들을 모음
        val results = strategies.map { it.select(context) }

        // 2. 모인 결과들을 Decision 전략에 넘겨 최종 결과를 반환
        return decisionStrategy.decide(results)
    }
}

Step 3. 주문이 들어왔을 때 조립하고 실행하기

마지막으로 실제 주문이 들어왔을 때 이 조각들을 어떻게 조립해서 사용하는지 살펴보겠습니다. 전략 패턴에서는 이렇게 전략을 골라 조립하는 쪽을 클라이언트(Client)라고 부릅니다.

이제 새로운 배송 조건(ex. 증정품이 포함된 상품은 증정품 전용 라인이 있는 센터에서 출고)이 생겨도 기존 전략들의 판단 로직을 수정할 필요가 없어졌습니다. CenterSelectionStrategy를 구현하는 클래스를 하나 더 만들고 selectCenter 함수의 strategies 리스트에 한 줄 추가해 주기만 하면 됩니다.

fun selectCenter(dto: DeliveryOptimizationDto): CenterSelectionResult {
    // 상황에 맞는 전략들을 주입하여 Composite 객체 조립
    val composite = CompositeCenterSelectionStrategy(
        strategies = listOf(
            capacityCenterSelectionStrategy, // CAPA 확인 전략
            inventoryCenterSelectionStrategy, // 재고 확인 전략
            postalCodeCenterSelectionStrategy // 배송 권역 확인 전략
        ),
        decisionStrategy = DefaultDecisionStrategy() // 결정 전략
    )

    // 복잡한 if문 없이 단 한 줄로 전체 흐름 실행
    return composite.select(dto)
}

4. 운영: 배포 없이 DB로 룰 On/Off

새로운 배송 조건이 생겨날 때마다 기존 코드를 건드리지 않고 새로운 전략 클래스만 추가하면 된다는 점은 이 구조가 가진 가장 명확한 장점입니다. 애초에 유지보수성과 확장성을 얻기 위해 디자인 패턴을 도입한 것이니까요. 그렇다면 실제 운영 단계에서 또 어떻게 활용할 수 있을지 아래의 상황을 가정해 보겠습니다.

🏃‍♀️ 기획자: "저번에 고객 우편번호(권역) 기준으로 배정해달라고 요청드렸는데요. 급하게 정책이 바뀌어서요! 당분간 권역 구분 없이 CAPA 여유 있는 센터부터 출고시켜야 할 것 같아요. 혹시 이거 수정해서 배포하시려면 얼마나 걸릴까요? ㅠㅠ"

👩‍💻 개발자: "아, 그건 추가 배포 없이 설정만 변경하면 됩니다. 😄"


급하게 기능 수정 요청이 들어왔는데 이렇게 여유롭게 답할 수 있었던 이유는 무엇일까요? 바로 전략(Rule)의 활성화 상태를 DB 기반으로 제어하도록 설계했기 때문입니다. 전략마다 매핑되는 규칙의 활성화 여부(On/Off) 상태 값을 DB에 저장해 두고, 앞서 만든 Composite 객체가 전략들을 실행하기 직전에 이 상태 값을 확인하여 활성화된 룰만 필터링해서 실행하도록 구현했습니다.


// 후보군을 선택(Selection)하는 전략 인터페이스
interface CenterSelectionStrategy<T> {
    val rule: RuleType    // 각 전략이 매핑되는 룰 타입 추가
    fun select(context: T): CenterSelectionResult
}
class CompositeCenterSelectionStrategy<T>(
    private val strategies: List<CenterSelectionStrategy<T>>,
    private val filter: RuleEnabledFilter<T>,    // 룰 활성화 여부 필터 추가
    private val decisionStrategy: DecisionStrategy,
) : CenterSelectionStrategy<T> {
    override val rule = RuleType.COMPOSITE_ROOT

    override fun select(context: T): CenterSelectionResult {
        // 1. 전체 전략 중 DB에서 활성화(true)된 전략들만 필터링하여 실행
        val results = filter.ruleEnabledFilter(strategies).map { it.select(context) }

        // 2. 실행된 결과들을 모아 최종 확정
        return decisionStrategy.decide(results)
    }
}
fun selectCenter(dto: DeliveryOptimizationDto): CenterSelectionResult {
    val composite = CompositeCenterSelectionStrategy(
        strategies = listOf(
            capacityCenterSelectionStrategy,
            inventoryCenterSelectionStrategy,
            postalCodeCenterSelectionStrategy
        ),
        filter = ruleEnabledFilter, // DB 설정에 따른 룰 On/Off 필터 적용
        decisionStrategy = DefaultDecisionStrategy()
    )
    return composite.select(dto)
}

이제 개발자는 코드 수정 없이 POSTAL_CODE 룰에 대한 활성화 여부 컬럼만 false로 변경해 주기만 하면 됩니다. 코드를 수정하고, 빌드하고, QA를 거쳐 배포하는 그 험난한 과정을 전부 생략할 수 있게 되었습니다. 확장에는 열려 있고 기존 코드의 변경에는 닫혀 있는 아키텍처(Open-Closed Principle, 개방-폐쇄 원칙)가 완성된 것입니다.

💡 예시 코드의 Composite에는 다형성을 위해 DB 룰과 매핑하는 더미(Dummy) RuleType을 두었지만, 객체의 명확한 책임 분리를 위해 룰 매핑 로직을 Composite 외부로 빼는 방향으로 고도화해 볼 수도 있습니다.

💡 DB 기반으로 전략의 활성화 상태를 제어할 경우 단순한 값 변경만으로도 시스템의 동작 방식이 크게 바뀔 수 있기 때문에 변경 이력 관리 체계를 함께 마련해 두어야 합니다.

5. 성능: 비싼 연산을 뒤로 미루는 실행 순서

현재 배송최적화 시스템은 최적의 출고 센터를 결정하는 과정에서 재고 서비스를 조회하고 있습니다. 이러한 외부 API 호출은 네트워크 I/O를 수반하기 때문에 내부 DB 조회에 비해 비용이 크고, 올영세일처럼 단시간에 트래픽이 몰리는 상황에서는 호출량이 급증하여 전체 시스템의 응답 시간에 직접적인 영향을 줄 수 있습니다. 이 문제를 해결하기 위해 여러 전략을 하나로 조합한 구조에서 전략의 실행 순서를 제어하여 불필요한 외부 호출을 줄였습니다.

예를 들어 CAPA에 여유가 있는 센터가 하나도 없는 상황을 가정해 보겠습니다. 이 경우 재고를 아무리 확인해도 출고할 수 있는 센터는 없습니다. 그런데 재고 확인 전략이 CAPA 확인 전략보다 먼저 실행된다면, 결론이 이미 정해진 요청에도 재고 API 호출이 발생하게 됩니다. 트래픽이 몰리는 시점에 이런 불필요한 호출이 쌓이면 재고 서비스에 대한 부하가 가중되고, 배송최적화 시스템의 전체 응답 시간도 느려질 수밖에 없습니다.

해결책은 비용이 적은 연산을 먼저, 비싼 연산은 뒤로 미루는 것이었습니다. 여기서 비싼 연산은 외부 API 호출입니다. 배송최적화 시스템의 자체 DB만으로 빠르게 확인이 가능한 센터 CAPA 확인 전략을 선순위로 두고, 타 시스템의 API 호출이 필요한 재고 확인 전략을 후순위로 배치했습니다. 다만 순서를 정하는 것만으로는 부족합니다. 앞선 전략에서 이미 결론이 난 경우 뒤따르는 전략을 실행하지 않고 멈출 방법이 함께 필요했습니다.

이를 구현하기 위해 각 전략의 실행 반환 값(CenterSelectionResult)에 계속 진행할지 멈출지 판단하는 상태 값을 추가했습니다. 이렇게 하면 앞선 CAPA 확인 단계에서 이미 배정 불가 판정이 났거나 다른 조건에 의해 센터 배정이 조기 확정된 경우 굳이 무거운 재고 확인 전략을 실행하지 않고 로직을 즉시 종료할 수 있습니다. 구조적인 설계 하나로 불필요한 연산과 네트워크 I/O 비용을 줄여낸 것입니다.

class CompositeCenterSelectionStrategy<T>(
    private val strategies: List<CenterSelectionStrategy<T>>,
    private val filter: RuleEnabledFilter<T>,
    private val decisionStrategy: DecisionStrategy,
) : CenterSelectionStrategy<T> {
    override val rule = RuleType.COMPOSITE_ROOT

    override fun select(context: T): CenterSelectionResult {
        // 룰에 정의된 우선순위대로 정렬
        val ordered = filter.ruleEnabledFilter(strategies).sortedBy { it.rule.priority }
        val results = mutableListOf<CenterSelectionResult>()

        for (strategy in ordered) {
            val result = strategy.select(context)
            results.add(result)

            if (result.shouldStop) {
                break
            }
        }
        return decisionStrategy.decide(results)
    }
}

6. 마치며

앞서 살펴본 성능 최적화 사례처럼 잘 짜인 아키텍처는 단순히 코드를 보기 좋게 만드는 것을 넘어 시스템의 안정성과 효율성까지 끌어올려 줍니다. 다만 정답처럼 꺼내 쓸 수 있는 구조가 따로 있는 것은 아닙니다.

개발자로서 연차가 쌓일수록 예전에 겪었던 것과 비슷한 문제를 다시 마주하곤 하지만, 그 속을 자세히 들여다보면 비즈니스 요구사항이나 시스템 환경까지 똑같은 경우는 흔치 않습니다.

결국 가장 중요한 것은 문제의 본질을 파악하는 눈과 기존의 지식을 우리의 현재 상황에 맞게 적용할 수 있는 응용력이라고 생각합니다. 선배 개발자들의 경험이 응축된 디자인 패턴의 핵심 아이디어를 잘 활용해서 올리브영 배송이라는 복잡한 도메인에 맞게 적용한 것처럼요.

끊임없이 추가되고 얽히는 복잡한 비즈니스 로직 앞에서 아키텍처 설계에 막막함을 느끼고 계신 개발자분들께, 배송최적화 시스템을 구축하며 고민했던 저의 이야기가 작은 힌트가 되었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다! 🌠

Design PatternArchitectureDeliveryLogistics OptimizationOrder Distribution리팩토링성능개선
올리브영 테크 블로그 작성 배송최적화 시스템 구축기 Part 02. 복잡한 비즈니스 로직에 유연한 디자인 패턴 입히기
🌠
로미 |
Back-end Engineer
안녕하세요:) 올리브영 백엔드 개발자 로미입니다.