N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)

9 minute read

Published:

목차

  1. 작성 이유
  2. N+1
  3. 나의 코드는..🤔
  4. Fetch Join 적용 전후 성능 비교하기

N+1기

계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
N+1은 왜 JPA에서 발생할까?🤔(JPQL)
N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔


작성 이유

블로그 스터디를 진행하던 중 “N+1은 어떻게 해결하신건가요?”라는 질문을 받았다. 막상 설명하려니 제대로 이해하지 못한 부분도 있는 것 같아 이 기회에 정리해 보려고 한다.


N+1

N+1 N+1 문제는 관계형 데이터베이스(RDB)와 객체 관계 매핑(ORM, e.g JPA, Hibernate) 기술 사이의 간극에서 발생하는 대표적인 성능 이슈이다. 연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다.

  • 1: 하위 엔티티들을 조회하기 위한 쿼리 1번
  • N: 조회된 결과(N개)만큼 각각의 하위 엔티티를 조회하기 위해 추가로 발생하는 쿼리

즉, N번 결과적으로 데이터 1건을 조회하려다 N+1번의 쿼리가 실행되어 서버 부하가 급증하는 현상이다. 한 번에 가져올 수 있는 데이터를 여러 번 나눠서 가져오므로 비효율적이다.

예시

User(사용자)와 Order (주문) 구조가 있다고 가정한다. 사용자 한 명이 여러 주문을 가질 수 있는 1:N 관계이다. 코드의 의도는 “모든 사용자를 조회하고, 각 사용자의 주문도 같이 볼 수 있다.” 이다.

동작 과정

// 사용자 전체 조회 쿼리 1번
SELECT * FROM user;

// 사용자 수가 10명이라면 각 사용자마다 주문 조회 쿼리 10번 실행
SELECT * FROM order WHERE user_id = 1;
SELECT * FROM order WHERE user_id = 2;
...
SELECT * FROM order WHERE user_id = 10;

// 총 11번의 쿼리 발생 = 1 + 10 -> 데이터가 1,000명이면 1,001번 쿼리 발생

N+1 문제는 MyBatis, JdbcTemplate 등 Mapper를 사용해 JDBC를 처리할 때는 없는 현상이다. 그러나 JPA를 사용하는 경우에는 N+1 문제가 발생할 수 있다. 이는 JPA의 프록시(Proxy) 때문이다.

프록시(Proxy)

JPA에서 프록시는 실제 엔티티 객체 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체를 의미한다. 가짜 객체라고 해서 실제 엔티티의 동작을 수행하지 못하는 것은 아니다. 실제 엔티티 클래스를 상속받아 만들어지므로 실제 클래스와 겉모양이 같으므로 사용하는 입장에서는 진짜 객체인지 가짜 객체인지 구분하지 않고 사용한다. 프록시(Proxy) 겉모양은 같아 사용하는 입장에서는 동일하게 사용하면 되지만 내부적으로는 다른 동작을 수행한다. 프록시 객체는 실제 객체에 대한 참조를 가지고 있기에 프록시 객체의 메서드를 호출하면 프록시 객체는 참조를 통해 메서드 호출을 위임하고 실제 객체의 메서드를 호출한다.

동작 과정

// 1. Order 조회(User는 아직 조회 X)
SELECT * FROM orders;

// 2. 코드에서 접근하는 순간(프록시 작동)
order.getUser().getName();

// 3. 프록시 입장
// "어? 진짜 객체 필요하네?"
// DB에 쿼리 날림 -> 실제 User 객체로 초기화
SELECT * FROM user WHERE id = ?;

프록시 초기화 시 영속성 컨텍스트에 해당 엔티티가 없으면 추가 쿼리가 발생하고, 이 과정이 반복되면서 N+1 문제가 발생한다.

특징

  • 실제 클래스를 상속받아서 만들어진다.
  • 실제 클래스와 겉모양이 같다.
  • 사용하는 입장에서 진짜 엔티티 객체인지, 프록시 객체인지 구분하지 않고 사용한다.
  • 프록시 객체는 실제 객체의 참조(target)를 보관한다.
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출한다.
  • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
  • 프록시 객체의 초기화는 실제 엔티티 객체로의 변경을 의미하는 것이 아니라 실제 엔티티 객체의 참조를 가지는 것으로 이해할 수 있다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시 주의해서 사용해야 한다.
  • 영속성 컨텍스트에 이미 존재한다면 데이터베이스 조회가 필요 없고 프록시 객체 사용 필요성이 없으므로 프록시 객체가 아닌 실제 엔티티 객체를 반환한다.
  • 동일성 보장을 위해 먼저 나온 객체가 프록시 객체일 때 find를 사용하더라도 프록시 객체가 나올 수 있다.

N+1의 영향

성능 저하

  • DB 왕복 횟수 급증
  • 네트워크 비용 증가
  • 응답 속도 느려짐

서버 부하 증가

쿼리 수가 많아질수록 DB가 힘들어한다.

개발자가 모르면 더 위험

코드만 보면 한 번에 가져오는 것처럼 보이는데 실제로는 수십~수백 번 쿼리가 나간다.

해결 방법

