N+1은 왜 JPA에서 발생할까?🤔(JPQL)

5 minute read

Published:

목차

  1. 작성 이유
  2. Spring Boot JPA
  3. JPQL
  4. N+1은 왜 JPA에서 발생할까?🤔

N+1기

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


작성 이유

저번 주 스터디에서 “JPQL을 사용하신 건가요?”란 질문에 나는 “MySQL을 사용했습니다.”라고 답했다. 하지만 실제로 사용한 것은 JPQL이었다. 글에도 분명 적혀 있었지만, 나는 그 차이를 제대로 이해하지 못하고 있었다. 이 경험을 계기로 JPQL이 무엇인지, 그리고 왜 중요한지 다시 학습해보기로 했다.

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


Spring Boot JPA

데이터베이스를 쉽게 다루기 위한 ‘데이터 액세스 기술’로 ORM(Object-Relational Mapping) 기법을 사용하여 자바 애플리케이션에서 사용하는 객체와 관계형 데이터베이스 사이의 매핑을 관리하는 ORM 기술에 대한 API 표준 명세서(인터페이스)이다. 이 API를 사용하여 개발자가 직접적인 SQL을 작성하지 않고도 데이터베이스에서 데이터를 저장, 업데이트, 삭제, 조회하는 등의 작업을 수행할 수 있다. JPA는 표준화된 API를 제공함으로써, 다양한 ORM 프레임워크(Hibernate, EclipseLink, OpenJPA 등)와의 호환성을 보장한다. 이로 인해 개발자는 특정 ORM 프레임워크에 종속되지 않고 필요에 따라 다른 프레임워크로 쉽게 전환할 수 있다. JPA

JpaRepository

Spring Data JPA에서 제공하는 기능 중 하나로 개발자가 데이터베이스와의 CRUD(Create, Read, Update, Delete) 연산을 더욱 쉽게 처리할 수 있도록 도와주는 인터페이스 이다. JpaRepository 인터페이스를 상속받은 인터페이스를 만들면, Spring Data JPA가 자동으로 해당 인터페이스의 구현체를 만들어서 Bean으로 등록한다. 이를 통해 개발자는 SQL 쿼리를 직접 작성하지 않고도 메서드 이름만으로 데이터베이스 연산을 수행할 수 있다.

Entity

해당 Entity는 데이터베이스 컬럼과 매핑되는 객체를 구성한 공간이다. 해당 명세한 객체를 기반으로 데이터베이스 테이블 및 컬럼이 생성된다. Entity 구성 이후 서버를 수행하였을 시 구성한 객체를 기반으로 테이블이 생성된다.

해당 Entity 구성에 사용된 JPA 어노테이션

어노테이션설명
@EntityJPA가 관리하는 Entity 클래스임을 지정한다. 이 어노테이션이 붙은 클래스는 JPA가 관리하며, 테이블과 매핑된다.
@TableEntity 클래스가 매핑될 테이블을 지정한다.
@Column필드가 매핑될 테이블의 컬럼을 지정한다.
@Id해당 필드가 테이블의 주키(primary key) 역할을 하는 필드임을 지정한다.
@GeneratedValue주키의 생성 전략을 지정한다.

디렉터리 구조

디렉터리설명
controller사용자의 요청을 처리하는 레이어로, HTTP 요청을 받아 서비스 레이어에 작업을 위임하고 결과를 클라이언트에게 전달한다.
entity데이터베이스 테이블에 해당하는 클래스를 담는 레이어로, 이 클래스는 데이터베이스의 테이블과 매핑된다.
repository데이터베이스와 관련된 연산을 처리하는 레이어로, 데이터베이스 CRUD(Create, Read, Update, Delete) 연산이 이 레이어에서 수행된다.
service비즈니스 로직을 담당하는 레이어로, 컨트롤러로부터 요청을 받아 필요한 데이터를 리포지토리에서 가져와 처리하고 결과를 컨트롤러에게 반환한다.
service.Impl서비스 인터페이스를 구현하는 클래스를 담는 레이어로, 이 레이어에는 실제 비즈니스 로직이 구현되어 있다.

JPQL(Java Persistence Query Language)

SQL을 기반으로 한 객체 모델용 쿼리 언어이다. SQL과 매우 유사한 형태지만 데이터베이스 ‘테이블과 컬럼’이 아닌 ‘자바 클래스와 변수(객체)’에 작업을 수행한다는 점에서 차이가 있다. 그렇기에 데이터베이스 테이블을 대상으로 쿼리 하는 것이 아닌 엔티티(객체)를 대상으로 쿼리를 수행한다. JPQL은 SQL문과 형태가 비슷하다. ​

특징

기본적인 연산 지원

SELECT, UPDATE, DELETE, INSERT(Embeddable 클래스에 한정) 등의 기본적인 연산을 지원하며, 함수, 연산자, 키워드 등 다양한 기능을 제공한다.

객체지향 쿼리 언어

