
Strategy 패턴은 교체 가능한 정책이나 알고리즘을 다룰 때 특히 유용합니다. 결론부터 말하면 핵심은 클래스를 많이 만드는 데 있지 않고, 무엇이 바뀌는 정책인지 분리하고 호출부는 그 정책을 인터페이스나 함수로 바꿔 끼울 수 있게 만드는 데 있습니다.
이번 글에서는 Strategy 패턴의 기본 구조부터 보고, State 패턴과 왜 자꾸 헷갈리는지, 상태와 정책은 무엇이 다른지, 코틀린에서는 왜 함수 타입과 람다가 강력한 대안이 되는지, 그리고 언제 클래스 기반 Strategy가 오히려 과한지까지 차분하게 정리하겠습니다.

기본 개념 참고로는 Refactoring.Guru의 Strategy 패턴 설명, State와의 차이 비교는 State 패턴 설명, 코틀린 함수형 대안 근거는 Kotlin higher-order functions and lambdas 문서와 Kotlin fun interface 문서를 참고했습니다.
Strategy 패턴은 왜 필요할까
처음에는 할인 정책이나 결제 방식 분기를 서비스 안에 직접 넣어도 충분해 보입니다. 하지만 정책 종류가 늘고 테스트에서 정책만 바꿔 끼우고 싶어지면, 핵심 흐름보다 정책 분기가 더 크게 부풀어 오르기 시작합니다.
class CheckoutService {
fun calculateFinalPrice(amount: Long, customerType: String): Long {
return when (customerType) {
"NEW" -> amount
"VIP" -> (amount * 90) / 100
"EMPLOYEE" -> (amount * 70) / 100
else -> amount
}
}
}정책이 계속 추가되면 checkout 서비스는 결제 흐름보다 정책 구현체 모음처럼 변합니다. Strategy 패턴은 ‘무엇을 할까’가 아니라 ‘어떤 방식으로 할까’가 자주 바뀌는 문제를 본 흐름에서 분리하려고 나왔습니다.

코틀린 Strategy 패턴 구조를 코드로 보면
전통적인 구조는 인터페이스, 여러 전략 구현체, 그리고 그 전략에만 의존하는 context입니다. 호출부는 어떤 구체 클래스인지 몰라도 같은 인터페이스로 정책을 바꿔 끼울 수 있습니다.
interface DiscountStrategy {
fun apply(amount: Long): Long
}
class RegularDiscountStrategy : DiscountStrategy {
override fun apply(amount: Long): Long = amount
}
class VipDiscountStrategy : DiscountStrategy {
override fun apply(amount: Long): Long = (amount * 90) / 100
}
class EmployeeDiscountStrategy : DiscountStrategy {
override fun apply(amount: Long): Long = (amount * 70) / 100
}
class CheckoutService(
private var discountStrategy: DiscountStrategy,
) {
fun changeStrategy(newStrategy: DiscountStrategy) {
discountStrategy = newStrategy
}
fun calculateFinalPrice(amount: Long): Long {
return discountStrategy.apply(amount)
}
}이 구조의 핵심은 context가 구체 정책을 직접 계산하지 않는다는 점입니다. 정책 선택과 정책 구현을 분리하면 새로운 할인 정책을 추가해도 checkout의 계산 흐름 자체는 비교적 안정적으로 유지됩니다.
State 패턴과 왜 자꾸 헷갈릴까
State와 Strategy는 둘 다 interface와 delegation 구조를 쓰기 때문에 파일 모양만 보면 매우 닮았습니다. 하지만 Strategy의 중심 질문은 어떤 정책이나 알고리즘을 선택할까이고, State의 중심 질문은 현재 상태에서 어떤 행동이 허용되며 다음 상태는 어디로 갈까입니다.
즉 Strategy는 교체 가능한 방식이 중심이고, State는 현재 상황에 따른 행동과 전이가 중심입니다. 이 차이를 놓치면 할인 정책도 상태처럼 설명하고, 플레이어 상태도 전략처럼 설명하는 혼선이 생깁니다.

