안녕하세요, 개발자 여러분! 코드를 작성하다 보면 수많은 if-else
또는 switch
문 때문에 골머리를 앓았던 경험, 다들 한 번쯤 있으시죠? 객체의 '상태'에 따라 '행동'이 달라져야 할 때, 이 조건문들은 눈덩이처럼 불어나 코드를 복잡하게 만들고 유지보수를 악몽으로 만듭니다.
오늘은 이 문제를 아주 우아하고 객체지향적으로 해결해주는 강력한 도구, GoF(Gang of Four)의 상태(State) 패턴에 대해 기본 개념부터 실전 응용 예제까지 깊이 있게 파헤쳐 보겠습니다.
상태 패턴, 왜 필요할까요? (문제 인식)
상태 패턴의 진가를 알기 위해 먼저 문제가 되는 상황을 살펴보겠습니다. 온라인 문서 편집기를 만든다고 상상해봅시다. 문서는 '초안', '검토 중', '발행됨'이라는 세 가지 상태를 가집니다.
- 초안 (Draft): 글 수정 가능, 발행 요청 시 '검토 중' 상태로 변경.
- 검토 중 (Moderation): 글 수정 불가, 승인 시 '발행됨' 상태로 변경.
- 발행됨 (Published): 글 수정 불가, 더 이상 상태 변경 없음.
가장 직관적인 방법은 클래스 내부에 상태를 나타내는 변수를 두고, 각 메서드에서 이 변수를 확인하여 분기 처리하는 것입니다.
class Document {
constructor() {
this.state = 'Draft';
this.content = '';
}
publish() {
if (this.state === 'Draft') {
console.log('문서 검토 요청!');
this.state = 'Moderation';
} else if (this.state === 'Moderation') {
console.log('문서 발행!');
this.state = 'Published';
} else if (this.state === 'Published') {
console.log('이미 발행된 문서입니다.');
}
}
}
지금은 상태가 3개뿐이라 괜찮아 보입니다. 하지만 '보관됨(Archived)', '임시 삭제(Trashed)' 같은 새로운 상태가 추가된다면 어떨까요? 모든 메서드를 찾아다니며 else if
블록을 추가해야 합니다. 이는 OCP(개방-폐쇄 원칙)를 정면으로 위반하며, 코드는 점점 더 이해하기 어렵고 수정하기 힘든 '스파게티 코드'가 되어갑니다.
해결책: 상태 패턴의 구조
상태 패턴은 '객체의 내부 상태가 변할 때, 객체의 행동을 마치 클래스가 바뀐 것처럼' 만들어줍니다. 즉, 상태 자체를 객체로 만드는 것입니다.
핵심 아이디어: 상태와 관련된 로직을 별도의 '상태 객체'로 뽑아내고, 원래 객체(Context)는 현재 상태 객체에게 행동을 위임한다.
상태 패턴은 주로 세 가지 역할로 구성됩니다.
- Context (문맥): 상태를 가지는 주체. (e.g.,
Document
) 현재 상태를 나타내는 State 객체에 대한 참조를 가집니다. 클라이언트는 Context 객체와 상호작용합니다.
- State (상태): 모든 구체적인 상태들이 따라야 할 공통 인터페이스. 상태에 따라 달라지는 행동 메서드들을 정의합니다. (e.g.,
publish()
, write()
)
- ConcreteState (구체적인 상태): State 인터페이스를 구현한 클래스. (e.g.,
DraftState
, ModerationState
) 각 상태에 맞는 실제 행동을 구현하고, 필요에 따라 Context의 상태를 다음 상태로 전환하는 책임을 가집니다.
실전 예제 1: 문서 편집기 리팩토링 (JavaScript)
위에서 본 문서 편집기 예제를 상태 패턴으로 리팩토링해 보겠습니다.
1. State 인터페이스와 ConcreteState 클래스 정의
class State {
publish(doc) { throw new Error('하위 클래스에서 구현해야 합니다.'); }
write(doc, text) { throw new Error('하위 클래스에서 구현해야 합니다.'); }
}
class DraftState extends State {
publish(doc) {
console.log('문서를 검토 상태로 전환합니다.');
doc.changeState(new ModerationState());
}
write(doc, text) {
doc.content += text;
console.log('내용 추가: ' + text);
}
}
class ModerationState extends State {
publish(doc) {
console.log('문서를 최종 발행합니다.');
doc.changeState(new PublishedState());
}
write(doc, text) {
console.log('[경고] 검토 중인 문서는 수정할 수 없습니다.');
}
}
class PublishedState extends State {
publish(doc) { console.log('[알림] 이미 발행된 문서입니다.'); }
write(doc, text) { console.log('[경고] 발행된 문서는 수정할 수 없습니다.'); }
}
2. Context 클래스 정의
class Document {
constructor() {
this.state = new DraftState();
this.content = '';
}
changeState(newState) {
this.state = newState;
}
publish() {
this.state.publish(this);
}
write(text) {
this.state.write(this, text);
}
}
이제 Document
클래스는 자신의 상태가 무엇인지 신경 쓰지 않습니다. 그저 현재 state
객체에게 "이거 해줘"라고 명령만 내리면 됩니다. 상태 추가가 필요하면? 새로운 State
클래스를 만들기만 하면 끝입니다. 기존 코드는 전혀 건드릴 필요가 없죠!
실전 예제 2: 게임 개발과 상태 패턴 (Unity/C#)
상태 패턴이 가장 빛을 발하는 분야 중 하나는 바로 게임 개발입니다. 플레이어 캐릭터는 '서있기', '걷기', '달리기', '점프하기', '공격하기' 등 수많은 상태를 가집니다. 각 상태에서 가능한 입력과 행동은 완전히 다릅니다. (예: 점프 중에는 또 점프할 수 없음)
Unity(C#) 환경에서 플레이어 캐릭터의 상태를 관리하는 코드를 상태 패턴으로 구현해 보겠습니다.
1. State 인터페이스와 Context(Player) 정의
public interface IPlayerState
{
void Enter(PlayerController player);
void Execute(PlayerController player);
void Exit(PlayerController player);
}
public class PlayerController : MonoBehaviour
{
private IPlayerState _currentState;
public void Start()
{
ChangeState(new IdleState());
}
public void Update()
{
if (_currentState != null)
{
_currentState.Execute(this);
}
}
public void ChangeState(IPlayerState newState)
{
if (_currentState != null)
{
_currentState.Exit(this);
}
_currentState = newState;
_currentState.Enter(this);
}
}
2. ConcreteState 클래스들 정의
public class IdleState : IPlayerState
{
public void Enter(PlayerController player) { Debug.Log("상태: 서있기"); }
public void Exit(PlayerController player) {}
public void Execute(PlayerController player)
{
if (Input.GetAxisRaw("Horizontal") != 0)
{
player.ChangeState(new WalkState());
}
else if (Input.GetKeyDown(KeyCode.Space))
{
player.ChangeState(new JumpState());
}
}
}
public class WalkState : IPlayerState
{
public void Enter(PlayerController player) { Debug.Log("상태: 걷기"); }
public void Exit(PlayerController player) {}
public void Execute(PlayerController player)
{
if (Input.GetAxisRaw("Horizontal") == 0)
{
player.ChangeState(new IdleState());
}
}
}
public class JumpState : IPlayerState
{
public void Enter(PlayerController player)
{
Debug.Log("상태: 점프!");
}
public void Exit(PlayerController player) {}
public void Execute(PlayerController player)
{
if (player.isGrounded)
{
player.ChangeState(new IdleState());
}
}
}
이 구조를 사용하면 PlayerController
의 Update
메서드는 매우 깔끔하게 유지됩니다. 각 상태에 대한 로직과 다른 상태로의 전환 조건은 해당 상태 클래스 내에 완벽하게 캡슐화됩니다. '공격하기', '방어하기', '스킬 사용' 등 새로운 상태를 추가하는 것은 그저 IPlayerState
를 구현하는 새 클래스를 만드는 것만으로 충분합니다.
상태 패턴, 언제 사용해야 할까요?
- 객체의 행동이 내부 상태에 따라 극적으로 변할 때
- 코드에 상태를 확인하는 조건문(
if/else
, switch
)이 너무 많고 복잡할 때
- 상태와 관련된 로직을 한 곳에 모아 응집도를 높이고 싶을 때
- 새로운 상태를 추가할 때 기존 코드를 수정하고 싶지 않을 때 (OCP 준수)
결론: 복잡성과 작별하는 방법
상태 패턴은 초기에 여러 클래스를 만들어야 해서 다소 번거롭게 느껴질 수 있습니다. 하지만 객체의 상태가 2~3개를 넘어가고 상태별 행동이 복잡해지는 순간, 이 패턴은 엄청난 유지보수성과 확장성을 선물해 줍니다.
복잡한 조건 분기문 때문에 코드가 엉망이 되어가고 있다면, 더 이상 망설이지 마세요. 상태 패턴을 도입하여 각 상태에 '역할'과 '책임'을 부여하고, 여러분의 코드를 한 단계 더 성숙시켜 보시길 바랍니다.