실무에서 바로 쓰는 JPA (4편) - 엔티티 설계 마스터하기
TL;DR
- 문제: 양방향 연관관계 남발, cascade 무분별 사용, setter 도배로 엔티티 설계가 엉망
- 원인: N+1, 영속성 컨텍스트, 쿼리 최적화는 했는데 근본 원인은 엔티티 설계
- 해결: 단방향 우선, cascade 최소화, setter 제거 (Builder 패턴), 불변 객체
- 효과: 순환참조 방지, 의도치 않은 수정 방지, 테스트 쉬움, 유지보수성 ↑
- 한계: 초기 설계 시간 증가, 연관관계 추가 시 fetch join 필요, Builder 코드 장황, 팀 컨벤션 필요
환경
- 프레임워크: Spring Boot 3.x + JPA (Hibernate)
- DB: MySQL 8.0, InnoDB
- 엔티티 패턴: Domain-Driven Design 적용
- 목표: 양방향 순환참조 방지, setter 제거로 불변성 확보
시리즈 안내
이 글은 “실무에서 바로 쓰는 JPA” 시리즈의 네 번째 편입니다.
1편: N+1 문제 해결
연관관계 조회 시 발생하는 성능 문제와 해결 방법을 다룹니다.
2편: 영속성 컨텍스트와 흔한 실수들
JPA의 내부 동작 원리와 실수하기 쉬운 포인트들을 짚어봅니다.
3편: 쿼리 최적화 실전
복잡한 쿼리를 효율적으로 작성하는 노하우를 공유합니다.
4편 (현재 글): 엔티티 설계 마스터하기
유지보수하기 좋은 엔티티 설계 원칙을 다룹니다.
5편: 실무 안티패턴 총정리
실무에서 자주 발생하는 실수와 개선 방법을 총정리합니다.
이전 편 요약:
1편에서 N+1 문제를, 2편에서 영속성 컨텍스트를, 3편에서 쿼리 최적화를 다뤘습니다. 하지만 근본적인 문제는 엔티티 설계에 있었습니다. 양방향 연관관계를 남발하고, cascade를 무분별하게 사용하고, setter로 도배된 엔티티들이 나중에 큰 문제가 됐죠.
이번 편에서는 제가 실패를 통해 배운 JPA 엔티티 설계 원칙과 유지보수 전략을 공유합니다.
서론
1편에서 N+1 문제를, 2편에서 영속성 컨텍스트를, 3편에서 쿼리 최적화를 다뤘습니다.
근데 돌아보니 근본적인 문제가 있더군요. 엔티티 설계가 잘못되어 있었습니다.
양방향 연관관계로 도배된 엔티티, 무분별한 cascade 설정, 그리고 변경 감지를 믿고 setter를 남발한 코드.
결국 6개월 후, 대규모 리팩토링을 해야 했습니다. “처음부터 제대로 설계했으면…” 후회가 많이 됐죠.
이번 포스팅에서는 제가 실패를 통해 배운 JPA 엔티티 설계 원칙과 유지보수 전략을 공유합니다.
양방향 vs 단방향 - 양방향은 정말 필요한가?
양방향의 유혹
JPA 처음 배울 때 이렇게 짰습니다.
@Entity
public class Order {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order")
private List<OrderItem> items = new ArrayList<>();
}
@Entity
public class Customer {
@Id
private Long id;
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
}
@Entity
public class OrderItem {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
}“양방향 참조가 있으면 편하겠지?” 싶었습니다.
문제가 터졌습니다:
@GetMapping("/customers/{id}")
public CustomerDto getCustomer(@PathVariable Long id) {
Customer customer = customerRepository.findById(id).get();
return new CustomerDto(customer); // 순환참조 발생!
}에러:
{
"timestamp": "2024-09-21T10:00:00",
"status": 500,
"error": "Internal Server Error",
"message": "Could not write JSON: Infinite recursion (StackOverflowError)"
}Customer → Order → OrderItem → Order → … 무한 반복입니다.
해결하려고 @JsonIgnore를 추가:
@Entity
public class Customer {
@OneToMany(mappedBy = "customer")
@JsonIgnore // 추가
private List<Order> orders = new ArrayList<>();
}근데 이러면 Customer 조회 시 Order를 못 가져옵니다. 필요할 때도 있는데 말이죠.
단방향으로 전환
고민 끝에 단방향으로 바꿨습니다.
@Entity
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
}
@Entity
public class Customer {
@Id
private Long id;
private String name;
// orders 필드 제거!
}
@Entity
public class OrderItem {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
}Customer에서 Order로의 참조를 제거했습니다.
“그럼 Customer의 주문 목록은 어떻게 조회하죠?”
Repository에서 조회합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
}
// Service
public List<OrderDto> getCustomerOrders(Long customerId) {
return orderRepository.findByCustomerId(customerId)
.stream()
.map(OrderDto::from)
.collect(Collectors.toList());
}장점:
graph LR
Before[양방향]
After[단방향]
Before --> P1[순환참조 위험]
Before --> P2[연관관계 관리 복잡]
Before --> P3[Json Ignore 도배]
After --> A1[순환참조 없음]
After --> A2[관계 명확]
After --> A3[DTO 변환 간단]
style Before fill:#ffebee
style After fill:#e8f5e9코드가 훨씬 단순해졌습니다.
실무 원칙
저는 이런 기준으로 결정합니다.
| 상황 | 선택 | 이유 |
|---|---|---|
| 부모 → 자식만 필요 | 단방향 | 가장 간단 |
| 자식 → 부모만 필요 | 단방향 | Repository 조회 |
| 양쪽 모두 자주 필요 | 단방향 + Repository | 순환참조 방지 |
| 도메인 규칙상 필수 | 양방향 (신중하게) | 편의 메서드 필수 |
양방향 연관관계는 순환 참조, 복잡성 증가 등 부작용이 많습니다. 대부분의 경우 단방향 + Repository 조회로 충분히 해결 가능합니다.