Skip to content
ioob.dev
Go back

디자인 패턴 2편 — Strategy와 State

· 9분 읽기
Design Pattern 시리즈 (2/8)
  1. 디자인 패턴 1편 — 패턴을 배우는 이유
  2. 디자인 패턴 2편 — Strategy와 State
  3. 디자인 패턴 3편 — Observer와 Command
  4. 디자인 패턴 4편 — Template Method와 Chain of Responsibility
  5. 디자인 패턴 5편 — Factory Method, Abstract Factory, Builder
  6. 디자인 패턴 6편 — Decorator와 Proxy
  7. 디자인 패턴 7편 — Adapter, Facade, Composite: 구조를 정리하는 패턴들
  8. 디자인 패턴 8편 — Singleton, Iterator, Prototype: 자주 쓰지만 오해도 많은 패턴들
Table of contents

Table of contents

if-else가 늘어나는 순간

결제 기능을 만든다고 해보자. 처음에는 신용카드 하나만 지원하면 된다. 그런데 다음 달에 카카오페이가 추가되고, 그 다음 달에 네이버페이가 들어오고, 토스페이까지 요구사항이 쏟아진다.

// 조건 분기가 점점 늘어나는 전형적인 코드
public class PaymentService {
    public void pay(String method, int amount) {
        if (method.equals("CREDIT_CARD")) {
            // 신용카드 결제 로직 (20줄)
        } else if (method.equals("KAKAO_PAY")) {
            // 카카오페이 결제 로직 (25줄)
        } else if (method.equals("NAVER_PAY")) {
            // 네이버페이 결제 로직 (30줄)
        } else if (method.equals("TOSS_PAY")) {
            // 토스페이 결제 로직 (15줄)
        }
        // 다음 달에 또 뭐가 추가될까?
    }
}

이 코드의 문제는 세 가지다.

첫째, 새 결제 수단을 추가할 때마다 이 메서드를 수정해야 한다. OCP(Open-Closed Principle, 확장에는 열려 있고 수정에는 닫혀 있어야 한다는 원칙)를 정면으로 위반한다.

둘째, 각 분기의 로직이 길어지면 메서드가 수백 줄이 된다. 코드 리뷰 때 이 메서드 좀 나눠야 하지 않나라는 코멘트가 반드시 달린다.

셋째, 테스트가 번거롭다. 카카오페이 로직만 테스트하고 싶은데, PaymentService 전체를 생성해야 한다. 한 분기를 고쳤는데 다른 분기가 깨지는 일도 생긴다.

Strategy 패턴은 이 문제를 정면으로 풀어준다.

Strategy 패턴의 구조

Strategy 패턴의 핵심 아이디어는 단순하다. 변하는 부분을 인터페이스로 분리하고, 구현체를 갈아끼운다.

패턴에 참여하는 세 역할을 보자.

classDiagram
    class Context {
        -strategy: Strategy
        +setStrategy(Strategy)
        +executeStrategy()
    }

    class Strategy {
        <<interface>>
        +execute()
    }

    class ConcreteStrategyA {
        +execute()
    }

    class ConcreteStrategyB {
        +execute()
    }

    class ConcreteStrategyC {
        +execute()
    }

    Context --> Strategy : uses
    Strategy <|.. ConcreteStrategyA
    Strategy <|.. ConcreteStrategyB
    Strategy <|.. ConcreteStrategyC

화살표 방향을 주목하자. Context는 구체적인 전략 클래스를 모른다. Strategy 인터페이스에만 의존한다. 새로운 결제 수단이 추가되면 ConcreteStrategy를 하나 더 만들면 끝이다. Context 코드는 한 줄도 건드리지 않는다.

Strategy 패턴 구현

앞의 if-else 코드를 Strategy 패턴으로 리팩토링해보자. 먼저 결제 전략 인터페이스를 정의한다.

// 결제 행위를 추상화한 인터페이스
interface PaymentStrategy {
    fun pay(amount: Int): PaymentResult
}

data class PaymentResult(
    val success: Boolean,
    val transactionId: String,
    val message: String
)

각 결제 수단을 구현체로 분리한다.

