개요
이번 글은 단순히 JPA 엔티티를 어떻게 설계했는지 정리하고자 하는 글이 아니다.
필자가 이번 사이드 프로젝트의 약관 도메인을 구현하면서,
무엇을 고민했고, 어떤 기준으로 설계했는지를 돌아보며 정리한 글이다.
이 과정을 통해 앞으로 도메인 설계 시, 스스로 사고하고 판단할 수 있는 틀을 만들고자 했다.
즉, 도메인을 설계하는 사고방식에 대해 정리하고자 한다.
목표
이번 글을 통해 필자는 “도메인을 어떻게 바라보고 설계할 것인가”에 대한 나만의 기준을 세우고자 한다.
단순히 현재의 설계를 회고하는 것을 넘어, 앞으로 다른 도메인을 마주했을 때에도,
같은 기준으로 사고하고 판단할 수 있도록 “설계의 중심에 둘 철학”을 명확히 하고 싶었다.
그렇다면 필자는 이번 도메인을 설계할 때 어떤 기준을 중심에 두고 있었을까?
이번에 약관 도메인을 구현하면서 실제로 적용했던 도메인(엔티티) 설계 원칙들을 하나씩 돌아보며,
내가 어떤 생각으로, 어떤 선택을 했는지 정리해보려 한다.
도메인(엔티티) 설계 원칙
많은 초보 개발자들이 엔티티를 단순히 데이터를 저장하는 구조체로 취급하곤 한다.
하지만 도메인은 단순한 데이터 집합이 아니라, 현실 세계의 개념과 행위를 담고 있는 객체다.
그래서 필자는 도메인을 설계할 때,
"이 객체는 어떤 책임을 지고 있는가?" "이 객체가 잘못된 상태로 존재할 수 있는가?" 같은 질문을 계속 던지며,
단순한 구조가 아닌 살아 있는 객체로서 설계하려고 노력했다.
이제, 실제로 어떤 설계 원칙들을 적용했는지 정리해 보자.
1. 잘못된 상태의 객체가 생성되지 않도록 주의하자
필자는 바로 직전 글("기능 분석: 약관 도메인")에서 약관 도메인을 분석하면서, 이제 코드로 약관 도메인을 설계해야 했다
그리고 설계를 시작하면서 가장 처음으로 신경 쓰고자 한 것은 바로, 객체 생성에 대한 부분이다
약관이라는 도메인은 단순한 텍스트가 아니다.
법적으로 서비스 이용에 동의를 받는 중요한 비즈니스 객체다.
만약 노출 기간(exposedFrom, exposedTo)이 잘못 설정되거나,
버전이 비정상적으로 증가하거나,
필수 약관인데도 누락되었다면?
그건 단순한 데이터 입력 오류가 아니라,
사용자에게 잘못된 정보로 동의를 받는 위법성이나
서비스 정책이 왜곡되는 문제로 이어질 수 있다.
그래서 나는 약관 객체를 생성할 때,
“이 상태는 유효한 약관인가?”를 생성자에서 검증하고,
필요한 경우 도메인 내부에서 예외를 던지도록 했다.
이런 방식은 코드가 조금 불편해지는 대신,
잘못된 약관이 시스템에 존재할 수 없는 구조를 만들어준다.
즉, 생성자에서 검증하는 이유는, 객체가 생성되는 순간부터 책임을 보장하기 위함이다
물론 비즈니스가 복잡해질수록, 잘못된 상태의 객체 생성을 막는 코드를 설계하는 게 어려워질 수 있다.
이를 극복하는 방법은 필자가 따로 정리한 글("객체 생성에 관해")이 있기에 생략한다
2. 상태가 가능한 null을 허용하지 않도록 노력하자
많은 개발자들이 null을 너무 쉽게 허용한다.
특히 JPA 엔티티 설계 시, nullable = true가 기본값이기 때문에 별다른 고민 없이 null을 허용하는 경우가 많다.
하지만 도메인을 설계하는 입장에서는 “이 필드는 정말 null이 될 수 있는가?”를 반드시 자문해봐야 한다.
예시: 약관의 title, code, version은 정말 null일 수 있을까?
- title이 없는 약관이 있을까?
- code가 없는 약관을 서비스에서 어떻게 구분할 수 있을까?
- version이 null이면, 이 약관이 몇 번째인지 어떤 기준으로 판단할 수 있을까?
사실상 이런 필드는 "존재하지 않으면 안 되는 값"이다.
그렇다면 당연히 null을 허용해서는 안 된다.
필자는 아래와 같은 기준을 세웠다
null이 되어선 안 되는 필드라면?
- 생성자에서 필수로 null 체크
- DB Column에도 nullable = false 명시
정말 null이 가능한 필드라면?
- 그 이유와 맥락을 도메인 객체 안에 주석으로 명시
null을 허용하는 것은 “그 값이 아직 존재하지 않을 수도 있음”을 시스템이 받아들이는 것이다.
따라서 null을 허용한다는 건 도메인의 불완전함을 허용한다는 의미와 같다.
null을 허용할 수밖에 없는 상황도 있겠지만, “null은 예외적인 상황”이라는 인식을 잊지 말아야 한다.
도메인을 설계할 때는,
“이 필드는 시스템에서 항상 존재해야 하는 값인가?” 스스로에게 이 질문을 던지는 것만으로도,
객체의 안정성과 의도를 훨씬 더 명확하게 표현할 수 있다.
3. 연관 관계는 기본적으로 Lazy 하게 설정하자
JPA에서는 연관 관계 설정 시 `@ManyToOne`, `@OneToMany` 등의 어노테이션을 사용한다.
이때 가장 많이 간과되는 설정이 바로 fetch 전략이다.
기본값은 `@ManyToOne(fetch = FetchType.EAGER)`이다.
즉, 연관된 객체를 즉시 로딩하겠다는 의미다.
하지만 이 기본값은 실무에서 거의 항상 지양해야 한다.
왜 EAGER가 문제일까?
- 쿼리 예측 불가
- 하나의 엔티티를 조회했을 뿐인데,
- 연관된 모든 엔티티까지 한꺼번에 Join 쿼리로 조회돼 버린다.
- → 성능 최적화와 쿼리 튜닝이 굉장히 어려워진다.
- N+1 문제의 시작점
- 연관된 엔티티가 컬렉션이면?
- 생각 없이 EAGER를 설정했다간 수십, 수백 개의 쿼리가 나갈 수 있다.
- 도메인의 책임이 흐려진다
- 어떤 데이터가 필요한지 도메인이 아니라 JPA 설정이 결정하게 됨.
- 설계자의 의도와 코드의 동작이 불일치된다.
💡 Lazy는 명확하다
필자는 모든 연관 관계에 기본적으로 fetch = LAZY를 명시했다.
필요한 연관 엔티티는 직접 fetch join으로 조회하거나,
도메인 서비스 내부에서 명시적으로 로딩하도록 설계했다.
이유는 단순하다.
연관 객체를 언제 조회할지에 대한 책임은 도메인이 아니라 서비스 계층이 져야 한다.
연관 관계는 설계의 경계선이라 생각한다.
의존성은 있지만, 언제 데이터를 불러올지는 도메인의 책임이 아니다.
JPA의 기본 전략을 그대로 따르기보다,
도메인의 설계 의도를 기준으로 fetch 전략을 명확히 선택해야 한다.
그 첫걸음이 바로 "연관 관계는 Lazy로 시작하자"이다.
4. 도메인 객체에는 의도적으로 Lombok 사용을 줄여라
많은 개발자들이 도메인을 만들 때 가장 먼저 붙이는 것이 있다.
바로 @Getter, @Setter, @NoArgsConstructor, @Builder 같은 Lombok 어노테이션이다.
IDE가 생성자나 게터/세터 자동 완성을 지원함에도,
습관처럼 Lombok을 붙이고 모든 필드를 노출시켜 버리는 일은 꽤 흔하다.
하지만 필자는 도메인 객체만큼은 Lombok 사용을 신중하게 제한하려고 한다.
이유 1. Getter는 도메인의 의도를 흐리게 만든다
@Getter는 편리하지만, 모든 필드에 대한 무차별적인 getter를 생성한다.
이건 곧, 도메인의 내부 구현이 외부에 모두 노출되는 것을 의미한다.
"약관의 노출 시작일"은 외부에 보여줘도 되지만,
"DB상의 ID", "내부 상태 코드" 같은 값은 외부에 꼭 보여줘야 할까?
모든 필드에 getter를 허용하는 건, 도메인의 책임 범위를 흐리게 만든다.
이유 2. Setter는 객체의 불변성을 무너뜨린다
도메인 객체는 가능하면 불변(immutable)에 가까워야 한다.
그래야 한 번 생성된 객체의 상태가 예상치 못하게 바뀌는 일을 막을 수 있다.
@Setter를 사용하면 외부에서 객체의 상태를 마음대로 바꿀 수 있다.
이건 "객체가 유효한 상태로만 존재해야 한다"는 설계 원칙에 위배된다.
이유 3. @Builder는 너무 많은 것을 허용한다
@Builder는 편리하지만,
- 필드 간의 유효성 검증을 하지 못하고
- 잘못된 상태로도 객체 생성을 허용해 버릴 수 있다.
특히 JPA에서는 기본 생성자가 꼭 필요하기 때문에 @NoArgsConstructor도 자주 붙이는데,
그와 함께 @Builder까지 붙으면 유효성 검증이 없는 생성 방식이 2개나 생겨버린다.
이렇게 되면 "객체가 유효한 상태로만 생성되도록" 설계한 의미가 퇴색될 수 있다.
그래서 필자는 이렇게 했다
- @Getter 대신, 꼭 필요한 필드에만 직접 getter를 정의
- @Setter는 절대 사용하지 않음
- @Builder도 사용하지 않고, 생성자에서 검증과 제어를 수행
- 경우에 따라 복잡한 객체 생성을 도와주는 정적 팩토리 메서드나 빌더를 직접 구현해 활용한다
도메인은 편의를 위해 설계하는 게 아니라,
의도를 표현하고, 의미를 보호하기 위해 설계해야 한다.
5. 도메인 객체를 순수하게 만들기 위해 시스템 필드를 분리하자
많은 JPA 엔티티에서 id, createdAt, updatedAt 같은 공통 필드를 다루기 위해 BaseEntity를 사용한다.
처음에는 이게 단순히 코드 재사용을 위한 패턴으로 보일 수 있다.
하지만 내가 BaseEntity를 도입한 진짜 이유는 그게 아니다.
👉 도메인 객체를 더 “순수하게” 유지하기 위해서다.
예를 들어, 약관이라는 도메인을 설계할 때,
이 약관의 id나 createdAt은 비즈니스 로직의 핵심 요소가 아니다.
그건 시스템의 부가 정보에 가까운 메타데이터다.
그런데 이런 시스템적인 필드가 도메인 클래스 안에 뒤섞이면
“도메인의 책임이 흐려지기 시작한다.”
그래서 나는 도메인 객체가 본질적으로 표현하고자 하는 정보만
직접 명시하고, 그 외의 필드는 상속을 통해 추상화했다.
이렇게 하면
- 도메인 클래스는 훨씬 더 명확하고,
- 실제 도메인 요구사항에만 집중된 구조를 유지할 수 있다.
그리고 이건 단지 깔끔한 코드의 문제가 아니다.
“이 객체가 무엇을 표현하고 있는가?”에 대한 해석의 정확성을 높여준다.
원칙이 적용된 도메인 클래스 예시
@Entity
@Table(
name = "terms",
uniqueConstraints = {
@UniqueConstraint(name = "uk_terms_code_version", columnNames = {"code", "version"})
}
)
public class Terms extends BaseEntity {
@Getter
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TermsCode code;
@Getter
@Column(nullable = false)
private String title; // ex: 이용약관
@Getter
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TermsType type; // 필수 or 선택
@Getter
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TermsStatus status; // ACTIVE or INACTIVE or DEPRECATED
@Getter
@Column(nullable = false)
private Integer version; // 버전 역할
@Getter
@Column(nullable = false)
private Boolean isLatest; // 최신 버전 여부
@Getter
@Column(nullable = false)
private LocalDateTime exposedFrom; // 노출 시작일
@Column(nullable = true)
private LocalDateTime exposedToOrNull; // 노출 종료일, 노출 종료일은 존재하지 않을 수 있다
@Column(nullable = false)
private Integer displayOrder; // 정렬 순서
@OneToMany(mappedBy = "terms", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@ToString.Exclude
private final List<Clause> clauseList = new ArrayList<>();
protected Terms() {
}
private Terms(TermsCode code, String title, TermsType type, TermsStatus status, Integer version, Boolean isLatest,
LocalDateTime exposedFrom,
LocalDateTime exposedToOrNull, Integer displayOrder) {
if (code == null) {
throw ErrorCode.CONFLICT.exception("약관 코드는 필수입니다.");
}
if (title == null || title.isBlank()) {
throw ErrorCode.CONFLICT.exception("약관 제목은 필수입니다.");
}
if (type == null) {
throw ErrorCode.CONFLICT.exception("약관 타입은 필수입니다.");
}
if (status == null) {
throw ErrorCode.CONFLICT.exception("약관 상태는 필수입니다.");
}
if (version == null || version < 1) {
throw ErrorCode.CONFLICT.exception("버전은 1 이상이어야 합니다.");
}
if (isLatest == null) {
throw ErrorCode.CONFLICT.exception("최신 여부는 필수입니다.");
}
if (exposedFrom == null) {
throw ErrorCode.CONFLICT.exception("노출 시작일은 필수입니다.");
}
if (displayOrder == null || displayOrder < 1) {
throw ErrorCode.CONFLICT.exception("정렬 순서는 1 이상이어야 합니다.");
}
if (exposedToOrNull != null && exposedFrom.isAfter(exposedToOrNull)) {
throw ErrorCode.CONFLICT.exception("노출 종료일은 노출 시작일보다 늦어야 합니다.");
}
this.code = code;
this.title = title;
this.type = type;
this.status = status;
this.version = version;
this.isLatest = isLatest;
this.exposedFrom = exposedFrom;
this.exposedToOrNull = exposedToOrNull;
this.displayOrder = displayOrder;
}
public static class TermsBuilder {
private TermsCode code;
private String title;
private TermsType type;
private TermsStatus status;
private Integer version;
private Boolean isLatest;
private LocalDateTime exposedFrom;
private LocalDateTime exposedToOrNull;
private Integer displayOrder;
public TermsBuilder code(TermsCode code) {
this.code = code;
return this;
}
public TermsBuilder title(String title) {
this.title = title;
return this;
}
public TermsBuilder type(TermsType type) {
this.type = type;
return this;
}
public TermsBuilder status(TermsStatus status) {
this.status = status;
return this;
}
public TermsBuilder version(Integer version) {
this.version = version;
return this;
}
public TermsBuilder isLatest(Boolean isLatest) {
this.isLatest = isLatest;
return this;
}
public TermsBuilder exposedFrom(LocalDateTime exposedFrom) {
this.exposedFrom = exposedFrom;
return this;
}
public TermsBuilder exposedToOrNull(LocalDateTime exposedToOrNull) {
this.exposedToOrNull = exposedToOrNull;
return this;
}
public TermsBuilder displayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
return this;
}
public Terms build() {
return new Terms(code, title, type, status, version, isLatest, exposedFrom, exposedToOrNull, displayOrder);
}
}
public void addClause(Clause clause) {
this.clauseList.add(clause);
}
public void validateComplete() {
if (this.clauseList == null || this.clauseList.isEmpty()) {
throw ErrorCode.CONFLICT.exception("약관은 하나 이상의 조항을 포함해야 합니다.");
}
}
public void deprecate() {
this.status = TermsStatus.DEPRECATED;
this.isLatest = false;
}
}
결론
- 도메인은 단순히 데이터를 저장하는 구조체가 아니라, 비즈니스의 진실을 표현하고 보호해 주는 객체다.
- 따라서 필자는 객체가 잘못된 상태로 존재하지 않도록 생성 시점부터 제어했으며,
기술적인 편의성보다 도메인이 가진 의미와 책임에 집중하는 방식을 선택했다.
생각 정리
이번 약관 도메인을 설계하면서
나는 처음으로 "도메인을 정말 설계하고 있는가?"라는 질문을 계속해서 자신에게 던졌다.
그전까지의 나는 대부분의 엔티티를
단순히 DB 테이블에 맞춰서 구성했고,
정확히 어떤 책임을 가져야 하는지 깊게 고민하지 않았던 적이 많았다.
불변성을 지켜야 할 값에도 Setter를 무심코 열어두고,
모든 필드에 @Getter를 달아두고,
생성과 동시에 검증해야 할 값들도 외부에서 아무렇게나 주입하게 두곤 했다.
그땐 편했지만,
그 편함이 쌓일수록
내가 의도하지 않은 상태의 객체가 존재하는 걸 막지 못했다.
하지만 이번엔 달랐다.
비즈니스상 중요한 객체를 설계하면서
단 한 줄의 필드조차 "정말 필요한가?"를 고민하게 됐다.
- "이 값은 외부에서 바꿀 수 있어야 하는가?"
- "생성자에서 예외를 던지는 건 과한 게 아닌가?"
- "이 연관 객체는 항상 불러와야 하는가?"
- "이 도메인의 책임은 어디까지인가?"
이런 질문들을 스스로에게 계속 던지며,
처음으로 '설계한다'는 느낌을 받았다.
물론 여전히 부족한 점이 많다.
지금도 “내가 무의식적으로 위험한 코드를 설계하고 있진 않을까?” 스스로에게 끊임없이 질문하게 된다.
그리고, 모든 코드를 완벽하게 안전하게 만들겠다는 과도한 망상에 빠지는 것 역시 경계하고 있다.
어떤 부분은 비즈니스적으로 반드시 보호되어야 하고,
어떤 부분은 개발 편의성을 위해 유연함을 허용해야 하는 순간도 있다.
그래서 더더욱 — "어디에 책임을 둘 것인가?"에 대한 설계 철학이 중요해진다.
이번 경험과 글 쓰기를 통해, 설계에 대한 확고한 철학이 하나 생겼다.
설계는 결국 사람을 위한 것이며,
코드는 그 설계를 전달하는 도구라는 것.
앞으로도 코드보다 설계를 먼저 떠올릴 것이고,
그 설계의 중심엔 항상 도메인이 존재할 것이다.
'개발 일기' 카테고리의 다른 글
[사이드 프로젝트] 일관된 응답 구조 설계 (0) | 2025.04.02 |
---|---|
[고찰하기] 필요한 기능만 의존하게 하자 (0) | 2025.04.01 |
[사이드 프로젝트] 기능 분석: 약관 도메인 (0) | 2025.03.27 |
[고찰하기] 레이어 및 패키지 구조 설계 (0) | 2025.03.23 |
[사이드 프로젝트] Spring Boot 멀티 모듈 프로젝트 구성 (0) | 2025.03.19 |