![[실전 DB 11] N 더하기 1 문제, 목록에 숨은 반복 조회](https://img.thenullpage.com/posts/5703/5703_1_f236f3.webp)
목록 쿼리를 인덱스로 최적화하고 개수 세기도 캐시했는데, 여전히 읽기가 예상보다 많았다. 파보니 목록의 각 글마다 딸린 정보를 따로 조회하고 있었다. 열 개 글을 보여주면서 각 글의 댓글 수를 열 번 따로 세는 식이었다. 이걸 반복 조회 문제, 흔히 엔 더하기 일 문제라고 부른다. 이번 편은 이 숨은 비용을 다룬다.
이건 개별 쿼리는 가벼운데 그게 반복돼서 전체가 무거워지는 경우다. 하나만 떼어 보면 아무 문제가 없어 보여서, 더 찾기 까다로운 함정이다.
1. 엔 더하기 일 문제란 무엇인가
엔 더하기 일 문제는 이름 그대로다. 목록을 가져오는 쿼리 하나에, 그 목록의 각 항목마다 딸린 정보를 가져오는 쿼리가 항목 수만큼 더해지는 것이다. 목록을 한 번 가져오고, 그 목록의 열 개 항목 각각에 대해 추가 조회를 열 번 하면, 쿼리가 총 열한 번이다. 목록 하나에 항목 수만큼의 추가 쿼리가 붙는다.
구체적으로, 글 목록을 보여주면서 각 글의 댓글 수나 추천 수를 함께 보여준다고 하자. 목록 쿼리로 글 열 개를 가져온 다음, 각 글마다 댓글 수를 세는 쿼리를 따로 날리면, 그 세기가 열 번 반복된다. 글 목록 한 번에 댓글 세기 열 번이 더해지는 것이다. 한 페이지에 스무 개를 보여주면 스물한 번이 된다.
이게 문제인 건 개별 쿼리는 가벼워 보이기 때문이다. 댓글 수 세기 한 번은 별거 아니어 보인다. 그런데 목록마다 항목 수만큼 반복되니, 그 가벼운 쿼리가 쌓여 큰 부담이 된다. 하나만 보면 안 무거운데 전체로 보면 무거운, 숨은 함정이다.
여기에 앞서 다룬 봇 트래픽과 캐시 부재가 겹치면 배가 커진다. 목록이 캐시되지 않은 채 봇이 계속 긁으면, 목록 한 번당 열몇 번의 쿼리가 그대로 데이터베이스에 닿는다. 목록 요청 하나가 사실은 요청 열 몇 개였던 셈이라, 겉으로 보이는 요청 수와 실제 쿼리 수의 간극이 벌어진다.
이 문제엔 사촌 격인 형태도 있다. 목록 쿼리 안에서 각 행마다 작은 조회를 딸려 실행하는 경우다. 겉보기엔 쿼리 하나인데, 그 안에서 행마다 다른 조회가 반복되어 사실상 같은 반복이 벌어진다. 형태는 달라도 뿌리는 하나다. 목록의 항목 수에 비례해 조회가 늘어나면, 그게 무슨 모양이든 엔 더하기 일 문제다.
2. 왜 놓치기 쉬운가
이 문제를 놓치기 쉬운 이유가 있다. 코드로 보면 지극히 자연스러워 보이기 때문이다. 목록을 가져와서 각 항목을 돌면서 그 항목의 추가 정보를 가져오는 건, 프로그래밍에서 아주 흔한 패턴이다. 각 항목을 처리하는 반복문 안에서 조회를 하는 것이다. 코드만 읽으면 아무 문제 없어 보인다.
그런데 그 반복문이 데이터베이스 쿼리를 반복 실행하고 있다는 걸 의식하지 못한다. 반복문 한 바퀴가 쿼리 한 번이니, 열 바퀴면 쿼리 열 번이다. 코드에서는 반복문 하나인데 데이터베이스에서는 쿼리 여러 번이 되는 것이다. 이 간극이 문제를 눈에 안 띄게 숨긴다. 코드 줄 수와 쿼리 수가 다르다는 걸 놓치는 것이다.
그래서 이 문제는 앞서 다룬 측정으로 드러난다. 코드만 봐선 안 보이는데, 어느 쿼리가 몇 번 실행되는지 재보면 이 반복이 드러난다. 목록 관련 조회가 예상보다 훨씬 많이 실행되고 있으면, 이 반복 조회를 의심한다. 측정이 숨은 반복을 찾아내는 것이다. 나도 쿼리 실행 횟수를 재보고 나서야 이 반복을 알아챘다.
편리한 도구가 이 문제를 더 잘 숨기기도 한다. 데이터를 객체처럼 다루게 해주는 도구를 쓰면, 관련 데이터를 점 하나로 편하게 가져올 수 있다. 그런데 그 점 하나가 뒤에서 조회 한 번을 부른다. 반복문 안에서 그 점을 찍을 때마다 조회가 반복되는 것이다. 편할수록 뒤에서 무슨 일이 벌어지는지 더 유심히 봐야 한다.
3. 함께 가져오기로 해결한다
해결의 기본 방향은 반복을 없애고 한 번에 가져오는 것이다. 각 항목마다 따로 조회하는 대신, 목록을 가져올 때 필요한 정보를 함께 가져온다. 여러 번의 작은 쿼리를 한 번의 쿼리로 합치는 것이다.
한 가지 방법은 조인이다. 목록 쿼리에 필요한 정보를 조인으로 붙여 한 번에 가져온다. 글 목록을 가져오면서 각 글의 관련 정보를 조인으로 함께 가져오면, 추가 조회가 필요 없다. 다만 조인은 잘못하면 그 자체가 무거워질 수 있어, 조인하는 컬럼에 인덱스가 있어야 한다. 인덱스 없는 조인은 오히려 더 큰 스캔을 부른다.
또 다른 방법은 목록의 식별자들을 모아 한 번에 조회하는 것이다. 글 열 개의 식별자를 모아, 그 식별자들에 해당하는 정보를 한 쿼리로 가져온다. 열 번의 조회가 한 번으로 준다. 각 항목마다 따로 묻는 대신, 다 모아서 한꺼번에 묻는 것이다. 그리고 가져온 결과를 코드에서 각 항목에 짝지어 준다.
두 방법 중 무엇을 쓸지는 상황에 달렸다. 관계가 단순하고 인덱스가 잘 걸려 있으면 조인이 깔끔하다. 반면 여러 정보를 각기 다른 곳에서 가져와야 하면 식별자를 모아 따로따로 한 번씩 조회하는 편이 관리하기 쉽다. 핵심은 항목 수만큼 반복하지 않고 쿼리 수를 상수로 묶는 것이다.
다만 한 번에 가져온다고 무조건 좋은 건 아니다. 조인이 지나치게 많은 곳을 한꺼번에 이으면, 그 자체가 무거운 쿼리가 된다. 반복을 없애려다 거대한 한 방을 만드는 셈이다. 그래서 반복을 줄이되 각 쿼리도 인덱스로 가볍게 유지하는 균형이 필요하다. 반복 조회 문제와 무거운 쿼리 문제는 함께 봐야 한다.
4. 비정규화라는 또 다른 해법
반복 조회가 특정 집계, 예를 들어 댓글 수 세기 같은 것이라면 다른 해법이 있다. 그 집계 값을 미리 계산해 저장해두는 것이다. 각 글에 댓글 수를 담는 자리를 두고, 댓글이 달릴 때마다 그 값을 갱신한다. 그러면 목록에서 댓글 수를 셀 필요 없이 저장된 값을 읽기만 하면 된다.
이걸 비정규화라고 한다. 원래는 댓글을 세야 나오는 값을, 글에 미리 저장해 중복으로 두는 것이다. 세는 비용을 없애는 대신 저장과 갱신의 부담을 지는 것이다. 자주 읽는 집계라면 이 교환이 유리하다. 조인이나 모아 조회하기가 반복 자체를 한 번으로 줄인다면, 비정규화는 세는 일 자체를 아예 없앤다. 이건 다음 편에서 자세히 다룬다.
내가 만든 사이트에서도 목록의 댓글 수와 추천 수를 각 글에 저장해뒀다. 목록을 보여줄 때 그 값을 읽기만 하니, 각 글마다 세던 반복이 사라졌다. 목록 쿼리 한 번으로 글과 그 집계 값을 함께 가져오는 것이다. 반복 조회의 상당 부분이 이 비정규화로 해결됐고, 목록 응답도 눈에 띄게 빨라졌다.
조인과 비정규화 중 무엇을 쓸지는 그 값의 성격에 달렸다. 관련 데이터를 통째로 함께 보여줘야 하면 조인이나 모아 조회하기가 맞고, 세어서 나오는 숫자 하나를 자주 보여줄 뿐이면 비정규화가 맞다. 앞의 방법은 반복을 한 번으로 줄이고, 뒤의 방법은 세는 일 자체를 없앤다. 문제의 모양을 보고 도구를 고르는 것이다.
5. 목록 쿼리를 한 덩어리로
이 편의 핵심은 목록을 가져오는 걸 한 덩어리 작업으로 만드는 것이다. 목록과 그에 딸린 정보를 각각 따로 조회하지 말고, 한 번에 또는 최소한의 쿼리로 가져온다. 조인으로 붙이거나, 식별자를 모아 한 번에 조회하거나, 집계는 미리 저장해둔다. 반복을 없애는 게 목표다.
이 관점을 가지면 목록을 짤 때부터 조심하게 된다. 각 항목마다 조회하는 코드를 짜기 전에, 이게 반복 쿼리가 되지 않을까 먼저 생각한다. 반복이 예상되면 처음부터 함께 가져오거나 미리 저장하는 방식으로 짠다. 문제를 만든 뒤 측정으로 찾아 고치기보다, 애초에 안 만드는 게 낫다. 습관이 되면 반복 조회를 짜는 일 자체가 줄어든다.
이 문제가 특히 교훈적인 건, 각 부분은 멀쩡한데 합쳐지면서 병목이 생긴다는 점이다. 목록 쿼리도 가볍고 댓글 세기도 가벼운데, 그 둘이 반복으로 얽히면서 무거워진다. 그래서 개별 쿼리만 들여다봐선 안 되고, 한 화면을 그리는 데 쿼리가 총 몇 번 도는지를 봐야 한다. 부분이 아니라 전체를 보는 시야가 필요하다.
정리하면 엔 더하기 일 문제는 목록의 각 항목마다 추가 조회가 반복돼 생기고, 코드로는 안 보이니 측정으로 찾아, 조인이나 모아 조회하기, 비정규화로 반복을 없앤다. 다음 편에서는 그 비정규화를 더 깊이 파고들어, 셀 것을 미리 저장하는 기법과 그 위험 관리를 다룬다.