객체지향 디자인 패턴 – 상태 패턴 (with javascript)

boyeonJ·2025년 6월 7일
1

디자인패턴

목록 보기
1/1

상태 패턴(행위 패턴)

행위 패턴(Behavioral Pattern) 중 하나로, 객체가 내부 상태(state) 변경에 따라 행동(behavior)과 상태 전이각각의 상태 객체에 위임하는 패턴입니다.

등장 배경

예를 들어 뽑기 기계를 개발할때, “동전 있음”, “동전 없음”, “상품 판매”, “상품 매진” 등의 상태는“동전 투입”, “동전 반환”, “손잡이 돌림”, “알맹이 내보냄” 등의 행동 에 따라 변경됩니다.

각각의 행동에 대한 함수를 구현해봅시다. 아래와 같은 고려사항이 있을 수 있습니다.

  1. 현재 상태에 따라 행동을 다르게 구현한다.
  2. 행동을 모두 마친 후의 상태에 따라 다음 상태를 정한다.

위와 같은 상황을 고려하여 구현하려면 행동 함수 내부의 많은 if-else가 필요합니다. 또한, 변경 사항이 발생(재고 보충, 특별 보너스 상품 지급 등등)하면 내부 코드를 전체적으로 고려하여 변경해야 합니다. (버그 발생 위험 높아짐)

이러한 문제를 해결하고자 각각의 상태에 대한 객체를 생성하고(NoCoinState, HasCoinState, SoldState, SoldOutState) 내부에서는 현재 상태에서 실행할 행동(insertCoin(), ejectCoin() ...)과 상태전이를 처리하도록 캡슐화 하여 각각의 객체에게 책임을 위임하는 패턴이 상태 패턴이 등장하였습니다.

이렇게 각각의 객체에게 위임하게 되면 OCP를 준수하게 되어 확장이 용이하고, SRP를 준수하게 되어 테스트가 용이해집니다.

핵심 아이디어

  1. State Context의 뼈대를 만듭니다.
    1. 아래에서 만들 State Object를 보유하고 상태 전의를 관리하는 상태의 주인
  2. State Interface를 생성합니다.
    1. 모든 행동 메서드 포함
  3. State Interface로 각각의 State Object 생성합니다.
    1. 모든 행동 매서드 구현
    2. State Context를 참조
      1. constructor parameter로 주입 받음
      2. 상태 내부로 의존성 주입(DI)
      3. State Context 객체를 참조 = 상태 객체가 자신을 보유한 컨텍스트 객체를 조작할 수 있도록 주입된 Context 레퍼런스
      4. 상속, 구성 x
  4. State Context의 내부를 다시 구현합니다.
    1. 각각의 상태를 생성 및 소유합니다. (new 사용, 구성 관계)
    2. 상태 전의를 위한 세터/케터를 구현합니다.
    3. 실제 외부에서 호출할 동작을 구현합니다.
  5. 외부에서는 각각의 상태 객체를 바라볼 필요 없이, State Context만 바라봅니다.
  • Context → State
    • 구성 관계 - Composition
    • Context가 State객체를 생성하여 소유
    • Context가 제거되면 State들도 같이 소멸(생명주기를 책임짐)
  • State → Context
    • 연관/참조 관계 - Association/Reference
    • State는 외부의 Context를 참조
    • State는 Context의 생명주기를 책임지지 x
  • 결국 서로 양방향으로 연관이 되어있지만 서로 다른 성격의 관계를 가집니다.
💡 구성 관계 vs 참조 관계

(1) 구성 관계
구성 관계는 소스 코드 내에서 new를 써서 내부에서 직접 인스턴스를 생성하고 그 객체의 생명주기를 자신이 책임집니다.
⇒ 부분(part)이 전체(whole)에 강하게 종속되어, 전체가 생성·파괴될 때 부분도 함께 생성·파괴되어야 할 때 사용

(2) 참조 관계
참조관계는 외부에서 만들어진 인스턴스를 단순히 constructor 파라미터로 받아와서 참조만 할당하여 사용만 할뿐, 생성/파괴 책임은 없습니다.
⇒ 한 객체가 다른 객체를 사용만 해야 할때 사용

실제 활용

State interface

export interface State {
  insertCoin(): void;
  ejectCoin(): void;
  turnCrank(): void;
  dispense(): void;
}

Context class

import { State } from './State';
import { NoCoinState } from './NoCoinState';
import { HasCoinState } from './HasCoinState';
import { SoldState } from './SoldState';
import { SoldOutState } from './SoldOutState';

export class GumballMachine {
  private noCoinState: State;
  private hasCoinState: State;
  private soldState:   State;
  private soldOutState: State;

