테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

생성 패턴

객체를 누가, 언제, 어떻게 만들지 감추는 다섯 패턴이다.

목차 (9)
  1. 생성 패턴이 푸는 문제: new를 흩뿌리면 생기는 결합
  2. 싱글턴: 하나만 만들고 어디서나 같은 것을
  3. 팩터리 메서드: 무엇을 만들지는 하위 클래스가
  4. 추상 팩터리: 함께 쓰는 객체 가족을 통째로
  5. 빌더: 복잡한 객체를 단계로 나눠 만든다
  6. 프로토타입: 클래스 대신 원본을 복제한다
  7. 무엇을 언제: 다섯 패턴 비교
  8. 흔한 오해
  9. 핵심 자료

생성 패턴이 푸는 문제: new를 흩뿌리면 생기는 결합

객체를 쓰는 코드가 그 객체를 직접 new로 만들면, 쓰는 코드는 인터페이스가 아니라 구체 클래스의 이름과 생성자 인자까지 알게 된다. 한두 곳이면 문제가 없다. 하지만 같은 "무엇을 만들지 고르는 코드"가 여러 서비스에 복사되면, 결제 수단 하나를 더할 때마다 그 모든 곳을 찾아 고쳐야 한다. 빠뜨린 한 곳은 실행해 봐야 드러난다.

// 결제 수단을 고르는 코드가 서비스마다 복사되어 있다
public Receipt refund(Order order) {
    PaymentProcessor processor;
    if (order.method() == PaymentMethod.CARD) {
        processor = new CardProcessor(cardGateway);
    } else if (order.method() == PaymentMethod.KAKAO_PAY) {
        processor = new KakaoPayProcessor(kakaoClient);
    } else {
        processor = new BankTransferProcessor(bankClient);
    }
    return processor.refund(order.total());
}
왼쪽은 세 서비스가 세 결제 클래스를 저마다 new로 만들어 선이 아홉 개이고, 오른쪽은 세 서비스가 팩터리 하나에만 기대고 팩터리가 결제 클래스를 만든다new를 흩뿌린 코드만드는 일을 한곳에주문 서비스주문 서비스환불 서비스환불 서비스정기 결제정기 결제카드 결제카드 결제간편 결제간편 결제계좌 이체계좌 이체팩터리서비스가 구체 클래스를 직접 안다: 선 9개결제 수단 하나 추가 → 서비스 3곳 수정서비스는 팩터리와 인터페이스만 안다결제 수단 하나 추가 → 팩터리 1곳 수정
세 서비스가 세 결제 클래스를 저마다 만들면 의존선이 아홉 개다(왼쪽). 만드는 일을 팩터리 한곳에 모으면 서비스는 팩터리와 인터페이스만 알고, 새 결제 수단은 한 곳만 고친다(오른쪽).

생성 패턴(creational pattern)은 이 "누가, 언제, 무엇을, 어떻게 만드는가"를 쓰는 코드에서 떼어 내는 다섯 가지 방법이다. 만드는 일을 한곳에 모으면 쓰는 코드는 추상(인터페이스)에만 기대게 되는데, 이는 SOLID의 의존 역전 원칙과 개방-폐쇄 원칙을 생성 쪽에서 지키는 방법이기도 하다. 원칙 자체는 소프트웨어 공학 노트의 설계 원칙 절에, 패턴이라는 말이 건축가 크리스토퍼 알렉산더의 패턴 언어에서 어떻게 GoF의 23가지로 왔는지는 디자인 패턴이란 노트에 있다.

  • 싱글턴: 몇 개를 만드는가를 감춘다(딱 하나).
  • 팩터리 메서드: 어떤 클래스를 만들지를 하위 클래스로 미룬다.
  • 추상 팩터리: 함께 쓰는 객체 가족을 통째로 갈아 끼운다.
  • 빌더: 복잡한 객체를 만드는 과정을 단계로 나눈다.
  • 프로토타입: 클래스 대신 이미 있는 객체를 복제해서 만든다.

아래 패턴마다 클래스 그림을 하나씩 둔다. 선 모양은 UML 관례를 따르고, 읽는 법은 이 그림 하나로 갈음한다.

클래스 그림 읽는 법: 실선과 빈 삼각형은 상속, 점선과 빈 삼각형은 구현, 실선과 채운 마름모는 합성, 점선과 열린 화살표는 의존이다상속하위 클래스가 상위 클래스를 넓힌다 (extends)구현클래스가 인터페이스를 구현한다 (implements)합성마름모 쪽이 상대를 필드로 품는다 (has-a)의존화살표 쪽 객체를 만들거나 부른다 (new, 호출)«interface»는 인터페이스, «abstract»는 추상 클래스, 파란 상자는 그 패턴의 핵심 역할이다
이 노트의 클래스 그림 읽는 법. 삼각형은 '무엇의 한 종류인가', 마름모는 '무엇을 품는가', 열린 화살표는 '무엇을 만들거나 부르는가'를 뜻한다.

싱글턴: 하나만 만들고 어디서나 같은 것을

의도: 클래스의 인스턴스를 하나로 제한하고, 그 하나에 닿는 전역 접근점을 준다.

문제 상황: 설정 파일을 읽는 객체를 서비스마다 new AppConfig()로 만들어 파일을 열 번 읽는다. 혹은 ID 생성기가 둘 생겨 같은 번호를 두 번 낸다. 둘 다 "프로그램 안에 하나만 있어야 할 것"이 여러 개가 된 냄새다.

싱글턴 구조: 두 서비스가 AppConfig.getInstance()를 부르고, AppConfig는 private 생성자와 static 인스턴스 필드로 자기 객체를 하나만 만든다주문 서비스알림 서비스AppConfig- instance: AppConfig- AppConfig()+ getInstance(): AppConfig하나만ClientSingleton: 자기 인스턴스를 스스로 관리① 생성자가 private이라 밖에서 new를 할 수 없다② 유일한 인스턴스를 static 필드에 들고 있다③ getInstance()는 누가 불러도 같은 객체를 돌려준다
싱글턴의 세 장치. 생성자를 막고, 유일한 인스턴스를 정적 필드에 두고, 정적 메서드가 늘 그 객체를 돌려준다. 두 서비스는 같은 AppConfig를 받는다.

예시 코드