- Strategy: 교체 가능한 정책이나 알고리즘 선택이 중심이다
- State: 현재 상태에 따른 행동과 전이가 중심이다
- 전략은 보통 서로를 몰라도 되지만, 상태는 서로의 전이를 의식하는 경우가 많다
바로 이전 글인 State 패턴은 조건문 폭발을 어떻게 막을까와 함께 보면 이 구분이 더 또렷해집니다.
상태와 정책의 차이를 실무 감각으로 구분하기
헷갈리는 이유는 둘 다 결국 행동이 달라지기 때문입니다. 하지만 달라지는 이유가 다릅니다. 정책은 비즈니스 선택의 문제이고, 상태는 시스템이 현재 어떤 국면에 있는지의 문제입니다.
- 할인 정책, 배송비 정책, 정렬 기준처럼 선택해서 바꿔 끼우는 것은 Strategy에 가깝다
- 재생 중, 일시정지, 승인됨, 실패처럼 현재 국면 때문에 같은 요청의 의미가 달라지는 것은 State에 가깝다
- 정책은 보통 호출자가 바꾸고, 상태는 객체 내부 흐름에서 바뀌는 경우가 많다
정책은 바꿔 끼우는 선택이고, 상태는 현재 시스템이 놓인 국면이라는 기준을 먼저 잡으면 혼선이 크게 줄어듭니다.
코틀린에서는 왜 함수형 대안이 강할까
Kotlin 공식 문서가 설명하듯 함수는 first-class입니다. 즉 함수 자체를 값처럼 변수에 담고, 다른 함수에 넘기고, 반환할 수 있습니다. 그래서 전략이 메서드 하나짜리 정책이라면 꼭 클래스로 감싸지 않아도 됩니다.
typealias DiscountPolicy = (Long) -> Long
class CheckoutService(
private var discountPolicy: DiscountPolicy,
) {
fun changePolicy(newPolicy: DiscountPolicy) {
discountPolicy = newPolicy
}
fun calculateFinalPrice(amount: Long): Long {
return discountPolicy(amount)
}
}
val vipPolicy: DiscountPolicy = { amount -> (amount * 90) / 100 }
val employeePolicy: DiscountPolicy = { amount -> (amount * 70) / 100 }이 방식은 보일러플레이트를 크게 줄여주고, 작은 정책은 오히려 더 읽기 쉽게 만듭니다. 전략이 입력 하나를 받아 결과 하나를 돌려주는 순수 정책에 가깝다면 함수 타입은 매우 강력한 대안입니다.

higher-order function과 fun interface는 어디에 끼어들까
전략 객체를 오래 들고 있지 않고 호출 시점에만 정책을 넘기면 되는 경우에는 higher-order function이 가장 가볍습니다. 반대로 타입 이름과 도메인 의미를 주고 싶지만 람다의 간결함도 유지하고 싶다면 fun interface가 좋은 중간 지점이 됩니다.
fun calculateFinalPrice(
amount: Long,
discountPolicy: (Long) -> Long,
): Long {
return discountPolicy(amount)
}
fun interface DiscountPolicy {
fun apply(amount: Long): Long
}
val holidayPolicy = DiscountPolicy { amount ->
(amount * 85) / 100
}- higher-order function: 한 번 호출할 때만 정책을 넘길 때 특히 가볍다
- typealias 함수 타입: 메서드가 하나이고 보일러플레이트를 최소화하고 싶을 때 좋다
- fun interface: 새 타입 이름과 SAM conversion을 함께 얻고 싶을 때 좋다
Kotlin 문서 기준으로 typealias는 기존 타입의 다른 이름일 뿐이지만, fun interface는 새 타입을 만듭니다. 그래서 도메인 의미와 확장 포인트가 필요하면 fun interface 쪽 가치가 커집니다.
언제 클래스 기반 Strategy가 꼭 필요하고, 언제 과할까
람다가 편하다고 해서 항상 정답은 아닙니다. 전략에 이름과 도메인 의미가 중요하거나, 메서드가 하나로 끝나지 않거나, 설정 데이터와 여러 부가 책임이 붙기 시작하면 클래스 기반 Strategy가 다시 강해집니다.
- 전략이 메서드 하나로 끝나지 않는다
- 전략별 설정, 설명, 지원 여부 같은 부가 정보가 필요하다
- 테스트 더블과 DI 구성이 명시적인 타입 구조를 더 선호한다
- 도메인 이름 자체가 문서 역할을 해야 한다
반대로 정책이 한 줄짜리이고 재사용도 거의 없고 도메인 의미도 단순하다면, 인터페이스와 구현체 파일을 여러 개 만드는 것이 오히려 과할 수 있습니다. 작은 정렬 기준이나 짧은 계산 정책은 함수 타입 하나가 더 자연스러운 경우가 많습니다.

