Stop Writing If-Else Trees: Use the State Pattern Instead 번역/의역글입니다.
State Design Pattern은 내부 상태가 변경되면 객체가 행동을 바꾸도록하는 행동 소프트웨어 패턴입니다.
더 간단하게 말해서, 상태 패턴은 if/else
혹은 switch
문로 작성된 어수선한 코드없이 객체가 현재 상태에 따라서 다르게 동작하게 합니다.
이전 글 "명령형 패턴이 당신 생각보다 유용한 이유"에서 우리는 동장을 객체화하는게 얼마나 코드를 유연하게 만드는지 탐구해봤습니다. 상태 패턴은 비슷한 접근 방식이지만 상태와 그의 맞는 동작을 객체화로 캡슐링하는 것에 더 집중합니다. 명령형 패턴과 함께 상태 패턴은 우리가 긴 조건문 대신 SOLID 디자인 패턴을 따르도록 도와줍니다. - 명령형 패턴과는 다른 문제를 해결하지만요:)
📱 현실 세계에서 비유해보기: 핸드폰 알림 모드
당신이 가진 휴대폰에 있는 다양한 알림 모드를 생각해보세요. 대표적으로 알림모드(Normal), 진동모드(Vibrate), 무소음모드(Silent)가 있습니다.
- 알림모드 일때는 전화가 왔을때 큰 소리로 울립니다.
- 진동모드 일때는 소리 대신 진동이 울리죠.
- 무소음 모드 일때는 소리도 진동도 울리지 않습니다. (단지 부재중 전화를 기록할 수도 있겠네요.)
휴대폰 소유자인 당신은 일을 하거나 미팅중이거나 영화를 볼때 등 상황에 따라 이 모드들을 수동으로 변경합니다. 그리고 당신이 직접 핸드폰의 동작을 설정하지 않아도 상태에 따라 바뀝니다.
이 시나리오는 상태 패턴과 관련이 있습니다.
- 핸드폰은 행동을 바꿀 객체입니다.
- 현재 모드(알림/진동/무소음)는 해드폰 내부의 상태입니다.
- 각 상태는 전화가 왔을 때, 핸드폰이 어떤 방식으로 반응할지를 정의합니다.
- 모드를 변경하는 것은 내부의 상태를 변경하는 것과 같습니다. 핸드폰의 동장이 달라지는거죠.
왜 if-else
나 enum
을 사용하면 안되는 걸까요? 핸드폰의 행동을 단순히 if-else
나 switch
로 작성할 수 있습니다.
if (mode === 'NORMAL') {
// ring loud
} else if (mode === 'VIBRATE') {
// buzz
} else if(mode === 'SILENT') {
// stay quiet
}
모드가 별로 없다면 이 코드는 괜찮을 겁니다. 하지만 핸드폰에 많은 모드가 있고 그에 따른 여러개에 동작(전화, 문자, 알람, 알림 등등)들이 있다고 생각해보세요. 조건부 분기의 수가 늘어나고, 각 모드에 대한 로직이 코드 전반에 걸쳐 여러 if
문에 흩어지게 됩니다. 따라서 유지보수가 어렵고 오류가 생기기 쉬워집니다.
상태 패턴은 State Class를 통해 각 모드에 따른 로직을 분리하면서 더 깔끔한 접근법을 제공합니다. 핸드폰은 상태 객체를 가지고 해당 객체를 통해 동작합니다. 상태가 변경되면 실제로 State 객체를 교체합니다. 이를 통해 복잡한 조건문을 작성하는 대신 다형성을 통해 상태별 알맞은 동작을 처리할 수 있습니다.
💪🏻 상태 패턴이 작동하는 방식
상태 패턴은 몇가지 주요 개념들이 함께 작동합니다.
Context
: 다양한 내부 상태를 가지는 객체를 의미합니다. 앞에 비유에서는 핸드폰에 해당합니다.State Interface(Abstract Class)
: 다양한 상태에 따른 동작을 공통 인터페이스로 정의합니다.Context
에서 동작할 메소드를 정의합니다. 예를 들어,PhoneState
인터페이스에서는handleIncomingCall()
같은 메소드를 정의합니다.Concrete State Classes
: 각 상태들을 객체로 정의하는 것입니다. 각 상태 클래스에 특정 상태를 표현하고, 상태 인터페이스를 구현하여 그 상태에서의 동작을 정의합니다. 예를 들어NormalState
,VibrateState
,SilentState
는 각각 전화를 받을 때의 동작을 서로 다르게 구현합니다.State Transitions
: 각Context
는 상태를 변경하는 메소드를 가집니다. 이는 외부 트리거 혹은 내부 로직에 의해서 발생할 수 있습니다.
Context
가 어떤 동작을 요청 받으면, 바로 그 요청을 처리하지 않습니다. 대신 작업을 현재 상태 객체에 위임합니다.
context.handleIncomingCall();
// 실제로는 currentState.handleIncomingCall()이 호출됨
각 상태 객체에 따라 동작 방식이 달라지고, 그에 따라 결과도 달라집니다. 이 코드에서는 하나의 메서드 호출이 실제 상태 객체에 따라 다르게 동작함으로써, 다형성이 구현됩니다.
🚫 복잡한 조건문을 지양하세요.
상태 패턴을 이용하는 주요 동기는 코드 전반에 흩어져있는 반복되는 조건부 로직을 제거하는 것입니다. 만약 상태에 따라 객체의 동작이 달라지는 경우, 상태를 추적하기 위해 eunm
이나 flags
를 사용하고 상태별로 달라지는 동작을 구현하기 위해 메소드 안에 switch/if
을 사용할 수 있습니다. 이건 복잡하고 유지보수하기 힘든 코드를 만들 수 있습니다.
상태 패턴은 각 클래스안에 상태별 로직을 구현함으로써 이 문제를 해결합니다.
- 상태별로 작동할 로직은 각 클래스에 작성됩니다.(e.g. 무소음 모드의 모든 로직은
SilentState
에 있습니다.) Context
코드가 더 간결해집니다. 더 이상 상태에 따른 긴 조건부 블럭은 필요하지 않습니다.- 상태를 추가하거나 변경하기 위해 긴
switch
문을 수정할 필요가 없습니다. 새로운 상태 클래스를 만들거나 수정하면 됩니다.
상태 패턴의 기존 정의에 따르면 객체의 상태에 따라 동작이 달라지는 복잡한 조건문들이 존재할 때, 상태 패턴은 이 조건문의 각 분기를 별도의 클래스로 분리해 상태 자체를 하나의 객체로 취급한다고 합니다.
이러한 캡슐화 덕분에 코드는 OCP(개방-패쇄 원칙)
따르게 됩니다. 또한 이는 SRP(단일 책임 원칙)
에도 부합합니다.
✅ 이럴때는 상태 패턴을 사용해보세요.
- 객체가 상태에 따라 다르게 동작해야하고 런타임 환경에서 상태가 변경되어야 할때: 만약 여러곳에서
if-else
을 사용하고 있는 당신을 발견한다면, 상태 패턴이 당신에게 도움이 될거예요 😎 - 명확하게 구분할 수 있는 여러 동작이 객체에 있을때: 예를 들어 핸드폰은 알림을 울리기, 진동하기, 무음상태에서 알림 기록해두기들이 각각 다르게 동작해야합니다.
- 상태를 검사하는 로직을 반복해서 작성하는 것을 피하고 싶을때: 상태 패턴을 사용하면 상태별 동작을 각 상태 클래스에 담아두기 때문에, 로직이 더 중앙집중화됩니다.
- 앞으로 상태를 추가하거나 미래에 상태별 로직이 복잡해질걸로 예상될때: 이 패턴은 상태를 확장하고 수정하는 것을 더 쉽게 만들어 줄거예요.
💣 이럴때는 사용하지 않는게 좋아요.
- 객체가 한두 개 정도의 상태를 가지고 있고 동작이 크게 다르지 않을 때: 이때 상태 패턴을 사용하는건 오버엔지니어링일 수 있어요. 사소한 경우에는 조건문이 더 읽기 쉬울 수 있습니다.
- 상태 변화가 드물거나 로직이 별로 복잡해질 가능성이 낮을 때: 상태별 클래스를 추가로 만드는건 이점이 크지 않을 수 있어요.
- 상태 수가 고정되어있고 변동성이 적으며, 상태별 로직도 단순할 때: 이런 경우,
eunm
이나switch
문을 사용하는 것으로도 충분합니다. 상태 패턴은 상태와 동작이 더 복잡하고 변경될 가능성이 있는 경우 효과적입니다.
🤨 왜 상태 패턴이 Eumn
혹은 Flag
보다 좋을까요?
Eunm
혹은 Flag
은 소프트웨어가 커질 수록 아래와 같은 문제를 가져오게 됩니다.
- 분산된 로직: 여러개의 동작이 모드에 따라 다르게 동작한 다면, 많은 메소드안에
if/else
혹은switch
문을 사용하게 되고 작은 변경사항에도 모든 조건문들을 읽고 수정해야할 것 입니다. - OPC 원칙 위반: 새로운 모드(e.g. 방해금지 모드)를 추가하기 위해서 모든
switch
문을 수정해야합니다. 모든 수정은 기존 코드의 영향을 미쳐 버그를 발생시킬 위험성이 있습니다. - 유지보수의 어려움: 상태와 조건들이 많을 수록 코드를 읽기 더 어려워려지고 유지보수하기 힘들 수 있습니다.
⚖️ 상태 패턴 장단점
어느 디자인 패턴과 같이 상태 패턴도 장점과 단점이 있습니다.
장점
- 깔끔한 코드 정리
- 복잡한 조건문 제거
- OCP에 친화적인 설계 방식
- 상태 변경 로직은 캡슐화
- 다형성(Polymorphic)을 활용한 동작
단점
- 더 많은 클래스와 복잡도 증가
- 상태와 컨텍스트 간의 강한 결합
- 높은 러닝커브: 처음 접하는 개발자에게는 코드 흐름을 이해하기 어려울 수 있습니다.
- 메모리/성능 오버헤드: 일부 언어에서는 객체를 생성하는 데 약간 더 성능이 필요할 수 있습니다. 하지만 대부분 크게 신경 쓰일 정도는 아닙니다.
단점 완화 하기
클래스가 너무 많아지는 것이 걱정된다면, 일부 언어에서는 상태 객체를 내부 클래스나 익명 클래스로 구현하여 컨텍스트와 함께 묶어둘 수 있습니다. 객체 생성 비용이 걱정된다면, 상태 객체를 매번 새로 만들 필요는 없습니다. 싱글턴이나 익명 클래스를 재사용할 수 있습니다.
예시코드
interface PhoneState {
handleIncomingCall(): void;
}
class NormalState implements PhoneState {
handleIncomingCall() {
console.log("벨소리가 울립니다.");
}
}
class SilentState implements PhoneState {
handleIncomingCall() {
console.log("아무 반응 없음 (무음)");
}
}
class Phone {
private state: PhoneState;
constructor(initialState: PhoneState) {
this.state = initialState;
}
setState(state: PhoneState) {
this.state = state;
}
receiveCall() {
this.state.handleIncomingCall();
}
}
const phone = new Phone(new NormalState());
phone.receiveCall(); // 벨소리가 울립니다.
phone.setState(new SilentState());
phone.receiveCall(); // 아무 반응 없음 (무음)
- OCP (Open/Closed Principle): 새로운 상태를 추가해도 기존 코드를 수정할 필요가 없습니다.
- SRP (Single Responsibility Principle): 각 상태 클래스는 하나의 상태에 대한 동작만 책임집니다.