  private state: State;
  private count: number;

  constructor(count: number) {
    this.count = count;
    this.noCoinState   = new NoCoinState(this);
    this.hasCoinState  = new HasCoinState(this);
    this.soldState     = new SoldState(this);
    this.soldOutState  = new SoldOutState(this);

    this.state = count > 0 ? this.noCoinState : this.soldOutState;
  }

  // 상태 전이를 위한 세터/게터
  setState(state: State) {
    this.state = state;
  }
  getNoCoinState()   { return this.noCoinState; }
  getHasCoinState()  { return this.hasCoinState; }
  getSoldState()     { return this.soldState; }
  getSoldOutState()  { return this.soldOutState; }

  // 알맹이 재고 차감
  releaseBall(): void {
    if (this.count > 0) {
      console.log('알맹이 나가는 중...');
      this.count--;
    }
  }

  // 외부에서 호출할 동작
  insertCoin()  { this.state.insertCoin();  }
  ejectCoin()   { this.state.ejectCoin();   }
  turnCrank()   {
    this.state.turnCrank();
    this.state.dispense();
  }

  getCount() { return this.count; }
}

각각의 State Object

import { State } from './State';
import { GumballMachine } from './GumballMachine';

export class NoCoinState implements State {
  constructor(private machine: GumballMachine) {}

  insertCoin(): void {
    console.log('동전이 투입되었습니다.');
    this.machine.setState(this.machine.getHasCoinState());
  }
  ejectCoin(): void {
    console.log('동전이 없습니다. 반환할 수 없습니다.');
  }
  turnCrank(): void {
    console.log('먼저 동전을 넣어주세요.');
  }
  dispense(): void {
    console.log('알맹이를 받을 수 없습니다.');
  }
}

// HasCoinState.ts
import { State } from './State';
import { GumballMachine } from './GumballMachine';

export class HasCoinState implements State {
  constructor(private machine: GumballMachine) {}

  insertCoin(): void {
    console.log('이미 동전이 있습니다. 추가로 넣을 수 없습니다.');
  }
  ejectCoin(): void {
    console.log('동전을 반환합니다.');
    this.machine.setState(this.machine.getNoCoinState());
  }
  turnCrank(): void {
    console.log('손잡이를 돌리셨습니다...');
    this.machine.setState(this.machine.getSoldState());
  }
  dispense(): void {
    console.log('알맹이를 받을 수 없습니다.');
  }
}

// SoldState.ts
import { State } from './State';
import { GumballMachine } from './GumballMachine';

export class SoldState implements State {
  constructor(private machine: GumballMachine) {}

  insertCoin(): void {
    console.log('잠시만 기다려주세요. 알맹이가 나가는 중입니다.');
  }
  ejectCoin(): void {
    console.log('이미 손잡이를 돌리셨습니다. 반환 불가합니다.');
  }
  turnCrank(): void {
    console.log('손잡이는 한 번만 돌려주세요.');
  }
  dispense(): void {
    this.machine.releaseBall();
    if (this.machine.getCount() > 0) {
      this.machine.setState(this.machine.getNoCoinState());
    } else {
      console.log('알맹이가 모두 소진되었습니다.');
      this.machine.setState(this.machine.getSoldOutState());
    }
  }
}

// SoldOutState.ts
import { State } from './State';
import { GumballMachine } from './GumballMachine';

export class SoldOutState implements State {
  constructor(private machine: GumballMachine) {}

  insertCoin(): void {
    console.log('더 이상 판매할 알맹이가 없습니다. 동전을 반환합니다.');
  }
  ejectCoin(): void {
    console.log('동전이 없습니다.');
  }
  turnCrank(): void {
    console.log('알맹이가 없어서 손잡이를 돌릴 수 없습니다.');
  }
  dispense(): void {
    console.log('알맹이가 없습니다.');
  }
}

실제 사용하는 부분

// main.ts (테스트)
import { GumballMachine } from './GumballMachine';

const machine = new GumballMachine(2);

machine.insertCoin();
machine.turnCrank();
// 동전이 투입되었습니다.
// 손잡이를 돌리셨습니다...
// 알맹이 나가는 중...
// 상태가 NoCoinState로 전이

machine.insertCoin();
machine.ejectCoin();
// 동전이 투입되었습니다.
// 동전을 반환합니다.

machine.insertCoin();
machine.turnCrank();
machine.insertCoin();
machine.turnCrank();
// 알맹이 나가는 중...
// 알맹이가 모두 소진되었습니다.
// 더 이상 판매할 알맹이가 없습니다. 동전을 반환합니다.
// 알맹이가 없습니다.

0개의 댓글