[DB] Query 작성 시 유의사항

Intro

안녕하세요 Noah입니다

오늘은 팀원이 작성한 쿼리 중 내부적으로 이슈가 된 쿼리가 있어 해당 쿼리를 분석해보려 합니다.

어떤 쿼리인지 살펴보고 일반 개발자분들이 DBA처럼 완벽한 쿼리 튜닝은 할 수 없을지라도 최소한의 주의사항은 지키면서 쿼리를 작성하자는 마음으로 글을 쓰게 되었습니다.

쿼리 예시

기능은 앱 시청기록 메뉴 접근 시 내가 시청했던 영상에 대한 시청 기록과 연관된 데이터 리스트를 받는 것입니다.
어떤 페이지든 성능을 고려해야겠지만 해당 페이지는 유저들의 이용량이 많을 것으로 판단되어 특히 신경을 써야 하는 페이지입니다.
(아래 코드는 실제 작성되어 있는 것에서 컬럼명 및 테이블명만 수정하여 그대로 가져온 것입니다.)

SELECT A.*
     ,(SELECT IF(DEL_YN='N',true,false)
       FROM USER_CLASS
       WHERE USER_UID = '유저아이디'
         AND CLASS_ID=A.CLASS_ID
         AND RELATION =13) AS bookMark /* TRUE일경우 북마크설정, FLASE일경우 북마크해제 */
FROM (select TMP.*
           ,IFNULL(UC2.EVALUATION_SCORE,0)  AS EVALUATION_SCORE
           ,IFNULL(UC2.EVALUATION_USER_CNT, 0) AS EVALUATION_USER_CNT
           ,C.CLASS_NM AS classTitle
           ,(TIME_TO_SEC(TMP.PLAYED_TIME)/TIME_TO_SEC(C.LEN))*100 as playedPercent
           ,C.CLASS_CATEGORY AS classWorkoutCategoryCode
           ,C.LIVE_JOB_ID AS liveId
           ,C.CONTENTS_TYPE
           ,C.VOD_ENTRY_ID AS videoId
           ,C.CREATE_DATETIME
           ,C.CLASS_LEVEL AS classLevelCode
           ,C.PAID_FREE_TYPE AS PAID_FREE_GUBUN
           ,C.THUMBNAIL_S3_FILE_URL
           ,C.LEN
           ,(SELECT GROUP_CONCAT(L.LECTURE_NAME)
             FROM CLASS_LECTURE CL,LECTURE L
             WHERE CL.CLASS_ID=TMP.CLASS_ID
               AND CL.LECTURE_ID=L.LECTURE_ID
               AND L.DEL_YN='N'
               AND CL.DEL_YN='N') AS lectureName
           ,(SELECT COUNT(1)
             FROM VIEWING_HISTORY VH
             WHERE VH.CLASS_ID=TMP.CLASS_ID
    ) AS CLASSVIEWCNT
      from (SELECT VH.CLASS_ID
                 ,VH.WATCH_START_DATETIME
                 ,VH.WATCH_END_DATETIME
                 ,TIME_TO_SEC(VH.PLAYED_TIME) AS PLAYED_TIME
            from VIEWING_HISTORY VH
            WHERE VH.USER_UID= '유저아이디'
              AND VH.WATCH_END_DATETIME IS NOT NULL
            order by VIEWING_HISTORY_ID desc
            LIMIT 18446744073709551615)  TMP , CLASS C
                                                   LEFT JOIN  (SELECT   CLASS_ID
                                                                    ,IF(COUNT(1)=0,0 ,TRUNCATE(AVG(EVALUATION_CNT),1))  AS EVALUATION_SCORE
                                                                    ,COUNT(CLASS_ID) AS EVALUATION_USER_CNT
                                                               FROM USER_CLASS
                                                                 AND DEL_YN ='N'
                                                               GROUP BY CLASS_ID  ) UC2
                                                              ON C.CLASS_ID = UC2.CLASS_ID
         ,CLASS_LECTURE CL,LECTURE L
      WHERE TMP.CLASS_ID=C.CLASS_ID
        AND C.CONTENTS_TYPE = 'VV'
        AND C.CLASS_ID = CL.CLASS_ID
        AND CL.LECTURE_ID = L.LECTURE_ID
        AND L.DEL_YN='N'
      GROUP BY TMP.CLASS_ID
      LIMIT 10) A
ORDER BY A.WATCH_END_DATETIME DESC





분석

