끄적끄적

Application Side Join 을 통한 쿼리 성능 개선 본문

개발/데이터베이스

Application Side Join 을 통한 쿼리 성능 개선

코리이 2023. 5. 7. 19:34

웹 백앤드 어플리케이션 개발을 할 때 대부분의 서비스에서 여러 테이블의 join 은 필수적으로 일어난다. 그런데 많은 테이블들을 join 하면서 성능이 떨어지는 경우가 많이 생긴다. 이를 해결하기 위해 join 되는 컬럼에 인덱스를 건 뒤, 그 인덱스를 타게 하기 위한 수많은 쿼리 튜닝 작업을 진행하곤 한다. 하지만 쿼리 튜닝 작업 전에 가장 기본적으로 성능 향상을 시킬 수 있는 방법이 있는데 db side 에서 join 을 사용하는 것이 아닌 application side 에서 join 을 진행하는 것이다. 이번 포스팅에서는 실제로 db side join 을 application  side join 으로 변경하면서 성능 향상을 한 경험에 대해서 이야기할 생각이다.

들어가기 전에

본문으로 들어가기 전에 포스팅에서 어떤 쿼리를 이용할지 설명하자면 채팅방 리스트를 가져오는 간단한 쿼리를 튜닝해볼 것이다. 아래 ERD 처럼 채팅방이 존재하고 채팅방 목록에 표시할 썸네일들이 여러장 있다고 하자. 또한 이 채팅방에는 검색을 위한 태그들이 여러개 존재하고 있으며, 채팅방의 주인이 존재한다.

물론 실제 서비스에서는 채팅방 주인은 따로 테이블로 분리될 수 있으며, 채팅방 멤버, 공지사항, 채팅메시지 등 더 많은 테이블들의 join 이 들어갈 수도 있고, 썸네일, 테그의 순서 등이 들어가면서 쿼리가 더 복잡해질 수도 있다. 하지만 이번 포스팅에서 모두 다루기엔 범위를 넘어갈 뿐 아니라 설명하기 더 복잡해지므로 생략했다.

DB Side Join 과 Application Side Join

우선 db side join 과 applicatoin side join 의 차이점에 대해서 이해할 필요가 있다. 쉽게 말해서 db join 은 실제 우리가 데이터베이스를 공부하면서 배웠던 join 을 사용하는 방식이다. 따라서 어플리케이션단에서는 db 에서 조회된 모든 결과를 가지고 객체에 매핑하는 작업을 진행할 것이다.

SELECT *
FROM blog_chat_rooms room
    LEFT JOIN blog_users owner ON owner.id = room.owner_id
    LEFT JOIN blog_chat_room_tags tags ON tags.room_id = room.id
    LEFT JOIN blog_chat_room_thumbnails thumbnails ON thumbnails.room_id = room.id;

그렇다면 application side join 의 방식은 무엇일까? 말 그대로 db 에서 join 을 사용하지 않고 필요한 모든 데이터를 조회한 후 application 에서 반복문을 돌면서 join 하는 방식이다. 따라서 db 단에서는 같은 결과를 얻기 위해 네 번의 쿼리를 필요로 한다. room 을 먼저 조회하고 결과로 room id 리스트와 owner id 리스트를 확인할 수 있고 이를 이용해 나머지 테이블에서 데이터를 가져오면 되는 구조이다.

SELECT *
FROM blog_chat_rooms room;

SELECT *
FROM blog_users owner
WHERE owner.id IN {user ids};

SELECT *
FROM blog_chat_room_tags tags
WHERE tags.room_id IN {room ids};

SELECT *
FROM blog_chat_room_thumbnails thumbnails
WHERE thumbnails.room_id IN {room ids};

그 후 어플리케이션에서는 각 값에 맞게 객체를 매핑해주면 된다.

// 생략 ...
const thumbnailMap: Map<string, ChatRoomThumbnail[]> = new Map(rooms.map((room) => [room.id, []]));
for (const thumbnail of thumbnails) {
    const results = thumbnailMap.get(thumbnail.roomId);
    if (results === undefined) continue;
    results.push(thumbnail);
}

rooms.map(room => ({
    ...room,
    owner: owners.find(owner => owner.id === room.ownerId),
    tags: tagMap.get(room.id),
    thumbnails: thumbnailMap.get(room.id),
}));

성능 비교

우선 같은 결과를 얻기 위해 채팅방 200 개를 가져오는 실험을 했다. 이 때 단순히 limit 200 을 하게 되면 db join 의 경우 채팅방 200개가 아니라 (태그 갯수 * 썸네일 갯수) 가 200 개만을 가져오므로 대략 200 / (5 * 5) = 8 개의 채팅방 밖에 못가져오게 된다. 이로 인해 typeorm 에서는 take 라는 함수를 제공해주고 이는 채팅방 200 개를 가져 온 뒤 채팅방 id 로 in 절을 사용하여 200 개를 조회하는 방식을 취한다. 하지만 이를 사용하면 서브쿼리가 발생해 실제 db join 성능은 더 최악으로 되므로 테스트는 200 개의 채팅방을 추가하고 모든 채팅방을 가져오는 테스트로 진행했다. 참고로 각각의 채팅방에는 2~10 개의 태그와 썸네일이 존재한다. 또한 커넥션 풀은 40 개 정도로 실제 서버에서 사용할 수 있을 정도로 넉넉히 사용했다.

테스트 툴로는 apache bench 도구를 활용해서 아래처럼 api url 을 잡고 30 개씩 동시에 총 3000 (30 * 100) 번의 api call 을 한 뒤 평균 시간을 확인했다.

