문서 읽는 데 61분 · C1

C-1: 생성 패턴 — 객체를 어떻게 만드나, 팩토리·빌더·싱글톤(그리고 싱글톤의 그림자)

목차 25
전체 11강 중 7강 · 클린코드
난이도 · 중급

ℹ️읽기 좋고 고치기 쉬운 코드를 쓰는 법 — 리팩토링·설계 원칙. 코드를 충분히 써본 뒤에 빛을 발하는 주제예요.

안녕하세요, 홍순구 튜터입니다. 지난 시간엔 SOLID 다섯 글자를 모두 채우고, 상속보다 컴포지션이 왜 기본값인지까지 봤죠. 거기서 우리는 "어떤 설계가 좋은가"를 재는 잣대를 손에 쥐었습니다. 응집도·결합도라는 두 축, 그리고 SOLID 다섯 원칙. 클래스 사이의 구조를 보는 눈을 길렀어요.

오늘부터는 카테고리 C, 디자인 패턴으로 넘어갑니다. 선배 개발자들이 같은 문제를 수없이 마주치며 정리해 둔 검증된 해법의 카탈로그예요. 설계 원칙이 "무엇이 좋은 코드인가"의 기준이라면, 패턴은 "그 좋은 코드에 이미 누군가 도달해 둔 길"입니다.

그 첫 모듈인 C-1에서 다루는 건 생성 패턴(creational pattern) — 한마디로 "객체를 어떻게 만드나"입니다. new 한 줄이면 객체가 생기는데 무슨 패턴까지 필요하냐고요? 바로 그 new가 코드 곳곳에 흩어지고, 생성자 인자가 열 개로 불어나고, "딱 하나만 있어야 하는" 객체를 통제하려다 오히려 발목이 잡히는 순간, 생성에도 설계가 필요해집니다. 그리고 그 유명한 싱글톤 — 한때 교과서적 패턴이었다가 지금은 왜 안티패턴으로 재평가됐는지, 지난 시간 배운 DIP·DI가 그 대안으로 어떻게 등장하는지도 오늘 직접 보게 됩니다.

텍스트
 카테고리 C — 디자인 패턴, 검증된 해법의 카탈로그

 C-1  생성 패턴   "객체를 어떻게 만드나"    오늘 여기
        팩토리 · 빌더 · 싱글톤(+안티패턴)
 C-2  구조 패턴   "객체를 어떻게 조립하나"  (다음 시간)
 C-3  행위 패턴   "객체가 어떻게 협력하나"

💡 오늘 수업의 핵심 — "팩토리로 흩어진 new를 한곳에 모으고, 빌더로 인자 지옥을 조립식으로 풀고, 싱글톤은 배우되 왜 안티패턴이 됐는지와 그 대안까지 함께 쥔다"

🎯 학습 목표

  1. 디자인 패턴이 왜 공통 어휘인지 이해하고, GoF 23개 패턴의 큰 지도를 그린다. 동시에 "패턴은 무기지 만능 망치가 아니다"라는 경계를 함께 세운다.
  2. 팩토리(정적 팩토리 메서드·간단한 팩토리)로 생성 로직을 한곳에 모으고, 빌더로 인자 많은 객체를 안전하게 조립한다.
  3. 싱글톤을 구현해 보고, 그것이 왜 안티패턴으로 재평가됐는지를 전역 상태·강결합·테스트 곤란으로 짚은 뒤, DIP·DI로 대체하는 현대적 대안까지 익힌다.

Step 1: "패턴이란 무엇인가" — 검증된 해법의 공통 어휘

본격적으로 객체를 만드는 패턴에 들어가기 전에, 한 걸음 물러서서 묻고 갑시다. 디자인 패턴이 대체 뭘까요?

1994년, 네 명의 저자(흔히 Gang of Four, 줄여서 GoF라 부릅니다)가 객체지향 설계에서 반복되는 문제 23개와 그 해법을 정리한 책을 냈습니다. 그게 디자인 패턴의 출발이에요. 핵심은 이겁니다. 선배 개발자들이 같은 문제를 수없이 만나 도달한, 검증된 해법에 이름을 붙인 것.

이름을 붙였다는 게 생각보다 중요합니다. 우리가 "이건 빌더로 풀자", "여기는 옵저버가 맞겠다"라고 한마디 하면, 같은 팀 누구나 머릿속에 같은 구조를 떠올려요. 패턴은 개발자들 사이의 공통 어휘입니다. 매번 "음, 이 객체를 만드는 메서드를 따로 빼서 거기서 종류를 분기하고…"를 길게 설명하지 않아도, "팩토리 쓰자" 한마디면 통하는 거죠.

GoF 패턴은 크게 세 갈래로 나뉩니다. 오늘 우리가 걷는 건 그중 첫 번째, 생성(creational) 패턴입니다.

텍스트
 디자인 패턴 (GoF) — "객체로 협력을 설계하는" 검증된 해법의 모음

 생성 (Creational)   "객체를 어떻게 만드나"
   팩토리 메서드        생성 로직을 한곳에 모은다
   빌더               복잡한 객체를 단계별로 조립한다
   싱글톤             하나만 만든다 (⚠ 안티패턴 논쟁 — DI로 대체)

 구조 (Structural)   "객체를 어떻게 조립하나"
   어댑터             안 맞는 인터페이스를 끼워 맞춘다
   데코레이터          기능을 겹겹이 덧입힌다 (상속 대신 조합)
   퍼사드             복잡한 내부를 단순한 창구로 가린다
   프록시             대리인을 세워 접근을 제어한다

 행위 (Behavioral)   "객체가 어떻게 협력하나"
   전략               알고리즘을 갈아 끼운다 ( 함수로도 대체)
   옵저버             상태 변화를 구독자에게 알린다
   템플릿 메서드        뼈대는 고정, 일부만 자식이 채운다
   상태               상태에 따라 행동이 바뀐다
   커맨드             요청 자체를 객체로 포장한다

구조 패턴은 다음 시간(C-2), 행위 패턴은 그다음(C-3)에 만납니다. 오늘은 생성 셋만 깊게 봅니다.

그런데 여기서 이 과목이 가장 강조하는 경계 하나를 먼저 분명히 짚고 가겠습니다.

⚠️ 패턴의 빛과 그림자. 패턴은 강력한 무기지만, 모든 못을 박는 망치는 아닙니다. 패턴을 많이 쓸수록 좋은 코드라고 믿는 순간, 함수 하나면 될 일을 클래스 다섯 개로 만드는 과잉 설계가 시작돼요. 패턴은 "이런 문제가 있을 때 꺼내는 도구"지, "가능한 한 많이 욱여넣는 장식"이 아닙니다. 그래서 우리는 패턴마다 "이건 언제 쓰고, 언제는 안 쓰나"를 항상 함께 답니다. 이게 주니어와 미들을 가르는 진짜 차이예요.

자, 경계를 세웠으니 첫 패턴으로 들어가 봅시다.

💡 한 줄 정리

디자인 패턴은 선배들이 검증한 해법에 이름을 붙인 공통 어휘다. GoF 23개는 생성·구조·행위로 나뉘고, 오늘은 생성 셋을 본다. 단, 패턴은 도구지 목적이 아니다.

🙋 학생 질문 — "튜터님, 패턴 23개를 다 외워야 하나요?"

아니요, 절대 그럴 필요 없습니다. 23개를 다 외우는 건 시험 공부지 개발이 아니에요. 실무에서 자주 쓰이는 건 그중 열 개 안팎이고, 우리 과목도 그 빈출 패턴만 골라 다룹니다.

더 중요한 건 외우는 게 아니라 "이 상황에 이런 해법이 있다"는 감각을 갖는 겁니다. 코드를 짜다 "어, 이거 어디서 본 문제인데?" 싶을 때, "아 이게 팩토리로 풀리는 거였지" 하고 떠올리는 정도면 충분해요. 이름은 그때 찾아보면 됩니다. 패턴의 목록이 아니라 패턴이 푸는 문제를 아는 게 핵심입니다.


