DDD에서 도메인과 엔티티를 분리했을 때, 생성자의 접근 제한자
DDD 프로젝트에서 도메인 객체와 JPA 엔티티를 분리했을 때, 생성자에 어떤 접근 제한자를 붙여야 하는지 직접 정리해 보았다.
기존 방식: 엔티티에 도메인 로직을 함께 담을 때
Spring 프로젝트에서 엔티티 하나가 도메인 역할까지 같이 맡는 경우가 많다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CompetitionEntity {
// 필드, 비즈니스 로직 ...
}
여기서 기본 생성자에 protected를 붙이는 이유는 단순하다.JPA 스펙이 그렇게 요구하기 때문이다.
JPA는 DB에서 데이터를 읽어와 객체를 복원할 때, 우리가 정의한 생성자를 직접 호출하지 않는다.
리플렉션으로 기본 생성자를 호출해 빈 객체를 만든 뒤, 필드에 값을 하나씩 주입한다.
DB 조회 → 기본 생성자로 빈 객체 생성 → 필드에 값 주입
이때 기본 생성자가 private이면 JPA가 접근하지 못해 런타임 에러가 발생한다.
JPA 스펙은 기본 생성자를 public 또는 protected로 요구한다.
그래서 외부의 무분별한 생성을 막으면서 JPA는 접근할 수 있도록, 둘 중 더 닫혀 있는 protected를 쓰는 것이다.
그런데 도메인을 따로 분리하면?
DDD에서는 도메인 모델의 순수성을 지키기 위해 도메인 객체를 JPA에서 분리하기도 한다.
domain.model ← 순수 도메인 객체 (JPA 의존 X)
infrastructure ← JPA 엔티티 (기술 구현)
이 구조에서는 도메인 객체가 더 이상 JPA에 의존하지 않는다. 즉, JPA에 양보할 이유 자체가 사라진다.
생성자에 protected를 붙여야 할 근거가 없어진 것이다.
순수 도메인 객체: private 생성자 + 정적 팩토리
먼저 순수 도메인 VO인 ParticipantCount를 보자.
public class ParticipantCount {
private final int min;
private final int max;
private final int current;
private ParticipantCount(int min, int max, int current) {
if (min > max) {
throw new IllegalArgumentException("최소 인원은 최대 인원보다 클 수 없습니다.");
}
if (min < 0 || current < 0) {
throw new IllegalArgumentException("참가 인원은 0 이상이어야 합니다.");
}
this.min = min;
this.max = max;
this.current = current;
}
public static ParticipantCount of(int min, int max) {
return new ParticipantCount(min, max, 0);
}
public static ParticipantCount of(int min, int max, int current) {
return new ParticipantCount(min, max, current);
}
// ...
}
생성자가 private이다. JPA가 손대지 않으니 리플렉션 제약에서 자유롭고,
외부에서는 오직 of() 팩토리 메서드를 통해서만 객체를 만들 수 있다.
잘못된 값으로는 애초에 인스턴스가 만들어지지 않는다. min > max이거나 음수가 들어오면 생성 단계에서 막힌다.
애그리거트 루트인 Competition도 같은 원칙을 따른다.
@Builder(access = AccessLevel.PRIVATE)
private Competition(...) {
// 신규 생성용
validate();
}
private Competition(UUID competitionId, ...) {
// 영속 데이터 복원용
validate();
}
public static Competition createCompetition(...) { ... }
public static Competition from(UUID competitionId, ...) { ... }
생성자 두 개 다 private, 빌더도 AccessLevel.PRIVATE로 닫혀 있다.
외부에서는 createCompetition()(신규 생성)이나 from()(영속 데이터 복원) 두 개의 정적 팩토리 메서드만 보인다.
도메인 객체가 자기 생성 규칙을 완전히 통제하게 된 것이다.
JPA 엔티티: 여전히 protected가 필요하다
반대로 CompetitionEntity는 JPA 영역이니 옛날 방식 그대로다.
@Entity
@Table(name = "p_competitions")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CompetitionEntity extends BaseEntity implements Persistable<UUID> {
@Builder(access = AccessLevel.PRIVATE)
private CompetitionEntity(...) {
// ...
validate();
}
public static CompetitionEntity from(Competition domain) { ... }
public Competition toDomain() { ... }
}
기본 생성자는 protected. JPA가 리플렉션으로 호출해야 하기 때문이다.
빌더 생성자는 private이고, 외부에서는 도메인 객체를 받는 from(Competition domain) 팩토리만 노출한다.
엔티티는 도메인을 DB에 저장할 형태로 변환하는 책임만 가진다.
정리
| 제한자 | 접근 | 이유 |
| protected | JPA 엔티티 기본 생성자 | JPA가 리플렉션으로 호출해야 함 |
| private | JPA 엔티티 빌더/지정 생성자 | 외부 생성은 from() 같은 팩토리로만 |
| private | 순수 도메인 객체 생성자 | JPA 제약 없음, 팩토리로 완전 캡슐화 |
protected는 JPA에 양보하기 위한 타협이고, private은 도메인이 스스로의 생성 규칙을 통제하기 위한 선택이다.
마무리
도메인과 엔티티를 분리해서 DDD를 적용할 때, 생성자가 어디에서 호출되고 사용되는 지를 생각하면서 개발해야하는 것 같다.
나아가서 생성자뿐만이아니라 메서드의 사용 위치를 고려해야하는 이유를 느꼈다.
'내배캠' 카테고리의 다른 글
| 전자기기로 이해하는 헥사고날 아키텍처 (1) | 2026.04.30 |
|---|---|
| TIL - 공통 모듈의 ErrorCode, 어떻게 관리해야 할까 (0) | 2026.04.26 |
| TIL - 실시간 랭킹 구현 방법 (0) | 2026.04.23 |
| TIL - 개발 키워드 정리 (0) | 2026.04.15 |
| TIL - 회복성, SPOF, 분산 트랜잭션과 결과적 일관성 (1) | 2026.04.13 |