// 신용카드 결제 전략
class CreditCardPayment(
    private val cardNumber: String,
    private val expiryDate: String
) : PaymentStrategy {
    override fun pay(amount: Int): PaymentResult {
        // 카드사 API 호출, 승인 요청 등
        println("신용카드 $cardNumber 으로 ${amount}원 결제 요청")
        return PaymentResult(
            success = true,
            transactionId = "CC-${System.currentTimeMillis()}",
            message = "신용카드 결제 완료"
        )
    }
}

// 카카오페이 결제 전략
class KakaoPayPayment(
    private val kakaoToken: String
) : PaymentStrategy {
    override fun pay(amount: Int): PaymentResult {
        // 카카오페이 SDK 호출
        println("카카오페이로 ${amount}원 결제 요청")
        return PaymentResult(
            success = true,
            transactionId = "KP-${System.currentTimeMillis()}",
            message = "카카오페이 결제 완료"
        )
    }
}

// 네이버페이 결제 전략
class NaverPayPayment(
    private val naverToken: String
) : PaymentStrategy {
    override fun pay(amount: Int): PaymentResult {
        println("네이버페이로 ${amount}원 결제 요청")
        return PaymentResult(
            success = true,
            transactionId = "NP-${System.currentTimeMillis()}",
            message = "네이버페이 결제 완료"
        )
    }
}

Context 역할을 하는 PaymentService는 어떤 전략이든 받아서 실행만 하면 된다.

// Context: 어떤 전략이 들어와도 같은 방식으로 실행한다
class PaymentService(
    private var strategy: PaymentStrategy
) {
    fun changeStrategy(newStrategy: PaymentStrategy) {
        this.strategy = newStrategy
    }

    fun processPayment(amount: Int): PaymentResult {
        println("결제 처리 시작: ${amount}원")
        val result = strategy.pay(amount)
        if (result.success) {
            println("트랜잭션 ID: ${result.transactionId}")
        }
        return result
    }
}

사용하는 쪽의 코드는 이렇게 된다.

fun main() {
    // 신용카드로 결제
    val service = PaymentService(CreditCardPayment("1234-5678", "12/25"))
    service.processPayment(50000)

    // 전략 교체: 카카오페이로 변경
    service.changeStrategy(KakaoPayPayment("kakao-token-abc"))
    service.processPayment(30000)
}

PaymentServicePaymentStrategy 인터페이스만 알고 있다. 토스페이를 추가하더라도 TossPayPayment 클래스만 하나 만들면 된다. 기존 코드를 수정할 필요가 없다.

Strategy 패턴의 실무 활용

결제 말고도 Strategy 패턴이 자주 쓰이는 곳이 있다. 정렬 알고리즘 교체, 파일 압축 방식 선택, 인증 방식 전환 등이 대표적이다.

실무에서 한 가지 유용한 패턴이 있다. Strategy를 enum이나 Map으로 관리하면 조건 분기 없이도 전략을 선택할 수 있다.

// 전략을 Map으로 관리하는 실무 패턴
class PaymentStrategyFactory {
    private val strategies = mutableMapOf<String, PaymentStrategy>()

    fun register(type: String, strategy: PaymentStrategy) {
        strategies[type] = strategy
    }

    fun getStrategy(type: String): PaymentStrategy =
        strategies[type] ?: throw IllegalArgumentException("지원하지 않는 결제 수단: $type")
}

// 애플리케이션 시작 시 등록
val factory = PaymentStrategyFactory()
factory.register("CREDIT_CARD", CreditCardPayment("1234-5678", "12/25"))
factory.register("KAKAO_PAY", KakaoPayPayment("kakao-token"))
factory.register("NAVER_PAY", NaverPayPayment("naver-token"))

// 사용 시 문자열 키로 전략을 꺼낸다
val strategy = factory.getStrategy("KAKAO_PAY")
strategy.pay(10000)

Spring Framework를 쓰고 있다면 더 간단하다. 각 전략을 @Component로 등록하고 List<PaymentStrategy>로 주입받으면 된다. 프레임워크가 Strategy 패턴을 자연스럽게 지원하는 셈이다.

