FireDrago

[bobzip] QueryDsl 활용하여 냉장고 재료로 레시피 검색하기 본문

프로젝트

[bobzip] QueryDsl 활용하여 냉장고 재료로 레시피 검색하기

화이용 2024. 7. 11. 11:39

<기능 목표>

1. 냉장고에 현재 가진 재료들을 추가하고 검색한다.

2. 등록된 레시피중에서 사용할 수 있는 재료들이 많은 순서대로 페이징 출력된다. (위 예시는 size = 1 로 설정)

3. 레시피의 재료는 가진재료와 필요한 재료로 구분하여 표시한다.

 

레시피 검색 (쿼리 만들기)

이번 프로젝트에서 가장 어려운 부분이었다.

구현하고 블로그 정리할때는 두줄 밑에 정리하면 끝인데,

막상 혼자 구현할때는 정말 삽질을 많이 했다. 아직 부족한 탓이겠지.......

쿼리를 생각해내야 하는데, 아직 쿼리를 다루는 것이 익숙하지 못하다는 것을 느꼈다.

 

먼저 기능 구현에 필요한 쿼리를 정리하면,

 

1. 사용자가 입력한 재료 목록중 하나라도 가진 레시피를 검색하는 쿼리(가진 재료 많을 수록 앞으로 정렬 + 페이징)

2. 해당 레시피의 모든 재료를 검색하는 쿼리

3. 페이징을 위한 1번 쿼리의 카운트 쿼리 

 

이제 테이블구조를 살펴보고 쿼리를 만들어 보자

<1번 쿼리> 

우선 재료 테이블과 레시피 테이블을 조인으로 연결해야 겠다는 생각을 했다.

관계형 테이블로 엮여 있으니, 조인은 두번필요할 것이다. 

조인된 테이블에서 입력된 레시피명을 가진 행만 WHERE 절과 IN 을 통해서 걸러낸다.

 

가진 재료가 많을 수록 앞으로 정렬되어야 하므로, 가진재료를 카운팅 하는 COUNT 함수가 필요하다.

COUNT 함수와 함께 따라나오는 GROUP BY 절도 작성해야 할것이다.

마지막으로 정렬을 위해 ORDER BY 절로 정렬해준다.

select
    r.recipe_id,
    r.title,
    count(i.name)       
FROM
    recipe r      
JOIN
    recipe_ingredient ri               
        on r.recipe_id=ri.recipe_id       
JOIN
    ingredient i             
        on i.ingredient_id=ri.ingredient_id       
WHERE
    i.name in ('김치', '두부', '식용유')       
GROUP BY
    r.recipe_id,
    r.title       
ORDER BY
    count(i.name) desc

 

페이징을 적용하기 위해서 OFFSET과 FETCH 까지 설정해주면 완성....?

페이징을 적용했는데, 레시피의 사용재료의 갯수가 같은경우,

1페이지와 2페이지가 같은 값이 출력되는 문제가 발생했다.

 

한참 씨름하다가 원인을 알아냈는데, ORDER BY 절에서 재료의 갯수만 기준으로 정렬하니

같은 값이 있을때 페이징 오류가 발생하는 것이 원인이었다. 레시피 id 를 정렬기준에 추가하여 최종적인 쿼리를 완성했다

select
    r.recipe_id,
    r.title,
    count(i.name)       
FROM
    recipe r      
JOIN
    recipe_ingredient ri               
        on r.recipe_id=ri.recipe_id       
JOIN
    ingredient i             
        on i.ingredient_id=ri.ingredient_id       
WHERE
    i.name in ('김치', '두부', '식용유')       
GROUP BY
    r.recipe_id,
    r.title       
ORDER BY
    count(i.name) desc,
    r.recipe_id asc
OFFSET
	0
FETCH
	first 1 rows only

 

<2번쿼리>

1번 쿼리에서 레시피를 얻었으므로, 그 정보를 바탕으로 재료 테이블에서 해당 레시피의 재료들을 모두 가져온다.

역시 두번의 조인이 필요하고, WHERE 절과 IN 으로 1번 쿼리에서 조회한 레시피의 ID와 일치하는

