본문 바로가기
웹개발 이모저모

[실무에서 복붙해 쓰는 에러 해결 및 트러블슈팅 가이드 #06] API 응답이 왜 이렇게 느려? 쿼리 폭탄 N+1 문제 완벽 타파하기

by 코드메이트 2026. 3. 23.

안녕하세요! 지난번 화면을 얼려버리는 조용한 살인마, 메모리 누수 추적기는 무사히 마치셨나요? 눈에 보이지 않는 쓰레기들을 청소하시느라 정말 고생 많으셨습니다. 자, 오늘 다룰 주제는 프론트엔드와 백엔드 모두의 복장을 터지게 만드는 주범, 바로 '느려터진 API 응답 속도'에 관한 이야기입니다.

 

프론트엔드 개발자가 다급하게 자리로 찾아옵니다. "개발자님! 유저 목록 불러오는 API가 로딩되는데 5초나 걸려요! 이거 타임아웃 떨어지겠는데요?" 깜짝 놀라 백엔드 코드를 열어봅니다. 데이터베이스에서 유저 목록을 가져오는 아주 단순한 조회 로직일 뿐인데 도대체 왜 이렇게 느린 걸까요? 로컬 환경에서 API를 찔러보고 콘솔창을 확인한 순간, 새카만 화면에 수백 줄의 SQL 쿼리문이 폭포수처럼 쏟아져 내리고 있습니다. 네, 맞습니다. 바로 백엔드 개발자들의 영원한 숙제, 'N+1 문제'에 당첨되신 겁니다! 

 

오늘은 ORM의 달콤함 이면에 숨겨진 무시무시한 쿼리 폭탄, N+1 문제가 도대체 왜 발생하고 어떻게 속 시원하게 해결할 수 있는지 제 처참했던 실무 경험담을 녹여 풀어보겠습니다.

 

백엔드 서버 터미널 콘솔창에 똑같은 형태의 SQL SELECT 쿼리문이 수십 줄 연속으로 쏟아져 나오는 N+1 쿼리 폭탄 현상 캡처 화면

1. 쿼리 한 번 날렸는데 수백 개가 쏟아진다고? N+1 문제의 정체

N+1 문제라는 이름부터 조금 낯설게 느껴지실 수 있습니다. 쉽게 말해, '1번의 쿼리로 N개의 데이터를 가져왔는데, 그 N개의 데이터 각각의 세부 정보를 알기 위해 N번의 추가 쿼리가 발생하는 현상'을 말합니다.

예를 들어 '유저 목록 100명'을 가져오는 쿼리(1번)를 날렸다고 가정해 봅시다. 그런데 유저 목록 화면에 각 유저가 작성한 '최근 게시글 제목'도 같이 보여줘야 하는 상황입니다. 데이터베이스는 유저 100명을 가져온 뒤, 1번 유저의 게시글을 찾기 위해 쿼리를 1번 더 날리고, 2번 유저의 게시글을 찾기 위해 또 날리고... 결국 유저 100명의 게시글을 찾기 위해 무려 100번(N번)의 추가 쿼리가 발생하게 됩니다. 1번이면 끝날 줄 알았던 조회가 101번의 데이터베이스 통신으로 둔갑해 버리니, API 응답 속도가 5초씩 걸리는 건 어찌 보면 당연한 결과입니다.

 

2. ORM의 달콤한 함정, 지연 로딩(Lazy Loading)이 낳은 비극

그렇다면 도대체 왜 이런 바보 같은 짓을 컴퓨터가 알아서 하고 있는 걸까요? 범인은 바로 우리가 너무나도 편리하게 사용하고 있는 JPA, TypeORM, Prisma 같은 ORM(Object-Relational Mapping) 기술에 있습니다.

ORM은 객체 지향 프로그래밍과 데이터베이스 사이의 찰떡같은 번역기 역할을 해줍니다. 이때 ORM은 성능 최적화를 한답시고 아주 똑똑한(사실은 눈치 없는) 전략을 기본적으로 사용하는데, 바로 '지연 로딩(Lazy Loading)'입니다. 처음 유저를 조회할 때는 유저 정보만 쏙 가져오고, "게시글 데이터는 당장 안 쓰니까 나중에 진짜 필요할 때 그때그때 데이터베이스에 물어봐서 가져올게!"라고 미뤄버리는 것이죠.

 

1번의 쿼리로 유저를 가져온 뒤 N번의 추가 쿼리로 게시글을 가져오는 지연 로딩(Lazy Loading)과 단 1번의 Join 쿼리로 모든 데이터를 가져오는 즉시 로딩(Eager Loading)의 차이를 비교한 다이어그램

 

이 지연 로딩 때문에 반복문 안에서 유저의 게시글 데이터에 접근하는 순간, 데이터베이스를 향해 머신건을 쏘듯 수백 개의 쿼리가 다다다닥 발사되는 끔찍한 비극이 벌어지게 됩니다.

 

3. 실무에서 겪은 처참한 5초짜리 API의 진실

제가 실무에서 이 문제를 처음 겪었을 때는 정말 멘붕 그 자체였습니다. 게시판 목록을 불러오는데, 작성자의 프로필 이미지, 달린 댓글 개수, 좋아요 누른 사람 목록 등 연관된 데이터가 너무 많았거든요.

게시글 20개를 불러오는 API를 호출했더니, 뒤단에서는 댓글 쿼리 20번, 프로필 쿼리 20번, 좋아요 쿼리 20번... 총 60번이 넘는 쿼리가 발생하고 있었습니다. 데이터베이스 서버의 CPU는 미친 듯이 치솟았고, 프론트엔드 화면에는 뱅글뱅글 도는 로딩 스피너만 하염없이 돌아갔죠. 코드는 단 몇 줄뿐이었지만, 그 안에 숨겨진 네트워크 통신 비용은 어마어마했던 겁니다.

 

4. 속 시원한 해결책: Fetch Join과 Eager Loading으로 한 방에 가져오기

이 무시무시한 쿼리 폭탄을 해체하는 방법은 생각보다 간단합니다. 데이터베이스에 "야, 이따가 어차피 게시글 데이터도 다 꺼내 쓸 거니까, 처음 유저 목록 가져올 때 아예 합쳐서(Join) 한 번에 다 가져와!"라고 명시적으로 알려주면 됩니다.

이를 Spring JPA에서는 Fetch Join이라고 부르고, Node.js의 TypeORM이나 Prisma 같은 환경에서는 Eager Loading (include 등의 옵션)이라고 부릅니다.

 

// JPA에서 N+1을 유발하는 무심한 코드 (X)
List<User> users = userRepository.findAll(); 

// Fetch Join을 사용해 한 방 쿼리로 해결하는 코드 (O)
@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();

 

이렇게 쿼리를 짜면 데이터베이스 내부에서 JOIN 연산을 통해 유저와 게시글 데이터를 하나의 커다란 표로 묶어서 단 1번의 쿼리만으로 깔끔하게 응답을 내려줍니다. 60번 치던 데이터베이스 통신이 1번으로 줄어드니, 5초 걸리던 API가 0.1초 만에 응답하는 마법을 경험할 수 있습니다! (GraphQL을 쓰신다면 DataLoader라는 훌륭한 배치(Batch) 도구가 이 역할을 대신해 줍니다.)

 

마무리하며

오늘은 프론트엔드와 백엔드 모두를 고통스럽게 만드는 느려터진 API의 주범, 'N+1 문제'의 원인과 해결책에 대해 알아보았습니다. ORM은 개발 속도를 엄청나게 끌어올려 주는 고마운 도구지만, 그 뒤에서 어떤 SQL 쿼리가 날아가고 있는지 확인하지 않으면 언제 터질지 모르는 시한폭탄과 같습니다. API를 개발하고 나면 콘솔창에 쿼리가 몇 개나 찍히는지 습관적으로 확인해 보세요. Fetch Join과 Eager Loading을 적절히 활용하는 것만으로도 여러분의 서비스는 날개를 단 듯 빨라질 것입니다. 오늘 제 삽질 기록이 누군가의 답답한 서버 속도를 뚫어주는 사이다가 되었길 바랍니다!

 

 

[다음 포스팅 예고] 백엔드와 프론트엔드를 오가는 성능 최적화를 훌륭하게 마치셨나요? 다음 글에서는 프론트엔드 개발자들의 터미널을 붉게 물들이는 또 다른 원흉, 패키지 매니저의 배신을 다뤄보겠습니다. 바로 "npm과 yarn, 패키지 의존성 충돌 시 해결하는 깔끔한 방법" 편으로 돌아오겠습니다. 어제까지 잘 되던 프로젝트가 갑자기 node_modules 에러를 뿜으며 실행조차 되지 않는 기이한 현상을 겪어보셨다면, 다음 글도 절대 놓치지 마세요! 오늘도 버그 없는 평온한 하루 보내시길 응원합니다!

 

[실무에서 복붙해 쓰는 에러 해결 및 트러블슈팅 가이드 #07] 어제까지 잘 되던 프로젝트가 뻗었

안녕하세요! 지난번 백엔드와 프론트엔드를 모두 고통받게 하던 N+1 쿼리 폭탄은 무사히 해체하셨나요? API 응답 속도가 눈에 띄게 빨라지셨길 바랍니다! 자, 오늘은 프론트엔드 개발자들의 터미

code-bricks.tistory.com