Java JPA N+1 문제는 어떤 상황에서 발견됐나
Java JPA N+1 문제는 대부분 코드만 봐서는 바로 드러나지 않습니다. Repository 메서드는 한 번만 호출한 것처럼 보이는데, 실제 SQL 로그를 보면 연관 엔티티를 조회하기 위해 추가 쿼리가 반복해서 실행되는 형태로 나타납니다.
제가 봤던 상황도 비슷했습니다. 게시글 목록을 조회하는 API에서 게시글과 작성자 정보를 함께 내려줘야 했습니다. 코드상으로는 게시글 목록을 한 번 조회하고, DTO로 변환하는 단순한 흐름이었습니다.
List<Post> posts = postRepository.findAll();
return posts.stream()
.map(post -> new PostResponse(
post.getId(),
post.getTitle(),
post.getMember().getName()
))
.toList();
겉으로 보면 특별히 문제가 없어 보입니다. 하지만 여기서 post.getMember().getName()을 호출하는 순간, 지연 로딩으로 인해 게시글마다 회원 조회 쿼리가 추가로 실행될 수 있습니다.
JPA N+1 문제의 실제 SQL 로그
JPA N+1 문제를 확인할 때 가장 먼저 보는 것은 SQL 로그입니다. 애플리케이션 코드에서는 Repository 호출이 한 번뿐이어도, Hibernate가 실제로 어떤 SQL을 실행하는지는 별도로 확인해야 합니다.
예를 들어 게시글 5개를 조회했는데 작성자 정보가 지연 로딩으로 설정되어 있다면 다음과 비슷한 SQL이 실행될 수 있습니다.
select p.id, p.title, p.member_id
from post p;
select m.id, m.name
from member m
where m.id = ?;
select m.id, m.name
from member m
where m.id = ?;
select m.id, m.name
from member m
where m.id = ?;
select m.id, m.name
from member m
where m.id = ?;
select m.id, m.name
from member m
where m.id = ?;
여기서 첫 번째 쿼리 1번이 게시글 목록 조회입니다. 그다음 게시글 개수만큼 작성자 조회 쿼리가 추가로 실행됩니다. 그래서 1개의 기본 쿼리와 N개의 추가 쿼리가 발생한다고 해서 N+1 문제라고 부릅니다.
이 문제는 데이터가 적을 때는 잘 보이지 않습니다. 개발 환경에서 게시글이 3개, 5개 정도라면 큰 차이를 느끼기 어렵습니다. 하지만 목록 개수가 늘어나거나 여러 연관관계를 함께 접근하면 쿼리 수가 빠르게 증가합니다.
JPA N+1 문제가 발생한 원인
JPA N+1 문제의 원인은 보통 연관관계 조회 방식과 관련이 있습니다. 특히 @ManyToOne, @OneToMany 같은 연관관계를 가진 엔티티에서 지연 로딩과 즉시 로딩을 정확히 이해하지 못하면 쉽게 발생합니다.
지연 로딩 자체가 문제는 아닙니다
먼저 지연 로딩은 잘못된 기능이 아닙니다. 오히려 필요한 시점에만 연관 데이터를 조회할 수 있기 때문에 기본 전략으로 권장되는 경우가 많습니다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
문제는 목록 조회 이후 반복문 안에서 연관 엔티티에 접근할 때 발생합니다. JPA는 처음 게시글을 조회할 때 회원 정보를 같이 가져오지 않았기 때문에, getMember() 이후 실제 값이 필요한 시점에 회원 조회 쿼리를 실행합니다.
즉, 지연 로딩이 문제라기보다 조회 목적과 데이터 접근 패턴이 맞지 않았던 것입니다. 목록 API에서 작성자 이름이 반드시 필요하다면, 처음부터 게시글과 작성자를 함께 조회하는 방식이 더 적절합니다.
즉시 로딩으로 바꾸면 해결될까
N+1 문제를 처음 마주하면 FetchType.EAGER로 바꾸면 된다고 생각하기 쉽습니다. 하지만 실무에서는 이 방식이 좋은 해결책이 아닌 경우가 많습니다.
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "member_id")
private Member member;
즉시 로딩은 엔티티를 조회할 때 연관 엔티티까지 함께 로딩하려는 전략입니다. 하지만 모든 조회 상황에서 연관 데이터가 필요한 것은 아닙니다. 어떤 API에서는 작성자 정보가 필요하지만, 다른 배치나 내부 로직에서는 필요하지 않을 수 있습니다.
엔티티 매핑에 즉시 로딩을 걸어두면 조회 의도가 Repository나 서비스 코드에 드러나지 않습니다. 나중에 코드를 읽는 사람은 왜 특정 연관 엔티티가 항상 같이 조회되는지 파악하기 어려워집니다. 그래서 기본은 지연 로딩으로 두고, 필요한 조회에서만 명시적으로 가져오는 쪽이 유지보수에 유리합니다.
JPA N+1 문제 해결 방법 1: Fetch Join 사용
JPA N+1 문제를 해결할 때 가장 먼저 검토할 수 있는 방법은 Fetch Join입니다. Fetch Join은 JPQL에서 연관 엔티티를 함께 조회하도록 명시하는 방식입니다.
게시글 목록과 작성자 정보를 함께 조회해야 한다면 다음처럼 Repository 메서드를 작성할 수 있습니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select p from Post p join fetch p.member")
List<Post> findAllWithMember();
}
이렇게 작성하면 게시글을 조회하면서 작성자도 함께 가져옵니다. 이후 DTO 변환 과정에서 post.getMember().getName()을 호출해도 이미 조회된 엔티티를 사용하기 때문에 추가 쿼리가 반복 실행되지 않습니다.
select p.id, p.title, p.member_id, m.id, m.name
from post p
join member m on p.member_id = m.id;
이 방식의 장점은 조회 의도가 코드에 명확하게 드러난다는 점입니다. findAllWithMember()라는 이름만 봐도 게시글과 회원을 함께 조회하는 메서드라는 것을 알 수 있습니다.
Fetch Join을 사용할 때 주의할 점
Fetch Join도 모든 상황에 무조건 적용하면 되는 기능은 아닙니다. 특히 @OneToMany 컬렉션을 Fetch Join할 때는 결과 행이 늘어날 수 있습니다.
예를 들어 게시글 하나에 댓글이 여러 개 있다면, 게시글과 댓글을 조인하는 순간 SQL 결과는 댓글 개수만큼 늘어납니다. JPA가 엔티티 중복을 어느 정도 정리해주더라도, 페이징과 함께 사용할 때는 의도와 다르게 동작할 수 있습니다.
@Query("select p from Post p join fetch p.comments")
List<Post> findAllWithComments();
단건 연관이나 @ManyToOne 방향에서는 Fetch Join이 비교적 단순하게 적용됩니다. 반면 컬렉션 연관관계에서는 중복, 페이징, 데이터 양을 함께 고려해야 합니다.
JPA N+1 문제 해결 방법 2: EntityGraph 사용
JPA N+1 문제를 해결하는 또 다른 방법은 @EntityGraph입니다. JPQL을 직접 작성하지 않고, 특정 연관 필드를 함께 조회하도록 지정할 수 있습니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = "member")
List<Post> findAll();
}
EntityGraph는 Spring Data JPA의 기본 메서드와 함께 사용하기 좋습니다. 단순한 조회에서 특정 연관관계를 함께 가져오고 싶을 때 코드가 짧아집니다.
다만 복잡한 조건, 조인 방식, 정렬, DTO 직접 조회까지 같이 고민해야 한다면 JPQL이나 QueryDSL을 사용하는 편이 더 명확할 수 있습니다. 팀에서 조회 로직을 어디까지 Repository 메서드 선언으로 처리할지 기준을 정해두면 코드 스타일이 혼동되지 않습니다.
JPA N+1 문제 해결 방법 3: DTO 직접 조회
Java JPA N+1 문제를 해결할 때 엔티티를 꼭 조회해야 하는지부터 다시 보는 것도 중요합니다. 화면이나 API 응답에 필요한 값이 정해져 있다면 DTO로 직접 조회하는 방식이 더 단순할 수 있습니다.
@Query("""
select new com.example.post.PostResponse(
p.id,
p.title,
m.name
)
from Post p
join p.member m
""")
List<PostResponse> findPostResponses();
이 방식은 필요한 컬럼만 조회할 수 있고, DTO 변환 과정에서 지연 로딩이 발생할 여지가 줄어듭니다. 특히 읽기 전용 목록 API에서는 엔티티를 조회한 뒤 다시 DTO로 변환하는 것보다 의도가 분명합니다.
다만 DTO 직접 조회를 과하게 사용하면 Repository 메서드가 화면 응답 구조에 강하게 묶일 수 있습니다. API별 응답이 자주 바뀌는 서비스라면 DTO 조회 쿼리도 함께 자주 수정될 수 있으므로, 조회 목적이 명확한 곳에 제한적으로 적용하는 편이 좋습니다.
해결 전후를 어떻게 검증했나
JPA N+1 문제를 해결했다고 판단하려면 코드 수정만으로 끝내면 안 됩니다. 실제 SQL 로그에서 쿼리 수가 줄었는지 확인해야 합니다.
제가 확인한 방식은 단순했습니다. 같은 조건으로 API를 호출하고, Hibernate SQL 로그에서 실행 쿼리 패턴을 비교했습니다.
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace
개발 환경에서는 위 설정으로 SQL과 바인딩 값을 확인할 수 있습니다. 운영 환경에서는 로그 양이 많아질 수 있으므로 그대로 켜두기보다, 필요한 환경과 시점에만 제한적으로 확인하는 것이 낫습니다.
해결 전에는 게시글 목록 조회 후 작성자 조회 쿼리가 게시글 수만큼 반복됐습니다. Fetch Join 적용 후에는 게시글과 작성자가 한 번의 조인 쿼리로 조회되는 것을 확인했습니다.
JPA N+1 문제를 예방하는 코드 작성 기준
JPA N+1 문제는 한 번 해결해도 비슷한 코드에서 다시 발생할 수 있습니다. 그래서 단순히 특정 Repository 메서드 하나를 수정하는 것보다, 팀 안에서 조회 기준을 맞춰두는 것이 중요합니다.
목록 API에서는 DTO 변환 코드를 유심히 봅니다
목록 조회 후 stream()이나 반복문 안에서 연관 엔티티에 접근하고 있다면 N+1 가능성을 먼저 의심합니다.
posts.stream()
.map(post -> post.getMember().getName())
.toList();
이런 코드는 문법적으로는 문제가 없습니다. 하지만 연관 데이터가 이미 로딩되어 있는지, 아니면 반복문 안에서 추가 조회가 발생하는지 확인해야 합니다.
기본 연관관계는 지연 로딩으로 둡니다
엔티티 매핑에서는 가능하면 지연 로딩을 기본으로 두는 편이 좋습니다. 조회 상황마다 필요한 연관 데이터가 다르기 때문입니다.
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
그리고 필요한 조회 메서드에서 Fetch Join, EntityGraph, DTO 조회 중 하나를 선택합니다. 이렇게 하면 엔티티 기본 설정과 API별 조회 요구사항이 분리됩니다.
Repository 메서드 이름에 조회 의도를 드러냅니다
단순히 findAll()이라는 이름으로 모든 조회를 처리하면 나중에 어떤 연관관계를 함께 가져오는지 파악하기 어렵습니다.
List<Post> findAllWithMember();
List<PostResponse> findPostResponses();
메서드 이름이 조금 길어지더라도 조회 의도가 드러나는 편이 좋습니다. 특히 JPA 성능 문제는 Repository 안쪽에서 발생하지만, 실제 문제는 서비스나 API 응답에서 발견되는 경우가 많습니다. 이름만 잘 지어도 추적 시간이 줄어듭니다.
Fetch Join, EntityGraph, DTO 조회 중 무엇을 선택할까
JPA N+1 문제를 해결하는 방법은 하나만 있는 것이 아닙니다. 상황에 따라 Fetch Join, EntityGraph, DTO 직접 조회를 나눠서 사용하는 편이 좋습니다.
| 방식 | 적합한 상황 | 주의할 점 |
|---|---|---|
| Fetch Join | 연관 엔티티를 함께 조회해야 하고 JPQL로 의도를 명확히 표현하고 싶을 때 | 컬렉션 조인과 페이징을 함께 사용할 때 주의가 필요합니다. |
| EntityGraph | 기본 Repository 메서드에 연관 조회만 추가하고 싶을 때 | 복잡한 조회 조건에서는 JPQL보다 의도가 덜 선명할 수 있습니다. |
| DTO 직접 조회 | 읽기 전용 API에서 필요한 값만 조회하고 싶을 때 | 응답 구조 변경이 잦으면 쿼리 수정도 함께 늘어날 수 있습니다. |
개인적으로는 단순한 @ManyToOne 연관 조회라면 Fetch Join이나 EntityGraph를 먼저 검토합니다. 목록 API에서 응답 필드가 명확하고 엔티티 변경이 필요 없다면 DTO 직접 조회도 좋은 선택입니다.
중요한 것은 N+1을 피하려고 모든 연관관계를 한 번에 다 가져오는 방향으로 가지 않는 것입니다. 필요한 화면, 필요한 API, 필요한 데이터 범위를 기준으로 조회 방식을 정해야 합니다.
JPA N+1 문제 정리
JPA N+1 문제는 Repository 호출 횟수만 보고는 알기 어렵습니다. 실제 SQL 로그를 확인해야 하고, 특히 목록 조회 후 반복문에서 연관 엔티티에 접근하는 코드를 주의해서 봐야 합니다.
해결 방법은 상황에 따라 다릅니다. Fetch Join은 조회 의도가 명확하고, EntityGraph는 간단한 연관 조회에 편합니다. DTO 직접 조회는 읽기 전용 API에서 필요한 값만 가져올 때 유용합니다.
가장 중요한 기준은 엔티티 매핑에 모든 조회 전략을 숨기지 않는 것입니다. 기본은 지연 로딩으로 두고, 필요한 조회에서만 명시적으로 함께 가져오는 구조가 유지보수에 좋습니다. JPA를 사용할 때 성능 문제를 줄이는 핵심은 기능을 많이 아는 것보다, 실제 SQL이 어떻게 실행되는지 확인하는 습관에 가깝습니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] Java Deadlock 발생했을 때 처리 전략: 원인 분석부터 재발 방지까지 (0) | 2026.05.19 |
|---|---|
| [JAVA] Java에서 Too many connections 발생했을 때 대응 방법 (0) | 2026.05.18 |
| [JAVA] Java Could not open JPA EntityManager 에러 해결 방법 (0) | 2026.05.17 |
| [JAVA] Java Spring Transaction silently rolled back 문제 원인 분석 (1) | 2026.05.16 |
| [JAVA] LazyInitializationException 실무에서 해결한 방법 (0) | 2026.05.15 |
