생성 패턴 — 객체를 만드는 일을 떼어낸다
new는 가장 결합도가 높은 문장이다. 그것을 쓰는 순간 어떤 구체 클래스를 쓸지 그 자리에서 확정된다. 생성 패턴 다섯은 전부 "무엇을 · 어떻게 만들지" 의 결정을 쓰는 곳에서 떼어내는 방법이다.암기: 싱 · 팩 · 추 · 빌 · 프
| 패턴 | 무엇을 떼어내나 | 대표 JDK |
|---|---|---|
| Singleton | 개수 — 딱 하나만 | Runtime.getRuntime() |
| Factory Method | 어떤 클래스인지 — 하위가 결정 | Calendar.getInstance() |
| Abstract Factory | 어울리는 한 벌 전체 | DocumentBuilderFactory |
| Builder | 조립 과정 | HttpRequest.newBuilder() |
| Prototype | 만드는 비용 — 복제로 대체 | Object.clone() |
Singleton — 단 하나의 인스턴스
인스턴스가 하나뿐임을 보장하고 전역에서 접근하게 한다.
순진한 구현이 깨지는 지점
public class Config {
private static Config instance;
private Config() { }
public static Config getInstance() {
if (instance == null) instance = new Config(); // ← 두 스레드가 동시에 통과한다
return instance;
}
}
두 스레드가 동시에 instance == null 을 통과하면 객체가 둘 만들어진다. 커넥션 풀이라면 풀이 두 벌 생기고, 캐시라면 절반씩 나뉘어 적중률이 반토막 난다. 단위 테스트로는 거의 안 잡히고 운영에서 드물게 터지는 종류의 버그다.
고치는 방법 셋
// ① 홀더 관용구 — 가장 단순하고 빠르다
public class Config {
private Config() { }
private static class Holder { static final Config INSTANCE = new Config(); }
public static Config getInstance() { return Holder.INSTANCE; }
}
내부 클래스는 처음 참조될 때 로딩되고, 클래스 로딩은 JVM 이 락으로 보호한다. 우리가 동기화를 쓰지 않았는데 스레드 안전한 이유가 이것이다. 지연 초기화까지 공짜로 따라온다.
// ② 이중 검사 잠금 — volatile 이 빠지면 조용히 깨진다
public class Config {
private static volatile Config instance; // volatile 필수
public static Config getInstance() {
if (instance == null) {
synchronized (Config.class) {
if (instance == null) instance = new Config();
}
}
return instance;
}
}
volatile 이 없으면 객체 생성이 완료되기 전에 참조가 먼저 보일 수 있다. 다른 스레드가 필드가 비어 있는 객체를 잡는다. 락 안에 있으니 안전해 보이지만 그렇지 않다.
// ③ enum — 가장 안전하다
public enum Config {
INSTANCE;
public String url() { return url; }
}
enum 은 리플렉션으로도 직렬화로도 인스턴스를 늘릴 수 없다. 일반 클래스의 싱글톤은 역직렬화가 새 객체를 만들어 버려서 readResolve() 를 직접 써야 하는데, enum 은 그 처리가 언어에 내장돼 있다. Effective Java Item 3 이 이것을 권하는 이유다.
JDK 에서는
Runtime.getRuntime().availableProcessors(); // JVM 당 하나
Desktop.getDesktop().browse(uri); // 데스크톱 환경당 하나
둘 다 "이 프로세스에 하나뿐인 무언가" 를 대표한다. 개수가 하나인 것이 우연이 아니라 본질인 경우가 싱글톤이 맞는 자리다.
실무에서는 직접 쓸 일이 줄었다
@Service // 스프링 빈의 기본 스코프가 singleton 이다
public class OrderService { }
컨테이너가 하나만 만들어 주므로 우리가 getInstance() 를 쓸 이유가 없다. 그리고 이쪽이 낫다 — 생성자로 의존성을 받을 수 있고, 테스트에서 가짜로 바꿀 수 있으며, 전역 정적 상태가 안 생긴다.
함정 — 싱글톤에 가변 상태를 두면 그 순간 공유 버그가 된다.
@Service public class ReportService { private final SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd"); // ✗ // SimpleDateFormat 은 내부에 가변 상태가 있어 스레드 안전하지 않다 }빈은 하나인데 요청 스레드는 여럿이다. 동시에 부르면 파싱 결과가 뒤섞이거나 예외가 난다. 불변인
DateTimeFormatter를 쓰거나, 가변 상태는 아예 필드에 두지 않는다. 공유 싱글톤은 불변일 때만 안전하다.
Factory Method — 생성 책임을 하위 클래스로
상위가 "만든다" 는 골격을 정하고, 어떤 구체 클래스를 만들지는 하위가 결정한다.
문제 — 만드는 부분만 다른데 전체가 갈라진다
class PdfReportService {
void publish(Data d) {
var report = new PdfReport(d); // ← 이 줄만 다르다
report.render(); report.sign(); report.upload(); // 나머지는 같다
}
}
class ExcelReportService {
void publish(Data d) {
var report = new ExcelReport(d); // ← 여기만
report.render(); report.sign(); report.upload(); // 복사-붙여넣기
}
}
적용 — 다른 부분만 추상 메서드로 남긴다
abstract class ReportService {
protected abstract Report createReport(Data d); // 팩토리 메서드
public final void publish(Data d) { // 골격은 고정
var report = createReport(d);
report.render(); report.sign(); report.upload();
}
}
class PdfReportService extends ReportService {
protected Report createReport(Data d) { return new PdfReport(d); }
}
여기서 보이듯 Factory Method 는 Template Method 와 짝을 이룬다. 골격을 고정하고 변하는 한 단계를 하위에 맡기는데, 그 한 단계가 마침 "생성" 인 경우다.
정적 팩토리 메서드는 이것이 아니다
List<String> a = List.of("x", "y"); // 정적 팩토리 메서드
Integer b = Integer.valueOf(42); // 정적 팩토리 메서드
Optional<T> c = Optional.ofNullable(v); // 정적 팩토리 메서드
이들은 상속도 하위 클래스도 없다. Effective Java Item 1 의 관용구로, 생성자 대신 이름 있는 정적 메서드로 만드는 것이다. GoF Factory Method 와는 다른 물건인데 실무에서 뭉뚱그려 부른다. 면접에서 구분을 묻는 단골 지점이다.
정적 팩토리의 이점은 넷이다 — 이름을 줄 수 있고(of · from · valueOf), 매번 새로 안 만들어도 되고(캐시), 하위 타입을 반환할 수 있고, 타입 추론이 짧아진다.
JDK 에서는
Calendar.getInstance(); // 로케일에 따라 GregorianCalendar 등을 고른다
NumberFormat.getInstance();
connection.createStatement(); // 드라이버마다 다른 Statement 구현을 준다
connection.createStatement() 가 특히 좋은 예다. 우리는 Statement 인터페이스만 알고, 어떤 구현이 오는지는 드라이버가 결정한다.
Abstract Factory — 제품 군을 통째로
서로 어울려야 하는 객체 한 벌을 일관되게 만든다. Factory Method 가 제품 하나라면, 이쪽은 세트다.
문제 — 섞이면 안 되는 것들이 섞인다
Button b = new WindowsButton();
Checkbox c = new MacCheckbox(); // ← 컴파일은 된다. 화면이 이상해질 뿐이다
타입 시스템이 막아 주지 않는다. "이 둘은 같은 계열이어야 한다" 는 제약이 코드에 표현돼 있지 않기 때문이다.
적용 — 한 벌을 만드는 창구를 하나로
interface UiFactory {
Button createButton();
Checkbox createCheckbox();
}
class WindowsFactory implements UiFactory {
public Button createButton() { return new WindowsButton(); }
public Checkbox createCheckbox() { return new WindowsCheckbox(); }
}
class MacFactory implements UiFactory { … }
// 쓰는 쪽은 어느 계열인지 모른다
class Dialog {
private final UiFactory ui;
Dialog(UiFactory ui) { this.ui = ui; }
void render() { ui.createButton(); ui.createCheckbox(); } // 섞일 수 없다
}
팩토리를 한 번 고르면 그 뒤로는 한 계열에서만 나온다. 섞임이 구조적으로 불가능해진다.
JDK 에서는
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = f.newDocumentBuilder(); // 이 팩토리가 만드는 파서
Document doc = builder.parse(input);
newInstance() 가 클래스패스를 뒤져 구현을 고른다. Xerces 가 있으면 Xerces 계열이, 다른 구현이 있으면 그쪽 계열이 통째로 나온다. TransformerFactory · XPathFactory 도 같은 구조다.
실무에서는
interface CloudClients { // AWS 냐 GCP 냐를 한 번에 정한다
ObjectStorage storage();
MessageQueue queue();
SecretStore secrets();
}
클라우드 SDK 추상화가 전형이다. 스토리지는 AWS 인데 큐는 GCP 를 쓰는 상황이 나오면 안 되는데, 팩토리 하나로 묶으면 그 실수가 불가능해진다.
함정 — 제품 군에 새 종류를 추가하기가 어렵다.
UiFactory에createSlider()를 넣는 순간 모든 구현체를 고쳐야 한다. 계열(가로축)은 늘리기 쉽고 제품 종류(세로축)는 늘리기 어렵다는 비대칭이 이 패턴의 본질적 대가다.
Builder — 복잡한 생성자 길들이기
선택 항목이 많은 객체를 단계별로 조립한다.
문제 — 점층적 생성자와 세터, 둘 다 나쁘다
주문 목록 검색처럼 조건이 많고 대부분 선택인 요청이 전형적인 사례다.
// ① 점층적 생성자 — 호출부를 읽을 수가 없다
new OrderSearch(1L, null, null, PAID, null, 0, 20, true);
// ↑userId ↑from ↑to ↑status ↑keyword ↑page ↑size ↑desc
// null 이 뭘 뜻하는지, 0 과 20 이 뭔지 호출부만 봐서는 모른다
// ② 자바빈즈 — 읽히지만 두 가지가 깨진다
OrderSearch s = new OrderSearch();
s.setUserId(1L);
s.setStatus(PAID);
// ← 이 시점에 s 는 '아직 완성되지 않은' 객체다. 다른 스레드가 보면 반쪽이다
s.setSize(20); // 그리고 세터가 있으니 나중에 누구든 바꿀 수 있다 → 불변 불가
두 번째가 특히 위험하다. 검색 조건 객체를 캐시 키로 쓰거나 다른 스레드에 넘기는 순간, 누군가 나중에 바꾼 값이 그 키를 오염시킨다.
적용 — 조립 중인 것과 완성된 것을 분리한다
public final class OrderSearch {
private final long userId; private final OrderStatus status;
private final LocalDate from, to; private final int page, size;
private OrderSearch(Builder b) {
this.userId = b.userId; this.status = b.status;
this.from = b.from; this.to = b.to; this.page = b.page; this.size = b.size;
}
public static Builder of(long userId) { return new Builder(userId); }
public static class Builder {
private final long userId; // 필수는 생성자로 강제
private OrderStatus status; // 선택은 기본값
private LocalDate from, to;
private int page = 0, size = 20;
Builder(long userId) { this.userId = userId; }
public Builder status(OrderStatus v) { this.status = v; return this; }
public Builder period(LocalDate f, LocalDate t) { this.from = f; this.to = t; return this; }
public Builder page(int p, int s) { this.page = p; this.size = s; return this; }
public OrderSearch build() {
if (size > 100) throw new IllegalArgumentException("size<=100");
if (from != null && to != null && from.isAfter(to))
throw new IllegalArgumentException("from>to"); // 검증을 여기서 한 번에
return new OrderSearch(this);
}
}
}
var cond = OrderSearch.of(userId)
.status(PAID)
.period(LocalDate.now().minusMonths(1), LocalDate.now())
.page(0, 50)
.build();
from > to 같은 조건 간 검증이 어디에 있어야 하는지가 여기서 분명해진다. 세터 방식에서는 둘 다 세팅된 시점을 알 수 없어 검증할 자리가 없다. 빌더는 build() 라는 완성 시점을 만들어 준다.
얻는 것이 셋이다 — 호출부가 읽힌다(이름이 붙었다), 완성된 객체는 불변이다(세터가 없다), 검증을 build() 한 곳에서 한다(반쪽 객체가 존재하지 않는다).
JDK 에서는
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/orders"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(3))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
StringBuilder sb = new StringBuilder().append("a").append(1).append(true);
StringBuilder 는 조금 다르다 — 불변 객체를 만들기 위해서라기보다 문자열 연결의 비용을 줄이기 위한 가변 버퍼다. 같은 체이닝 모양이라도 목적이 다르다는 점은 알아 둘 만하다.
실무에서는
@Builder // Lombok 이 위 코드를 생성해 준다
public record Order(Long id, String no, int amount, PayType pay) { }
Order o = Order.builder().id(1L).no("A-1").amount(1000).pay(CARD).build();
테스트에서 특히 유용하다. 필드가 열 개인 객체를 만들 때 바꾸고 싶은 두 개만 적고 나머지는 기본값으로 둘 수 있다.
함정 — Lombok
@Builder는 필수 값을 강제하지 못한다. 필드를 추가해도 빌더에서 빼먹은 채 컴파일이 통과하므로 누락이 조용히 지나간다. 정말 필수인 값은 위 예시처럼 빌더 생성자 인자로 받거나@NonNull을 건다.단순한 값 객체라면
record로 충분하다. 빌더는 선택 항목이 많을 때의 도구이지 모든 클래스에 붙이는 장식이 아니다.
Prototype — 복제로 생성
새로 만드는 대신 기존 객체를 복사한다. 생성 비용이 크거나, 만들 타입을 실행 중에야 알 때 쓴다.
Cloneable 은 권하지 않는다
class Order implements Cloneable {
private List<Item> items;
@Override public Order clone() throws CloneNotSupportedException {
return (Order) super.clone(); // ← 얕은 복사: items 를 원본과 공유한다
}
}
Cloneable 은 메서드가 없는 표시용 인터페이스인데 정작 clone() 은 Object 에 protected 로 있다. 규약이 언어 밖에 있어서 생성자를 거치지 않고 객체가 만들어지고, final 필드를 다룰 수 없으며, 기본 동작이 얕은 복사다.
복사 생성자·복사 팩토리를 쓴다
public record Order(String no, List<Item> items) {
public Order(Order other) { // 복사 생성자
this(other.no, List.copyOf(other.items)); // 깊은 복사를 명시적으로
}
public static Order copyOf(Order other) { return new Order(other); } // 복사 팩토리
}
평범한 생성자라 final 도 되고, 예외 처리가 필요 없으며, 무엇을 얼마나 깊게 복사하는지 코드에 드러난다. Effective Java Item 13 의 권고다.
얕은 복사와 깊은 복사
var a = new Order("A-1", new ArrayList<>(List.of(item)));
var shallow = new Order(a.no(), a.items()); // 리스트를 공유한다
shallow.items().add(item2); // a 도 같이 바뀐다 ✗
var deep = new Order(a.no(), List.copyOf(a.items())); // 리스트를 복사한다 ✓
복제에서 사고가 나는 지점은 거의 항상 여기다. 복사했다고 생각했는데 내부 컬렉션은 같은 객체를 가리키고 있어, 사본을 고쳤더니 원본이 바뀐다.
실무에서는
// 무거운 기본 설정을 한 번 만들고, 요청마다 복제해서 조금씩 바꾼다
private static final RenderOptions DEFAULT = loadFromDisk();
RenderOptions forUser(User u) {
return DEFAULT.toBuilder().locale(u.locale()).build(); // 복제 + 일부 변경
}
record 는 필드를 바꾼 새 객체를 만드는 문법이 없어서, 실무에서는 이렇게 빌더를 복제 수단으로 쓰는 형태가 흔하다. 파일을 다시 읽지 않고 값 하나만 갈아 끼운다.
정리 — Prototype 은 패턴 이름보다 "복제 비용 < 생성 비용" 일 때 복제를 쓴다 는 판단이 본체다. 그리고 자바에서는
Cloneable이 아니라 복사 생성자로 구현한다.
한눈에 정리
new는 결합이다 — 생성 패턴 다섯은 전부 그 결정을 쓰는 곳에서 떼어낸다- Singleton — 순진한 지연 초기화는 스레드에서 깨진다. 홀더 관용구가 가장 단순하고,
enum이 가장 안전하다. 실무에서는 컨테이너가 대신하며 가변 상태를 두는 순간 공유 버그가 된다 - Factory Method — 골격은 상위, 생성만 하위. Template Method 와 짝이다. 정적 팩토리 메서드는 다른 것이다
- Abstract Factory — 어울려야 하는 한 벌을 묶어 섞임을 불가능하게 한다. 계열은 늘리기 쉽고 제품 종류는 늘리기 어렵다
- Builder — 점층적 생성자는 안 읽히고 세터는 불변을 깬다. 빌더는 읽힘 · 불변 · 한곳 검증을 동시에 준다
- Prototype —
Cloneable대신 복사 생성자. 사고는 거의 항상 얕은 복사에서 난다
출처 — GoF Design Patterns(1994) §3 Creational Patterns · Effective Java 3e Item 1(정적 팩토리) · Item 2(빌더) · Item 3(enum 싱글톤) · Item 13(clone 재정의) · Java SE 21 API Docs(
HttpRequest.Builder·DocumentBuilderFactory·Calendar)