엔티티를 직접 노출하지 마라
@GetMapping("/api/v1/members")
public List<Member> membersV1() {
return memberService.findMembers();
}
위의 코드처럼 엔티티를 직접적으로 노출하는 것은 많은 문제를 발생시킬 수 있다. 엔티티를 외부에 바로 노출시킬 경우, 엔티티에 변경이 생기면 api의 스펙 자체가 모두 변경이 되고 이는 곧 오류 발생의 원인이 된다. 즉, 화면에 종속적인 api가 만들어지는 것이다. 때문에 엔티티를 바로 노출시키는 것보다는 dto를 활용하여 필요한 것만 노출시켜야 한다.
xToOne 관계에서의 성능 최적화
순환 참조
양방향 연관관계에서 엔티티를 직접 노출하게 되면 순환 참조 문제가 발생할 수 있다. 순환 참조란 무엇일까?
순환 참조란, 참조하는 대상이 서로 물려 있어 무한으로 참조하는 현상을 말한다.
JPA에서 양방향으로 연결된 엔티티를 그대로 조회하는 경우, 서로의 정보를 무한히 반복적으로 조회하며 stack overflow 에러가 발생하는 순환 참조 문제가 발생할 수 있다. 아래 코드를 살펴보자.
// Blog 클래스
public class Blog {
...
@Builder.Default
@OneToMany(mappedBy = "blog",fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
}
// Post 클래스
public class Post {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false)
private Blog blog;
}
현재 Blog와 Post 엔티티의 관계는 양방향으로 매핑된 일대다 관계다. 여기서 Blog 엔티티를 그대로 조회하여 반환한다면 어떻게 될까?
public List<Blog> findAll() {
return em.createQuery("SELECT b FROM Blog b LEFT JOIN FETCH b.posts", Blog.class)
.getResultList();
}
// controller
@GetMapping("/api/v1/blogs")
public List<Blog> ordersV1() {
List<Blog> all = blogRepository.findAll();
return all;
}
Blog 엔티티가 Post 엔티티를 참조하고, Post 엔티티가 다시 Blog 엔티티를 참조하는 순환 관계에 의해 무한 루프에 빠지게 되어 StackOverflow가 발생하게 된다. 정리하면 Spring Boot가 JSON으로 객체를 직렬화할 때, 해당 객체가 다른 객체를 참조하고 있고 그 참조된 객체 또한 다시 해당 객체를 참고하고 있어 무한히 객체를 참조하여 StackOverflow가 발생하는 것을 순환참조라 한다. 이러한 문제를 어떻게 해결할 수 있을까?
@JsonIgnore 어노테이션 사용
순환참조 문제를 해결할 수 있는 첫번째 방법으로 @JsonIgnore 어노테이션을 사용할 수 있다. 해당 어노테이션을 양방향 연관관계가 설정된 필드 위에 사용하면, Json 데이터에 포함되지 않게 된다. 이는 한쪽 방향의 관계를 끊는 것으로써 순환 참조 문제를 해결할 수 있다.
DTO 사용
가장 추천하는 해결 방법은 Dto를 사용하는 것이다. 순환 참조가 발생하게 된 주된 원인은 ‘양방향 매핑’ 이기도 하지만, 정확하게는 엔티티 자체를 response로 노출시킨 것에 있다. 애초에 순환참조가 발생하는 시점은 엔티티 객체들이 메모리에 로드되는 시점이 아닌, 해당 객체들이 JSON으로 직렬화될 때 발생한다. 즉, Java 객체를 JSON 형식으로 변환하는 Jackson 라이브러리가 엔티티를 JSON 형태로 변환하는 과정에서 무한루프에 빠져 발생하는 것이 순환 참조이다. 더하여 이전 섹션부터 강조했듯이 엔티티 자체를 반환하는 것은 굉장히 위험하다. 때문에 엔티티를 직접 반환하는 것이 아닌 데이터를 운반하는 Dto 객체를 만들어 필요한 데이터만 옮겨담아 반환하면 순환 참조 문제를 방지할 수 있다. 즉, Dto를 사용하여 순환되는 항목을 포함하지 않아 엔티티에 대한 의존을 끊음으로써 해결할 수 있다.
JPA N + 1 문제
프록시 객체 (Proxy Object)
🍀 프록시 객체는 엔티티의 실제 데이터를 데이터베이스에서 가져오는 시점을 지연시키기 위해 원본(타겟) 객체를 대신해서 호출될 가짜 객체이다.
프록시 객체는 클라이언트 코드와 실제 데이터베이스에서 로드된 엔티티 객체(타겟 객체) 사이에 위치하기 때문에 클라이언트는 실제 엔티티 객체에 직접 접근하지 않고, 프록시 객체를 통해 간접적으로 접근하게 된다. 쉽게 비유하자면 타겟 객체를 집 주인이라고 생각했을 때, 프록시 객체는 집 주인을 대신해서 계약을 요청받는 중개인이다.
지연 로딩 (Lazy Loading)
지연 로딩은 엔티티가 로드될 때, 연관된 엔티티를 즉시 로드하지 않고 필요한 시점에 연관된 객체의 데이터를 로드하는 방식이다. @OneToMany 랑 @ManyToMany 는 기본 설정이 지연로딩이라고 한다.
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정 방법
지연 로딩 방식에서는 연관된 엔티티 데이터는 실제로 접근할 때까지 로드되지 않는다. 즉, 클라이언트 코드가 객체의 메서드를 호출해야 비로소 프록시 객체는 그 순간 데이터베이스에 접근하여 실제 데이터를 로드하게 된다.
public Blog getBlogByPostId(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new NotFoundException("Post not found"));
Blog blog = post.getBlog();
return blog;
}
Post 엔티티 내에서 Blog에 대한 접근은 FetchType.LAZY 즉, 지연 로딩으로 설정되어 있기 때문에 Post 객체만 먼저 로드되고 Blog에 대한 프록시 객체가 생성되게 된다. 그 후, getter 함수를 통해 post.getBlog() 를 호출하면, 프록시 객체는 실제 데이터가 필요한 시점이기 때문에 비로소 데이터베이스에 접근하여 Blog 데이터를 로드하게 되는 것이다.
즉시 로딩 (Eager Loading)
즉시 로딩이란 말 그대로 데이터를 조회할 때, 연관된 모든 객체의 데이터까지 한 번에 불러오는 방식이다. @ManyToOne 랑 @OneToOne 는 기본 설정이 즉시 로딩이다.
@OneToMany(fetch = FetchType.EAGER) // 즉시 로딩 설정 방법
아까의 코드에서 만약 Post 엔티티와 Blog 엔티티가 FetchType.EAGER 즉, 즉시 로딩으로 설정되어 있다면 어 떻게 될까? postRepository.findById(postId) 메서드를 통해 Post 엔티티를 조회할 때 즉시 Post 데이터와 연관된 Blog 데이터가 함께 로드된다. 프록시 객체의 생성 없이 별도의 쿼리를 통해 바로 Blog 엔티티에 접근하는 것이다!
이처럼 즉시 로딩 방식은 지연 로딩 방식에 비해 연관된 데이터가 필요한 작업에서 빠르게 처리를 할 수 있다는 장점이 있다. 하지만!!! 특히 실무에서는 즉시 로딩을 가급적 사용하지 않는다고 한다. 즉시 로딩은 한번에 연관된 모든 정보를 가져오기 때문에 성능 튜닝이 어렵기 때문이다.
JPA N+1 문제란?
JPA N+1 문제란 데이터를 조회할 때, 1개의 쿼리로 요청이 처리할 것으로 기대했으나 의도하지 않은 N개의 쿼리가 추가적으로 더 발생하는 현상을 말한다.
public void getAllBlogTitleByPostId(Long postId) {
List<Post> posts = postRepository.findByPostId(postId);
for (Post post : posts) {
System.out.println(post.getBlog().getTitle());
}
}
만약 즉시로딩 관계라면, 해당 메소드를 실행시키면 다음 순서에 따라 쿼리문이 발생하게 된다.
- 주어진 postId에 대응하는 모든 Post 객체들을 데이터베이스로부터 로드하는 쿼리를 발생시킨다. 이때 findByPostId 와 같이 JPQL으로 엔티티를 조회할 경우, Fetch 전략을 무시하고 SQL문을 실행하게 된다.
- 먼저 조회한 Post 엔티티에 연관관계가 설정된 Blog 엔티티가 존재하고 즉시로딩 관계이기 때문에 즉시 Blog를 로드하는 추가 쿼리가 실행된다. (N번의 쿼리 발생)
이처럼 즉시로딩 관계에서는 Post를 조회하는 1개의 쿼리를 기대했으나 의도하지 않은 N개의 쿼리가 추가적으로 더 발생하는 N+1문제가 발생하게 된다.
그렇다면 지연로딩 관계에서는 N+1문제가 발생하지 않을까? 다시 위의 코드를 지연로딩 관계라고 생각하고 차근차근 살펴보자.
- 주어진 postId에 대응하는 모든 Post 객체들을 데이터베이스로부터 로드하는 쿼리를 발생시킨다. (이때 Post와 Blog는 지연로딩 관계이기 때문에 Blog 객체는 즉시 로드되지 않고 프록시 객체로 존재합니다.)
- 리스트에서 각 Post 객체에 대해 post.getBlog().getTitle() 메서드를 호출할 때마다, 각각의 Post 객체에 대해 개별적으로 Blog를 로드하는 추가 쿼리가 실행된다. (N번의 쿼리 발생)
결과적으로 지연로딩 관계에서도 마찬가지로 첫번째 Post 객체를 로드하는 쿼리 1개와 각 Post 객체의 Blog를 로드하는 추가적인 쿼리 N개(각 Post 마다 1개)가 발생하게 되어 총 N+1회의 쿼리가 발생하게 된다.
Fetch Join
대용량의 데이터가 존재할 때, JPA N+1문제가 발생한다면 엄청나게 많은 쿼리문이 실행되게 될 것이고 이는 심각한 문제를 유발하게 된다. 그럼 해당 문제를 어떻게 해결할 수 있을까?
바로 Fetch Join을 이용하여 해결할 수 있다.
🍀 Fetch Join은 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능이다.
즉, Fetch Join은 조회의 주체가 되는 엔티티와 그 관련 엔티티들까지 함께 조회하기 때문에 한 번의 쿼리로 필요한 정보를 모두 가져올 수 있게 된다.
Join과의 차이점
일반적인 Join문은 Fetch Join과 다르게 영속성 컨텍스트에서 조회의 주체가 되는 엔티티만 불러오게 된다. 즉, JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화한다. 때문에 영속성 컨텍스트에 조회하고자 하는 연관된 엔티티의 정보가 존재하지 않아 LazyInitializationException 예외가 발생한다. 정리하면 일반 join은 실제 쿼리에 join을 걸어주기는 하지만 join 대상에 대한 영속성까지는 관여하지 않는다. 따라서 일반 Join문은 실제로 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 사용된다. 반면, Fetch Join은 join 대상의 모든 연관 엔티티를 영속성 컨텍스트에 저장한다. 즉, Fetch Join으로 걸린 엔티티를 모두 영속화하기 때문에 지연로딩인 엔티티를 참조하더라도 이미 영속성 컨텍스트에 들어있어 따로 쿼리문이 실행되지 않고 영속성 컨텍스트에서 조회하여 N+1문제가 해결된다.
쿼리 방식 선택 권장 순서
- 엔티티 자체를 반환하는 것이 아니라 DTO로 변환하여 반환한다.
- JPA N+1 문제가 발생할 것이라 예측되면 Fetch Join으로 성능을 최적화한다.
- 2번의 경우로 대부분의 성능 이슈가 해결되지만, 더 최적화를 시키고 싶다면 DTO로 직접 조회하는 방법을 사용한다.
- JPA가 제공하는 네이티브 SQL문이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
'JPA' 카테고리의 다른 글
실전! 스프링 데이터 JPA - 섹션 1~5 (0) | 2025.03.17 |
---|---|
실전! 스프링 부트와 JPA 활용 2 - 섹션 5~6 (0) | 2025.03.17 |
실전! 스프링 부트와 JPA 활용1 - 섹션 3~7 (0) | 2025.03.17 |
실전! 스프링 부트와 JPA 활용1 - 섹션 1~2 (0) | 2025.03.17 |
자바 ORM 표준 JPA 프로그래밍 - 섹션 10~11 (0) | 2025.03.17 |