생성자 대신 정적 팩터리 메서드를 고려하라
아이템 1. 생성자 대신 정적 팩터리 메서드를 고려하라
정적 팩터리 메서드는 생성자를 대체하는 문법이 아니라, 객체 생성의 의미를 API로 끌어올리는 설계 방식이다. 생성자는 호출자가 new로 무조건 새로운 인스턴스를 만든다는 의도를 고정한다. 반면 정적 팩터리는 다음을 선언적으로 표현하게 한다.
- 지금 이 호출이
새 인스턴스 생성인지,재사용인지,선택인지,검증인지 - 반환 타입을 인터페이스로 숨겨 구현체를 감추고 교체 가능한지
- 입력 정규화, 캐싱, 풀링, 프록시 반환, 실패/대체 정책까지 포함할지
이게 왜 중요하냐면, 실무에서 객체 생성을 둘러싼 문제는 보통 다음으로 귀결된다.
- 호출부가 객체 생성 정책을 알지 못해서 성능/메모리/동시성 문제를 만든다.
- 구현체에 결합되어 테스트/확장이 어렵다.
- 생성 시 유효성 검증이 분산되어 “조용히 잘못된 상태”가 시스템 내부로 흘러 들어간다.
- 런타임 환경(플러그인, SPI, 리플렉션, 프록시)이 요구될 때 생성자만으로는 유연성이 부족하다.
정적 팩터리는 이걸 생성을 통해 해결하는 게 아니라, 생성 자체를 정책으로 승격시켜 해결한다.
1) 이름이 생기는 순간, 코드가 문장이 된다
생성자는 이름을 가질 수 없다. 그래서 의미를 파라미터/오버로딩으로 떠넘긴다.
1
2
new BigInteger("10");
new BigInteger("10", 16);
여기서 16의 의미를 모르면 읽는 사람이 틀린 코드를 만든다. 반면 정적 팩터리는 이름으로 의미를 고정한다.
1
2
BigInteger.valueOf(10);
BigInteger.valueOf(0x10); // 의미가 명확하지 않으면 이런 게 위험
실무에서 가장 강력한 건 의미가 명확한 이름을 갖는 것이다.
of(...): 입력으로부터 만들기(가장 일반)from(...): 변환의 의미 강조(다른 타입에서 넘어옴)valueOf(...): 캐싱/재사용 가능성을 암시getInstance(...): 인스턴스 조회/획득(풀/캐시/싱글턴 계열)newInstance(...): 매번 새로 만들기(반대로 ‘재사용 안 함’을 강조)
이름만으로 성능 모델이 결정되진 않지만, 팀이 공통 규칙을 가지기 시작한다. 코드 리뷰에서 왜 newInstance인데 캐시 쓰냐? 같은 질문이 가능해진다. 생성자에는 이런 토론 포인트가 없다.
2) 생성자 오버로딩 지옥을 정적 팩터리로 끊는 법
실무에서 자주 나오는 악취는 파라미터 타입이 같아서 의미가 뒤섞이는 생성자다.
1
2
3
4
public class Money {
public Money(long amount, String currency) { ... }
public Money(String currency, long amount) { ... } // 호출부가 실수하면 끝
}
정적 팩터리는 호출부에서의 실수 가능성을 구조적으로 낮춘다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public final class Money {
private final long amount;
private final Currency currency;
private Money(long amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public static Money ofWon(long amount) {
return new Money(amount, Currency.KRW);
}
public static Money of(long amount, Currency currency) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
return new Money(amount, currency);
}
public static Money parse(String text) {
// "10_000 KRW" 같은 포맷 파싱
...
}
}
생성자 기반이면 호출부가 new Money(…)로 무지성 생성하고, 검증/파싱이 흩어진다. 정적 팩터리는 어떻게 만들지를 타입 내부로 끌고 들어온다. 이게 바로 생성 책임의 집중이다.
3) 캐싱과 재사용: ‘생성 비용’이 아니라 ‘GC 비용’이 핵심이다
정적 팩터리의 대표 장점은 인스턴스를 재사용할 수 있다인데, 이걸 단순히 new 비용을 줄인다고 이해하면 반쪽이다. JVM에서 더 중요한 건 GC 압력과 힙 단편화, p95/p99 지연이다.
3-1. 값 객체(Value Object)에서 캐시는 특히 강력하다
값 객체는 동일 값이면 동일 의미다. 그래서 캐싱이 자연스럽다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class UserId {
private static final Map<Long, UserId> CACHE = new java.util.concurrent.ConcurrentHashMap<>();
private final long value;
private UserId(long value) { this.value = value; }
public static UserId of(long value) {
if (value <= 0) throw new IllegalArgumentException("id must be positive");
// 캐시 전략: 너무 큰 id까지 다 캐시하면 메모리 누수처럼 보일 수 있으니
// 실제론 범위를 제한하거나 LRU 캐시를 쓴다.
return CACHE.computeIfAbsent(value, UserId::new);
}
public long value() { return value; }
}
이 설계는 불필요한 객체 생성을 줄이는 수준을 넘어, 시스템에서 UserId가 식별자처럼 사용되는 모든 곳에서 객체 수를 줄여준다. 객체 수가 줄면 GC의 mark 대상이 줄고, 오래 살아남는 그래프도 덜 복잡해진다.
3-2. 무조건 캐싱하면 안 되는 이유: 캐시도 메모리다
정적 팩터리를 도입하면 캐시를 붙이고 싶은 유혹이 생긴다. 하지만 캐시는 그 자체가 생명주기 관리다. 특히 id처럼 단조 증가하는 값을 무한 캐싱하면, 사실상 메모리 누수와 똑같이 보일 수 있다.
그래서 실무에서는 대개 이렇게 간다.
- 작고 제한된 값 영역(예: enum 성격, 작은 범위 정수)은 강한 캐싱
- 큰 영역(예: 유저 id)은 캐시 대상이 아님이 기본
- 캐시가 필요하면 Caffeine 같은 검증된 라이브러리로 TTL/LRU 등을 명시
정적 팩터리의 핵심은 캐싱이 가능하다지 캐싱해야 한다가 아니다. 다만 정적 팩터리를 쓰면 캐싱 정책을 타입 내부에서 일관되게 적용할 수 있다.
4) 반환 타입을 바꿀 수 있다: 구현체 숨기기 = 설계 수명 연장
정적 팩터리가 진짜 강력해지는 순간은 반환 타입을 인터페이스로 고정하고 구현체를 바꿀 수 있을 때이다.
4-1. 인터페이스 반환으로 교체 가능성 만들기
1
2
3
public interface RateLimiter {
boolean tryAcquire(String key);
}
처음에는 단순 구현으로 시작하자.
1
2
3
final class InMemoryRateLimiter implements RateLimiter {
...
}
정적 팩터리로 생성 정책을 묶는다.
1
2
3
4
5
6
7
8
9
10
11
public final class RateLimiters {
private RateLimiters() {}
public static RateLimiter inMemory(int permitsPerSecond) {
return new InMemoryRateLimiter(permitsPerSecond);
}
public static RateLimiter redis(String redisUrl, int permitsPerSecond) {
return new RedisRateLimiter(redisUrl, permitsPerSecond);
}
}
이 구조가 있으면, 나중에 로컬은 InMemory, 운영은 Redis로 바꾸는 게 생성 코드 한 군데로 끝난다. 생성자를 직접 호출하는 코드가 퍼져 있으면 이게 불가능하다.
4-2. 구현체를 바꾸면서 API는 그대로 두는 전략
정적 팩터리로 반환 타입을 감추면, 구현체를 이런 식으로 갈아끼울 수 있다.
- 단순 구현 → 캐싱 구현 → 동기화 개선 구현 → 비동기/배치 구현
- record 기반 → 클래식 클래스 → 프록시/데코레이터 구성
- 라이브러리 변경(CGLIB → ByteBuddy) 등
호출부가 구현체를 모르기 때문에 리팩터링 내성이 생긴다.
5) 열거 타입(enum)과 정적 팩터리: 닫힌 집합을 운영 안전성으로 바꾸기
백엔드 운영을 하다 보면, 문자열 상태값/타입값이 얼마나 쉽게 사고를 만드는지 알 것이다. 조금만 틀려도 조용히 잘못된 분기를 타고, 장애가 난다.
5-1. from(String) 팩터리로 입력 정규화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum PaymentStatus {
READY, CONFIRMED, CANCELED;
public static PaymentStatus from(String raw) {
if (raw == null) throw new IllegalArgumentException("status is null");
String s = raw.trim().toUpperCase();
return switch (s) {
case "READY" -> READY;
case "CONFIRMED" -> CONFIRMED;
case "CANCELED", "CANCELLED" -> CANCELED; // 외부 시스템 표기차 흡수
default -> throw new IllegalArgumentException("unknown status: " + raw);
};
}
}
이건 단순 편의가 아니라 경계에서 오염을 막는 방역이다. 외부 PG, 외부 API, CSV, 관리자 입력은 항상 더럽다. from()이 있으면 더러움을 내부로 들이지 않는다.
5-2. enum 자체를 전략으로 만들기
정적 팩터리와 enum은 함께 전략 선택을 깔끔하게 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum DiscountType {
NONE {
@Override public long apply(long price) { return price; }
},
VIP {
@Override public long apply(long price) { return Math.max(0, price - 1000); }
};
public abstract long apply(long price);
public static DiscountType from(String raw) {
return valueOf(raw.trim().toUpperCase());
}
}
이렇게 하면 switch가 흩어지지 않는다. 정책이 타입에 붙는다.
6) 서비스 제공자 프레임워크(SPF): 플러그인 아키텍처의 핵심
실무에서 나중에 확장될 것 같은 기능은 거의 SPI 형태로 간다. 예를 들어 결제, 인증, 파일 스토리지(S3/로컬/MinIO), 메시지큐 등. 정적 팩터리는 SPF에서 “서비스를 얻는 API” 역할을 한다. 기본 구조를 짚자.
- 서비스 인터페이스:
Storage - 제공자 구현체:
S3Storage,LocalStorage - 제공자 등록:
META-INF/services/... - 로딩:
ServiceLoader - 선택:
정적 팩터리(혹은 Registry)
6-1. ServiceLoader 예시
1
2
3
4
public interface StorageProvider {
String name();
Storage create(StorageConfig config);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public final class Storages {
private Storages() {}
public static Storage of(String providerName, StorageConfig config) {
ServiceLoader<StorageProvider> loader = ServiceLoader.load(StorageProvider.class);
for (StorageProvider p : loader) {
if (p.name().equalsIgnoreCase(providerName)) {
return p.create(config);
}
}
throw new IllegalArgumentException("unknown provider: " + providerName);
}
}
정적 팩터리 없으면 호출부에서 ServiceLoader를 직접 돌리게 되고, 구현체 선택 로직이 퍼진다. 퍼지는 순간 유지보수는 끝이다.
7) 리플렉션: 정적 팩터리와 함께 쓰면 독을 약으로 바꿀 수 있다
리플렉션은 보통 동적으로 클래스를 만들고 싶다에서 시작한다. 하지만 리플렉션을 호출부에서 남발하면 다음이 터진다.
- 컴파일 타임 안전성 상실(오타가 런타임)
- IDE 리팩터링 영향 없음(문자열은 안 바뀜)
- 보안/권한 문제
- 성능(클래스 로딩/접근) 및 예외 처리 복잡도
- 정적 팩터리를 쓰면 리플렉션을 한 점에 가둘 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public final class Instances {
private Instances() {}
public static <T> T newInstance(String className, Class<T> type) {
try {
Class<?> clazz = Class.forName(className);
Object obj = clazz.getDeclaredConstructor().newInstance();
return type.cast(obj);
} catch (Exception e) {
throw new IllegalArgumentException("cannot create: " + className, e);
}
}
}
이렇게라도 통로가 한 곳이면, 추후에 ServiceLoader로 대체하거나, 화이트리스트를 걸거나, 로깅/모니터링을 붙일 수 있다.
반대로 호출부가 여기저기서 Class.forName()을 하게 되면 통제가 불가능해진다.
8) 정적 팩터리의 단점도 실무적으로 이해해야 한다
정적 팩터리는 만능이 아니다. 단점은 다음처럼 설계 비용으로 돌아온다.
8-1. 상속/프레임워크 호환성
JPA 엔티티는 기본 생성자가 필요하다. 프록시 생성에도 필요할 수 있다. 이런 경우 정적 팩터리만 강제하면 충돌한다. 그래서 실무에서는 도메인 모델(불변)과 엔티티(프레임워크 친화)를 분리하거나, 엔티티 내부에서 생성 규칙을 우회하지 않도록 패턴을 잡는다.
예: 엔티티는 protected 기본 생성자 + 정적 팩터리로 “의도된 생성”만 열기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
protected Member() {} // JPA용
private Member(String name) { this.name = name; }
public static Member of(String name) {
if (name == null || name.isBlank())
throw new IllegalArgumentException("name required");
return new Member(name);
}
}
8-2. 무조건 of() 남발의 위험
팀이 정적 팩터리 맛을 보면 모든 타입에 of()를 찍고 싶어진다. 그런데 무의미한 of()는 오히려 혼란을 만든다. 의미가 없으면 생성자로 두는 게 더 낫다. 정적 팩터리는 생성 정책이 있을 때 빛난다.
- 검증이 필요하다
- 캐싱/재사용이 있다
- 구현체를 숨겨야 한다
- 입력 정규화가 필요하다
- 실패/대체 전략이 있다
- 프록시/데코레이터를 조립한다
이 중 하나도 없다면, 굳이 억지로 정적 팩터리로 갈 이유는 약하다.
9) 실무 설계 템플릿: 정적 팩터리를 규칙으로 굳히기
정적 팩터리를 팀 규칙으로 굳히고 싶다면, 아래처럼 “의미별 네이밍 룰”을 잡는 게 제일 강하다.
- of(…): 가장 표준 생성(검증 포함)
- from(…): 다른 타입/표현에서 변환(파싱/매핑)
- valueOf(…): 캐시/재사용 가능성이 있음(없어도 되지만 암시)
- getInstance(…): 단일/풀에서 가져오기(싱글턴/풀)
- newInstance(…): 매번 새로(테스트에서 특히 유용)
예를 들어, 같은 타입이라도 의도가 다르면 서로 다른 팩터리를 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Token {
private final String value;
private Token(String value) { this.value = value; }
public static Token fromHeader(String header) {
// "Bearer xxx" 파싱
...
}
public static Token ofRaw(String raw) {
// raw 정규화/검증
...
}
}
이렇게 되면 호출부에서 지금 내가 뭘 하는지가 명확해진다.
10) 정적 팩터리와 테스트: 생성 정책이 테스트성을 만든다
실무에서 가장 큰 이득은 여기서 나온다. 생성자가 퍼져 있으면 테스트에서 객체 만들기가 귀찮아져서 대충 아무 값을 넣는다. 그 순간 테스트는 의미를 잃는다.
정적 팩터리를 두면, 테스트에서 유효한 생성을 강제할 수 있다.
1
2
3
4
5
@Test
void member_name_is_required() {
assertThatThrownBy(() -> Member.of(" "))
.isInstanceOf(IllegalArgumentException.class);
}
테스트가 검증 규칙 자체를 문서로 만든다. 생성자만 있으면 이런 규칙이 흩어진다.
11) 플라이웨이트(Flyweight) 패턴: 재사용이 설계로 고정되는 순간
정적 팩터리를 쓰면 캐싱/재사용 가능이 열리는데, 그걸 패턴으로 설명한 게 플라이웨이트다. 핵심은 단순하다.
같은 의미의 객체를 여러 개 만들지 말고 하나를 공유해서 객체 수 자체를 줄여라
이건 new 비용을 아끼는 게 아니라, GC 비용과 힙 그래프 복잡도를 줄인다.
11-1. 언제 플라이웨이트가 이득인가
다음 조건이 강하면 플라이웨이트가 이득이다.
- 객체가 많이 만들어진다(요청당 수천~수만).
- 객체가 작거나 중간 크기인데 “엄청 많이” 생긴다.
- 동일 값이 반복된다(중복이 많다).
- 객체가 불변(immutable)이다(공유해도 안전).
예를 들어, 로그/이벤트 처리에서 CountryCode, Currency, Status 같은 값이 수십만 번 반복된다면 플라이웨이트가 이득이다.
11-2. 위험 포인트: 캐시 = 생명주기를 잊으면 누수처럼 된다
플라이웨이트 구현을 대충 하면 메모리가 계속 쌓인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class Email {
private static final java.util.concurrent.ConcurrentHashMap<String, Email> CACHE = new java.util.concurrent.ConcurrentHashMap<>();
private final String value;
private Email(String value) { this.value = value; }
public static Email of(String raw) {
String v = normalize(raw);
return CACHE.computeIfAbsent(v, Email::new);
}
private static String normalize(String raw) {
if (raw == null) throw new IllegalArgumentException("email null");
return raw.trim().toLowerCase();
}
}
이건 이메일이 무한히 다양하면 캐시가 무한히 커진다. 즉, 플라이웨이트가 아니라 무한 메모리 보관소가 된다. 실무에서 플라이웨이트는 이렇게 가는 게 안전하다.
- 유한한 범위만 캐시(예: small int, enum에 준하는 값)
- 그 외는 캐시를 하지 않거나
- 캐시를 하더라도 TTL/LRU 같은 축출 정책을 둔다(Caffeine 등)
정적 팩터리는 재사용의 가능성을 열어주지만, 그 가능성을 무분별하게 쓰면 오히려 장애 포인트가 된다.
12) 열거 타입(Enum): 정적 팩터리와 궁합이 미친 수준으로 좋다
열거 타입은 값의 집합이 닫혀 있다는 걸 타입으로 보장한다. 이게 운영 안정성에서 얼마나 큰지, 문자열 상태값 써본 순간 안다.
12-1. 외부 입력 흡수: from()을 무조건 만들자
실무에서 상태값은 외부 시스템/DB/관리자 화면에서 들어온다. 그 입력은 늘 더럽다. from()이 방역선이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum OrderStatus {
READY, ORDERED, CANCELED;
public static OrderStatus from(String raw) {
if (raw == null) throw new IllegalArgumentException("status null");
String s = raw.trim().toUpperCase();
return switch (s) {
case "READY" -> READY;
case "ORDERED" -> ORDERED;
case "CANCELED", "CANCELLED" -> CANCELED;
default -> throw new IllegalArgumentException("unknown status: " + raw);
};
}
}
여기서 포인트는 중앙집중이다. 호출부가 여기저기서 if (“cancelled”) … 같은 걸 쓰기 시작하면, 분기가 복제되면서 버그가 시스템 전체로 전염된다.
12-2. Enum + 정적 팩터리로 전략 선택을 고정하는 방식
열거 타입은 값이면서 동시에 전략을 담는 그릇이 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public enum FeePolicy {
NONE {
@Override long fee(long amount) { return 0; }
},
FIXED_500 {
@Override long fee(long amount) { return 500; }
},
PERCENT_1 {
@Override long fee(long amount) { return Math.round(amount * 0.01); }
};
abstract long fee(long amount);
}
이렇게 해두면, 나중에 정책 추가/변경이 switch 폭발로 이어지지 않는다. 정책이 타입에 붙는다.
13) 인터페이스 + 정적 메소드: 생성을 타입 바깥으로 빼서 결합도를 낮추기
정적 팩터리는 꼭 해당 클래스 내부에만 있어야 하는 게 아니다. 오히려 실무에서는 생성 정책이 여러 구현체를 아우르는 경우가 많아서, 별도 유틸/팩토리 클래스로 분리하는 게 더 깔끔하다.
13-1. 인터페이스가 계약이고, 정적 팩터리가 진입점이 된다
1
2
3
public interface IdGenerator {
String next();
}
1
2
3
4
5
6
7
final class UuidGenerator implements IdGenerator {
@Override public String next() { return java.util.UUID.randomUUID().toString(); }
}
final class SnowflakeGenerator implements IdGenerator {
@Override public String next() { ... }
}
정적 팩터리는 보통 이런 형태로 간다.
1
2
3
4
5
6
7
8
9
10
11
public final class IdGenerators {
private IdGenerators() {}
public static IdGenerator uuid() {
return new UuidGenerator();
}
public static IdGenerator snowflake(long workerId) {
return new SnowflakeGenerator(workerId);
}
}
호출부는 구현체를 몰라도 된다. 덕분에 나중에 UUID → Snowflake로 바꿀 때 생성 호출부 전체 수정이 아니라 생성 진입점 1곳 수정으로 끝난다.
13-2. 왜 생성자를 직접 호출하게 두면 위험한가
- 호출부가 구현체에 결합된다.
- 구현체 교체가 곧 리팩터링 폭탄이다.
- 테스트에서 구현체를 갈아끼우기 어렵다.
정적 팩터리의 효과는 코드가 짧아짐이 아니라 결합도가 줄어듦이다.
14) 서비스 제공자 프레임워크(SPF): 플러그인 구조는 정적 팩터리가 정답이다
서비스 제공자 프레임워크는 구현체를 나중에 끼워 넣는다는 구조다. 즉, 컴파일 시점에 구현체를 고정하지 않는다.
대표적으로 이런 요구가 나온다.
- 결제 PG를 바꿔야 한다
- 저장소를 S3 ↔ Local ↔ MinIO로 바꿔야 한다
- 인증 공급자(OAuth) 확장
- 메시지 브로커 교체
이때 생성자를 쓰면 누가 어떤 구현체를 만드는지가 호출부에 퍼진다. 퍼지는 순간 플러그인 구조는 망한다.
14-1. ServiceLoader 기반 구조(최소 형태)
1
2
3
4
public interface PaymentClientProvider {
String name();
PaymentClient create(PaymentClientConfig config);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class PaymentClients {
private PaymentClients() {}
public static PaymentClient of(String name, PaymentClientConfig config) {
java.util.ServiceLoader<PaymentClientProvider> loader =
java.util.ServiceLoader.load(PaymentClientProvider.class);
for (PaymentClientProvider p : loader) {
if (p.name().equalsIgnoreCase(name)) {
return p.create(config);
}
}
throw new IllegalArgumentException("unknown payment provider: " + name);
}
}
중요한 건 ServiceLoader를 호출부에서 직접 돌리지 않는다는 거다.
무조건 정적 팩터리(혹은 레지스트리) 1곳으로 모은다.
14-2. 실무에서 더 자주 쓰는 형태: Registry + 설정 기반 선택
운영에서는 보통 application.yml로 선택한다.
payment.provider: nicepaypayment.provider: toss
정적 팩터리는 그 설정을 해석해서 구현체를 골라준다. 이게 생성 정책의 중앙집중이다.
15) 리플렉션: 잘 쓰면 확장, 못 쓰면 런타임 지뢰
리플렉션은 현실적으로 피하기 어렵다. 프레임워크(스프링), 프록시, 직렬화, DI 컨테이너가 다 리플렉션 기반이다.
문제는 애플리케이션 코드에서 리플렉션을 남발할 때다. 문자열 클래스명으로 객체를 만들면, 컴파일러/IDE가 도와줄 게 없다.
정적 팩터리는 리플렉션을 한 곳에 가둬서 안전장치를 붙일 수 있게 만든다.
15-1. 리플렉션을 팩터리로 격리하기
1
2
3
4
5
6
7
8
9
10
11
12
13
public final class ReflectiveFactories {
private ReflectiveFactories() {}
public static <T> T create(String className, Class<T> type) {
try {
Class<?> clazz = Class.forName(className);
Object obj = clazz.getDeclaredConstructor().newInstance();
return type.cast(obj);
} catch (Exception e) {
throw new IllegalArgumentException("cannot instantiate: " + className, e);
}
}
}
이렇게 해두면 다음이 가능하다.
- 허용 클래스 화이트리스트
- 로깅/모니터링(어떤 클래스가 얼마나 생성되는지)
- 예외 메시지 표준화
- 나중에
ServiceLoader로 이관
호출부에서 Class.forName()을 직접 쓰기 시작하면 이런 통제가 불가능하다.
16) 정적 팩터리와 불변 객체(Freezing)는 한 몸이다
정적 팩터리의 장점 중 상당수는 불변과 합쳐질 때 폭발한다.
- 캐시/재사용이 안전해진다(공유해도 변경이 없다)
- 입력 정규화 후 상태가 고정된다
- equals/hashCode가 안정적이다
- 멀티스레드에서 안전하다
예를 들어, 요청 파라미터를 불변 객체로 얼려서(freeze) 서비스 계층으로 넘기면, 로직이 훨씬 예측 가능해진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class SearchCondition {
private final String name;
private final Integer minAge;
private SearchCondition(String name, Integer minAge) {
this.name = name;
this.minAge = minAge;
}
public static SearchCondition of(String name, Integer minAge) {
String n = (name == null || name.isBlank()) ? null : name.trim();
Integer a = (minAge != null && minAge < 0) ? null : minAge;
return new SearchCondition(n, a);
}
}
생성자를 열어두면 호출부가 상태를 이리저리 만들면서 정규화되지 않은 상태가 도메인 내부로 침투한다. 팩터리로 얼려버리면 그게 차단된다.
마무리: 아이템 1의 결론은 new를 없애라가 아니다
정적 팩터리를 고려하라는 말의 본질은 이거다.
- 객체 생성을 행위로 보고,
- 그 행위를 API로 만들고,
- 생성 정책(검증/캐시/선택/변환/숨김)을 타입 내부에 모아라.
그러면 성능/안전성/확장성/테스트성이 같이 따라온다.
실무 체크: 정적 팩터리를 써야 하는 신호만 기억해도 된다
다음 중 하나라도 있으면 정적 팩터리를 강하게 고려한다.
- 생성 시 검증/정규화가 필요하다(외부 입력)
- 생성 비용이 크다(파싱, 네트워크, 파일 로딩)
- 재사용/캐싱/풀링이 가능하거나 필요하다
- 반환 타입을 인터페이스로 숨기고 싶다
- 구현체가 바뀔 가능성이 높다(전략/플러그인)
- 생성 과정에서 프록시/데코레이터를 조립한다
- 리플렉션/동적 로딩이 필요하다(가둬야 한다)
반대로 다음이면 굳이 강요할 이유가 약하다.
- 그냥 값 몇 개 넣어 만드는 단순 DTO
- 생성 정책이 전혀 없다(검증/정규화/선택/캐시 없음)
- 호출부 가독성이 생성자 쪽이 더 명확하다
정적 팩터리는 new를 숨기는 트릭이 아니라, 생성 정책을 타입의 API로 만드는 설계 도구다.
열거 타입/플라이웨이트/SPI/리플렉션 같은 실무의 복잡한 요구는, 생성자가 아니라 정적 팩터리에서 안전하게 처리된다.