재료 목록들을 가져오면 될 것이다.

SELECT
    r.recipe_id AS 레시피_ID,
    i.name AS 재료_이름
FROM
    recipe AS r
JOIN 
    recipe_ingredient AS ri ON r.recipe_id = ri.recipe_id
JOIN 
    ingredient AS i ON i.ingredient_id = ri.ingredient_id
WHERE
    r.recipe_id IN (1, 2);

 

<3번 쿼리>

페이징을 위한 PageImpl 객체 생성을 위한 레시피의 총 개수가 필요하므로, 

1번 쿼리에서 페이징을 제외한 총 갯수를 카운트 하는 쿼리를 작성하면 될것이다

select
    count(distinct r.recipe_id)       
from
    recipe r       
join
    recipe_ingredient ri              
        on r.recipe_id=ri.recipe_id       
join
    ingredient i               
        on i.ingredient_id=ri.ingredient_id       
where
    i.name in ('김치', '두부', '식용유')

 

레시피 검색 (코드 작성)

작성한 쿼리를 바탕으로 RePository 메서드를 작성한다. queryDSL을 사용했다.

리포지토리는 DB와 연결하는 로직만 작성되어야 한다. 

처음에는 DTO 생성과 페이징등 서비스 객체의 역할이 섞여있었지만, 리팩토링을 통해 

리포지토리는 DB와 연결하는 책임만을 담당하도록 바꾸었다.

