JPA를 프로젝트에 적용하면서 JPA 상속 관계 매핑이 왜 필요하고, 어떤 관계를 어떻게 매핑해야하는지 공부했었다. JPA를 공부하는 단계에서 상속 관계 매핑이 되게 어렵게 다가왔지만, 공부하면서 앞으로 DB 설계시 필수적으로 배워야함을 깨닫게 됐다. 이를 잘 정리하여 프로젝트의 성격과 규모에 따라 필요한 상속 방법을 선택해야겠다. 🌱
JPA 상속 관계 매핑이란?
설계 요구사항 (DB 설계 계획)
- 여행 정보는 여러 여정 정보로 구성됩니다. (여행 : 여정 = 1 : n)
- 여행 정보에는 다음 정보가 필수 항목으로 포함되어야 합니다.
- [여행]의 이름, 일정(출발 날짜, 도착 날짜), 국내/외 여부
- 여정 정보에는 다음 항목으로 분류됩니다.
- 이동 (이동 수단, 출발지(장소명), 도착지(장소명), 출발 일시, 도착 일시)
- 숙박 (숙소명, 체크인 일시, 체크아웃 일시)
- 체류 (장소명, 도착 일시, 출발 일시)
즉, 설계해야할 DB는 크게 Trip, Itinerary, Accommodation, Transportation, Visit이다.
DB는 상속을 지원하지 않으므로 논리 모델을 물리 모델로 구현할 방법이 필요한데 이는 크게 3가지가 존재한다.
- JOINED 전략
- SINGLE TABLE 전략
- TABLE PER CLASS 전략
위 3가지 전략들을 직접 프로젝트에 적용해보자.
상속 관계 매핑 전략
0. JPA에서 매핑시 필요한 Annotation
JPA에서 매핑을 할 때, @Ingeritance
, @DiscriminatorColumn
, @DiscriminatorValue
Annotation을 사용하여 상속 관계를 매핑할 수 있다.
- @Ingeritance
@Inheritance(strategy = InheritanceType.XXX) //XXX에는 상속 Type을 작성한다.
- default 상속 Type은 SINGLE_TABLE 전략이다.
- 상속 Type
InheritanceType.JOINED
InheritanceType.SINGLE_TABLE
InheritanceType.TABLE_PER_CLASS
- @DiscriminatorColumn
@DiscriminatorColumn(name="XXX") // XXX에는 하위 클래스를 구분하기 위한 칼럼을 작성한다.
- default name은 DTYPE이다.
- 부모 클래스에 선언하고, 하위 클래스를 구분하는 용도의 Annotation이다.
- @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