Fetch Join 사용(JPA에서 가장 대표적)

한 번의 쿼리로 User + Orders를 같이 가져올 수 있다.

SELECT u FROM User u JOIN FETCH u.orders

EntityGraph 사용

특정 연관 관계만 선택적으로 즉시 로딩한다.

Batch Size 설정

1개씩이 아니라 여러 개를 묶어서 조회한다.

@BatchSize(size = 100)

DTO 조회

필요한 데이터만 직접 쿼리로 가져온다.


나의 코드는..🤔

테스트 코드

withdrawAccount가 LAZY 로딩 상태이기 때문에, 트랜잭션을 조회하는 시점에는 Account 엔티티가 함께 조회되지 않는다. 반복문에서 getWithdrawAccount()에 접근하는 순간 프록시 객체가 초기화되며 Account를 조회하는 추가 쿼리가 실행된다. 이 과정이 트랜잭션 개수(N)만큼 반복되면서 1번의 조회 쿼리 + N번의 추가 쿼리, 즉 N+1 문제가 발생한다. src/test/java/com/FinFlow/domain/transaction/TransactionRepositoryImplTests.java

@Test
public void findTransactionList_withdraw_test() throws Exception {
  // given
  Long accountId = 1L;

  // when
  List<Transaction> transactionList = transactionRepository.findTransactionList(accountId, "WITHDRAW", 0);

  transactionList.forEach(transaction -> {
    // 이 부분이 있어야 N+1 발생
    System.out.println("withdrawAccount 잔액: " + transaction.getWithdrawAccount().getBalance());
    System.out.println("withdrawAccount 번호: " + transaction.getWithdrawAccount().getNumber());
  });
}

레포지토리 코드

src/main/java/com/FinFlow/repository/TransactionRepositoryImpl.java

fetch join 적용 전

해당 메서드는 transaction_type 값에 따라 JPQL을 동적으로 생성하여 거래 목록을 조회한다. 하지만 select t from Transaction t 구문은 Transaction 엔티티만 조회하며, 연관된 Account 엔티티는 LAZY 전략에 의해 즉시 조회되지 않는다. 따라서 이후 연관 엔티티에 접근하는 시점에 추가 쿼리가 발생하게 되며, 이것이 N+1 문제의 원인이 된다.