Step 2: "생성자만 봐선 뭘 만드는지 모르겠어요" — 정적 팩토리 메서드

객체를 만드는 가장 기본적인 방법은 new 생성자죠. 그런데 생성자에는 결정적인 약점이 하나 있습니다. 이름이 없어요. 클래스 이름만 그대로 따를 뿐, "이게 어떤 종류의 객체를 만드는지"를 이름으로 표현할 수가 없습니다.

회원 계정을 만드는 코드를 봅시다. 게스트, 일반 회원, 관리자 세 종류가 있어요.

Java
// Before — public 생성자를 그대로 노출 (cleancode.patterns.creational.staticfactory.AccountBefore)
public class AccountBefore {

    private final String email;
    private final String role;

    public AccountBefore(String email, String role) {
        this.email = email;
        this.role = role;
    }
    // email(), role() 게터 생략
}

이걸 쓰는 호출부는 이렇게 됩니다.

Java
AccountBefore guest = new AccountBefore("guest@noreply", "GUEST");
AccountBefore admin = new AccountBefore("admin@spartaclub.kr", "ADMIN");

문제가 보이시나요? 게스트를 만들 때마다 호출부가 "guest@noreply"라는 기본 이메일과 "GUEST"라는 역할 문자열을 직접 적어야 합니다. 누군가는 "GEUST"로 오타를 낼 거고, 게스트의 기본 이메일이 바뀌면 이 코드가 흩어진 모든 곳을 찾아 고쳐야 해요. 생성의 세부 사항이 호출부 곳곳으로 새어 나간 겁니다.

해법은 이름 있는 정적 팩토리 메서드(static factory method)입니다. 생성자를 private으로 감추고, 의미가 드러나는 이름의 정적 메서드로만 만들게 하는 거예요.

Java
// After — 생성자를 감추고 이름 있는 정적 팩토리로 (cleancode.patterns.creational.staticfactory.Account)
public class Account {

    private static final String GUEST_EMAIL = "guest@noreply";

    private final String email;
    private final String role;

    private Account(String email, String role) {
        this.email = email;
        this.role = role;
    }

    public static Account guest() {
        return new Account(GUEST_EMAIL, "GUEST");
    }

    public static Account member(String email) {
        return new Account(email, "MEMBER");
    }

    public static Account admin(String email) {
        return new Account(email, "ADMIN");
    }
    // email(), role() 게터 생략
}

이제 호출부는 이렇게 깔끔해집니다.

Java
Account guest = Account.guest();
Account admin = Account.admin("admin@spartaclub.kr");

Account.guest()는 읽는 순간 "게스트 계정을 만드는구나"가 보입니다. 게스트의 기본 이메일이나 "GUEST" 같은 역할 문자열은 Account 클래스 안에 딱 한 번만 적혀 있어, 호출부는 그 세부를 더 이상 알 필요가 없어요. 표준 라이브러리도 이 방식을 즐겨 씁니다. List.of(...), LocalDate.of(2026, 6, 23), Integer.valueOf("10") — 전부 생성자 대신 이름 있는 정적 팩토리예요.

정적 팩토리는 생성자가 못 하는 일도 합니다. 같은 입력에 같은 객체를 캐시해서 돌려주거나(예: Boolean.valueOf), 상황에 따라 하위 타입 객체를 돌려줄 수도 있어요. 생성자는 "딱 그 클래스의 새 인스턴스"만 만들 수 있지만, 정적 팩토리는 그 안에서 무엇을 돌려줄지 자유롭게 정합니다.

⚠️ 언제 안 쓰나. 생성할 객체가 한 종류뿐이고 인자가 그대로 필드가 되는 단순한 경우라면, 굳이 정적 팩토리로 감쌀 필요가 없습니다. 그냥 생성자(또는 record)가 더 짧고 솔직해요. 정적 팩토리는 "이름으로 의도를 드러내야 할 때", "생성 세부를 감춰야 할 때", "종류가 여럿일 때" 빛납니다. 모든 클래스에 기계적으로 of()를 붙이는 건 오히려 군더더기예요.

💡 한 줄 정리

생성자는 이름이 없다. 정적 팩토리 메서드는 이름으로 의도를 드러내고 생성 세부를 캡슐화한다. 단, 단순한 객체엔 그냥 생성자가 낫다.

🙋 학생 질문 — "정적 팩토리랑 생성자랑, 결국 객체 만드는 건 똑같지 않나요?"

결과물(만들어진 객체)은 똑같습니다. 차이는 부르는 쪽이 읽기 좋은가, 그리고 만드는 쪽이 자유로운가예요.

생성자는 클래스 이름을 그대로 따라야 해서 new Account(...) 하나뿐입니다. 게스트든 관리자든 같은 모습이라, 인자를 봐야만 뭘 만드는지 알 수 있어요. 반면 Account.guest()Account.admin(email)은 메서드 이름이 곧 설명입니다.

또 하나, 생성자는 호출할 때마다 반드시 새 객체를 만듭니다. 정적 팩토리는 그 안에서 "이미 만든 걸 재사용할지, 새로 만들지, 하위 타입을 줄지"를 마음대로 정할 수 있어요. 부르는 쪽은 그 내부를 몰라도 됩니다. 이 자유가 나중에 큰 차이를 만듭니다.


Step 3: "new가 코드 곳곳에 흩어져 있어요" — 팩토리로 생성을 한곳에

정적 팩토리가 "한 클래스를 어떻게 만드나"였다면, 이번엔 한 걸음 더 나갑니다. 여러 종류 중 어떤 객체를 만들지 결정하는 로직이 코드 곳곳에 흩어지는 문제예요.

알림을 보내는 코드를 봅시다. 채널은 이메일, 문자, 푸시 세 가지가 있어요. 먼저 보낼 객체들의 약속(인터페이스)과 구현입니다.

Java
// 알림을 보내는 약속 (cleancode.patterns.creational.factorymethod.Notifier)
public interface Notifier {

    String send(String message);
}
Java
// 구현 셋 — 이메일·문자·푸시 (같은 패키지)
public class EmailNotifier implements Notifier {
    @Override
    public String send(String message) {
        return "[이메일] " + message;
    }
}
// SmsNotifier 는 "[문자] ", PushNotifier 는 "[푸시] " 를 붙인다

자, 문제는 "어떤 알림 객체를 만들지"를 정하는 코드입니다. 패턴을 모르면 보통 이렇게 짜요.

Java
// Before — 채널 → new 분기가 호출부 한가운데 박혀 있다 (NotificationServiceBefore)
public class NotificationServiceBefore {

    public String notify(String channel, String message) {
        Notifier notifier;
        if (channel.equals("EMAIL")) {
            notifier = new EmailNotifier();
        } else if (channel.equals("SMS")) {
            notifier = new SmsNotifier();
        } else if (channel.equals("PUSH")) {
            notifier = new PushNotifier();
        } else {
            throw new IllegalArgumentException("알 수 없는 채널: " + channel);
        }
        return notifier.send(message);
    }
}

당장은 잘 돌아갑니다. 진짜 문제는 이 "채널을 보고 new 하는" 분기가 알림이 필요한 곳마다 복사돼 흩어진다는 거예요. 알림 보내는 데가 다섯 군데면 같은 if-new 덩어리가 다섯 벌 생깁니다. 새 채널(예: 카카오톡)을 추가하면? 흩어진 다섯 곳을 전부 찾아 고쳐야 하고, 한 곳이라도 놓치면 그곳만 새 채널을 모릅니다.

해법은 생성 로직을 팩토리 한곳에 모으는 겁니다.

