포스트

백엔드 초짜의 착각 - DB에 부하를 주는 게 미안했긴 했는데...

백엔드 초짜의 착각 - DB에 부하를 주는 게 미안했긴 했는데...

난 Django를 제대로 배우지 못한 채 백엔드 개발을 시작한 3개월차 삐약삐약🐣 개발자다…

복잡한 SQL을 짤 때마다 반사적으로 드는 생각이 있었다.

“이거 DB에 너무 부담 주는 거 아닌가?”

그래서 무의식적으로 쿼리를 단순하게 쪼개고, 나머지는 Python에서 처리하는 쪽을 택해왔다. JOIN을 여러 번 건 쿼리보다는 간단한 SELECT 여러 번이 더 착하고 안전해 보였다. 한 번에 많은 걸 요구하는 쿼리보다, 작게 나눠서 여러 번 물어보는 게 예의 바른 느낌이었다. “단순한 쿼리 여러 개”가 “복잡한 쿼리 하나”보다 DB한테 덜 미안한 선택이라는 감각이 있었다.

최근에야 이 감각이 완전히 틀렸다는 걸 알게 됐다. 이 글은 그 깨달음에 대한 기록이다.

내가 오해하고 있었던 것들

곱씹어보니, 나는 세 가지를 잘못 알고 있었다.

오해 1. SQL이 복잡하면 DB가 힘들어한다.

실제로는 정반대다. DB 엔진은 복잡한 쿼리를 최적화하려고 수십 년간 발전해온 물건이다. 쿼리 플래너, 인덱스, 해시 조인, 병렬 실행 — 이런 건 다 복잡한 쿼리를 잘 처리하려고 만들어진 장치들이다. 복잡한 쿼리를 피하는 건 F1 머신을 사놓고 1단으로만 다니는 것과 비슷하다. DB 입장에서 복잡한 쿼리는 부담이 아니라, 자기가 제일 잘하는 일이다.

오해 2. 쿼리를 나눠서 보내면 DB 부하가 분산된다.

쪼개면 오히려 네트워크 왕복, 커넥션 점유, 파싱 비용이 N배로 늘어난다. 쿼리 하나하나가 단순해지는 건 맞지만, 그 단순함의 대가로 앱 서버와 DB 사이에 오고가는 트래픽이 폭발한다. “부하 분산”이 아니라 “부하 증폭”이다. 게다가 분산되는 부하의 상당 부분은 DB가 아니라 내 앱 서버 쪽으로 쏠린다.

오해 3. Python에서 처리하면 DB를 배려하는 것이다.

DB는 “배려”받을 대상이 아니라, 그 일을 하라고 존재하는 도구다. 배려한다고 Python으로 끌어오는 순간, 그 일은 DB보다 훨씬 못하는 쪽(내 앱 서버)으로 넘어간다. 마치 전문 요리사에게 재료만 손질해달라고 부탁한 뒤, 정작 요리는 내가 집에서 하겠다고 하는 꼴이다. 전문가한테 일을 맡기는 게 배려가 아니라, 전문가한테서 일을 뺏어오는 게 배려라고 착각하고 있었다.

내가 짜고 있던 N+1

예를 들면 이런 코드를 짜고 있었다.

1
2
3
4
5
contracts = TbkPartnerContract.objects.all()
for c in contracts:
    count = TbkConsulting.objects.filter(
        legal_partner_id=c.legal_partner_id
    ).count()

짤 때 내 머릿속은 대충 이랬다. “쿼리 하나하나는 단순하니까 괜찮겠지. 어차피 DB는 인덱스 있으니까 빠를 거고, 복잡한 JOIN 걸어서 DB 고생시키는 것보다 낫잖아.”

실제로 일어나고 있던 일은 달랐다. 계약이 100개면 쿼리가 101개(contracts 조회 1개 + count 조회 100개) 발생한다. 각 쿼리마다 네트워크 왕복 비용, 커넥션 획득/반납 비용, SQL 파싱 비용, 쿼리 플랜 생성 비용이 붙는다. 개별 쿼리 실행 시간은 밀리초 단위로 짧아도, 왕복 비용이 누적되면서 전체 응답 시간은 수 초 단위로 늘어난다.

그리고 정작 바쁜 게 누구였냐면, DB가 아니라 내 앱 서버였다. DB는 단순한 쿼리 100개를 받아서 가볍게 처리하고 돌려줬을 뿐인데, Python 쪽은 100번의 왕복을 기다리면서 스레드 하나를 계속 점유하고 있었다. 내가 “DB 아낀다”고 하면서 실제로는 앱 서버를 괴롭히고 있었던 거다.

“DB에 일을 시켜라”

같은 로직을 이렇게 바꿀 수 있다.

1
2
TbkPartnerContract.objects.annotate_signed_consulting_count()
# 내부적으로 Subquery로 count를 annotate하는 커스텀 매니저 메서드

