HooneyLog
© 2026 Seunghoon Shin. All rights reserved.
모든 게시글
Backend
2026. 3. 28.•
5

DB 성능 최적화: N+1 문제부터 데드락(Deadlock)까지 완벽 정복하기

Seunghoon Shin
작성자 Seunghoon Shin풀스택 개발자

DB 성능 최적화: N+1 문제부터 데드락(Deadlock)까지 완벽 정복하기

1. 문제의 배경

현대 백엔드 개발에서 JPA, TypeORM, Prisma와 같은 ORM(Object-Relational Mapping)의 사용은 선택이 아닌 필수가 되었습니다. 단순한 CRUD 작성 시간을 획기적으로 줄여주고, 비즈니스 로직에 집중할 수 있게 해주기 때문이죠.

하지만, "객체"와 "관계형 데이터베이스" 사이의 패러다임 불일치는 종종 심각한 성능 저하를 야기합니다. 개발자가 의도하지 않은 수십, 수백 개의 쿼리가 데이터베이스로 날아가거나, 트래픽이 몰리는 상황에서 DB가 멈춰버리는 상황을 겪어본 적이 있으신가요?

이러한 문제들은 서비스 규모가 커지고 트래픽이 증가할 때 시스템의 목을 조르는 치명적인 병목(Bottleneck)이 됩니다. 이번 글에서는 실무에서 가장 빈번하게 발생하는 DB 문제들과 그 해결책을 짚어보려 합니다.

2. 해결 방안 탐색

데이터베이스 성능 문제를 해결하기 위해 가장 먼저 해야 할 일은 문제가 무엇인지 정확히 진단하는 것입니다.

  1. Slow Query Log 분석: 어떤 쿼리가 오래 걸리는지 DB 로그를 통해 확인합니다.
  2. APM 툴 사용: Datadog이나 New Relic 등을 통해 애플리케이션 레벨에서 병목 지점을 찾습니다.
  3. ORM 쿼리 로깅: 개발 환경에서 ORM이 생성하는 실제 원시 쿼리(Raw Query)를 출력하여 의도한 대로 쿼리가 나가는지 확인합니다.

진단이 끝났다면, 상황에 맞게 쿼리 최적화, 인덱스 추가, 로직 수정 등을 진행해야 합니다.

3. 핵심 개념 및 아키텍처

우리가 마주하는 대표적인 DB 문제들의 원리와 발생 과정을 살펴보겠습니다.

3.1. N+1 문제 (The N+1 Problem)

가장 흔하게 발생하는 문제입니다. 한 번의 쿼리로 N개의 레코드를 가져왔는데, 그 레코드들과 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 현상을 말합니다.

3.2. 카테시안 곱 (Cartesian Product) 문제

조인(Join) 조건이 잘못되었거나 다대다(N:M) 관계를 잘못 풀었을 때 발생합니다. 테이블 A의 레코드가 1,000개, 테이블 B의 레코드가 1,000개일 때 두 테이블을 조건 없이 조인하면 1,000,000개의 데이터가 메모리로 올라오게 되어 애플리케이션이 뻗어버릴 수 있습니다. (JPA에서는 Multiple Bag Fetch 문제라고도 부릅니다.)

3.3. 교착 상태 (Deadlock)

두 개 이상의 트랜잭션이 서로가 점유하고 있는 락(Lock)을 기다리며 무한 대기 상태에 빠지는 현상입니다.

  • 트랜잭션 A: 테이블 X 락 획득 ➡️ 테이블 Y 락 요청 (대기)
  • 트랜잭션 B: 테이블 Y 락 획득 ➡️ 테이블 X 락 요청 (대기)

4. 구현 및 트러블슈팅

그렇다면 이런 문제들을 어떻게 해결할 수 있을까요? 상황별 해결책을 코드로 살펴보겠습니다.

4.1. N+1 문제 해결하기

[해결책 A: Fetch Join (JPA 기준)] 연관된 데이터를 처음부터 SQL의 JOIN을 사용하여 한 번에 가져오도록 강제하는 방법입니다.