큰 틀에서부터 위 쿼리를 하나씩 분석해 보도록 하겠습니다.

1차 튜닝

  1. 쿼리가 라인 정리 및 대소문자 정리가 되어있지 않아 보는 것 자체가 어려움
    코드 분석 전 우선 코드를 분석하는 것 자체가 어렵다는 것이 가장 큰 단점으로 보입니다.
    저라면 위 쿼리를 아래 쿼리와 같이 정리할 것 같습니다.
    (더 좋은 정리 방법이 있다면 공유해 주세요 ^^)

         SELECT A.*
         FROM (
               SELECT TMP.*
                    , IFNULL(UC2.EVALUATION_SCORE,0)  AS EVALUATION_SCORE
                    , IFNULL(UC2.EVALUATION_USER_CNT, 0) AS EVALUATION_USER_CNT
                    , C.CLASS_NM AS CLASSTITLE
                    , (TIME_TO_SEC(TMP.PLAYED_TIME)/TIME_TO_SEC(C.LEN))*100 AS PLAYEDPERCENT
                    , C.CLASS_CATEGORY AS CLASSWORKOUTCATEGORYCODE
                    , C.LIVE_JOB_ID AS LIVEID
                    , C.CONTENTS_TYPE
                    , C.VOD_ENTRY_ID AS VIDEOID
                    , C.CREATE_DATETIME
                    , C.CLASS_LEVEL AS CLASSLEVELCODE
                    , C.PAID_FREE_TYPE AS PAID_FREE_GUBUN
                    , C.THUMBNAIL_S3_FILE_URL
                    , C.LEN
               , (
                  SELECT GROUP_CONCAT(L.LECTURE_NAME)
                  FROM   CLASS_LECTURE CL, LECTURE L
                  WHERE  CL.CLASS_ID = TMP.CLASS_ID
                    AND  CL.LECTURE_ID = L.LECTURE_ID
                    AND  L.DEL_YN = 'N'
                    AND  CL.DEL_YN = 'N'
                 ) AS LECTURENAME
               , (
                  SELECT COUNT(1)
                  FROM   VIEWING_HISTORY VH
                  WHERE  VH.CLASS_ID=TMP.CLASS_ID
                 ) AS CLASSVIEWCNT
               FROM (
                     SELECT VH.CLASS_ID
                          , VH.WATCH_START_DATETIME
                          , VH.WATCH_END_DATETIME
                          , TIME_TO_SEC(VH.PLAYED_TIME) AS PLAYED_TIME
                     FROM   VIEWING_HISTORY VH
                     WHERE  VH.USER_UID = '유저아이디'
                       AND  VH.WATCH_END_DATETIME IS NOT NULL
                     ORDER BY VIEWING_HISTORY_ID DESC
                     LIMIT  18446744073709551615
               ) TMP
               , CLASS C
               LEFT JOIN (
                     SELECT CLASS_ID
                          , IF(COUNT(1)=0,0 ,TRUNCATE(AVG(EVALUATION_CNT),1)) AS EVALUATION_SCORE
                          , COUNT(CLASS_ID) AS EVALUATION_USER_CNT
                     FROM   USER_CLASS
                       AND  DEL_YN = 'N'
                     GROUP BY CLASS_ID
               ) UC2
               ON C.CLASS_ID = UC2.CLASS_ID
               , CLASS_LECTURE CL
               , LECTURE L
               WHERE TMP.CLASS_ID = C.CLASS_ID
                 AND C.CONTENTS_TYPE = 'VV'
                 AND C.CLASS_ID = CL.CLASS_ID
                 AND CL.LECTURE_ID = L.LECTURE_ID
                 AND L.DEL_YN = 'N'
               GROUP BY TMP.CLASS_ID
               LIMIT 10
         ) A
         LEFT OUTER JOIN (
               SELECT IF(DEL_YN='N',TRUE,FALSE) AS BOOKMARK
                    , CLASS_ID
               FROM   USER_CLASS
               WHERE  USER_UID = '유저아이디'
                 AND  DEL_YN = 'N'
         ) B
         ON A.CLASS_ID = B.CLASS_ID
         ORDER BY A.WATCH_END_DATETIME DESC;
    



  2. 스칼라 서브쿼리를 활용하기 때문에 영상 종류가 많아질수록 성능이 떨어질 수밖에 없음

    스칼라 서브쿼리 사용 지양

       SELECT A.*
          ,(SELECT IF(DELETE_YN='N',true,false)
            FROM USER_MY_CLASS
            WHERE USER_ID = '유저아이디'
              AND CLASS_ID=A.CLASS_ID) AS bookMark
    

    이런 식으로 SELECT절 안에 ‘스칼라 서브쿼리’를 사용하게 되면 FROM절에서 ‘인라인뷰형태’로 사용하는 것과는 다른 작동방식을 보입니다.
    쿼리는 먼저 FROM절에 있는 Query문을 기반으로 데이터를 모두 조회한 뒤 SELECT절에 작성되어진 양식으로 값을 뿌려주게 됩니다.
    이 때 SELECT절 안에 또다시 서브쿼리가 있다면 해당 서브쿼리를 별도로 실행하는 작업이 2차적으로 필요해지는 것입니다.
    문제는 이런 현상이 한 ROW를 조회할 때마다 반복되기 때문에 자주 조회하는 페이지일수록, 데이터량이 많아질 수록 성능이 당연히 떨어질 수 밖에 없습니다.
    (RDBMS에서 지원하는 캐싱에 대한 내용을 나중에 알아보겠습니다.)



    인라인뷰로 변경 시

       SELECT A.*
            , IFNULL(B.BOOKMARK, 0) AS BOOKMARK
            -- (중략)
            GROUP BY TMP.CLASS_ID
            LIMIT 10
       ) A
       LEFT OUTER JOIN (
             SELECT IF(DEL_YN='N',true,false) AS BOOKMARK
                   ,CLASS_ID
             FROM USER_CLASS
             WHERE USER_UID = '유저아이디'
       ) B
       ON A.CLASS_ID = B.CLASS_ID
       ORDER BY A.WATCH_END_DATETIME DESC
    

    위와 같이 작성을 하게 되면 최초 FROM을 통해 데이터를 가져올 당시에 한번에 데이터를 가져와 BOOKMARK 값을 뿌려주기 때문에 2차적인 조회를 하지 않게 됩니다.


  3. 불필요한 값을 서브쿼리로 호출하는 행위

    기존 쿼리


    기존 쿼리 내용을 살펴보면 유저의 최근 시청 기록에 대한 데이터를 각 영상별로 정제해서 가져오도록 조건이 설정된 것이 아니라 시청했던 모든 내역을 불필요하게 조회하고 있었습니다.
    하지만 이런 식으로 쿼리 조회 시 불필요한 데이터 ROW들을 상위 쿼리에서도 관리해야 하기 때문에 자원 낭비가 발생하게 됩니다. 때문에 자원을 효율적으로 사용할 수 있도록 조건절을 설정할 필요가 있습니다.



    변경한 쿼리

    변경한 쿼리에서는 기존 쿼리와 달리 각 영상별 유저의 가장 최근 시청 기록만을 조회하도록 변경하였습니다.





