객체가 시간에 따라 다른 행동을 한다는 것은 그 객체에 흐름이 있다는 뜻입니다. 흐름에 이름을 붙이지 않으면, 그 자리는 분기문으로 차오릅니다.
시간이 만드는 분기
객체가 같은 메서드 호출에 대해 시점마다 다르게 반응해야 하는 경우가 자주 있습니다. 주문 객체에 cancel()을 호출한다고 합시다. 작성 중인 주문은 그냥 삭제할 수 있습니다. 결제 완료된 주문은 환불 흐름이 필요합니다. 이미 배송된 주문은 취소 자체가 불가능합니다.
가장 흔한 답은 상태 필드와 분기문입니다.
class Order {
status: 'draft' | 'submitted' | 'paid' | 'shipped' | 'delivered'
cancel() {
if (this.status === 'draft') {
this.status = 'cancelled'
} else if (this.status === 'submitted') {
this.status = 'cancelled'
this.notifyCustomer()
} else if (this.status === 'paid') {
this.refund()
this.status = 'cancelled'
} else if (this.status === 'shipped') {
throw new Error('Cannot cancel shipped order')
} else if (this.status === 'delivered') {
throw new Error('Cannot cancel delivered order')
}
}
ship() {
if (this.status === 'paid') {
// ...
} else if (this.status === 'submitted') {
throw new Error('Cannot ship unpaid order')
}
// ...
}
// 다른 메서드들도 비슷한 분기를 반복...
}
분기가 자랍니다. 메서드마다 같은 status를 확인하는 코드가 반복됩니다. 새 상태가 추가되면 모든 메서드를 함께 고쳐야 합니다.
State는 다른 답을 냅니다. 상태를 필드가 아니라 객체로 들어 올립니다. 각 상태가 자기만의 행동을 가진 객체가 됩니다. 객체는 현재 상태 객체에 행동을 위임합니다.
핵심은 단순합니다. 상태별 분기를 객체의 교체로 대체한다. 분기문은 사라지고, 상태 사이의 전이만 남습니다.
State의 정석
기본 구조
각 상태를 클래스로 만듭니다. 모두 같은 인터페이스를 구현합니다.
interface OrderState {
cancel(order: Order): void
ship(order: Order): void
// 상태에서 가능한 모든 동작
}
class DraftState implements OrderState {
cancel(order: Order): void {
order.setState(new CancelledState())
}
ship(order: Order): void {
throw new Error('Cannot ship draft order')
}
}
class PaidState implements OrderState {
cancel(order: Order): void {
order.refund()
order.setState(new CancelledState())
}
ship(order: Order): void {
order.setState(new ShippedState())
}
}
class ShippedState implements OrderState {
cancel(order: Order): void {
throw new Error('Cannot cancel shipped order')
}
ship(order: Order): void {
throw new Error('Already shipped')
}
}
Order는 자기의 현재 상태 객체에 위임합니다.
class Order {
private state: OrderState = new DraftState()
setState(state: OrderState): void {
this.state = state
}
cancel(): void {
this.state.cancel(this)
}
ship(): void {
this.state.ship(this)
}
}
// 사용
const order = new Order()
order.ship() // DraftState → throw
order.setState(new PaidState())
order.ship() // ShippedState로 전이
Order의 메서드에는 더 이상 분기문이 없습니다. 현재 상태 객체가 무엇이냐에 따라 행동이 자연스럽게 결정됩니다.
Strategy와 State는 다시
ep.08에서 짚었듯, Strategy와 State는 구조가 비슷합니다. 결정적인 차이를 다시 한 번 명확히 합니다.
- Strategy의 교체는 외부가 결정합니다. 사용자가 정렬을 바꿉니다. 개발자가 결제 게이트웨이를 갈아 끼웁니다. Context는 누군가의 결정을 받습니다.
- State의 교체는 내부에서 일어납니다. 주문이 결제되면 자기 상태를
PaidState로 바꿉니다. 외부는pay()라는 메서드를 부를 뿐, 어떤 상태로 가는지 결정하지 않습니다.
State는 본질적으로 상태 머신(Finite State Machine)의 객체 구현입니다. 정의된 상태들과 그 사이의 전이만이 가능합니다. 정의되지 않은 전이는 일어나지 않습니다(예외를 던지거나 무시).
안티패턴
상태 클래스 폭발. 상태가 너무 많으면 클래스 수가 폭발합니다. 7개 상태에 5개 메서드면 35개의 메서드 구현이 필요합니다. 이런 경우 상태를 데이터로 다루는 다른 방식, 예를 들면 xstate 같은 라이브러리, 또는 단순한 switch가 더 가벼울 수 있습니다.
상태가 다른 상태를 너무 자세히 앎. DraftState가 PaidState의 내부 동작을 알아야 한다면, 두 상태가 결합되어 있습니다. 상태 사이의 통신은 Context를 통해 일어나야 합니다.
Context의 상태 직접 검사. Context 코드 어디에선가 if (this.state instanceof PaidState) 같은 검사가 나타나면, State 패턴의 의미가 사라집니다. 모든 분기는 상태 객체 내부에 있어야 합니다.
같은 결정이 다시 나타나는 자리
State가 답하는 질문은 객체의 흐름에 이름을 붙이는 것입니다. 이 질문은 UI 폼에서 매일 반복됩니다.
폼의 상태를 생각합니다. 초기 상태는 idle입니다. 사용자가 입력을 마치고 제출 버튼을 누르면 validating이 됩니다. 검증이 통과하면 submitting이 되고, 서버 응답이 오면 success 또는 error로 바뀝니다. 같은 폼이 다섯 가지 다른 모습을 가집니다.
type FormState =
| { type: 'idle' }
| { type: 'validating' }
| { type: 'submitting' }
| { type: 'success'; data: Response }
| { type: 'error'; message: string }
function CheckoutForm() {
const [state, setState] = useState<FormState>({ type: 'idle' })
const handleSubmit = async (input: FormInput) => {
setState({ type: 'validating' })
const error = validate(input)
if (error) {
setState({ type: 'error', message: error })
return
}
setState({ type: 'submitting' })
try {
const data = await submit(input)
setState({ type: 'success', data })
} catch (e) {
setState({ type: 'error', message: e.message })
}
}
// 각 상태마다 다른 렌더링
switch (state.type) {
case 'idle': return <FormFields onSubmit={handleSubmit} />
case 'validating': return <FormFields disabled hint="확인 중..." />
case 'submitting': return <FormFields disabled hint="제출 중..." />
case 'success': return <SuccessMessage data={state.data} />
case 'error': return <ErrorMessage message={state.message} onRetry={...} />
}
}
TypeScript의 discriminated union이 GoF의 State 패턴을 가볍게 표현합니다. 클래스 폭발 없이 같은 결정 구조를 만들 수 있습니다.
왜 머신으로 다루는가
폼 상태를 단순한 boolean 두세 개로 다룰 수도 있습니다. isLoading, hasError, isSuccess. 그러나 이 세 boolean의 조합은 8가지입니다. 그중 의미 있는 조합은 5가지뿐입니다. 나머지 3가지, 예를 들어 isLoading && isSuccess는 절대 일어나서는 안 되는 상태입니다.
State 머신으로 다루는 이유는 불가능한 상태를 표현 불가능하게 만드는 것입니다. 의미 있는 상태만 타입에 존재합니다. 잘못된 상태 조합이 컴파일 타임에 막힙니다.
복잡한 폼, 결제 흐름, 다단계 마법사에서 이 차이가 결정적입니다. 버그의 큰 비율이 “이 두 상태가 동시에 일어났다”에서 나오는데, State 머신은 그것이 일어나지 않도록 구조 자체로 막습니다.
디자이너에게 이 패턴이 보이는 자리. 폼이나 마법사의 흐름을 그릴 때, 디자이너는 자연스럽게 상태 머신을 그립니다. “이 화면에서 이 버튼을 누르면 이 화면으로 갑니다.” 이 그림이 바로 State입니다. 디자이너의 화면 흐름도와 개발자의 상태 머신은 같은 결정의 두 표현입니다.
좋은 디자인 시스템은 이 대응을 가시화합니다. 폼 컴포넌트의 props가 상태 머신을 그대로 반영하면, 디자인 명세와 코드 사이의 거리가 좁아집니다.
다음 편 예고
지금까지 본 10개 패턴은 객체 수준 GoF의 핵심입니다. 시즌 1의 마지막 편은 본편에서 다루지 않은 나머지 패턴들을 가볍게 정리합니다. Visitor, Memento, Flyweight, Iterator, Command, Chain of Responsibility, Prototype, Bridge, Mediator, Template Method 각각이 어떤 결정에 이름을 붙인 패턴이고 왜 본편에는 들어오지 못했는지를 살펴봅니다.