정적 팩토리 메서드, 취향에서 컨벤션으로

2024-10-178

Setter 기반 객체 생성의 한계

회사 프로젝트 코드를 보다 보면 유지보수가 쉽지 않은 부분이 눈에 띄곤 합니다. 새로운 기능을 추가하기 전에 코드를 분석하다 보면 "이 부분은 지금 방식보다 더 나은 방법이 있지 않을까?"라는 생각이 들 때가 있습니다. 가능하다면 작은 개선부터 시도해 두는 편이 이후 유지보수에 도움이 된다고 믿습니다.

다행히 회사에서는 이런 시도를 긍정적으로 봐주고, 대표님과 동료들도 개선 작업에 힘을 실어주십니다. 다만 단순히 "코드를 다듬었다"는 이유만으로는 충분하지 않기 때문에, 왜 바꿔야 하는지, 어떤 장점이 있는지 근거를 항상 설명해야 했습니다. 같은 질문이 반복될 때가 많아 글로 정리해두고 링크를 공유하는 것이 더 효율적이겠다고 생각했습니다.

현재 회사 여러 프로잭트 코드에서 가장 자주 보이는 패턴 중 하나는 기본 생성자로 객체를 만든 뒤 setter로 값을 주입하는 방식입니다. 예를 들어 TerminalAuthResponse 같은 DTO를 다룰 때 이런 코드가 흔합니다.

response.setResponseCode(TerminalResponseCode.APPROVED);
response.setApprovalCode(...);     // 승인 코드 생성
response.setCardReferenceId(...);  // 카드 참조 ID 세팅

겉보기에 단순해 보이지만, 유지보수 관점에서 여러 가지 문제가 있습니다.

객체가 불완전한 상태로 존재할 수 있음

  • 객체를 new로 만든 직후에는 아직 필수 값이 세팅되지 않은 상태입니다.
  • 만약 중간에 setter 호출이 누락되면 불완전한 객체가 그대로 다른 메서드로 넘어갈 수 있습니다.

불변성을 지키기 어려움

  • setter는 외부에서 언제든 호출될 수 있으므로, 객체가 만들어진 뒤에도 값이 바뀔 위험이 있습니다.
  • 디버깅할 때 "도대체 어디서 값이 바뀌었지?"라는 문제로 이어질 수 있습니다.

코드 라인이 불필요하게 늘어남

  • 객체 하나를 만들기 위해 new → set → set → … 과정을 반복해야 합니다.
  • 필드가 많아질수록 코드가 장황해지고, 의도가 잘 드러나지 않습니다.

코드를 읽는 사람이 의도를 파악하기 어려움

  • response.setResponseCode(APPROVED)라는 한 줄만 봐서는, 이것이 단순히 필드 값을 바꾸는 것인지, 아니면 "승인된 응답 객체를 생성하는 맥락"인지 알기 어렵습니다.
  • 결국 클래스 정의를 열어보거나 다른 로직을 추적해야 해서 유지보수 부담이 커집니다.

이런 이유들 때문에 저는 setter 방식보다 더 명확하고 안전한 객체 생성 방법이 필요하다고 생각했습니다.

정적 팩토리 메서드 도입

이 문제를 해결하기 위해 생성자와 빌더를 고민했습니다. 생성자는 안전하지만 파라미터가 늘어지면 의도가 보이지 않고, 빌더는 가독성이 좋지만 코드가 장황해집니다.

결국 제가 선택한 건 정적 팩토리 메서드였습니다. 가장 큰 이유는 단순합니다. 이름을 가질 수 있다는 점이 매력적이었습니다.

예를 들어 승인 응답을 만들 때, 다음과 같이 작성할 수 있습니다.

TerminalAuthResponse response = TerminalAuthResponse.approved(cardReferenceId, approvalCode);

이 코드만 보아도 "승인된 응답을 만들고 있구나"라는 의도가 분명히 드러납니다. 생성자나 빌더만으로는 담기 어려운 맥락을 메서드 이름으로 표현할 수 있다는 점에서 큰 가치를 느꼈습니다.

정적 팩토리 메서드의 다양한 장점