전략이 단순할수록 함수형 대안이 강하고, 전략의 의미와 책임이 커질수록 클래스 기반 Strategy가 다시 유리해집니다.
코틀린 Strategy 패턴을 고를 때 빠르게 쓰는 체크리스트
- 내 문제는 상태 전이보다 정책 교체가 중심인가
- 전략이 메서드 하나짜리인가, 아니면 부가 규약이 필요한가
- 전략을 오래 보관해야 하는가, 호출 시점에만 넘기면 되는가
- 타입 이름이 문서 역할을 해야 하는가
- 람다로 쓰면 더 선명한가, 오히려 의미가 숨는가
이 질문에 답하면 함수 타입으로 시작할지, fun interface로 갈지, 아니면 전통적인 인터페이스 기반 Strategy로 갈지 훨씬 빨리 판단할 수 있습니다.
함께 보면 좋은 글로는 State 패턴은 조건문 폭발을 어떻게 막을까, Chain of Responsibility 패턴으로 조건 분기 줄이기, Decorator 패턴은 상속 대신 어떻게 확장할까가 있습니다.
코틀린 디자인 패턴 시리즈
코틀린 디자인 패턴 시리즈(0) – 왜 아직도 디자인 패턴을 배워야 할까
코틀린 디자인 패턴(1) – Singleton 패턴은 언제 쓰고 object는 어떻게 다를까
코틀린 디자인 패턴(2) – Factory Method 패턴으로 생성 책임 나누기
코틀린 디자인 패턴(3) – Abstract Factory 패턴은 언제 필요할까
코틀린 디자인 패턴(4) – Builder 패턴과 named argument는 어떻게 다를까
코틀린 디자인 패턴(5) – Prototype 패턴과 data class copy 정리
코틀린 디자인 패턴(6) – Adapter 패턴으로 기존 코드를 새 인터페이스에 맞추기
코틀린 디자인 패턴(7) – Bridge 패턴은 상속 폭발을 어떻게 줄일까
코틀린 디자인 패턴(8) – Composite 패턴으로 트리 구조 다루기
코틀린 디자인 패턴(9) – Decorator 패턴은 상속 대신 어떻게 확장할까
코틀린 디자인 패턴(10) – Facade 패턴으로 복잡한 시스템 감추기
코틀린 디자인 패턴(11) – Flyweight 패턴은 메모리를 어떻게 아낄까
코틀린 디자인 패턴(12) – Proxy 패턴은 Decorator와 무엇이 다를까
코틀린 디자인 패턴(13) – Chain of Responsibility 패턴으로 조건 분기 줄이기
코틀린 디자인 패턴(14) – Command 패턴으로 실행과 취소를 다루는 법
코틀린 디자인 패턴(15) – Interpreter 패턴은 어디까지 써야 할까
코틀린 디자인 패턴(16) – Iterator 패턴과 sequence는 어떻게 연결될까
코틀린 디자인 패턴(17) – Mediator 패턴으로 객체 간 얽힘 줄이기
코틀린 디자인 패턴(18) – Memento 패턴으로 상태 저장과 복원하기