Java
// After — "채널 → 알림 객체" 생성을 이 한곳에 (NotifierFactory)
public class NotifierFactory {

    public Notifier create(String channel) {
        return switch (channel) {
            case "EMAIL" -> new EmailNotifier();
            case "SMS" -> new SmsNotifier();
            case "PUSH" -> new PushNotifier();
            default -> throw new IllegalArgumentException("알 수 없는 채널: " + channel);
        };
    }
}
Java
// After — 서비스는 팩토리를 주입받아 "보낸다"는 본래 일만 한다 (NotificationServiceAfter)
public class NotificationServiceAfter {

    private final NotifierFactory factory;

    public NotificationServiceAfter(NotifierFactory factory) {
        this.factory = factory;
    }

    public String notify(String channel, String message) {
        return factory.create(channel).send(message);
    }
}

서비스 코드에 new가 한 번도 등장하지 않는 게 보이시나요? "무엇을 만들지" 결정하는 책임이 서비스 밖으로 빠졌습니다. 이제 새 채널을 추가할 때 고칠 곳은 NotifierFactory 하나뿐이고, 호출부는 한 줄도 바뀌지 않아요.

이거, 지난 카테고리에서 본 원칙이죠. B-1의 OCP(개방-폐쇄 원칙) — 확장에는 열려 있고 수정에는 닫혀 있어야 한다. DiscountPolicy로 새 등급을 "추가"만 했듯, 여기서도 새 채널을 추가만 합니다. 팩토리는 OCP를 생성 영역에서 실현한 모습이에요.

그런데 한 가지 짚고 갈 게 있습니다. 방금 우리가 만든 건 엄밀히는 간단한 팩토리(simple factory)예요. 사실 "팩토리"라는 이름이 붙은 게 셋이라 헷갈리기 쉬운데, 면접에서도 단골로 묻습니다. 한 번 정리하고 갑시다.

텍스트
 "팩토리" 삼 형제 — 헷갈리지 말자

 ① 정적 팩토리 메서드   클래스 안의 static 생성 메서드 (Step 2, Account.guest())
                       한 클래스를 이름으로 만든다

 ② 간단한 팩토리        종류를 보고 알맞은 객체를 만들어 주는 별도 클래스 (방금, NotifierFactory)
                       흩어진 new 를 한곳에 모은다 (실무에서 제일 흔함)

 ③ 팩토리 메서드 패턴    "무엇을 만들지"를 자식 클래스가 오버라이드로 결정 (GoF 정식)
                       상속으로 생성 책임을 자식에게 위임

①과 ②는 우리가 코드로 봤고, ③ GoF 정식 팩토리 메서드 패턴은 상속을 써서 "만드는 메서드"를 부모가 추상으로 두고 자식이 채우는 방식입니다. 실무에서 "팩토리 쓰자"라고 하면 대개 ②를 가리켜요. 이름의 미묘한 차이보다, 셋 다 "생성을 어딘가 한곳으로 모은다"는 정신이 같다는 게 핵심입니다.

