2025-09-23 20:39
-
상태 패턴은 객체의 내부 상태가 변경될 때마다 객체의 행동을 바꾸는 디자인 패턴이다.
-
상태 패턴을 사용하면 상태에 따른 조건문 코드를 줄여 유지보수성을 높일 수 있다.
-
각 상태를 별도의 클래스로 캡슐화하고, 컨텍스트 객체는 현재 상태 객체에 행동을 위임한다.
당신의 코드를 유연하게 만드는 비결 상태 패턴 완벽 핸드북
개발을 하다 보면 객체가 특정 상태에 따라 다르게 행동해야 하는 경우가 많다. 예를 들어, 온라인 쇼핑몰의 주문 상태는 ‘결제 대기’, ‘배송 중’, ‘배송 완료’, ‘주문 취소’ 등 다양하며, 각 상태에 따라 사용자가 할 수 있는 행동(결제, 배송 조회, 환불 요청 등)이 달라진다.
이러한 요구사항을 가장 단순하게 구현하는 방법은 조건문(if-else 또는 switch-case)을 사용하는 것이다. 하지만 상태가 많아지고 상태에 따른 행동이 복잡해질수록 조건문 코드는 길고 복잡해져 가독성과 유지보수성을 크게 떨어뜨린다. 새로운 상태가 추가될 때마다 수많은 조건문을 수정해야 하는 ‘코드 지옥’에 빠지기 쉽다.
이러한 문제를 해결하기 위해 등장한 것이 바로 **상태 패턴(State Pattern)**이다. 상태 패턴은 객체의 내부 상태가 변경됨에 따라 행동을 변경할 수 있도록 하는 행위 디자인 패턴이다. 마치 객체가 자신의 클래스를 바꾸는 것처럼 보이게 만든다.
이 핸드북에서는 상태 패턴이 왜 만들어졌는지부터 시작하여 그 구조와 사용법, 그리고 심화 내용까지 깊이 있게 탐색하며 상태 패턴의 모든 것을 완벽하게 이해할 수 있도록 안내한다.
1. 상태 패턴은 왜 만들어졌는가
상태 패턴의 탄생 배경을 이해하기 위해 간단한 예시를 살펴보자. 형광등을 켜고 끄는 프로그램을 만든다고 가정해본다. 형광등은 ‘꺼짐(Off)‘과 ‘켜짐(On)‘이라는 두 가지 상태를 가진다.
조건문을 사용한 방식
Java
public class Light {
private String state; // "on", "off"
public Light() {
this.state = "off";
}
public void on_button_pushed() {
if (state.equals("off")) {
System.out.println("불을 켭니다.");
this.state = "on";
} else {
System.out.println("변화 없음");
}
}
public void off_button_pushed() {
if (state.equals("on")) {
System.out.println("불을 끕니다.");
this.state = "off";
} else {
System.out.println("변화 없음");
}
}
}
이 코드는 간단하고 직관적이다. 하지만 형광등에 ‘수면 모드’나 ‘절전 모드’와 같은 새로운 상태가 추가된다면 어떻게 될까? on_button_pushed와 off_button_pushed 메서드 내의 조건문은 점점 더 복잡해질 것이다. 상태가 추가될 때마다 모든 관련 메서드를 수정해야 하며, 이는 OCP(개방-폐쇄 원칙)를 위반한다.
이러한 문제의 근본적인 원인은 상태와 행동이 하나의 클래스 안에 강하게 결합되어 있기 때문이다. 상태 패턴은 이 결합을 끊는 것에서부터 시작한다.
상태 패턴의 핵심 아이디어
“상태에 따라 달라지는 행동을 각각의 상태 객체에게 위임하자.”
상태 패턴은 각 상태를 별도의 클래스로 분리한다. 그리고 상태를 가지는 객체(Context)는 현재 상태를 나타내는 상태 객체(State)를 참조한다. Context에 요청이 들어오면, Context는 직접 처리하는 대신 현재 상태 객체에게 처리를 위임한다. 상태가 변경되어야 할 때는 현재 상태 객체를 다른 상태 객체로 교체하기만 하면 된다.
이렇게 함으로써 우리는 다음과 같은 이점을 얻을 수 있다.
-
단일 책임 원칙 (SRP): 각 상태 클래스는 자신과 관련된 행동만 책임진다. 상태 추가 및 수정 시 다른 상태 클래스에 영향을 주지 않는다.
-
개방-폐쇄 원칙 (OCP): 새로운 상태가 추가되더라도 기존 Context 코드나 다른 상태 클래스를 수정할 필요가 없다. 새로운 상태 클래스를 추가하기만 하면 된다.
-
가독성 및 유지보수성 향상: 복잡한 조건문이 사라지고, 각 상태의 행동이 명확하게 분리되어 코드를 이해하고 관리하기 쉬워진다.
결론적으로 상태 패턴은 상태에 따른 로직을 캡슐화하여 객체의 행동을 유연하게 확장하고 변경할 수 있도록 만들어주기 위해 탄생했다.
2. 상태 패턴의 구조 파헤치기
상태 패턴은 크게 세 가지 주요 구성 요소로 이루어진다.
| 구성 요소 | 역할 |
|---|---|
| Context | 상태를 가지는 객체. 현재 상태를 나타내는 State 객체를 참조하며, 클라이언트의 요청을 현재 State 객체에 위임한다. State를 변경하는 인터페이스를 제공한다. |
| State | 인터페이스 또는 추상 클래스. Context가 가질 수 있는 모든 상태의 공통 인터페이스를 정의한다. 각 상태에서 처리할 수 있는 메서드를 선언한다. |
| ConcreteState | State 인터페이스를 구현한 구체적인 상태 클래스. 각 상태에 따른 실제 행동을 구현한다. 필요에 따라 Context의 상태를 다른 ConcreteState로 변경할 수 있다. |
이를 다이어그램으로 표현하면 다음과 같다.
+----------------+ +------------------+
| Context |----->| State |
+----------------+ +------------------+
| - state | | + handle() |
| + request() | +------------------+
| + setState() | ^
+----------------+ |
|
+---------------------+---------------------+
| |
+------------------+ +------------------+
| ConcreteStateA | | ConcreteStateB |
+------------------+ +------------------+
| + handle() | | + handle() |
+------------------+ +------------------+
-
클라이언트는 Context 객체와 상호작용한다. 클라이언트는 상태에 대해 알 필요가 없다.
-
Context는 클라이언트로부터
request()요청을 받는다. -
Context는 이 요청을 현재 자신이 참조하고 있는 State 객체(예:
ConcreteStateA)의handle()메서드에 위임한다. -
ConcreteStateA의handle()메서드는 해당 상태에서 수행해야 할 구체적인 작업을 실행한다. -
handle()메서드 실행 중 상태 전환이 필요하면,ConcreteStateA는 Context의setState()메서드를 호출하여 상태를 다른ConcreteState(예:ConcreteStateB)로 변경한다.
이러한 구조를 통해 Context는 상태에 따른 구체적인 행동 로직으로부터 분리될 수 있으며, 오직 현재 상태 객체에 대한 참조만 유지하면 된다.
3. 상태 패턴 실전 사용법
이제 앞서 살펴본 형광등 예시를 상태 패턴을 적용하여 다시 구현해보자.
1단계: State 인터페이스 정의
먼저 형광등이 가질 수 있는 모든 상태의 공통 행동을 정의하는 State 인터페이스를 만든다. 형광등은 on_button_pushed와 off_button_pushed라는 두 가지 주요 이벤트를 처리해야 한다.
Java
// State 인터페이스
public interface State {
void on_button_pushed(Light light);
void off_button_pushed(Light light);
}
각 메서드는 Light 객체(Context)를 인자로 받아, 필요시 Context의 상태를 변경할 수 있도록 한다.
2단계: ConcreteState 클래스 구현
각각의 구체적인 상태(‘꺼짐’, ‘켜짐’)를 클래스로 구현한다.
- OffState (꺼진 상태)
Java
public class OffState implements State {
private static OffState instance = new OffState(); // 싱글턴 패턴 적용
private OffState() {}
public static OffState getInstance() {
return instance;
}
@Override
public void on_button_pushed(Light light) {
System.out.println("불을 켭니다.");
light.setState(OnState.getInstance()); // 상태를 OnState로 변경
}
@Override
public void off_button_pushed(Light light) {
System.out.println("반응 없음");
}
}
- OnState (켜진 상태)
Java
public class OnState implements State {
private static OnState instance = new OnState(); // 싱글턴 패턴 적용
private OnState() {}
public static OnState getInstance() {
return instance;
}
@Override
public void on_button_pushed(Light light) {
System.out.println("반응 없음");
}
@Override
public void off_button_pushed(Light light) {
System.out.println("불을 끕니다.");
light.setState(OffState.getInstance()); // 상태를 OffState로 변경
}
}
여기서 주목할 점은 각 상태 클래스를 **싱글턴(Singleton)**으로 구현했다는 것이다. 상태 객체는 내부에 별도의 멤버 변수를 가지지 않으므로, 여러 개를 만들 필요 없이 하나만 생성하여 공유하는 것이 효율적이다.
3단계: Context 클래스 구현
상태를 가지는 Light 클래스를 구현한다.
Java
// Context 클래스
public class Light {
private State state; // 현재 상태
public Light() {
// 초기 상태는 '꺼짐'
this.state = OffState.getInstance();
}
public void setState(State state) {
this.state = state;
}
public void on_button_pushed() {
state.on_button_pushed(this);
}
public void off_button_pushed() {
state.off_button_pushed(this);
}
}
Light 클래스는 더 이상 상태에 따른 조건문을 가지지 않는다. 대신 모든 요청을 현재 state 객체에 위임한다. 상태 변경의 책임은 ConcreteState 클래스들이 가져간다.
4단계: 클라이언트 코드 실행
Java
public class Client {
public static void main(String[] args) {
Light light = new Light(); // 초기 상태: Off
light.off_button_pushed(); // 반응 없음
light.on_button_pushed(); // 불을 켭니다.
light.on_button_pushed(); // 반응 없음
light.off_button_pushed(); // 불을 끕니다.
}
}
결과 분석
상태 패턴을 적용함으로써 얻는 이점은 명확하다.
-
확장성: 만약 ‘수면 모드(SleepingState)‘라는 새로운 상태가 추가된다고 해도,
SleepingState클래스를 새로 만들고State인터페이스를 구현하기만 하면 된다. 기존의Light,OnState,OffState코드는 전혀 수정할 필요가 없다. -
응집도: 상태와 관련된 모든 로직은 해당 상태 클래스 안에 모여있다.
OnState의 행동을 알고 싶으면OnState클래스만 보면 되고,OffState의 행동을 알고 싶으면OffState클래스만 보면 된다. 코드의 응집도가 높아졌다. -
가독성:
Light클래스에서 복잡한 조건문이 사라져 코드가 훨씬 간결하고 이해하기 쉬워졌다.
4. 심화 내용 및 고려사항
상태 패턴은 강력하지만, 모든 상황에 적합한 만능 해결책은 아니다. 사용하기 전에 몇 가지 고려할 점이 있다.
상태 전환의 책임 소재
상태 전환을 누가 책임질 것인지는 두 가지 방식으로 결정할 수 있다.
-
Context가 책임지는 방식: 클라이언트로부터 특정 상태로 변경하라는 명시적인 요청을 받아 Context가
setState()를 직접 호출하는 방식이다. 상태 전환 규칙이 한 곳(Context)에 집중되어 관리하기 편할 수 있지만, Context가 모든 상태와 전환 규칙을 알아야 하므로 결합도가 높아진다. -
ConcreteState가 책임지는 방식 (위 예시): 각
ConcreteState가 다음 상태를 결정하고 Context의setState()를 호출하여 스스로 상태를 전환하는 방식이다. 상태 전환 규칙이 각 상태 클래스에 분산되어 Context는 다음 상태가 무엇인지 알 필요가 없다. 이는 결합도를 낮추고 각 상태의 독립성을 높이지만, 상태 전환 로직이 여러 클래스에 흩어져 있어 전체적인 흐름을 파악하기 어려울 수 있다.
대부분의 경우, 상태 전환의 로직 자체가 상태의 본질적인 행동 중 하나이므로 ConcreteState가 책임을 지는 방식이 더 객체지향적이고 일반적이다.
상태 객체의 관리: 싱글턴 vs. 동적 생성
-
싱글턴: 상태 객체가 내부 데이터를 가지지 않고 행동만 정의한다면, 싱글턴으로 만들어 공유하는 것이 메모리 효율성 면에서 유리하다. 대부분의 간단한 상태 패턴 구현에서 이 방식을 사용한다.
-
동적 생성: 상태 객체가 특정 데이터를 저장해야 한다면 (예: ‘일시 정지’ 상태가 정지된 시점을 기억해야 하는 경우), 매번 새로운 상태 객체를
new키워드로 생성해야 한다.
전략 패턴(Strategy Pattern)과의 비교
상태 패턴은 구조적으로 전략 패턴과 매우 유사하다. 둘 다 알고리즘(행동)을 객체로 캡슐화하고 Context가 이를 위임하는 방식을 사용한다. 하지만 의도에서 결정적인 차이가 있다.
| 구분 | 상태 패턴 (State Pattern) | 전략 패턴 (Strategy Pattern) |
|---|---|---|
| 의도 | 객체의 내부 상태가 변경됨에 따라 행동을 바꾸는 것. | 클라이언트가 필요에 따라 알고리즘(전략)을 교체하여 사용하는 것. |
| 행동 변경 주체 | Context 또는 상태 객체 스스로 (내부적으로) | 클라이언트 (외부에서) |
| 핵심 | ’상태’와 ‘상태에 따른 행동’의 캡슐화 | ’알고리즘’의 캡슐화 및 동적 교체 |
비유하자면, 상태 패턴은 한 사람(Context)이 기분(State)에 따라 행동(노래 부르기, 화내기)이 저절로 바뀌는 것과 같다. 반면 전략 패턴은 한 사람(Context)이 이동할 때(request) 교통수단(Strategy)을 필요에 따라 버스, 지하철, 택시 중에서 선택해서 타는 것과 같다.
상태 패턴의 장단점 요약
| 장점 | 단점 |
|---|---|
| 상태에 따른 복잡한 조건문 코드를 제거한다. | 상태가 적고 단순한 경우에는 오히려 코드가 복잡해지고 클래스 수가 늘어난다. |
| 각 상태의 행동을 캡슐화하여 응집도를 높인다. | 상태 전환 로직이 여러 클래스에 분산될 경우 전체적인 흐름을 이해하기 어려울 수 있다. |
| 새로운 상태 추가 시 기존 코드를 수정하지 않아도 된다 (OCP). | |
| 코드의 가독성과 유지보수성이 향상된다. |
결론: 언제 상태 패턴을 사용해야 하는가
상태 패턴은 다음과 같은 상황에서 유용하게 사용될 수 있다.
-
객체가 수많은 상태를 가지며, 각 상태에 따라 행동이 크게 달라질 때. (예: 자판기, 온라인 주문, 게임 캐릭터)
-
if-else나switch-case와 같은 조건문이 길고 복잡하게 얽혀 있어 리팩토링이 필요할 때. -
현재 상태에 기반한 로직이 여러 곳에 중복되어 나타날 때.
상태 패턴은 단순히 복잡한 조건문을 없애는 기술이 아니다. 그것은 상태와 행동을 하나의 단위로 캡슐화하여 객체를 더욱 객체답게 만들고, 코드의 유연성과 확장성을 극대화하는 강력한 설계 원칙이다. 상태에 따라 변화무쌍하게 행동하는 객체를 만들어야 한다면, 상태 패턴은 당신의 코드를 한 단계 더 높은 수준으로 이끌어 줄 최고의 비결이 될 것이다.