2차 튜닝

1차 튜닝은 기존 쿼리에 대해 내용적으로 문제가 있던 것을 지적했다면 2차 튜닝은 아예 서브쿼리 자체를 줄여야 한다는 점을 짚고 넘어가려 합니다. MySQL은 기본적으로 Join으로 연결하는 것이 서브쿼리를 사용하는 것보다 더 좋은 성능을 보입니다. 5.6버전부터는 서브쿼리 성능 최적화가 이루어졌지만 적용 안되는 조건들이 다수 존재합니다.
이와 같은 점을 고려하여 다음과 같은 쿼리로 위 쿼리를 변경할 수 있습니다.

    SELECT VH.USER_UID
         , VH.CLASS_ID
         , VH.WATCH_START_DATETIME
         , VH.WATCH_END_DATETIME
         , TIME_TO_SEC(VH.PLAYED_TIME) AS PLAYED_TIME
         , IFNULL(UC2.EVALUATION_SCORE,0)  AS EVALUATION_SCORE
         , IFNULL(UC2.EVALUATION_USER_CNT, 0) AS EVALUATION_USER_CNT
         , C.CLASS_NM AS classTitle
         , (TIME_TO_SEC(VH.PLAYED_TIME)/TIME_TO_SEC(C.LEN))*100 as playedPercent
         , C.CLASS_CATEGORY AS classWorkoutCategoryCode
         , C.LIVE_JOB_ID AS liveId
         , C.CONTENTS_TYPE
         , C.VOD_ENTRY_ID AS videoId
         , C.CREATE_DATETIME
         , C.CLASS_LEVEL AS classLevelCode
         , C.PAID_FREE_TYPE AS PAID_FREE_GUBUN
         , C.THUMBNAIL_S3_FILE_URL
         , C.LEN
         , GROUP_CONCAT(L.LECTURE_NAME ORDER BY LECTURE_NAME) AS lectureName
         , VH2.CNT AS CLASSVIEWCNT
         , CASE WHEN UC3.DEL_YN ='N' THEN TRUE ELSE FALSE END bookMark
    FROM VIEWING_HISTORY VH
    INNER JOIN CLASS C
         ON VH.CLASS_ID = C.CLASS_ID 
        AND C.CONTENTS_TYPE = 'VV'
    INNER JOIN (
        SELECT USER_UID, CLASS_ID, MAX(VIEWING_HISTORY_ID) MAX_VIEWING_HISTORY_ID
        FROM VIEWING_HISTORY A
        WHERE A.WATCH_END_DATETIME IS NOT NULL
        GROUP BY A.USER_UID, CLASS_ID
    ) A
         ON VH.USER_UID = A.USER_UID 
        AND VH.VIEWING_HISTORY_ID = A.MAX_VIEWING_HISTORY_ID
    INNER JOIN (
        SELECT CLASS_ID
             , COUNT(1) AS CNT
        FROM VIEWING_HISTORY vh
        GROUP BY CLASS_ID
    ) VH2
        ON VH.CLASS_ID  = VH2.CLASS_ID
    LEFT OUTER JOIN  (
        SELECT USER_UID
             , CLASS_ID
             , IF(COUNT(1)=0,0 
             , TRUNCATE(AVG(EVALUATION_CNT),1)) AS EVALUATION_SCORE
             , COUNT(CLASS_ID) AS EVALUATION_USER_CNT
        FROM USER_CLASS
        WHERE RELATION='12'
          AND DEL_YN ='N'
        GROUP BY USER_UID, CLASS_ID
    ) UC2
         ON VH.CLASS_ID = UC2.CLASS_ID 
        AND VH.USER_UID = UC2.USER_UID
    LEFT OUTER JOIN USER_CLASS UC3
         ON VH.USER_UID = UC3.USER_UID 
        AND VH.CLASS_ID = UC3.CLASS_ID 
        AND UC3.RELATION ='13'
    INNER JOIN CLASS_LECTURE CL
         ON VH.CLASS_ID = CL.CLASS_ID 
        AND CL.DEL_YN ='N'
    INNER JOIN LECTURE L
         ON CL.LECTURE_ID = L.LECTURE_ID 
        AND L.DEL_YN='N'
    WHERE VH.USER_UID ='유저 아이디'
    GROUP BY VH.CLASS_ID

