문제점
poi_basic 테이블 만을 조회하려했는데, 아래와 같이 N + 1 현상이 일어나고 있었다.
현상황
- poi_basic 테이블과 poi_facility 테이블은 OneToOne관계이다.
- Entity로 DB 조회
테이블 조회 코드
고찰
그렇다면 왜 OneToOne관계에서 N+1 문제가 생기는지 생각해보았다.
분명 양쪽 테이블에 지연로딩(Lazyloading)을 설정했었는데, 지연로딩이 제대로 적용되지 않았던 것이다.
자 그럼 왜 N+1이 발생하는지 알아보자.
N+1이 발생하는 이유는 프록시 초기화가 일어났기 때문이다.
예를들어, 아래와 같은 테이블 연관관계가 있다고 하자.
Q. [Order_Item]테이블을 조회하는데 [Order]테이블과 [Product]를 항상 함께 조회해야 하나?
답은 “No”다. 한 테이블을 조회하는데, 연관관계에 있는 테이블 모두를 조회한다면 우리는 JPA를 사용할 필요가 없다.(성능이슈가 심각하기때문)
그래서 이때 등장하는 개념이 프록시인데, Order_Item의 product와 order를 프록시로 가져오는 것이다.
JPA에서 프록시는 실제 엔티티 객체 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체를 의미
프록시의 특징
- 실제 클래스를 상속받아서 만들어짐
- 실제 클래스와 겉모양이 같다
- 사용하는 입장에서 진짜 엔티티 객체인지, 프록시 객체인지 구분하지 않고 사용하면 됨
- 프록시 객체는 실제 객체의 참조(target)를 보관
- 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출
즉, Order_Item만 조회하고 싶다면 Order_Item에 연관된 엔티티인 Product와 Order는 프록시로 조회하면 된다. 여기서 프록시는 실제 값이 들어있지는 않다(빈깡통)
프록시 초기화
프록시로 받아왔지만, 이것을 실제 엔티티처럼 사용하려면 데이터베이스를 조회해서 실제 엔티티 객체를 생성 후 해당 객체를 참조하게 되는데, 이를 프록시 객체의 초기화라 한다.
N+1 발생이유
프록시를 초기화 하기위해 데이터베이스에 조회를 하게 되는데, 이 때 N+1현상이 발생한다.
해결방법
- 페치 조인(fetch join)
- DTO 조회
페치 조인 (fetch join)
- SQL에서 사용하는 조인의 종류가 아님
- JPQL에서 성능 최적화를 위해 제공해주는 기능
- 연관된 Entity나 Collection을 SQL 1번에 함께 조회하는 기능
- Inner Join과 Left Join 모두 가능
DTO 조회
이 방법에 대해선 우아한 형제 콘서트에서 발표한 좋은 자료가 있으니 참고하자.
간단하게 요약하면,
- 조회할땐 Entity 보다는 Dto를 우선적으로 가져오기
- + DTO조회할때 필요한 칼럼만 가져오기(매개변수로 받은 칼럼은 제외하기)
+ DTO조회할때 필요한 칼럼만 가져오기(매개변수로 받은 칼럼은 제외하기)
- SELECT 칼럼에 Entity는 자제하기
프로젝트에 적용하기
현재 Entity로 조회하는 쿼리를 DTO조회 방식으로 변경하려고 한다.
적용과정은 다음과 같다.
- 메서드 return값을 dto로 변경한다.
- 기존의 selectFrom해서 select로 변경(Projections.fields 적용 ⇒ dto로 조회하기 위함)
- poiBasic객체는 QPoiBasic클래스를 static import한 객체이다.
- 검색쿼리이기 때문에 null체크를 해줘야하기에, BooleanExpression으로 parameter null체크
- dist객체는 디비에 저장된 위도 경도 값과, 파라미터로 받은 위도와 경도로 MySQL 내장 function인 ST_Distance_Sphere함수로 거리를 구한 값이다.
- QueryDSL에서 DB function을 사용하려면 Expressions.stringTemplate을 사용해야함.
- QueryDSL은 ANSI 함수의 경우 함수명이 등록되어 지원하지만, 함수명이 등록되어 있지 않은 경우엔 따로 Dialect를 상속 후 함수명을 등록해줘야한다.
import org.hibernate.dialect.MariaDB103Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;
public class MariaDBDialectCustom extends MariaDB103Dialect {
public MariaDBDialectCustom() {
super();
registerFunction("ST_Distance_Sphere", new StandardSQLFunction("ST_Distance_Sphere", StandardBasicTypes.STRING));
}
}
spring:
jpa:
database-platform: com.humanf.docent_backend.config.MariaDBDialectCustom
- select구문에서 entity객체와 dto객체의 명이 일치 하지않으면 mapping되지않음
⇒ 별칭(as)로 매핑시키기!
코드 결과
public List<PoiBasicResponseDto> findAllBySearchType(PoiBasicSearchCondition condition) {
StringTemplate dist = getDist(condition.getLongitude(), condition.getLatitude());
return queryFactory
.select(Projections.fields(PoiBasicResponseDto.class,
poiBasic.id.as("idx_pb"),
poiBasic.poiNm.as("poi_nm"),
poiBasic.barrierFree.as("barrier_free"),
poiBasic.poiAddress.as("poi_address"),
poiBasic.latitude,
poiBasic.longitude,
dist.as("distance"),
poiBasic.poiDesc.as("poi_desc"),
poiBasic.operationtime,
poiBasic.telNo.as("tel_no"),
poiBasic.inouts,
poiBasic.takentimeGeneral.as("takentime_general"),
poiBasic.takentimeBf.as("takentime_bf"),
poiBasic.tourDifficulty.as("tour_difficulty"),
poiBasic.disabledCd.as("disabled_cd"),
poiBasic.chaoticCd.as("chaotic_cd"),
poiBasic.vidUrl.as("vid_url"),
poiBasic.imgFile1.as("img_file1"),
poiBasic.imgFile2.as("img_file2"),
poiBasic.imgFile3.as("img_file3"),
poiBasic.imgFile4.as("img_file4"),
poiBasic.lineCnt.as("line_cnt"),
poiBasic.spotCnt.as("spot_cnt"),
poiBasic.arCnt.as("ar_cnt"),
poiBasic.toiletCnt.as("toilet_cnt"),
poiBasic.disabledtoiletCnt.as("disabledtoilet_cnt"),
poiBasic.parkingCnt.as("parking_cnt"),
poiBasic.disabledparkingCnt.as("disabledparking_cnt"),
poiBasic.createDt.as("create_dt"),
poiBasic.updateDt.as("update_dt"))
)
.from(poiBasic)
.where(
distLoe(dist, condition.getDistanceRange()),
tourDiffEq(condition.getTourDifficulty()),
disabledCdEq(condition.getDisabledCd())
)
.orderBy(dist.asc())
.fetch();
}
private StringTemplate getDist(BigDecimal longitude, BigDecimal latitude) {
if (latitude != null && longitude != null) {
System.out.println("longitude: " + longitude + ", \t" + "latitude: " + latitude + ", \t");
return Expressions.stringTemplate("ST_Distance_Sphere({0},{1})",
Expressions.stringTemplate("POINT({0},{1})", longitude, latitude),
Expressions.stringTemplate("POINT({0},{1})", poiBasic.longitude, poiBasic.latitude)
);
}
return null;
}
private BooleanExpression distLoe(StringTemplate dist, Integer distance) {
return distance != null ? dist.loe(String.valueOf(distance)) : null;
}
private BooleanExpression tourDiffEq(String tourDifficulty) {
return hasText(tourDifficulty) ? poiBasic.tourDifficulty.eq(tourDifficulty) : null;
}
private BooleanExpression disabledCdEq(String disabledCd) {
return hasText(disabledCd) ? poiBasic.disabledCd.eq(disabledCd) : null;
}
'Languages | Frameworks > Spring' 카테고리의 다른 글
[Redis] springboot + Redis cache(조회) (1) | 2023.11.20 |
---|---|
@ControllerAdvice를 통한 예외처리 분리, 통합하기 (0) | 2022.12.20 |
[IntelliJ] 자주쓰는 단축키 정리 (0) | 2022.11.21 |
[Springboot] spring-security 적용기(2) - OAuth2 구글 소셜 로그인 (2) | 2022.11.02 |
@Autowired, @Component, @Service, @Repository 등 스프링 어노테이션에 관해.. (0) | 2022.11.02 |