하리Hari

8. 상태 패턴 (State)

객체의 내부 상태가 바뀔 때 행동도 함께 변하게 하는 상태 패턴을 알아봅니다.

한 줄 요약
객체가 “지금 어떤 상태인지”에 따라 다르게 행동해야 할 때, 상태마다 클래스를 하나씩 만들고 객체는 지금 상태에게 일을 넘기게 하는 패턴이에요.

문제: 상태가 늘어날수록 커지는 갈래 나누기

자판기를 만든다고 해 볼게요. 자판기는 세 가지 상태 중 하나예요.

  • 동전없음: 동전을 기다리는 중
  • 동전있음: 버튼만 누르면 음료수가 나오는 중
  • 품절: 재고가 없어서 아무것도 못 하는 중

같은 버튼을 눌러도 상태에 따라 하는 일이 달라요. 가장 먼저 떠오르는 방법은 상태를 글자로 기억해 두고, 메서드마다 ~에 따라 나누자로 갈래를 나누는 거예요. 한 값을 보고 여러 갈래로 나눌 때 가장 깔끔한 방법이죠.

[자판기]를 설계하자:
    '상태'를 [문자열]인 "동전없음"으로 정하자
    '재고'를 [숫자]인 0으로 정하자

    처음 만들어질 때 ([숫자]인 '재고') 다음과 같이 하자:
        '나'의 '재고'를 '재고'로 정하자
        만약 ('재고'가 0과 같다) 라면:
            '나'의 '상태'를 "품절"로 정하자

    <동전넣기>를 만들자 ():
        '나'의 '상태'에 따라 나누자:
            "동전없음" 인 경우:
                "동전을 넣었어요."를 출력하자
                '나'의 '상태'를 "동전있음"으로 정하자
            "동전있음" 인 경우:
                "이미 동전이 들어 있어요."를 출력하자
            "품절" 인 경우:
                "품절이에요. 동전을 돌려드릴게요."를 출력하자

    <뽑기>를 만들자 ():
        '나'의 '상태'에 따라 나누자:
            "동전없음" 인 경우:
                "동전을 먼저 넣어 주세요!"를 출력하자
            "동전있음" 인 경우:
                "음료수가 나와요! 덜컹!"을 출력하자
                '나'의 '재고'를 ('나'의 '재고' - 1)로 정하자
                만약 ('나'의 '재고'가 0과 같다) 라면:
                    '나'의 '상태'를 "품절"로 정하자
                그렇지 않다면:
                    '나'의 '상태'를 "동전없음"으로 정하자
            "품절" 인 경우:
                "품절이에요."를 출력하자

'기계'를 [자판기]인 새로운 [자판기](1)로 정하자
'기계'의 <뽑기>()를 실행하자
'기계'의 <동전넣기>()를 실행하자
'기계'의 <뽑기>()를 실행하자
'기계'의 <동전넣기>()를 실행하자

실행해 보면 잘 돌아가고, 코드도 깔끔해 보여요.

실행 결과
동전을 먼저 넣어 주세요!
동전을 넣었어요.
음료수가 나와요! 덜컹!
품절이에요. 동전을 돌려드릴게요.

그런데 이 코드는 자라면서 아파져요. “점검 중” 상태를 하나 더 넣는다고 생각해 보세요.

  • <동전넣기>와 <뽑기>, 모든 메서드의 따라 나누자에 "점검중" 인 경우:를 하나씩 더 붙여야 해요. 메서드가 열 개라면 열 곳이에요.
  • 한 곳이라도 빼먹으면 그 상태에서 버튼을 눌렀을 때 아무 일도 일어나지 않아요. 맞는 갈래가 없어도 에러가 나지 않아서 찾기도 어려워요.
  • "동전있음"을 "동전 있음"으로 한 번만 잘못 써도 그 갈래는 조용히 건너뛰어져요.
  • “동전있음일 때 자판기는 어떻게 행동하지?“를 알려면 메서드 전부를 훑어봐야 해요. 한 상태의 이야기가 여기저기 흩어져 있거든요.

따라 나누자로도 안 되는 것
따라 나누자는 한 메서드 안의 긴 만약 사다리를 깔끔하게 정리해 줘요. 하지만 여러 메서드에 같은 나누기가 반복되는 것은 고쳐 주지 못해요. 상태가 늘어날 때 아픈 건 바로 이 반복이에요.

해결책: 상태를 객체로 만들기

상태 패턴은 생각을 뒤집어요. “메서드 안에서 상태를 나누는” 대신 “상태 안에 메서드를 모아요.”

  1. 모든 상태가 지킬 약속, 곧 규정을 만들어요. 상태라면 <동전넣기>와 <뽑기>를 할 줄 알아야 해요.
  2. 상태마다 클래스를 하나씩 만들고, 그 상태일 때 할 일만 적어요.
  3. 자판기는 '현재상태' 하나만 기억하고, 버튼이 눌리면 지금 상태에게 그대로 넘겨요.
  4. 상태를 바꾸고 싶으면 '현재상태'에 다른 상태 객체를 끼워요.

