디자인 패턴 핵심 정리 시리즈 2편. 생성 패턴 5가지(Singleton·Factory Method·Abstract Factory·Builder·Prototype)를 회사 대표·자동 생산 라인·복사기 비유로 풀어가며, 각 패턴의 위반·준수 코드와 시험에 자주 나오는 함정 6가지를 처음 보는 사람도 따라올 수 있게 친절하게 풀어쓴 2편.
이 글은 디자인 패턴 핵심 정리 시리즈의 두 번째 편입니다. 1편에서는 SOLID 원칙 다섯 가지를 회사 직무 분담 비유로 묶었는데, 그 원칙을 코드 차원에서 풀어내는 첫 번째 무리가 바로 생성 패턴이에요. "객체를 어떻게 만드느냐"에 관한 이야기를 다섯 가지 패턴으로 정리합니다.
생성 패턴은 GoF 23가지 디자인 패턴 중 5가지를 차지해요. Singleton · Factory Method · Abstract Factory · Builder · Prototype — 이름만 봐도 머리가 아픈데, 핵심을 한 줄씩 묶으면 의외로 단순해집니다. 이번 글에서는 다섯 가지를 차례차례 비유로 묶어두고 위반·준수 코드를 나란히 비교해 보겠습니다.
왜 생성 패턴이 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, new 키워드 한 줄이면 끝나는 일을 왜 굳이 패턴으로 만드나 싶습니다. 사실 단순한 객체 하나를 만드는 건 new로 충분해요. 하지만 객체가 복잡하거나, 생성 비용이 크거나, 같은 인스턴스를 여러 곳에서 공유하거나, 부품 묶음으로 만들어야 할 때는 그 단순한 new 한 줄이 오히려 발목을 잡습니다.
둘째, 다섯 가지가 비슷한 듯 다릅니다. Factory Method와 Abstract Factory는 이름부터 헷갈리고, Builder와 Prototype도 "복잡한 객체를 만든다"는 점에서 겹쳐 보여요. 하지만 각 패턴이 풀려고 하는 문제가 달라서, 한 번 차이를 잡으면 헷갈리지 않습니다.
셋째, 언제 어떤 패턴을 써야 할지가 안 보입니다. "이 상황은 Builder를 써야지"라는 직관은 코드 신호 한두 가지를 익혀야 생기는데, 그 신호가 처음엔 잘 안 보여요.
해결법은 한 가지예요. 각 패턴을 일상 비유 한 줄로 묶고, 위반 코드 신호(생성자 폭발·new ConcreteClass()·전역 변수 같은 인스턴스)를 익히면 한 번에 정리됩니다. 이 글이 그 비유와 신호를 따라 다섯 가지를 차례로 풀어 갑니다.
다섯 가지 생성 패턴을 한 줄로 묶기
본격 설명에 들어가기 전에 다섯 가지 생성 패턴을 일상 비유로 한 줄씩 묶어 두고 시작할게요. 시험 직전에 이 표만 다시 봐도 70%는 떠오릅니다.
| 패턴 | 비유 | 핵심 |
|---|---|---|
| Singleton | "회사 대표는 한 명" | 인스턴스가 단 하나만 존재 |
| Factory Method | "자동 생산 라인" | 어떤 제품을 만들지 팩토리가 결정 |
| Abstract Factory | "OS별 부품 한 묶음 생산" | 관련된 제품 군을 한 세트로 |
| Builder | "주문서 부분별 조립" | 옵션 많은 객체를 단계별로 |
| Prototype | "복사기로 똑같이 찍어내기" | 기존 객체를 복제해 새 것 만들기 |
다섯 가지 모두 "객체를 어떻게 만드느냐"에 관한 답이에요. 하지만 각자가 풀려는 상황이 달라서, 어떤 신호를 만났을 때 어떤 패턴을 꺼낼지를 익히는 게 핵심입니다. 자세한 사양은 Refactoring.Guru의 생성 패턴 가이드에서도 같이 보면 좋아요.
Singleton — 회사 대표는 한 명
가장 먼저 만나는 패턴이고 가장 논쟁이 많은 패턴이에요. 정의는 한 줄입니다.
"한 클래스의 인스턴스가 오직 하나만 존재하도록 보장하고, 그 인스턴스에 전역 접근점을 제공한다."
회사 비유로 — 대표이사는 한 명이에요. 회사 안 어디서든 "대표 어디 있어요?"라고 물으면 같은 분으로 안내됩니다. 부서마다 따로 모셔둔 대표가 있을 수 없죠. 앱 설정·DB 연결 풀·로거 같은 "전사 공유 자원"이 딱 이 모양입니다.
기본 Singleton 구현
// 앱 전체 설정을 한 곳에서 관리
public class AppSettings {
// 유일한 인스턴스 — static이라 클래스 레벨에 하나만 존재
private static AppSettings instance;
// private 생성자 — 외부에서 new AppSettings() 불가
// 이게 싱글톤의 핵심
private AppSettings() {
System.out.println("AppSettings instance created");
}
// 전역 접근점 — 외부는 이 메서드로만 인스턴스 획득
public static AppSettings getInstance() {
// Lazy Initialization — 처음 호출될 때만 생성
if (instance == null) {
instance = new AppSettings();
}
return instance;
}
private String databaseUrl = "jdbc:mysql://localhost:3306/mydb";
private int maxConnections = 10;
public String getDatabaseUrl() { return databaseUrl; }
public int getMaxConnections() { return maxConnections; }
}
// 클라이언트
public class App {
public static void main(String[] args) {
AppSettings settings1 = AppSettings.getInstance();
AppSettings settings2 = AppSettings.getInstance();
System.out.println(settings1 == settings2); // true — 같은 객체
}
}
여기서 시험 함정이 하나 있어요. 위 코드는 멀티스레드 환경에서 안전하지 않습니다. 두 스레드가 동시에 if (instance == null)을 통과하면 인스턴스가 두 개 생길 수 있어요. 싱글톤이 아니게 되는 순간이죠.
스레드 안전 Singleton 세 가지 방식
// 방법 1: synchronized 메서드 — 가장 단순하지만 매 호출마다 락 → 성능 저하
public class ThreadSafeSingleton {
private static ThreadSafeSingleton instance;
private ThreadSafeSingleton() {}
public static synchronized ThreadSafeSingleton getInstance() {
if (instance == null) {
instance = new ThreadSafeSingleton();
}
return instance;
}
}
// 방법 2: Double-Checked Locking — 권장
public class DoubleCheckedSingleton {
// volatile — CPU 캐시 가시성 보장
private static volatile DoubleCheckedSingleton instance;
private DoubleCheckedSingleton() {}
public static DoubleCheckedSingleton getInstance() {
if (instance == null) { // 1차 체크 — 락 없이 빠르게
synchronized (DoubleCheckedSingleton.class) {
if (instance == null) { // 2차 체크 — 락 안에서 확실히
instance = new DoubleCheckedSingleton();
}
}
}
return instance;
}
}
// 방법 3: Bill Pugh 방식 — 가장 권장
public class BillPughSingleton {
private BillPughSingleton() {}
// 내부 정적 클래스 — 처음 getInstance() 호출 시 JVM이 로드
// JVM 클래스 로딩이 thread-safety 자동 보장
private static class SingletonHolder {
private static final BillPughSingleton INSTANCE = new BillPughSingleton();
}
public static BillPughSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
여기서 정말 중요한 시험 함정 — volatile 키워드가 빠진 Double-Checked Locking은 미묘하게 깨집니다. JVM의 명령어 재배치(reordering) 때문에 다른 스레드가 "아직 초기화가 끝나지 않은" 인스턴스를 보게 될 수 있어요. volatile은 옵션이 아니에요.
Enum 싱글톤 — 가장 안전한 패턴
// Enum 싱글톤 — thread-safe + 직렬화 안전 + 리플렉션 안전
public enum AppConfig {
INSTANCE;
private String dbUrl = "jdbc:mysql://localhost:3306/mydb";
public String getDbUrl() { return dbUrl; }
}
// 사용
AppConfig.INSTANCE.getDbUrl();
Effective Java의 저자 Joshua Bloch가 권장하는 방식이에요. JVM이 enum의 단일성을 자동으로 보장하기 때문에 Bill Pugh 방식보다도 깔끔합니다.
Singleton을 언제 쓰는가
- 앱 전체 공유 설정 객체
- 데이터베이스 연결 풀
- 로깅 시스템(Logger)
- 캐시 관리자
- 스레드 풀 관리자
여기서 시험 함정이 하나 있어요. Singleton은 사실 "안티패턴"이라는 비판을 자주 받습니다. 전역 상태를 만들어 단위 테스트가 어려워지고, 의존성이 코드 안에 숨어버리거든요. 면접에서도 "꼭 필요한 곳에만 쓰고, 가능하면 의존성 주입(DI)으로 대체하라"는 답을 기대해요. 무조건 좋은 패턴이라고 말하면 마이너스입니다.
Factory Method — 자동 생산 라인
"객체 생성 코드를 클라이언트로부터 분리하고, 어떤 클래스의 인스턴스를 만들지 팩토리가 결정한다."
회사 비유로 — 자동 생산 라인이에요. 클라이언트는 "차량 한 대 주세요"라고 주문서만 넣고, 어떤 차종이 만들어질지는 라인(팩토리)이 결정합니다. 클라이언트는 구체적인 차종 이름을 몰라도 됩니다.
기본 Factory Method 구현
// 공통 인터페이스 — 모든 교통수단의 계약
public interface Transport {
void deliver();
String getType();
}
// 구체적인 교통수단들
public class Car implements Transport {
@Override public void deliver() {
System.out.println("Delivering by Car - road delivery");
}
@Override public String getType() { return "Car"; }
}
public class Bike implements Transport {
@Override public void deliver() {
System.out.println("Delivering by Bike - fast urban delivery");
}
@Override public String getType() { return "Bike"; }
}
public class Bus implements Transport {
@Override public void deliver() {
System.out.println("Delivering by Bus - bulk delivery");
}
@Override public String getType() { return "Bus"; }
}
// 팩토리 — 객체 생성 로직을 한 곳에서
public class TransportFactory {
public static Transport createTransport(String type) {
switch (type.toLowerCase()) {
case "car": return new Car();
case "bike": return new Bike();
case "bus": return new Bus();
default:
throw new IllegalArgumentException("Unsupported transport type: " + type);
}
}
}
// 클라이언트 — 구체적인 클래스 몰라도 됨
public class DeliveryApp {
public static void main(String[] args) {
Transport car = TransportFactory.createTransport("car");
Transport bike = TransportFactory.createTransport("bike");
Transport bus = TransportFactory.createTransport("bus");
car.deliver();
bike.deliver();
bus.deliver();
}
}
인터페이스 기반 Factory Method (정통 형태)
위 예시는 사실 Static Factory 또는 Simple Factory라고도 불러요. GoF 책의 정통 Factory Method는 팩토리 자체도 인터페이스로 추상화한 형태입니다.
// 팩토리 인터페이스 — 어떤 Transport를 만들지는 서브클래스가 결정
public interface TransportFactory {
Transport createTransport();
}
public class CarFactory implements TransportFactory {
@Override public Transport createTransport() {
return new Car();
}
}
public class BikeFactory implements TransportFactory {
@Override public Transport createTransport() {
return new Bike();
}
}
// 클라이언트 — 팩토리 인터페이스에만 의존 (DIP 준수)
public class LogisticsApp {
private TransportFactory factory;
public LogisticsApp(TransportFactory factory) {
this.factory = factory; // 의존성 주입
}
public void planDelivery() {
Transport transport = factory.createTransport();
transport.deliver();
}
}
여기서 시험 함정이 하나 있어요. Static Factory와 Factory Method를 구분하는 문제가 면접에 자주 나옵니다. Static Factory는 "정적 메서드 안에서 분기로 객체를 만드는 방식"이고, GoF의 Factory Method는 "팩토리 자체도 추상화해서 서브클래스가 어떤 객체를 만들지 결정하는 방식"이에요. 둘 다 "팩토리 패턴"이라는 우산 아래 묶여 있지만 형태가 미묘하게 다릅니다.
Factory Method를 언제 쓰는가
- 생성할 객체 타입이 런타임에 결정될 때
- 객체 생성 로직을 중앙 집중화하고 싶을 때
- 클라이언트 코드를 구체적인 클래스로부터 분리하고 싶을 때
> 한 줄 정리 — Factory Method는 "어떤 클래스를 만들지" 책임을 팩토리에 위임하는 패턴.
Abstract Factory — OS별 부품 한 묶음 생산
"관련된 객체들의 군(family)을 생성하는 인터페이스를 정의하되, 구체적인 클래스는 명시하지 않는다."
회사 비유로 — OS별 UI 부품 한 세트 생산이에요. Factory Method가 차량 한 대를 만든다면, Abstract Factory는 "Mac용 부품 한 묶음(버튼·스크롤바·텍스트박스)" 또는 "Windows용 부품 한 묶음"처럼 연관된 부품 세트를 한꺼번에 만드는 패턴입니다.
이 패턴이 풀려고 하는 문제는 한 줄로 — "Mac 버튼과 Windows 스크롤바를 섞어 쓰는 사고" 를 막는 거예요. 한 팩토리에서 나온 부품들은 서로 어울린다는 일관성을 보장합니다.
크로스 플랫폼 UI 예시
// UI 컴포넌트 인터페이스들
public interface Button {
void render();
void onClick();
}
public interface ScrollBar {
void scroll();
}
// Windows 구현체들
public class WindowsButton implements Button {
@Override public void render() {
System.out.println("Rendering Windows-style button");
}
@Override public void onClick() {
System.out.println("Windows button clicked");
}
}
public class WindowsScrollBar implements ScrollBar {
@Override public void scroll() {
System.out.println("Windows-style scrolling");
}
}
// Mac 구현체들
public class MacButton implements Button {
@Override public void render() {
System.out.println("Rendering Mac-style button (rounded corners)");
}
@Override public void onClick() {
System.out.println("Mac button clicked");
}
}
public class MacScrollBar implements ScrollBar {
@Override public void scroll() {
System.out.println("Mac-style scrolling (smooth momentum)");
}
}
// 추상 팩토리 — 관련 부품 군을 만드는 계약
public interface UIFactory {
Button createButton();
ScrollBar createScrollBar();
}
// Windows용 팩토리 — Windows 부품 세트
public class WindowsFactory implements UIFactory {
@Override public Button createButton() { return new WindowsButton(); }
@Override public ScrollBar createScrollBar() { return new WindowsScrollBar(); }
}
// Mac용 팩토리 — Mac 부품 세트
public class MacFactory implements UIFactory {
@Override public Button createButton() { return new MacButton(); }
@Override public ScrollBar createScrollBar() { return new MacScrollBar(); }
}
// 애플리케이션 — UIFactory에만 의존
public class Application {
private Button button;
private ScrollBar scrollBar;
public Application(UIFactory factory) {
this.button = factory.createButton();
this.scrollBar = factory.createScrollBar();
// 같은 팩토리에서 만들어졌으니 일관성 보장
}
public void render() {
button.render();
scrollBar.scroll();
}
}
// 클라이언트 — OS 감지해 적절한 팩토리 선택
public class Main {
public static void main(String[] args) {
UIFactory factory;
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("windows")) {
factory = new WindowsFactory();
} else if (os.contains("mac")) {
factory = new MacFactory();
} else {
factory = new WindowsFactory();
}
Application app = new Application(factory);
app.render();
}
}
Factory Method vs Abstract Factory
| 비교 항목 | Factory Method | Abstract Factory |
|---|---|---|
| 생성 대상 | 단일 객체 | 관련 객체들의 군(family) |
| 추상화 | 하나의 팩토리 메서드 | 여러 팩토리 메서드를 가진 인터페이스 |
| 초점 | 어떤 클래스를 생성할지 | 관련 객체들 간의 일관성 |
| 비유 | "차량 한 대 주세요" | "Mac용 부품 세트 주세요" |
여기서 정말 중요한 시험 함정 — Abstract Factory는 새로운 "제품 타입" 추가가 어려워요. UIFactory에 createTextField()를 추가하면 Windows·Mac 팩토리 클래스를 모두 수정해야 합니다. 반면 새로운 "플랫폼"(예: Linux)을 추가하는 건 쉬워요. 새 LinuxFactory 클래스만 추가하면 됩니다. 제품 타입이 자주 바뀌는 영역에는 Abstract Factory가 어울리지 않습니다.
> 한 줄 정리 — Abstract Factory는 "관련 부품 한 묶음을 한 팩토리에서 일관되게 만든다"는 패턴. 새 플랫폼 추가는 쉬움, 새 제품 타입 추가는 어려움.
Builder — 주문서 부분별 조립
"복잡한 객체를 단계별로 구성한다. 같은 구성 과정으로 다양한 표현을 만들 수 있다."
회사 비유로 — 주문서 부분별 조립이에요. 집을 짓는다면 기초·벽·지붕 같은 필수 부분이 있고, 정원·차고·수영장·층수 같은 선택 옵션이 있어요. 옵션 조합이 많아지면 생성자 하나로는 감당이 안 됩니다.
생성자 폭발 문제 (Without Builder)
// 나쁜 예 — 선택적 파라미터로 인한 생성자 폭발
public class House {
private String foundation; // 필수
private String walls; // 필수
private String roof; // 필수
private boolean hasGarden; // 선택
private boolean hasGarage; // 선택
private boolean hasPool; // 선택
private int numFloors; // 선택
public House(String foundation, String walls, String roof) { ... }
public House(String foundation, String walls, String roof, boolean hasGarden) { ... }
public House(String foundation, String walls, String roof, boolean hasGarden, boolean hasGarage) { ... }
// ... 조합이 많아질수록 관리 불가
// boolean 파라미터 순서 실수 위험도 큼
}
여기서 시험 함정이 하나 있어요. 선택적 파라미터가 4개 이상이거나 같은 타입(boolean·int)이 여러 개 연속해서 등장한다면 생성자 호출이 어떤 의미인지 코드만 봐서는 알 수 없게 됩니다. new House("c", "b", "t", true, false, true, 3) — 이 boolean 세 개가 뭔지 누가 봐도 못 맞춰요. Builder 신호가 뜨는 순간입니다.
Builder 패턴 구현
// 최종 생성 대상 — 생성자를 private으로 막아서 Builder만 만들 수 있게
public class House {
private final String foundation;
private final String walls;
private final String roof;
private final boolean hasGarden;
private final boolean hasGarage;
private final boolean hasSwimmingPool;
private final int numFloors;
// private 생성자 — HouseBuilder만 호출 가능
private House(HouseBuilder builder) {
this.foundation = builder.foundation;
this.walls = builder.walls;
this.roof = builder.roof;
this.hasGarden = builder.hasGarden;
this.hasGarage = builder.hasGarage;
this.hasSwimmingPool = builder.hasSwimmingPool;
this.numFloors = builder.numFloors;
}
// Getter들 (불변 객체)
public String getFoundation() { return foundation; }
public String getWalls() { return walls; }
public String getRoof() { return roof; }
public boolean hasGarden() { return hasGarden; }
public boolean hasGarage() { return hasGarage; }
public boolean hasSwimmingPool() { return hasSwimmingPool; }
public int getNumFloors() { return numFloors; }
// 정적 내부 클래스 Builder
public static class HouseBuilder {
// 필수 속성
private final String foundation;
private final String walls;
private final String roof;
// 선택 속성 — 기본값 지정
private boolean hasGarden = false;
private boolean hasGarage = false;
private boolean hasSwimmingPool = false;
private int numFloors = 1;
// 빌더 생성자 — 필수 속성만 받음
public HouseBuilder(String foundation, String walls, String roof) {
this.foundation = foundation;
this.walls = walls;
this.roof = roof;
}
// 각 setter는 this 반환 → 메서드 체이닝
public HouseBuilder setGarden(boolean hasGarden) {
this.hasGarden = hasGarden;
return this;
}
public HouseBuilder setGarage(boolean hasGarage) {
this.hasGarage = hasGarage;
return this;
}
public HouseBuilder setSwimmingPool(boolean hasSwimmingPool) {
this.hasSwimmingPool = hasSwimmingPool;
return this;
}
public HouseBuilder setNumFloors(int numFloors) {
this.numFloors = numFloors;
return this;
}
// 최종 생성 — 유효성 검증도 여기서
public House build() {
validateHouse();
return new House(this);
}
private void validateHouse() {
if (numFloors < 1 || numFloors > 100) {
throw new IllegalStateException("Invalid number of floors: " + numFloors);
}
}
}
}
// 클라이언트 — 메서드 체이닝으로 직관적
public class HouseApp {
public static void main(String[] args) {
// 기본 집
House simpleHouse = new House.HouseBuilder("concrete", "brick", "tile")
.build();
// 옵션 풀세트 집
House luxuryHouse = new House.HouseBuilder("reinforced concrete", "glass", "solar panels")
.setGarden(true)
.setGarage(true)
.setSwimmingPool(true)
.setNumFloors(3)
.build();
}
}
이름 있는 setter로 옵션을 지정하니 가독성이 좋아지고, boolean 순서 실수도 컴파일 단계에서 막힙니다.
표준 라이브러리에 숨어 있는 Builder
// Java의 StringBuilder도 Builder 패턴
StringBuilder sb = new StringBuilder()
.append("Hello")
.append(", ")
.append("World")
.append("!");
// Stream API도 Builder 패턴의 변형
Stream.of(1, 2, 3, 4, 5)
.filter(n -> n > 2)
.map(n -> n * 2)
.collect(Collectors.toList());
체이닝하면서 단계별로 구성하는 모든 API가 사실 Builder의 사촌이에요.
Lombok @Builder
// Lombok @Builder — 보일러플레이트 자동 생성
@Builder
public class House {
private final String foundation;
private final String walls;
private final String roof;
@Builder.Default private final boolean hasGarden = false;
@Builder.Default private final int numFloors = 1;
}
House house = House.builder()
.foundation("concrete")
.walls("brick")
.roof("tile")
.hasGarden(true)
.build();
여기서 시험 함정이 하나 있어요. 속성이 2~3개뿐인 객체에 Builder를 적용하면 과도한 설계입니다. 필수 파라미터 3개 정도면 그냥 생성자가 더 깔끔해요. Builder는 선택적 파라미터가 4개 이상이거나 옵션 조합이 복잡할 때의 처방전입니다.
> 한 줄 정리 — Builder는 "옵션 많은 객체를 단계별로 조립"하는 패턴. 생성자 폭발 신호가 보이면 꺼내 쓴다.
Prototype — 복사기로 똑같이 찍어내기
"기존 객체를 복사(clone)하여 새 객체를 만든다."
회사 비유로 — 복사기로 똑같이 찍어내기예요. 객체를 처음부터 다시 초기화하는 비용이 크거나 복잡할 때, 이미 만들어 둔 객체를 본떠 새 객체를 만드는 패턴입니다. 게임 세이브 포인트, Undo 기능, 설정 템플릿 복제 등에 자주 보입니다.
이 패턴을 다룰 때 가장 중요한 개념이 얕은 복사 vs 깊은 복사예요.
얕은 복사의 함정
class GamePiece {
private String color;
private int[] position; // 참조 타입
public GamePiece(String color, int[] position) {
this.color = color;
this.position = position;
}
public int[] getPosition() { return position; }
public String getColor() { return color; }
}
// 얕은 복사 — 참조만 복사 (같은 배열을 공유)
GamePiece original = new GamePiece("red", new int[]{1, 2});
GamePiece shallowCopy = original;
// 문제 — 복사본을 바꿨는데 원본이 같이 바뀜!
shallowCopy.getPosition()[0] = 99;
System.out.println(original.getPosition()[0]); // 99
여기서 시험 함정이 하나 있어요. 얕은 복사는 단순 참조 복사예요. 같은 객체를 두 변수가 가리키는 셈이라 한쪽을 바꾸면 다른 쪽도 따라 바뀝니다. 진짜 독립된 복제본을 만들려면 깊은 복사가 필요해요.
Prototype 패턴 구현 (깊은 복사 포함)
// 프로토타입 인터페이스 — 자기 자신을 복제해 반환
interface Prototype<T> {
T clone();
}
// 게임 말 — 기본 타입(int·String)이라 단순 깊은 복사
public class GamePiece implements Prototype<GamePiece> {
private String color;
private int x;
private int y;
public GamePiece(String color, int x, int y) {
this.color = color;
this.x = x;
this.y = y;
}
@Override
public GamePiece clone() {
return new GamePiece(this.color, this.x, this.y);
}
public String getColor() { return color; }
public int getX() { return x; }
public int getY() { return y; }
public void setPosition(int x, int y) { this.x = x; this.y = y; }
}
// 게임 보드 — 여러 GamePiece를 포함 (참조 타입 필드)
public class GameBoard implements Prototype<GameBoard> {
private List<GamePiece> pieces;
public GameBoard() {
this.pieces = new ArrayList<>();
}
public void addPiece(GamePiece piece) {
pieces.add(piece);
}
public List<GamePiece> getPieces() { return pieces; }
// 깊은 복사 — 각 piece를 clone()으로 새로 만들기
@Override
public GameBoard clone() {
GameBoard newBoard = new GameBoard();
for (GamePiece piece : this.pieces) {
newBoard.addPiece(piece.clone()); // 각 piece도 새 객체!
}
return newBoard;
}
}
// 클라이언트 — 게임 체크포인트 시뮬레이션
public class GameApp {
public static void main(String[] args) {
GameBoard originalBoard = new GameBoard();
originalBoard.addPiece(new GamePiece("white", 0, 0));
originalBoard.addPiece(new GamePiece("black", 1, 1));
// 체크포인트 저장 — Prototype으로 복제
GameBoard savedBoard = originalBoard.clone();
// 원본 보드에서 말 이동
originalBoard.getPieces().get(0).setPosition(3, 3);
// 복사본은 영향 없음 — 깊은 복사 확인
// 체크포인트로 복원
GameBoard restoredBoard = savedBoard.clone();
}
}
Java의 Cloneable 인터페이스
// Java 표준 라이브러리의 Cloneable
public class Player implements Cloneable {
private String name;
private int score;
public Player(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public Player clone() {
try {
return (Player) super.clone(); // Object.clone() — 얕은 복사!
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
여기서 정말 중요한 시험 함정 — Object.clone()은 기본적으로 얕은 복사예요. 참조 타입 필드(List·배열·다른 객체)가 있으면 깊은 복사를 위해 별도로 구현해야 합니다. Java의 Cloneable은 사실 Effective Java에서도 "쓰지 마라"는 권고를 받는 인터페이스예요. 더 권장되는 방식은 복사 생성자(copy constructor) 또는 복사 팩토리 메서드(copy factory) 입니다.
// 복사 생성자 — Cloneable보다 권장
public class Player {
private final String name;
private final int score;
public Player(String name, int score) {
this.name = name;
this.score = score;
}
// 복사 생성자
public Player(Player other) {
this.name = other.name;
this.score = other.score;
}
}
// 사용
Player original = new Player("Alice", 100);
Player copy = new Player(original);
Prototype을 언제 쓰는가
- 객체 생성 비용이 크거나 복잡할 때
- 비슷한 객체를 다량 생성할 때 (게임 적군 100마리)
- 게임 세이브·Undo 기능
- 설정 객체의 변형 만들기
> 한 줄 정리 — Prototype은 "기존 객체를 본떠 새 객체"를 만드는 패턴. 깊은 복사 vs 얕은 복사 구분이 핵심.
다섯 가지 생성 패턴 종합 비교표
다섯 가지를 한 표로 정리하면 이래요. 시험 직전에 이 표만 다시 봐도 머리에서 정리됩니다.
| 패턴 | 목적 | 핵심 메커니즘 | 대표 예시 | 장점 | 단점 |
|---|---|---|---|---|---|
| Singleton | 단일 인스턴스 보장 | private 생성자 + static 인스턴스 | 앱 설정·Logger | 전역 접근, 자원 절약 | 테스트 어려움, 전역 상태 |
| Factory Method | 객체 생성 캡슐화 | 팩토리 클래스/메서드 | 교통수단 생성 | 클라이언트-구현 분리 | 클래스 수 증가 |
| Abstract Factory | 관련 객체 군 생성 | 팩토리 인터페이스 | 플랫폼별 UI | 일관성 보장 | 새 제품 타입 추가 어려움 |
| Builder | 단계별 복잡한 객체 생성 | 내부 빌더 클래스 + 체이닝 | 집 건설·HTTP 요청 | 가독성, 불변 객체 | 빌더 클래스 별도 필요 |
| Prototype | 기존 객체 복사로 생성 | clone() 메서드 | 게임 세이브 | 초기화 비용 절감 | 깊은 복사 구현 복잡 |
다섯 가지 패턴은 어떻게 연결되나
서로 따로 노는 게 아니라 의외의 곳에서 만나요.
- Singleton + Factory — 팩토리 자체를 싱글톤으로 만드는 경우가 흔합니다. 팩토리 인스턴스가 여러 개일 필요가 없으니까요.
- Abstract Factory + Factory Method — Abstract Factory의 각 메서드는 사실 Factory Method예요. 여러 Factory Method를 묶은 게 Abstract Factory.
- Builder + Prototype — 복잡한 객체의 시작점을 Prototype으로 만들고 Builder로 변형하는 조합이 자주 보입니다.
- Builder + Singleton — Builder를 통해 만들어진 객체가 사실상 시스템에서 유일한 설정 객체일 때.
> 한 줄 정리 — 다섯 가지 생성 패턴은 서로 묶일 수 있는 처방전. 단독으로만 쓰는 게 아니라 조합해 쓰는 경우가 많다.
생성 패턴에서 자주 빠지는 함정 6가지
1. Singleton 남용
전역 상태가 되어 단위 테스트가 어려워져요. 꼭 필요한 곳에만, 가능하면 의존성 주입(DI)으로 대체. Spring 같은 프레임워크는 빈(Bean)을 기본 싱글톤으로 관리하니, 직접 Singleton을 구현할 일은 생각보다 적습니다.
2. Singleton의 스레드 안전성 무시
기본 Lazy Initialization은 멀티스레드에서 깨져요. Bill Pugh 방식 또는 Enum 싱글톤을 권장. Double-Checked Locking을 쓴다면 volatile 빠뜨리지 말 것.
3. Factory Method의 분기 폭발
Static Factory의 switch/if-else가 점점 길어지면 OCP 위반 신호예요. 새 타입이 추가될 때마다 팩토리를 수정해야 한다면 인터페이스 기반 Factory Method로 전환을 고려.
4. Abstract Factory의 제품 타입 추가
새 플랫폼은 쉽게 추가되지만 새 제품 타입(예: TextField)은 모든 팩토리를 손대야 해요. 제품 타입이 안정적인 영역에 적용. 자주 바뀐다면 다른 패턴 고려.
5. Builder의 과도한 적용
속성 2~3개뿐인 객체에 Builder를 박으면 보일러플레이트만 늘어나요. 선택적 파라미터 4개 이상 또는 boolean·int 같은 동일 타입이 연속될 때만.
6. Prototype의 얕은 복사 실수
Object.clone() 또는 단순 참조 복사로 끝내면 원본과 복사본이 내부 객체를 공유해요. 참조 타입 필드(List·배열·다른 객체)는 재귀적으로 깊은 복사해야 합니다. 또는 복사 생성자를 쓰는 게 더 안전.
생성 패턴 코드 리뷰 체크리스트 — 실전용
Singleton 신호
- 전역 상태가 필요한가? 정말 단일 인스턴스가 보장되어야 하나?
- 멀티스레드 환경에서 안전한가? (
volatile·synchronized·Bill Pugh·Enum 중 하나) - 단위 테스트 시 mock 주입이 가능한 구조인가?
Factory Method 신호
- 클라이언트 코드에
new ConcreteClass()가 흩어져 있는가? - 새 타입 추가 시 분기문이 늘어나는가?
- 객체 생성 로직이 중앙화되어 있는가?
Abstract Factory 신호
- 관련 부품들이 한 세트로 묶여야 하는가? (Mac 부품·Windows 부품)
- 부품들 간 일관성이 깨지면 안 되는 비즈니스 요구가 있는가?
- 새 플랫폼은 자주 추가되지만 새 제품 타입은 안정적인가?
Builder 신호
- 객체 속성이 4개 이상 + 선택적 파라미터가 많은가?
- 같은 타입(boolean·int)의 파라미터가 연속해서 등장하는가?
- 객체를 불변(immutable)으로 만들고 싶은가?
Prototype 신호
- 객체 생성 비용이 큰가? (DB 조회·파일 로드·복잡한 초기화)
- 비슷한 객체를 다량 생성하는가?
- 참조 타입 필드를 깊은 복사로 처리하고 있는가?
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 생성 패턴 2편의 핵심입니다. 시험 직전·코드 리뷰 직전에 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 생성 패턴 5가지 — Singleton · Factory Method · Abstract Factory · Builder · Prototype
- 모두 "객체를 어떻게 만드느냐"에 관한 답 —
new한 줄로 안 되는 상황의 처방전 - Singleton — 인스턴스 단 하나, private 생성자 + static 인스턴스. 회사 대표 비유
- 멀티스레드 안전 — Bill Pugh 방식 또는 Enum 싱글톤 권장
- Double-Checked Locking은
volatile필수 — 빠지면 미묘하게 깨짐 - Singleton은 안티패턴 비판 자주 받음 — 가능하면 DI로 대체
- Factory Method — 객체 생성 책임을 팩토리에 위임. 자동 생산 라인 비유
- Static Factory(분기형) ≠ GoF Factory Method(인터페이스 추상화) — 미묘한 차이
- Abstract Factory — 관련 부품 한 세트를 일관되게. OS별 UI 부품 비유
- Abstract Factory는 새 플랫폼 추가는 쉽지만 새 제품 타입 추가는 어려움 (모든 팩토리 수정)
- Factory Method vs Abstract Factory — 단일 객체 vs 객체 군
- Builder — 옵션 많은 객체 단계별 조립. 집 건설 비유
- Builder 신호 — 선택적 파라미터 4개 이상 + boolean/int 연속
- Builder는 불변 객체 만들기에도 좋음 (private 생성자 + final 필드)
- StringBuilder·Stream API도 Builder의 변형
- Prototype — 기존 객체 복사로 새 객체. 복사기 비유
- 얕은 복사 vs 깊은 복사 — 참조 타입 필드 있으면 반드시 깊은 복사
Object.clone()은 기본 얕은 복사 — 복사 생성자가 더 안전한 대안- 다섯 가지 패턴은 서로 조합 가능 — Singleton + Factory, Abstract Factory + Factory Method 등
- YAGNI 원칙 — 처음부터 모든 객체에 패턴 박지 말 것, 변경이 보일 때만
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — SOLID 원칙
- 2편 — 생성 패턴 (Singleton·Factory·Builder 등) (현재 글)
- 3편 — 구조 패턴 (Adapter·Decorator·Proxy 등)
- 4편 — 행위 패턴 (Observer·Strategy·Command 등)
- 5편 — OOP 원칙
- 6편 — 패턴 조합 실전 프로젝트 (완)