BACKEND/JAVA & SPRING

JPA 상속 관계 매핑

이-프 2023. 11. 7. 02:15

JPA를 프로젝트에 적용하면서 JPA 상속 관계 매핑이 왜 필요하고, 어떤 관계를 어떻게 매핑해야하는지 공부했었다. JPA를 공부하는 단계에서 상속 관계 매핑이 되게 어렵게 다가왔지만, 공부하면서 앞으로 DB 설계시 필수적으로 배워야함을 깨닫게 됐다. 이를 잘 정리하여 프로젝트의 성격과 규모에 따라 필요한 상속 방법을 선택해야겠다. 🌱

JPA 상속 관계 매핑이란?

설계 요구사항 (DB 설계 계획)

  1. 여행 정보는 여러 여정 정보로 구성됩니다. (여행 : 여정 = 1 : n)
  1. 여행 정보에는 다음 정보가 필수 항목으로 포함되어야 합니다.
    1. [여행]의 이름, 일정(출발 날짜, 도착 날짜), 국내/외 여부
  1. 여정 정보에는 다음 항목으로 분류됩니다.
    1. 이동 (이동 수단, 출발지(장소명), 도착지(장소명), 출발 일시, 도착 일시)
    1. 숙박 (숙소명, 체크인 일시, 체크아웃 일시)
    1. 체류 (장소명, 도착 일시, 출발 일시)

즉, 설계해야할 DB는 크게 Trip, Itinerary, Accommodation, Transportation, Visit이다.

DB는 상속을 지원하지 않으므로 논리 모델을 물리 모델로 구현할 방법이 필요한데 이는 크게 3가지가 존재한다.

  • JOINED 전략
  • SINGLE TABLE 전략
  • TABLE PER CLASS 전략

위 3가지 전략들을 직접 프로젝트에 적용해보자.

상속 관계 매핑 전략

0. JPA에서 매핑시 필요한 Annotation

JPA에서 매핑을 할 때, @Ingeritance, @DiscriminatorColumn, @DiscriminatorValue Annotation을 사용하여 상속 관계를 매핑할 수 있다.

  1. @Ingeritance
    @Inheritance(strategy = InheritanceType.XXX) //XXX에는 상속 Type을 작성한다.
    • default 상속 Type은 SINGLE_TABLE 전략이다.
    • 상속 Type
      • InheritanceType.JOINED
      • InheritanceType.SINGLE_TABLE
      • InheritanceType.TABLE_PER_CLASS
  1. @DiscriminatorColumn
    @DiscriminatorColumn(name="XXX") // XXX에는 하위 클래스를 구분하기 위한 칼럼을 작성한다.
    • default name은 DTYPE이다.
    • 부모 클래스에 선언하고, 하위 클래스를 구분하는 용도의 Annotation이다.
  1. @DiscriminatorValue
    @DiscriminatorValue("XXX") // XXX에는 클래스 이름을 작성한다.
    • default는 클래스 이름이다.
    • 하위 클래스에 선언하는 애노테이션이다.
    • 앤티티 저장 시 슈퍼타입의 구분 칼럼에 저장할 값을 지정한다.

1. JOINED전략을 사용하는 case

/** Trip.java **/
@OneToMany(mappedBy = "trip", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Itinerary> itineraries = new ArrayList<>();


/** Itinerary.java **/
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "type")
public abstract class Itinerary {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "trip_id")
    protected Trip trip;

    protected String itineraryName;
}
  • 기존에는 Trip과 Itinerary과 1:N 관계이자 Itinerary를 Accommodation, Transportation, Visit 엔티티가 상속받으므로 각각을 모두 테이블로 만들고 자식 엔티티가 부모 엔티티의 PK를 받아서 PK+FK로 사용한다. 이때, 부모 테이블에 식별자(Discriminator)와 DTYPE을 두고 자식테이블을 따로 생성한다.
  • 그러므로 여정에 관련된 CRUD API는 모두 DTYPE에 따라 각 엔티티의 DTO, Repository을 사용하여 구현한다.
  • JOINED 전략은 가장 정규화된 방법이자 객체지향적인 방법이다.
  • 그러나 이때, JPA N+1문제가 발생한다. 이는 1번의 쿼리를 날렸을 때, 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 문제로 JPA Fetch 전략이 LAZY 전략으로 데이터를 가져온 이후에 연관관계인 하위 엔티티를 다시 조회하는 경우에 발생한다.
  • Fetch타입이 Lazy일 경우, JPQL에서 만든 SQL을 통해 데이터를 조회하고, 지연로딩이므로 추가 조회를 할 수 없다. 하지만, Itinerary는 Accommodation, Transportation, Visit과 같은 하위 엔티티가 존재하고 CRUD 시 하위 엔티티를 갖고 작업을 해야하므로 추가 조회가 발생하여 N+1 문제가 발생한다.
  • 이는 Fetch타입이 Eager여도 동일하다. Eager은 하위 엔티티들을 추가로 조회할 수 있지만 그 과정으로 인해 N+1문제가 발생한다.

