[JPA] N+1 문제는 낯설어서

skyepodium·2022년 1월 2일
3
post-thumbnail
post-custom-banner

N+1 문제에 대해 알아봅시다.

0. 개요

가정합니다.

우리의 토끼는 인싸라서 10마리의 호랑이들과 친구입니다.

2) ERD

DB 테이블로 보면 N:1 이지만

3) JPA N:1 양방향 관계

객체에서는 N:1 양방향 관계로 설정해주었습니다.

1. 환경설정

직접 만들어봅시다.

1) H2 DB 설치

h2 DB 설치는 이것을 참고해주세요

2) 프로젝트 생성

프로젝트명: nPlusOne
빌드환경: gradle
자바버전: 11
라이브러리: spring web, spring-data-jpa(hibernate), lombok, h2

3) 구조


하나씩 채워봅시다.

4) build.gradle

plugins {
	id 'org.springframework.boot' version '2.6.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	runtimeOnly 'com.h2database:h2'
}

test {
	useJUnitPlatform()
}

lombok 쓰니까 Enable annotaion processing 체크까지 해줍니다.

3) yml

spring:
  # DB 연결
  datasource:
    # 설치된 H2 DB와 연결 URL
    url: jdbc:h2:tcp://localhost/~/test
    # 접속을 위한 드라이버
    driver-class-name: org.h2.Driver
    # springboot 2.4 부터는 username이 꼭 있어야합니다. 없으면 에러가 발생합니다.
    username: sa
  jpa:
    # JPA가 수행하는 SQL을 볼 수 있다.
    show-sql: true
    # 객체를 보고 자동으로 테이블 생성 여부. 생성 - create, 비생성 - none
    # 테스트이기 때문에 create로 설정하며
    # 실제로는 none 으로 합니다. create이면 기존의 테이블을 전부 밀어버립니다.
    hibernate:
      ddl-auto: create
  # 콘솔 확인을 위한 always
  output:
    ansi:
      enabled: always
# 파라미터 확인을 위한 trace
logging:
  level:
    org.hibernate.type: trace

4) Entity

// Tiger.java
package com.example.nplusone;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Tiger {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 의존관계의 주인 rabbit
    @ManyToOne
    private Rabbit rabbit;

    // 연관관계 편의 메소드
    // 양쪽으로 넣어주느것보다 한쪽에서 넣어준다.
    // setter랑 혼동될 수 있어서 change prefix를 붙인다.
    public void changeTiger(Rabbit rabbit) {
        this.rabbit = rabbit;
        rabbit.getTigerList().add(this);
    }
}
// Rabbit.java
package com.example.nplusone;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
public class Rabbit {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // mappedBy를 통한 양방향 바인딩, 즉시 로딩
    @OneToMany(mappedBy = "rabbit", fetch = FetchType.EAGER)
    private List<Tiger> tigerList = new ArrayList<>();
}

5) Repository

JPA 기본 쿼리만 사용할 것입니다.

//TigerRepository.java
package com.example.nplusone;

import org.springframework.data.jpa.repository.JpaRepository;

public interface TigerRepository extends JpaRepository<Tiger, Long> {
}
//RabbitRepository.java
package com.example.nplusone;

import org.springframework.data.jpa.repository.JpaRepository;

public interface RabbitRepository extends JpaRepository<Rabbit, Long>{
}

2. 실습

1) 테스트 코드 작성

package com.example.nplusone;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;

@SpringBootTest
class NPlusOneApplicationTests {

	private final EntityManager entityManager;
	private final RabbitRepository rabbitRepository;
	private final TigerRepository tigerRepository;

	@Autowired
	// 의존성 주입
	public NPlusOneApplicationTests(RabbitRepository rabbitRepository, TigerRepository tigerRepository, EntityManager entityManager) {
		this.rabbitRepository = rabbitRepository;
		this.tigerRepository = tigerRepository;
		this.entityManager = entityManager;
	}

