자판기 하나를 코드로 옮긴다고 하자. 동전을 넣고, 버튼을 누르면, 음료가 나온다. 겉보기엔 단순한 세 동작이다. 그런데 조금만 들여다보면 같은 동작이 상황에 따라 전혀 다르게 굴러야 한다. 동전이 없을 때 버튼을 누르면 아무 일도 없어야 하고, 동전이 있을 때 누르면 음료가 나와야 하며, 품절일 때 누르면 거절해야 한다. 동전을 넣는 동작조차 이미 동전이 있으면 반환해야 하고, 품절이면 받지 않아야 한다. 자판기가 지금 어떤 상태에 있느냐가, 같은 버튼에 대한 반응을 통째로 바꾼다.
가장 먼저 떠오르는 대로 짜면 자판기 클래스에 state라는 정수 필드를 두고, 모든 메서드가 그 값을 if로 갈라 본다. insertCoin도 switch(state), pressButton도 switch(state), dispense도 switch(state). 상태가 넷이고 동작이 넷이면 네 메서드 각각에 네 갈래가 열려, 같은 상태 판단이 코드 곳곳에 흩어져 복제된다. 상태(State) 패턴은 이 흩어진 분기를 상태 하나당 객체 하나로 모아, 자판기가 “지금 상태에게” 행동을 위임하게 한다.
무엇이 문제인가 — 동기
상태 필드 하나에 모든 걸 거는 코드는 세 방향에서 동시에 썩는다.
첫째, 같은 분기가 메서드마다 복제된다. 상태가 NO_COIN, HAS_COIN, SOLD, SOLD_OUT 넷이라면, insertCoin·pressButton·dispense·refund 네 메서드가 저마다 이 네 상태를 switch로 훑는다. 상태를 하나 추가하면(예: MAINTENANCE) 네 메서드를 전부 열어 새 case를 심어야 한다. 하나라도 빠뜨리면, 그 동작에서만 새 상태가 조용히 무시된다 — 컴파일러는 아무 경고도 하지 않는다.
둘째, 전이 규칙이 코드 전체에 녹아 사라진다. “동전 없음에서 동전을 넣으면 동전 있음으로 간다”, “동전 있음에서 버튼을 누르면 판매됨으로 간다” — 이 전이표는 자판기를 이해하는 핵심인데, switch 방식에서는 state = HAS_COIN 같은 대입문이 여러 메서드의 여러 분기 안쪽에 흩뿌려진다. 상태 기계 전체를 한눈에 볼 수 있는 자리가 어디에도 없다. 전이가 잘못됐을 때(품절인데 음료가 나오는 버그) 원인이 어느 분기의 어느 대입인지 추적하는 비용이 치솟는다.
셋째, 불가능한 상태가 표현 가능한 채로 남는다. 정수나 열거형 하나로 상태를 들면, 그 값과 무관한 다른 필드들(투입된 동전 수, 재고)이 따로 놀 수 있다. state == SOLD_OUT인데 재고가 5인 모순된 조합을 타입이 막아 주지 못한다. 상태와 그 상태에서만 유효한 데이터가 한 몸으로 묶이지 않기 때문이다.
판단 기준: 한 객체의 여러 메서드가 똑같은 상태 필드를 저마다 switch로 가르고 있고, 그 분기 안에서 상태 전이(state = ...)까지 일어나면 상태 패턴을 의심한다. 함정: 상태가 둘뿐이고(켜짐/꺼짐) 전이도 단순하면 boolean 하나가 정답이다 — 두 갈래 토글까지 상태 객체로 뽑는 건 대가만 치른다.
패턴의 구조 — 누가 무엇을 하나
상태 패턴은 세 배역으로 이뤄진다. 전략과 배역 이름까지 닮았지만, 배역 사이에 오가는 것이 다르다.
State(상태 인터페이스). 상태에 따라 달라지는 행동들을 계약으로 선언한다. 자판기라면 insertCoin(machine), pressButton(machine), dispense(machine) 같은 메서드 묶음이다. 전략의 인터페이스가 대개 메서드 하나였다면, 상태의 인터페이스는 그 상태에서 일어날 수 있는 모든 사건을 담는다.
ConcreteState(구체 상태들). 각 상태를 클래스 하나로 구현한다. NoCoinState, HasCoinState, SoldState, SoldOutState. 각 클래스는 “내가 이 상태일 때 각 사건이 어떻게 처리되는가”만 안다. NoCoinState.pressButton은 “동전을 먼저 넣으라”고 거절하고, HasCoinState.pressButton은 판매를 진행한다. 같은 pressButton이 상태 클래스마다 다르게 구현된 것 — 이것이 흩어졌던 switch의 한 갈래씩이 클래스로 이주한 모습이다.
Context(맥락). 상태를 가지는 쪽, 곧 VendingMachine이다. 현재 상태를 필드로 들고 있다가, 외부 요청이 오면 자신이 판단하지 않고 그대로 현재 상태에게 넘긴다 — currentState.pressButton(this). Context는 자기 상태 필드를 바꾸는 세터(setState)를 상태 객체에게 열어 준다.
여기서 전략과 갈리는 결정적 협력이 나온다. 전략에서는 클라이언트가 밖에서 전략을 골라 주입하고, 전략끼리는 서로를 몰랐다. 상태에서는 상태 객체가 스스로 다음 상태를 정해 Context에 갈아 끼운다. NoCoinState.insertCoin은 처리 끝에 machine.setState(new HasCoinState())를 호출한다 — 자기가 자기 다음을 안다. 그래서 구체 상태들은 서로의 존재를 알아야 하고(다음으로 누구에게 넘길지 지목해야 하니까), 전이의 흐름이 Context 밖이 아니라 상태 객체들 안에 들어간다.
판단 기준: 다음 상태를 누가 정하는가로 배역을 나눈다. 클라이언트가 밖에서 고르면 전략, 현재 상태가 처리 도중에 다음을 지목하면 상태다. 함정: 상태 객체가 다음 상태를 정하게 해 놓고 클라이언트도 여기저기서 setState를 부르면, 전이의 주인이 둘이 되어 상태 기계가 다시 흩어진다 — 전이 권한은 상태 객체 안으로 모은다.
코드로 — 패턴이 자라나는 과정
상태 필드 하나에 매달린 switch 자판기에서 출발해, 상태를 객체로 뽑고, 전이를 상태 안으로 옮기는 과정을 단계로 따라간다.
public class VendingMachine { static final int NO_COIN = 0, HAS_COIN = 1, SOLD_OUT = 2; private int state = NO_COIN; private int stock; public VendingMachine(int stock) { this.stock = stock; if (stock == 0) state = SOLD_OUT; } public void insertCoin() { switch (state) { case NO_COIN: System.out.println("동전 투입됨"); state = HAS_COIN; break; case HAS_COIN: System.out.println("이미 동전이 있습니다"); break; case SOLD_OUT: System.out.println("품절 — 동전을 돌려드립니다"); break; } } public void pressButton() { switch (state) { case NO_COIN: System.out.println("동전을 먼저 넣으세요"); break; case SOLD_OUT: System.out.println("품절입니다"); break; case HAS_COIN: stock--; System.out.println("음료 나갑니다"); state = (stock == 0) ? SOLD_OUT : NO_COIN; break; } }}// 같은 3-갈래 switch가 메서드마다 되풀이된다. 상태를 하나 추가하면// 모든 메서드를 열어 case를 심어야 하고, 하나라도 빠뜨리면 조용히 샌다.// 전이(state = ...)가 분기 안쪽에 흩어져, 상태 기계를 한눈에 볼 자리가 없다.
첫 스텝의 switch가 사라진 것이 아니라 상태 클래스들로 분산됐다는 점이 핵심이다. 전략과 똑같이, 상태도 조건 분기를 마법처럼 없애지 않는다. “여러 상태 중 지금은 어느 것인가”라는 판단을, 코드의 switch에서 어떤 상태 객체가 꽂혀 있는가로 옮겼을 뿐이다. 대신 각 상태가 자기 클래스를 얻어, 그 상태의 모든 행동과 그 상태에서 떠날 전이가 한자리에 모인다. NoCoinState를 열면 “동전 없음”의 세상 전부가 거기 있다.
판단 기준: 리팩터링 후 “새 상태 추가 = 새 클래스 하나 + 그 상태로 가는 전이 몇 줄, 기존 상태 클래스는 최소 수정”이 성립하면 상태가 제대로 앉았다. 함정: 상태 인터페이스에 어떤 상태에서도 안 쓰는 메서드까지 몰아넣으면, 모든 구체 상태가 빈 구현으로 그것을 떠안는다 — 사건이 정말 그 상태에서 의미 있을 때만 인터페이스에 올린다.
전략과의 결정적 차이
여기서 반드시 짚어야 할 이웃이 [전략]이다. 두 패턴은 구조가 거의 포개진다 — 둘 다 행동을 인터페이스 뒤의 객체로 뽑고, Context가 그 객체에게 위임한다. UML만 나란히 놓으면 구별이 안 될 정도다. 갈라지는 것은 형태가 아니라 의도와 전이의 주인이다.
전략은 클라이언트가 고르고, 전략끼리는 서로를 모른다. Collections.sort(list, comparator)에서 어떤 비교자를 넣을지는 밖에서 클라이언트가 정한다. 빠른정렬 전략과 병합정렬 전략은 서로의 존재를 알 필요가 없고, 한 전략이 실행 중에 “이제 다른 전략으로 바꿔라”라고 말하지 않는다. 전략들은 교체 가능한 평등한 대안들이고, 선택은 바깥의 몫이다. 하나를 골라 끼우면 그걸로 끝, 스스로 갈아타지 않는다.
상태는 상태 객체가 스스로 다음 상태로 넘긴다. HasCoinState는 버튼이 눌리면 처리 끝에 자판기를 SoldOutState나 NoCoinState로 옮긴다. 상태들은 서로를 알아야 하고(“결제 완료 다음은 배송 준비”), 전이의 순서가 정해져 있다. 클라이언트는 상태를 고르지 않는다 — 그저 사건을 흘려보낼 뿐이고, 다음에 어느 상태가 될지는 현재 상태가 자기 안에서 결정한다. 상태 객체들의 집합은 평등한 대안이 아니라, 서로 이어진 하나의 상태 기계다.
한 문장으로 못박으면 이렇다 — 방법을 밖에서 고르면 전략, 상태가 스스로 다음을 정하면 상태다. 전략의 갈아 끼움은 클라이언트가 일으키는 수평 교체이고, 상태의 갈아 끼움은 상태 자신이 일으키는 시간축 전이다. 그래서 전략을 쓸 자리에 상태를 쓰면 있지도 않은 전이를 억지로 만들게 되고, 상태를 쓸 자리에 전략을 쓰면 다음 상태를 정하는 로직이 클라이언트로 새어 나와 상태 기계가 코드 곳곳에 흩어진다.
판단 기준: “이 객체들이 서로에게 ‘다음은 너다’라고 넘기는가”를 물어 본다. 넘긴다면 상태, 각자 독립해 클라이언트의 선택만 기다린다면 전략이다. 함정: 구조가 같다고 아무거나 써도 된다고 넘기면, 코드를 읽는 사람이 “이건 교체 가능한 대안인가, 이어진 전이인가”를 매번 되짚어야 한다 — 패턴 이름이 곧 의도의 문서다.
결과 — 무엇을 얻고 무엇을 내주나
상태가 사는 값은 분기 제거와 전이의 명시화다. 메서드마다 복제되던 switch가 사라지고, 한 상태의 모든 행동이 그 상태 클래스 한자리에 모인다. 상태를 추가할 때 여러 메서드를 헤집는 대신 클래스 하나를 새로 짜고 그리로 가는 전이만 이으면 되니, 개방-폐쇄 원칙이 상태 축에서 성립한다. 무엇보다 “이 상태에서 저 상태로”라는 전이가 상태 객체의 메서드 안에 말로 적힌다 — ShippedState.deliver가 곧 “배송 중일 때 배달 완료되면 어디로 가는가”의 문서다. 불가능한 상태의 조합도 줄어든다. 상태별 데이터를 그 상태 클래스가 함께 쥐면, 그 상태가 아닐 때는 그 데이터가 아예 존재하지 않는다.
대가는 전략과 겹치면서도 상태 고유의 것이 있다. 첫째, 클래스 수가 는다. 상태 넷이면 클래스가 넷 생긴다. 상태가 잘게 많으면 파일이 우수수 늘고, 전체 상태 기계를 보려면 여러 파일을 오가야 한다 — 한 switch에서 모든 갈래를 훑던 편함은 사라진다. 둘째, 그리고 이것이 상태 패턴 특유의 대가인데, 전이 로직이 상태 객체들에 분산된다. switch 시절엔 흩어졌어도 최소한 한 클래스 안이었지만, 이제 전이는 여러 상태 클래스에 나뉘어 산다. “전체 전이표”를 한 화면에서 보고 싶어도 클래스들을 순회해야 하고, A→B→C의 흐름을 좇으려면 세 파일을 열어야 한다. 상태 기계가 복잡하면 이 분산이 되레 추적을 어렵게 만든다. 셋째, 상태 객체를 공유할지 매번 생성할지를 정해야 한다. 상태가 자기 안에 인스턴스별 데이터를 안 가지면 싱글턴처럼 공유해 아낄 수 있지만(위 코드의 미리 만든 상태들), 데이터를 가지면 매 전이마다 새로 만들어야 한다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. 상태가 여럿이고 전이 규칙이 복잡하며 자주 바뀌는 자리라면, 흩어진 switch의 복제 비용과 빠뜨림 버그가 크므로 상태 객체로 모으는 이득이 그 대가를 넘는다. 반대로 상태가 둘셋이고 전이가 굳어 있다면, 클래스 넷과 분산된 전이는 간접성만 더한다 — 그 자리엔 enum에 동작을 붙이거나 switch 하나가 더 읽기 쉽다.
판단 기준: 얻는 것(분기 제거·전이 명시·상태별 응집)과 내주는 것(클래스 증가·전이 분산·공유 결정)을 세어 순이득이 양수일 때만 상태를 앉힌다. 함정: “if는 나쁘다”는 미신으로 두세 상태짜리 토글까지 상태 객체로 바꾸면, 전이표를 한눈에 보던 것을 여러 파일로 흩어 놓고 얻는 유연성은 놀린다 — 분산이 응집을 이기는 지점이 있다.
언제 쓰고, 언제 과용인가
상태가 정확히 맞는 자리는 이렇다. 한 객체의 행동이 상태에 따라 크게 달라지고, 그 상태가 여럿이며 서로 정해진 순서로 전이할 때. 주문 처리(결제 대기→결제 완료→배송 준비→배송 중→배송 완료, 각 단계에서 취소·환불 가능 여부가 다르다), TCP 연결(Closed→Listen→Established→Closed, 같은 send가 상태마다 다르게 굴러야 한다), 문서 워크플로(초안→검토 중→승인→게시), 게임 캐릭터(대기→이동→공격→사망) — 전부 “지금 어느 단계냐”가 “무엇을 할 수 있느냐”를 결정하는 자리다. 여러 메서드가 같은 상태 필드를 반복해서 switch로 가르고 있다면, 그 자체가 상태 패턴을 부르는 신호다.
과용의 냄새는 반대편에서 난다. 상태가 둘뿐이고 전이가 단순하면 boolean이나 enum 하나가 정답이다 — 여기에 상태 클래스 계층을 세우는 건 오지 않을 복잡성에 거는 보험이다. 상태별 행동 차이가 거의 없고 데이터 값만 다르다면, 그건 상태가 아니라 그냥 필드 값이다. 상태를 뽑았는데 전이가 여전히 Context나 클라이언트의 거대한 switch에서 일어난다면, 상태를 객체로 만들기만 하고 전이의 주인을 옮기지 않은 것이다 — 분기를 없앤 게 아니라 절반만 옮겼다. 그리고 상태 기계가 정말 복잡하다면(수십 상태·수백 전이), 손으로 짠 상태 클래스보다 상태 전이 테이블이나 전용 상태 기계 라이브러리가 전이를 한자리에서 보게 해 더 나을 수 있다 — 상태 패턴은 만능이 아니라 “상태가 적당히 많고 상태별 행동이 풍부할 때”의 도구다.
판단 기준: 상태 개수 × 상태별 행동의 풍부함이 클수록 상태 패턴이 이득이고, 전이 규칙만 복잡하고 상태별 행동은 빈약하면 전이 테이블이 낫다. 함정: 상태와 전략을 구조가 같다고 뭉뚱그리면, 교체 가능한 대안(전략)에 억지 전이를 넣거나 이어진 전이(상태)를 클라이언트 선택으로 풀어 헤친다 — 다시, “누가 다음을 정하는가”로 가른다.
다음으로 전략