|

코틀린 디자인 패턴 (20) – State 패턴은 조건문 폭발을 어떻게 막을까

코틀린 State 패턴 대표 이미지
State 패턴은 상태별 행동과 전이를 객체로 모아 조건문 폭발을 줄인다

코틀린 State 패턴은 상태에 따라 같은 요청의 동작이 크게 바뀌는 객체를 정리할 때 특히 힘을 발휘합니다. 결론부터 말하면 핵심은 상태 이름을 저장하는 데 있지 않고, 상태별 행동과 전이 규칙을 상태 객체 안으로 밀어 넣어 context에 쌓이던 거대한 if/when 분기를 줄이는 데 있습니다.

이번 글에서는 조건문 폭발이 왜 생기는지부터 보고, 코틀린에서 State 패턴을 어떻게 구현하는지, sealed class 기반 대안과는 무엇이 다른지, Strategy와 왜 자꾸 헷갈리는지까지 차분하게 정리하겠습니다.

State 패턴 핵심 요약 카드
이번 글에서 먼저 잡아야 할 핵심 판단 기준

기본 개념 참고로는 Refactoring.Guru의 State 패턴 설명, Strategy와의 차이 정리는 Strategy 패턴 설명, 코틀린 쪽 상태 모델링 근거는 Kotlin sealed classes 문서Kotlin when 문서를 참고했습니다.

왜 조건문 폭발이 생길까

처음에는 상태가 두세 개뿐이라 if나 when으로 충분해 보입니다. 하지만 clickPlay, clickNext, clickStop, renderButton, canSeek 같은 메서드마다 같은 상태 분기가 반복되기 시작하면, 상태 규칙이 한 군데가 아니라 여러 메서드에 흩어집니다.

class AudioPlayer {
    enum class PlayerState {
        READY, PLAYING, PAUSED
    }

    private var state = PlayerState.READY

    fun clickPlay() {
        when (state) {
            PlayerState.READY -> {
                startPlayback()
                state = PlayerState.PLAYING
            }
            PlayerState.PLAYING -> {
                pausePlayback()
                state = PlayerState.PAUSED
            }
            PlayerState.PAUSED -> {
                resumePlayback()
                state = PlayerState.PLAYING
            }
        }
    }

    fun clickNext() {
        when (state) {
            PlayerState.READY -> showMessage("재생 목록이 준비되지 않았습니다")
            PlayerState.PLAYING -> nextTrack()
            PlayerState.PAUSED -> showMessage("일시정지 중에는 다음 곡 이동을 제한합니다")
        }
    }
}

처음에는 읽을 만하지만, 상태 하나를 추가할 때마다 여러 when을 동시에 고쳐야 하고 전이 규칙을 바꿀 때도 누락 위험이 커집니다. 조건문 폭발은 if/when 자체가 나빠서가 아니라, 상태별 행동이 여러 메서드에 중복 배치되기 시작할 때 생깁니다.

조건문 기반 상태 처리와 State 패턴 위임 구조 비교 도식
분기를 메서드마다 반복하는 대신 현재 상태 객체에 위임하는 구조로 바꾼다

코틀린 State 패턴 구조를 코드로 보면

State 패턴은 context가 현재 state 객체를 하나 들고 있고, 상태별 행동은 각 state 구현체로 보내는 구조입니다. 즉 현재 상태를 조건문으로 매번 묻는 대신, 현재 상태 객체에게 직접 일을 시키는 쪽으로 바꿉니다.

interface PlayerState {
    fun clickPlay(player: AudioPlayer)
    fun clickNext(player: AudioPlayer)
    fun clickStop(player: AudioPlayer)
}

class AudioPlayer {
    private var state: PlayerState = ReadyState

    fun changeState(newState: PlayerState) {
        state = newState
    }

    fun clickPlay() = state.clickPlay(this)
    fun clickNext() = state.clickNext(this)
    fun clickStop() = state.clickStop(this)

