자바 디자인 패턴 학습 노트 목차

생성 패턴 — 객체를 만드는 일을 떼어낸다

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 MethodTemplate 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 를 쓰는 상황이 나오면 안 되는데, 팩토리 하나로 묶으면 그 실수가 불가능해진다.

함정 — 제품 군에 새 종류를 추가하기가 어렵다. UiFactorycreateSlider() 를 넣는 순간 모든 구현체를 고쳐야 한다. 계열(가로축)은 늘리기 쉽고 제품 종류(세로축)는 늘리기 어렵다는 비대칭이 이 패턴의 본질적 대가다.


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()Objectprotected 로 있다. 규약이 언어 밖에 있어서 생성자를 거치지 않고 객체가 만들어지고, 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 — 점층적 생성자는 안 읽히고 세터는 불변을 깬다. 빌더는 읽힘 · 불변 · 한곳 검증을 동시에 준다
  • PrototypeCloneable 대신 복사 생성자. 사고는 거의 항상 얕은 복사에서 난다

출처 — 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)

원칙 — 패턴이 서 있는 바닥구조 패턴 ① — 맞추고, 나누고, 쌓는다