제가 처음에는 "이름을 가질 수 있다"는 이유 하나로 정적 팩토리 메서드에 끌렸습니다. 그런데 조금 더 찾아보니 이 방식에는 생각보다 더 많은 장점이 있었습니다.

객체 재사용이 가능하다.

생성자는 호출할 때마다 무조건 새로운 객체를 만듭니다. 하지만 정적 팩토리 메서드는 캐싱된 객체를 반환할 수도 있습니다.

대표적인 예는 Boolean과 Integer 클래스입니다.

Boolean a = Boolean.valueOf(true);
Boolean b = Boolean.valueOf(true);

// 항상 같은 객체를 반환
System.out.println(a == b); // true

내부 구현을 아래와 같습니다.

public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);

public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}

즉, new Boolean(true)를 매번 호출하는 대신 미리 만들어둔 상수를 그대로 반환하는 방식입니다. 이런 캐싱 덕분에 불필요한 객체 생성을 줄이고 성능까지 최적화할 수 있습니다

반환 타입의 하위 타입을 자유롭게 선택할 수 있다

정적 팩토리 메서드의 또 다른 장점은, 반환 타입이 인터페이스나 추상 클래스라면 어떤 하위 타입의 객체든 반환할 수 있다는 점입니다.

예를 들어 EnumSet을 보겠습니다.

EnumSet<DayOfWeek> set = EnumSet.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY);

내부적으로는 원소 개수가 64개 이하면 RegularEnumSet, 그 이상이면 JumboEnumSet을 반환합니다. 클라이언트 입장에서는 이 두 클래스의 존재조차 몰라도 됩니다. 단지 EnumSet이라는 추상 타입만 사용하면 되니까요.

이 덕분에 JDK 개발자는 새로운 최적화된 구현체를 추가하거나, 기존 구현체를 교체하더라도 클라이언트 코드에 영향을 주지 않고 개선할 수 있습니다.

서비스 제공자 프레임워크의 기반이 된다

정적 팩토리 메서드는 작성 시점에 반환할 클래스가 없어도 된다는 특징이 있습니다. 이 유연함 덕분에 JDBC 같은 서비스 제공자 프레임워크가 가능해집니다.

  • 서비스 인터페이스: java.sql.Connection, Driver
  • 제공자 등록 API: 드라이버가 DriverManager에 자신을 등록
  • 서비스 접근 API: DriverManager.getConnection(url, user, password)
Connection conn = DriverManager.getConnection(
    "jdbc:mysql://localhost:3306/test", "user", "password"
);

이 코드만으로 MySQL, Oracle, H2 등 어떤 드라이버가 쓰일지 알 수 없지만, 클라이언트는 신경 쓸 필요가 없습니다. 내부적으로 정적 팩토리 메서드가 알맞은 구현체를 반환해주기 때문입니다.

이 구조 덕분에 JDBC는 수많은 데이터베이스 벤더가 각자의 드라이버를 제공할 수 있고, 클라이언트는 인터페이스에만 의존할 수 있습니다.

외부에서 알아야 할 API 수를 줄일 수 있다

마지막으로, 정적 팩토리 메서드는 외부에서 알아야 할 API 수를 줄이는 데 도움을 줍니다.

자바 컬렉션 프레임워크는 수십 개의 구현체를 갖고 있습니다. 만약 이들을 전부 public으로 노출했다면 개발자가 익혀야 할 클래스가 지나치게 많아졌을 겁니다.

하지만 JDK는 Collections라는 하나의 유틸리티 클래스 안에 정적 팩토리 메서드를 모아두었습니다.

List<String> empty = Collections.emptyList();
List<String> unmodifiable = Collections.unmodifiableList(new ArrayList<>());

클라이언트는 EmptyList, UnmodifiableList 같은 실제 구현체를 알 필요가 없습니다. 덕분에 문서화해야 할 API의 범위가 줄고, 개발자가 익혀야 할 개념의 수도 감소합니다.

동료들과의 토론

정적 팩토리 메서드의 장점을 정리한 뒤, 팀에 제안했습니다. 하지만 새로운 방식을 도입하자는 제안이 한 번에 받아들여지지는 않았습니다. 동료들과 여러 차례 토론을 거쳤고, 그 과정에서 오히려 제 생각이 더 단단해졌습니다.

"가독성은 주관적인 거 아닌가?"