비유: 휴대폰의 소리 모드

휴대폰에 전화가 오는 일은 늘 똑같은데, 벨소리 모드면 울리고, 진동 모드면 떨리고, 무음 모드면 화면만 켜져요. 휴대폰이 “만약 벨소리 모드라면…“을 매번 따지는 게 아니라, 모드 버튼으로 반응하는 방식 자체를 갈아 끼운다고 생각하면 돼요. 상태 패턴의 상태 객체가 바로 이 “모드”예요.

누가 무엇을 하나요?

역할 이 예제에서 하는 일
상태 약속 [상태] (규정) 모든 상태가 가져야 할 메서드를 정해요
구체적인 상태 [동전없음상태], [동전있음상태], [품절상태] 그 상태일 때의 행동, 그리고 다음 상태로 넘어가는 시점을 알아요
맥락(주인) [자판기] 지금 상태 하나를 들고 있고, 일을 넘기기만 해요

하리로 만들기

똑같은 자판기를 상태 패턴으로 다시 만들어 볼게요. 코드 속 (참고: n단계)를 따라 읽어 보세요.

(참고: 1단계 — 모든 상태가 지킬 약속)
[상태]를 규정하자:
    <동전넣기>가 있어야 한다 ()
    <뽑기>가 있어야 한다 ()

(참고: 2단계 — 자판기는 지금 상태에게 일을 넘기기만 해요)
[자판기]를 설계하자:
    '현재상태'를 [상태]인 비어있음으로 정하여 숨기자
    '재고'를 [숫자]인 0으로 정하자

    처음 만들어질 때 ([숫자]인 '재고') 다음과 같이 하자:
        '나'의 '재고'를 '재고'로 정하자
        만약 ('재고'가 0과 같다) 라면:
            '나'의 '현재상태'를 새로운 [품절상태]()로 정하자
        그렇지 않다면:
            '나'의 '현재상태'를 새로운 [동전없음상태]('나')로 정하자

    <상태바꾸기>를 만들자 ([상태]인 '새상태'):
        '나'의 '현재상태'를 '새상태'로 정하자

    <동전넣기>를 만들자 ():
        '나'의 '현재상태'의 <동전넣기>()를 실행하자

    <뽑기>를 만들자 ():
        '나'의 '현재상태'의 <뽑기>()를 실행하자

(참고: 3단계 — 상태마다 클래스 하나, 그 상태일 때 할 일만 적어요)
[상태]를 따르는 [동전없음상태]를 설계하자:
    '기계'를 [자판기]인 비어있음으로 정하여 숨기자

    처음 만들어질 때 ([자판기]인 '기계') 다음과 같이 하자:
        '나'의 '기계'를 '기계'로 정하자

    <동전넣기>를 만들자 ():
        "동전을 넣었어요."를 출력하자
        '나'의 '기계'의 <상태바꾸기>(새로운 [동전있음상태]('나'의 '기계'))를 실행하자

    <뽑기>를 만들자 ():
        "동전을 먼저 넣어 주세요!"를 출력하자

[상태]를 따르는 [동전있음상태]를 설계하자:
    '기계'를 [자판기]인 비어있음으로 정하여 숨기자

    처음 만들어질 때 ([자판기]인 '기계') 다음과 같이 하자:
        '나'의 '기계'를 '기계'로 정하자

    <동전넣기>를 만들자 ():
        "이미 동전이 들어 있어요."를 출력하자

    <뽑기>를 만들자 ():
        "음료수가 나와요! 덜컹!"을 출력하자
        '나'의 '기계'의 '재고'를 ('나'의 '기계'의 '재고' - 1)로 정하자
        만약 ('나'의 '기계'의 '재고'가 0과 같다) 라면:
            '나'의 '기계'의 <상태바꾸기>(새로운 [품절상태]())를 실행하자
        그렇지 않다면:
            '나'의 '기계'의 <상태바꾸기>(새로운 [동전없음상태]('나'의 '기계'))를 실행하자

[상태]를 따르는 [품절상태]를 설계하자:
    <동전넣기>를 만들자 ():
        "품절이에요. 동전을 돌려드릴게요."를 출력하자

    <뽑기>를 만들자 ():
        "품절이에요."를 출력하자

'기계'를 [자판기]인 새로운 [자판기](1)로 정하자
'기계'의 <뽑기>()를 실행하자
'기계'의 <동전넣기>()를 실행하자
'기계'의 <뽑기>()를 실행하자
'기계'의 <동전넣기>()를 실행하자

결과는 앞의 코드와 한 글자도 다르지 않아요. 바뀐 건 코드의 모양뿐이에요. 이렇게 겉으로 하는 일은 그대로 두고 속 구조만 고치는 걸 리팩터링이라고 불러요.