// 1번 쿼리
public List<Tuple> findAllSortedRecipes(List<String> ingredientNames, Pageable pageable) {
    return queryFactory
            .select(recipe.id, recipe.title, recipeIngredient.ingredient.name.count())
            .from(recipe)
            .join(recipe.recipeIngredients, recipeIngredient)
            .join(recipeIngredient.ingredient, ingredient)
            .where(ingredient.name.in(ingredientNames))
            .groupBy(recipe.id, recipe.title)
            .orderBy(recipeIngredient.ingredient.name.count().desc(), recipe.id.asc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
}

// 2번 쿼리
public List<Tuple> findIngredientTuples(List<Long> recipeIds) {
    return queryFactory
            .select(recipe.id, ingredient.name)
            .from(recipe)
            .join(recipe.recipeIngredients, recipeIngredient)
            .join(recipeIngredient.ingredient, ingredient)
            .where(recipe.id.in(recipeIds))
            .fetch();
}

// 3번 쿼리
public Long countRecipesByIngredients(List<String> ingredientNames) {
    return queryFactory
            .select(recipe.countDistinct())
            .from(recipe)
            .join(recipe.recipeIngredients, recipeIngredient)
            .join(recipeIngredient.ingredient, ingredient)
            .where(ingredient.name.in(ingredientNames))
            .fetchOne();
}

 

이제 서비스 객체에서 이 메서드를 이용하여, 데이터를 페이징 처리한 DTO로 반환하는 로직을 작성한다.

@Data
public class RecipeSearchDTO {

    private Long recipeId;

    private String recipeName;

    private List<String> availableIngredients = new ArrayList<>();

    private List<String> requiredIngredients = new ArrayList<>();

    protected  RecipeSearchDTO() {}

    public RecipeSearchDTO(Long recipeId, String recipeName) {
        this.recipeId = recipeId;
        this.recipeName = recipeName;
    }

    public void addAvailableIngredients(String ingredientName) {
        availableIngredients.add(ingredientName);
    }

    public void addRequiredIngredients(String ingredientName) {
        requiredIngredients.add(ingredientName);
    }
}

클라이언트에게 반환될 DTO 객체이다.

레시피ID, 레시피명, 가진 재료와 필요한 재료를 분류해서 전달한다.

서비스 객체는 다음과 같은 기능이 필요하다.

 

1. 리포지토리에서 얻은 데이터를 DTO로 변환

2. 그 과정에서 가진 재료와 필요한 재료를 구분

3. 페이징 처리를 위한 Page<RecipeSearchDTO> 생성 

 

public Page<RecipeSearchDTO> searchByIngredient(
                              List<String>ingredientNames, Pageable pageable) {
    // 재료를 가진 레시피 페이징되어 조회 (1번 쿼리)
    List<Tuple> sortedRecipes = recipeSearchRepository.findAllSortedRecipes(ingredientNames, pageable);
    
    // 레시피 id 추출
    List<Long> paginatedRecipeIds = findPaginatedRecipeIds(sortedRecipes, pageable);
    for (Tuple t : sortedRecipes) {
        log.info("recipe = {}", t.get(recipe.title));
    }
    
    // 추출한 레시피 id로 레시피의 모든 재료를 조회 (2번쿼리)
    List<Tuple> ingredientTuples = recipeSearchRepository.findIngredientTuples(paginatedRecipeIds);
	
    // DTO 생성하고, 가진재료와 필요한 재료를 분류하여 DTO 초기화
    Map<Long, RecipeSearchDTO> recipeMap = new LinkedHashMap<>();
    createRecipeSearchDto(sortedRecipes, recipeMap);
    addIngredientsToRecipeSearchDto(ingredientNames, ingredientTuples, recipeMap);
    List<RecipeSearchDTO> contents = new ArrayList<>(recipeMap.values());
    
    // page 생성을 위한 총 개수 조회 (3번쿼리)
    Long count = recipeSearchRepository.countRecipesByIngredients(ingredientNames);
    
    // Page<RecipeSearchDTO> 반환
    return new PageImpl<>(contents, pageable, count);
}

findPaginatedRecipeIds 메서드는 단순히 id만을 뽑아내는 메서드로 생략한다. stream.map().toList() 쓰면 간단하다.

 

createRecipeSearchDto 메서드를 살펴보자

private void createRecipeSearchDto(List<Tuple> sortedRecipes, Map<Long, RecipeSearchDTO> recipeMap) {
    for (Tuple tuple : sortedRecipes) {
        Long recipeId = tuple.get(recipe.id);
        String recipeTitle = tuple.get(recipe.title);
        recipeMap.put(recipeId, new RecipeSearchDTO(recipeId, recipeTitle));
    }
}

1번 쿼리에서 페이징된 레시피의 아이디와 타이틀을 DTO를 통해 생성하고,

recipeMap에 recipeId를 키값으로 넣는다.

 

private void addIngredientsToRecipeSearchDto(
                          List<String> ingredientNames, 
                          List<Tuple> ingredientTuples, 
                          Map<Long, RecipeSearchDTO> recipeMap) {
    for (Tuple tuple : ingredientTuples) {
        Long recipeId = tuple.get(recipe.id);
        String ingredientName = tuple.get(ingredient.name);
        RecipeSearchDTO dto = recipeMap.get(recipeId);
        if (dto != null) {
            if (ingredientNames.contains(ingredientName)) {
                dto.addAvailableIngredients(ingredientName);
            } else {
                dto.addRequiredIngredients(ingredientName);
            }
        }
    }
}

2번 쿼리에서 조회한 모든 레시피의 재료들을 recipeMap에서 꺼낸 dto에 넣는다.

이때 사용자가 입력한 ingredientNames 에 재료가 존재하면 availableIngredients에,

존재하지 않으면 requiredIngredients에 넣는다. 

 

LinkedHashMap을 사용한 이유는 1번쿼리에서 recipe는 정렬순서가 페이징되어 조회되었기 때문에

순서가 보장되어야 하고, 레시피 (key) 하나에 하나의 dto가 생성되는 것을 보장하기 위해서 사용되었다.

 

@ResponseBody
@PostMapping("/searchByIngredients")
public ResponseEntity<Page<RecipeSearchDTO>> searchByIngredient(@RequestBody List<String> ingredientNames,
                                                              @PageableDefault(size = 1) Pageable pageable) {
    Page<RecipeSearchDTO> searchRecipes = recipeSearchService.searchByIngredient(ingredientNames, pageable);
    return ResponseEntity.ok().body(searchRecipes);
}

완성된 객체는 JSON 의 형태로 클라이언트에게 전달된다.

 

클라이언트는 JSON 객체를 바탕으로 화면을 구성하여 사용자에게 표시해주면 완성!