생성되는 SQL은 훨씬 복잡해진다. OuterRef, Subquery, 경우에 따라 여러 단계 중첩까지 들어간다. 쿼리 문자열만 보면 길고 무섭다. 처음 이런 쿼리를 봤을 때 든 생각은 솔직히 이랬다.

“이렇게 복잡한 쿼리를 DB에 던져도 돼? 이거 DB 죽는 거 아니야?”

던져도 된다. 오히려 그게 DB가 가장 잘하는 일이다. 왕복은 1번, 나머지 반복은 DB 엔진이 인덱스와 쿼리 플래너로 알아서 처리한다. Python이 for문으로 100번 돌리는 것과, DB가 내부적으로 100개 row에 대해 서브쿼리를 실행하는 것은 겉보기엔 비슷해 보이지만, 실행 계층이 완전히 다르다. 후자는 DB가 인덱스를 활용해 효율적인 실행 계획을 짜고, 필요하면 조인 전략을 바꾸고, 메모리에 올려서 한 번에 처리한다. 전자에는 그런 최적화의 여지가 아예 없다.

부하의 위치를 착각하고 있었다

여기가 내 깨달음의 핵심이다.

내가 “DB 부하”라고 부르던 것의 실체는, 사실 앱 서버의 부하와 네트워크 비용이었다. 쿼리를 100번 쪼개서 보낼 때 정작 힘든 건 DB가 아니라 Python 쪽이다. 네트워크 왕복을 기다리면서 스레드는 블로킹되고, ORM은 매번 객체를 만들었다 버리고, 커넥션 풀은 계속 들락거린다. DB 입장에서는 단순한 쿼리 100개든 복잡한 쿼리 1개든 처리량으로 보면 후자가 대체로 훨씬 가볍다.

DB는 인덱스, 해시 조인, 쿼리 플래너, 병렬 실행 같은 무기를 가지고 있다. 수십 년간 수많은 엔지니어들이 “어떻게 하면 복잡한 쿼리를 빨리 처리할까”만 고민해서 만든 결과물이다. 반면 Python의 for문에는 그런 게 없다. 단순한 순차 반복이고, 매 반복마다 I/O를 기다려야 하고, 최적화의 여지가 거의 없다.

그런데 나는 그 강력한 무기들을 일부러 안 쓰게 만들면서, 그게 “DB를 아끼는 것”이라고 착각하고 있었다. 결국 “DB를 아낀다”는 발상 자체가, DB가 뭘 잘하는지 모를 때 나오는 생각이었다.

지금의 판단 기준

이 깨달음 이후로 판단 기준이 단순해졌다.

쿼리가 1개로 표현되면, 복잡해 보여도 일단 믿고 맡긴다. OuterRef가 몇 단계든, Subquery가 얼마나 중첩되든, SQL 1개로 표현되는 한 그건 N+1과는 근본적으로 다른 층위의 문제다. 전자는 튜닝의 영역이고, 후자는 구조의 문제다. 튜닝은 인덱스 추가나 쿼리 재작성으로 해결되지만, 구조 문제는 코드를 다시 짜야 한다.

느리면 그때 EXPLAIN을 찍어보고 인덱스를 튜닝한다. 이게 올바른 순서다. 미리 “복잡하면 느릴 것 같으니까 쪼개자”가 아니라, 일단 맡기고 문제가 생기면 그때 DB에게 상담받는 식이다.

Python으로 데이터를 끌어와서 처리하고 싶어질 때마다 스스로에게 묻는다. “이거 DB에서 끝낼 수 있지 않나?” filter(), annotate(), aggregate(), Count, Subquery — Django ORM이 제공하는 도구들은 대부분 “DB에서 끝내기 위한” 장치들이다. Python 레벨에서 리스트 컴프리헨션이나 for문으로 후처리하고 싶어진다면, 그건 대체로 ORM 메서드를 몰라서 생기는 우회인 경우가 많다.

미안함의 방향이 틀렸다

결국 내가 잘못한 건 미안해한 것 자체가 아니라, 미안함의 방향이었다.

DB한테 미안할 게 아니었다. 쓸데없이 왕복하느라 느려진 응답을 기다리고 있는 사용자에게 미안해야 했다. DB를 배려하려다 오히려 시스템 전체를 느리게 만들고 있었던 셈이다. 배려의 대상을 잘못 고르면, 선의가 오히려 피해가 된다.

복잡한 SQL을 보고 움찔하는 감각이 아직 완전히 사라진 건 아니다. 길게 늘어진 서브쿼리나 여러 단계 중첩된 annotate를 보면 여전히 순간적으로 “이거 너무 과한 거 아닌가” 싶은 마음이 든다. 다만 이제는 그 감각을 의심할 줄 안다. 그 움찔함이 합리적인 경계심인지, 아니면 DB가 뭘 잘하는지 몰라서 나오는 막연한 불안인지 구분할 수 있게 됐다.

그게 이번 깨달음이 남긴 가장 큰 변화인 것 같다 …..

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.