깨끗한 JPA 엔티티와 더러운 테스트 사이, ID 생성자 딜레마

최기웅·2025년 8월 21일
9
post-thumbnail

1. 들어가며

최근 한 기업의 백엔드 개발 과제를 진행하며 흥미로운 딜레마에 빠졌습니다. 과제의 요구사항에 따라 JPA로 엔티티를 설계하며, 저는 제가 사랑하는 원칙을 충실히 따랐습니다.

ID는 데이터베이스가 생성하는 것, 고로 내 코드는 ID 없는 순수한 도메인 객체만 다뤄야 한다.

@GeneratedValue가 붙은 ID 필드를 보며 흐뭇해하고, ID를 제외한 생성자를 만들며 '이것이 바로 객체지향적인 설계지'라며 뿌듯해했습니다.

하지만 단위 테스트 작성 단계에 들어서는 순간, 이 아름다운 원칙은 현실의 벽에 부딪혔습니다. Mock 객체를 만들고 검증 로직을 짜던 제 테스트 코드가 외쳤습니다.

"그래서 이 객체 ID는 뭔데?"

… 🤦🏻‍♂️🤦🏻‍♂️🤦🏻‍♂️

이 글은 과제를 해결하는 과정에서 마주한, '깨끗한 엔티티 설계'와 '현실적인 테스트 코드' 사이의 딜레마를 어떻게 해결했는지에 대한 기록입니다.

2. 이상(Ideal)을 향한 원칙: 왜 우리는 ID를 생성자에 넣기 싫어할까?

ID를 엔티티 생성자에서 받지 않으려는 이유는 명확합니다.

책임의 분리

ID 생성 책임은 DB에게 있습니다. 엔티티는 비즈니스 데이터만 관리하면 됩니다.

객체 상태 구분

id = null 인 엔티티는 “아직 영속화되지 않은 객체”라는 의미를 가집니다.
생성자에서 ID를 받게 되면 이 구분이 모호해집니다.

실수 방지

만약 ID를 받는 생성자가 있으면, 운영 코드에서 실수로 해당 생성자를 호출할 위험이 있습니다.
이는 @GeneratedValue 전략과 충돌합니다.

3. 현실(Reality)의 벽: 테스트 코드는 왜 ID를 요구하는가?

반대로, 테스트 코드는 왜 자꾸 ID를 원할까요?

Mocking의 현실

서비스 레이어 테스트에서 repository.findById(1L)이 호출되면, 반환할 객체는 id = 1L을 갖고 있어야 합니다.

검증의 필요성

assertThat(result.getCouponId()).isEqualTo(1L); 같은 검증을 하려면, 애초에 Mock 객체의 ID가 세팅돼 있어야 합니다.

즉, 테스트는 “ID가 있는 객체”를 필요로 합니다.

이 지점에서 순수한 엔티티 vs 테스트의 현실이라는 딜레마가 발생합니다.

4. 딜레마 해결을 위한 3가지 방법

1. 테스트 전용 생성자

운영 코드에서는 쓰지 않지만, 테스트 편의성을 위해 ID까지 받는 생성자나 ofTest(…) 같은 팩토리 메소드를 추가합니다.

테스트 코드가 깔끔해지고, 타입 세이프하다는 장점이 생기지만, 운영 코드에 테스트 전용 코드가 섞여 들어간다는 단점이 있습니다.

public CouponBenefit(Long id, String name, ...) {
    this.id = id; -> id가 있는 생성자 추가
    this.name = name;
    ...
}

2. ReflectionTestUtils

운영 코드는 ID 없는 순수 상태를 유지하고, 테스트에서만 리플렉션으로 id를 주입합니다.

운영 코드가 100% 순수하게 유지되는 장점이 있는 반면에 리팩토링에 취약하고, 테스트 가독성이 떨어집니다.

ReflectionTestUtils.setField(couponBenefit, "id", 1L);

3. @Builder + @AllArgsConstructor

@Builder + @AllArgsConstructor를 통해 ID까지 포함한 빌더를 만들고, 운영 코드에서는 .id()를 쓰지 않는 약속을 지킵니다.

운영 코드와 테스트 코드 모두에서 생성 메커니즘이 통일되지만, 약속을 어기면 ID를 잘못 세팅할 수 있습니다.

CouponBenefit benefit = CouponBenefit.builder()
    .id(1L) -> 테스트에서만 사용
    .name("테스트 쿠폰")
    .build();

5. 결론: 정답은 없지만, 더 나은 선택이 있다.

정답은 없습니다.

팀마다 코드의 순수성을 더 중시할 수도 있고, 테스트 편의성을 더 중시할 수도 있습니다.

운영 코드의 순수성을 최우선으로 한다면? → ReflectionTestUtils를 사용하면됩니다.
테스트의 안정성과 가독성을 중시한다면? → ID 생성자 or @Builder를 사용합니다.

6. 그래서 나의 선택은?

저는 최종적으로 @Builder + private 생성자 방식을 선택했습니다.

@Builder
private CouponBenefit(Integer id, ... , ...) {
    this.id = id;
        .
        .
        .
}

이것은 앞서 설명한 방법들의 장점을 결합한, 아주 세련된 해결책입니다.

  1. 생성자가 private이므로, 운영 코드에서 개발자가 new CouponBenefit(id, …)를 호출할 실수 자체를 아예 차단합니다.
  2. 객체 생성은 반드시 빌더를 통해서만 가능하도록 강제합니다.
  3. 테스트 코드에서는 .id(1L)를 통해 안전하고 명확하게 ID를 설정할 수 있습니다.

저는 이 방식 덕분에 운영 코드와 테스트 코드의 객체 생성 전략을 하나로 통일할 수 있었고, 테스트 코드도 훨씬 읽기 좋아졌습니다.

💡 여러분은 어떤 방법을 선택하고 계신가요?

profile
https://giwoong01.tistory.com/

0개의 댓글