	@Test
	@Transactional//반드시 하나의 트랜잭션에 있어야합니다.    
	@DisplayName("1. 토끼 1마리 생성, 호랑이 10마리 생성 후 토기 1마리 매핑, 토끼 4마리 추가 생성, 토끼 모두 조회")
	void create() {
		// 1. 토끼 1마리 생성
		// 1) 비영속 상태
		Rabbit rabbit = new Rabbit();
		// 2) 영속 상태
		rabbitRepository.save(rabbit);

		// 2. 호랑이 10마리 생성
		// 1) 비영속 상태
		List<Tiger> tigerList = new ArrayList<>();
		for(int i=0; i<10; i++) {
			Tiger tiger = new Tiger();
			// 토끼 등록
			tiger.changeTiger(rabbit);
			tigerList.add(tiger);
		}
		// 2) 영속성 컨텍스트에 등록
		tigerRepository.saveAll(tigerList);

		System.out.println("========================");

		// 3. 토끼 4마리 추가 생성
		for(int i=0; i<4; i++) {
			Rabbit rabbit1 = new Rabbit();
			rabbitRepository.save(rabbit1);
		}

		// 4. 영속성 컨텍스트 모두 비움
		entityManager.clear();

		System.out.println("========================");

		// 5. 토끼 모두 조회
		rabbitRepository.findAll();
	}
}

2) 확인

토끼 모두(5마리) 조회하는 쿼리는 수행되었는데

관련해서 호랑이 모두(10마리)도 조회하고 있다.

조회된 토끼가 5마리 이기 때문에 N번의 호랑이 조회 쿼리가 수행됩니다.

SELECT * FROM RABBIT;
SELECT * FROM TIGER WHERE RABBIT_ID = 1;
SELECT * FROM TIGER WHERE RABBIT_ID = 2;
SELECT * FROM TIGER WHERE RABBIT_ID = 3;
SELECT * FROM TIGER WHERE RABBIT_ID = 4;
SELECT * FROM TIGER WHERE RABBIT_ID = 5;

3. 그래서 무엇인 문제?

1) 원인

N:1 양방향 관계의 OneToMany측 엔티티를 조회할때 연관관계의 엔티티를 모두 조회하게 될때 발생됩니다.

2) 즉시로딩, 지연로딩

즉시로딩(Eager), 지연로딩(Lazy)에 관계없이 모두 발생합니다.

다만, N+1 문제가 발생하는 시점이 다릅니다.
즉시로딩 - 조회 시점에 발생
지연로딩 - 연관 엔티티를 꺼내는 시점에 발생

3) Lazy 로딩에서 N+1

Fetch Type을 Lazy로 변경하고 수행합니다.

package com.example.nplusone;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
public class Rabbit {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // mappedBy를 통한 양방향 바인딩
    @OneToMany(mappedBy = "rabbit", fetch = FetchType.LAZY)
    private List<Tiger> tigerList = new ArrayList<>();
}
package com.example.nplusone;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.persistence.EntityManager;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;

@SpringBootTest
class NPlusOneApplicationTests {

	private final EntityManager entityManager;
	private final RabbitRepository rabbitRepository;
	private final TigerRepository tigerRepository;

	@Autowired
	// 의존성 주입
	public NPlusOneApplicationTests(RabbitRepository rabbitRepository, TigerRepository tigerRepository, EntityManager entityManager) {
		this.rabbitRepository = rabbitRepository;
		this.tigerRepository = tigerRepository;
		this.entityManager = entityManager;
	}