제가 처음 던진 문제는 가독성이었습니다. 기본 생성자에 setter를 나열하는 코드는 위에서부터 set 메서드를 하나씩 읽어 내려가며 어떤 필드에 값을 주입하는지 파악해야 해서 불편하다고 했습니다.

동료의 반론은 명쾌했습니다. "위에서부터 천천히 읽으면 되지 않느냐? 가독성이라는 건 주관적인 것이다."

맞는 말이었습니다. 가독성이 주관적이라는 점은 부정할 수 없습니다. 하지만 저는 이렇게 재반박했습니다.

"우리는 이 프로젝트를 이미 구현했거나 분석이 끝난 상태라서 익숙하게 느낄 수 있다. 하지만 신규로 투입되는 사람이 처음 이 코드를 보면, 코드를 한 줄 한 줄 읽어 내려가는 것 자체가 부담이 된다."

그리고 더 중요한 문제를 짚었습니다.

"지금 예시는 짧아서 괜찮아 보이지만, 실제 코드에서는 메서드 호출 전에 기본 생성자와 setter로 객체를 만들고, 그 메서드 안에서도 setter로 필드를 변경하는 경우가 있다. 이때 setter 호출이 객체를 생성하는 과정인지, 비즈니스 로직을 수행하면서 값을 변경하는 것인지 구분하는 데 리소스가 든다. 이것 자체가 피로를 유발한다."

정적 팩토리 메서드를 쓰면 이 문제가 자연스럽게 해결됩니다. 객체 생성은 create, approved 같은 이름으로 명확히 드러나고, 비즈니스 로직으로 값을 변경하는 경우에는 해당 행위를 설명하는 메서드 이름을 지으면 됩니다. 용도가 이름으로 구분되니까 코드를 읽는 사람이 맥락을 추적할 필요가 없어집니다.

동료는 이 부분에서 납득했습니다.

"그렇게 실수하는 사람이 어디 있겠냐?"

다음으로 setter가 열려 있을 때 발생할 수 있는 사이드 이펙트를 설명했습니다.

// 승인 응답 객체 생성
TerminalAuthResponse response = new TerminalAuthResponse();
response.setResponseCode(TerminalResponseCode.APPROVED);
response.setApprovalCode("A12345");
response.setCardReferenceId("CARD-001");

// ... 중간에 여러 로직이 실행된 뒤 ...

// 특정 조건에서 이미 생성된 객체의 상태가 변경됨
if (someCondition) {
    response.setResponseCode(TerminalResponseCode.DO_NOT_HONOR);
}

setter가 열려 있으니 이미 승인 처리된 응답 객체의 상태가 어디서든 바뀔 수 있습니다. 코드가 길어지면 "어디서 responseCode가 바뀌었지?"를 추적하는 데 상당한 시간이 소모됩니다.

동료의 반응은 이랬습니다. "그렇게 실수하는 사람이 어디 있겠냐? 그건 그 사람이 잘못한 거지!"

틀린 말은 아닙니다. 하지만 저는 이렇게 답했습니다.

"물론 웬만하면 실수를 안 하겠지만, 그래도 시스템적으로 방지하는 것이 좋다. 프로젝트 규모가 커지고 복잡해지다 보면 실수할 확률이 높아진다."

정적 팩토리 메서드로 객체를 생성하고 setter를 닫아두면, approved()로 만들어진 객체는 애초에 외부에서 상태를 변경할 수 없습니다. 상태를 바꿔야 하는 상황이라면 rejected() 같은 새로운 정적 팩토리 메서드로 별도의 객체를 만들어야 합니다. 이렇게 하면 실수 자체가 구조적으로 불가능해집니다.

개인의 주의력에 의존하는 것보다, 코드 구조로 실수를 원천 차단하는 편이 팀 전체의 안정성에 기여한다고 생각합니다.

"필드 하나 바꾸는 것도 무조건 메서드를 만들어야 하나?"

setter를 지양하자는 데 점차 공감대가 형성되자, 동료가 현실적인 질문을 던졌습니다. "그러면 필드 1개 바꾸는 상황에서도 무조건 의미 있는 메서드를 만들어야 하는 거냐?"

