데코레이터, 팩토리, 싱글턴 패턴
- 2.1 스타버즈 커피 주문 시스템
- 2.2 OCP: 개방-폐쇄 원칙
- 2.3 데코레이터 패턴으로 동적 책임 추가
- 2.4 java.io와 실전 데코레이터
- 2.5 피자 가게와 간단한 팩토리
- 2.6 팩토리 메소드 패턴
- 2.7 의존성 뒤집기 원칙(DIP)
- 2.8 추상 팩토리 패턴
- 2.9 초콜릿 보일러와 싱글턴의 필요성
- 2.10 고전적 싱글턴 구현
- 2.11 멀티스레딩 문제와 해결법
기존 코드를 건드리지 않고 기능을 확장하고, 객체 생성을 캡슐화하고, 인스턴스를 하나로 제한하는 것 -- 생성과 구조의 기본기를 다지는 세 가지 패턴이야.
스타버즈 커피숍에서 HouseBlend, DarkRoast, Espresso 같은 음료 클래스가 있는데, 여기에 우유, 모카, 휘핑크림 같은 첨가물을 조합해야 한다고 해보자. 상속으로 처리하면 HouseBlendWithMocha, HouseBlendWithMochaAndWhip... 클래스 폭발이 일어나지. 그렇다고 Beverage에 hasMilk(), hasSoy() 같은 boolean을 넣으면, 첨가물이 추가될 때마다 클래스를 수정해야 해. 이건 OCP(개방-폐쇄 원칙) — "확장에는 열려 있고, 변경에는 닫혀 있어야 한다" — 를 정면으로 위반하는 거야.
**데코레이터 패턴(Decorator Pattern)**은 객체를 감싸서 새로운 책임을 동적으로 추가해. DarkRoast를 Mocha로 감싸고, 다시 Whip으로 감싸면, cost()를 호출했을 때 Whip이 Mocha를 호출하고, Mocha가 DarkRoast를 호출해서 각자 자기 가격을 더해 돌려주는 구조야. 핵심은 데코레이터의 타입이 감싸는 객체의 타입과 같다는 거지. Mocha도 Beverage이고 DarkRoast도 Beverage니까, 어디든 Beverage가 필요한 곳에 쓸 수 있어. Java의 java.io 패키지가 바로 이 패턴의 실전 예시야 — FileInputStream을 BufferedInputStream으로 감싸고, 다시 LineNumberInputStream으로 감싸는 거. 기존 클래스 수정 없이 새로운 기능을 자유롭게 덧붙일 수 있지. 다만 자잘한 래퍼 클래스가 많아지는 건 감수해야 해.
데코레이터가 기존 객체에 기능을 덧붙이는 거였다면, 이번엔 객체를 만드는 과정 자체를 캡슐화해보자. 피자 가게에서 orderPizza(String type) 안에 new CheesePizza(), new PepperoniPizza() 같은 코드가 있으면, 메뉴가 바뀔 때마다 이 코드를 수정해야 하잖아. 바뀌는 부분(객체 생성)과 바뀌지 않는 부분(준비/굽기/자르기/포장)이 섞여 있는 게 문제야. 바뀌는 부분을 분리하면 SimplePizzaFactory가 되는데, 이건 패턴이라기보다 관용구에 가까워.
진짜 패턴은 프랜차이즈 상황에서 등장해. 뉴욕, 시카고 지점이 각각 지역 스타일 피자를 만들어야 할 때, **팩토리 메소드 패턴(Factory Method Pattern)**은 PizzaStore를 추상 클래스로 만들고 createPizza()를 추상 메소드로 선언해서, 어떤 피자를 만들지를 서브클래스가 결정하게 해. orderPizza()의 알고리즘은 슈퍼클래스에 있고, 구체적인 생성만 서브클래스에 위임하는 구조지. 이렇게 하면 PizzaStore는 Pizza라는 추상 클래스에만 의존하고, 구상 피자 클래스들도 Pizza에 의존해 — 의존성의 방향이 뒤집어진 거야. 이게 DIP(의존성 뒤집기 원칙): 고수준 모듈과 저수준 모듈 모두 추상화에 의존해야 한다는 것. 여기서 한 단계 더 나가면 **추상 팩토리 패턴(Abstract Factory Pattern)**이야. 각 지점이 같은 재료를 쓰되 지역별로 다른 원재료를 써야 할 때, PizzaIngredientFactory 인터페이스가 연관된 제품군 전체를 만들어줘. 팩토리 메소드가 상속으로 하나의 객체 생성을 서브클래스에 맡기는 거라면, 추상 팩토리는 구성으로 연관된 제품군 전체를 만드는 인터페이스를 제공하는 거야.
객체 생성을 캡슐화하는 게 팩토리였다면, 인스턴스를 딱 하나로 제한해야 하는 경우도 있어. 초콜릿 보일러가 두 개 만들어지면 이미 끓고 있는 보일러에 재료를 또 넣는 사고가 나잖아. 커넥션 풀, 설정 객체, 로거 같은 것들도 마찬가지야. **싱글턴 패턴(Singleton Pattern)**은 private 생성자로 외부 생성을 막고, static getInstance()로 유일한 인스턴스를 반환하는 간단한 구조야. 전역 변수 대신 쓰면 lazy initialization까지 가능하지.
근데 이 심플한 구현에 치명적 함정이 있어. 두 스레드가 거의 동시에 getInstance()를 호출하면 둘 다 null 체크를 통과해서 싱글턴이 두 개 생겨버려. 해결법이 세 가지 있는데 — synchronized는 간단하지만 매번 동기화 오버헤드가 붙고, eager initialization은 클래스 로딩 시 바로 만들어서 스레드 문제를 원천 차단하고, **DCL(Double-Checked Locking)**은 처음에만 동기화하고 volatile로 가시성을 보장해. 현대 Java에서는 enum을 쓰는 게 가장 안전하다고 Joshua Bloch도 말하지. 다만 잊지 말아야 할 건, 싱글턴은 결국 전역 상태라는 거야. 남용하면 테스트가 어렵고 결합이 높아져. 요즘은 DI 프레임워크가 대부분의 사용 사례를 대체하니까, 정말로 인스턴스가 하나여야 하는 경우에만 쓰자.
정리
2장 읽고 기억할 거 세 가지:
- OCP와 데코레이터 — 기존 코드를 수정하지 않고 객체를 감싸서 동적으로 기능을 추가해. 상속 대신 구성으로 확장하는 대표적인 방법이야.
- 팩토리 패턴과 DIP —
new대신 팩토리에 위임하면 구상 클래스에 대한 의존을 끊을 수 있어. 고수준과 저수준 모두 추상화에 의존하게 만들자. - 싱글턴은 신중하게 — 멀티스레딩 함정이 있고, 남용하면 전역 상태가 돼. 정말 필요한 경우에만 쓰고, DI 프레임워크를 먼저 고려하자.