5. Singleton — 주방은 하나다, 그리고 그게 전부가 아니다

작성일:2026.05.21|조회수:4

5. Singleton — 주방은 하나다, 그리고 그게 전부가 아니다

에이든 피자의 시범 운영이 닷새째에 접어들자, 이제 주문 번호가 필요해졌다. 처음에는 대충 손으로 적어도 됐다. 하지만 주문이 늘어나자 직원들은 번호표를 붙이고, 주방은 그 번호를 기준으로 피자를 만들고, 손님은 그 번호를 보고 자기 피자를 찾아갔다.

장사가 너무 잘 되자 직원 하나가 POS를 하나 더 가져왔다. 길게 늘어진 줄을 둘로 나누자는 판단이었다. 역시 손님도 스레드도 하나보다는 둘이 낫다. 컴공 출신 알바생다운 훌륭한 최적화처럼 보였다.

TS
const posA = new KitchenManager();
posA.nextOrderNumber(); // 1

const posB = new KitchenManager();
posB.nextOrderNumber(); // 1

문제는 POS 관리 객체가 두 군데서 각각 만들어졌다는 데 있었다. 두 POS는 서로의 존재를 몰랐고, 각자 자신이 첫 주문을 받았다고 생각했다. 그 결과 두 주문이 모두 1번이 됐다. 같은 피자가 두 판인 것은 좋은 일이지만 같은 주문 번호가 두 개면 좋지 않다. 손님 둘이 동시에 "제가 1번인데요"라고 말하는 순간, 매장은 작은 추리 게임장이 된다.

무엇이 불편한가

여기서 불편한 점은 KitchenManager 객체를 두 번 만들었다는 사실 자체가 아니다. 진짜 문제는 하나의 흐름이어야 할 주문 번호 상태가 서로 다른 인스턴스 안에 따로 갇혔다는 것이다.

주문 번호는 매장 전체에서 하나의 기준으로 증가해야 한다. 그런데 POS 화면에서 만든 관리자와 주방 쪽에서 만든 관리자가 각자 자기 번호표를 들고 있으면, 둘 다 자기 입장에서는 정상적으로 1, 2, 3을 세고 있을 뿐이다. 문제는 시스템 전체에서 그 숫자들이 충돌한다는 데 있다. 각 객체는 틀리지 않았는데, 가게는 틀린 상태가 된다.

우리가 원하는 것은 주문 번호를 발급하는 책임이 여러 곳에서 새로 태어나지 못하게 막는 것이다. 모두가 같은 관리자에게 접근해야 하고, 그 관리자는 같은 상태를 기준으로 번호를 증가시켜야 한다. 다만 이 요구는 편리함과 위험을 함께 데려온다. 전역으로 접근 가능한 하나의 객체는 문제를 해결할 수도 있지만, 너무 쉽게 모든 코드가 기대는 기둥이 될 수도 있다.

Singleton 패턴

Singleton 패턴은 이런 상황에서 등장한다. GoF의 원래 의도는 다음과 같다.

클래스의 인스턴스가 하나만 존재하도록 보장하고, 그 인스턴스에 접근할 수 있는 전역 지점을 제공한다.

에이든 피자식으로 말하면, 주문 번호를 발급하는 KitchenManager를 아무 데서나 새로 만들 수 없게 하고, 모든 코드가 같은 관리자에게 접근하도록 만드는 방식이다. 전통적인 클래스 기반 구현은 private constructorstatic getInstance()를 사용한다.

TS
class KitchenManager {
  private static instance: KitchenManager | null = null;
  private orderNumber = 0;

  private constructor() {}

  static getInstance(): KitchenManager {
    if (!KitchenManager.instance) {
      KitchenManager.instance = new KitchenManager();
    }
    return KitchenManager.instance;
  }

  nextOrderNumber(): number {
    this.orderNumber += 1;
    return this.orderNumber;
  }
}

이제 외부에서는 new KitchenManager()를 호출할 수 없다. 반드시 KitchenManager.getInstance()를 통해 같은 객체를 받아야 한다.

TS
const managerA = KitchenManager.getInstance();
const managerB = KitchenManager.getInstance();

console.log(managerA.nextOrderNumber()); // 1
console.log(managerB.nextOrderNumber()); // 2

주문 번호는 하나의 흐름으로 증가한다. 적어도 번호표 때문에 손님끼리 결투를 벌일 일은 줄었다.

TypeScript에서는 모듈 싱글턴도 자연스럽다

TypeScript와 JavaScript 환경에서는 모듈 캐싱을 활용한 싱글턴이 더 자연스러운 경우도 많다.

TS
class KitchenManager {
  private orderNumber = 0;

  nextOrderNumber(): number {
    this.orderNumber += 1;
    return this.orderNumber;
  }
}

export const kitchenManager = new KitchenManager();

이 모듈을 여러 파일에서 import해도 같은 인스턴스가 재사용된다. Node.js나 번들러 환경에서는 모듈이 한 번 평가되고 캐시되기 때문이다. 불필요한 getInstance() 보일러플레이트가 없고, 사용법도 단순하다.

