JOIN을 지웠더니 편안해졌다, Application-Level Join 실전 도입기

2026. 9. 6. 16:43·탐구 생활/데이터베이스

이전 글에서는 RDBMS 조인이 '넓은 테이블(Wide Table)'과 '깊은 페이지네이션(Deep Pagination)'을 만났을 때 겪게 되는 수학적, 아키텍처적 한계와 디스크 스필(Disk Spill) 현상을 다루었습니다. 이 글에서는 디스크 스필이 실제 프로덕션 환경에서 어떻게 시스템 장애를 유발했는지, 그리고 이를 어플리케이션 레벨 조인(Application-Level Join)이라는 설계 패턴으로 어떻게 타파했는지 공유합니다.


장애의 발단

제가 맡은 서비스는 간혹 데이터베이스 CPU사용률이 80~90%까지 치솟는 문제가 발생하고 있었습니다. 이때마다 Read Instance가 수평확장되어서 결국 서비스가 안정화 되었지만, Read Instance가 확장되고 안정화되고 백엔드 어플리케이션과 연결될때까지 10~15분 가량 서비스 전체적인 Latency 이슈가 발생하는 아찔한 경험을 하게 되었습니다.

 

원인을 분석해보니 데이터베이스에 부하를 일으킨 주범은 LIMIT&OFFSET페이지네이션 환경에서 과도하게 깊게 발생하는 페이지네이션이 문제였습니다. 딱, 이전 글에서 설명한 디스크 스필 케이스였죠. 평소에 API Throttling과 캐시레이어를 통해 어느정도 안정성을 확보하고 있었지만 이 경우는 사용량 제한이 문제가 아니라 너무 깊은 Pagination 이 문제였습니다. 이게 Abuse에 해당한다면 막으면 되겠지만 인터뷰 결과 정상적인 usecase 였고, 그래서 고민이 더 깊어졌습니다.

 

디스크 스필이 발생하는 쿼리의 실행계획(EXPLAIN ANALYZE)을 뜯어본 결과는 아래와 같았습니다.

Sort  (cost=3966.31..3967.09 rows=313 width=1313) (actual time=2139.189..2150.664 rows=10100 loops=1)
  Sort Key: {table}.sort DESC
  Sort Method: external merge Disk: 134760kB
  Buffers: shared hit=556004, temp read=18444 written=33739
  I/O Timings: temp read=41.220 write=280.703

 

모든 테이블이 적절한 인덱스를 타고 있었음에도 불구하고 병합정렬을 위해 약 134MB의 데이터를 디스크 I/O하는 바람에 쿼리가 느려져 버린겁니다.


가설과 실험

문제의 원인이 '넓은 두 테이블의 JOIN ➔ 거대한 중간 결과셋 생성 ➔ 디스크 스필(Disk Spill)'로 이어지는 흐름이라는건 자명했죠. 이 문제를 해결하는 가장 멋진 방법은 Cursor 기반 Pagination으로의 전환이었습니다. 그러면 더이상 LIMIT&OFFSET을 사용하지 않아도 되었고, 덕분에 Sort 도 더욱 쉬워질거였죠. 하지만 2개 이상의 테이블에 걸친 JOIN과, 다양한 정렬조건을 Cursor 기반 Pagination에 녹여내는건 쉽지 않았습니다.

 

그나마 다행인건, 해당 쿼리를 분석해보니 드라이빙 테이블이 드리븐 테이블의 PK로 JOIN을 시도한다는 거였죠. 그래서 한가지 아이디어가 떠올랐습니다. '그냥 따로따로 조회하고, Python Application 메모리에서 합치면 안되나?' 여기서 Application Level Join 이라는 아이디어가 떠올랐고, 생각해보니 Java&SpringBoot로 실무를 할때 간혹 사용했던 방법이었습니다.

 

과감하게 INNER JOIN을 제거하고, Pagination 쿼리가 단일 테이블만 조회하도록 변경했습니다. 당연히 결과는 극적이었습니다.

Sort  (cost=1321.46..1322.24 rows=313 width=530) (actual time=498.775..503.732 rows=10100 loops=1)
  Sort Key: sort_key DESC
  Sort Method: external merge Disk: 66456kB
  Buffers: shared hit=96520, temp read=9291 written=16633
  I/O Timings: temp read=17.837 write=66.218

단순히 JOIN을 없앤 것만으로 정렬에 필요한 디스크 공간 요구량이 134MB에서 66MB로 50% 감소했습니다. DB 실행 시간 역시 2,374ms에서 519ms로 4배 이상 빨라졌습니다.


어플리케이션 레벨 조인 (Application-Level Join) 도입

Application Level Join 을 도입하고 쿼리 실행속도가 4배 빨라졌다.

