조인(Join) 한방 쿼리와 여러 개 쿼리
어떤 경우에 join 쿼리를 쓰고 어떤 경우에 여러개 쿼리를 나눠 쓸 수 있을까
최근에 프로젝트에서 리팩토링과 동시에 쿼리 개선을 하고 있다. 조인 한방 쿼리로 한 번에 끝내는 게 여러 번 네트워크 타는 것보다 좋을 것 같아서 한방 쿼리로 수정하고 있다가 다음과 같이 조인이 덕지 덕지 붙어있는 코드를 보고 뭔가 이상함을 감지했다.
select * from a
join b
join c
join d
join e
뭔가 아닌 거 같은데..?
물론 한방 쿼리가 좋을 때도 있지만 항상 한방 쿼리가 좋진 않을 것인데 언제 한방 쿼리를 사용하고 언제 여러 개 쿼리를 사용할 수 있을까 궁금증이 생겼다.
조인(Join)으로 한방 쿼리 vs 쿼리 여러 개
No Silver Bullet - 은총알은 없다.
어떤 성능 시나리오에서든 어느 솔루션이 더 빠른지 확인하기 위해 직접 솔루션을 테스트하고 측정해야 한다. 즉, 베스트 상황은 없다. 하지만, 일반적으로 어떤 상황에서 쓰면 좋을지는 통계가 있을 것이다. 이를 기반으로 한번 살펴보자.
일대일 관계
일반적으로 일대일 관계 같은 많은 외부 레코드를 가리키지 않는 경우 다음과 같은 성능을 나타낸다.
조인이 가장 빠른데 그 이유는 여러 개 쿼리가 발생하면 데이터베이스에 대한 각 쿼리에 고정 비용이 발생하기 때문이다. 일대일 관계 같은 경우 중복도 발생하지 않아서 부담 없이 조인을 사용할 수 있을 것 같다.
일대다에서는 절대 조인을 쓰지 말란 것이 아니다. 위에서 말한 것에 핵심 포인트는 일대일뿐 아니라 일대다에서도 많은 외부 레코드를 가리키지 않는 경우이다. 한방 조인으로 결괏값 주기 vs 여러 번 통신 + 애플리케이션에서 조합의 트레이드오프를 잘 고려해 봐야 된다.
일대다 관계
하지만, 참조된 레코드 중 다수가 동일할 수 있는 일대다 관계 같은 경우 조인 시에 중복이 엄청나게 발생할 수 있으니 잘 생각해 봐야 된다.
예를 들어보자.
1개의 게시물이 있고 게시물에는 2개의 댓글과 2개의 태그가 있다고 가정해 보자
SELECT post.id, comment.id, tag.id
FROM post
LEFT JOIN comment on post.id = comment.post_id
LEFT JOIN tag on tag.post_id = post.id;
-- post_id comment_id tag_id
-- 1 1 1
-- 1 1 2
-- 1 2 1
-- 1 2 2
이 경우 2 * 2로 4개의 결과가 나왔다. 여기서 댓글과 태그가 4개로 늘어난다면 어떻게 될까?
SELECT post.id, comment.id, tag.id
FROM post
LEFT JOIN comment on post.id = comment.post_id
LEFT JOIN tag on tag.post_id = post.id;
-- post_id comment_id tag_id
-- 1 1 1
-- 1 1 2
-- 1 1 3
-- 1 1 4
-- 1 2 1
-- 1 2 2
-- 1 2 3
-- 1 2 4
-- 1 3 1
-- 1 3 2
-- 1 3 3
-- 1 3 4
-- 1 4 1
-- 1 4 2
-- 1 4 3
-- 1 4 4
각 행의 곱인 4 * 4 = 16개의 행이 나오게 된다. 그럼 만약에 각 데이터의 수가 1000개씩만 되어도 나오는 행의 수는 몇 개일까..? 여기서 더 많은 테이블과 레코드들을 추가하면 대부분 중복된 데이터로 가득 찬 수많은 행으로 문제가 확대가 된다.
실제 테스트를 해보자. 실험 환경은 다음과 같다.
- Mac M1 Pro 16G 512GB
- 실험 데이터 수는 게시글 1개, 댓글 1000개, 태그 1000개
먼저 조인을 이용하여 100만(1000 * 1000)개의 테이블을 생성해 결괏값 100만 개를 내어줄 때는 12초가 걸렸다.
하지만, 두 개의 쿼리(1000 + 1000)로 분리하여 결괏값 100만 개를 내어 줄 때는 고작 0.2초밖에 안 걸린 걸 볼 수 있다.
여러 개 테이블의 경우도 벤치마크를 한번 보자. 두 경우 모두 동일한 결과(6 x 50 x 7 x 12 x 90 = 2268000)를 얻지만 여러 개의 쿼리가 훨씬 더 빠르다.
5개의 조인을 사용한 단일 쿼리
쿼리: 8.074508초
결과 크기: 2268000
연속 쿼리 5개
결합된 쿼리 시간: 0.00262초
결과 크기: 165(6 + 50 + 7 + 12 + 90)
또한, 중복 데이터로 인해 기하급수적으로 더 많은 메모리를 사용한다. 적은 테이블의 수를 조인하는 경우 크게 나쁘지 않을 수 있지만 늘어날수록 행의 수가 기하급수적으로 늘기 때문에 잘 고려해 보자.
적합한지 여부
조인을 사용해야 하는지는 조인이 적합한 지에 따라 판단해 볼 수도 있다. 위의 예시로 사용한 테이블에서 댓글과 태그는 게시글과는 관련이 있지만 서로는 관련이 없다. 이 경우 두 개의 별도 쿼리를 사용하는 것이 더 좋다. 태그와 댓글을 결합하려고 하면 둘 사이에는 직접적인 관계가 없는데도 가능한 모든 조합이 생성된다. 또한, 두 쿼리를 병렬로 수행하여 이득을 얻을 수도 있다.
하지만, 여기서 만약에 다음과 같은 상황을 생각해 보자.
게시물에 댓글을 달고 댓글 작성자의 이름까지 원한다면? 조인을 고려해 볼 수 있다. 훨씬 더 자연스러운 쿼리일 뿐 아니라 데이터베이스 시스템에서 이와 같은 쿼리를 최적화하려고 노력하고 있다고 한다.
서브쿼리(SubQuery)
조인 대신에 서브 쿼리를 이용해서 쿼리를 짤 수도 있을 거 같은데 서브 쿼리와 조인의 성능을 비교해 보자. 우선 MYSQL 5.5 버전에서 서브 쿼리는 못 쓸 정도라고 평할 정도로 제대로 수행되지 않았다.
테스트 데이터
- 메인 테이블 100만 건
- 서브 테이블1 (Index O) 1000건
- 서브 테이블2 (Index X) 1000건
수행 시간
- MYSQL 5.5 + 서브 쿼리 + No Index에서 100만 건 & 1천 건 조회에 3분이 소요
- MYSQL 5.5 + 서브 쿼리 + Index에서 100만 건 & 1천 건 조회에 1.8초 소요
- MYSQL 5.5 + 조인 + No Index에서 100만 건 & 1천 건 조회에 11초 소요
- MYSQL 5.5 + 조인 + Index에서 100만 건 & 1천 건 조회에 0.139초 소요
MYSQL 특정 버전(5.5이하)에서는 서브 쿼리 대신 조인이 압도적으로 빠른 걸 볼 수 있다. 5.6에서는 서브 쿼리 성능 개선이 많이 이루어졌다.
수행 시간
- MYSQL 5.6 + 서브 쿼리 + No Index에서 100만 건 & 1천 건 조회에 19초 소요
- MYSQL 5.6 + 서브 쿼리 + Index에서 100만 건 & 1천 건 조회에 0.18초 소요
3분에서 19초, 11초에서 0.18초 개선될 정도로 5.6에서는 최적화가 이루어졌지만 아직 모든 서브 쿼리가 다 최적화가 된 것은 아니라고 한다.
즉, 웬만하면 최대한 조인을 이용하고 조인을 이용하기 어렵다면 5.6 이상은 서브 쿼리를 사용하자. 5.5 이하는 절대 사용하지 않고 차라리 쿼리를 나눠서 2번(메인 쿼리, 서브 쿼리) 실행하고 애플리케이션에서 조립하자. (서브 쿼리가 조인보다 빠를 때도 있음, 스칼라 서브 쿼리 캐싱 어쩌고저쩌고..)
각 버전 서브 쿼리의 자세한 동작 원리나 서브 쿼리 최적화 적용 조건을 보고 싶다면 동욱 님의 포스트 참고!
결론
사실 위의 내용은 어느 정도 통계에 기반에서 이렇다 할 뿐이지 자신의 상황에서는 전혀 다르게 동작(고려해야 할 변수가 많기 때문에)할 수도 있다. 그러므로 참고만 하고 EXPLAIN 같은 실행계획이나 직접 테스트하여 측정해 사용하도록 하자.
Don’t guess, measure!
참고:
- https://stackoverflow.com/questions/1067016/join-queries-vs-multiple-queries
- https://jojoldu.tistory.com/520
*틀린 부분이 있으면 언제든지 말씀해 주시면 공부해서 수정하겠습니다.