    fun startPlayback() = println("start")
    fun pausePlayback() = println("pause")
    fun resumePlayback() = println("resume")
    fun stopPlayback() = println("stop")
    fun nextTrack() = println("next")
    fun showMessage(message: String) = println(message)
}

object ReadyState : PlayerState {
    override fun clickPlay(player: AudioPlayer) {
        player.startPlayback()
        player.changeState(PlayingState)
    }

    override fun clickNext(player: AudioPlayer) {
        player.showMessage("재생 목록이 준비되지 않았습니다")
    }

    override fun clickStop(player: AudioPlayer) = Unit
}

object PlayingState : PlayerState {
    override fun clickPlay(player: AudioPlayer) {
        player.pausePlayback()
        player.changeState(PausedState)
    }

    override fun clickNext(player: AudioPlayer) {
        player.nextTrack()
    }

    override fun clickStop(player: AudioPlayer) {
        player.stopPlayback()
        player.changeState(ReadyState)
    }
}

object PausedState : PlayerState {
    override fun clickPlay(player: AudioPlayer) {
        player.resumePlayback()
        player.changeState(PlayingState)
    }

    override fun clickNext(player: AudioPlayer) {
        player.showMessage("일시정지 중에는 다음 곡 이동을 제한합니다")
    }

    override fun clickStop(player: AudioPlayer) {
        player.stopPlayback()
        player.changeState(ReadyState)
    }
}

이제 Playing 상태의 규칙은 PlayingState 안에 모입니다. 다른 메서드 곳곳에서 when(state)를 반복하지 않아도 되므로, 상태별 행동과 전이를 읽는 위치가 훨씬 선명해집니다.


State 패턴이 조건문 폭발을 막는 이유

  1. 상태별 규칙이 한 군데 모여 추적이 쉬워진다
  2. 새 상태를 추가할 때 영향 범위를 예측하기 쉬워진다
  3. 전이 규칙을 상태 근처에서 읽을 수 있다
  4. context는 공통 서비스 제공과 현재 상태 보관에 더 집중할 수 있다

엄밀히 말하면 분기가 완전히 사라지는 것은 아닙니다. 대신 분기가 context의 여러 메서드에 복제되지 않고 각 상태 객체 안으로 이동합니다. 그래서 읽기 위치와 수정 위치가 훨씬 안정됩니다.

코틀린에서는 상태가 immutable singleton처럼 다뤄질 때 object로 표현하기 좋아서 State 패턴이 전통적인 OOP 예제보다 더 간결하게 보이기도 합니다. 상태마다 데이터가 필요하면 data class 상태 구현으로도 확장할 수 있습니다.


sealed class 기반 대안과는 무엇이 다를까

여기서 가장 많이 나오는 질문은 “코틀린에서는 sealed class + when이면 충분한 것 아닌가”입니다. 이 질문은 절반은 맞고 절반은 아닙니다. sealed class는 상태의 경우의 수를 닫고 exhaustive when을 만들며 상태별 payload를 다르게 붙이는 데 매우 강합니다.

sealed interface PlayerUiState {
    data object Ready : PlayerUiState
    data object Playing : PlayerUiState
    data object Paused : PlayerUiState
    data class Error(val message: String) : PlayerUiState
}

fun renderTitle(state: PlayerUiState): String = when (state) {
    PlayerUiState.Ready -> "재생 준비"
    PlayerUiState.Playing -> "재생 중"
    PlayerUiState.Paused -> "일시정지"
    is PlayerUiState.Error -> state.message
}

문제는 상태별 행동이 여러 함수에 퍼질 때입니다. clickPlay, clickStop, renderButton, canSeek 같은 함수마다 같은 when이 반복되면, sealed class 자체는 깔끔해도 행동 조직은 다시 분산될 수 있습니다.

State 패턴과 sealed class 상태 모델링 비교 카드
상태 표현에 강한 쪽과 상태별 행동 위임에 강한 쪽이 다르다
  • sealed class: 상태의 경우의 수와 데이터 모델링에 강하다
  • State 패턴: 상태별 행동과 전이 규칙의 위임에 강하다

