현대 백엔드 개발에서 JPA, TypeORM, Prisma와 같은 ORM(Object-Relational Mapping)의 사용은 선택이 아닌 필수가 되었습니다. 단순한 CRUD 작성 시간을 획기적으로 줄여주고, 비즈니스 로직에 집중할 수 있게 해주기 때문이죠.
하지만, "객체"와 "관계형 데이터베이스" 사이의 패러다임 불일치는 종종 심각한 성능 저하를 야기합니다. 개발자가 의도하지 않은 수십, 수백 개의 쿼리가 데이터베이스로 날아가거나, 트래픽이 몰리는 상황에서 DB가 멈춰버리는 상황을 겪어본 적이 있으신가요?
이러한 문제들은 서비스 규모가 커지고 트래픽이 증가할 때 시스템의 목을 조르는 치명적인 병목(Bottleneck)이 됩니다. 이번 글에서는 실무에서 가장 빈번하게 발생하는 DB 문제들과 그 해결책을 짚어보려 합니다.
데이터베이스 성능 문제를 해결하기 위해 가장 먼저 해야 할 일은 문제가 무엇인지 정확히 진단하는 것입니다.
진단이 끝났다면, 상황에 맞게 쿼리 최적화, 인덱스 추가, 로직 수정 등을 진행해야 합니다.
우리가 마주하는 대표적인 DB 문제들의 원리와 발생 과정을 살펴보겠습니다.
가장 흔하게 발생하는 문제입니다. 한 번의 쿼리로 N개의 레코드를 가져왔는데, 그 레코드들과 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 현상을 말합니다.
조인(Join) 조건이 잘못되었거나 다대다(N:M) 관계를 잘못 풀었을 때 발생합니다. 테이블 A의 레코드가 1,000개, 테이블 B의 레코드가 1,000개일 때 두 테이블을 조건 없이 조인하면 1,000,000개의 데이터가 메모리로 올라오게 되어 애플리케이션이 뻗어버릴 수 있습니다. (JPA에서는 Multiple Bag Fetch 문제라고도 부릅니다.)
두 개 이상의 트랜잭션이 서로가 점유하고 있는 락(Lock)을 기다리며 무한 대기 상태에 빠지는 현상입니다.
그렇다면 이런 문제들을 어떻게 해결할 수 있을까요? 상황별 해결책을 코드로 살펴보겠습니다.
[해결책 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)); });
데드락은 코드를 작성하는 순서나 트랜잭션의 범위 때문에 발생합니다.
// TypeORM 낙관적 락 예시 @Entity() export class Product { @PrimaryGeneratedColumn() id: number; @Column() stock: number; // 레코드가 수정될 때마다 자동으로 1씩 증가 @VersionColumn() version: number; }
| 문제 | 주된 해결책 | 장점 | 단점 (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) 도입을 고려해야 합니다.
ORM은 개발을 편하게 해주지만, 뒷단에서 어떤 SQL을 만들어내는지 모르면 결국 큰 기술 부채로 돌아옵니다. 항상 "이 코드가 실행될 때 DB에서는 어떤 일이 벌어질까?"를 상상하는 습관을 들이는 것이 중요합니다.
데이터베이스의 특성을 잘 이해하고 적절한 도구(ORM, 쿼리 빌더)를 섞어 쓰는(CQS 패턴) 유연한 아키텍처를 설계해 보시길 바랍니다.