@Override
public List<Transaction> findTransactionList(Long accountId, String transaction_type, Integer page) {
// 동적 쿼리 (transaction_type 값을 가지고 동적 쿼리 = DEPOSIT, WITHDRAW, ALL)

// JPQL
String sql = "";
sql += "select t from Transaction t ";

if (transaction_type.equals("WITHDRAW")) {
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
} else if (transaction_type.equals("DEPOSIT")) {
  sql += "where t.depositAccount.id = :depositAccountId ";
} else {
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
  sql += "or ";
  sql += "t.depositAccount.id = :depositAccountId ";
}

로그를 보면 Transaction을 먼저 조회한 뒤, getWithdrawAccount().getBalance()를 호출하는 순간 프록시가 초기화되면서 account를 조회하는 쿼리가 추가로 실행되었음을 확인할 수 있다. fetch join 적용 전

Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: select t1_0.id,t1_0.amount,t1_0.created_at,t1_0.deposit_account_id,t1_0.deposit_account_balance,t1_0.receiver,t1_0.sender,t1_0.tel,t1_0.transaction_type,t1_0.updated_at,t1_0.withdraw_account_id,t1_0.withdraw_account_balance from account_transaction t1_0 where t1_0.withdraw_account_id=? offset ? rows fetch first ? rows only
Hibernate: select a1_0.id,a1_0.balance,a1_0.created_at,a1_0.is_active,a1_0.number,a1_0.password,a1_0.updated_at,a1_0.user_id from account a1_0 where a1_0.id=?
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111

fetch join 적용 후

이 코드는 transaction_type 값에 따라 동적으로 JPQL을 생성하며, 연관된 Account 엔티티를 fetch join으로 함께 조회하도록 구성하였다. 기존에는 Transaction 엔티티만 조회한 뒤, 연관된 withdrawAccount 또는 depositAccount에 접근하는 시점에 추가 쿼리가 발생하여 N+1 문제가 발생했다. 이는 연관 관계가 LAZY 로딩으로 설정되어 있기 때문이다.

이를 해결하기 위해 JPQL에서 join fetch를 사용하여 Transaction 조회 시점에 연관된 Account 엔티티를 함께 로딩하도록 수정했다. 이렇게 하면 연관 엔티티가 프록시로 남지 않고 즉시 초기화되므로, 이후 필드에 접근하더라도 추가 쿼리가 발생하지 않는다. 이로써 fetch join 적용 전에는 조회된 Transaction 개수만큼 추가 쿼리가 발생했지만, 적용 후에는 단일 쿼리로 모든 연관 데이터를 가져오게 되어 N+1 문제가 해결된다.

@Override
public List<Transaction> findTransactionList(Long accountId, String transaction_type, Integer page) {
// 동적 쿼리 (transaction_type 값을 가지고 동적 쿼리 = DEPOSIT, WITHDRAW, ALL)

// JPQL
String sql = "";
sql += "select t from Transaction t ";

if (transaction_type.equals("WITHDRAW")) {
  sql += "join fetch t.withdrawAccount wa ";
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
} else if (transaction_type.equals("DEPOSIT")) {
  sql += "join fetch t.depositAccount da ";
  sql += "where t.depositAccount.id = :depositAccountId ";
} else {
  sql += "left join fetch t.withdrawAccount wa ";
  sql += "left join fetch t.depositAccount da ";
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
  sql += "or ";
  sql += "t.depositAccount.id = :depositAccountId ";
}

로그를 보면 Transaction 조회와 동시에 Account까지 한 번에 가져옴을 확인할 수 있다. fetch join 적용 전

Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: select t1_0.id,t1_0.amount,t1_0.created_at,t1_0.deposit_account_id,t1_0.deposit_account_balance,t1_0.receiver,t1_0.sender,t1_0.tel,t1_0.transaction_type,t1_0.updated_at,t1_0.withdraw_account_id,wa1_0.id,wa1_0.balance,wa1_0.created_at,wa1_0.is_active,wa1_0.number,wa1_0.password,wa1_0.updated_at,wa1_0.user_id,t1_0.withdraw_account_balance from account_transaction t1_0 join account wa1_0 on wa1_0.id=t1_0.withdraw_account_id where wa1_0.id=? offset ? rows fetch first ? rows only
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111

Fetch Join 적용 전후 성능 비교하기

fetch join 적용 전후의 성능 차이를 명확히 비교하기 위해, 트랜잭션 생성 로직을 1000회 반복하도록 확장했다.

for (int i = 0; i < 1000; i ++) {
  Transaction withdrawTransaction1 = transactionRepository
          .save(newWithdrawTransaction(alicesAccount, accountRepository));
  Transaction depositTransaction1 = transactionRepository
          .save(newDepositTransaction(bobAccount, accountRepository));
  Transaction transferTransaction1 = transactionRepository
          .save(newTransferTransaction(alicesAccount, bobAccount, accountRepository));
  Transaction transferTransaction2 = transactionRepository
          .save(newTransferTransaction(alicesAccount, charlieAccount, accountRepository));
  Transaction transferTransaction3 = transactionRepository
          .save(newTransferTransaction(bobAccount, alicesAccount, accountRepository));
}

Fetch Join 적용 전후 성능 비교하기 트랜잭션 데이터를 1000회 반복 생성한 후, fetch join 적용 전후의 실행 시간을 비교하였다. 소량 데이터 환경에서는 큰 차이가 체감되지 않았지만, 1000건 이상의 트랜잭션을 조회하는 상황에서는 명확한 성능 차이를 확인할 수 있었다.

fetch join 적용 전에는 Transaction을 조회한 뒤, 각 연관된 Account 엔티티에 접근할 때마다 추가 쿼리가 발생하였다. 이로 인해 조회된 트랜잭션 수(N)만큼 추가 쿼리가 실행되는 N+1 Query Problem이 발생했고, 전체 실행 시간이 크게 증가하였다. 반면, fetch join을 적용한 후에는 연관된 Account 엔티티를 한 번의 쿼리로 함께 조회하므로 추가 쿼리가 발생하지 않았다. 이로써 실행 쿼리 수가 크게 감소하였고 전체 응답 시간이 눈에 띄게 단축되었으며 데이터 양이 증가할수록 성능 차이가 더욱 뚜렷하게 나타났다.

이번 테스트를 통해 N+1 문제는 소량 데이터에서는 잘 드러나지 않지만, 데이터가 증가할수록 성능 저하가 기하급수적으로 커질 수 있음을 확인할 수 있었다. 또한 fetch join은 이러한 문제를 해결하는 가장 직관적이고 효과적인 방법임을 검증할 수 있었다.

소량 데이터 환경에서는 성능 차이가 적은 이유

이는 테스트 환경의 특성 때문이다.

  • 데이터가 너무 적다.
    • Transaction 3건, Account 4건밖에 없어서 N+1이 발생해도 추가 쿼리가 고작 1번이다.
    • 이 정도 차이는 의미있는 성능 차이를 만들지 못한다.
  • H2 인메모리 DB라 네트워크 비용이 없다.
    • 실제 운영 환경에서 N+1이 위험한 이유는 쿼리 1번당 네트워크 왕복 비용이 발생하기 때문인데, H2는 같은 JVM 내에서 동작하니까 쿼리가 몇 번 더 나가도 시간 차이가 거의 없다.
  • 오히려 페치조인이 더 느린 이유
    • JOIN 쿼리가 단순 단일 테이블 조회보다 무겁고, SELECT 컬럼 수도 더 많아서 데이터 전송량이 늘어나기 때문이다.

결론적으로 페치조인의 효과는 데이터가 수백~수천 건이고, 실제 DB에 네트워크로 연결된 운영 환경에서야 체감할 수 있다.

참고

Gemini
ChatGPT
Claude.ai
야! 너도 할 수 있어, N+1 해결
[JPA] N+1 문제 원인 및 해결방법 알아보기
N+1 문제
[JPA] 프록시와 로딩 전략