// Spring Data JPA 예시 @Query("SELECT u FROM User u JOIN FETCH u.posts") List<User> findAllUsersWithPosts();

[해결책 B: DataLoader를 활용한 일괄 처리 (Node.js/GraphQL 기준)] Node.js 진영에서는 DataLoader를 사용하여 여러 개의 조회 요청을 모아서(Batching) 한 번의 IN 쿼리로 처리합니다.

// TypeORM + DataLoader 예시 const postLoader = new DataLoader(async (userIds: number[]) => { // 100번의 쿼리 대신 1번의 IN 쿼리로 최적화! const posts = await postRepository.find({ where: { userId: In(userIds) } }); // userIds 순서에 맞게 데이터 그룹핑 후 반환 return userIds.map(id => posts.filter(post => post.userId === id)); });

4.2. 교착 상태(Deadlock) 해결하기

데드락은 코드를 작성하는 순서나 트랜잭션의 범위 때문에 발생합니다.

  1. 트랜잭션 순서 맞추기: 모든 트랜잭션이 테이블을 동일한 순서(예: 항상 Table A ➡️ Table B 순서)로 접근하도록 설계합니다.
  2. 트랜잭션 최소화: 트랜잭션 범위를 최대한 짧게 가져가서 락(Lock)을 유지하는 시간을 줄입니다. 외부 API 호출 등은 트랜잭션 밖으로 뺍니다.
  3. 낙관적 락(Optimistic Lock) 사용: 충돌이 자주 발생하지 않는다면, DB 단의 락 대신 애플리케이션 레벨에서 버전(Version) 관리를 통해 충돌을 방지합니다.
// TypeORM 낙관적 락 예시 @Entity() export class Product { @PrimaryGeneratedColumn() id: number; @Column() stock: number; // 레코드가 수정될 때마다 자동으로 1씩 증가 @VersionColumn() version: number; }

5. 결과 및 Trade-off

문제주된 해결책장점단점 (Trade-off)
N+1 문제Fetch Join, Eager Loading단일 쿼리로 최적화되어 성능 향상너무 많은 연관관계를 한 번에 조인하면 카테시안 곱이 발생하거나 데이터 전송량이 커질 수 있음
카테시안 곱Batch Size 설정, IN 쿼리 유도메모리 초과 방지, 페이징(Pagination) 가능쿼리가 1번에서 2~3번으로 나뉘어 실행됨 (하지만 N+1보단 훨씬 빠름)
데드락짧은 트랜잭션, 일관된 락 순서 유지, 비관적/낙관적 락 적용데이터 무결성 보장, DB 멈춤 방지비관적 락을 남용하면 동시성(Concurrency) 처리량이 급감하여 전반적인 성능 저하 발생

모든 상황에 맞는 "은탄환(Silver Bullet)"은 없습니다. 조인이 너무 복잡하다면 ORM 대신 MyBatis나 QueryDSL 같은 쿼리 빌더를 사용하는 것이 현명하며, 읽기(Read) 트래픽이 너무 높다면 캐싱(Redis) 도입을 고려해야 합니다.

6. 마치며

ORM은 개발을 편하게 해주지만, 뒷단에서 어떤 SQL을 만들어내는지 모르면 결국 큰 기술 부채로 돌아옵니다. 항상 "이 코드가 실행될 때 DB에서는 어떤 일이 벌어질까?"를 상상하는 습관을 들이는 것이 중요합니다.

데이터베이스의 특성을 잘 이해하고 적절한 도구(ORM, 쿼리 빌더)를 섞어 쓰는(CQS 패턴) 유연한 아키텍처를 설계해 보시길 바랍니다.

← 이전 글백엔드 개발자의 영원한 고민: Raw SQL, Query Builder, ORM 완벽 비교 및 레포지토리 패턴
다음 글 →데이터 무결성을 지키는 최후의 보루: 트랜잭션과 격리 수준(Isolation Level) 완벽 이해하기