지연 초기화가 필요하면 홀더 관용구(initialization-on-demand holder)가 가장 깔끔하다. 바깥 클래스가 로드되어도 안쪽 Holder 클래스는 getInstance()를 처음 부를 때 초기화되고, 자바 언어 명세가 클래스 초기화를 한 번만, 동시에 일어나지 않게 보장하므로 따로 잠글 필요가 없다.

public final class AppConfig {
    private final Properties properties;

    private AppConfig() {
        this.properties = loadFromClasspath("/app.properties");
    }

    // 홀더 관용구: getInstance()를 처음 부를 때 Holder 클래스가 초기화된다
    private static final class Holder {
        private static final AppConfig INSTANCE = new AppConfig();
    }

    public static AppConfig getInstance() {
        return Holder.INSTANCE;
    }

    public String get(String key) {
        return properties.getProperty(key);
    }

    private static Properties loadFromClasspath(String name) {
        Properties loaded = new Properties();
        try (InputStream input = AppConfig.class.getResourceAsStream(name)) {
            if (input == null) {
                throw new IllegalStateException("설정 파일이 없습니다: " + name);
            }
            loaded.load(input);
        } catch (IOException e) {
            throw new UncheckedIOException("설정 파일을 읽지 못했습니다: " + name, e);
        }
        return loaded;
    }
}

홀더 관용구 이전에 흔하던 것이 이중 검사 잠금(double-checked locking)이다. 잠금 없이 한 번 보고, 비어 있으면 잠근 뒤 다시 본다. 자바 1.4까지는 컴파일러가 생성자를 다 돌기 전에 필드에 참조를 먼저 써 넣을 수 있어, 다른 스레드가 덜 만들어진 객체를 집어 가는 버그가 있었다. J2SE 5.0부터 volatile이 메모리 장벽 역할을 하도록 정의되어 이 형태가 올바르게 동작한다. 그래서 volatile을 빼면 안 된다.

public final class MetricsRegistry {
    // volatile이 없으면 다른 스레드가 덜 만들어진 객체를 볼 수 있다
    private static volatile MetricsRegistry instance;

    private MetricsRegistry() {
    }

    public static MetricsRegistry getInstance() {
        MetricsRegistry local = instance;
        if (local == null) {
            synchronized (MetricsRegistry.class) {
                local = instance;
                if (local == null) {
                    local = new MetricsRegistry();
                    instance = local;
                }
            }
        }
        return local;
    }
}

가장 짧고 튼튼한 것은 원소가 하나인 enum이다. 자바 언어 명세(8.9절)는 enum 클래스가 상수로 선언한 것 말고는 인스턴스를 가질 수 없다고 정하고, 이를 위해 명시적 생성을 컴파일 오류로 막고, Enum의 final clone으로 복제를, 리플렉션 생성 금지와 직렬화의 특별 처리로 나머지 길을 막는다. 직렬화와 리플렉션으로 두 번째 인스턴스가 생기는 일을 직접 막아야 하는 보통 클래스와 다른 점이다.

public enum IdGenerator {
    INSTANCE;

    private final AtomicLong next = new AtomicLong();

    public long nextId() {
        return next.incrementAndGet();
    }
}

// 쓰는 쪽
long orderId = IdGenerator.INSTANCE.nextId();
방법언제 만드는가스레드 안전메모
정적 필드에 바로 생성클래스가 초기화될 때예(클래스 초기화 규칙)안 써도 만든다. 대개 이것으로 충분하다
synchronized 메서드처음 부를 때예부를 때마다 잠근다
이중 검사 잠금처음 부를 때volatile과 함께일 때만J2SE 5.0 이후의 메모리 모델이 전제
홀더 관용구처음 부를 때예(클래스 초기화 규칙)생성이 실패하면 안 되고 인자를 받을 수 없다
enumenum이 초기화될 때예복제, 리플렉션, 역직렬화로 둘째가 생기지 않는다

실제 쓰임

  • Runtime.getRuntime(): JDK 문서는 모든 자바 애플리케이션에 Runtime 인스턴스가 하나 있고, 지금 런타임은 getRuntime으로 얻는다고 적는다.
  • Spring 빈의 singleton 스코프: 스프링의 기본 스코프다. 다만 스프링 문서가 직접 구분하듯, GoF 싱글턴은 클래스로더마다 하나를 클래스 안에 박아 두는 것이고 스프링 싱글턴은 컨테이너마다, 빈 정의마다 하나다. 같은 클래스를 빈 두 개로 정의하면 인스턴스도 둘이다.

장점과 단점

  • 장점: 비싼 자원(설정, 연결 풀, 캐시)을 한 번만 만든다. 하나뿐이라는 규칙을 코드가 강제한다.
  • 단점: 사실상 전역 상태다. 쓰는 클래스는 생성자에 드러나지 않는 숨은 의존을 갖고, 단위 테스트에서 가짜로 바꾸기 어렵고, 테스트끼리 상태가 새어 순서에 따라 결과가 달라진다. 인스턴스 관리와 본래 일을 함께 맡아 단일 책임 원칙에도 어긋난다는 비판이 있다.

주의: 직접 만들기보다 컨테이너에 맡긴다

요즘 자바 서버 코드에서 "하나만 있어야 할 것"은 대개 DI 컨테이너가 맡는다. 클래스는 평범하게 두고, 컨테이너가 하나만 만들어 생성자로 넣어 준다. "하나뿐"은 설정의 문제가 되고, 테스트에서는 다른 객체를 넣으면 된다. 의존성 주입 자체는 GoF 너머 노트에서 다룬다.

@Configuration
class TimeConfiguration {
    @Bean
    Clock clock() {
        return Clock.systemUTC();
    }
}

@Service
class CouponService {
    private final Clock clock;

    // 컨테이너가 하나뿐인 Clock 빈을 넣어 준다. 클래스는 "하나뿐"인지 모른다
    CouponService(Clock clock) {
        this.clock = clock;
    }

    boolean isExpired(Coupon coupon) {
        return coupon.expiresAt().isBefore(clock.instant());
    }
}

