Home 12. 연관 잘 쓰기
Post
Cancel

12. 연관 잘 쓰기

01. 연관의 복잡성

1.1 로딩 설정의 어려움

1
2
3
4
5
6
7
Order order = em.find(Order.class, orderId);
List<OrderLine> orderLines = order.getOrderLines();
for(OrderLine ol: orderLines) {
  Content content = ol.getProduct().getContent();
  System.out.println(content.getTitle() + "" + content.getCast());
}

위 코드는 Order 엔티티에서 시작해서 OrderLine, Product, Content를 접근하고 있다. 모든 연관을 즉시 로딩으로 설정했다면 Order를 로딩하는 시점에 OrderLine, Product, Content를 로딩하기 위한 쿼리도 함께 실행할 것이다. 그렇다고 해서 지연 로딩과 즉시 로딩을 적절하게 섞어 쓰기도 쉽지 않다. 상황에 따라 필요한 연관 객체가 다를 수 있어 특정 연관을 지연 로딩이나 즉시 로딩으로 한정할 수 없다.

1.2 편리한 객체 탐색과 높은 결합도

모든 엔티티를 연관으로 연결하면 객체 탐색을 통해 쉽게 원하는 객체에 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
//객체들이 연관으로 연결되어 있으면 연관된 객체의 데이터를 쉽게 변경할 수 있다.
public class Order {
  private User orderer;

  public void changeShippingAddress(Address newShippingAddress, boolean useUserAddress) {
    this.shippingAddress = newShippingAddress;
    if(useUserAddress) {
      //연관된 User 데이터를 변경
      orderer.setAddress(newShippingAddress);
    }
  }
}

이렇게 한 엔티티에서 다른 엔티티의 상태를 변경하는 기능을 실행하면 엔티티가 서로 강하게 엮이게 되면서 서로 수정을 어렵게 만드는 원인이 될 수 있다.

02. 연관 범위 한정과 식별자를 통한 간접 참조

엔티티 간의 참조가 많아질수록 한 엔티티의 기능을 변경할 때 여러 엔티티를 함께 수정해야 할 가능성이 커진다. 이는 코드 변경을 어렵게 만드는 원인이 될 수 있다. 그러기 위해 다음의 방법을 적용한다.

  • 연관 범위를 도메인을 기준으로 한정
  • 도메인을 넘어서는 엔티티 간에는 식별자를 이용한 간접 참조

위 그림을 보면 특정 영역 안에서는 연관을 이용해 직접 참조를 유지했지만, 영역을 벗어나는 관계에 대해서는 식별자를 이용해서 간접적으로 참조했다.

식별자를 통한 간접 참조 방식을 사용하면 식별자로 연관된 엔티티를 검색하는 과정이 추가되기 때문에 다소 코드가 길어진다. 하지만 앞선 로딩 설정의 어려움과 엔티티 간의 결합도 증가를 완화할 수 있다.

03. 상태 변경 관련 기능과 조회 관련 기능

연관을 한정해서 사용하면 설정이나 코드 복잡도가 줄어드는 장점이 있다. 하지만 데이터 조회시 여러 엔티티를 직접 조회해야 하는 불편함도 있다. 이런 불편함을 해결하는 방법으로 상태를 변경하는 기능과 조회하는 기능을 분래해서 생각하는 것이다.

데이터를 새로 생성하거나 수정하거나 삭제하는 상태 변경 기능은 한두 개의 엔티티만 로딩하기 때문에 식별자로 연관된 엔티티를 직접 로딩해야하는 불편함이 크지 않다.

조회 관련 기느은 한 개 이상의 엔티티를 함께 조회하는 경우가 많다. 이렇게 여러 엔티티의 데이터를 조합해야 하는 조회 기능은 조회 기능애 맞는 모델을 따로 구현하는 것을 고려해보자

1
2
3
4
5
6
7
8
9
10
11
//예) 주문 목록 > Order + OrderLine + Product
public class OrderSummary {
  private String id;
  private String ordererName;
  private Timestamp orderDate;
  private int totalAmounts;
  private String firstProductName;
  private String firstProductId;

  //...
}

도메인이 커질수록 한 개의 모델로 상태 변경 기능과 조회 기능을 구현하기 어려워진다. 로딩 방식의 문제뿐 아니라 상태 변경 시점과 조회 시점에 필요한 데이터가 다르기 때문이다.

조회 시점에 필요한 데이터와 변경 시점에 다루는 데이터의 차이가 클수록 조회 전용 모델을 별도로 만들 것을 고려해 봐야 한다.

04. 식별자를 공유하는 1:1 연관이 엔티티와 밸류 관계인지 확인

모든 테이블을 엔티티로 매핑하는 것은 모델의 의미를 약화시킬 수 있다. 한 도메인 영역세 속하면서 식별자 공유 방식으로 1:1 연관을 맺는 두 엔티티가 동일한 라이프사이클을 갖게 된다. 그렇게 되면 이 관계는 두 엔티티의 1:1 연관이 아닌 엔티티와 밸류 관계일 가능성이 크다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//1:1 연관
@Entity
public class Appeal {
  @Id
  private String id;

