State 패턴: if-else 대신 상태를 객체로 다루는 법
State 패턴이 상태 전이를 어떻게 객체로 캡슐화하는지, Strategy 패턴과 무엇이 다른지, 동시성·클래스 폭증 트레이드오프를 실무 관점에서 정리한다.
2026-08-13 · 최초 발행 2025-10-14
미디어 플레이어에 정지·재생·일시정지 세 가지 상태만 있어도, play()가 호출됐을 때 지금이 어느 상태인지에 따라 동작이 갈린다. 이 분기를 메서드마다 if-else로 반복해서 쓰면 상태가 하나 늘 때마다 모든 메서드의 조건문을 다시 훑어야 한다. State 패턴은 상태 자체를 객체로 만들어 이 문제를 해결한다.
컨텍스트가 상태에 위임한다는 것
컨텍스트(Context)는 현재 상태를 들고 있다가 연산이 들어오면 그 상태 객체에 위임한다. State 인터페이스는 컨텍스트가 호출할 행위 계약을 정의하고, Concrete State는 상태별 동작과 전이 규칙을 구현한다. 이벤트가 들어왔을 때 다음 상태를 무엇으로 할지 결정하는 것도 이 상태 객체의 몫이다. 목적은 분기 복잡도를 없애는 데 그치지 않는다 — 상태 전이 규칙 자체를 명시적으로 모델링해서, 상태가 바뀌어도 영향 범위를 국소화하는 것이 핵심이다.
전이 로직을 국소화하면 달라지는 것들
상태 전이를 객체로 만들면 각 상태가 허용·거부하는 행위가 명시적으로 드러나고, 사이클로매틱 복잡도가 줄어 코드 리뷰와 변경 추적이 수월해진다. 같은 인터페이스 아래 다형성으로 실행 중 행위를 바꿀 수 있다는 점도 기능 플래그나 실험군 분기와 결합할 때 위험을 격리하는 데 도움이 된다. 신규 상태를 추가할 때 기존 상태를 거의 건드리지 않아도 되고, 단위 테스트에서 상태별 케이스를 따로 떼어 검증하기 쉬우며, 상태 전이 테이블을 두면 검증 자체를 자동화할 수도 있다.
물론 상태 변경이 여러 스레드에서 동시에 일어날 수 있는 환경이라면 이 이점은 원자성을 보장해야 유지된다. 불변 상태 객체, CAS 또는 락 기반 전이, 단일 스레드 전담 루프 중 하나를 선택해 경쟁 조건을 막아야 한다.
State와 Strategy는 왜 헷갈리는가
두 패턴 모두 "인터페이스 뒤에 행위를 숨기고 다형성으로 교체한다"는 겉모습이 같아서 자주 혼동된다. 하지만 의도가 다르다. Strategy는 알고리즘 교체가 중심이고 전이라는 개념 자체가 없다 — 클라이언트가 어떤 전략을 쓸지 결정해서 주입할 뿐, 전략끼리 관계를 맺지 않는다. State는 상태 전이와 라이프사이클 모델링이 핵심이고, 상태 간 관계가 구조 안에 내재돼 있다 — 지금 상태가 다음에 어떤 상태로 갈 수 있는지 그 자체가 설계 대상이다.
상태 전이 흐름
이벤트가 들어오면 컨텍스트는 현재 상태 객체에 위임하고, 상태 객체는 동작을 수행한 뒤 전이 규칙을 판단한다. 합법적인 전이라면 컨텍스트의 상태를 원자적으로 교체하고, 불법 전이라면 예외를 던지거나 no-op으로 무시하며 로그를 남긴다. 상태 변경과 함께 일어나는 부수 작업(DB 업데이트 등)은 트랜잭션 경계를 정합성 있게 맞춰야 하고, 분산 시스템이라면 상태 저장소에 대한 CAS(버전 체크)로 멱등성을 보장해야 한다.
미디어 플레이어로 보는 State 구현
JDK 17 이상, 표준 자바 컴파일 환경을 전제로 한다. 동시성은 단순화를 위해 synchronized 기반 원자 전이로 처리했다.
// File: MediaPlayer.java
interface State {
void play(MediaPlayer ctx);
void pause(MediaPlayer ctx);
void stop(MediaPlayer ctx);
String name();
}
final class MediaPlayer {
private State state = StoppedState.INSTANCE;
public synchronized String getState() { return state.name(); }
synchronized void setState(State next) { this.state = next; }
public void play() { synchronized (this) { state.play(this); } }
public void pause() { synchronized (this) { state.pause(this); } }
public void stop() { synchronized (this) { state.stop(this); } }
// 부수 효과 예시
void startTimer() { /* ... */ }
void stopTimer() { /* ... */ }
}
final class StoppedState implements State {
static final StoppedState INSTANCE = new StoppedState();
private StoppedState() {}
public void play(MediaPlayer ctx) {
// 시작 로직
ctx.startTimer();
ctx.setState(PlayingState.INSTANCE);
}
public void pause(MediaPlayer ctx) { /* No-op or throw */ }
public void stop(MediaPlayer ctx) { /* No-op */ }
public String name() { return "Stopped"; }
}
final class PlayingState implements State {
static final PlayingState INSTANCE = new PlayingState();
private PlayingState() {}
public void play(MediaPlayer ctx) { /* No-op */ }
public void pause(MediaPlayer ctx) {
ctx.stopTimer();
ctx.setState(PausedState.INSTANCE);
}
public void stop(MediaPlayer ctx) {
ctx.stopTimer();
ctx.setState(StoppedState.INSTANCE);
}
public String name() { return "Playing"; }
}
final class PausedState implements State {
static final PausedState INSTANCE = new PausedState();
private PausedState() {}
public void play(MediaPlayer ctx) {
ctx.startTimer();
ctx.setState(PlayingState.INSTANCE);
}
public void pause(MediaPlayer ctx) { /* No-op */ }
public void stop(MediaPlayer ctx) {
ctx.stopTimer();
ctx.setState(StoppedState.INSTANCE);
}
public String name() { return "Paused"; }
}
public class Demo {
public static void main(String[] args) {
MediaPlayer p = new MediaPlayer();
System.out.println(p.getState()); // Stopped
p.play(); System.out.println(p.getState()); // Playing
p.pause(); System.out.println(p.getState()); // Paused
p.stop(); System.out.println(p.getState()); // Stopped
}
}
상태별 공통 로깅·메트릭이 필요하면 데코레이터나 AOP를 얹으면 되고, 불법 전이를 예외로 던질지 no-op으로 넘길지 아니면 안전 상태로 보정할지는 도메인 정책에 따라 정한다.
실무에서 상태 패턴이 등장하는 곳
- 결제/주문 워크플로우: Created → Paid → Shipped → Completed/Cancelled, 결제 실패·재시도 규칙의 상태화
- 네트워크 연결 관리: Disconnected/Connecting/Connected/Retrying, 재연결 백오프와 타임아웃 처리 통합
- 미디어 플레이어/장비 제어: Stopped/Playing/Paused, 이벤트 누적·무시 규칙 명료화
- 티켓/이슈 트래커: Open → In Progress → Resolved → Closed, 역할·권한별 전이 제약 반영
- IoT 디바이스 펌웨어: Booting/Active/Sleep/Updating, 전력 정책·업데이트 원자성 보장
큰 조건문과 비교하면
| 항목 | State 패턴 | 거대 if-else/switch | Strategy 패턴 |
|---|---|---|---|
| 성능 | 호출 간접비용 소폭 증가, 전이 비용 명시화 | 조건 평가 비용 증가, 분기 급증 시 저하 | 유사 |
| 확장성 | 신규 상태 클래스 추가로 저충돌 확장 | 기존 분기 수정 필요, 충돌 위험 | 알고리즘 교체 용이, 전이 없음 |
| 일관성 | 전이 규칙 캡슐화, 회귀 감소 | 로직 산재, 규칙 누락 위험 | 규칙 통일 어려움 |
| 안정성 | 불법 전이 제어 용이, 테스트 용이 | 분기 누락·사이드이펙트 증가 | 전이 모델 없음 |
| 운영 편의 | 상태 관찰·모니터링 지표 설계 용이 | 관찰 지점 파편화 | 관찰 지점 제한적 |
운영 팁: 전이 테이블, 불변화, 클래스 폭증 관리
전이 규칙은 상태 전이 테이블이나 다이어그램으로 명세하고 코드와 동기화 상태를 유지해야 규칙이 문서와 따로 놀지 않는다. 상태 객체는 불변·싱글턴으로 만들면 메모리·생성 비용이 줄고 동등성 비교도 쉬워지며, 상태 쪽에서는 컨텍스트의 최소 API만 참조하도록 결합을 낮춘다. 동시성은 단일 스레드 이벤트 루프로 락을 최소화하거나, 다중 스레드 환경이라면 synchronized 또는 AtomicReference로 상태 교체를 원자화하는 전략 중에서 고른다. 현재 상태, 최근 전이 이벤트, 전이 실패 카운터를 메트릭으로 노출해두면 관찰 가능성이 확보된다.
단순한 유한 상태 기계라면 클래스가 폭증하는 대신 enum과 전이 테이블로 대체하는 것도 고려할 만하다. 대형 분기를 제거한다고 가정했을 때 사이클로매틱 복잡도는 3060%, 전이 누락·불법 호출 관련 버그는 2040% 감소가 기대되고, 신규 상태 추가 시 영향 범위가 국소화되면서 코드 변경 라인 수(LoC)도 30% 이상 줄어드는 것으로 제시된다. 상태 기반 모니터링을 도입하면 장애 탐지 평균 시간(MTTA)도 단축되는 효과가 있다.