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): 각 상태 클래스는 하나의 상태에 대한 동작만 책임집니다.