JOIN을 안 하면 쿼리가 빨라지는 것은 당연합니다. 문제는 클라이언트에게 응답을 내려주려면 JOIN했던 테이블의 정보가 반드시 필요하다는 점입니다. 백엔드 개발자들은 주로 ORM의 prefetch_related나 Fetch Join을 통해 N+1 쿼리 문제를 해결하려고 노력하지만, 이번 사례처럼 그것이 오히려 데이터베이스를 질식시키는 원인이 되기도 합니다.

 

이 딜레마를 해결하기 위해 어플리케이션 레벨 조인(Application-Level Join) 전략을 채택했습니다.

  • 1차 쿼리 (Index Driver): JOIN을 제거한 가벼운 쿼리로 드라이빙 테이블을 조회하여, 페이지네이션된 100건의 데이터와  JOIN 에 사용되었던 FK를 가져옵니다.
  • 2차 쿼리 (Payload Fetcher): 백엔드 어플리케이션 단에서 추출한 FK리스트를 이용해 드리븐 테이블을 PK를 활용해 IN (...) 조건으로 추가 조회합니다.
  • Application Merge: 조회된 두 데이터를 어플리케이션 메모리상에서 병합(Merge)하여 응답을 내려줍니다.

이러한 변경을 통해 옵티마이저를 멍청하게 만들던 의미론적 장벽(Semantic Barrier)을 우회할 수 있게 되었습니다. 데이터베이스는 부담스러운 JOIN 데이터 없이 좁고 가벼운 튜플만으로 정렬을 초고속으로 끝내고, 최종 데이터 결합은 수평 확장이 용이한 어플리케이션 서버에서 처리하도록 역할을 완벽히 분리한 것입니다.

 

또한 병합작업을 분산된 서버에서 시행하게 함으로써 데이터베이스의 메모리가 SPoF 되었던 기존 아키텍처의 불안한 지점을 일부 해소할 수 있었습니다.


아키텍처 관점에서 트레이드 오프

쿼리를 2번 날리면 네트워크 왕복 횟수가 증가합니다. 하지만 클라우드 VPC 내부의 아주 짧은 네트워크 지연 시간은, 134MB짜리 거대한 데이터를 디스크에서 정렬하며 허비하는 수백 밀리초의 디스크 I/O 시간에 비하면 공짜나 다름없습니다.

 

또한, 드리븐 테이블의 Primary Key를 이용한 2차 조회는 0.1ms 수준으로 매우 빠르기 때문에 추가 쿼리가 발생하더라도 데이터베이스에 가해지는 부담은 사실상 제로에 가깝습니다. 더불어 버려질 10,000개의 오프셋 데이터에 대해 상품 튜플을 결합하고 역직렬화하던 낭비도 사라져 DB CPU가 크게 안정화됩니다.

 

E2E로 시간을 측정해본 결과 API의 응답속도가 ALJ를 도입한 경우가 4배 빠른게 검증되었습니다. 


실제 프로덕션 적용 이후

실험실에서 충분히 검증을 거친 ALJ 개념을 실제 프로덕션에 적용했습니다. 그 결과 시스템은 완벽한 안정성을 되찾았으며 극적인 성능 지표 개선을 이루어냈습니다.

  • DB CPU 안정화: 평일 피크 타임 기준 25~40%, 심할 경우 80%까지 치솟았던 데이터베이스 CPU 사용률이 패치 이후 10~30% 수준으로 완벽하게 안정화되었습니다.
  • API 속도 40% 개선: 가장 무거운 API는 p95 응답 속도(상위 5%의 느린 요청)가 기존 1.151초에서 0.690초로 40% 개선되었습니다. p99 응답 속도 역시 1.401초에서 0.996초로 29% 향상되어 문제가 되었던 Tail Latency에 충분한 방어력이 있음이 검증되었습니다.
  • 비용 절감: 데이터베이스 부하가 드라마틱하게 줄어들면서 DB 인스턴스 사양을 절반으로 낮출 수(Scale-down) 있게 되었고, ReadInstance 의 오토스케일링 횟수도 눈에 띄게 줄어들었습니다.

글을 마치며

RDBMS에서 JOIN은 기본적이고 필수적인 연산입니다. 하지만 테이블의 덩치가 커지고 쿼리 패턴이 복잡해지는 순간, JOIN은 성능의 불확실성을 키우는 뇌관이 될 수 있습니다.
ORM이 만들어주는 단일 쿼리의 편리함에 갇혀 디스크 스필(Disk Spill)을 무시해서는 안됩니다. 때로는 데이터베이스에게 모든 JOIN과 정렬을 맡길게 아니라 분산되어있는 서버에서 처리하는게 전체 시스템을 안정적으로 운영하는 방법이 될 수 있습니다.

 