// 테스트에서는 시각을 고정한 Clock을 넣는다
CouponService service = new CouponService(
        Clock.fixed(Instant.parse("2026-10-30T00:00:00Z"), ZoneOffset.UTC)
);
  • 홀더 관용구의 함정: 생성자가 예외를 던지면 그 클래스로더에서 이후 시도는 보통 NoClassDefFoundError로 실패한다. 실패할 수 있는 초기화(네트워크, 파일)는 싱글턴 생성자에 넣지 않는 편이 낫다.
  • "하나"의 범위: 정적 필드는 클래스로더마다 따로다. 애플리케이션 서버나 플러그인 구조처럼 클래스로더가 여럿이면 싱글턴도 여럿일 수 있다.
  • 바뀌는 상태를 두지 않는다: 싱글턴이 여러 스레드에서 쓰는 가변 필드를 가지면 그 자체로 동시성 문제가 된다. 가능하면 불변으로, 아니면 AtomicLong처럼 스레드 안전한 타입을 쓴다.

다른 언어에서는

파이썬은 모듈이 프로그램 안에 하나뿐이라, 공식 FAQ도 설정 모듈 하나를 만들어 모든 곳에서 import하라고 하고 같은 이유로 모듈이 싱글턴 구현의 바탕이 된다고 적는다. Go는 패키지 수준 변수와 sync.Once를 쓴다. Once.Do는 그 Once 인스턴스에서 처음 부를 때만 함수를 실행하고, Go 1.21에 더해진 sync.OnceValue는 값을 한 번 만들어 돌려주는 함수를 바로 만든다.

# settings.py — 모듈은 프로그램 안에 하나뿐이라 그 자체가 싱글턴이다
timeout_seconds = 10
api_base = "https://api.example.com"

# 다른 파일
import settings
print(settings.timeout_seconds)
// sync.OnceValue(Go 1.21+): 처음 부를 때 한 번만 만들고 같은 값을 돌려준다
var loadConfig = sync.OnceValue(func() *Config {
    return mustReadConfig("app.yaml")
})

func handler(w http.ResponseWriter, r *http.Request) {
    cfg := loadConfig()
    // ...
}

팩터리 메서드: 무엇을 만들지는 하위 클래스가

의도: 객체를 만드는 인터페이스는 상위 클래스가 정하고, 어느 클래스의 인스턴스를 만들지는 하위 클래스가 정하게 한다.

문제 상황: 결제 흐름(검증, 결제, 영수증, 로그)은 결제 수단과 상관없이 같은데, 그 한가운데에 new CardProcessor()가 박혀 있다. 간편 결제를 더하려면 흐름 전체를 복사하거나, 흐름 안에 if 문을 늘려야 한다.

팩터리 메서드 구조: 추상 CheckoutService가 createProcessor()로 PaymentProcessor를 얻어 쓰고, 하위 클래스 CardCheckoutService가 그 메서드를 덮어 CardProcessor를 만든다«abstract»CheckoutService+ checkout(order)# createProcessor()Creator: 팩터리 메서드를 선언«interface»PaymentProcessor+ pay(amount)Product: 만들어질 것의 타입CardCheckoutService# createProcessor()ConcreteCreator: 실제로 생성CardProcessor+ pay(amount)ConcreteProduct: 실제 결제쓴다new
팩터리 메서드 구조. CheckoutService는 결제 흐름을 갖고 있고, 그 흐름이 쓰는 PaymentProcessor를 만드는 일만 추상 메서드 createProcessor()로 미룬다. 하위 클래스가 그 메서드를 덮어 실제 결제 수단을 고른다.

예시 코드

흐름(checkout)은 상위 클래스에 한 번만 쓰고, 바뀌는 한 줄(무엇을 만들지)만 하위 클래스에 둔다. 흐름 안에서 팩터리 메서드를 부르는 모양이라 템플릿 메서드 패턴과 자주 함께 나온다.

public interface PaymentProcessor {
    Receipt pay(Money amount);
}

public abstract class CheckoutService {
    private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);

    /** 팩터리 메서드: 어떤 결제 수단을 쓸지는 하위 클래스가 정한다 */
    protected abstract PaymentProcessor createProcessor();

    /** 결제 흐름은 여기 한 곳에 있다. 무엇으로 결제하는지는 모른다 */
    public Receipt checkout(Order order) {
        PaymentProcessor processor = createProcessor();
        Receipt receipt = processor.pay(order.total());
        log.info("주문 {} 결제 완료: {}", order.id(), receipt.id());
        return receipt;
    }
}

public class CardCheckoutService extends CheckoutService {
    private final CardGateway gateway;

    public CardCheckoutService(CardGateway gateway) {
        this.gateway = gateway;
    }

    @Override
    protected PaymentProcessor createProcessor() {
        return new CardProcessor(gateway);
    }
}

실무에서 더 흔한 것은 상속 대신 정적 팩터리 메서드나 단순 팩터리 클래스 하나에 고르는 코드를 모으는 형태다. 위키백과의 팩터리 메서드 문서도 조슈아 블로크가 말하는 정적 팩터리 메서드는 『디자인 패턴』에 직접 대응하는 것이 없다고 짚는다. 이름은 비슷해도 GoF의 팩터리 메서드는 상속으로 만드는 쪽을 바꾸는 패턴이고, 정적 팩터리는 생성자에 이름을 붙이고 구현을 숨기는 관용구다. 둘 다 "쓰는 코드가 구체 클래스를 모르게 한다"는 목적은 같다.

/** 정적 팩터리: 결제 수단 값으로 구현을 고른다. 새 수단은 이 한 곳에만 더한다 */
public final class PaymentProcessors {
    private PaymentProcessors() {
    }

    public static PaymentProcessor of(
            PaymentMethod method,
            Gateways gateways
    ) {
        return switch (method) {
            case CARD -> new CardProcessor(gateways.card());
            case KAKAO_PAY -> new KakaoPayProcessor(gateways.kakaoPay());
            case BANK_TRANSFER -> new BankTransferProcessor(gateways.bank());
        };
    }
}

실제 쓰임

  • Collection.iterator(): 컬렉션 인터페이스가 "반복자를 만든다"는 메서드만 정하고, ArrayList와 HashSet이 저마다 다른 반복자 클래스를 돌려준다. GoF 형태 그대로다.
  • Calendar.getInstance(): JDK 문서대로 기본 시간대와 로캘로 현재 시각이 채워진 Calendar를 돌려준다. 반환 타입은 추상 클래스 Calendar이고 문서에 알려진 하위 클래스로 GregorianCalendar가 있어, 부르는 쪽은 어떤 구현을 받는지 몰라도 된다. 정적 팩터리 쪽의 예다.

