백엔드 태그 기능(M:N) 구현시 다중 and 조건 처리(태그 검색 기능)

김세환·2021년 2월 23일
2

최근 동아리에서 진행중인 사이드 프로젝트를 진행하면서 M:N 관계의 정석이라고 할 수 있는 태그 기능을 구현하면서 겪은 이슈이다.

기술 스택 : Node.js, Sequelize, MySQL

현재 우리가 진행중인 프로젝트의 데이터베이스의 모습은 다음과 같다.

plantstags는 M:N 관계로서, plantTags라는 junction table을 통해 관계를 맺고있다.

요구 기능

식물은 여러 태그를 갖고있다. 여러 태그를 API를 호출하면서 담아서 보내면, 응답으로서 제공되는 식물은 해당하는 태그를 모두포함 하고 있어야 하며, 태그중 하나라도 갖고있지 않다면 제외 되어야 한다.

올바르지 않은 코드

처음엔 단순히 여러 태그들을 or 조건으로 쿼리하여 보내주면 되겠지 하는 안일한 생각으로 코드를 다음과 같이 짰다.

    const findPlantsIds = await Plant.findAll({
      attributes: ['id'],
      include: [
        {
          model: Tag,
          where: {
            name: {
              [Op.in] : plantDTO
            },
          },
          attributes: [],
          through: {
            attributes: [],
          },
        },
      ],
    });
  

Sequelize 공식 문서에 확인한 Sequelize.Op 는 여러 연산자들을 지원하는데, Op.in 연산자는 배열안의 요소들을 모두 or 조건으로 체크하여 쿼리하는 연산자였다.

plantDTO 의 경우 ['#tag1' ,'#tag2'] 와 같이 여러 태그의 이름들로 구성된 배열이다.

위 코드의 문제점은 배열 안에 있는 태그가 단 1개 라도 존재한다면, 그 조건에 부합하는 모든 식물을 응답하는 문제점이 있었다.

위 코드에 대한 SQL 변환 결과는 다음과 같다.

SELECT `Plant`.`id` FROM `plants` AS `Plant` 
INNER JOIN ( `plantTags` AS `Tags->PlantTag` 
INNER JOIN `tags` AS `Tags` ON `Tags`.`id` = `Tags->PlantTag`.`TagId`) 
ON `Plant`.`id` = `Tags->PlantTag`.`PlantId` AND `Tags`.`name` IN ('#tag1', '#tag2');

우리가 원하는 조건은 배열로 주어진 요소들을 모두 갖고있으며, 하나라도 갖고있지 않을경우 제외하고 응답을 해주어야 한다.

삽질..

우선 Sequelize 공식 문서를 열심히 뒤져보았다. 항상 나는 어떤 라이브러리나 프레임워크를 쓰기 전 문서를 최대한 읽어보고 사용하려고 노력하는데....
Sequelize 공식 문서는 불친절하기로 유명해서 🤦🏻‍♂️ 원하는 답을 찾을 수 없었다.

그러다가 찾은 한줄기의 빛. Stack Overflow

https://stackoverflow.com/questions/52290036/mysql-many-to-many-relationship-with-multiple-and-condition

내용을 확인해보니 이런 M:N 관계에서 우리가 생각하는 and(?) 조건을 구현하기 위해서는 sql의 having , group 절을 조합하여야 구현이 가능했다.

상기 링크에 있는 예시는 다음과 같다.

SELECT i.id, i.name    -- OK to select name assuming id is the PK
FROM item i
LEFT JOIN relation r
    ON i.id = r.item_id
LEFT JOIN tag t
    ON t.id = r.tag_id
WHERE t.tag IN ('sport', 'leather')
GROUP BY i.id
HAVING COUNT(DISTINCT t.tag) = 2;

위와 같이 쿼리하면, sport, leather 라는 태그를 둘다 갖고 있는 요소들이 반환되는것이다.

그래서... 그대로 적용하려고 Sequelize의 find 메서드들의 옵션을 찾아본 결과

...?

...??

...찾았다...?

있지만.. 안된다

HAVING 절 + Count, Distinct를 구현한 레퍼런스는 없었기 때문에 Sequelize 에서 제공하는 literal 메서드를 사용하여 raw query를 만들었다.

올바른 코드

그 결과 최종적으로 구현된 코드는 다음과 같다.

const findPlantsIds = await Plant.findAll({
      attributes: ['id'],
      include: [
        {
          model: Tag,
          where: {
            name: {
              [Op.in] : plantDTO
            },
          },
          attributes: [],
          through: {
            attributes: [],
          },
        },
      ],
      having: sequelize.literal(`COUNT(DISTINCT Tags.name) = ${plantDTO.length}`),
      group: ['id'],
    });

group 절에 어떤 필드가 들어가는지는 크게 중요하지 않아보였다. 다만 having 절이 큰 의미가 있었는데. having절의 의미를 해석하자면 다음과 같다.

Tags.name 중복을 제거하고, 태그 배열들의 요소들을 식물이 갖고있는 태그들과 비교해봤을때, 개수가 태그 배열의 원소 길이와 일치하면 조건 OK, 그렇지 않다면 Pass

그러니까 #tag1, #tag2 를 배열의 원소로 제공하였을때, 저 두개의 태그를 포함하고 있으면 조건 통과, 그렇지 않다면 통과 X의 의미다.

해당 내용을 SQL로 변환하면 다음과 같다.

SELECT `Plant`.`id` FROM `plants` AS `Plant` 
INNER JOIN ( `plantTags` AS `Tags->PlantTag` 
INNER JOIN `tags` AS `Tags` ON `Tags`.`id` = `Tags->PlantTag`.`TagId`) 
ON `Plant`.`id` = `Tags->PlantTag`.`PlantId` AND `Tags`.`name` 
IN ('#tag1', '#tag2') 
GROUP BY `id` HAVING COUNT(DISTINCT Tags.name) = 2;

내가 예상한대로 grouphaving 절이 올바르게 들어갔다.

요구조건에 충족하는 응답도 확인했다.

뒤돌아보며

사실 비관계형 데이터베이스를 많이 써왔고, 관계형 DB자체도 익숙하지 않은데다, 처음 써보는 Node, Express를 사용해 프로젝트를 진행하게 되어서 처음엔 부담감도, 걱정도 너무 많았다.

하지만 정말 다행스럽게도 JS자체가 내가 느끼기엔 파이썬과 비슷한 부분이 많아서 금방 적응을 한 것 같다.

하지만 적응도 한순간, 악명높은 Sequelize는 이 프로젝트를 진행하면서 나를 종종 시험에 들게 했는데, 불친절하긴 해도 공식문서는 공식문서라 나름 도움은 많이 되었던 것 같다.

누군가에게 이 삽질이 도움이 되길 바란다.

profile
DevOps 엔지니어로 핀테크 회사에서 일하고있습니다. 아직 많이 부족합니다.

0개의 댓글