[MySQL, TypeORM] 쿼리 튜닝 (1) 조인 순서 변경하기

acho·2024년 7월 22일

Nested Loop

Nested Loop는 조인 알고리즘의 하나로 중첩 조인이라고도 합니다.
중첩 조인에서는 어떤 테이블에 먼저 접근하는지가 성능에 있어 중요하게 작용합니다.
우선 용어를 정리하자면, Join이 대상이 되는 테이블 중 먼저 접근하는 테이블을 드라이빙 테이블, 이후에 접근하는 테이블을 드리븐 테이블이라고 합니다.

중첩 조인의 작동 방식은 다음과 같습니다.
1. 쿼리에 포함된 조건절에 따라 드라이빙 테이블을 필터링 합니다.
2. 필터링 된 드라이빙 테이블의 한 행당 드리븐 테이블을 탐색해 매치되는 행을 찾습니다. (인덱스가 없다면 풀 테이블 스캔, 있다면 인덱스 스캔을 합니다.)
3. 드라이빙 테이블의 모든 행에 대해 반복합니다.
4. 공통된 데이터를 출력합니다.
이런 로직을 따르기 때문에, 먼저 접근되는 드라이빙 테이블의 행 수가 적을수록 유리합니다. 행의 갯수만큼 드리븐 테이블에 접근하기 때문입니다.
따라서 조건절의 결과로 반환되는 행의 갯수가 더 적은 테이블에 가장 먼저 접근하는 것이 조인시 유리합니다.

문제 쿼리

    private async findUserDogJournalsByDate(
        userId: number,
        dogId: number,
        startDate: Date,
        endDate: Date,
    ): Promise<Journals[]> {
        return await this.entityManager
            .createQueryBuilder(Journals, 'journals')
            .innerJoin(JournalsDogs, 'journals_dogs', 'journals.id = journals_dogs.journal_id')
            .where('journals.user_id = :userId', { userId })
            .andWhere('journals_dogs.dog_id = :dogId', { dogId })
            .andWhere('journals.started_at >= :startDate', { startDate })
            .andWhere('journals.started_at < :endDate', { endDate })
            .getMany();
    }
SELECT  distance, duration, started_at FROM journals
INNER JOIN journals_dogs ON journals.id = journals_dogs.journal_id
WHERE journals.user_id = 1 AND journals_dogs.dog_id = 1
AND journals.started_at >= "2024-06-30T15:00:00.000Z" AND journals.started_at < "2024-07-31T14:59:59.999Z";

산책 일지를 담고 있는 journals 테이블과, 관계형 테이블 journals_dogs 테이블을 조인해 user_id = 1, dog_id = 1이며 한달 날짜 기준에 부합하는 산책일지를 리턴하는 쿼리입니다.

이 쿼리의 실행 계획중 JOIN과 관련된 부분만 살펴보겠습니다.

idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEjournals_dogsrefPRIMARY,FK_65d6e1349dda45ff53bc0d865afFK_65d6e1349dda45ff53bc0d865af4const1011100.0Using index
1SIMPLEjournalseq_refPRIMARY,FK_dcd8f26897887ea1ca19e9b910aPRIMARY4performance.journals_dogs.journal_id15.0Using where
  • 실행 계획의 첫번째에 오는 테이블이 보통 드라이빙 테이블(먼저 접근하는 테이블)입니다.
  • id는 실행 순서를 표시하는 숫자로 조인 시에는 같은 id가 표시됩니다.
  • type : 테이블의 데이터를 어떻게 찾을지에 관한 정보를 제공하는 항목입니다. 테이블을 처음부터 끝까지 전부 확인할지, 아니면 인덱스를 통해 바로 데이터를 찾아갈지 등을 해석할 수 있습니다.
    • ref: 테이블 검색 시 조건에 해당하는 데이터가 2개 이상 존재하는 경우입니다. 이 경우 dog_id = 1인 데이터가 jouanals_dogs 테이블에 여러 건 존재하기에 ref가 사용됩니다.
    • eq_ref: 테이블 검색 시 고유 인덱스 또는 기본 키로 단 1건의 데이터를 조회한다는 뜻입니다. 이 경우에는 journals_dogs와 journals 사이 조인이 수행될 때 조인 조건이 PK 등으로 유니크하다는 뜻입니다.
  • ref: reference의 약자로, 테이블에 접근할 때 어떤 조건으로 해당 테이블에 액세스 되었는지를 알려주는 정보입니다. jouanls_dogs 테이블과 journal 테이블 조인시 journal_id가 사용되었음을 알 수 있습니다.
  • rows: SQL문을 수행하고자 접근하는 데이터의 모든 행row 수를 나타내는 예측 항목입니다.
    • journals_dogs 테이블에 1011 행이 리턴될 것으로 보고 있습니다.
  • filtered: SQL문을 통해 DB엔진으로 가져온 데이터 대상으로 필터 조건에 따라 어느 정도의 비율로 데이터를 제거했는지를 의미하는 항목입니다.
    • 여기서는 5%로, 1011 행에 대해 5퍼센트 필터링해 51행이 출력될 것으로 예상하고 있습니다.
  • extra: 부가적인 정보입니다.
    • Using where : WHERE 절의 필터 조건을 사용해 MySQL 엔진으로 가져온 데이터를 추출할 것이라는 의미입니다. 여기서는 journals_dogs와 journals 테이블을 jouanr_id가 같은 행만 남도록 JOIN한 뒤, 이 JOIN의 결과에 대해 user_id =1, dog_id = 1, started_at이 (현지 시간 기준으로)7월에 해당하는 행만 남기고 필터링을 한다는 뜻입니다.

설명이 길었지만, 핵심적인 정보는 journals_dogs 테이블이 드라이빙 테이블, journals 테이블이 드리븐 테이블이라는 것입니다.
그런데 ERD 설계상 한 강아지가 여러 주인을 가질 수 있기 때문에,
위와 같은 조건 하에서는 필연적으로 journals_dogs의 결과 행이 더 많을 수밖에 없습니다.
journals 테이블에 먼저 액세스하도록, 즉 journals 테이블을 드라이빙 테이블로 만들면 효율이 더 높아질 것으로 보입니다.

해결

이 문제를 해결하기 위해서는 JOIN의 순서를 명시적으로 지정해줘야 합니다.
STRAIGHT JOIN 구문을 사용하면 FROM 절에 명시된 대로 JOIN 되도록 순서를 지정할 수 있습니다.

SELECT STRAIGHT_JOIN  journals.distance, journals.duration, journals.started_at FROM journals
INNER JOIN journals_dogs ON journals.id = journals_dogs.journal_id
WHERE journals.user_id = 1 AND journals_dogs.dog_id = 1
AND journals.started_at >= "2024-06-30T15:00:00.000Z" AND journals.started_at < "2024-07-31T14:59:59.999Z";
idselect_typetablepartitionstypepossible_keyskey
1SIMPLEjournalsrefPRIMARY,FK_dcd8f26897887ea1ca19e9b910aFK_dcd8f26897887ea1ca19e9b910a
1SIMPLEjournals_dogseq_refPRIMARY,FK_65d6e1349dda45ff53bc0d865afPRIMARY
  • journals 테이블이 1행, journals_dogs 테이블이 2행으로 순서가 바뀌어 표시됩니다. journals 테이블이 드라이브 테이블로 사용됨을 알 수 있습니다.

