[Study-6, 7주차] AOP + QueryDSL + (N+1) 문제 및 RequestDTO 수정 + JPA Auditing 사용 (regDate, modDate) - ②
[Study-6, 7주차] AOP + QueryDSL + (N+1) 문제 및 RequestDTO 수정 + JPA Auditing 사용 (regDate, modDate) - ②
[Study-6, 7주차] AOP + QueryDSL + (N+1) 문제 및 RequestDTO 수정 + JPA Auditing 사용 (regDate, modDate) - ① [Study-6, 7주차] AOP + QueryDSL + (N+1) 문제 및 RequestDTO 수정 + JPA Auditing 사용 (regDate, modDate) - ① [Study-5주차] @Que
soohykeee.tistory.com
앞서 우리는 AOP와 QueryDSL을 사용해주었고, 추가적으로 N+1 문제 해결을 위해서 fetch join 도 진행해주었다. 또한 Auditing을 사용하여 생성, 수정시간을 저장하는 속성을 BaseEntity에 추가해주었다. 이번 8~9주차에 스터디원끼리 해오기로한 과제는 다음과 같다. 내용이 어려워지고 복잡해지는 만큼, 제대로 완료하지 못한 스터디원들이 많아서 Auditing과 AOP, QueryDSL을 각자 더욱 보완해오고, 추가기능으로는 @RestControllerAdvice 를 활용하여 Exception Handler를 처리해오기로 하였다. 간단하게 정리하면 다음과 같다.
- Auditing, AOP, Querydsl 적용 마무리 짓기
- @RestControllerAdvice 활용해서 Exception Handler 처리하기
- AOP와 Querydsl은 조금 심화 공부가 필요하다고 봄
- 클래스 이름이랑 파라미터 정보 찍기
- 실행시간 로그 찍는거
- 내부 메소드 어떻게 할 지?
- Querydsl
- 일대다 관계에서 페치조인
- Querydsl로 구현하면 기능이 좋아질만한 것들 리펙토링
- PersistenceContext
- Auditing
- 날짜 사이에 있는 데이터 조회하기
위의 내용에서 Auditing과 AOP에 관련된 내용을 모두 완료하였다. 하지만, QueryDSL의 경우 조회만 해주었기에 생성, 수정, 삭제 등에도 적용시켜줄 것이다. 또한 그전에는 SearchRepositoryTest 코드를 통해 동적쿼리가 진행되는 것을 확인했었다. 이번에는 Controller와 Service 에 적용을 해줄 것이다.
@RestControllerService 와 같은 새기능을 추가해주기 전에, 기존의 코드에서 수정이 필요한 부분과 추가로 작성해줘야 하는 부분 먼저 살펴보겠다.
SearchRepository - Controller, Service에 적용
앞서 각 entity별로 searchRepository를 생성해 주었고, test 코드를 통해 실행까지 되는 것을 확인했었다. 이번에는 해당 queryDSL을 사용하는 동적쿼리의 조건을 더욱 세밀하게 수정해주고, service와 controller에 적용을 시켜줄것이다.
우선적으로 Owner쪽부터 살펴보겠다.
package kr.co.jshpetclinicstudy.persistence.repository.search;
@Repository
@RequiredArgsConstructor
public class OwnerSearchRepository {
private final JPAQueryFactory queryFactory;
private final QOwner owner = QOwner.owner;
public List<Owner> find(OwnerRequestDto.CONDITION condition) {
return queryFactory
.selectFrom(owner)
.where(
ownerIdIn(condition.getOwnerIds()),
ownerFirstNameEq(condition.getFirstName()),
ownerCityContain(condition.getCity()),
ownerTelephoneEq(condition.getTelephone())
)
.fetch();
}
private BooleanExpression ownerIdIn(List<Long> ownerIds) {
if (CollectionUtils.isEmpty(ownerIds)) {
return null;
}
return owner.id.in(ownerIds);
}
private BooleanExpression ownerFirstNameEq(String firstName) {
if (!StringUtils.hasText(firstName)) {
return null;
}
return owner.firstName.eq(firstName);
}
private BooleanExpression ownerCityContain(String city) {
if (!StringUtils.hasText(city)) {
return null;
}
return owner.city.contains(city);
}
private BooleanExpression ownerTelephoneEq(String telephone) {
if (!StringUtils.hasText(telephone)) {
return null;
}
return owner.telephone.eq(telephone);
}
}
앞서 작성해줬던 동적쿼리에 더 많은 조건을 추가해주었다. 해당 기능들이 test코드를 통해 실행이 되는 것도 확인했었으니, service와 controller에 적용해 줄 것이다.
OwnerController
package kr.co.jshpetclinicstudy.controller;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/owners")
public class OwnerController {
// ... 생략
/**
* Read(Get) Owner API
*
* @param condition
* @return
*/
@PostMapping("/search")
public ResponseFormat<List<OwnerResponseDto.READ>> getOwnersByCondition(@RequestBody @Valid OwnerRequestDto.CONDITION condition) {
try {
return ResponseFormat.successWithData(ResponseStatus.SUCCESS_OK, ownerService.getOwnersByCondition(condition));
} catch (NotFoundException e) {
return ResponseFormat.error(ResponseStatus.FAIL_NOT_FOUND);
} catch (RuntimeException e) {
return ResponseFormat.error(ResponseStatus.FAIL_BAD_REQUEST);
}
}
}
우선 검색값을 받아와야 하기에 기존의 Get방식으로 URL을 통해 ownerId값을 받는 것이 아니라, Post 방식으로 값을 받아와야하기에 위처럼 수정해주었다. 추가적으로 return값이 List로 넘어오기에 받아오는 return 값도 List로 수정해주었다.
OwnerService
package kr.co.jshpetclinicstudy.service;
@Service
@RequiredArgsConstructor
public class OwnerService {
private final OwnerRepository ownerRepository;
private final OwnerSearchRepository ownerSearchRepository;
private final OwnerMapper ownerMapper;
// ...생략
// QueryDSL 을 사용하는 방식으로 수정
public List<OwnerResponseDto.READ> getOwnersByCondition(OwnerRequestDto.CONDITION condition) {
final List<Owner> owners = ownerSearchRepository.find(condition);
return owners.stream()
.map(ownerMapper::toReadDto)
.collect(Collectors.toList());
}
}
Postman을 통해 살펴보면 다음처럼 성공적으로 검색이 되는것을 확인할 수 있다.
해당 동적쿼리를 사용하는 부분에서 고민이 많았다. 다른 스터디원은 update, delete시에도 동적쿼리를 사용하여 검색을 한 후, update와 delete처리를 해주었다. 물론 모든 쿼리를 querydsl을 사용하여 동적쿼리로 관리해주는 것은 유지보수 측면이나 코드 가독성 측면에서 효율적이다. 추가적으로 앞서에도 설명했지만, Java 코드로 작성하기에 컴파일 시점에서 오류를 발견할 수 있어 개발 생산성을 높일 수 있다. 하지만, 간단하고 정적인 쿼리의 경우에 Query를 사용하면 더욱 간결한 코드를 유지할 수 있고 더욱 편리하다.
그렇기에 내가 내린 결론은 개발하고 있는 시스템의 요구사항과 복잡성, 성능 등등을 모두 고려하여 QueryDSL과 JPQL, SQL을 사용해주는 것이 더욱 유연하고 효율적인 코드라고 생각했다. 그 어디에서도 무엇이 정답이라고 알려주지 않았기에 나는 검색을 해줄 때를 제외하고는 update, delete 같은 곳에 QueryDSL을 사용하지 않고 간결한 코드를 사용할 수 있는 Query를 사용할 것이다.
Pet, Vet, Visit도 Owner와 동일한 방식이기에 추가적인 설명은 하지 않겠다. 자세한 코드 사항은 github에서 확인할 수 있다.
https://github.com/soohykeee/jsh-PetClinic-Study
GitHub - soohykeee/jsh-PetClinic-Study
Contribute to soohykeee/jsh-PetClinic-Study development by creating an account on GitHub.
github.com
여전히 발생하는 N+1 문제
앞서 우리는 N+1 문제를 해결해주기 위해서 fetchjoin()을 사용해주었다. 다대일의 경우에는 fetchjoin()을 사용하여 문제가 해결된 것을 확인할 수 있었다. 하지만 일대다의 경우 여전히 N+1문제가 발생하는 것을 확인할 수 있었다.
아래의 경우는 Vet에서 queryDSL을 사용하여 조회하는 부분을 실행했을 때, fetchjoin()을 해줬음에도 N+1이 여전히 발생하는 모습이다. 기존에 무작정 fetchjoin()을 해주었다고 해서 해결이 되었다고 생각해 제대로 확인해주지 않았었다.
이러한 문제를 위해 @BatchSize, @Fetch 를 사용하여 해결할 수 있다. 하지만 스터디원과 같이 생각했을 때 근본적으로 발생하는 N+1문제를 해결하는 것이 아닌, 임시방편으로 문제를 해결해주는 느낌이 강하게 들어서 적용해주지 않았었다.
해당 문제를 어떻게 해결하면 좋을지 고민하다. N사에 현업 개발직으로 근무중인 지인에게 연락하여 물어보았다. 우선적으로 결론을 말하면 '나는 그렇기에 일대다를 최대한 사용해주지 않으려고 한다' 였다. 가능하면 다대일을 사용하여 최대한 연관관계를 해결하거나, 또 다른 해결책으로는 service에 일대다를 사용하는 entity를 사용하지 말라고 하였다. 쉽게 말해서 대부분의 일대다 관계는 다대다 관계를 풀어주기 위해서 생기는 관계이다. 그렇기에 일대다 관계가 있는 entity에 service를 구현해주지 말고 일대다 관계를 작성해준 연관테이블을 사용하여 서비스 구현을 해주라는 것이였다.
이런말을 들으니 정말 그렇게 해주면 해결이 될 문제였었다. 무작정 연관테이블을 사용하는 서비스를 사용하게 되면 뭔가 안될것같고, 연관해준 테이블을 주인으로 사용해서 구현해줘야 할 것 같다고 느꼈기에 그렇게 해준 것이였다. Vet과 Specialty를 연관해준 VetSpecialty를 사용해서 서비스를 구현해주면 되는것이였다. 우린 추후 리팩토링을 해줄 때 구현해주기로 했다.