이 질문에는 저도 "무조건 그렇다"고 답하지 않았습니다.

"필드 한 개를 변경하는 경우라도, 그것이 비즈니스 상 중요한 정책이라고 생각되면 의미 있는 메서드 이름을 짓는다. 하지만 단순한 값 변경이거나 네이밍 짓기가 어려운 경우에는, 클래스 레벨에서 @Setter를 열지 않고 해당 필드 1개에만 setter를 허용한다."

핵심은 클래스 전체에 @Setter를 거는 것을 금지하는 것이지, setter 자체를 절대 쓰지 말자는 것이 아니었습니다. 필요한 곳에 최소한으로 허용하되, 무분별하게 모든 필드가 외부에 노출되는 상황을 막자는 것이 요점이었습니다.

정적 팩토리 메서드의 단점

토론 과정에서 동료들이 제기한 문제뿐만 아니라, 정적 팩토리 메서드 자체가 가진 한계도 분명히 존재합니다.

첫째, 상속이 불가능합니다. 정적 팩토리 메서드만 제공하려면 생성자를 private으로 막아야 하는데, 이렇게 되면 하위 클래스를 만들 수 없습니다. 하지만 이는 오히려 불변 객체를 설계하거나, 상속 대신 컴포지션을 사용하도록 유도하는 장점으로도 볼 수 있습니다.

둘째, 찾기 어렵습니다. 생성자는 문서에서 명확히 드러나지만, 정적 팩토리 메서드는 그렇지 않습니다. API 문서를 잘 정리하지 않으면 "이 객체를 어떻게 만들어야 하지?" 하는 혼란이 생깁니다. 그래서 관례적인 이름(of, from, valueOf, getInstance, newInstance)을 따르는 게 중요합니다.

빌더와의 비교에서 나온 고민

실제로 동료 개발자와도 이런 얘기를 나눈 적이 있습니다. 동료는 "빌더를 쓰면 파라미터 순서에 신경 쓰지 않아도 되고, 같은 타입의 값이 여러 개 있어도 혼동이 없다"는 점을 장점으로 꼽았습니다. 이 말에 충분히 공감했습니다. 정적 팩토리 메서드는 결국 메서드 시그니처에 정의된 순서대로 인자를 넘겨야 하고, 동일한 타입의 파라미터가 여러 개 있으면 어떤 값이 어떤 필드에 들어가는지 구분하기 어려운 문제가 생길 수 있습니다.

실제로 제가 정적 팩토리 메서드로 리팩토링을 하면서도 이런 점을 체감했습니다. 특히 필드가 많은 객체의 경우, 혹시라도 잘못된 값이 들어갈까 싶어 항상 해당 클래스 정의를 열어보면서 하나하나 신경 써서 작업해야 했습니다. 빌더라면 필드명을 직접 지정할 수 있어 이런 걱정이 줄어들었을 텐데, 정적 팩토리 메서드에서는 어쩔 수 없이 순서와 타입에 의존해야 했습니다.

하지만 빌더에도 분명한 약점이 있습니다. 필수 값 설정을 누락해도 컴파일 시점에 잡아주지 못한다는 점입니다. 빌더는 어떤 필드를 채우든 채우지 않든 .build()를 호출할 수 있기 때문에, 필수 필드를 빠뜨린 채 객체가 생성될 위험이 있습니다.

// responseCode는 필수인데 누락해도 컴파일 에러가 나지 않는다
TerminalAuthResponse response = TerminalAuthResponse.builder()
        .approvalCode("A12345")
        .cardReferenceId("CARD-001")
        .build();  // responseCode가 null인 불완전한 객체 생성

반면 정적 팩토리 메서드는 필수 파라미터가 메서드 시그니처에 명시되어 있으므로, 값을 빠뜨리면 컴파일 단계에서 바로 에러가 발생합니다. 런타임까지 가지 않고 실수를 잡을 수 있다는 점에서, 정적 팩토리 메서드가 빌더보다 안전한 측면도 있는 셈입니다.

결국 빌더와 정적 팩토리 메서드는 각각 장단점이 있고, 저희 팀에서는 정적 팩토리 메서드 내부에서 빌더를 활용하는 조합으로 두 방식의 장점을 취하기로 했습니다. 외부에서는 정적 팩토리 메서드의 시그니처로 필수 값을 강제하고, 내부에서는 빌더로 가독성 좋게 객체를 조립하는 방식입니다.