코드로 표현시 TypeORM은 STRAIGHT_JOIN 구문을 지원하지 않아 Raw 쿼리의 사용이 필요합니다.

    private async findUserDogJournalsByDate(
        userId: number,
        dogId: number,
        startDate: Date,
        endDate: Date,
    ): Promise<DogWalkJournalEntry[]> {
        return await this.entityManager.query(
            `
        SELECT STRAIGHT_JOIN journals.distance, journals.duration, journals.started_at as startedAt 
        FROM journals 
        INNER JOIN journals_dogs ON journals.id = journals_dogs.journal_id
        WHERE journals.user_id = ?
          AND journals_dogs.dog_id = ?
          AND journals.started_at >= ?
          AND journals.started_at < ?
      `,
            [userId, dogId, startDate, endDate],
        );
    }

쿼리 빌더의 SELECT 인자 배열 첫번째 요소를 'STRAIGHT_JOIN journlas.distance' 로 주어 자연스럽게 STRAIGHT_JOIN이 맨 앞에 가는 방법을 시도해보았지만, 배열의 첫번째 요소로 넣어도 실제로는 항상 두번째 컬럼으로 실행되어 실패했습니다.
.addSelect()로 체이닝 하는 방법도 써봤지만 이 방법 역시 순서를 보장하지 않아 Raw 쿼리 외에는 현재로서 방법이 없는 것 같습니다.

개선 결과

-> Nested loop inner join  (cost=457 rows=50.6) (actual time=0.137..6.65 rows=31 loops=1)
    -> Covering index lookup on journals_dogs using FK_65d6e1349dda45ff53bc0d865af (dog_id=1)  (cost=104 rows=1011) (actual time=0.08..0.919 rows=1011 loops=1)
    -> Filter: ((journals.user_id = 1) and (journals.started_at >= TIMESTAMP'2024-06-30 15:00:00') and (journals.started_at < TIMESTAMP'2024-07-31 14:59:59.999'))  (cost=0.25 rows=0.05) (actual time=0.00535..0.00538 rows=0.0307 loops=1011)
        -> Single-row index lookup on journals using PRIMARY (id=journals_dogs.journal_id)  (cost=0.25 rows=1) (actual time=0.00456..0.00462 rows=1 loops=1011)
  • Single-row index lookup on journals using PRIMARY (id=journals_dogs.journal_id) (cost=0.25 rows=1) (actual time=0.00456..0.00462 rows=1 loops=1011) 이 부분이 journals_dogs 테이블과 journals 간 중첩 조인을 수행하는 부분입니다. journals_dogs 테이블에 먼저 접근해 반환된 1011개의 행으로 journals 테이블에 접근하고 있어, 1011번 루프가 실행됨을 알 수 있습니다.

-> Nested loop inner join  (cost=307 rows=114) (actual time=0.502..5.05 rows=31 loops=1)
    -> Filter: ((journals.started_at >= TIMESTAMP'2024-06-30 15:00:00') and (journals.started_at < TIMESTAMP'2024-07-31 14:59:59.999'))  (cost=268 rows=114) (actual time=0.48..4.71 rows=55 loops=1)
        -> Index lookup on journals using FK_dcd8f26897887ea1ca19e9b910a (user_id=1)  (cost=268 rows=1025) (actual time=0.464..4.36 rows=1025 loops=1)
    -> Single-row covering index lookup on journals_dogs using PRIMARY (journal_id=journals.id, dog_id=1)  (cost=0.251 rows=1) (actual time=0.00558..0.00561 rows=0.564 loops=55)
  • Single-row covering index lookup on journals_dogs using PRIMARY (journal_id=journals.id, dog_id=1) (cost=0.251 rows=1) (actual time=0.00558..0.00561 rows=0.564 loops=55) 이번에는 journal 테이블에 먼저 접근해서 필터링을 한 뒤 반환된 55개의 행으로 journals_dogs 테이블에 접근하고 있습니다. 루프는 55번만 실행됩니다.

이로써 1011번 -> 55번의 반복으로 조인을 수행할 수 있게 되어
최종 cost가 457 -> 307로 약 32퍼센트 감소했습니다.
(Actual time은 캐싱의 영향으로 정확도가 떨어지는 것 같아 지표로 삼지 않았습니다.)

0개의 댓글