자바의 특성을 최대한 활용할 수 있으며, 쿼리 결과를 객체 또는 객체의 컬렉션으로 직접 반환받을 수 있다.

타입 안정성 제공

컴파일 시점에 쿼리의 문법 오류를 검사할 수 있다.

JpaRepository와 비교

분류JpaRepositoryJPQL
정의Spring Data JPA에서 제공하는 인터페이스로 개발자가 JPA를 더 쉽고 편하게 사용할 수 있게 도와준다.Java Persistence Query Language의 약자로, SQL을 기반으로 한 객체 모델용 쿼리 언어이다.
사용 용이성JpaRepository를 상속받은 Repository 인터페이스를 생성함으로써 간단하게 CRUD 기능을 사용할 수 있다.SQL과 비슷한 문법을 가지며, 객체 지향적인 쿼리를 작성할 수 있다.
유연성Spring Data JPA에 의해 구현되므로, 개발자는 쿼리를 직접 작성하지 않아도 된다. 하지만 이는 복잡한 쿼리를 작성하는데 한계가 있을 수 있다.개발자가 직접 쿼리를 작성하므로 상황에 따른 복잡한 쿼리 작성이 가능하다.
호환성JpaRepository는 JPA를 기반으로 하므로 JPA를 지원하는 모든 데이터베이스 시스템과 호환된다.JPQL도 JPA를 기반으로 하므로, JPA를 지원하는 모든 데이터베이스 시스템과 호환된다.

JPQL 처리방식

  1. 리턴 타입 ‘쿼리 타입’을 지정한다.(TypedQuery, Query)
  2. 쿼리를 구성한다.(createQuery)
  3. 쿼리 프로젝션을 설정한다.(엔티티, 임베디드 타입, 스칼라 타입 프로젝션)
  4. 쿼리 파라미터를 지정한다.(이름 기준, 위치 기준)
  5. 쿼리 결과를 조회 방식을 선택한다.(getSingleResult(), getResultList())

N+1은 왜 JPA에서 발생할까?🤔

JPQL(Java Persistence Query Language)을 사용하다 보면 가장 흔하게 마주치는 문제가 N+1 문제이다.

N+1문제

쿼리 1번으로 N개의 데이터를 가져왔는데, 관련 데이터를 더 가져오기 위해 N번의 쿼리가 추가로 실행되는 현상이다.

예를 들어, ‘팀(Team)’과 ‘멤버(Member)’가 1:N 관계라고 가정하자.

  • 1(기본 쿼리)
    • 모든 멤버를 조회한다.(SELECT * FROM Member)
  • N(추가 쿼리)
    • 조회된 각 멤버가 속한 팀의 이름을 알고 싶어서 각 멤버마다 팀 조회 쿼리를 날린다.(SELECT * FROM Team WHERE id = ?)

결과적으로 데이터가 100건이라면 쿼리는 1 + 100 = 101번 실행된다.

JPQL은 객체 기준으로 조회하고, 연관관계는 기본적으로 즉시 조인하지 않기 때문에 N+1 문제가 발생할 수 있다.

발생 이유

JPQL의 특성

JPQL은 작성된 SQL 형태 그대로를 먼저 해석해서 SQL을 생성한다. SELECT m FROM Member m이라는 JPQL을 실행하면, JPA는 일단 SELECT * FROM MEMBER를 실행한다.

연관 데이터 로딩

  • EAGER(즉시 로딩): 데이터를 가져온 직후, 연관된 엔티티가 비어있는 것을 보고 즉시 N번의 추가 쿼리를 날린다.
  • LAZY(지연 로딩): 일단은 프록시 객체로 채워두지만, 나중에 루프를 돌며 실제 데이터를 사용하는 시점에 N번의 추가 쿼리가 나간다.

결국 어떤 로딩 전략을 쓰든 JPQL만으로는 N+1 문제를 원천 봉쇄할 수 없다.

해결 방법

Fetch Join

JPQL의 JOIN FETCH 키워드를 사용하여 연관된 엔티티를 한 번의 SQL Join으로 다 가져온다.

SELECT m FROM Member m JOIN FETCH m.team

@EntityGraph

어노테이션을 통해 어떤 연관 관계를 한 번에 조회할지 명시한다.

@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findAllWithTeam();

Batch Size 설정

완벽하게 1번의 쿼리로 줄이지는 못하지만, N번 나갈 쿼리를 IN절을 사용해 설정한 개수만큼 묶어서 보낸다.

hibernate.default_batch_fetch_size: 100 (전역 설정)

이렇게 하면 100번 나갈 쿼리가 1번으로 줄어든다. N+1 문제를 ‘N/100 + 1’ 정도로 획기적으로 줄여주는 안전장치 역할을 한다.

참고

Gemini
ChatGPT
Claude.ai
[Java/JPA] Spring Boot Data JPA 이해하기 -1: ORM, JPA, Hibernate, QueryDSL 이론
[Java/JPA] Spring Boot Data JPA 이해하기 -2 : 초기 환경 구성 + JpaRepository 활용 방법