사실 코틀린에서는 이런 문제가 없습니다. named argument를 지원하기 때문에 파라미터 이름을 직접 지정할 수 있고, 순서를 바꿔도 상관없습니다.

val response = TerminalAuthResponse.rejected(
    reasonMessage = "Hot card",
    cardReferenceId = "12345",
    terminalResponseCode = TerminalResponseCode.DO_NOT_HONOR
)

이처럼 파라미터 이름을 직접 지정할 수 있으니 순서를 지키지 않아도 되고, 어떤 값이 어떤 의미인지 훨씬 명확합니다. 자바에도 언젠가 이런 기능이 들어온다면 정적 팩토리 메서드가 지금보다 훨씬 편리해질 것 같습니다.

토론에서 컨벤션으로

여러 차례 토론을 거치면서 동료들도 setter 기반 객체 생성의 문제점에 공감하게 되었고, 자연스럽게 "그러면 우리 팀은 어떤 기준으로 코드를 작성할 것인가?"라는 논의로 이어졌습니다. 개인의 취향이 아니라 팀이 함께 합의한 규칙이라는 점에서, 이 컨벤션은 단순한 코딩 스타일 가이드 이상의 의미를 가집니다.

클래스 레벨 @Setter 금지

  • 클래스 전체에 @Setter를 거는 것을 금지합니다.
  • 비즈니스 상 필요한 경우 개별 필드에 한해 setter를 허용하되, 가능하면 의미 있는 메서드 이름을 사용합니다.

생성자는 Lombok @NoArgsConstructor(access = AccessLevel.PRIVATE)

외부에서 무분별하게 객체를 생성하지 못하게 제한합니다.

@Builder + private 생성자 조합

실제 필드 초기화는 @Builder와 private 생성자를 통해서만 이뤄집니다

@Builder
private TerminalAuthResponse(
        TerminalResponseCode responseCode,
        String approvalCode,
        String cardReferenceId,
        String reasonMessage
) {
    this.responseCode = responseCode;
    this.approvalCode = approvalCode;
    this.cardReferenceId = cardReferenceId;
    this.reasonMessage = reasonMessage;
}

정적 팩토리 메서드 작성

의도가 명확한 이름을 가진 정적 메서드를 통해 객체를 생성합니다.

public static TerminalAuthResponse approved(String cardReferenceId, String approvalCode) {
    return TerminalAuthResponse.builder()
            .responseCode(TerminalResponseCode.APPROVED)
            .approvalCode(approvalCode)
            .cardReferenceId(cardReferenceId)
            .build();
}

이 규칙들은 제가 일방적으로 정한 것이 아니라, 토론을 통해 동료들과 함께 합의한 결과입니다. 덕분에 팀원 모두가 이 컨벤션의 배경과 이유를 이해하고 있고, 자발적으로 지켜나가고 있습니다.

맺으며

정적 팩토리 메서드가 무조건 옳은 해법은 아닙니다. 상황에 따라 생성자나 빌더가 더 적합할 수도 있습니다. 다만 저는 프로젝트를 리팩토링하면서 "이름을 가질 수 있다"는 단순한 이유 때문에 정적 팩토리 메서드를 선호하게 되었고, 그 과정에서 다른 장점들도 자연스럽게 알게 되었습니다.

돌이켜보면, 가장 값진 경험은 기술적 지식을 쌓은 것이 아니라 동료를 설득하는 과정 그 자체였습니다. "가독성은 주관적이다"라는 반론에 신규 투입자의 관점을 제시하고, "실수하는 사람이 잘못이다"라는 의견에 시스템적 방지의 가치를 이야기하고, "무조건 setter 금지냐"는 질문에 실용적인 기준을 제안했습니다. 이 과정에서 제 생각도 더 정교해졌고, 결과적으로 혼자 정한 규칙보다 훨씬 현실적인 컨벤션이 만들어졌습니다.

결국 좋은 코드는 혼자 만드는 것이 아니라, 팀이 함께 납득하고 지켜갈 때 비로소 의미가 있다고 생각합니다.


© 2026 박건희