그렇다고 클래스 기반 Singleton이 틀렸다는 뜻은 아니다. GoF 패턴의 원형을 이해하려면 클래스 방식이 좋고, 실제 TypeScript 애플리케이션에서는 모듈 싱글턴이 더 간결한 경우가 많다. 중요한 것은 "하나만 있어야 하는가"와 "그 하나가 테스트와 변경을 방해하지 않는가"를 함께 보는 것이다. 디자인 패턴 원리주의자라는 표현을 들어본 적도, 써본 적도 없지만, 아무튼 그런 사람이 되지는 말자.

Singleton의 진짜 문제

Singleton은 편하다. 그리고 편한 전역 상태는 대체로 언젠가 청구서를 보낸다. 가장 큰 문제는 테스트 격리다. 테스트 A가 주문 번호를 10까지 올려두면 테스트 B는 1을 기대하다가 11을 받는다. 테스트는 갑자기 시간 순서에 예민한 생물이 된다.

TS
const manager = KitchenManager.getInstance();
manager.nextOrderNumber();
manager.nextOrderNumber();

// 다른 테스트에서
expect(manager.nextOrderNumber()).toBe(1); // 실패할 가능성이 높다

그래서 많은 Singleton 예제에는 reset() 같은 테스트용 메서드가 붙는다. 하지만 이것은 약간 슬픈 신호다. 테스트를 위해 운영 객체에 리셋 버튼을 달고 있다면, 설계가 테스트를 불편하게 만들고 있다는 뜻이다. 물론 가끔은 현실적인 선택이지만, 그 불편함을 모른 척하면 안 된다.

대안은 의존성 주입이다

인스턴스를 하나만 쓰고 싶다는 요구와 Singleton 패턴을 반드시 써야 한다는 요구는 다르다. 애플리케이션 루트에서 KitchenManager를 하나 만들고, 필요한 곳에 주입해도 된다.

TS
class OrderService {
  constructor(private kitchenManager: KitchenManager) {}

  placeOrder(): number {
    return this.kitchenManager.nextOrderNumber();
  }
}

const manager = new KitchenManager();
const service = new OrderService(manager);

이 방식은 실제 런타임에서는 인스턴스 하나를 공유하면서도, 테스트에서는 가짜 KitchenManager를 넣을 수 있다. 전역 접근 지점은 줄고, 의존성은 생성자에 드러난다. 클래스 시그니처만 봐도 이 서비스가 무엇에 의존하는지 알 수 있다.

그래서 이게 항상 좋은 선택인가

Singleton은 설정, 로거, 메뉴 레지스트리처럼 애플리케이션 전체에서 하나의 인스턴스만 필요한 경우에 유용하다. 하지만 상태를 가진 Singleton은 조심해야 한다. 상태가 전역으로 공유되면 편리함과 위험이 함께 커진다. 에이든 피자의 주문 번호처럼 정말 하나여야 하는 값이라면 쓸 수 있지만, 단순히 어디서나 접근하기 편하다는 이유라면 다시 생각해야 한다.

1부에서는 객체를 어떻게 만들 것인가를 다뤘다. Factory Method로 생성 책임을 옮기고, Builder로 복잡한 객체를 조립하고, Abstract Factory로 제품군을 맞추고, Prototype으로 복제하고, Singleton으로 단일 인스턴스를 다뤘다. 이제 피자를 만들 준비는 어느 정도 됐다.

전체 코드

TS
class KitchenManager {
  private static instance: KitchenManager | null = null;
  private orderNumber = 0;

  private constructor() {}

  static getInstance(): KitchenManager {
    if (!KitchenManager.instance) {
      KitchenManager.instance = new KitchenManager();
    }
    return KitchenManager.instance;
  }

  nextOrderNumber(): number {
    this.orderNumber += 1;
    return this.orderNumber;
  }

  getIssuedCount(): number {
    return this.orderNumber;
  }
}

class OrderNumberTicketMachine {
  constructor(private readonly kitchenManager: KitchenManager) {}

  printTicket(): string {
    const orderNumber = this.kitchenManager.nextOrderNumber();
    return `에이든 피자 주문 번호: ${orderNumber}`;
  }
}

const sharedManagerA = KitchenManager.getInstance();
const sharedManagerB = KitchenManager.getInstance();

const machineA = new OrderNumberTicketMachine(sharedManagerA);
const machineB = new OrderNumberTicketMachine(sharedManagerB);

console.log(machineA.printTicket()); // 1
console.log(machineB.printTicket()); // 2
console.log(sharedManagerA.getIssuedCount()); // 2

다음 편부터는 주문을 받는다. 그리고 주문은 단순한 함수 호출로 끝나지 않는다. 취소해야 하고, 재주문해야 하고, 바쁠 때는 큐에 넣어야 한다. 주문을 객체로 만들면 이야기가 달라진다.

댓글

댓글을 불러오는 중...