	@Test
	@Transactional// 반드시 하나의 트랜잭션에 있어야한다.
	@DisplayName("1. 토끼 1마리 생성, 호랑이 10마리 생성 후 토기 1마리 매핑, 토끼 4마리 추가 생성, 토끼 모두 조회")
	void create() {
		// 1. 토끼 1마리 생성
		// 1) 비영속 상태
		Rabbit rabbit = new Rabbit();
		// 2) 영속 상태
		rabbitRepository.save(rabbit);

		// 2. 호랑이 10마리 생성
		// 1) 비영속 상태
		List<Tiger> tigerList = new ArrayList<>();
		for(int i=0; i<10; i++) {
			Tiger tiger = new Tiger();
			// 토끼 등록
			tiger.changeTiger(rabbit);
			tigerList.add(tiger);
		}
		// 2) 영속성 컨텍스트에 등록
		tigerRepository.saveAll(tigerList);

		System.out.println("========================");

		// 3. 토끼 4마리 추가 생성
		for(int i=0; i<4; i++) {
			Rabbit rabbit1 = new Rabbit();
			rabbitRepository.save(rabbit1);
		}

		// 4. 영속성 컨텍스트 모두 비움
		entityManager.clear();

		System.out.println("findAll ========================");
		List<Rabbit> rabbitList = rabbitRepository.findAll();

		System.out.println("rabbitList loop ========================");
		for(Rabbit rabbit2: rabbitList) {
			System.out.println(rabbit2.getTigerList().size());
		}
	}
}

4. 해결방법

1) fetch join

fetch join은 조회하는 Entity 이외에 연관 Entity를 조회 후 영속성 컨텍스트에 등록합니다.

이미 영속성 컨텍스트에 등록되어 있기 대문에 지연 로딩 시점에서도 재조회하지 않습니다.

// 일대다 join을 수행해서 중복이 발생할 수 있습니다. 반드시 distinct를 사용합니다.
List<Rabbit> rabbitList = entityManager.createQuery("select distinct r from Rabbit r join fetch r.tigerList", Rabbit.class).getResultList();
package com.example.nplusone;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;

@SpringBootTest
class NPlusOneApplicationTests {

	private final EntityManager entityManager;
	private final RabbitRepository rabbitRepository;
	private final TigerRepository tigerRepository;

	@Autowired
	// 의존성 주입
	public NPlusOneApplicationTests(RabbitRepository rabbitRepository, TigerRepository tigerRepository, EntityManager entityManager) {
		this.rabbitRepository = rabbitRepository;
		this.tigerRepository = tigerRepository;
		this.entityManager = entityManager;
	}

	@Test
	@DisplayName("호랑이 10마리 생성, 토끼 5마리 생성, 토끼 모두 조회")
	void create() {
		// 1. 호랑이 10마리 생성
		// 1) 비영속 상태
		List<Tiger> tigerList = new ArrayList<>();
		for(int i=0; i<10; i++) {
			Tiger tiger = new Tiger();
			tigerList.add(tiger);
		}
		// 2) 영속성 컨텍스트에 등록
		tigerRepository.saveAll(tigerList);

		System.out.println("========================");

		// 2. 토키 5마리 생성
		// 1) 비영속 상태
		List<Rabbit> rabbitList = new ArrayList<>();
		for(int i=0; i<5; i++) {
			// 1) 비영속 상태
			Rabbit rabbit = new Rabbit();
			rabbit.setTigerList(tigerList);
			rabbitList.add(rabbit);
		}
		// 2) 영속성 컨텍스트에 토끼 등록
		rabbitRepository.saveAll(rabbitList);

		System.out.println("========================");

		// 3. 영속성 컨텍스트 모두 비움
		entityManager.clear();

		System.out.println("========================");

		// fetch join 사용
		entityManager.createQuery("select r from Rabbit r join fetch r.tigerList", Rabbit.class).getResultList();
	}
}

1번만 조회되었습니다.

참고로 DB 상태

5. 정리

  • 즉시로딩 - 지연로딩으로 변경한다.
  • 성능 최적화가 필요한 경우 - JPQL 페치 조인을 사용한다.
profile
callmeskye
post-custom-banner

0개의 댓글