CEP에는 keyword검색, 편의점 종류에 따른 검색, 행사 종류에 따른 검색과 즐겨찾기에서의 검색 기능이 있다.
아무것도 선택하지 않은경우, keyword만 쓴 경우, 편의점 종류를 2개만 선택하고 이벤트 종류는 1+1만 선택한 경우 등등 수많은 경우의 수가 나올 수 있다.
이 경우에는 SpringData jpa나 jpql 보다 동적 쿼리 구성과 유지보수성 측면에서 이점을 가진 QueryDsl을 사용하는 것이 좋을 것 같아 QueryDsl을 적용하고, 테스트 해 보았다. 총 3단계의 과정을 거쳤다.
1.queryDsl적용
2.단위 테스트
3.통합 테스트
1. QueryDsl적용
@Override
public Page<ProductResponseDto> findProducts(PageDto pageDto,
ProductRequestDto productRequestDto) {
Pageable pageable = pageDto.toPageable();
BooleanBuilder builder = new BooleanBuilder();
if (productRequestDto.keyword() != null) {
builder.and(product.productName.containsIgnoreCase(productRequestDto.keyword()));
}
if (productRequestDto.convenienceClassifications() != null && !productRequestDto.convenienceClassifications().isEmpty()) {
builder.and(product.convenienceClassification.in(productRequestDto.convenienceClassifications()));
}
if (productRequestDto.eventClassifications() != null && !productRequestDto.eventClassifications().isEmpty()) {
builder.and(product.eventClassification.in(productRequestDto.eventClassifications()));
}
JPAQuery<ProductResponseDto> query = jpaQueryFactory
.select(
Projections.bean(
ProductResponseDto.class
,product.id.as("productId")
,product.productName
,product.productPrice
,product.productImg
,product.dumImg
,product.dumName
,product.eventClassification
,product.convenienceClassification
,product.productHash
)
)
.from(product)
.where(builder)
.orderBy(product.id.asc(),product.productName.desc())
.limit(pageable.getPageSize())
.offset(pageable.getOffset());
List<ProductResponseDto> list = query.fetch();
long totalSize = countQuery(builder).fetch().get(0);
return PageableExecutionUtils.getPage(list, pageable, () -> totalSize);
}
queryDsl의 booleanBuilder를 사용해 keyword와 편의점 종류, 행사종류를 동적으로 처리하고, 페이징 처리까지 해 주었다.
keyword, convenienceClassifications, eventClassifications가 null이 아닐경우 and으로 들어가게 되며, 배열의 형태로 들어오는 편의점 종류, 행사종류는 in절로 처리된다.
2. 단위 테스트
이렇게 적용한 queryDsl코드가 잘 작동하는지 테스트 코드를 작성해 보았다.
public void findProducts() {
// Given
//반환 데이터 검증을 위한 배열
List<ConvenienceClassification> convenienceClassifications = Arrays.asList(
ConvenienceClassification.CU, ConvenienceClassification.GS25, ConvenienceClassification.EMART24);
List<String> eventClassifications = Arrays.asList("1+1", "2+1");
PageDto pageDto = mock(PageDto.class);
when(pageDto.getPage()).thenReturn(0);
when(pageDto.getSize()).thenReturn(20);
ProductRequestDto productRequestDto = mock(ProductRequestDto.class);
when(productRequestDto.convenienceClassifications()).thenReturn(convenienceClassifications);
when(productRequestDto.eventClassifications()).thenReturn(eventClassifications);
// Mock 데이터 생성
List<ProductResponseDto> mockProductList = Arrays.asList(
new ProductResponseDto(null,"Product1", null,null,null,null,"1+1",ConvenienceClassification.CU, null),
new ProductResponseDto(null,"Product2", null,null,null,null,"2+1",ConvenienceClassification.GS25, null),
new ProductResponseDto(null,"Product3", null,null,null,null,"1+1",ConvenienceClassification.EMART24, null)
);
Page<ProductResponseDto> mockPage = new PageImpl<>(mockProductList);
// productRepositoryQueryImpl이 findProducts 메서드 호출 시 mockPage를 반환하도록 설정
when(productRepositoryQueryImpl.findProducts(pageDto, productRequestDto)).thenReturn(mockPage);
// When
Page<ProductResponseDto> result = productRepositoryQueryImpl.findProducts(pageDto, productRequestDto);
// Then
boolean[] convenienceFlags = new boolean[convenienceClassifications.size()];
boolean[] eventFlags = new boolean[eventClassifications.size()];
// 결과 리스트를 순회하며 각각의 조건 확인
for (ProductResponseDto favoriteDto : result.getContent()) {
// 편의점 분류 확인
for (int i = 0; i < convenienceClassifications.size(); i++) {
if (favoriteDto.getConvenienceClassification().equals(convenienceClassifications.get(i))) {
convenienceFlags[i] = true;
}
}
// 행사 분류 확인
for (int i = 0; i < eventClassifications.size(); i++) {
if (favoriteDto.getEventClassification().equals(eventClassifications.get(i))) {
eventFlags[i] = true;
}
}
}
// 조건으로 제공된 모든 편의점과 행사 분류가 존재하는지 확인
boolean allConveniencePresent = true;
boolean allEventPresent = true;
// 모든 플래그가 true인지 확인
for (boolean flag : convenienceFlags) {
if (!flag) {
allConveniencePresent = false;
break;
}
}
for (boolean flag : eventFlags) {
if (!flag) {
allEventPresent = false;
break;
}
}
assertTrue(allConveniencePresent);
assertTrue(allEventPresent);
}
반환된 데이터를 확인하기 위해 작성해서 넣어준 데이터 외의 객체는 모두 mocking해 주었고, 선택된 분류가 있다고 가정할때, 모든 분류를 전부 탐색해서 반환하는지 확인 해 주었다.
하지만 단위테스트로는 실제로 쿼리가 잘 작동해서 데이터가 의도한대로 반환되는지 알기가 쉽지 않다.
따라서 실제로 쿼리를 보내보는 통합테스트를 해 주었다.
3. 통합 테스트
하지만 products 를 가져오는 쿼리를 테스트하기에는 product의 전체 크기가 너무 크며, 정렬한 후 페이징해서 주어진 크기만큼만 가져오기에 주어진 분류를 전부 가져오는지 확인하기는 번거롭고, 복잡해질 수가 있다는 생각이 들었다.
그래서 같은 로직으로 데이터를 가져오지만, 가져오는 데이터가 훨씬 적으며 테스트용 더미데이터를 넣기도 편한 유저 즐겨찾기 조회를 테스트 해 보았다.
public Page<FavoriteCheckResponseDto> getFavoritesEventEnd(Long userId, PageDto pageDto,
FavoriteSearchRequestDto favoriteSearchRequestDto) {
Pageable pageable = pageDto.toPageable();
int pageSize = pageDto.getSize();
BooleanBuilder builder = new BooleanBuilder();
builder.and(favorite.userId.eq(userId));
if (favoriteSearchRequestDto.keyword() != null) {
builder.and(favorite.productName.containsIgnoreCase(favoriteSearchRequestDto.keyword()));
}
if (favoriteSearchRequestDto.convenienceClassifications() != null && !favoriteSearchRequestDto.convenienceClassifications().isEmpty()) {
builder.and(favorite.convenienceClassification.in(favoriteSearchRequestDto.convenienceClassifications()));
}
if (favoriteSearchRequestDto.eventClassifications() != null && !favoriteSearchRequestDto.eventClassifications().isEmpty()) {
builder.and(favorite.eventClassification.in(favoriteSearchRequestDto.eventClassifications()));
}
// Favorite 데이터를 우선 조회
List<FavoriteCheckResponseDto> favoriteCheckList = jpaQueryFactory
.select(
Projections.bean(
FavoriteCheckResponseDto.class,
favorite.id.as("favoriteId"),
favorite.productName,
favorite.productImg,
product.productPrice,
favorite.dumName,
favorite.dumImg,
favorite.convenienceClassification,
favorite.eventClassification
)
)
.from(favorite)
.leftJoin(product).on(product.productHash.eq(favorite.productHash)) // 일치하는 productHash 조건으로 조인
.where(builder)
.fetch();
// product가 없는 favorite 필터링 (leftJoin에서 product가 없으면 null이므로 이를 기반으로 필터링)
favoriteCheckList = favoriteCheckList.stream()
.filter(dto -> dto.getProductPrice() == null) // product가 조인되지 않은 경우만 필터링
.toList();
// 페이징 인덱스 설정
int startIndex;
if(pageDto.getPage() == 0){
startIndex = 0;
}else{
startIndex = (pageDto.getPage() - 1) * pageSize;
}
int endIndex = Math.min(startIndex + pageSize, favoriteCheckList.size());
if (startIndex >= favoriteCheckList.size()) {
// 시작 인덱스가 전체 리스트 크기보다 크면 빈 리스트 반환
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
// 필요한 데이터만 추출
List<FavoriteCheckResponseDto> pagedFavorites = favoriteCheckList.subList(startIndex, endIndex);
pagedFavorites.forEach(dto -> dto.setIsSale(false));
// 전체 크기 계산
long totalSize = favoriteCheckList.size();
return new PageImpl<>(pagedFavorites, pageable, totalSize);
}
@Test
//실제 요청 반환
//일정 조건의 검색조건을 제공한 후, 제공한 조건에 맞는 데이터 확인시 테스트 성공
//이벤트가 끝난 상품 조회(products에 존재하지 않는 상품 조회)
public void testGetFavoritesEndEvent() {
// Given
//검색조건 제공
Long userId = 2L; // 테스트용 유저 ID(임의로 조작된 특정 데이터가 들어간 테스트데이터)
List<ConvenienceClassification> convenienceClassifications = Arrays.asList(
ConvenienceClassification.CU, ConvenienceClassification.EMART24);
List<String> eventClassifications = Arrays.asList("1+1", "2+1");
// 20개의 데이터를 가져오도록 설정
PageDto pageDto = new PageDto(0, 100, false, null);
// 다른 조건은 제외하고 편의점, 행사상품 종류만 탐색
FavoriteSearchRequestDto searchRequestDto = new FavoriteSearchRequestDto(
null, // keyword
convenienceClassifications, // convenienceClassifications
eventClassifications // eventClassifications
);
// When
Page<FavoriteCheckResponseDto> result = favoriteRepositoryQueryImpl.getFavoritesEventEnd(
userId, pageDto, searchRequestDto);
// Then
assertNotNull(result); // 결과가 null이 아닌지 확인
boolean[] convenienceFlags = new boolean[convenienceClassifications.size()];
boolean[] eventFlags = new boolean[eventClassifications.size()];
// 결과 리스트를 순회하며 각각의 조건 확인
for (FavoriteCheckResponseDto favoriteDto : result.getContent()) {
// 편의점 분류 확인
for (int i = 0; i < convenienceClassifications.size(); i++) {
if (favoriteDto.getConvenienceClassification()
.equals(convenienceClassifications.get(i))) {
convenienceFlags[i] = true; // 해당 편의점 분류가 존재하면 플래그 true
}
}
// 행사 분류 확인
for (int i = 0; i < eventClassifications.size(); i++) {
if (favoriteDto.getEventClassification().equals(eventClassifications.get(i))) {
eventFlags[i] = true; // 해당 행사 분류가 존재하면 플래그 true
}
}
}
// 조건으로 제공된 모든 편의점과 행사 분류가 존재하는지 확인
boolean allConveniencePresent = true;
boolean allEventPresent = true;
// 모든 플래그가 true인지 확인
for (boolean flag : convenienceFlags) {
if (!flag) {
allConveniencePresent = false;
break;
}
}
for (boolean flag : eventFlags) {
if (!flag) {
allEventPresent = false;
break;
}
}
assertTrue(allConveniencePresent);
assertTrue(allEventPresent);
}
조건에 맞게 유저 데이터에 요청을 했을때 조건에 맞는 특정 데이터를 모두 반환하는지 테스트를 진행해 주었다.
프로젝트는 배포되어있는 서버의 DB에 연결되어 있기 때문에 간단하게 단위 테스트코드에서 mocking을 하지 않으면 실제로 요청이 가능하다.
테스트가 성공하는 모습을 볼 수 있다.
그렇다면 쿼리가 의도한대로 조건에 맞는 모든 데이터를 탐색하여 반환한다고 생각 할 수 있다.
'개발일지(일간)' 카테고리의 다른 글
Gatling을 이용한 부하 테스트 (0) | 2024.10.11 |
---|---|
도커로 프로젝트 배포하기 - 로드밸런싱 (0) | 2024.10.01 |
도커로 프로젝트 배포하기 - 서비스 분리 (0) | 2024.09.28 |
도커로 프로젝트 배포하기 - 워크플로우 (0) | 2024.09.28 |
SpringBoot를 이용한 crawling 프로젝트 - 2 (0) | 2024.08.14 |