상태를 표현하는 문제와 상태에 따라 행동을 조직하는 문제는 비슷해 보여도 완전히 같은 문제는 아닙니다. 상태 표현이 중심이면 sealed class가 먼저고, 같은 상태 분기가 여러 메서드에 퍼지기 시작하면 State 패턴을 검토할 가치가 커집니다.

이 감각은 sealed class vs enum 상태 모델링 글과도 직접 연결됩니다.


언제 State 패턴이 과할까

State 패턴은 멋져 보이지만 모든 상태 문제의 정답은 아닙니다. 상태 수가 적고 행동 차이도 작거나, 상태 전이가 거의 없거나, 상태별 메서드가 한두 개뿐인 경우에는 enum이나 sealed class로 충분한 경우가 많습니다.

  1. 상태 수가 적고 행동 차이도 작다
  2. 로딩 → 성공/실패처럼 일회성 흐름이라 값 모델링이 더 중요하다
  3. 상태별 메서드가 한두 개라 객체 분리보다 한 파일 분기가 더 읽기 쉽다
  4. context가 제공할 공통 서비스가 거의 없어 State 구조의 이득이 작다

조건문이 있다는 이유만으로 State 패턴으로 가는 것은 과한 반응일 수 있습니다. 핵심은 조건문 개수보다 상태별 행동이 얼마나 여러 위치에 흩어졌는지입니다.

State 패턴이 잘 맞는 경우와 과한 경우 비교 카드
State 패턴은 상태별 행동과 전이가 커질수록 가치가 커진다

State vs Strategy는 어떻게 구분할까

State와 Strategy는 interface를 두고 구현체에 위임한다는 점에서 구조가 정말 닮았습니다. 하지만 Strategy의 중심 질문은 어떤 알고리즘이나 정책을 선택할까이고, State의 중심 질문은 현재 상태에서 어떤 행동이 허용되며 다음 상태는 어디로 갈까입니다.

Refactoring.Guru 설명처럼 State 쪽은 concrete state가 서로를 알고 전이를 일으키는 경우가 많고, Strategy는 보통 전략끼리 서로를 알지 않습니다. 즉 Strategy는 교체 가능한 방식의 선택이 중심이고, State는 상태와 전이 서사가 중심입니다.

interface PaymentStrategy {
    fun pay(amount: Long)
}

class KakaoPayStrategy : PaymentStrategy {
    override fun pay(amount: Long) = println("카카오페이 $amount")
}

class CardStrategy : PaymentStrategy {
    override fun pay(amount: Long) = println("카드 $amount")
}

이 예제에서 전략은 현재 상태를 표현하지 않습니다. client가 원하는 알고리즘을 고를 뿐입니다. 반면 State는 PausedState -> PlayingState처럼 전이 자체가 중요한 설계입니다.


실전에서 스스로 던지면 좋은 질문

  1. 내 문제는 상태 이름을 안전하게 표현하는 문제인가
  2. 아니면 상태별 행동이 여기저기 복제되는 문제인가
  3. 상태 전이가 자주 바뀌는가
  4. 같은 when(state)가 여러 함수에 반복되고 있는가
  5. 상태 객체로 옮기면 규칙을 더 가까이 모을 수 있는가
  6. 반대로 파일만 늘고 읽기는 더 어려워지지 않는가

이 질문에 답하면 sealed class로 충분한지, State 패턴까지 갈지 훨씬 빨리 판단할 수 있습니다. 정리하면 sealed class는 상태 표현에 강하고, State 패턴은 상태별 행동 조직에 강합니다.

함께 보면 좋은 글로는 Chain of Responsibility 패턴으로 조건 분기 줄이기, Observer 패턴과 Flow 감각 함께 이해하기, Memento 패턴으로 상태 저장과 복원하기가 있습니다.


코틀린 디자인 패턴 시리즈

코틀린 디자인 패턴 시리즈(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 패턴으로 상태 저장과 복원하기

코틀린 디자인 패턴(19) – Observer 패턴과 Flow 감각 함께 이해하기

함께보면 좋은 글