장점과 단점

  • 장점: 흐름과 생성이 갈라져 새 제품을 더할 때 기존 흐름을 고치지 않는다. 상위 클래스는 구체 클래스를 모른다.
  • 단점: 제품 하나에 Creator 하위 클래스 하나가 따라붙어 클래스가 늘어난다. 상속에 묶이므로 실행 중에 바꾸기 어렵다. 이 경우 생성 함수(또는 팩터리 객체)를 생성자로 넘기는 합성이 더 가볍다.

주의

  • 만들 것이 하나뿐이고 바뀔 계획도 없으면 그냥 new가 맞다. 팩터리를 미리 깔아 두는 것은 YAGNI에 걸린다.
  • 팩터리 메서드를 생성자 안에서 부르지 않는다. 자바에서 상위 클래스 생성자가 덮어쓴 메서드를 부르면, 하위 클래스의 필드가 아직 초기화되기 전에 실행된다.

다른 언어에서는

함수와 클래스가 값인 언어에서는 상속이 필요 없다. 파이썬과 자바스크립트는 "무엇을 만들지"를 함수나 클래스 인자로 넘기면 그것이 팩터리 메서드의 역할을 한다. 자바스크립트에서는 class 없이 객체 리터럴을 돌려주는 팩터리 함수(createProcessor(method))도 흔하다. Go에는 생성자가 없어 NewXxx 생성 함수가 관례다. Effective Go는 os 패키지의 NewFile을 예로 들고, 패키지가 내보내는 타입이 하나면 ring.New처럼 그냥 New라 부른다고 설명한다. 생성 함수가 인터페이스를 돌려주면 그대로 팩터리가 된다.

# Python: 클래스도 값이라 "무엇을 만들지"를 인자로 넘기면 된다
def checkout(order, make_processor=CardProcessor):
    processor = make_processor()
    return processor.pay(order.total)

checkout(order, make_processor=KakaoPayProcessor)
// Go: 생성 함수가 인터페이스를 돌려주면 호출하는 쪽은 구체 타입을 모른다
func NewProcessor(method Method, gw Gateways) (Processor, error) {
    switch method {
    case Card:
        return &cardProcessor{gateway: gw.Card}, nil
    case BankTransfer:
        return &bankProcessor{client: gw.Bank}, nil
    default:
        return nil, fmt.Errorf("지원하지 않는 결제 수단: %v", method)
    }
}

추상 팩터리: 함께 쓰는 객체 가족을 통째로

의도: 서로 관련되거나 함께 써야 하는 객체 가족을, 구체 클래스를 밝히지 않고 만드는 인터페이스를 준다.

문제 상황: 다크 모드를 넣었더니 화면 곳곳에 dark ? new DarkButton() : new LightButton()가 생겼다. 누군가 한 곳에서 체크박스만 밝은 것을 만들면 어두운 버튼 옆에 밝은 체크박스가 붙는다. 가족이 섞이는 것을 막을 장치가 없다.

추상 팩터리 구조: 가운데 WidgetFactory, Button, Checkbox 인터페이스가 있고, 위 줄의 LightWidgets는 LightButton과 LightCheckbox를, 아래 줄의 DarkWidgets는 DarkButton과 DarkCheckbox를 만든다LightWidgetsLightButtonLightCheckbox만든다밝은 테마 가족DarkWidgetsDarkButtonDarkCheckbox만든다어두운 테마 가족«interface»WidgetFactory+ createButton()+ createCheckbox()«interface»Button«interface»CheckboxAbstractFactory = WidgetFactory, ConcreteFactory = LightWidgets와 DarkWidgetsAbstractProduct = Button과 Checkbox, ConcreteProduct = 띠 안의 네 클래스
추상 팩터리 구조. 가운데 줄이 인터페이스이고, 위아래 띠가 테마별 가족이다. LightWidgets는 위 띠의 제품만, DarkWidgets는 아래 띠의 제품만 만들므로 공장 하나를 고르면 가족이 섞이지 않는다.

예시 코드

화면 코드는 WidgetFactory와 제품 인터페이스만 받는다. 어떤 공장을 쓸지는 프로그램이 시작하는 곳(조립 지점) 한 곳에서 정한다.

public interface Button {
    void render(Canvas canvas);
}

public interface Checkbox {
    void render(Canvas canvas, boolean checked);
}

/** 한 테마의 위젯 가족을 만드는 공장 */
public interface WidgetFactory {
    Button createButton(String label);

    Checkbox createCheckbox(String label);
}

public final class DarkWidgets implements WidgetFactory {
    @Override
    public Button createButton(String label) {
        return new DarkButton(label);
    }

    @Override
    public Checkbox createCheckbox(String label) {
        return new DarkCheckbox(label);
    }
}

/** 화면 코드는 인터페이스만 안다. 어떤 테마인지 모른다 */
public final class SettingsScreen {
    private final Button saveButton;
    private final Checkbox syncCheckbox;

    public SettingsScreen(WidgetFactory widgets) {
        this.saveButton = widgets.createButton("저장");
        this.syncCheckbox = widgets.createCheckbox("자동 동기화");
    }
}

// 프로그램 시작점에서 한 번만 고른다
WidgetFactory widgets = prefersDark ? new DarkWidgets() : new LightWidgets();
SettingsScreen screen = new SettingsScreen(widgets);

팩터리 메서드와의 차이는 이렇다. 팩터리 메서드는 메서드 하나를 하위 클래스가 덮어 제품 하나를 고르고, 추상 팩터리는 객체 하나(공장)를 갈아 끼워 제품 여러 개를 한꺼번에 바꾼다. 공장의 각 메서드를 들여다보면 그것이 곧 팩터리 메서드인 경우가 많다.

실제 쓰임

  • DocumentBuilderFactory(JAXP): 문서에 따르면 newInstance()는 JAXP 조회 방식으로 실제 구현 공장 클래스를 고르고, newDocumentBuilder()는 지금 설정으로 새 DocumentBuilder를 만든다. 쓰는 코드는 어느 XML 파서 구현인지 모른다. 공급자가 여럿일 때 특정 구현을 고르는 newInstance(factoryClassName, classLoader)도 있다.
  • 데이터베이스 드라이버, UI 툴킷의 테마: 한 공급자의 객체끼리만 짝이 맞는 곳에서 자주 이 모양이 나온다.