실행 결과
동전을 먼저 넣어 주세요!
동전을 넣었어요.
음료수가 나와요! 덜컹!
품절이에요. 동전을 돌려드릴게요.

단계별로 다시 보기

  • 1단계 — 약속 만들기. [상태]를 규정하자는 “상태라면 이 메서드들이 꼭 있어야 해”라는 약속이에요. 새 상태를 만들면서 <뽑기>를 빼먹으면 하리가 바로 알려 줘요(아래 예제). 앞의 코드처럼 조용히 아무 일도 안 일어나는 일이 없어져요.
  • 2단계 — 자판기는 일을 넘기기만. [자판기]의 <동전넣기>와 <뽑기>에는 따라 나누자도 만약도 없어요. 지금 상태가 무엇이든 '현재상태'에게 그대로 넘겨요.
  • 3단계 — 상태마다 클래스 하나. “동전있음일 때는 어떻게 행동하지?“가 궁금하면 [동전있음상태]만 보면 돼요. 다음에 어느 상태로 넘어갈지도 상태가 스스로 정해요.

약속을 어기면 어떻게 되는지 직접 볼까요? <뽑기>를 빼먹은 상태를 만들면, 그 상태를 쓰기도 전에 클래스를 만드는 순간 에러가 나요.

[상태]를 규정하자:
    <동전넣기>가 있어야 한다 ()
    <뽑기>가 있어야 한다 ()

[상태]를 따르는 [점검중상태]를 설계하자:
    <동전넣기>를 만들자 ():
        "점검 중이에요."를 출력하자

[품절상태]에는 왜 '기계'가 없나요?
품절은 끝 상태라서 다른 상태로 넘어갈 일이 없어요. 자판기를 바꿀 일이 없으니 기억할 필요도 없어요. 상태 객체는 꼭 필요한 것만 들고 있으면 돼요.

새 상태를 더해 보면

이 구조의 진짜 힘은 상태를 하나 더 넣을 때 드러나요. “점검 중” 상태를 만든다면:

  • 앞의 방식: 모든 메서드를 열어서 "점검중" 인 경우:를 붙여야 해요.
  • 상태 패턴: [상태]를 따르는 [점검중상태]라는 새 클래스 하나만 만들면 돼요. 이미 있는 상태 클래스는 거의 건드리지 않아요.

직접 해 보기
위 코드에 [점검중상태]를 더해 보세요. 동전을 넣으면 “점검 중이에요.“를 출력하고, 뽑기를 하면 아무것도 나오지 않아요. 그리고 [자판기]에 <점검시작>을 만들어서 지금 상태를 [점검중상태]로 바꿔 보세요. 기존 상태 클래스는 하나도 고칠 필요가 없을 거예요.

언제 쓰면 좋을까요?

  • 객체가 상태에 따라 완전히 다르게 행동하고, 상태가 세 개 이상이거나 앞으로 늘어날 것 같을 때
  • 여러 메서드에 같은 상태 값으로 나누는 갈래(따라 나누자나 만약 사다리)가 반복될 때
  • “이 상태에서 저 상태로 넘어가는 규칙”이 복잡해서 한눈에 보고 싶을 때

좋은 점과 아쉬운 점

좋은 점 아쉬운 점
한 상태의 행동이 클래스 하나에 모여 있어 읽기 쉬워요 클래스 수가 늘어나요
새 상태를 더할 때 기존 코드를 거의 안 고쳐요 상태가 두 개뿐이고 늘 일 없다면 오히려 과해요
규정 덕분에 빠뜨린 행동을 하리가 잡아 줘요 상태끼리 서로를 알아야 해서(다음 상태를 만드니까) 얽힐 수 있어요
상태 값을 글자로 비교하지 않아서 오타로 틀릴 일이 없어요

너무 이른 패턴은 독이에요. 상태가 “켜짐/꺼짐” 두 개뿐이고 메서드도 하나라면, 만약 한 번이 더 읽기 쉬워요. 갈래 나누기가 실제로 아프기 시작할 때 상태 패턴으로 옮겨도 늦지 않아요.

다른 패턴과의 관계

  • 전략 패턴과 닮았어요. 둘 다 “규정을 따르는 객체를 갈아 끼우는” 모양이에요. 차이는 누가 갈아 끼우느냐예요. 전략은 바깥(사용하는 쪽)이 골라서 끼우고 전략끼리는 서로 몰라요. 상태는 상태가 스스로 다음 상태로 넘어가요.
  • 싱글톤 패턴과 함께 쓸 수 있어요. [품절상태]처럼 아무것도 기억하지 않는 상태는 매번 새로 만들 필요 없이 하나만 만들어 함께 써도 돼요.
  • 커맨드 패턴과도 어울려요. 버튼 누르기를 커맨드 객체로 만들고, 그 커맨드가 지금 상태에게 일을 넘기게 하면 “되돌리기”까지 붙이기 쉬워요.
놀이터
결과
코드를 작성하고 '실행' 버튼을 눌러보세요.