  @OneToOne(mapperdBy="appeal")
  private AppealStatus status;
}

@Entity
@Table(name="appeal_atatus")
public class AppealStatus {
  @Id
  priavate String id;

  @OneToOne
  @primaryKeyColumn
  priavate Appeal appeal;

  //...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//엔티티-밸류 관계
@Entity
SecondaryTable(
  name="appeal_status",
  pkJoinColumns=@PrimaryKeyJoinColumn(
        name="id",
        referencedColumnName="id")
)
public class Appeal{
  @Id
  private String id;

  @Embedded
  private AppealStatus status;
  //...
}

@Embeddedable
public class AppealStatus{
  //...
}

05. 엔티티 콜렉션 연관과 주의 사항

5-1. 1:N 연관보다 N:1 연관 우선

1:N 연관을 사용할 때 주의할 점은 N에 해당하는 부분을 실제 기능에서 어떤 식으로 사용하는지 알아야 한다는 점이다.

1
2
3
4
5
6
7
8
@Entity
public class Hotel {
  //...

  public List<Review> getLatestReview(int cnt) {
    return reviews.stream().limit(cnt).collect(Collectors.toList());
  }
}

위 예에서 최근 리뷰 n개를 제공하는 메서드를 제공한다. 그런데 이 코드에서 호텔과 관련된 리뷰의 개수가 만 개면 reviews는 만 개의 review 객체를 갖게 된다. 필요한 것은 최근에 등록한 n개지만, 만 개를 로딩하게 된다. 따라서 Hotel과 연관된 Review가 많을수록 이 기능은 실행 속도가 느려져서 성능에 문제를 일으킨다.

1:N연관에서 콜렉션에 보관된 엔티티를 일부만 사용하는 기능이 있다면 1:N 연관을 사용하면 안 된다. 대신 N:1 연관을 사용해야 한다. N:1 연관을 사용하면 코드는 다소 길어지지만 1:N 연관을 사용할 때 발생하는 성능 관련 문제를 해결할 수 있다.

5-2. 엔티티 간 1:N 연관과 밸류 콜렉션

엔티티간 1:N 연관으로 보이는 것 중에 실제로는 밸류에 대한 콜렉션 연관인 경우도 있다. 처음 JPA를 사용할 때 자주 접하는 실수는 모든 테이블에 대해 엔티티 클래스를 작성하는 것이다.

대표적인 예가 주문과 개별 주문 항목이다. 이 때 주문과 개별 주문 항목은 각각 별도 테이블과 매핑되나 개별 주문 항목은 자신만의 식별자를 갖는 엔티티라기 보다는 주문에 포함된 밸류이다. 따라고 이 둘은 1:N 관계가 아니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Order{
  @ElementCollection
  @CollectionTable(
    name="order_line",
    joinColumns=@JoinColumn(name="order_id")
  @OrderColumn(name="idx"))
  private List<OrderLine> orderLines = new ArrayList<>();
  //...
}

@Embeddedable
public class OrderLine {
  //...
}

단순히 테이블이 따로 존재한다고 해서 엔티티 간의 1:N 연관으로 매핑하는 것은 옳지 않다. 1:N 연관이 필요하다면 해당 연관이 엔티티 간의 연관인지 밸류 콜렉션인지 검토 해야 한다. 한 도메인 영역에 속하면서 1:N 연관을 맺는 엔티티가 동일한 라이프 사이클을 갖는다면 엔티티 콜렉션이 아닌 밸류 콜렉션이 더 적합하지 않은지 확인하도록 하자

5-3. M:N 연관 대체하기 : 연관 엔티티 사용

단방향 연관이든 양방향 연관이든 M:N 연관은 구현을 복잡하게 만들기 때문에 최대한 피하는 게 좋다. M:N 연관 엔티티를 사용하는 방법은 조인 테이블을 엔티티로 매핑하는 것이다. 이 방법은 코드가 복잡해지는 것보다 나은 선택이다.

1
2
3
4
5
6
7
8
9
10
11
//CastMap 클래스의 식별자로 사용하기 위한 castMapId 클래스
@Embeddedable
public class CastMapId implements Serializable {
  @Column(name="performace_id")
  private String performanceId;

  @Column(name="person_id")
  private String personId;

  //생성자, getter, equals, hashCode()...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
@Table(name="perf_person")
public class CastMap {
  @Id
  private CastMapId id;

  @ManyToOne
  @JoinColumn(name="performance_id", insetable=false, updatable=false)
  private Performance performance;

  @ManyToOne
  @JoinColumn(name="person_id", insertable=false, updatable=false)
  private Person person;

  //생성자, getter...
}

1
2
3
4
5
6
7
8
em.getTransaction().begin();

Performance perf = em.find(Performance.class, "PF002");
Person person = em.find(Person.class, "P05");
CastMap castMap = new CastMap(perf, person);
em.persist(castMap);

em.getTransaction().commit();
This post is licensed under CC BY 4.0 by the author.