// newInstance()가 구현을 찾아 공장을 고르고, 그 공장이 파서를 만든다
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder parser = factory.newDocumentBuilder();
Document document = parser.parse(input);

장점과 단점

  • 장점: 가족 단위의 일관성을 구조로 보장한다. 새 가족(예: 고대비 테마)은 공장 하나와 제품 클래스를 더하면 되고 화면 코드는 그대로다.
  • 단점: 반대로 새 제품 종류(예: 슬라이더)를 더하면 공장 인터페이스와 모든 구체 공장을 고쳐야 한다. 가족은 늘리기 쉽고 종류는 늘리기 어렵다. 처음부터 쓰면 불필요한 복잡도와 추상 층이 늘어 디버깅이 어려워진다는 지적도 있다.

주의

가족이 하나뿐인데 "나중에 생길지 모르니" 만들지 않는다. 또 공장을 싱글턴 전역에서 꺼내 쓰기 시작하면 싱글턴의 단점(숨은 의존)을 그대로 얻는다. 공장은 생성자로 넘긴다.

다른 언어에서는

함수가 값인 언어에서는 공장을 "생성 함수 묶음"(파이썬의 딕셔너리나 작은 클래스, 자바스크립트의 객체 리터럴)으로 표현해도 된다. Go는 메서드 몇 개짜리 작은 인터페이스를 공장으로 두고, 조립 지점에서 구현 구조체를 넘긴다. 어느 쪽이든 핵심은 "가족을 고르는 결정이 한 곳에만 있다"는 것이다.

빌더: 복잡한 객체를 단계로 나눠 만든다

의도: 복잡한 객체를 만드는 과정과 그 결과물의 표현을 떼어, 같은 과정으로 여러 결과를 만들 수 있게 한다. 자바 실무에서는 "선택 인자가 많은 객체를 읽기 쉽고 안전하게 만든다"는 쓰임이 더 흔하다.

문제 상황: 요청 객체의 선택 인자가 늘 때마다 생성자를 하나씩 더해 텔레스코핑 생성자가 됐다. 부르는 줄은 new ApiRequest(uri, "GET", null, true, null)처럼 자리만 있고 이름이 없다.

텔레스코핑 생성자 단계 그림: 생성자 다섯 개, 이름 없는 인자로 부르는 줄, 세터로 반쯤 만든 객체, 이름 붙은 빌더 호출을 차례로 보인다① 텔레스코핑 생성자② 부르는 쪽③ 자바빈즈 세터④ 빌더ApiRequest(String uri)ApiRequest(String uri, String method)ApiRequest(String uri, String method, Duration timeout)ApiRequest(String uri, String method, Duration timeout, boolean retry)ApiRequest(String uri, String method, Duration timeout, boolean retry, String body)선택 인자 4개 → 생성자 5개, 조합은 16가지timeout만 주려 해도 method를 채워야 한다: 바로 못 쓰는 조합 11가지new ApiRequest(uri, "GET", null, true, null)urimethodtimeout?retry?body?읽는 사람은 생성자 정의를 열어 봐야 각 자리가 무엇인지 안다같은 타입끼리 자리가 바뀌어도(null과 null) 컴파일은 통과한다ApiRequest request = new ApiRequest(uri);request.setMethod("POST");// 이 사이에서는 반쯤 만든 객체가 보인다request.setBody(json);필드를 final로 둘 수 없어 불변 객체가 되지 못한다필수 값을 빠뜨려도 실행해 보기 전에는 모른다ApiRequest request = ApiRequest.builder(uri).method("POST").body(json).timeout(Duration.ofSeconds(3)).build();값마다 이름이 붙고, 안 준 값은 기본값이다build()가 한 번에 검사하고, 결과는 final 필드의 불변 객체다
1 / 4

선택 인자가 4개면 생성자는 5개로 늘어난다. 그래도 고를 수 있는 조합 16가지 가운데 11가지는 앞쪽 인자를 억지로 채워야 한다.

요청 객체 하나를 만드는 세 가지 방법. 선택 인자가 늘수록 생성자 방식은 개수와 읽기 모두에서 무너진다. 개수는 계산 함수로 구했다.
빌더 구조: ApiRequests(Director)가 HttpRequest.Builder를 쓰고, Builder는 uri, header, timeout, POST로 값을 모은 뒤 build()로 불변 HttpRequest를 만든다ApiRequests+ jsonPost(uri, body)Director (없어도 된다)HttpRequest.Builder+ uri(u): Builder+ header(k, v): Builder+ timeout(d): Builder+ POST(body): Builder+ build(): HttpRequestBuilder: 단계마다 값을 받는다HttpRequest- uri: URI- headers: Map- timeout: DurationProduct: 불변 결과물쓴다만든다호출 모양: 메서드가 Builder 자신을 돌려주므로 점으로 잇는다HttpRequest.newBuilder(uri).header(…).timeout(…).POST(…).build()
빌더 구조. HttpRequest.Builder가 값을 한 단계씩 받고, build()가 불변 HttpRequest를 만든다. 자주 쓰는 조립 순서를 묶어 두는 Director는 있어도 되고 없어도 된다.

예시 코드

제품의 필드는 모두 final이고 생성자는 private이다. 빌더는 정적 중첩 클래스로 두어 기본값을 갖고, 세터 대신 이름 붙은 메서드가 this를 돌려줘 점으로 잇는다. 여러 필드에 걸친 규칙(POST면 본문이 있어야 한다)은 build() 한 곳에서 검사한다.

public final class ApiRequest {
    private final URI uri;
    private final String method;
    private final Map<String, String> headers;
    private final Duration timeout;
    private final String body;

    private ApiRequest(Builder builder) {
        this.uri = builder.uri;
        this.method = builder.method;
        this.headers = Map.copyOf(builder.headers);
        this.timeout = builder.timeout;
        this.body = builder.body;
    }

    public static Builder builder(URI uri) {
        return new Builder(uri);
    }

    public static final class Builder {
        private final URI uri;
        private final Map<String, String> headers = new LinkedHashMap<>();
        private String method = "GET";
        private Duration timeout = Duration.ofSeconds(10);
        private String body;