위와 같이 쿼리를 변경함으로써 서브 쿼리를 많이 제외한 것을 보실 수 있습니다.


  • 부가 효과 01. 자원 낭비 감소

    12번의 테이블 조인이 발생하던 기존 쿼리


    조인회수 감소

    위 내용을 보시면 기존과 비교하여 테이블 조인 회수 감소 및 테이블 조인 시 Full Scan이 발생하지 않도록 조치된 것을 보실 수 있습니다.

  • 부가 효과 02. 협업 가능 이 외에도 테이블 조인 시 쿼리 작성 방법도 기존 쿼리는 MSSQL, Oracle, MySQL이 혼재되었던 것에 비해 현재는 MySQL 방식으로 통일하여 다른 개발자들과 업무적으로 협업하기에도 더 효과적인 것을 알 수 있습니다.



글을 마치며

이번에 글을 작성하며 동일한 결과값을 가져오더라도 서버단에서 처리할 것인지 DB단에서 처리할 것인지를 결정해야 하고 DB단에서 조건을 처리하기로 결정하였으면 어떤 방식으로 처리하는 것이 가장 효과적으로 처리하는 것인지에 대해 고찰할 수 있었다는 것이 좋았습니다.

개발 실력이 늘어가는 것은 경력이 쌓이며 자연스럽게 늘어가는 것도 있겠지만 이렇게 주변 사람들과 소통하고 더 나은 방법을 찾기 위해 토론하는 경험을 통해 혼자 공부하는 것보다 더 재밌고 빠르게 늘 수 있다는 것을 알 수 있었습니다.

여러분도 쿼리 튜닝에 대해 알고 있으신 지식이 있다면 여기서 공유하시면서 댓글로 같이 공유해 주시면 감사하겠습니다.

이상으로 마치겠습니다. 긴 글 읽어주셔서 감사드립니다. ^^