ab -n 3000 -c 30 {API URL}

우선 DB Side Join 의 결과이다. 아래의 스크린샷을 보면 알 수 있겠지만 대략 한개의 request 는 2700 ms 정도 걸렸다. 물론 처음 api call 을 했을 때는 130ms 정도가 소모되었지만 동시에 여러번 실행시키다 보니 커넥션 풀을 모두 사용하기도 하는 등 여러 이슈에 의해서 응답 시간이 더 길어지는 걸 확인할 수 있다.

이후 Application Side Join 의 결과이다. 평균적으로는 대략 600 ms 가 걸렸으며 가장 처음 api call 의 경우 50ms 정도로 훨씬 빠른 성능을 보여주었다.

결과만 확인해도 알겠지만 Application Side Join 이 Db Side Join 보다 대략 400 % (4배) 빠른 것을 확인할 수 있다.

DB Side Join 의 문제점

그렇다면 DB Side Join 은 왜 느린 걸까? 결론은 간단하다. DB 에서 Join 을 하게 되면 필요없는 값들이 함께 join 되어 진다. 결과적으로 db join 은 O(M x N) 의 성능을 가진다는 것이다. 쉽게 결과로 설명하기 위해 하나의 방만 조회해서 결과를 확인해보자.

SELECT room.id, tags.tag, thumbnails.url
FROM blog_chat_rooms room
    LEFT JOIN blog_users owner ON owner.id = room.owner_id
    LEFT JOIN blog_chat_room_tags tags ON tags.room_id = room.id
    LEFT JOIN blog_chat_room_thumbnails thumbnails ON thumbnails.room_id = room.id
WHERE room.id = 'roomId';

쿼리 결과

결과를 보면 알겠지만 하나의 room 을 조회하는데 쓸데없이 id 도 여러번 반환하고 tag 도 여러번 반환한다. 즉 위의 결과에서는 (1 * tag * thumbnails) 의 수만큼 조회를 한다는 것이다. 테스트 시 조회한 room 갯수가 200 개였으니 join 갯수는 (200 * (각각의 room의 태그 수) * (각각의 room 의 썸네일 수)) 가 된다. 실제로 테스트 시에 조회한 레코드 수는 5600 개였다. 그에 따라 db 의 데이터를 가져오는 데 오버헤드가 생기고 join 연산도 늘어나서 당연히 느려질 수밖에 없는 것이다.

 

반면 Application Side Join 의 경우에는 어떨까? 모든 테이블을 따로 가져오게 되므로 O(M+N) 의 성능을 가지게 된다. 물론 어플리케이션에서 join 을 해야 하는 문제가 있지만 곱연산이 아니라 합연산이 되므로 성능면에서는 훨씬 유리하다고 볼 수 있다. 아래는 실제 테스트를 위해 만든 application side join 코드이다. (typeorm 으로 작성되었다.)

const rooms = await this.dataSource.createQueryBuilder(ChatRoom, 'room').getMany();

const roomIds = rooms.map(room => room.id);
const ownerIds = rooms.map(room => room.ownerId);

const owners = await this.dataSource.createQueryBuilder(User, 'user')
    .where(`id IN (:...ids)`, { ids: ownerIds })
    .getMany();

const tags = await this.dataSource.createQueryBuilder(ChatRoomTag, 'tag')
    .where(`tag.room_id IN (:...ids)`, { ids: roomIds })
    .getMany();

const thumbnails = await this.dataSource.createQueryBuilder(ChatRoomThumbnail, 'thumbnail')
    .where(`thumbnail.room_id IN (:...ids)`, { ids: roomIds })
    .getMany();

const tagMap: Map<string, ChatRoomTag[]> = new Map(rooms.map((room) => [room.id, []]));
for (const tag of tags) {
    const results = tagMap.get(tag.roomId);
    if (results === undefined) continue;
    results.push(tag);
}

const thumbnailMap: Map<string, ChatRoomThumbnail[]> = new Map(rooms.map((room) => [room.id, []]));
for (const thumbnail of thumbnails) {
    const results = thumbnailMap.get(thumbnail.roomId);
    if (results === undefined) continue;
    results.push(thumbnail);
}

return rooms.map(room => ({
    ...room,
    owner: owners.find(owner => owner.id === room.ownerId),
    tags: tagMap.get(room.id),
    thumbnails: thumbnailMap.get(room.id),
}));

결론

사실 많은 ORM 들이 application side join 을 지원한다. 하지만 필자가 자주 사용하는 typeorm 은 이를 지원하지 않으며 java 에서 가장 많이 사용한다는 jpa 같은 경우에도 기본적으로 db join 을 사용한다. 물론 db join 이 더 빠른 경우도 존재한다. 위에서 tag 와 thumbnail 을 조회하지 않고 owner 만 join 해서 가져오는 경우에는 O(N) 의 성능이므로 두번 이상 db 네트워크를 타야하는 application side join 보다 빠를 것이다. 

따라서 무조건 application side join 이 좋다는 건 아니므로 상황에 맞게 적잘하게 사용하면 될 것 같다. 참고로 필자가 개발하는 서비스의 경우 실제로 따로 쿼리 튜닝을 진행하지 않았음에도 대략 3~4 배 빠른 쿼리 성능을 가지게 되었으며, 특히나 js 의 경우에는 Promise All 과 함께 db query 를 동시에 사용한다면 더 큰 성능 향상도 가능했다. 

DB 쿼리를 튜닝하는 것도 좋지만 시간이 없다면 우선적으로 application side join 을 고려해보는 게 좋은 선택일 것 같다.