        private Builder(URI uri) {
            this.uri = Objects.requireNonNull(uri, "uri");
        }

        public Builder header(String name, String value) {
            headers.put(name, value);
            return this;
        }

        public Builder timeout(Duration timeout) {
            this.timeout = timeout;
            return this;
        }

        public Builder post(String body) {
            this.method = "POST";
            this.body = body;
            return this;
        }

        /** 값을 한 번에 검사하고 불변 객체를 만든다 */
        public ApiRequest build() {
            if (timeout.isNegative() || timeout.isZero()) {
                throw new IllegalStateException("timeout은 0보다 커야 합니다: " + timeout);
            }
            if (method.equals("POST") && body == null) {
                throw new IllegalStateException("POST에는 본문이 필요합니다");
            }
            return new ApiRequest(this);
        }
    }
}

ApiRequest request = ApiRequest.builder(URI.create("https://api.example.com/orders"))
        .header("Accept", "application/json")
        .timeout(Duration.ofSeconds(3))
        .post("{\"itemId\":42}")
        .build();

GoF의 원래 모양에는 Director가 있다. 조립 순서를 아는 객체가 빌더 인터페이스를 불러, 같은 순서로 다른 빌더(예: HTML 문서 빌더와 PDF 문서 빌더)에 서로 다른 결과물을 만들게 하는 것이다. 위의 자바 관용형은 Director 없이 부르는 쪽이 직접 순서를 쓰는 형태다.

손으로 쓰기 귀찮은 빌더는 Lombok의 @Builder가 만든다. 정적 중첩 빌더 클래스, 인자마다 빌더를 돌려주는 메서드, build(), 정적 builder()를 생성하고, toBuilder = true면 지금 값을 채운 빌더를, 컬렉션 필드의 @Singular는 하나씩 더하는 메서드를 만든다.

@Builder(toBuilder = true)
@Getter
public class Notification {
    private final String recipient;
    private final String title;
    @Singular
    private final List<String> tags;
}

Notification shipped = Notification.builder()
        .recipient("user-42")
        .title("배송이 시작됐습니다")
        .tag("shipping")
        .tag("push")
        .build();

// toBuilder: 지금 값을 채운 빌더에서 한 군데만 바꿔 새 객체를 만든다
Notification delivered = shipped.toBuilder().title("배송이 끝났습니다").build();

실제 쓰임

  • HttpRequest.newBuilder(): JDK 문서에 따르면 각 세터는 빌더 상태를 바꾸고 같은 인스턴스를 돌려주며, build()는 부를 때마다 새 HttpRequest를 만들고, 만들어진 요청은 불변이라 여러 번 보낼 수 있다. URI 없이 build()하면 IllegalStateException을 던진다. 빌더 자체는 동기화되지 않으므로 여러 스레드가 함께 쓰지 않는다.
  • StringBuilder: 바뀌는 문자열 버퍼에 append로 조각을 더하고(메서드가 자기 자신을 돌려줘 이어 쓸 수 있다), toString()이 새 String을 만든다. 그 뒤 버퍼를 바꿔도 만든 문자열은 그대로다. GoF 빌더의 엄밀한 형태는 아니지만 "가변 조립기 → 불변 결과"라는 같은 생각이다.

장점과 단점

  • 장점: 인자에 이름이 붙어 읽힌다. 결과물을 불변으로 만들 수 있다. 검증을 한 곳에 모은다. 만드는 단계를 제어할 수 있다.
  • 단점: 클래스와 코드가 늘어난다. 빌더 자체는 가변이다. 필수 값을 빠뜨린 것이 컴파일이 아니라 build()를 실행할 때 드러난다. 필수 값은 빌더 생성자 인자(builder(uri))로 받으면 이 문제를 줄인다.

주의

  • 인자가 두세 개이고 모두 필수면 생성자가 낫다. 빌더는 선택 인자가 많거나 여러 필드에 걸친 검증이 있을 때 값을 한다.
  • 빌더를 재사용하면 앞서 넣은 값이 남는다. HttpRequest.Builder처럼 build()가 매번 새 객체를 만들어도, 빌더 상태는 그대로이므로 섞이지 않게 새 빌더를 쓰거나 copy()로 갈래를 낸다.

다른 언어에서는

이름 붙은 인자와 기본값이 있는 언어에서는 빌더가 거의 필요 없다. 파이썬은 키워드 인자(kwarg=value)와 기본값으로 같은 일을 하고, * 뒤의 인자는 이름으로만 받게 해 자리 실수를 막는다. 자바스크립트는 옵션 객체 하나를 넘기는 것이 관례다. Go는 함수형 옵션 패턴을 쓴다. 데이브 체니가 롭 파이크의 2014년 글에서 가져와 정리한 것으로, 생성 함수가 ...Option 가변 인자를 받고 옵션 하나하나가 대상 구조체를 고치는 함수다. 기본값이 자연스럽고, 아무 옵션도 안 줄 때 nil이나 빈 설정 구조체를 넘길 필요가 없다.

# Python: 키워드 인자와 기본값이 빌더의 일을 대부분 한다
def make_request(uri, *, method="GET", timeout=10.0, headers=None, body=None):
    ...

make_request("https://api.example.com/orders", method="POST", body=payload, timeout=3)
// JavaScript: 옵션 객체 하나로 이름 붙은 인자를 넘긴다
const response = await fetch('https://api.example.com/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(order),
});
// Go: 함수형 옵션. 옵션은 *Server를 고치는 함수다
type Option func(*Server)

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{addr: addr, timeout: 30 * time.Second}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

srv := NewServer(":8080", WithTimeout(5*time.Second))

프로토타입: 클래스 대신 원본을 복제한다

의도: 만들 객체의 종류를 원본 인스턴스(프로토타입)로 정하고, 그 원본을 복제해 새 객체를 만든다.

문제 상황: 계약서 템플릿을 만들려면 조항 수십 개를 불러와 서식을 입히는 비싼 초기화를 거쳐야 한다. 새 계약서가 필요할 때마다 그 과정을 처음부터 되풀이한다. 또는 사용자가 실행 중에 만든 "내 템플릿"처럼 클래스로 미리 정할 수 없는 종류가 있다.