⚠️ 언제 안 쓰나. 만들 객체가 한 종류뿐이거나, 종류가 늘어날 일이 없다면 팩토리는 과합니다. new EmailNotifier() 한 줄로 끝날 걸 팩토리 클래스로 감싸면 괜히 파일만 하나 늘어요. 팩토리는 종류가 여럿이고 앞으로 더 늘어날 때, 그리고 그 생성 로직이 여러 곳에서 필요할 때 값을 합니다. "혹시 나중에 늘어날까 봐" 미리 만드는 건 YAGNI(You Aren't Gonna Need It) 위반이에요.

💡 한 줄 정리

흩어진 if/new팩토리 한곳에 모으면, 새 종류 추가에 호출부가 안 바뀐다(OCP). 단, 종류가 안 늘면 팩토리는 과잉이다.

🙋 학생 질문 — "팩토리 안을 보면 결국 똑같은 switch잖아요. 분기를 옮긴 것뿐 아닌가요?"

날카로운 질문이에요. 맞습니다. 분기 자체가 사라진 건 아니에요. 하지만 그 분기가 몇 군데에 있느냐가 완전히 달라집니다.

Before에서는 "채널 → new" 분기가 알림이 필요한 모든 호출부에 복사됩니다. 다섯 곳이면 다섯 벌이에요. 새 채널이 생기면 다섯 곳을 다 고쳐야 하고, 빠뜨린 곳은 조용히 버그가 됩니다.

After에서는 그 분기가 팩토리 딱 한 곳에만 있어요. 호출부는 전부 factory.create(channel)만 부르니, 새 채널을 추가해도 팩토리 한 곳만 고치면 끝입니다. 분기를 없앤 게 아니라, 분기를 한곳에 가둬 변경 지점을 하나로 줄인 거예요. 클린 코드는 "중복을 한곳으로 모으는" 싸움이라는 걸 기억하면, 이게 왜 이득인지 보입니다.


Step 4: "생성자 인자가 10개라 뭐가 뭔지 몰라요" — 빌더

이번엔 인자가 많은 객체를 만드는 문제입니다. A-2에서 우리가 뭐라고 했죠? "함수 인자는 0~2개가 이상적이고, 많아지면 객체로 묶어라." 생성자도 함수입니다. 그 신호가 정확히 여기서 켜져요.

피자를 만든다고 합시다. 크기와 토핑(치즈·페퍼로니·버섯·양파·베이컨)이 있어요. 선택 항목이 많죠. 생성자로 풀면 이렇게 됩니다.

Java
// Before — 선택 항목마다 인자가 줄줄이 (cleancode.patterns.creational.builder.PizzaBefore)
public class PizzaBefore {

    public PizzaBefore(int size, boolean cheese, boolean pepperoni,
            boolean mushroom, boolean onion, boolean bacon) {
        // 필드 대입 생략
    }
}

호출부를 보면 등골이 서늘합니다.

Java
PizzaBefore pizza = new PizzaBefore(12, true, false, true, false, true);

true, false, true, false, true가 대체 뭘까요? 세 번째 true가 버섯인지 페퍼로니인지, 인자 순서를 통째로 외워야 알 수 있습니다. 한 칸만 밀려 적으면 전혀 다른 피자가 되고, 컴파일러는 둘 다 boolean이라 잘못을 잡아주지도 못해요. 이렇게 인자가 길게 늘어선 생성자를 점층적 생성자(telescoping constructor)라고 부릅니다. 선택 조합이 늘 때마다 생성자를 새로 만들거나 인자를 더 붙이게 되죠.

해법이 빌더(builder) 패턴입니다. 객체를 한 번에 만들지 않고, 넣을 것만 이름을 부르며 단계별로 조립한 다음 마지막에 완성하는 거예요.

Java
// After — 빌더로 넣을 토핑만 이름을 불러 조립 (cleancode.patterns.creational.builder.Pizza)
public class Pizza {

    private final int size;
    private final boolean cheese;
    // pepperoni, mushroom, onion, bacon 필드 생략

    private Pizza(Builder builder) {
        this.size = builder.size;
        this.cheese = builder.cheese;
        // 나머지 필드도 builder 에서 옮겨 담는다
    }

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

    public static class Builder {

        private int size = 12;          // 기본값을 빌더가 들고 있다
        private boolean cheese;
        // 나머지 토핑 필드 생략

        public Builder size(int size) {
            this.size = size;
            return this;                // 자기 자신을 돌려줘 .메서드().메서드() 로 잇는다
        }

        public Builder cheese() {
            this.cheese = true;
            return this;
        }
        // mushroom(), bacon() 등도 같은 모양

        public Pizza build() {
            return new Pizza(this);
        }
    }
}

각 메서드가 return this로 자기 자신을 돌려주는 게 핵심입니다. 그래서 점을 찍어 줄줄이 이을 수 있어요. 이걸 플루언트(fluent) 빌더라고 합니다. 호출부를 보면 차이가 확연합니다.

Java
Pizza pizza = Pizza.builder()
        .size(12)
        .cheese()
        .bacon()
        .build();

true, false, true를 외워야 했던 게 .cheese().bacon()으로 바뀌었어요. 무엇을 넣는지가 한눈에 읽히고, 안 넣을 토핑에 false를 줄 세울 필요도 없습니다. 부르지 않은 토핑은 기본값(없음)으로 남고, 크기도 빌더가 기본값 12를 들고 있다가 호출부가 바꿀 때만 바뀝니다. 완성된 Pizzafinal 필드만 가진 불변 객체고요(A-3에서 본 불변성이죠). 생성자가 private이라 빌더를 거치지 않고는 만들 수 없습니다.

그럼 자연스럽게 떠오르는 질문. "필드 묶음이면 A-3에서 배운 record를 쓰면 되잖아요?" 좋은 지적입니다. 둘의 경계를 정리하면 이래요.

텍스트
 record 와 빌더, 언제 무엇을?

 record Point(int x, int y)        필드가 적고(2~3개) 전부 필수     record 가 최선
                                   불변 + 게터 + equals 자동, 한 줄

 Pizza.builder()...build()         필드가 많고 상당수가 선택         빌더가 빛난다
                                   순서를 외울 필요 없이 이름으로 조립

필드가 두셋이고 다 필수라면 record가 압도적으로 짧고 좋습니다. 빌더는 필드가 많고 그중 상당수가 선택적일 때 값을 해요. 참고로 실무에선 빌더를 손으로 다 짜지 않고 Lombok의 @Builder 어노테이션 한 줄로 자동 생성하는 경우가 많은데, 만들어지는 결과물은 방금 우리가 손으로 짠 것과 똑같습니다. 원리를 알면 그 한 줄이 무엇을 만드는지 보이죠.

파이썬이라면 어떨까요? 파이썬은 키워드 인자와 기본값을 언어가 기본 제공합니다. 그래서 빌더 없이도 이렇게 써요.

Python
# 넣을 토핑만 이름을 줘서 만든다 — 빌더가 필요 없다
pizza = Pizza(size=12, cheese=True, bacon=True)

순서를 외울 필요도, 안 쓸 토핑에 False를 줄 세울 필요도 없습니다. 빌더가 풀려던 문제를 언어 기능이 이미 풀어 둔 거예요. 이건 중요한 시각을 줍니다. 디자인 패턴 중 상당수는 "언어가 부족한 곳을 메우는 우회로"이기도 하다는 것. 자바엔 키워드 인자가 없어서 빌더가 필요했던 거죠.

⚠️ 언제 안 쓰나. 인자가 두셋뿐인데 빌더부터 꺼내면 보일러플레이트만 늘어납니다. new Point(3, 4) 면 될 걸 Point.builder().x(3).y(4).build()로 만드는 건 과잉이에요. 빌더는 인자가 많고, 상당수가 선택적이며, 객체가 불변이어야 할 때 켜는 카드입니다. 필드 두 개짜리에 빌더는 망치로 압정 박기예요.

💡 한 줄 정리

인자 많은 객체는 빌더로 "넣을 것만 이름 불러" 조립한다. 단, 필드 적고 다 필수면 record가 낫고, 파이썬은 키워드 인자로 대체된다.

🙋 학생 질문 — "record가 있으면 빌더는 이제 안 써도 되는 거 아니에요?"

record는 정말 좋은 도구지만, 빌더를 대체하진 못합니다. 둘이 푸는 문제가 다르거든요.

record는 "필드 묶음을 불변으로, 짧게"를 풀어요. record Point(int x, int y) 한 줄이면 불변 객체에 게터, equals, toString까지 다 생깁니다. 필드가 적고 전부 필수일 때 최고예요.

하지만 필드가 여덟 개고 그중 다섯 개가 선택이라면? record로는 new Pizza(12, true, false, true, false, true, ...)처럼 결국 점층적 생성자 문제로 돌아갑니다. record도 생성자는 똑같이 인자를 순서대로 받으니까요. 이때 "넣을 것만 이름 불러 조립"하는 빌더가 필요합니다.

실무 팁 하나. 둘을 같이 쓰기도 해요. 불변 record를 만들되, 인자가 많으면 그 record에 빌더를 얹는 식으로요. 도구는 배타적이지 않습니다.


Step 5: "딱 하나만 있어야 하는 객체" — 싱글톤

이번 패턴은 좀 특별합니다. 앞의 둘과 달리, 배우면서 동시에 경계해야 하는 패턴이에요. 바로 그 유명한 싱글톤(singleton)입니다. 오늘은 먼저 "무엇인지, 어떻게 만드는지"를 보고, 다음 Step에서 "왜 안티패턴이 됐는지"를 정면으로 다룹니다.

싱글톤의 목적은 한 문장입니다. 어떤 클래스의 인스턴스가 프로그램 전체에서 딱 하나만 존재하도록 보장한다. 앱 전역 설정값, 로그를 남기는 로거처럼 "여러 개 있으면 오히려 곤란한" 객체에 쓰려고 만들어졌어요.

자바에서 고전적인 싱글톤은 이렇게 생겼습니다.

Java
// 고전적 싱글톤 — 인스턴스를 딱 하나만 (cleancode.patterns.creational.singleton.Settings)
public class Settings {

    private static Settings instance;

    private final Map<String, String> values = new HashMap<>();

    private Settings() {
    }

    public static Settings getInstance() {
        if (instance == null) {
            instance = new Settings();
        }
        return instance;
    }

    public void set(String key, String value) {
        values.put(key, value);
    }

    public String get(String key) {
        return values.get(key);
    }
}

핵심 장치는 두 가지예요. 첫째, 생성자를 private으로 막아 외부에서 new를 못 하게 합니다. 둘째, getInstance()만 공개해서 처음 부를 때 하나 만들어 보관하고(이걸 지연 초기화, lazy initialization이라 해요), 그다음부터는 보관해 둔 같은 것을 돌려줍니다. 그래서 어디서 Settings.getInstance()를 부르든 항상 같은 인스턴스가 나오고, 한쪽에서 담은 설정값을 다른 쪽에서도 그대로 볼 수 있어요.

그런데 이 고전적 방식엔 미묘한 약점이 있습니다. 여러 스레드가 getInstance()동시에 처음 부르면, instance == null 검사를 둘 다 통과해 인스턴스가 두 개 생길 틈이 있어요. "하나만"이 깨지는 거죠. 그래서 자바에서는 더 간단하고 안전한 방법을 권장합니다. 바로 enum 싱글톤이에요.

Java
// enum 싱글톤 — 자바에서 가장 간단하고 안전한 싱글톤 (EnumSettings)
public enum EnumSettings {

    INSTANCE;

    private final Map<String, String> values = new HashMap<>();

    public void set(String key, String value) {
        values.put(key, value);
    }

    public String get(String key) {
        return values.get(key);
    }
}

원소가 INSTANCE 하나뿐인 enum이에요. JVM이 enum 원소를 정확히 하나만, 스레드 안전하게, 직렬화·리플렉션 공격에도 견디게 보장해 줍니다. getInstance()의 null 검사도 필요 없어요. 호출은 EnumSettings.INSTANCE.get("theme")처럼 합니다. 자바에서 진짜 싱글톤이 필요하다면, 이 enum 방식이 가장 권장되는 구현이에요.

파이썬이라면? 사실 파이썬엔 싱글톤 패턴을 위한 장치가 거의 필요 없습니다. 모듈이 곧 싱글톤이거든요. 파이썬 모듈은 처음 import될 때 한 번만 평가되고 그 뒤로는 캐시됩니다. 그래서 모듈 수준 변수에 상태를 담으면, 별도 장치 없이 "전 프로그램에서 하나"가 돼요. 자바가 클래스로 끙끙대며 만드는 걸, 파이썬은 모듈 하나로 끝냅니다. 빌더 때와 똑같은 교훈이죠 — 패턴의 모습은 언어 따라 달라집니다.

⚠️ 잠깐, 여기서 멈춥시다. 방금 "싱글톤은 이렇게 만든다"를 배웠지만, 사실 싱글톤은 현대 개발에서 가장 논쟁적인 패턴입니다. 한때 교과서 단골이었던 이 패턴이 지금은 널리 안티패턴으로 재평가됐어요. 왜일까요? 그 이야기를 다음 Step에서 정면으로 다룹니다. 지금은 "만드는 법은 알되, 함부로 쓰면 안 되는 패턴"이라고만 기억해 두세요.

💡 한 줄 정리

싱글톤은 인스턴스를 딱 하나로 보장한다. 자바는 private 생성자 + getInstance, 더 안전하게는 enum 싱글톤. 파이썬은 모듈이 곧 싱글톤이다.

🙋 학생 질문 — "그냥 public static 변수 하나 두면 되는데 왜 굳이 getInstance를 만들어요?"

좋은 질문이에요. public static Settings instance = new Settings();처럼 공개 정적 변수를 두는 것도 한 방법이긴 합니다. 실제로 그렇게 쓰기도 해요.

차이는 통제권입니다. getInstance()라는 메서드를 거치게 하면, 그 안에서 "처음 부를 때까지 안 만들기(지연 초기화)", "스레드 안전하게 만들기", "나중에 구현을 바꾸기" 같은 걸 할 여지가 생깁니다. 공개 변수는 그냥 노출돼 있어서 이런 통제를 끼워 넣을 수 없어요.

다만, 이 질문엔 더 깊은 함정이 숨어 있습니다. public static이든 getInstance()든, 둘 다 "전역에서 아무나 꺼내 쓸 수 있다"는 점은 똑같다는 거예요. 그리고 바로 그 전역 접근이 싱글톤을 안티패턴으로 만든 진짜 원인입니다. 이 이야기를 다음 Step에서 이어가요.


Step 6: "싱글톤이 전역 변수처럼 테스트를 막아요" — 왜 안티패턴이 됐나, DI로 갈아끼우기

자, 약속한 이야기입니다. 싱글톤은 왜 안티패턴으로 재평가됐을까요? 코드로 직접 봅시다. 앞 Step에서 만든 Settings 싱글톤을 가져다 쓰는 가격 계산 서비스예요.

Java
// Before — 전역 싱글톤을 메서드 안에서 직접 끌어다 쓴다 (PriceServiceBefore)
public class PriceServiceBefore {

    public int finalPrice(int price) {
        Settings settings = Settings.getInstance();
        int vatRate = Integer.parseInt(settings.get("vatRate"));
        return price + (price * vatRate / 100);
    }
}

Settings.getInstance()를 코드 한가운데서 부르는 순간, 세 가지 문제가 한꺼번에 터집니다.

첫째, 전역 상태. 싱글톤은 전 세계 어디서나 꺼내 쓸 수 있고, 누구나 그 안의 값을 바꿀 수 있습니다. 마치 전역 변수예요. 한 곳에서 vatRate를 바꾸면 그걸 쓰는 모든 코드가 영향을 받는데, 어디서 바꿨는지 추적하기가 어렵습니다.

둘째, 강결합과 숨은 의존. PriceServiceBefore의 생성자만 봐서는 이 클래스가 Settings에 의존한다는 사실이 전혀 드러나지 않아요. 의존이 메서드 안에 숨어 있죠. 이 서비스를 쓰려는 사람은 코드를 다 읽어야만 "아, SettingsvatRate가 미리 들어 있어야 하는구나"를 압니다.

셋째, 테스트 곤란. 이게 가장 치명적입니다. 이 서비스를 테스트하려면 vatRate를 원하는 값으로 두어야 하는데, 방법은 전역 싱글톤을 직접 건드리는 것뿐이에요. 그리고 한 테스트에서 바꾼 그 값은 싱글톤이 하나뿐이라 다음 테스트로 그대로 새어 나갑니다. 테스트들이 서로 격리되지 못하고 실행 순서에 따라 결과가 흔들려요.

해법은 지난 시간 배운 바로 그것입니다. DIP(의존성 역전)와 DI(의존성 주입). 필요한 것을 메서드 안에서 몰래 꺼내 오지 말고, 밖에서 주입받는 거예요. 먼저 "부가세율을 알려 준다"는 약속(추상)을 둡니다.

Java
// 부가세율을 알려 주는 약속 (cleancode.patterns.creational.singleton.RateProvider)
public interface RateProvider {

    int vatRate();
}
Java
// After — 필요한 것을 생성자로 주입받는다 (PriceServiceAfter)
public class PriceServiceAfter {

    private final RateProvider rateProvider;

    public PriceServiceAfter(RateProvider rateProvider) {
        this.rateProvider = rateProvider;
    }

    public int finalPrice(int price) {
        return price + (price * rateProvider.vatRate() / 100);
    }
}

세 가지 문제가 한꺼번에 풀립니다. 이제 의존이 생성자에 드러나고(숨은 의존이 사라짐), 전역 상태에 매달리지 않아요. 진짜 앱에서는 시작 지점에서 RateProvider 구현을 하나 만들어 주입하면 됩니다. 이 "앱이 시작할 때 객체들을 만들어 엮는 한 지점"을 컴포지션 루트(composition root)라고 불러요. 그리고 테스트에서는 가짜 공급자를 끼워 격리된 채로 검증할 수 있습니다. RateProvider는 메서드가 하나뿐이라 람다로도 구현돼요.

Java
// 가짜 공급자를 람다로 끼워 격리 테스트 — 전역을 건드리지 않는다
PriceServiceAfter service = new PriceServiceAfter(() -> 10);
// service.finalPrice(1000) → 1100, 옆 테스트에 새지 않는다

핵심은 이겁니다. 싱글톤의 진짜 가치는 "하나만 둔다"였는데, 문제는 거기 딸려 온 "전역에서 아무나 꺼내 쓴다"였어요. DI는 이 둘을 분리합니다. "하나만 둔다"는 컴포지션 루트가 챙기고(거기서 하나 만들어 모두에게 주입), "전역 접근"이라는 해악만 떼어 냈죠. 인스턴스가 하나인 건 똑같지만, 아무도 전역에서 그걸 몰래 꺼내지 않습니다.

싱글톤 외에 대안이 하나 더 있어요. 만약 그 객체가 상태가 전혀 없다면(설정값 같은 걸 안 들고, 순수 계산만 한다면), 싱글톤이고 뭐고 필요 없이 그냥 정적 유틸리티로 두면 됩니다. Math.max(...)처럼요. 상태가 없으니 전역 상태 문제 자체가 안 생기죠.

⚠️ 그럼 싱글톤은 절대 쓰면 안 되나? 그건 또 아닙니다. 진짜로 인스턴스가 하나여야만 하는 자원(예: 비용이 큰 커넥션 풀, 앱 전체에서 공유하는 캐시)은 여전히 싱글톤이 맞을 수 있어요. 다만 "전역에서 직접 꺼내 쓰는" 방식만은 피하고, 그 하나뿐인 객체조차 DI로 주입받게 만드는 게 현대적 기본값입니다. 안티패턴인 건 "싱글톤이라는 개념"이 아니라 "전역 접근으로 강결합을 만드는 그 사용 방식"이에요. 실제로 스프링 같은 프레임워크가 객체를 기본적으로 하나만 만들어 관리하는데, 그건 전역 접근이 아니라 주입으로 나눠 주기 때문에 안티패턴이 아닙니다.

💡 한 줄 정리

싱글톤의 죄는 전역 상태·강결합·테스트 곤란이다. "하나만 둔다"는 살리고 "전역 접근"만 떼어 내는 게 DI다. 진짜 단일 자원도 주입받게 한다.

🙋 학생 질문 — "스프링 빈은 다 싱글톤이라던데, 그럼 스프링은 안티패턴을 쓰는 거예요?"

아주 좋은 질문이고, 면접에서도 자주 나옵니다. 결론부터 말하면 아니에요. 둘은 "인스턴스가 하나"라는 결과만 같을 뿐, 사용 방식이 정반대입니다.

우리가 안티패턴이라 부른 싱글톤의 문제는 "하나만 있다"가 아니라 "전역에서 아무나 getInstance()로 직접 꺼내 쓴다"였어요. 코드가 전역 객체에 직접 손을 뻗으니 강결합·숨은 의존·테스트 곤란이 생긴 거죠.

프레임워크가 객체를 하나만 만들어 관리하는 건 다릅니다. 그 하나뿐인 객체를 필요한 곳에 주입해 주거든요. 코드는 전역에서 직접 꺼내는 게 아니라, 생성자로 받기만 합니다. 의존이 드러나고, 테스트에선 가짜로 갈아끼울 수 있어요. 즉 "하나만 둔다"는 이점은 누리되 "전역 접근"이라는 해악은 없는, 바로 우리가 이 Step에서 본 그 방식입니다. 그 본격적인 적용은 후속 spring-boot 과목에서 만나게 됩니다.


마무리

오늘은 카테고리 C의 문을 열며, "객체를 어떻게 만드나"를 다루는 생성 패턴 셋을 익혔습니다.

오늘 배운 핵심 세 가지

💡 하나 — 팩토리. 생성자는 이름이 없다. 정적 팩토리 메서드는 이름으로 의도를 드러내고, 간단한 팩토리는 흩어진 new를 한곳에 모아 새 종류 추가에 호출부가 안 바뀌게 한다(OCP). 단, 종류가 안 늘면 팩토리는 과잉이다.

💡 둘 — 빌더. 인자가 많고 선택적인 객체는 빌더로 "넣을 것만 이름 불러" 조립한다. 점층적 생성자의 순서 외우기 지옥을 푼다. 단, 필드가 적고 다 필수면 record가 낫고, 파이썬은 키워드 인자로 이미 해결돼 있다.

💡 셋 — 싱글톤과 그 그림자. 싱글톤은 인스턴스를 하나로 보장하지만, 전역 상태·강결합·테스트 곤란 때문에 안티패턴으로 재평가됐다. "하나만 둔다"는 살리고 "전역 접근"만 떼어 내는 DI가 현대적 대안이다. 패턴조차 맹신하지 않고 언제 쓸지 판단하는 게 미들의 눈이다.

다음 시간 예고

생성 패턴으로 "객체를 어떻게 만드나"를 봤으니, 다음 시간(C-2)에는 구조 패턴 — "객체를 어떻게 조립하나"로 넘어갑니다. 서로 안 맞는 인터페이스를 끼워 맞추는 어댑터(A-4에서 외부 라이브러리를 감쌌던 그 경계 이야기가 패턴으로 돌아옵니다), 상속 대신 조합으로 기능을 겹겹이 덧입히는 데코레이터(B-2 끝에서 씨앗을 심었던 바로 그 컴포지션의 결정판이죠)를 만나요. 오늘 팩토리에서 "생성을 한곳에 모으는" 감각을 잡았으니, 다음엔 "이미 만들어진 객체들을 어떻게 엮는가"로 시야가 넓어질 차례입니다.


과제

오늘 배운 세 패턴을 직접 손으로 적용해 봅니다. 각 Before 코드를 더 나은 구조로 고쳐 보세요. 정답은 예시답안에서 확인할 수 있어요.

[기초] 흩어진 new를 팩토리로 모으기

아래 ButtonRendererBefore는 운영체제(OS)에 따라 다른 버튼을 그리는데, "OS → new" 분기가 메서드 안에 들어 있습니다.

Java
// Before — OS 분기가 호출부에 흩어진다
public class ButtonRendererBefore {

    public String render(String os) {
        if (os.equals("WINDOWS")) {
            return new WindowsButton().draw();
        } else if (os.equals("MAC")) {
            return new MacButton().draw();
        } else {
            throw new IllegalArgumentException("알 수 없는 OS: " + os);
        }
    }
}

Button 인터페이스(메서드 draw())와 WindowsButton, MacButton 구현이 있다고 할 때, 생성 로직을 ButtonFactory 한곳으로 모으고, 렌더러는 팩토리를 주입받아 new를 모르게 고쳐 보세요. 그런 다음 새 OS(예: 리눅스)를 추가할 때 고칠 곳이 팩토리 하나뿐인지 확인해 보세요.

[응용] 점층적 생성자를 빌더로

아래 Coffee는 선택 항목이 많아 생성자 인자가 줄줄이 늘어섭니다.

Java
// Before — 점층적 생성자
public class Coffee {

    public Coffee(String base, int shots, boolean iced,
            boolean decaf, boolean extraSyrup) {
        // 필드 대입 생략
    }
}
// 호출: new Coffee("ESPRESSO", 2, true, false, true) — 뭐가 뭔지!

Coffee를 플루언트 빌더로 고쳐, Coffee.builder().base("ESPRESSO").shots(2).iced().extraSyrup().build()처럼 넣을 것만 이름을 불러 만들 수 있게 해 보세요. 기본값(예: 샷 1, 따뜻하게)은 빌더가 들고 있게 하고요.

[심화] 전역 싱글톤에 묶인 코드를 DI로 풀기

아래 TaxCalculatorBefore는 세율을 전역 싱글톤 TaxConfig.getInstance()에서 직접 꺼내 씁니다. 그래서 테스트에서 세율을 바꾸려면 전역 상태를 건드리는 수밖에 없어요.

Java
// Before — 전역 싱글톤에 직접 매달림
public class TaxCalculatorBefore {

    public int afterTax(int amount) {
        TaxConfig config = TaxConfig.getInstance();
        int rate = config.getRate();
        return amount + (amount * rate / 100);
    }
}

세율을 알려 주는 약속(인터페이스)을 도입하고, 그 구현을 생성자로 주입받도록 고쳐 보세요. 그런 다음 가짜 세율을 끼워 전역을 건드리지 않고 테스트할 수 있는지 확인해 보세요. (싱글톤의 어떤 "죄"가 풀렸는지도 한 줄로 정리해 보면 좋습니다.)


생각해볼 주제

1. 팩토리가 오히려 과한 경우는 언제일까?

우리는 흩어진 new를 팩토리로 모으면 좋다고 배웠다. 그런데 만들 객체가 한 종류뿐이고 앞으로 늘어날 일도 없는데 "혹시 모르니" 팩토리부터 만든다면 어떨까? 패턴을 적용하는 것과 과잉 설계 사이의 경계는 어디일까? YAGNI 원칙과 함께 생각해 보자.

2. record와 빌더, 그리고 파이썬 키워드 인자 — 같은 문제의 세 답

자바에서 "인자 많은 객체 만들기"는 빌더로 풀었지만, record로 충분한 경우도 있고, 파이썬에선 키워드 인자로 애초에 문제가 안 생긴다. 같은 문제에 언어와 도구마다 다른 답이 있다는 건 무엇을 뜻할까? "디자인 패턴은 언어가 부족한 곳을 메우는 우회로이기도 하다"는 시각에 대해 생각해 보자.

3. 스프링 빈은 싱글톤인데 왜 안티패턴이 아닐까?

싱글톤은 전역 상태·강결합·테스트 곤란 때문에 안티패턴으로 재평가됐다. 그런데 스프링 같은 프레임워크는 객체를 기본적으로 하나만 만들어 쓴다. 둘 다 "인스턴스가 하나"인데, 왜 하나는 안티패턴이고 하나는 권장될까? "하나만 둔다"와 "전역에서 직접 꺼내 쓴다"를 분리해서 생각해 보자.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 팩토리 클래스 이름이나 빌더 메서드 모양은 "이렇게도 할 수 있다"는 한 갈래일 뿐, 흩어진 new가 한곳에 모이고 인자 지옥이 풀리면 다른 모습도 좋습니다. 중요한 건 "왜 이렇게 바꿨는가"입니다.


과제 예시답안

🎯 [과제 1 예시답안] 흩어진 new를 팩토리로 모으기

채점 포인트

항목 보는 것 배점
생성 로직 집중 OS → 버튼 생성을 ButtonFactory 한곳에 모았는가 ★★★
호출부에서 new 제거 렌더러가 팩토리를 주입받아 new를 모르는가 ★★★
OCP 확인 새 OS 추가 시 팩토리만 고치고 렌더러는 그대로인가 ★★☆
동작 보존 Before와 After가 같은 버튼을 그리는가 ★★☆

풀이 예시

❌ Before — "OS → new" 분기가 렌더러 메서드 안에 들어 있다. 버튼을 그리는 모든 곳에 이 분기가 복사될 운명이다.

Java
public class ButtonRendererBefore {

    public String render(String os) {
        if (os.equals("WINDOWS")) {
            return new WindowsButton().draw();
        } else if (os.equals("MAC")) {
            return new MacButton().draw();
        } else {
            throw new IllegalArgumentException("알 수 없는 OS: " + os);
        }
    }
}

✅ After — 생성 로직을 팩토리 한곳에 모은다.

Java
public class ButtonFactory {

    public Button create(String os) {
        return switch (os) {
            case "WINDOWS" -> new WindowsButton();
            case "MAC" -> new MacButton();
            case "LINUX" -> new LinuxButton();
            default -> throw new IllegalArgumentException("알 수 없는 OS: " + os);
        };
    }
}

렌더러는 팩토리를 주입받아 "그린다"는 일에만 집중합니다. new가 한 번도 등장하지 않아요.

Java
public class ButtonRendererAfter {

    private final ButtonFactory factory;

    public ButtonRendererAfter(ButtonFactory factory) {
        this.factory = factory;
    }

    public String render(String os) {
        return factory.create(os).draw();
    }
}

새 OS(리눅스)를 추가할 때 손대는 곳은 ButtonFactorycase "LINUX" 한 줄뿐입니다. 렌더러도, 렌더러를 부르는 호출부도 한 글자도 바뀌지 않아요. 이게 바로 B-1에서 본 OCP(개방-폐쇄)를 생성 영역에서 실현한 모습입니다.

💡 튜터의 한마디 — 팩토리의 핵심은 "분기를 없앤다"가 아니라 "분기를 한곳에 가둔다"입니다. switch는 여전히 있지만, 그게 온 코드에 흩어지지 않고 팩토리 하나에만 있다는 게 이득이에요. 변경 지점이 하나로 줄어든 거죠.

🎯 [과제 2 예시답안] 점층적 생성자를 빌더로

채점 포인트

항목 보는 것 배점
플루언트 빌더 각 설정 메서드가 return this로 이어지는가 ★★★
이름으로 조립 .base().shots().iced()처럼 의도가 드러나는가 ★★★
기본값 보유 빌더가 기본값(샷 1·따뜻하게)을 들고 있는가 ★★☆
불변·캡슐화 생성자가 private이고 완성된 객체가 불변인가 ★★☆

풀이 예시

❌ Before — 선택 항목마다 인자가 늘어, 호출부가 boolean을 순서대로 외워야 한다.

Java
public class CoffeeBefore {

    public CoffeeBefore(String base, int shots, boolean iced,
            boolean decaf, boolean extraSyrup) {
        // 필드 대입 생략
    }
}
// 호출: new CoffeeBefore("ESPRESSO", 2, true, false, true) — 세 boolean 의 순서를 외워야 한다.

✅ After — 빌더로 넣을 것만 이름을 불러 조립한다. 부르지 않은 항목은 빌더가 든 기본값으로 남는다.

Java
public class Coffee {

    // 필드 생략 — 전부 final 불변

    private Coffee(Builder builder) {
        this.base = builder.base;
        this.shots = builder.shots;
        // 나머지 필드도 builder 에서 옮겨 담는다
    }

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

    public static class Builder {

        private String base = "AMERICANO";
        private int shots = 1;          // 기본값을 빌더가 든다
        private boolean iced;
        private boolean decaf;
        private boolean extraSyrup;

        public Builder base(String base) {
            this.base = base;
            return this;                // 자기 자신을 돌려줘 점으로 잇는다
        }

        public Builder shots(int shots) {
            this.shots = shots;
            return this;
        }

        public Builder iced() {
            this.iced = true;
            return this;
        }
        // decaf(), extraSyrup() 도 같은 모양

        public Coffee build() {
            return new Coffee(this);
        }
    }
}

이제 호출부가 읽힙니다.

Java
Coffee coffee = Coffee.builder()
        .base("ESPRESSO")
        .shots(2)
        .iced()
        .extraSyrup()
        .build();

true, false, true를 외우던 게 .iced().extraSyrup()으로 바뀌었어요. 디카페인을 안 넣으면 decaf()를 안 부르면 그만이고, 따뜻한 기본값이 그대로 유지됩니다.

💡 튜터의 한마디 — 빌더는 인자가 많고 상당수가 선택적일 때 빛납니다. 만약 이 커피가 baseshots 둘만 받고 둘 다 필수였다면, 빌더 대신 record Coffee(String base, int shots) 한 줄이 더 좋았을 거예요. 도구는 문제 크기에 맞춰 고릅니다.

🎯 [과제 3 예시답안] 전역 싱글톤에 묶인 코드를 DI로 풀기

채점 포인트

항목 보는 것 배점
약속 도입 세율을 알려 주는 인터페이스(추상)를 두었는가 ★★★
생성자 주입 그 약속의 구현을 생성자로 주입받는가 ★★★
전역 제거 TaxConfig.getInstance() 직접 호출이 사라졌는가 ★★☆
테스트 격리 가짜 세율을 끼워 전역 없이 검증되는가 ★★☆

풀이 예시

❌ Before — 세율을 전역 싱글톤에서 직접 꺼내 쓴다. 의존이 메서드 안에 숨고, 테스트하려면 전역 상태를 건드려야 한다.

Java
public class TaxCalculatorBefore {

    public int afterTax(int amount) {
        TaxConfig config = TaxConfig.getInstance();
        int rate = config.getRate();
        return amount + (amount * rate / 100);
    }
}

✅ After — "세율을 알려 준다"는 약속을 두고, 그 구현을 생성자로 주입받는다.

Java
public interface TaxRateProvider {

    int rate();
}
Java
public class TaxCalculatorAfter {

    private final TaxRateProvider rateProvider;

    public TaxCalculatorAfter(TaxRateProvider rateProvider) {
        this.rateProvider = rateProvider;
    }

    public int afterTax(int amount) {
        return amount + (amount * rateProvider.rate() / 100);
    }
}

이제 가짜 세율을 끼워, 전역을 건드리지 않고 격리된 채로 확인할 수 있습니다. TaxRateProvider는 메서드가 하나라 람다로도 구현돼요.

Java
TaxCalculatorAfter calculator = new TaxCalculatorAfter(() -> 10);
// calculator.afterTax(1000) → 1100, 옆 코드의 세율에 영향받지 않는다

어떤 "죄"가 풀렸나 — ① 전역 상태: 더 이상 어디서나 꺼내 쓰는 전역에 매달리지 않는다. ② 강결합·숨은 의존: 의존이 생성자에 드러난다. ③ 테스트 곤란: 가짜를 주입해 격리 검증되고, 한 검증이 다음으로 새지 않는다.

💡 튜터의 한마디 — "하나만 둔다"가 정말 필요하면, 앱 시작 지점(컴포지션 루트)에서 TaxRateProvider 구현을 하나 만들어 모두에게 주입하면 됩니다. 인스턴스는 여전히 하나지만, 아무도 전역에서 몰래 꺼내지 않아요. 안티패턴이었던 건 "싱글톤"이 아니라 "전역 접근"이었습니다.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 팩토리가 오히려 과한 경우는 언제일까?

문제 상황 요약

흩어진 new를 팩토리로 모으면 좋다고 배웠습니다. 그런데 만들 객체가 한 종류뿐이고 앞으로 늘어날 일도 없는데 "혹시 모르니" 팩토리부터 만든다면 어떨까요? 패턴 적용과 과잉 설계 사이의 경계는 어디일까요?

튜터의 가이드 및 해설

핵심은 팩토리가 푸는 문제가 실제로 있는가입니다. 팩토리는 "종류가 여럿이고, 어떤 종류를 만들지 정하는 로직이 여러 곳에서 필요할 때" 값을 합니다. 이 둘이 없으면 팩토리는 빈 포장일 뿐이에요.

만들 객체가 new EmailNotifier() 한 종류뿐이라면, 그걸 팩토리로 감싸 봤자 파일만 하나 늘고 코드를 읽을 때 점프가 한 번 더 생깁니다. "지금은 하나지만 나중에 늘어날까 봐" 미리 만드는 것도 위험해요. 그 "나중"은 자주 오지 않고, 막상 오면 그때 추출해도 늦지 않거든요. 이게 YAGNI(You Aren't Gonna Need It) 원칙입니다. 판단 기준을 정리하면 이래요. 종류가 둘 이상이고, 그 생성이 여러 곳에서 일어나거나 앞으로 종류가 늘 게 분명할 때 팩토리를 꺼냅니다. 확신이 없으면 그냥 new로 두는 게 솔직합니다.

🎯 면접에선 이렇게 나온다

"팩토리는 종류가 여럿이고 그 생성 로직이 여러 곳에서 중복될 때 값을 합니다. 종류가 하나뿐이거나 변할 일이 없으면 팩토리는 읽기 비용만 늘리는 군더더기예요. 저는 '같은 new 분기가 두세 곳에 복사되기 시작할 때'를 팩토리 도입 신호로 봅니다. YAGNI를 어기고 미리 만들지 않아요."

💡 실무에선

같은 "타입 → new" 분기가 두 번째, 세 번째 복사되는 게 보일 때 — 그때가 팩토리로 모을 시점입니다. 한 곳에만 있는 new는 그냥 두세요.

🤔 [생각해볼 주제 2] record·빌더·파이썬 키워드 인자 — 같은 문제의 세 답

문제 상황 요약

자바에서 "인자 많은 객체 만들기"는 빌더로 풀었지만, record로 충분한 경우도 있고, 파이썬에선 키워드 인자로 애초에 문제가 안 생깁니다. 같은 문제에 언어와 도구마다 다른 답이 있다는 건 무엇을 뜻할까요?

튜터의 가이드 및 해설

이건 디자인 패턴을 바라보는 시각 자체를 바꿔 주는 질문입니다. 디자인 패턴 중 상당수는 "언어가 부족한 곳을 메우는 우회로"이기도 하다는 것이죠.

빌더 패턴이 풀려던 진짜 문제는 "인자가 많을 때 순서를 외우지 않고, 넣을 것만 이름으로 지정하기"였어요. 그런데 파이썬은 키워드 인자와 기본값을 언어가 기본 제공합니다. Pizza(size=12, cheese=True)면 끝이라, 빌더라는 패턴 자체가 거의 필요 없어요. 자바엔 그 문법이 없어서 빌더로 우회한 겁니다. record도 비슷해요. 필드가 적고 다 필수인 불변 객체라면 record 한 줄이 빌더를 대체합니다. 그렇다고 패턴이 쓸모없다는 건 아니에요. 패턴이 푸는 문제(인자 지옥, 생성 통제)는 언어를 가리지 않고 존재합니다. 다만 그 해법의 모습이 언어 기능에 따라 달라질 뿐이죠. 그래서 "이 언어에선 이 패턴이 문법으로 녹아 있나?"를 묻는 게, 패턴을 신성시하지 않고 도구로 보는 눈입니다.

🎯 면접에선 이렇게 나온다

"빌더가 푸는 문제는 '인자가 많을 때 이름으로 안전하게 조립하기'인데, 파이썬은 키워드 인자로 그걸 언어가 기본 제공해서 빌더가 거의 필요 없습니다. 패턴이 푸는 문제는 언어를 안 가리지만, 해법의 모습은 언어 기능에 따라 달라져요. GoF 패턴 일부는 언어의 빈틈을 메우는 우회로라는 시각을 갖고 있습니다."

💡 실무에선

자바에서도 필드가 두셋이고 다 필수면 빌더 대신 record를 먼저 떠올리세요. 빌더는 "선택 항목이 많을 때" 꺼내는 카드입니다. 도구를 문제 크기에 맞추는 게 핵심이에요.

🤔 [생각해볼 주제 3] 스프링 빈은 싱글톤인데 왜 안티패턴이 아닐까?

문제 상황 요약

싱글톤은 전역 상태·강결합·테스트 곤란 때문에 안티패턴으로 재평가됐습니다. 그런데 스프링 같은 프레임워크는 객체를 기본적으로 하나만 만들어 씁니다. 둘 다 "인스턴스가 하나"인데, 왜 하나는 안티패턴이고 하나는 권장될까요?

튜터의 가이드 및 해설

답은 "하나만 둔다"와 "전역에서 직접 꺼내 쓴다"를 분리하는 데 있습니다. 우리가 싱글톤을 안티패턴이라 부른 건 "인스턴스가 하나"라서가 아니에요. 코드가 getInstance()전역에 직접 손을 뻗는 그 방식 때문이었죠.

전통적 싱글톤은 두 가지를 한 덩어리로 묶습니다. ① 인스턴스를 하나로 보장, ② 그 하나를 전역에서 아무나 직접 꺼냄. 문제를 일으키는 건 ②예요. 코드가 전역 객체에 직접 의존하니 강결합·숨은 의존·테스트 곤란이 따라옵니다. 프레임워크가 객체를 하나만 관리하는 건 ①만 취하고 ②를 버린 겁니다. 그 하나뿐인 객체를 필요한 곳에 주입해 주거든요. 코드는 전역에서 꺼내는 게 아니라 생성자로 받기만 하니, 의존이 드러나고 테스트에선 가짜로 갈아 끼울 수 있어요. 즉 "단일 인스턴스"의 이점은 누리되 "전역 접근"의 해악은 없는, 우리가 과제 3에서 직접 만든 바로 그 구조입니다. 그 본격적인 적용은 후속 spring-boot 과목에서 만나게 됩니다.

🎯 면접에선 이렇게 나온다

"둘 다 인스턴스는 하나지만 접근 방식이 정반대입니다. 안티패턴인 싱글톤은 코드가 getInstance()로 전역을 직접 꺼내 강결합·테스트 곤란을 부르고, 프레임워크의 싱글톤은 그 하나를 주입으로 나눠 줘 의존이 드러나고 교체가 쉽습니다. 문제는 '하나라는 것'이 아니라 '전역으로 직접 접근하느냐'예요."

💡 실무에선

진짜 하나여야 하는 자원(커넥션 풀·공유 캐시)도 전역에서 getInstance()로 꺼내지 말고, 시작 지점에서 하나 만들어 주입받게 하세요. "단일 인스턴스"와 "전역 접근"은 별개의 결정입니다.

전체 목록 클린코드