그럼 "무조건 조인을 쪼개는 게 좋은가?"라는 의문이 드실 수 있습니다. 물론 세상에 그런건 없겠죠. ALJ에 대해서 논쟁이 있던 Reddit 을 요약하면서 ALJ에 대한 새로운 관점을 제시하고 글을 마무리하고자 합니다.

 

"기본적으로는 데이터베이스 옵티마이저를 믿고 RDBMS 조인을 써라. 인덱스 조인은 수 밀리초면 끝나지만, 어플리케이션 조인은 네트워크 레이턴시를 낭비한다." 이 말은 정말 정석적입니다. 일반적인 상황에서 RDBMS 조인은 압도적으로 빠르며, SQL의 선언적 특성을 활용하는 것이 유지보수에도 좋습니다.

 

하지만 커뮤니티에서도 ALJ가 필수불가결해지는 명확한 순간들을 꼽습니다

  • 옵티마이저가 바보짓을 할 때: 앞선 우리의 사례처럼 깊은 페이징 등으로 인해 디스크 I/O가 폭발하는 경우, ALJ가 수 초짜리 쿼리를 수백 밀리초로 줄여줍니다.
  • 효율적인 캐싱(Caching)이 필요할 때: 복잡하게 얽힌 조인 결과는 캐시 히트율이 낮습니다. 하지만 단일 테이블 조회를 쪼개면 개별 엔티티 캐싱이 훨씬 유리해집니다.
  • 도메인 분리(MSA)를 대비할 때: 서로 다른 도메인의 데이터를 어플리케이션 레벨에서 병합하는 습관은, 훗날 두 도메인이 물리적으로 다른 DB나 마이크로서비스로 쪼개질 때 마이그레이션 비용을 크게 낮춰줍니다.

'탐구 생활 > 데이터베이스' 카테고리의 다른 글

MySQL vs PostgreSQL, 조인의 불확실성과 엔진별 한계  (0) 2026.09.06
RDB 복합인덱스 조금 깊게 알아보기  (0) 2026.09.05
MySQL vs PostgreSQL 2편: UPDATE의 나비효과  (0) 2026.08.30
MySQL vs PostgreSQL 1편: 데이터는 어떻게 저장되는가?  (0) 2026.08.30
B+Tree 구조와 데이터베이스 인덱스  (0) 2026.08.29
🚀 이 글을 끝까지 읽은 당신! 샐러드랩에서 함께 일해보실래요?
주도적인 환경에서 빠르게 성장하는 프로덕트를 만들어가실 백엔드/풀스택 개발자 분들을 모시고 있습니다.
백엔드 채용 공고 풀스택 채용 공고
'탐구 생활/데이터베이스' 카테고리의 다른 글
  • MySQL vs PostgreSQL, 조인의 불확실성과 엔진별 한계
  • RDB 복합인덱스 조금 깊게 알아보기
  • MySQL vs PostgreSQL 2편: UPDATE의 나비효과
  • MySQL vs PostgreSQL 1편: 데이터는 어떻게 저장되는가?
개발프로브
개발프로브
가볍게, 오랫동안 기록하고 싶은 블로그입니다.
  • 개발프로브
    ProbeHub
    개발프로브
  • 전체
    오늘
    어제
    • 분류 전체보기 (66) N
      • 탐구 생활 (56) N
        • 개발 탐구 (8)
        • FastAPI CORS (3)
        • FastAPI Log (4)
        • gRPC&Python (4)
        • SpringBoot 파헤치기 (2)
        • Python Monorepo (3)
        • Python 과 zstd (2)
        • Python (6) N
        • FastAPI (4)
        • Terraform (8)
        • MSA (0)
        • GraphQL (2)
        • 데이터베이스 (8) N
        • 네트워크 (0)
        • LLM (1)
      • 기초 지식 (9)
        • Terraform (2)
        • MSA (5)
        • K8s (2)
      • 회고 (1)
  • 블로그 메뉴

    • 링크

      • github
      • stackoverflow
    • 공지사항

    • 인기 글

    • 태그

      RDBMS
      백엔드
      python 불변 객체
      fastapi cors
      brotli
      springboot
      오블완
      HOTUpdate
      FastAPI
      AWS
      pagination
      zstd
      fastapi logging
      티스토리챌린지
      PostgreSQL
      MySQL
      gzip
      Terraform
      rest vs grpc
      ApplicationLevelJoin
      쓰기증폭
      grpc
      데이터베이스
      sqlalchemy
      granian
      spring 트랜잭션
      java
      Python
      BTREE
      MSA
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.0
    개발프로브
    JOIN을 지웠더니 편안해졌다, Application-Level Join 실전 도입기
    상단으로

    티스토리툴바