🚀 JPA N+1 문제를 해결하는 방법

  • 🚫 FetchJoin을 사용
    • N+1의 본질적인 원인은 Itinerary만 조회하고 상속받은 Accommodation, Transportation, Visit은 따로 조회하기 때문이다.
    • 즉, 미리 Itinerary와 Accommodation, Transportation, Visit을 다 JOIN하여 한번에 모든 데이터를 가져올 수 있다면 본질적으로 N+1문제가 발생하지 않을 것이다.
    • 기본적으로 JPA는 엔티티간의 자동 조인을 수행하지 않으므로 @Query() 를 작성하여 엔티티간의 join을 수행한다.
    @Query("select t from trip  t join fetch t.itinerary")  

2. Single Table 전략을 사용하는 case

/** Itinerary.java **/
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn
public abstract class Itinerary {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "trip_id")
    protected Trip trip;

    protected String itineraryName;
}
  • 서비스 규모가 크지 않고, 굳이 JOINED 전략을 사용해서 복잡해지지 않아도 판단할 때 사용하는 전략이다.
  • SINGLE_TABLE 전략은 JOINED 전략과 마찬가지로 Itinerary 엔터티를 하위 Accommodation, Transportation, Visit 엔터티가 상속받도록 한다.
  • SINGLE_TABLE 전략은 JOINED 전략과 달리 @DiscriminatorColumn을 선언하지 않아도 DTYPE 칼럼이 생성이 된다.
  • 따라서 조회 쿼리가 간단해지면서 N+1 문제를 피할 수 있어 앞서 설명한 JOINED 전략의 단점을 극복할 수 있다.
  • 단, 테이블의 필드값에 NULL이 들어갈 수도 있으며 테이블의 크기가 매우 커질 수 있다.

3. TABLE PER CLASS 전략을 사용하는 case

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Itinerary {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    protected Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "trip_id")
    protected Trip trip;

    protected String itineraryName;
}
  • 얼핏보면 JOINED 전략과 유사하지만, 상위 클래스의 칼럼값들을 하위 클래스로 모두 내린다. 즉, 하위 클래스에는 모두 상위 클래스의 칼럼값들이 존재한다.
  • 구현 클래스마다 테이블을 생성하는 전략이다. 이때, Itinerary는 실제 생성되는 테이블이 아니므로 abstract 클래스로 구현된다.
  • SINGLE TABLE 전략에 비해 NULL 한 칼럼이 많지 않다.
  • 하지만, 각 하위 테이블에 중복 데이터가 다량으로 발생한다.

4. Itinerary Entity 에 Accommodation, Transportation, Visit Entity 모두 구현

  • 모든 Entity들의 필드를 Nullable하게 Itinerary에 넣은 뒤, 이를 구별할 수 있는 type 필드를 추가하여 0 = Accommodation, 1 = Transportation, 2 = Visit으로 구별한다.
  • 위와 같은 문제점을 제출 기한 마지막날에 postman으로 테스트하면서 확인했고, JPA에 대해 정확한 지식을 보유하고 있지 않아 본질적으로 해결할 수 있는 방식인 Itinerary Entity에 모든 하위 Entity들의 필드값을 넣었다.
    @SuperBuilder
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Entity
    public class Itinerary {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "trip_id")
        private Trip trip;
    
        @Comment("0: Accommodation, 1: Transportation, 2: Visit")
        private int type;
    
        private String itineraryName;
        /** 숙박 **/
        private String accommodationName;
        private String accommodationRoadAddressName;
        private LocalDateTime checkIn;
        private LocalDateTime checkOut;
    
        /** 이동 **/
        private String transportation;
        private String departurePlace;
        private String departurePlaceRoadAddressName;
        private String destination;
        private String destinationRoadAddressName;
    
        /** 체류 **/
        private String placeName;
        private String placeRoadAddressName;
        private LocalDateTime arrivalTime;
        private LocalDateTime departureTime;
    }

앞으로 어떻게 상속관계를 써야할까?

  • 기본적으로는 JOINED 전략을 사용한다.
    • Toy #2 Project를 마무리한 뒤 멘토님께 Entity 상속관계 매핑에서 어려움을 겪었고 그로인해 개발 중에 상속관계를 바꿨던 경험에 대해 말씀을 드렸었다. 실제로 프로젝트 기간 중 팀원들과 상의했을 때, 프로젝트 규모가 작기에 JPA N+1문제로 JOIN을 직접 QUERY로 작성해서 데이터를 받는게 효율적이지 못하다 판단하여 SINGLE TABLE 전략을 사용했었다.
    • 멘토님께서는 2번정도의 JOIN은 크게 data loading 지연 문제에 어긋나지 않으므로 JOIN 전략으로 바꿔서 해보는 것을 추천했다. 실제로 이 포스팅을 작성하면서 왜 JOIN전략을 더 사용해야 하는지 깨닫게 됐다.

      ∴ 심플하고 확장의 가능성이 적으면 SINGLE TABLE 전략을 추천하지만, TOY Project는 기획을 추가하여 점차 개발을 확장해 나아가고 있기에 JOIN 전략이 더 효과적이다.

🔗 참고한 url

https://truehong.tistory.com/147

https://velog.io/@mohai2618/JPA에서-fetchJoin을-사용할-때-주의해야할-점


Uploaded by N2T

'BACKEND > JAVA & SPRING' 카테고리의 다른 글

Filter & Interceptor  (0) 2023.11.09
Dispatcher Servlet이란?  (0) 2023.11.09
@Builder란?  (0) 2023.11.01
MariaDB + JPA 연동  (0) 2023.11.01
올바른 URI 설계 - Path Variable 과 Query Parameter  (1) 2023.10.31