프로토타입 구조: TemplateRegistry가 DocumentTemplate 원본들을 품고, create(name)이 원본의 copy()를 불러 복제본을 돌려준다. ContractTemplate과 ReportTemplate이 copy()를 구현한다TemplateRegistry- originals: Map+ create(name)Client: 원본을 등록해 두고 복제«interface»DocumentTemplate+ copy(): DocumentTemplatePrototype: 자기를 복제하는 연산품는다ContractTemplate+ copy()ConcretePrototypeReportTemplate+ copy()ConcretePrototypecreate("계약서")는originals.get(name).copy()클래스를 몰라도 같은 종류가 나온다new 대신 이미 설정을 마친 원본을 복제하므로, 비싼 초기화를 한 번만 한다
프로토타입 구조. 등록소가 이름별 원본을 품고 있다가 create(name)이 오면 원본의 copy()를 부른다. 부르는 쪽은 구체 클래스를 모르고도 같은 종류의 새 객체를 받는다.

예시 코드

자바에서는 clone()보다 복사 생성자(또는 copy() 같은 복사 메서드)를 권하는 경우가 많다. final 필드를 그대로 둘 수 있고, 무엇을 얼마나 깊게 복사하는지가 코드에 드러난다.

public interface DocumentTemplate {
    DocumentTemplate copy();
}

public final class ContractTemplate implements DocumentTemplate {
    private final String title;
    private final List<Section> sections;
    private final Map<String, String> fields;

    public ContractTemplate(
            String title,
            List<Section> sections,
            Map<String, String> fields
    ) {
        this.title = title;
        this.sections = new ArrayList<>(sections);
        this.fields = new HashMap<>(fields);
    }

    /** 복사 생성자: 바뀔 수 있는 목록과 절까지 새로 만든다(깊은 복사) */
    private ContractTemplate(ContractTemplate source) {
        this.title = source.title;
        this.sections = new ArrayList<>();
        for (Section section : source.sections) {
            this.sections.add(new Section(section));
        }
        this.fields = new HashMap<>(source.fields);
    }

    @Override
    public ContractTemplate copy() {
        return new ContractTemplate(this);
    }

    public void fill(String key, String value) {
        fields.put(key, value);
    }
}

public final class TemplateRegistry {
    private final Map<String, DocumentTemplate> originals = new HashMap<>();

    public void register(String name, DocumentTemplate original) {
        originals.put(name, original);
    }

    public DocumentTemplate create(String name) {
        DocumentTemplate original = originals.get(name);
        if (original == null) {
            throw new IllegalArgumentException("등록되지 않은 템플릿: " + name);
        }
        return original.copy();
    }
}

주의: 얕은 복사와 깊은 복사

복제에서 가장 흔한 버그는 복제본을 고쳤더니 원본도 바뀌는 것이다. 얕은 복사는 맨 위 객체만 새로 만들고 필드에 든 참조는 그대로 옮기므로, 안쪽의 목록과 절은 원본과 함께 쓴다. 깊은 복사는 닿는 객체를 모두 새로 만든다. 위 코드가 절 하나하나를 new Section(section)으로 다시 만드는 이유다.

얕은 복사와 깊은 복사 단계 그림: 얕은 복사본은 원본의 절 목록을 함께 가리켜 첫 절을 고치면 원본도 바뀌고, 깊은 복사본은 목록과 절을 따로 가져 원본이 그대로다얕은 복사원본복제본문서절 목록“안녕하세요”“본문”원본: 문서 → 절 목록 → 절 두 개문서절 목록“안녕하세요”“본문”문서원본과 함께 쓰는 객체 3개문서절 목록“반갑습니다”“본문”문서원본의 첫 절도 “반갑습니다”깊은 복사원본복제본문서절 목록“안녕하세요”“본문”원본: 문서 → 절 목록 → 절 두 개문서절 목록“안녕하세요”“본문”문서절 목록“안녕하세요”“본문”원본과 함께 쓰는 객체 0개문서절 목록“안녕하세요”“본문”문서절 목록“반갑습니다”“본문”원본은 “안녕하세요” 그대로
1 / 3

원본 템플릿: 문서가 절 목록을 가리키고, 목록이 절 두 개를 가리킨다.

문서 템플릿을 복제하고 복제본을 고쳐 본다. 원본에 손대지 않으려면 바뀔 수 있는 객체까지 복사해야 한다. 공유 여부는 계산 함수로 구했다.
  • Object.clone()은 얕은 복사다: JDK 문서는 이 메서드가 깊은 복사가 아니라 얕은 복사를 하며 필드의 내용 자체는 복제하지 않는다고 적는다. 클래스가 Cloneable을 구현하지 않으면 CloneNotSupportedException을 던지고, 원본과 독립된 복제본을 원하면 super.clone()이 돌려준 객체에서 가변인 안쪽 구조를 복사본으로 바꿔 넣어야 한다. 이 "바꿔 넣기"는 final 필드에는 할 수 없어 불변 설계와 잘 맞지 않는다.
  • 모두 깊게 복사할 필요는 없다: 문자열처럼 불변인 객체는 함께 써도 안전하다. 바뀔 수 있는 것만 복사한다.
  • 순환 참조: 객체 그래프에 고리가 있으면 순진한 재귀 복사는 끝나지 않는다. 이미 복사한 객체를 기억해 두는 표가 필요하다.

실제 쓰임

  • Object.clone과 배열: 배열은 모두 Cloneable로 취급되어 array.clone()이 된다. 다만 원소가 객체면 원소는 함께 쓴다(얕은 복사).
  • HttpRequest.Builder.copy(): 지금 상태 그대로의 빌더 복제본을 돌려준다. 공통 헤더를 채운 빌더 하나를 원본으로 두고 요청마다 복제해 고치는 프로토타입식 쓰임이다.
  • 문서 편집기의 "복제", 게임의 몬스터 생성기: 실행 중에 만든 설정을 원본으로 등록하고 찍어 낸다.

장점과 단점

  • 장점: 비싼 초기화를 한 번만 한다. 클래스로 미리 정할 수 없는 종류(사용자가 만든 템플릿)를 실행 중에 더하고 뺄 수 있다. 팩터리 메서드처럼 Creator 하위 클래스를 만들 필요가 없다.
  • 단점: 모든 제품이 복제 연산을 올바르게 구현해야 하고, 그 "올바름"(어디까지 깊게)이 클래스마다 다르다. 복제 버그는 원본이 몰래 바뀌는 형태로 늦게 드러난다.

