Skip to content

실무에서 바로 쓰는 JPA (4편) - 엔티티 설계 마스터하기

실무에서 바로 쓰는 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 조회로 충분히 해결 가능합니다.





cascade와 orphanRemoval - 신중하게 써야 합니다

cascade의 함정

cascade를 쉽게 생각했습니다.

@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items = new ArrayList<>();
}

“Order를 저장하면 OrderItem도 자동 저장! 편하네!”


그런데 이런 일이 생겼습니다:

public void addItemToOrder(Long orderId, OrderItem newItem) {
    Order order = orderRepository.findById(orderId).get();
    order.getItems().add(newItem);
    orderRepository.save(order);  // Order를 저장했을 뿐인데...
}

실행된 쿼리:

INSERT INTO order_item (...) VALUES (...);
INSERT INTO order_item (...) VALUES (...);
INSERT INTO order_item (...) VALUES (...);
-- 기존 items까지 전부 INSERT 시도!

cascade 때문에 의도하지 않은 INSERT가 발생했습니다.



orphanRemoval의 위험성

더 큰 문제는 orphanRemoval이었습니다.

@Entity
public class Order {
    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,
               orphanRemoval = true)  // 조심!
    private List<OrderItem> items = new ArrayList<>();
}

이런 코드를 짰습니다:

public void updateOrder(Long orderId, OrderUpdateDto dto) {
    Order order = orderRepository.findById(orderId).get();

    // DTO에서 새로운 items 리스트를 받음
    order.getItems().clear();  // 기존 items 제거
    order.getItems().addAll(dto.getItems());  // 새 items 추가

    orderRepository.save(order);
}

결과:

DELETE FROM order_item WHERE order_id = ?;  -- 기존 전부 삭제!
INSERT INTO order_item (...) VALUES (...);  -- 새로 추가
INSERT INTO order_item (...) VALUES (...);

기존 OrderItem이 전부 삭제됐습니다. 주문 통계가 꼬였죠.



올바른 사용법

cascade와 orphanRemoval은 정말 주인-종속 관계일 때만 씁니다.

// Good - 주문이 삭제되면 주문 상품도 함께 삭제되어야 함
@Entity
public class Order {
    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();
}

// Bad - 회원이 삭제된다고 주문까지 삭제하면 안 됨
@Entity
public class Customer {
    @OneToMany(mappedBy = "customer",
               cascade = CascadeType.ALL)  // 위험!
    private List<Order> orders = new ArrayList<>();
}

🚨Cascade & OrphanRemoval 사용 기준

cascade 사용 기준:

  1. 부모 없이 자식이 존재할 수 없는 경우만
  2. 부모와 자식의 생명주기가 완전히 동일한 경우
  3. 자식이 다른 엔티티에서 참조되지 않는 경우

orphanRemoval 사용 기준:

  1. cascade보다 더 신중하게
  2. 컬렉션에서 제거 = DB 삭제가 확실한 경우만
  3. 통계/이력 관리가 필요 없는 경우




@ManyToOne의 함정

즉시 로딩 문제

@ManyToOne의 기본 fetch 전략은 EAGER입니다.

@Entity
public class Order {
    @ManyToOne  // 기본값: fetch = FetchType.EAGER
    @JoinColumn(name = "customer_id")
    private Customer customer;
}

문제:

List<Order> orders = orderRepository.findAll();

실행되는 쿼리:

SELECT * FROM orders;  -- 1번

SELECT * FROM customer WHERE id = 1;  -- N번
SELECT * FROM customer WHERE id = 2;
SELECT * FROM customer WHERE id = 3;
...

N+1 문제가 다시 발생합니다.


해결:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)  // 명시적으로 LAZY
    @JoinColumn(name = "customer_id")
    private Customer customer;
}
💡즉시 로딩(EAGER)은 피하세요

무조건 LAZY로 설정하세요. EAGER는 예측하지 못한 쿼리를 발생시키고, N+1 문제의 주범이 됩니다. 필요하면 Fetch Join으로 조회하면 됩니다.



@JoinColumn 없으면 조인 테이블 생성

이것도 몰랐다가 놀랐습니다.

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;  // @JoinColumn 없음!
}

생성되는 테이블:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    ...
);

CREATE TABLE customer (
    id BIGINT PRIMARY KEY,
    ...
);