State 패턴 — 같은 구조, 다른 의도

Strategy 패턴의 클래스 다이어그램을 기억하는가? Context가 인터페이스에 의존하고, 구현체를 갈아끼우는 구조. State 패턴의 다이어그램도 거의 동일하게 생겼다. 그런데 풀려는 문제가 완전히 다르다.

Strategy는 어떤 알고리즘을 쓸 것인가를 외부에서 정한다. 클라이언트가 전략을 선택해서 Context에 넣어준다. 반면 State는 현재 상태에 따라 같은 요청이 다르게 동작한다를 표현한다. 상태 전환은 객체 내부에서 일어난다.

주문 상태를 예로 들어보자. 주문은 생성, 결제 완료, 배송 중, 배송 완료 같은 상태를 거친다. 같은 cancel() 요청이라도 상태에 따라 동작이 달라진다. 결제 완료 상태에서는 환불 처리가 가능하지만, 배송 중에는 회수 절차가 필요하고, 배송 완료 후에는 반품으로 처리해야 한다.

stateDiagram-v2
    [*] --> Created : 주문 생성
    Created --> Paid : 결제 완료
    Created --> Cancelled : 주문 취소

    Paid --> Shipping : 배송 시작
    Paid --> Cancelled : 결제 취소 + 환불

    Shipping --> Delivered : 배송 완료
    Shipping --> Cancelled : 배송 회수 + 환불

    Delivered --> [*]
    Cancelled --> [*]

이걸 if-else로 구현하면 어떻게 되는지 보자.

// 상태마다 분기하는 코드 — 상태가 늘어날수록 복잡해진다
public class Order {
    private String state = "CREATED";

    public void cancel() {
        if (state.equals("CREATED")) {
            state = "CANCELLED";
            System.out.println("주문 취소");
        } else if (state.equals("PAID")) {
            refund();
            state = "CANCELLED";
            System.out.println("결제 취소 + 환불");
        } else if (state.equals("SHIPPING")) {
            recall();
            refund();
            state = "CANCELLED";
            System.out.println("배송 회수 + 환불");
        } else if (state.equals("DELIVERED")) {
            throw new IllegalStateException("배송 완료 후에는 반품으로 처리해야 합니다");
        }
    }

    // ship(), deliver() 메서드에도 비슷한 분기가 반복된다...
}

cancel() 하나만 이 정도인데, ship(), deliver(), refund() 메서드에도 상태별 분기가 반복된다. 상태가 하나 추가되면 모든 메서드를 다 고쳐야 한다.

State 패턴 구현

State 패턴은 각 상태를 별도의 클래스로 분리한다. 상태별 행동은 해당 상태 클래스 안에 캡슐화된다.

// 주문 상태 인터페이스: 각 상태에서 가능한 행동을 정의한다
interface OrderState {
    fun cancel(order: Order)
    fun ship(order: Order)
    fun deliver(order: Order)
    fun getStateName(): String
}

각 상태를 구현한다. 먼저 주문 생성 상태다.

// 주문 생성 상태: 취소만 가능하고, 배송이나 배달은 불가
class CreatedState : OrderState {
    override fun cancel(order: Order) {
        println("주문이 취소되었다")
        order.changeState(CancelledState())
    }

    override fun ship(order: Order) {
        throw IllegalStateException("결제가 완료되지 않은 주문은 배송할 수 없다")
    }

    override fun deliver(order: Order) {
        throw IllegalStateException("배송 중이 아닌 주문은 배달 완료 처리할 수 없다")
    }

    override fun getStateName() = "CREATED"
}

결제 완료 상태에서는 취소 시 환불이 동반된다.

// 결제 완료 상태: 배송 시작이나 취소(환불 포함)가 가능
class PaidState : OrderState {
    override fun cancel(order: Order) {
        println("결제 취소 및 환불 처리")
        order.refund()
        order.changeState(CancelledState())
    }

    override fun ship(order: Order) {
        println("배송을 시작한다")
        order.changeState(ShippingState())
    }

    override fun deliver(order: Order) {
        throw IllegalStateException("배송이 시작되지 않은 주문은 배달 완료 처리할 수 없다")
    }

