JPA N + 1 왜 궁금했을까❓
ArchiVIEW 프로젝트에서 JPA를 활용하여 DB 데이터를 조회하고 있었다. console을 보니 MyBatis에서는 하나의 쿼리로 처리했던 것들이 여러 개의 쿼리로 나눠져서 나가고 있는 것을 확인했다. 이를 찾아보니 N + 1 문제라고 지칭하는 것을 알았고 이를 해결해보려고 한다.
[Spring boot] JPA(Java Persistence API)
JPA(Java Persistence API) 왜 궁금했을까❓SSAFY 1학기 프로젝트인 Share Your Trip을 할 때, MyBatis를 사용해서 DB에 Query를 날리곤 했는데 타입이나 변수명 등 Java 객체와 불일치 하는 경우가 많아 오류가 많
pslog.co.kr
[Spring boot] JPA save() & Dirty Checking
JPA 메소드인 SAVE() 왜 궁금했을까❓JPA의 save()를 이용하여 객체를 데이터베이스에 저장하곤 했는데 Dirty Checking을 통해 저장하는 방식이 존재한다는 것을 알고 그 원리가 궁금해서 save()와 함께 학
pslog.co.kr
위 포스팅들을 통해 JPA에 대한 개념들을 이해할 수 있다.
Hash Tag 조회 API
태그에는 Job과 Cs가 존재하는데 Job과 Cs의 속성과 코드는 똑같기에 Job을 기준으로 설명하도록 하겠다.
@Entity // 직무 대분류 테이블
@Getter
@Table(name = "job_main")
public class JobMain {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "name", length = 64)
@NotNull
private String name;
@OneToMany(mappedBy = "jobMain", fetch = FetchType.LAZY)
private List<JobSub> jobSubList = new ArrayList<>();
public CommonDto.jobMainDto toDto() {
return CommonDto.jobMainDto.builder()
.name(name)
.jobSubList(jobSubList.stream()
.map(JobSub::getName)
.collect(Collectors.toList()))
.build();
}
}
@Entity // 직무 소분류
@Getter
@Table(name = "job_sub")
public class JobSub {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(name = "name", length = 64)
private String name;
@ManyToOne
@JoinColumn(name = "job_main_id")
private JobMain jobMain;
}
@Override
public CommonDto.tagResponseDto tagList() {
List<CommonDto.csMainDto> csList = csMainRepository.findAll().stream()
.map(CsMain::toDto)
.toList();
List<CommonDto.jobMainDto> jsList = jobMainRepository.findAll().stream()
.map(JobMain::toDto)
.toList();
return CommonDto.tagResponseDto.builder()
.csList(csList)
.jsList(jsList)
.build();
}
기존의 tagList Method의 코드를 보면 Spring Data JPA의 findAll을 이용하여 부모 태그를 조회하고 oneToMany로 엮여있는 SubTag를 지연 로딩을 통해 가져온다.
과정을 보면, 메인 태그를 먼저 조회하고 subTag가 사용되면 해당 태그를 지연 로딩을 통해 DB에 접근하여 조회하고 있는 것을 확인할 수 있다.
결국, 메인 태그 1개와 이와 연관된 서브 태그 N개의 쿼리가 발생하는 것을 알 수 있다. 해당 부분을 fetch join을 이용한다면 하나의 쿼리로 연관된 엔티티의 데이터들을 가져와 영속성 컨텍스트에 넣기 때문에 해결할 수 있을 것이다.
fetch join을 사용하기 위해 JPQL이 아닌 Query DSL을 사용했는데 그 이유는 Query DSL 포스팅 에 작성해놓았다.
@Override
public List<CommonDto.jobMainDto> getJobTagList() {
QJobMain qJobMain = QJobMain.jobMain;
QJobSub qJobSub = QJobSub.jobSub;
return factory.selectFrom(qJobMain)
.leftJoin(qJobMain.jobSubList, qJobSub)
.fetchJoin()
//.distinct()
.fetch().stream()
.map(JobMain::toDto)
.toList();
}
@Override
public CommonDto.tagResponseDto tagList() {
List<CommonDto.csMainDto> csList = csMainRepository.getCsTagList();
List<CommonDto.jobMainDto> jsList = jobMainRepository.getJobTagList();
return CommonDto.tagResponseDto.builder()
.csList(csList)
.jsList(jsList)
.build();
}
위와 같이 Query DSL을 사용하여 하나의 쿼리를 보낼 수 있었고 N + 1의 문제를 해결할 수 있었다.
또한, Query DSL에서 distinct가 주석되어 있는 것을 확인할 수 있는데 hibernate 6.0 밑에 버전에서는 해당 코드가 존재해야 한다.
그 이유는 메인-서브1, 메인-서브2, ... , 메인-서브N 식으로 카테시안 곱으로 인해 메인의 중복이 발생할 것이다.
원하는 결과 값 - 소프트웨어 개발자-프론트엔드, 백엔드
실제 결과 값- 소프트웨어 개발자-프론트엔드, 소프트웨어 개발자-백엔드
이러한 문제를 해결하고 distinct를 사용하면 메인의 중복이 사라지고 원하는 결과 값을 출력할 수 있을 것이다.
SQL의 distinct와 Query DSL, JPQL의 distinct는 약간 다르다고 할 수 있는데 SQL은 row가 기준이고 Query DSL, JPQL은 중복되는 Entity를 제거시켜 준다.
채용 공고 및 질문 검색 API
@Override
public List<RecruitDto.DetailListResponseDto> searchAll(RecruitDto.DetailListRequestDto requestDto) {
BooleanBuilder builder = new BooleanBuilder();
QRecruit recruit = QRecruit.recruit;
QCompany company = QCompany.company;
StringExpression start = Expressions.stringTemplate("FUNCTION('DATE_FORMAT', {0}, '%Y-%m')", recruit.start);
StringExpression end = Expressions.stringTemplate("FUNCTION('DATE_FORMAT', {0}, '%Y-%m')", recruit.end);
builder.and(start.eq(requestDto.getDate()).or(end.eq(requestDto.getDate())));
if(requestDto.getCompanyId() != 0) {
builder.and(recruit.company.id.eq(requestDto.getCompanyId()));
}
return factory
.selectFrom(recruit)
.leftJoin(recruit.company, company)
.fetchJoin()
.where(builder)
.fetch().stream()
.map(Recruit::toDetailListDto)
.toList();
}