CREATE TABLE orders_customer (  -- 조인 테이블이 생성됨!
    order_id BIGINT,
    customer_id BIGINT
);

조인 테이블이 생깁니다. 의도한 게 아니었는데 말이죠.


해결:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")  // 명시!
    private Customer customer;
}
⚠️조인 컬럼 명시

@JoinColumn을 생략하면 JPA가 자동으로 조인 테이블을 생성할 수 있습니다. 의도치 않은 테이블 생성을 막기 위해 항상 @JoinColumn을 명시하세요.





변경 감지와 병합(merge)

merge()의 위험성

초보 때 이렇게 짰습니다.

public void updateOrder(Long id, OrderUpdateDto dto) {
    Order order = new Order();
    order.setId(id);
    order.setStatus(dto.getStatus());
    order.setTotalAmount(dto.getTotalAmount());

    orderRepository.save(order);  // save()는 merge() 호출!
}

문제:

SELECT * FROM orders WHERE id = ?;  -- merge()가 먼저 SELECT
UPDATE orders SET
    status = ?,
    total_amount = ?,
    customer_id = NULL,  -- 설정 안 한 필드는 NULL로!
    order_date = NULL
WHERE id = ?;

설정하지 않은 필드가 NULL로 업데이트됩니다.



변경 감지 (Dirty Checking) 사용

올바른 방법은 변경 감지입니다.

@Transactional
public void updateOrder(Long id, OrderUpdateDto dto) {
    Order order = orderRepository.findById(id)
        .orElseThrow(() -> new EntityNotFoundException());

    // 엔티티의 메서드로 변경
    order.updateStatus(dto.getStatus());
    order.updateTotalAmount(dto.getTotalAmount());

    // save() 호출 불필요! 변경 감지가 자동으로 UPDATE
}

실행되는 쿼리:

SELECT * FROM orders WHERE id = ?;  -- findById()
UPDATE orders SET
    status = ?,
    total_amount = ?
WHERE id = ?;  -- 변경된 필드만 UPDATE

변경된 필드만 정확하게 업데이트됩니다.



DTO → Entity 변환 전략

실무에서 자주 고민하는 부분입니다.

Bad - setter 남발:

public Order toEntity(OrderCreateDto dto) {
    Order order = new Order();
    order.setCustomerId(dto.getCustomerId());
    order.setStatus(dto.getStatus());
    order.setTotalAmount(dto.getTotalAmount());
    order.setOrderDate(LocalDateTime.now());
    return order;
}

Good - 정적 팩토리 메서드:

@Entity
public class Order {
    // setter 제거

    public static Order create(Long customerId, BigDecimal totalAmount) {
        Order order = new Order();
        order.customerId = customerId;
        order.status = OrderStatus.PENDING;
        order.totalAmount = totalAmount;
        order.orderDate = LocalDateTime.now();
        return order;
    }

    public void updateStatus(OrderStatus newStatus) {
        // 비즈니스 로직 검증
        if (this.status == OrderStatus.COMPLETED) {
            throw new IllegalStateException("완료된 주문은 수정할 수 없습니다");
        }
        this.status = newStatus;
    }
}

// Service
public Order createOrder(OrderCreateDto dto) {
    return orderRepository.save(
        Order.create(dto.getCustomerId(), dto.getTotalAmount())
    );
}

장점:

graph LR
    Before[setter 사용]
    After[정적 팩토리]

    Before --> P1[무분별한 수정]
    Before --> P2[검증 로직 누락]
    Before --> P3[불완전한 객체]

    After --> A1[의도 명확]
    After --> A2[검증 로직 포함]
    After --> A3[불변성 보장]

    style Before fill:#ffebee
    style After fill:#e8f5e9




연관관계 편의 메서드

양방향 관계 관리

양방향 관계를 정말 써야 한다면, 편의 메서드가 필수입니다.

Bad - 직접 설정:

Order order = new Order();
OrderItem item = new OrderItem();

order.getItems().add(item);  // 한쪽만 설정
item.setOrder(order);        // 반대쪽도 설정

// 실수하기 쉬움
order.getItems().add(item);  // 이것만 하고
// item.setOrder(order);  // 이거 깜빡!

Good - 편의 메서드:

@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    // 연관관계 편의 메서드
    public void addItem(OrderItem item) {
        this.items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        this.items.remove(item);
        item.setOrder(null);
    }
}

// 사용
order.addItem(item);  // 양쪽 다 설정됨!

주의사항:

// Bad - 무한 루프 위험
public void addItem(OrderItem item) {
    this.items.add(item);
    item.setOrder(this);  // 이게 다시 addItem() 호출하면?
}

// Good - 방어 로직
public void addItem(OrderItem item) {
    this.items.add(item);
    if (item.getOrder() != this) {  // 중복 호출 방지
        item.setOrder(this);
    }
}




실무에서 배운 교훈

엔티티 설계 체크리스트

6개월 리팩토링을 하면서 만든 체크리스트입니다.

**엔티티 설계 전 체크:**

□ 양방향 관계가 정말 필요한가?
□ cascade 옵션이 적절한가?
□ orphanRemoval이 안전한가?
□ @ManyToOne이 LAZY인가?
□ @JoinColumn이 명시되어 있는가?
□ setter가 없는가?
□ 정적 팩토리 메서드가 있는가?
□ 비즈니스 로직이 엔티티에 있는가?
□ DTO 변환 로직이 분리되어 있는가?

이 체크리스트대로만 해도 80%는 문제없습니다.



“완벽한 설계는 없습니다”

2년간 느낀 건, 완벽한 엔티티 설계는 없다는 겁니다.

  • 비즈니스 요구사항은 계속 변합니다
  • 성능 요구사항도 변합니다
  • 팀 구성원도 변합니다

중요한 건 변화에 대응할 수 있는 구조입니다.

제가 선택한 전략:

  1. 단순하게 시작: 양방향보다 단방향, cascade보다 명시적 저장
  2. 점진적 개선: 성능 문제가 생기면 그때 최적화
  3. 테스트 작성: 리팩토링을 위한 안전망
  4. 문서화: 설계 의도를 주석이나 문서로 남기기

가장 중요한 것:

“1년 후의 나도 이해할 수 있는 코드”

저희 팀 코드 리뷰에서 가장 많이 하는 질문입니다. “6개월 후에 이 코드를 처음 보는 사람이 이해할 수 있을까요?”





시스템 점검 체크리스트

저도 엔티티를 설계할 때 이 항목들을 확인합니다. JPA 엔티티 설계를 고려한다면 참고하시면 좋을 것 같습니다.

  • 단방향 우선: 양방향 연관관계가 정말 필요한가? 단방향으로 충분하지 않은가?
  • cascade 최소화: cascade.ALL이 정말 필요한가? 의도치 않은 삭제/수정이 발생하지 않는가?
  • setter 제거: setter 대신 Builder 패턴이나 생성자를 사용하는가?
  • 불변 객체: Value Object는 불변으로 설계했는가? (final 필드, setter 없음)
  • 순환참조 방지: 양방향 관계에서 toString, equals, hashCode를 조심스럽게 구현했는가?



결론

JPA 엔티티 설계, 처음부터 완벽하게 하기는 어렵습니다.

저도 2년간 수없이 리팩토링했습니다. 양방향을 단방향으로, cascade를 명시적 저장으로, setter를 정적 팩토리로.

근데 후회하지 않습니다.

실패를 통해 배웠고, 지금은 훨씬 나은 코드를 짭니다.


핵심만 기억하세요:

  • 양방향보다 단방향
  • cascade는 신중하게
  • @ManyToOne은 무조건 LAZY
  • setter 대신 의미있는 메서드
  • merge() 대신 변경 감지

이 다섯 가지만 지켜도 유지보수하기 좋은 JPA 코드를 만들 수 있습니다.


다음 편 예고:

마지막 5편에서는 실무에서 가장 자주 발생하는 JPA 안티패턴들을 총정리합니다. 반복문 안에서 save() 호출, 불필요한 양방향 연관관계, 잘못된 트랜잭션 관리 등 지금까지 다룬 내용들을 빠르게 확인할 수 있는 레퍼런스로 정리할 예정입니다.





참고 :

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/
자바 ORM 표준 JPA 프로그래밍 (김영한)
https://vladmihalcea.com/
https://www.baeldung.com/jpa-entities
Effective Java 3/E (조슈아 블로크)




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site