이번에는 Spring JPA에서 테이블을 조인하는 방법과 이 후 데이터를 조회하는 방법에 대해서 정리해보고자한다.
현재, 만들고 있는 API를 예시로 들어 작성해보고자 한다.
먼저, 조인을 위해서는 두 테이블의 관계를 고려하는 것이 중요하다.
단방향 : 한쪽 테이블은 한쪽 테이블을 알지만 다른 쪽 테이블은 모르는 것.
양방향 : 서로 아는 구조
양방향의 문제점
- ToString, hashcode, equals 등 사용시 무한 루프로 인한 StackOverFlow의 가능성
- 데이터 정합성 문제 : 업데이트시 양쪽 모두 업데이트 해야한다.
-> 이러한 문제를 해결하기 위해 양방향 설정시 MappedBy를 통해 관계의 주인을 설정하고 일련의 규칙을 만들어준다.
- 객체의 두 관계 중 하나를 연관관계의 주인으로 지정합니다.
- 연관관계의 주인만이 외래키를 관리합니다. (등록, 수정)
- 주인이 아닌 쪽은 읽기만 가능합니다.
- 주인은 mappedBy 속성 사용 X
- 주인이 아니면 mappedBy 속성으로 주인 지정합니다.
보통 일대다 관계에서 관계의 주인은 '다'이다. 부모테이블은 '일'이고 자식테이블은 '다'이다. 헷갈리지 말것.
예시로, 학생-학교 관계에서 학교가 부모, 자식은 학생이다. 하지만 두 관계에서 관계의 주인은 학생이다. 또한 학생만이 본인을 수정하고 등록한다. 학교는 읽기만 가능하다.
API
음식점이 쉬는 날을 등록하고자 한다.
사장은 자신의 가게의 쉬는날을 등록하고 삭제하기를 원한다.
또한 어플의 이용자는 가게의 쉬는날을 조회하고자 한다.
Register
HTTP 통신을 사용하며
데이터는 다음과 같이 주어진다.
가게 이름이 주어지고, date를 HashMap<String, String>으로 직렬화하여 서버에 전송된다.
{
"storeName" : "steakHouse8",
"date": {
"date1": "1월 6일",
"date2": "1월 7일"
}
}
또한 데이터베이스에 저장할 테이블의 기본 구조는 다음과 같다.
두 테이블은 조인 관계이고 1:N관계이다.
Store Rest Days(가게 쉬는날 테이블)
- Days Id
- Store Name
Store Rest Days Map(가게 쉬는날 조인 테이블)
- Day Id
- Days Id
- Date
JPA에서 코드를 테이블을 만들기 위한 Entity는 다음과 같다.
1. Store Rest Days
- 테이블이름을 비슷하게 설정하고 인덱스도 생성해 놓았다.
- 아이디는 테이블에 데이터 생성시 없는 아이디 중 구별 가능하게 설정하도록 하였다.
- 객체 생성시 가게이름을 받아 우선 생성가능하게 해두었다.
- @OneToMany 애너테이션을 통해 이 테이블이 1:N 관계임을 설정해두었고 Spring내의 StoreRestDaysEntity 즉, 현재 클래스에 의해 StoreRestDaysMapEntity가 매핑됨을 설정해두었다. 또한 LinkedHashSet으로 설정하여 날짜가 입력된 순서대로 저장되도록 설정하였다.
- fetch = FetchType.LAZY : 해당 필드를 사용할 때 연관 엔터티가 필요한 경우에만 로딩되며, 그렇지 않은 경우에는 로딩되지 않음을 의미한다. 즉, 날짜 정보를 조회할 때는 Entity가 로딩되지만 그렇지 않으면 로딩 되지 않는다(<-> FetchType.EAGER)
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "StoreRestDays", indexes = {@Index(name = "idx_storeName", columnList = "storeName")})
public class StoreRestDaysEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long daysId;
private String storeName;
@OneToMany(mappedBy = "storeRestDaysEntity", fetch = FetchType.LAZY)
private Set<StoreRestDaysMapEntity> childSet = new LinkedHashSet<>();
public StoreRestDaysEntity(String storeName) {
this.storeName = storeName;
}
}
2. Store Rest Day Map Entity
- 위에서 설명한 내용과 겹치는 내용은 생략한다.
- @ManyToOne을 통해 현재 테이블이 조인된 테이블임을 선언한다.
- @JoinColumn을 통해 부모 테이블의 어떤 필드로 조인된것인지 선언한다.
- @JsonBackReference를 통해 부모 Entity의 클래스를 선언한다.
@Entity
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "StoreRestDaysMap")
public class StoreRestDaysMapEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long dayId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "days_id")
@JsonBackReference
private StoreRestDaysEntity storeRestDaysEntity;
private String date;
public StoreRestDaysMapEntity(String date, StoreRestDaysEntity storeRestDaysEntity) {
this.date = date;
this.storeRestDaysEntity = storeRestDaysEntity;;
}
}
3. JPA 작성
- 우선 ID는 DB에 저장시 생성되게 설정해두었기에 DAOImpl에서 로직이 실행되어야한다.
- Parent를 우선 생성하고 저장하여 아이디를 생성하게 설정
- Child 생성시 Parent 넣고 생성
- Parent에 Child Set을 넣는다.
- Transactional 상황임을 사용하여 데이터 저장
StoreRestDaysEntity parent = new StoreRestDaysEntity(storeName);
storeRestDayRepositoty.save(parent);
Set<StoreRestDaysMapEntity> childs = new LinkedHashSet<>();
Set<String> keys = restDayDTO.getDate().keySet();
for (String key : keys) {
StoreRestDaysMapEntity child = new StoreRestDaysMapEntity(restDayDTO.getDate().get(key), parent);
storeRestDayMapRepositoty.save(child);
childs.add(child);
}
parent.setChildSet(childs);
Get Data
N+1 문제
- N+1 문제란 데이터베이스에서 쿼리를 실행할 때 한번의 쿼리문이 추가적인 N번의 쿼리를 실행시켜 발생하는 문제를 일컫는다. 예를 들어, 위의 예시에서는 만약 fetch 전략을 LAZY로 설정시 발생한다. storeName으로 조회시 해당 부모 테이블에 해당하는 자식 테이블의 데이터은 LAZY 전략으로 인해 조회되지 않고 자식 테이블도 같이 조회를 원할시 하나하나 조회를 해야하기에 여러번의 쿼리문을 발생시키는데 이는 빅데이터 등 많은 데이터를 조회할 때 문제가 발생한다.
위에서 LAZY 전략으로 설정하여 부모 테이블만 조회되게끔 설정해두었다. 하지만 자식 테이블의 내용이 필요할 경우 따로 쿼리문을 작성해줘야한다는 번거로움이 있다. 이로 인해 데이터를 찾고자 할 때, 지연로딩에 따른 N+1문제가 발생할 수 있다. 하지만 실행시 불필요한 데이터는 가져오지 않을 수 있다는 장점이 있다.
- "따라서 LAZY 전략으로 설정해두고 밑의 방법으로 N+1 문제를 해결하는 전략을 채택하고자 한다."
예를 들어, 위의 테이블에서 부모테이블에서는 가게 이름에 맞는 DaysID를 가져 온 후 자식 테이블에서 그것에 맞는 데이터를 가져오는 경우를 보면 아래와 같다.
해결 전 기본 요청 과정 (LAZY 전략에 따른 N+1 문제)
1. 가게의 이름에 맞는 DaysID가 반환 (여러개 존재)
2. 그 아이디에 맞는 데이터를 자식테이블에서 조회 (여러번의 쿼리문 실행됨)
해결 : Left Fetch Join
1. BASIC JPQL
2. BASIC JPQL + Entity Graph
3. Query DSL
1. Left Fetch Join
Left Fetch Join이란 기본적으로 검색하고자하는 데이터를 왼쪽 테이블(부모 테이블)의 행에서 찾고 그 찾은 데이터에 해당하는 데이터를 오른쪽 테이블(자식 테이블)에서 찾아서 같이 반환하고 없다면 Null을 반환한다.
간단하게 설명하면, 부모테이블을 조회할 때, 자식테이블을 담아서 반환한다.
이 때, 부모테이블의 데이터에 해당하는 자식테이블이 없다면 자식테이블의 칼럼을 Null 로 반환하게 된다.
만약, 가게 사장이 여러번에 걸쳐 쉬는날을 등록을 했다. 등록시마다 다른 아이디로 테이블에 영속화된다.
만약 손님이 가게 쉬는날을 조회하기 위해 가게이름을 검색했다면 여러가지의 부모테이블이 조회되고 부모테이블 하나하나 맞는 여러 자식테이블이 여러번 조회될 것이다.
하지만 Left Fetch Join을 사용하면 자식 테이블에서 해당되는 데이터를 담아서 가져오기 때문에 한번에 쿼리문으로 조회된다.
JPQL
@Query("SELECT DISTINCT srd FROM StoreRestDaysEntity srd LEFT JOIN FETCH srd.childSet srdm WHERE srd.storeName = :storeName")
List<StoreRestDaysEntity> findByStoreName(@Param("storeName") String storeName);
- LEFT JOIN FETCH : 왼쪽 조인 조회 + FETCH : Lazy 방지(Lazy를 실행되지 못하게 막는다.)
- DISTINCT : 부모 클래스 엔터티 중복 방지
SQL
select
distinct s1_0.days_id,
c1_0.days_id,
c1_0.day_id,
c1_0.date,
s1_0.created_at,
s1_0.store_name,
s1_0.updated_at
from
store_rest_days s1_0
left join
store_rest_days_map c1_0
on s1_0.days_id=c1_0.days_id
where
s1_0.store_name=?
2. Entity Graph with JPQL
- 원리는 위의 Left Fetch Join과 같다. 다만, @EntityGraph 애너테이션을 통해 childSet이 LAZY 되는 것을 막고 데이터를 가져온다.
@EntityGraph(attributePaths = "childSet")
@Query("SELECT srd FROM StoreRestDaysEntity srd WHERE srd.storeName = :storeName")
List<StoreRestDaysEntity> findByStoreName(@Param("storeName") String storeName);
3. Query DSL
- 위와 같은 방식으로 데이터를 가져온다.
List<StoreRestDaysEntity> check = queryFactory
.select(parent)
.distinct()
.from(parent)
.leftJoin(parent.childSet, child)
.fetchJoin()
.where(parent.storeName.eq(storeName))
.fetch();
'Server Development > Data API' 카테고리의 다른 글
JPA - Query DSL Method (0) | 2023.05.12 |
---|---|
JPA - Query DSL (0) | 2023.05.11 |
Cache - Redis (0) | 2023.04.06 |
JPA - @Query with Spring JPA (0) | 2023.04.05 |
JPA - Query Method with Spring JPA (0) | 2023.04.05 |