Table of contents
new가 문제가 되는 순간- Factory Method — 서브클래스에 위임하기
- Abstract Factory — 관련 객체를 묶어서 만들기
- Builder — 복잡한 객체를 단계별로 조립하기
- 세 패턴 비교
- 실무에서 흔히 만나는 형태
- 생성 패턴을 고를 때
new가 문제가 되는 순간
객체를 만들 때 new를 쓰는 건 자연스러운 일이다. new ArrayList<>(), new UserService() — 프로그래밍을 처음 배울 때부터 해온 것이다. 그런데 코드베이스가 커지면 new가 불편해지는 지점이 생긴다.
public class OrderService {
public void placeOrder(OrderRequest request) {
// 알림 발송
Notification notification = new EmailNotification(); // 여기가 문제
notification.send(request.getUserEmail(), "주문이 완료되었습니다.");
}
}
이 코드에서 OrderService는 EmailNotification이라는 구체 클래스를 직접 안다. SMS로 바꾸고 싶으면? new SmsNotification()으로 수정해야 한다. 사용자 설정에 따라 이메일과 SMS를 동적으로 골라야 한다면? if-else가 들어간다. 테스트에서 실제 이메일을 보내지 않고 가짜 알림을 넣고 싶다면? OrderService의 코드를 건드려야 한다.
이 문제의 뿌리는 생성과 사용이 한 곳에 섞여 있다는 것이다. 객체를 만드는 책임과 객체를 쓰는 책임을 분리하면 결합이 느슨해진다. 이 분리를 체계적으로 하는 패턴이 Factory Method, Abstract Factory, Builder다.
Factory Method — 서브클래스에 위임하기
패턴의 구조
Factory Method 패턴은 객체 생성을 서브클래스에 위임한다. 상위 클래스는 무엇을 만들지의 인터페이스만 정의하고, 어떤 구체 클래스를 쓸지는 하위 클래스가 결정하는 방식이다.
classDiagram
class NotificationFactory {
+createNotification()* Notification
+notify(message: String)
}
class EmailNotificationFactory {
+createNotification() Notification
}
class SmsNotificationFactory {
+createNotification() Notification
}
class Notification {
<<interface>>
+send(to: String, message: String)
}
class EmailNotification {
+send(to: String, message: String)
}
class SmsNotification {
+send(to: String, message: String)
}
NotificationFactory <|-- EmailNotificationFactory
NotificationFactory <|-- SmsNotificationFactory
Notification <|.. EmailNotification
Notification <|.. SmsNotification
EmailNotificationFactory ..> EmailNotification : creates
SmsNotificationFactory ..> SmsNotification : creates
NotificationFactory ..> Notification : uses
NotificationFactory는 createNotification()이라는 팩토리 메서드를 추상으로 선언한다. notify()는 이 메서드를 호출해서 객체를 얻고, 실제 알림을 보낸다. 어떤 Notification 구현체가 돌아오는지는 관심 밖이다.
코드로 보자
팩토리 상위 클래스를 정의한다.
public abstract class NotificationFactory {
// 팩토리 메서드 — 서브클래스가 결정
protected abstract Notification createNotification();
// 사용 로직은 상위 클래스에 있다
public void notify(String to, String message) {
Notification notification = createNotification();
notification.send(to, message);
}
}
이 클래스가 하는 일은 notify()에서 createNotification()을 호출해 알림 객체를 받고, 그 객체의 send()를 실행하는 것이다. 어떤 알림인지는 모른다.
이메일 팩토리와 SMS 팩토리를 하나씩 만든다.
public class EmailNotificationFactory extends NotificationFactory {
@Override
protected Notification createNotification() {
return new EmailNotification("smtp.company.com", 587);
}
}
public class SmsNotificationFactory extends NotificationFactory {
@Override
protected Notification createNotification() {
return new SmsNotification("API_KEY_HERE");
}
}
클라이언트 코드는 팩토리만 갈아끼우면 된다.
NotificationFactory factory = resolveFactory(userPreference); // 런타임 결정
factory.notify(user.getEmail(), "주문 완료");
resolveFactory()가 사용자 설정에 따라 적절한 팩토리를 반환한다. 이메일이든 SMS든 카카오톡이든, 클라이언트 코드는 바뀌지 않는다.
Factory Method의 핵심 가치
이 패턴이 해결하는 문제를 정리하면 세 가지다.
- 구체 클래스 의존 제거:
OrderService가EmailNotification을 직접 아는 대신,Notification인터페이스만 안다. - 확장에 열려 있는 구조: 새 알림 수단이 추가될 때 기존 코드를 안 건드리고 팩토리 클래스를 하나 더 만들면 된다. OCP(Open-Closed Principle, 개방-폐쇄 원칙)를 따르는 셈이다.
- 생성 로직 캡슐화: 이메일 알림을 만들려면 SMTP 서버 주소와 포트가 필요하고, SMS는 API 키가 필요하다. 이 세부사항이 팩토리 안에 감춰진다.
Abstract Factory — 관련 객체를 묶어서 만들기
Factory Method와 뭐가 다른가
Factory Method는 하나의 객체를 서브클래스에서 생성한다. Abstract Factory는 관련된 객체 군(family)을 한 번에 생성하는 인터페이스를 제공한다. 이메일 알림이 아니라 이메일 알림 + 이메일 로거 + 이메일 템플릿처럼, 서로 연관된 객체 세트를 일관되게 만들어야 할 때 쓴다.
UI 테마를 예로 들어보자. 라이트 테마에선 밝은 버튼과 밝은 텍스트필드를, 다크 테마에선 어두운 버튼과 어두운 텍스트필드를 만들어야 한다. 버튼과 텍스트필드는 서로 다른 객체지만, 같은 테마에 속해야 한다는 제약이 있다. 밝은 버튼 + 어두운 텍스트필드 조합은 의도하지 않은 것이다.
코드로 보자 — 크로스 플랫폼 UI 컴포넌트
먼저 제품(Product) 인터페이스를 정의한다.
public interface Button {
void render();
}
public interface TextField {
void render();
}
각 플랫폼별 구현체를 만든다.
// 윈도우 계열
public class WindowsButton implements Button {
@Override
public void render() { System.out.println("[Windows Button]"); }
}
public class WindowsTextField implements TextField {
@Override
public void render() { System.out.println("[Windows TextField]"); }
}
// macOS 계열
public class MacButton implements Button {
@Override
public void render() { System.out.println("[Mac Button]"); }
}
public class MacTextField implements TextField {
@Override
public void render() { System.out.println("[Mac TextField]"); }
}
Abstract Factory를 선언한다. 이 인터페이스가 핵심이다.
public interface UIFactory {
Button createButton();
TextField createTextField();
}
이 인터페이스가 버튼과 텍스트필드를 한 세트로 만드는 규약을 정의한다.
플랫폼별 팩토리를 구현한다.
public class WindowsUIFactory implements UIFactory {
@Override
public Button createButton() { return new WindowsButton(); }
@Override
public TextField createTextField() { return new WindowsTextField(); }
}
public class MacUIFactory implements UIFactory {
@Override
public Button createButton() { return new MacButton(); }
@Override
public TextField createTextField() { return new MacTextField(); }
}
클라이언트는 UIFactory만 받으면 된다.
public class Application {
private final Button button;
private final TextField textField;
public Application(UIFactory factory) {
this.button = factory.createButton();
this.textField = factory.createTextField();
}
public void render() {
button.render();
textField.render();
}
}
// 사용
UIFactory factory = isWindows() ? new WindowsUIFactory() : new MacUIFactory();
Application app = new Application(factory);
app.render();
Application은 버튼과 텍스트필드가 Windows인지 Mac인지 전혀 모른다. 팩토리가 보장하는 것은 같은 계열의 객체가 함께 만들어진다는 것이다. 윈도우 팩토리에서 Mac 텍스트필드가 튀어나올 일은 구조적으로 불가능하다.
언제 쓰고 언제 안 쓰는가
Abstract Factory는 관련 객체 군이 존재할 때만 의미가 있다. 버튼 하나만 만들면 되는 상황에서 Abstract Factory를 꺼내면 과설계가 된다. 반대로, DB 연결 + 쿼리 빌더 + 트랜잭션 매니저처럼 벤더마다 세트로 바뀌어야 하는 객체가 있다면, 이 패턴이 불일치를 구조적으로 막아준다.
Builder — 복잡한 객체를 단계별로 조립하기
왜 생성자가 불편해지는가
필드가 많은 객체를 new로 만들면 매개변수 순서를 외워야 한다.
// 매개변수가 뭘 뜻하는지 호출부에서 알 수 없다
HttpRequest request = new HttpRequest(
"GET", "https://api.example.com", null, 30, true, false, null
);
이 코드만 보고 30이 타임아웃인지 재시도 횟수인지 알 수 없다. true와 false가 각각 뭘 켜고 끄는지도 모른다. 필드 하나를 추가하면 기존 모든 호출부가 깨진다. 이런 상태를 텔레스코핑 생성자 안티패턴(Telescoping Constructor Anti-Pattern)이라 부른다.
패턴의 구조
Builder 패턴은 복잡한 객체의 생성 과정을 단계별로 분리한다. 각 단계는 메서드 호출로 표현되고, 마지막에 build()를 호출하면 완성된 객체가 나온다.
flowchart LR
A["Builder 생성"] --> B["method(url)"]
B --> C["timeout(30)"]
C --> D["followRedirects(true)"]
D --> E["header(key, value)"]
E --> F["build()"]
F --> G["HttpRequest 객체"]
style G fill:#5ca45c
코드로 보자 — HTTP 요청 빌더
먼저 완성될 HttpRequest 클래스를 정의한다.
public class HttpRequest {
private final String method;
private final String url;
private final Map<String, String> headers;
private final String body;
private final int timeoutSeconds;
private final boolean followRedirects;
// 패키지 프라이빗 — Builder만 접근 가능
HttpRequest(Builder builder) {
this.method = builder.method;
this.url = builder.url;
this.headers = Map.copyOf(builder.headers);
this.body = builder.body;
this.timeoutSeconds = builder.timeoutSeconds;
this.followRedirects = builder.followRedirects;
}
// getter 생략
public static Builder builder(String method, String url) {
return new Builder(method, url);
}
public static class Builder {
// 필수
private final String method;
private final String url;
// 선택 (기본값 있음)
private Map<String, String> headers = new HashMap<>();
private String body = null;
private int timeoutSeconds = 30;
private boolean followRedirects = true;
private Builder(String method, String url) {
this.method = method;
this.url = url;
}
public Builder header(String key, String value) {
this.headers.put(key, value);
return this;
}
public Builder body(String body) {
this.body = body;
return this;
}
public Builder timeout(int seconds) {
this.timeoutSeconds = seconds;
return this;
}
public Builder followRedirects(boolean follow) {
this.followRedirects = follow;
return this;
}
public HttpRequest build() {
// 유효성 검증도 여기서
if (method == null || url == null) {
throw new IllegalStateException("method와 url은 필수");
}
return new HttpRequest(this);
}
}
}
이 빌더의 특징은 필수 파라미터(method, url)는 생성자에서 강제하고, 나머지는 메서드 체이닝으로 선택적으로 지정하게 한다는 것이다.
사용하는 코드는 훨씬 읽기 좋다.
HttpRequest request = HttpRequest.builder("POST", "https://api.example.com/users")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.body("{\"name\": \"김개발\"}")
.timeout(10)
.build();
각 설정이 무엇인지 메서드 이름이 말해준다. 필요 없는 설정은 생략하면 기본값이 적용된다.
Kotlin의 named arguments가 Builder를 대체하는가
Kotlin은 이름 붙인 인자(named arguments)와 기본값(default values)을 언어 차원에서 지원한다. 같은 HTTP 요청을 Kotlin 데이터 클래스로 표현해보자.
data class HttpRequest(
val method: String,
val url: String,
val headers: Map<String, String> = emptyMap(),
val body: String? = null,
val timeoutSeconds: Int = 30,
val followRedirects: Boolean = true
)
// 사용
val request = HttpRequest(
method = "POST",
url = "https://api.example.com/users",
headers = mapOf(
"Content-Type" to "application/json",
"Authorization" to "Bearer $token"
),
body = """{"name": "김개발"}""",
timeoutSeconds = 10
)
Builder 패턴이 주는 이점 대부분이 언어 기능으로 해결된다. 매개변수 이름이 보이고, 기본값이 있고, 순서를 마음대로 바꿀 수 있다. Kotlin 프로젝트에서 단순 DTO(Data Transfer Object)에 Builder를 만드는 건 불필요한 보일러플레이트다.
그렇다면 Kotlin에서 Builder는 완전히 불필요한가? 그렇지는 않다. 다음 상황에서는 Builder가 여전히 가치 있다.
- 생성 과정에 단계별 검증이 필요할 때:
build()시점에 전체 유효성을 검사하거나, 단계별로 불변 조건을 확인해야 하는 경우 - 생성 과정 자체가 복잡한 로직일 때: 단순히 필드를 채우는 게 아니라, 한 필드의 값이 다른 필드의 기본값에 영향을 주는 경우
- Java 코드와의 상호운용: Kotlin 클래스를 Java에서 쓸 때, named arguments를 활용할 수 없으니 Builder를 제공하는 게 낫다
Kotlin에서 Builder를 쓸 때는 apply 스코프 함수로 간결하게 만들 수 있다.
class QueryBuilder {
private var table: String = ""
private val conditions = mutableListOf<String>()
private var limit: Int? = null
fun from(table: String) = apply { this.table = table }
fun where(condition: String) = apply { conditions.add(condition) }
fun limit(n: Int) = apply { this.limit = n }
fun build(): String {
require(table.isNotBlank()) { "테이블명은 필수" }
val query = StringBuilder("SELECT * FROM $table")
if (conditions.isNotEmpty()) {
query.append(" WHERE ${conditions.joinToString(" AND ")}")
}
limit?.let { query.append(" LIMIT $it") }
return query.toString()
}
}
val sql = QueryBuilder()
.from("users")
.where("age > 20")
.where("status = 'active'")
.limit(100)
.build()
이 빌더가 하는 일은 SQL 쿼리를 조건별로 조립하는 것이다. 단순 필드 대입이 아니라 조건이 누적되는 구조이므로, named arguments로 대체할 수 없다.
세 패턴 비교
| 기준 | Factory Method | Abstract Factory | Builder |
|---|---|---|---|
| 해결하는 문제 | 어떤 클래스를 만들지 위임 | 관련 객체 군을 일관되게 생성 | 복잡한 객체를 단계별로 조립 |
| 핵심 구조 | 상속 (팩토리 서브클래스) | 인터페이스 (팩토리 인터페이스) | 메서드 체이닝 |
| 만드는 객체 수 | 하나 | 여러 개 (세트) | 하나 (복잡한) |
| 클라이언트가 아는 것 | 팩토리 상위 클래스 | 팩토리 인터페이스 | 빌더 클래스 |
| 확장 방법 | 새 팩토리 서브클래스 추가 | 새 팩토리 구현체 추가 | 빌더에 메서드 추가 |
| Java 실무 예시 | Calendar.getInstance() | JDBC DriverManager | StringBuilder, Lombok @Builder |
세 패턴은 경쟁 관계가 아니라 적용 범위가 다르다. 알림 발송처럼 단일 객체의 구체 타입을 감추려면 Factory Method, DB 드라이버 + 커넥션 + 스테이트먼트처럼 묶음이 필요하면 Abstract Factory, HTTP 요청처럼 설정이 많은 단일 객체를 편하게 만들려면 Builder를 꺼내면 된다.
실무에서 흔히 만나는 형태
정적 팩토리 메서드
GoF의 Factory Method는 상속 기반이지만, 실무에서 가장 자주 보는 변형은 정적 팩토리 메서드(Static Factory Method)다. 조슈아 블로크(Joshua Bloch)의 Effective Java에서 강조한 기법으로, 생성자 대신 이름 있는 static 메서드를 제공한다.
// 생성자 — 의도가 불명확
Boolean b1 = new Boolean(true);
// 정적 팩토리 메서드 — 의도가 이름에 드러남
Boolean b2 = Boolean.valueOf(true);
List<String> empty = List.of();
Optional<User> user = Optional.ofNullable(findById(id));
정적 팩토리 메서드의 장점은 명확하다.
- 이름이 있다:
of(),valueOf(),from(),getInstance(),newInstance()등 의도를 표현할 수 있다 - 호출할 때마다 새 객체를 만들 의무가 없다: 캐싱된 인스턴스를 반환할 수 있다 (
Boolean.valueOf(true)는 항상 같은 객체를 돌려준다) - 반환 타입의 하위 타입을 반환할 수 있다: 인터페이스를 반환 타입으로 선언하고, 구현체는 숨긴다
GoF Factory Method와 이름이 비슷해서 혼동되지만, 상속 구조가 아닌 단일 클래스 내 static 메서드라는 점이 다르다. 실무에선 이 변형이 훨씬 빈번하게 등장한다.
Lombok의 @Builder
Java 프로젝트에서 Builder 패턴을 매번 수작업으로 구현하는 건 번거롭다. Lombok의 @Builder 어노테이션을 붙이면 컴파일 타임에 Builder 코드가 자동 생성된다.
@Builder
public class UserProfile {
private final String name;
private final String email;
@Builder.Default
private final String role = "USER";
@Builder.Default
private final boolean active = true;
}
// 자동 생성된 빌더 사용
UserProfile profile = UserProfile.builder()
.name("김개발")
.email("dev@example.com")
.build();
이 어노테이션이 하는 일은 UserProfileBuilder 내부 클래스를 자동으로 만들어주는 것이다. @Builder.Default는 빌더에서 값을 지정하지 않았을 때 사용할 기본값을 선언한다.
편리하지만 주의할 점도 있다. @Builder는 필수 필드를 강제하지 않는다. name을 빼먹어도 컴파일이 되고, 런타임에 null이 들어간다. 필수값 검증이 필요하면 build() 메서드를 직접 오버라이드하거나, Kotlin의 require/check를 쓰는 방식이 더 안전하다.
생성 패턴을 고를 때
객체 생성 관련 고민이 생겼을 때 물어볼 질문 세 가지다.
어떤 구체 클래스를 만들지가 런타임에 결정되는가? → Factory Method 또는 정적 팩토리 메서드- 서로 연관된 객체들이 세트로 바뀌어야 하는가? → Abstract Factory
- 하나의 객체인데 설정이 많고 조합이 다양한가? → Builder
단순한 경우에는 그냥 new를 쓰는 게 맞다. 패턴은 문제가 있을 때 꺼내는 도구이지, 모든 생성에 적용할 의식이 아니다. new ArrayList<>()에 Factory를 감싸는 건 과설계의 전형이다. 코드에서 실제로 결합이 문제가 되는 지점을 찾고, 그 지점에만 정확히 패턴을 적용하는 것이 실용적인 접근이 된다.
다음 편에서는 기존 객체를 감싸서 기능을 더하거나 접근을 제어하는 두 패턴을 다룬다. 상속 없이 기능을 확장하는 Decorator, 접근 제어와 지연 로딩을 맡는 Proxy. 구조가 비슷해서 헷갈리기 쉬운 이 둘의 의도 차이를 파고든다.




Loading comments...