들어가면서...
이번에 배달 레전드 프로젝트에서는 배달의 민족과 같은 배달 앱을 개발한다.
키워드 검색 API를 구현하면서 시도했던 것, 구현한 것을 정리하려고 한다!
먼저 내가 구현하고자 했던 키워드 검색 API의 기능은 특정 키워드로 검색 시
해당 키워드가 `가게 이름에 포함된 가게`, 혹은 `해당 키워드가 메뉴 이름에 포함된 가게`들을 조회하는 것이었다.
ex) 배달의 민족처럼 가게에 해당 키워드가 포함된 메뉴들이 있다면 뜨도록!
그래서 처음 생각한 방법은 아래와 같다.
처음 시도한 방법
public Page<SearchShopResponse> search(PageRequest pageRequest, String keyword) {
// 가게 이름에 해당 키워드가 포함된 가게 가져오기
Page<Shop> shops = shopRepo.findByNameLike(pageRequest, keyword);
// Shop Id를 키로 SearchShopResponse 를 값으로 responseMap 생성
Map<String, SearchShopResponse> responsesMap = shops.stream()
.map(SearchShopResponse::from)
.collect(Collectors.toMap(SearchShopResponse::getId, response -> response));
// 메뉴에 해당 키워드가 포함된 메뉴들 가져오기
List<Menu> menus = menuRepo.findByNameLike(keyword);
menus.forEach(menu -> {
String shopId = menu.getShop().getId().toString();
SearchShopResponse response = responsesMap.get(shopId);
if (response != null) {
if (response.getMenuNames().size() < 3) {
response.addMenuName(menu.getName());
}
} else {
SearchShopResponse newResponse = SearchShopResponse.from(menu.getShop());
newResponse.addMenuName(menu.getName());
responsesMap.put(shopId, newResponse);
}
});
List<SearchShopResponse> responses = new ArrayList<>(responsesMap.values());
return new PageImpl<>(responses, pageRequest, responses.size());
}
먼저 가게 이름에 해당 키워드가 포함된 가게 리스트를 가져와 해당 가게 리스트를 id를 키로 하는 map을 생성한다.
그리고 해당 키워드가 포함된 메뉴 리스트를 가져온다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "shop_id",
nullable = false,
foreignKey = @ForeignKey(name = "FK_MENU_TO_SHOP")
)
private Shop shop;
메뉴 도메인에는 `@ManyToOne`으로 Shop 들이 있으니
가져온 메뉴 리스트들을 순회하면서 이미 가져온 가게 목록에 있다면 메뉴 리스트에만 메뉴 항목들을 추가하고 (최대 3개까지)
가게 목록에 없다면 가게를 추가한 뒤 메뉴 항목도 추가한다.
그런데 이렇게 하는 방법에는 `페이징 처리에 대한 문제`가 존재한다.
현재 로직에서는 가게 이름에 키워드가 포함된 가게를 `Page` 객체로 받은 뒤 또 따로 메뉴 이름에 키워드가 포함된 메뉴를 검색하여 `Response` 에 추가하고 있다. 그래서 `PageRequest`로 요청한 size 보다 더 많게 불러오는 경우가 생긴다. 만약 size가 2일 때, 처음 불러온 가게 리스트가 2이고 뒤에 불러온 메뉴 리스트가 모두 새로운 가게들이라면 size를 초과하는 상황이 발생한다.
다음으로 시도한 방법
다음으로 시도한 방법은 조인 쿼리를 직접 날려 중복되지 않은 가게들만 가져와 해당 가게들의 메뉴들을 불러오는 것이다.
처음에는 `@Query` 어노테이션을 통해 직접 쿼리를 작성하려 했지만 컴파일 단계에서 문법 오류를 확인할 수 없다는 단점이 있어 `QueryDSL`을 사용해보기로 했다!
먼저 build.gradle에 관련 의존성을 추가한 뒤
// Query DSL configurations
sourceSets {
main {
java {
srcDirs = ['src/main/java', 'src/main/generated']
}
}
}
관련 설정을 추가한다.
이 설정은 프로젝트의 주요 소스 코드와 자동으로 생성된 코드를 포함하는 디렉토리를 Gradle에 알려주는 역할을 수행한다.
설정을 마치고 Gradle의 build - clean -> other - compileJava 를 실행한다.
이렇게 src/main/generated 디렉토리 하위에 필요한 코드들이 생성된다!
`QueryDSL` 사용에 필요한 `Q 클래스`도 저기에 생성이 된다.
Q 클래스
`QueryDSL`은 타입 세이프하게 쿼리를 작성할 수 있도록 돕는 프레임워크로 다양한 쿼리를 컴파일 타임에 검증할 수 있도록 한다!
`Q 클래스`란 QueryDSL에서 도메인 모델의 각 엔티티 클래스에 대응하는 자동 생성된 클래스로 엔티티의 속성들을 `타입 세이프`하게 쿼리할 수 있도록 필드로 제공한다.
`Q 클래스`의 이름은 일반적으로 원래 엔티티 클래스 이름 앞에 Q가 붙어 만들어진다.
Shop 엔티티 클래스의 `Q 클래스`는 QShop 으로 만들어진 것을 알 수 있다.
public interface ShopRepositoryCustom {
Page<Shop> search(PageRequest pageRequest, String keyword);
}
그 뒤 필요한 인터페이스와 구현체를 생성하였다. `QueryDSL`는 인터페이스를 생성하여 메서드를 선언하고
@RequiredArgsConstructor
public class ShopRepositoryImpl implements ShopRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<Shop> search(PageRequest pageRequest, String keyword) {
...
return new PageImpl<>(shops, pageRequest, shops.size());
}
}
Impl 클래스에서 추상 메서드를 구현한다!!!!
여기에 주의할 점이 있는데 바로 구현체의 이름이다. 무조건 ~RepositoryImpl 로 설정해야 한다.
안 그러면 오류가 난다!!!!!!!!!!
그리고 또 한 가지 주의할 점
@Repository
public interface ShopRepository extends JpaRepository<Shop, UUID>, ShopRepositoryCustom {
ShopStatus openStatus = ShopStatus.OPEN;
@Query("SELECT s FROM Shop s WHERE s.name LIKE %:keyword%")
Page<Shop> findByNameLike(PageRequest pageRequest, @Param("keyword") String keyword);
}
`ShopRepositoryCustom`을 상속 받는 인터페이스 즉, 기존에 사용하던 JpaRepository 로 설정한 클래스 파일 이름도
정직하게 ~Repository 로 설정해야 한다..
나는 ~Repo 로 설정했다가 2시간 동안 헤맸다.. 해결하고 너무 허무했다 ㅋㅋㅋ
QueryDSL로 구현하기
public interface ShopRepositoryCustom {
Page<Shop> search(PageRequest pageRequest, String keyword);
}
이렇게 인터페이스에 내가 작성할 메서드를 선언하고
@RequiredArgsConstructor
public class ShopRepositoryImpl implements ShopRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<Shop> search(PageRequest pageRequest, String keyword) {
List<Shop> shops = queryFactory
.select(shop).distinct()
.from(menu)
.rightJoin(menu.shop, shop)
.where(shop.name.contains(keyword)
.or(menu.name.contains(keyword)))
.offset(pageRequest.getOffset())
.limit(pageRequest.getPageSize())
.fetch();
return new PageImpl<>(shops, pageRequest, shops.size());
}
}
구현 클래스에서 `@Override`로 구현해준다.
import static com.sparta.baedallegend.domains.menu.domain.QMenu.menu;
import static com.sparta.baedallegend.domains.shop.domain.QShop.shop;
아 그리고 `Q 클래스`로 생성된 클래스들 중 사용할 클래스들을 이런식으로 static import 해줘야 한다!
먼저 `Shop`과 `Menu`를 `shop_id`로 join 하여 Shop 혹은 Menu 이름에 해당 키워드가 포함된 Shop들만 가져온다.
그런데 처음에 그냥 `inner join`으로 했더니 메뉴가 존재하지 않는 Shop들은 뜨지 않는 상황이 발생했다.
따라서 Menu가 없는 Shop들 중 이름에 키워드가 포함되는 Shop들을 불러오기 위해 Shop - Menu 의 `right join`을 해줬다.
그리고 마지막으로 한 Shop에 해당 키워드가 포함된 Menu가 여러 개 있을 경우 하나만 조회되도록 하기 위해 `distinct()`를 붙여줬다.
그 결과로 이러한 쿼리문이 실행된다. 내가 원하는데로 작동했다!
해당 쿼리문은 Menu를 따로 조회하고 있지 않기 때문에 Shop Entity에 `@OneToMany`로 양방향 관계를 설정해주었다.
`Shop.java`
@OneToMany(mappedBy = "shop")
private List<Menu> menus = new ArrayList<>();
`ShopService.java`
public Page<SearchShopResponse> search(PageRequest pageRequest, String keyword) {
Page<Shop> shops = shopRepository.search(pageRequest, keyword);
return shops.map(shop -> SearchShopResponse.of(shop, keyword));
}
가져온 shops를 `map`을 통해 스트림을 돌면서 `SearchShopResponse` 객체로 변환한다!
`of` 메서드는 DTO 생성, 변환시 주로 사용하는 메서드 패턴으로 정적 메서드로 `Shop`을 받아 `SearchShopResponse` DTO로 변환한다. 변환한 DTO Page 객체를 반환하면 끝!!
`SearchShopResponse.java`
@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class SearchShopResponse {
private String id;
private String name;
private String description;
private String status;
private List<String> menuNames = new ArrayList<>();
public static SearchShopResponse of(Shop shop, String keyword) {
return new SearchShopResponse(shop.getId().toString(), shop.getName(),
shop.getDescription(), shop.getStatus().getDescription(),
shop.getMenus().stream().map(menu -> menu.getName())
.filter(name -> name.contains(keyword))
.limit(3)
.collect(Collectors.toList()));
}
}
of 메서드는 `Shop` 객체를 받아와 `SearchShopResponse` 객체로 변환하는 동시에 `Shop`의 `Menu` 중 조건을 만족하는 `Menu` 3개만을 가져오고 있다.
마지막으로 `ShopController.java`!! 컨트롤러 코드까지 완성
@GetMapping("/search")
public ResponseEntity<Page<SearchShopResponse>> search(
@RequestParam(value = "keyword", defaultValue = "") String keyword,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "1") int size
) {
PageRequest pageRequest = PageRequest.of(page, size);
Page<SearchShopResponse> responses = shopService.search(pageRequest, keyword);
return ResponseEntity.ok().body(responses);
}
라볶이 키워드로 검색 시 정상적으로 동작한다.
마시도니 가게에는 라볶이 메뉴를 10개 넣어놨는데 3개만 가져오는 것을 알 수 있다.
메뉴 이름에만 키워드가 포함되어도 정상적으로 조회된다.
마치면서...
이번에 조금!!이나마 복잡한 API 를 구현하면서 배우게 된 것들이 많다!
- `QueryDSL` 사용하기 (`@Query`와의 차이점 알기)
- `stream()` `map()` 사용에 익숙해지기
- 필요 시 양방향 관계 설정하기
- Page 객체 사용에 익숙해지기
역시 환경세팅이 스트레스 받지..
개발 자체는 정말 재밌는 것 같다!!
근데 양방향 매핑을 하게 되면 N+1 문제가 발생할 수 있다 그랬는데
나는 Shop의 메뉴 조회 시 쿼리가 두 번 나갔다.. 뭐지?! 검색해본 뒤 내용을 추가해야겠다!
+ 아하! 프로젝트 기본 설정에 BatchSize 설정이 있어서 그런것이었다!!!!!!!!!
그래서 2번의 쿼리만 나간 것.. ㅎㅎ
'공부 > Project' 카테고리의 다른 글
Gateway, Spring Security, JWT 사용하여 인증 및 인가 처리하기 (0) | 2024.09.22 |
---|---|
배달 레전드 프로젝트 회고 (정적 팩토리 메서드, 전략 패턴, 퍼사드 패턴, QueryDSL @Query 차이, Page, JPA N+1 문제) (3) | 2024.09.07 |
Springboot JPA + QueryDSL 사용 시 no property found for type 오류 해결 (0) | 2024.08.31 |
JPA 게시물 조회 시 N+1 문제 해결방법 (feat. FetchType.LAZY) (0) | 2024.07.16 |
Springboot, JUnit 테스트 코드 작성하기 (0) | 2024.06.25 |