    override fun getStateName() = "PAID"
}

배송 중 상태와 배송 완료 상태도 같은 방식으로 구현한다.

// 배송 중 상태: 배달 완료 또는 회수(취소)가 가능
class ShippingState : OrderState {
    override fun cancel(order: Order) {
        println("배송 회수 요청 및 환불 처리")
        order.recall()
        order.refund()
        order.changeState(CancelledState())
    }

    override fun ship(order: Order) {
        throw IllegalStateException("이미 배송 중이다")
    }

    override fun deliver(order: Order) {
        println("배송이 완료되었다")
        order.changeState(DeliveredState())
    }

    override fun getStateName() = "SHIPPING"
}

// 배송 완료 상태: 더 이상 상태 전이가 없다
class DeliveredState : OrderState {
    override fun cancel(order: Order) {
        throw IllegalStateException("배송 완료 후에는 반품으로 처리해야 한다")
    }

    override fun ship(order: Order) {
        throw IllegalStateException("이미 배송 완료되었다")
    }

    override fun deliver(order: Order) {
        throw IllegalStateException("이미 배송 완료되었다")
    }

    override fun getStateName() = "DELIVERED"
}

// 취소 상태: 모든 행동이 불가
class CancelledState : OrderState {
    override fun cancel(order: Order) {
        throw IllegalStateException("이미 취소된 주문이다")
    }

    override fun ship(order: Order) {
        throw IllegalStateException("취소된 주문은 배송할 수 없다")
    }

    override fun deliver(order: Order) {
        throw IllegalStateException("취소된 주문은 배달 완료 처리할 수 없다")
    }

    override fun getStateName() = "CANCELLED"
}

Context 역할의 Order 클래스는 현재 상태에게 행동을 위임한다.

// Context: 현재 상태에게 모든 행동을 위임한다
class Order(val orderId: String) {
    private var state: OrderState = CreatedState()

    fun changeState(newState: OrderState) {
        println("[$orderId] 상태 전환: ${state.getStateName()}${newState.getStateName()}")
        this.state = newState
    }

    fun cancel() = state.cancel(this)
    fun ship() = state.ship(this)
    fun deliver() = state.deliver(this)

    fun refund() {
        println("[$orderId] 환불 처리 실행")
    }

    fun recall() {
        println("[$orderId] 배송 회수 요청")
    }

    fun getCurrentState(): String = state.getStateName()
}

사용 예시를 보자.

fun main() {
    val order = Order("ORD-001")

    order.changeState(PaidState())  // 결제 완료로 전환
    order.ship()                     // 배송 시작
    order.deliver()                  // 배송 완료

    println("최종 상태: ${order.getCurrentState()}")
    // [ORD-001] 상태 전환: CREATED → PAID
    // 배송을 시작한다
    // [ORD-001] 상태 전환: PAID → SHIPPING
    // 배송이 완료되었다
    // [ORD-001] 상태 전환: SHIPPING → DELIVERED
    // 최종 상태: DELIVERED
}

if-else 코드와 비교해보면 차이가 명확하다. 새로운 상태가 추가되면 새 상태 클래스만 만들면 된다. 기존 상태 클래스는 건드리지 않는다. 각 상태의 행동이 해당 클래스 안에 응집되어 있어서, “배송 중 상태에서 취소하면 어떻게 되지?”라는 질문에 ShippingState만 열어보면 답이 나온다.

Strategy vs State — 판단 기준

두 패턴의 클래스 다이어그램은 사실상 동일하다. Context가 인터페이스에 의존하고, 구현체를 갈아끼운다. 그래서 처음에는 헷갈릴 수밖에 없다. 핵심적인 차이는 누가 구현체를 바꾸는가에 있다.

기준StrategyState
핵심 질문어떤 알고리즘을 쓸 것인가?현재 상태가 무엇인가?
구현체 교체 주체클라이언트(외부)가 전략을 선택상태 객체(내부)가 스스로 전환
교체 시점클라이언트가 원할 때특정 조건이 충족될 때 자동으로
구현체 간 관계서로 독립적 (카카오페이와 네이버페이는 무관)서로 연결됨 (Created → Paid → Shipping)
대표적 예시결제 수단 선택, 정렬 알고리즘, 압축 방식주문 상태, TCP 연결 상태, 게임 캐릭터 상태