다른 언어에서는

파이썬은 copy 모듈의 copy.copy(얕은)와 copy.deepcopy(깊은)를 쓰고, 3.13에 더해진 copy.replace는 데이터클래스나 네임드튜플을 몇 필드만 바꿔 새로 만든다. 자바스크립트의 전개 구문 { ...obj }은 MDN이 밝히듯 한 단계만 복사하고, structuredClone()은 순환 참조까지 다루는 깊은 복사를 하되 직렬화할 수 없는 값이 있으면 DataCloneError를 던진다. Go는 구조체를 대입하면 필드가 복사되지만, 슬라이스는 배열 조각을 가리키는 서술자라 슬라이스를 잘라도 데이터는 복사되지 않는다. 슬라이스나 맵 필드가 있는 구조체를 복제하려면 copy나 새 맵으로 안쪽을 따로 복사한다.

# Python
import copy
draft = copy.deepcopy(template)        # 안쪽 목록까지 새로 만든다
shallow = copy.copy(template)          # 안쪽 목록은 원본과 함께 쓴다

// JavaScript
const shallow = { ...template };       // 한 단계만 복사
const draft = structuredClone(template); // 깊은 복사(순환 참조도 된다)

무엇을 언제: 다섯 패턴 비교

패턴감추는 것이럴 때이럴 땐 과하다요즘 흔한 대안
싱글턴인스턴스 개수프로그램에 정말 하나만 있어야 하는 자원"편하게 어디서나 꺼내 쓰려고"DI 컨테이너의 싱글턴 스코프, enum
팩터리 메서드만들 구체 클래스흐름은 같고 만드는 것만 하위 타입마다 다를 때구현이 하나뿐일 때정적 팩터리, 생성 함수를 넘기는 합성
추상 팩터리함께 쓰는 객체 가족테마, 공급자처럼 섞이면 안 되는 가족이 둘 이상가족이 하나, 또는 제품 종류가 자주 늘 때조립 지점에서 고른 구현 묶음을 주입
빌더조립 과정선택 인자가 많고, 결과를 불변으로 두고 싶을 때필수 인자 두세 개Lombok @Builder, 키워드 인자, 함수형 옵션
프로토타입만들 클래스와 초기화 비용초기화가 비싸거나 종류가 실행 중에 정해질 때새로 만드는 비용이 싼 단순 객체복사 생성자, 불변 객체의 with 메서드

다섯 패턴은 서로 겹쳐 쓰인다. 추상 팩터리의 각 메서드는 팩터리 메서드로 구현되기 쉽고, 공장은 하나만 있으면 되니 싱글턴(또는 컨테이너의 싱글턴 빈)이 되기 쉽고, 공장이 원본을 복제해 제품을 내면 프로토타입이 된다. 이름보다 "무엇이 바뀌고 무엇이 그대로인가"를 먼저 묻고, 바뀌는 쪽을 감추는 패턴을 고른다.

흔한 오해

  • "싱글턴은 전역 변수보다 객체지향적이니 괜찮다" — 접근을 메서드로 감쌌을 뿐 전역 상태라는 점은 같다. 숨은 의존과 테스트 문제가 그대로 따라온다.
  • "스프링 빈은 싱글턴 패턴이다" — 스프링 싱글턴은 컨테이너마다, 빈 정의마다 하나다. 클래스는 평범하고 생성자도 열려 있어 테스트에서 직접 만들 수 있다. 그 차이가 핵심이다.
  • "스프링의 prototype 스코프는 프로토타입 패턴이다" — 이름만 같다. 스프링 문서의 prototype 스코프는 빈을 요청할 때마다 새 인스턴스를 만든다는 뜻이고, 원본을 복제하지 않는다. 스프링은 prototype 빈의 소멸 콜백을 부르지 않으므로 자원 정리는 쓰는 쪽 몫이다.
  • "정적 팩터리 메서드가 곧 팩터리 메서드 패턴이다" — GoF의 팩터리 메서드는 하위 클래스가 덮어쓰는 메서드다. 정적 팩터리는 이름 있는 생성자에 가깝다. 목적은 닮았지만 구조가 다르다.
  • "clone()이면 복사는 끝난다" — 기본 clone()은 얕은 복사라 안쪽 목록을 원본과 함께 쓴다. 무엇을 깊게 복사할지는 직접 정해야 한다.
  • "빌더는 언제나 생성자보다 낫다" — 빌더는 클래스를 늘리고 빌더 자체는 가변이며, 필수 값 누락을 실행할 때에야 알린다. 필수 인자 몇 개짜리 값 객체에는 생성자나 레코드가 더 단순하다.
  • "패턴을 많이 쓸수록 좋은 설계다" — 패턴은 문제에 붙는 이름이다. 문제가 없는데 쓰면 간접 층만 는다. 언제 쓰지 말아야 하는지는 디자인 패턴이란 노트에 있다.

핵심 자료

요지는 직접 정리한 한 줄이다.

자료한 줄 요지
Spring — Bean ScopesGoF 싱글턴은 클래스로더마다 하나, 스프링 싱글턴은 컨테이너와 빈 정의마다 하나다.
Wikipedia — Initialization-on-demand holder idiom클래스 초기화 규칙에 기대 잠금 없이 지연 초기화한다. 생성이 실패하면 안 된다.
JLS 8.9 — Enum Classesenum은 상수 말고 다른 인스턴스가 생길 수 없다(복제, 리플렉션, 역직렬화 모두 막힘).
Java API — HttpRequest.Builder세터가 같은 빌더를 돌려주고, build()는 매번 새 불변 HttpRequest를 만든다.
Java API — Object.clone기본 clone은 얕은 복사다. 바뀔 수 있는 안쪽 구조는 직접 복사해 바꿔 넣어야 한다.
Dave Cheney — Functional options for friendly APIsGo에서 생성자 인자가 불어날 때 옵션 함수 가변 인자로 기본값과 확장을 함께 얻는다.
Python FAQ — 모듈 간 전역 변수모듈은 하나뿐이라 공유 설정의 자리이자 싱글턴 구현의 바탕이 된다.