업무를 하다 디자인 패턴이 헷갈려 글을 작성했다.
“생성자 대신
static메서드로 객체를 만들면 그게 팩터리 메서드 패턴인가?”
아니다. 둘은 이름만 비슷하고 층위가 완전히 다르다. 하나는 자바 관용구(idiom)고, 하나는 GoF 디자인 패턴이다. 최근 실무에서 외부 AI 연동 기능을 만들다가 이 둘을 정확히 구분하게 된 계기가 있어서 정리해둔다.
상황: 외부 API 응답을 담는 객체
외부 AI 서버에 작업을 제출하고, 주기적으로 상태를 폴링하는 기능을 만들고 있었다. AI의 작업 조회 응답은 대략 이런 JSON이다.
{ "status": "done", "result": { ... }, "execution_metadata": { ... } }
{ "status": "failed", "error_code": "rate_limited", "message": "...", "retryable": true }
이 응답을 담는 AiJob이라는 객체가 있는데, 생성하는 코드가 이렇게 생겼다.
public class AiJob {
private String status;
private JsonNode result;
private String errorCode;
private String errorMessage;
private boolean retryable;
// 외부 생성을 막고 static 메서드 두 개만 생성을 담당한다.
private AiJob() {}
/** 정상 응답을 해석해서 만든다 */
static AiJob from(JsonNode body) {
AiJob job = new AiJob();
job.status = body.path("status").asText();
job.result = body.path("result");
if (job.isDone() && !job.result.isObject()) {
// 계약 위반 — 결과 없는 done을 성공으로 흘려보내지 않는다.
job.status = "failed";
job.errorCode = "invalid_result";
return job;
}
// ... 실패 필드 파싱 등
return job;
}
/** 404(TTL 만료)를 "재시도 불가 실패"로 표현해서 만든다 */
static AiJob expired(String jobId) {
AiJob job = new AiJob();
job.status = "failed";
job.errorCode = "job_not_found";
job.errorMessage = "job " + jobId + " not found (expired or deleted)";
return job;
}
public boolean isDone() { return "done".equals(status); }
public boolean isFailed() { return "failed".equals(status); }
}
호출하는 쪽에서는 new AiJob()을 볼 일이 없다.
AiJob job = AiJob.from(mapper.readTree(response)); // 정상 응답
AiJob job = AiJob.expired(jobId); // 404
처음엔 “이게 팩터리 메서드 패턴인가?” 싶었다. 결론부터: 이건 정적 팩터리 메서드이고, 팩터리 메서드 패턴이 아니다.
정적 팩터리 메서드 — Effective Java 아이템 1
public 생성자 대신 static 메서드로 인스턴스를 반환하는 코딩 관용구다. 클래스 하나 안에서 끝난다. 상속도 없고 다형성도 없다.
그런데도 new 대신 이걸 쓰는 이유가 세 가지 있다. 위 코드에서 실제로 얻고 있는 것들이다.
1. 이름이 의도를 말한다
생성자는 이름이 클래스명으로 고정이다. new AiJob(body)와 new AiJob(jobId)는 뭐가 다른지 시그니처를 파봐야 안다. 반면 from(응답)과 expired(jobId)는 “응답을 해석한 것”과 “만료를 실패로 표현한 것”이라는 서로 다른 생성 경로를 이름으로 구분한다.
2. 생성 시점에 검증·정규화를 강제한다
from() 안에는 “status가 done인데 result가 없으면 failed로 바꾼다”는 방어 로직이 있다. 외부 API가 계약을 어긴 응답을 줘도, 이 객체를 쓰는 쪽은 “done이면 result가 반드시 있다”고 믿어도 된다.
만약 밖에서 new AiJob()으로 만들 수 있다면? 이 검사를 우회한 반쪽짜리 객체가 시스템을 돌아다닐 수 있다. 생성 경로를 정적 팩터리 하나로 좁혔기 때문에 불변식이 뚫릴 구멍이 없다.
3. 접근을 잠글 수 있다
from/expired에 접근 제어자가 없는 것(package-private)도 의도다. 같은 패키지의 API 클라이언트만 AiJob을 만들 수 있고, 서비스 계층은 받아서 질문(isDone(), isFailed())만 할 수 있다. “외부 응답의 해석은 클라이언트 계층에서 끝낸다”는 계층 규칙이 컴파일 타임에 강제된다.
물론 만능은 아니다. 생성자를 private으로 잠그면 외부에서 상속할 수 없고, 정적 팩터리는 생성자보다 Javadoc에서 눈에 덜 띄기도 한다.
팩터리 메서드 패턴 — GoF, 완전히 다른 것
GoF의 Factory Method는 상속이 핵심이다. static은 오히려 이 패턴과 상극이다(static 메서드는 오버라이드가 안 되니까).
패턴이 해결하려는 문제는 이거다.
알고리즘의 골격은 상위 클래스에서 재사용하고 싶은데, 그 과정에서 생성할 객체의 구체 타입이 하위 클래스마다 다르다.
교과서적인 구조:
abstract class ReportExporter {
// 골격은 상위 클래스가 갖는다 — Template Method와 한 세트로 자주 나온다
public final File export(ReportData data) {
Document doc = createDocument(); // ← 이 한 줄이 팩터리 메서드
doc.write(data);
return doc.save();
}
// "무엇을 만들지"만 하위 클래스에 뚫어놓는다
protected abstract Document createDocument();
}
class PdfReportExporter extends ReportExporter {
@Override
protected Document createDocument() { return new PdfDocument(); }
}
class ExcelReportExporter extends ReportExporter {
@Override
protected Document createDocument() { return new ExcelDocument(); }
}
export()라는 골격은 한 번만 작성하고, PDF냐 엑셀이냐는 하위 클래스가 createDocument() 오버라이드로 결정한다. 새 포맷이 필요하면 기존 코드를 고치는 게 아니라 하위 클래스를 추가한다(OCP).
AiJob에는 하위 클래스도 오버라이드도 없다. 그러니 팩터리 메서드 패턴이 아니다.
구분 요령
| 정적 팩터리 메서드 | 팩터리 메서드 패턴 | |
|---|---|---|
| 출처 | Effective Java 아이템 1 (관용구) | GoF 디자인 패턴 |
| 핵심 메커니즘 | static 메서드 | 상속 + 추상 메서드 오버라이드 |
| 참여 클래스 | 1개 | 최소 2계층 (Creator + ConcreteCreator) |
| 해결하는 문제 | 생성 경로에 이름·검증·접근제어를 붙임 | 골격 재사용 + 생성 타입의 다형적 결정 |
| 판별법 | static이 있고 상속이 없다 | 생성 메서드가 abstract이고 하위가 채운다 |
한 줄 요약: static이 붙어 있고 상속이 없으면 관용구, 생성 메서드가 추상이고 하위 클래스가 채우면 패턴.
보너스: Creation Method
같은 뿌리에서 나온 변형이 하나 더 있다. 정적 팩터리가 “자기 자신의 생성”을 감싼다면, 엔티티가 자식 객체의 생성을 감싸는 경우다.
같은 프로젝트에서, 문서 분석(DocumentAnalysis)이 분석 실행(DocumentAnalysisRun)을 만드는 코드:
public class DocumentAnalysis {
/** 실행은 문서가 만든다 — 실행의 스키마가 항상 이 문서의 "검토 확정본"임을 문서가 보장한다. */
public DocumentAnalysisRun startRun(File runPdfFile) {
return new DocumentAnalysisRun(this, runPdfFile, reviewedSchemaForRun());
}
public String reviewedSchemaForRun() {
if (reviewedSchema == null) {
throw new ReviewedSchemaRequiredException(id);
}
return reviewedSchema;
}
}
DocumentAnalysisRun의 생성자 접근을 좁혀두면, “검토가 끝나지 않은 문서로는 실행을 만들 수 없다”는 도메인 규칙을 우회할 방법 자체가 사라진다. 조슈아 케리에브스키가 Creation Method라고 부르는 기법으로, 켄트 벡의 Constructor Method와 같은 계보에 있다. 원리는 정적 팩터리와 같다 — new를 이름 있는 메서드 뒤로 숨기고, 그 메서드에서 불변식을 강제한다.
정리
AiJob.from()같은 코드를 보고 “팩터리 메서드 패턴 적용!”이라고 하면 틀린 말이다. 정적 팩터리 메서드라는 관용구다.- 팩터리 메서드 패턴은 상속 기반이고, “골격은 공유하되 생성 타입은 하위가 결정”이라는 훨씬 특정한 문제를 푼다.
- 셋 다(정적 팩터리, 팩터리 메서드 패턴, Creation Method) 공통 뿌리는 하나:
new를 이름 있는 메서드 뒤로 숨기면, 그 자리에 이름·검증·접근제어를 붙일 수 있다.
패턴 공부를 하면서 느끼는 건, 구조를 외우는 것보다 “이 코드가 그 패턴인가 아닌가”를 판별하는 눈이 훨씬 늦게 생긴다는 점이다. 실무 코드에 이름을 붙여보고, 틀리고, 정정받는 과정이 예제 코드 열 개보다 나았다.