판단할 때 자문해보면 좋은 질문이 있다.

“구현체들 사이에 전환 순서가 있는가?”

있다면 State다. 주문은 Created에서 바로 Delivered로 갈 수 없고, 반드시 Paid → Shipping을 거쳐야 한다. 없다면 Strategy다. 신용카드에서 카카오페이로 바꾸는 데 순서 제약이 없다.

“구현체 교체를 누가 결정하는가?”

외부에서 결정하면 Strategy다. 사용자가 결제 화면에서 카카오페이를 선택하는 것처럼. 내부 로직에 의해 자동으로 바뀌면 State다. 결제가 완료되면 자동으로 Paid 상태로 전환되는 것처럼.

실무에서의 주의점

Strategy의 함정

Strategy 패턴을 적용하면 각 전략이 독립적인 클래스가 되므로 테스트하기 좋고 확장하기도 편하다. 그런데 전략이 너무 많아지면 관리가 어려워진다. 전략이 30개인데 어떤 전략을 써야 하는지 알려면 결국 어딘가에 조건 분기가 필요하잖아라는 문제가 생기는 것이다.

이럴 때는 Strategy 패턴 자체를 버리는 게 아니라, 전략 선택 로직을 별도로 분리한다. 앞서 본 PaymentStrategyFactory가 그 예다. 전략의 생성과 선택을 한 곳에 모아두면 관리 포인트가 줄어든다.

State의 함정

State 패턴에서 주의할 점은 상태 전이 규칙이 여러 상태 클래스에 분산된다는 것이다. PaidState에서 ShippingState로 전환하는 규칙은 PaidState 안에 있고, ShippingState에서 DeliveredState로 전환하는 규칙은 ShippingState 안에 있다. 전체 전이 규칙을 한눈에 보려면 모든 상태 클래스를 다 열어봐야 한다.

상태가 많고 전이 규칙이 복잡하다면, 상태 전이를 한 곳에서 관리하는 별도의 전이 테이블을 두는 방법도 있다.

// 상태 전이 규칙을 한 곳에서 관리하는 방식
enum class OrderStatus { CREATED, PAID, SHIPPING, DELIVERED, CANCELLED }
enum class OrderEvent { PAY, SHIP, DELIVER, CANCEL }

val transitions: Map<Pair<OrderStatus, OrderEvent>, OrderStatus> = mapOf(
    (OrderStatus.CREATED to OrderEvent.PAY) to OrderStatus.PAID,
    (OrderStatus.CREATED to OrderEvent.CANCEL) to OrderStatus.CANCELLED,
    (OrderStatus.PAID to OrderEvent.SHIP) to OrderStatus.SHIPPING,
    (OrderStatus.PAID to OrderEvent.CANCEL) to OrderStatus.CANCELLED,
    (OrderStatus.SHIPPING to OrderEvent.DELIVER) to OrderStatus.DELIVERED,
    (OrderStatus.SHIPPING to OrderEvent.CANCEL) to OrderStatus.CANCELLED,
)

fun transition(current: OrderStatus, event: OrderEvent): OrderStatus =
    transitions[current to event]
        ?: throw IllegalStateException("$current 상태에서 $event 이벤트는 허용되지 않는다")

이 방식은 순수한 State 패턴은 아니지만, 상태 전이 규칙을 한 곳에서 파악할 수 있다는 장점이 있다. 상태별 행동이 복잡하면 State 패턴을, 전이 규칙이 복잡하면 전이 테이블을, 둘 다 복잡하면 혼합해서 쓴다. 정답은 상황에 따라 달라진다.

핵심 정리


다음 편에서는 객체 간 느슨한 연결을 다루는 Observer 패턴과, 요청을 객체로 캡슐화하는 Command 패턴을 살펴본다.

3편: Observer와 Command


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
디자인 패턴 1편 — 패턴을 배우는 이유
Next Post
디자인 패턴 3편 — Observer와 Command