안녕하세요~😄 오늘은 YOUTUBE 개발바닥
영상을 보던 중에 N + 1 문제라는 키워드가 환기되어 현재 진행 중인 프로젝트에서 N + 1 문제가 발생되고 있는지 확인해 보고 2가지 방법으로 해결해 보도록 하겠습니다.
가이드 && 환경
- 💡 SpringBoot 3.2.2 , SpringDataJPA, Hibernate
- 💡 Tag Entity, Category Entity, N+1 문제 확인
- 💡 해결방법 1. Fetch Join, 2. @EntityGraph 사용하기
- 💡 성능향상 점검
- 💡 결론
문제 인식
✅ 먼저 Postman을 사용하여 자주 사용하는 Rest API를 모두 호출해 보았습니다.
✅ 그중 사용빈도가 매우 높은 "모든 태그를 불러오는 기능"에서 Tag
, Category
두 엔티티의 연관관계에서 N + 1문제가 발생하는 것을 확인했습니다. /api/tags
는 메인페이지, 지도페이지, 독후감 작성 페이지에 사용되는 만큼 호출 빈도가 상당히 높기 때문에 바로 해결해 주도록 하겠습니다.
Tag Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Tag extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tag_id")
private Long id;
@Builder.Default
@OneToMany(mappedBy = "tag",cascade = CascadeType.ALL)
private List<Tagged> tags = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@Column(nullable = false, unique = true)
private String content;
}
Category Entity
@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Getter
public class Category extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private Long id;
@Builder.Default
@OneToMany(mappedBy = "category")
private List<Tag> tag = new ArrayList<>();
@Column(nullable = false, unique = true)
private String content;
}
N+1 문제 인식
💡 모든 태그(1)
를 호출했을 때 카테고리의 개수(N)
만큼 추가적으로 DB에 접근하는 것을 확인했습니다.
해결 방안
1. Fetch Join 사용하기
@Query("SELECT t FROM Tag t JOIN FETCH t.category")
List<Tag> findAllWithCategory();
Hibernate: select t1_0.tag_id,c1_0.category_id,c1_0.created_date,c1_0.name,c1_0.updated_date,t1_0.content,t1_0.created_date,t1_0.updated_date
from tag t1_0
join category c1_0 on c1_0.category_id=t1_0.category_id
💡 패치 조인(Fetch Join)은 JPA에서 연관된 엔티티나 컬렉션을 SQL 한 번의 조회로 함께 가져오는 기능입니다. 지연로딩(fetch = FetchType.LAZY)으로 인해 연관엔티티를 조회하지 않게 설정되어 있지만 N+1의 문제가 발생하였을 때 예외적으로 Fetch Join을 통하여 불필요한 DB접근을 줄여주는 데에 목적이 있습니다.
# JPQL
@Query("SELECT t FROM Tag t JOIN FETCH t.category")
# SQL
SELECT t.*, c.* FROM tag t INNER JOIN category c ON t.category_id = c.category_id;
✅ 위의 두 쿼리를 같은 동작은 하는 쿼리이며 이해를 돕기위해 시각화하여 보여드리겠습니다.
DB구현 초반에 INSERT한 데이터여서 날짜값은 없네요😅
2. @EntityGraph 사용하기
@EntityGraph(attributePaths = {"category"})
List<Tag> findAll();
Hibernate:
select t1_0.tag_id,c1_0.category_id,c1_0.created_date,c1_0.name,c1_0.updated_date,t1_0.content,t1_0.created_date,t1_0.updated_date
from tag t1_0
left join category c1_0 on c1_0.category_id=t1_0.category_id
💡 여기서 Fetch Join과 EntityGraph의 차이점을 알 수 있었는데요, FetchJoin은 기본적으로 Inner Join을 제공합니다. (Left FETCH를 통해 조정가능) 그에 반해 EntityGraph는 Left Outer Join을 기본으로 제공합니다.
성능 개선
N+1 문제시 성능
✅ 먼저 N + 1문제가 있는 상황에서의 Api 성능입니다.
📈 10번 정도 호출하여 중간값으로 가져왔습니다 평균적으로 35ms ~ 40ms
의 조회속도를 보였습니다.
Fetch Join 성능
✅ Fetch Join 24ms ~ 31ms
정도의 조회 성능을 보였습니다.
@EntityGraph 성능
✅ EntityGraph 23ms ~ 32ms
정도의 조회성능을 보였습니다.
결론
평균적인 조회를 기준으로 37ms -> 26ms
로 조회 성능이 개선된 것을 확인할 수 있었습니다.
( 37ms - 26ms ) / 26ms
로 계산하였을 때 대략 42.32%
의 성능이 개선되었습니다.
이번 사례에서는 카테고리가 단 세 개뿐이었기 때문에, 성능 개선의 효과를 체감하기 어려울 수도 있었습니다. 하지만, 데이터가 많아지거나 프로젝트 규모가 커질수록 N+1 문제
는 더욱 중요해질 것이라고 생각됩니다. 그렇기 때문에 앞으로 개발 과정에서 발생할 수 있는 문제를 미리 인식하고 체크하는 능력의 중요성을 인지하게되는 좋은 경험이었던 것 같습니다.
감사합니다😊
- TODO
배치사이즈 조정에 관한 공부
'Backend > 🌿Spring' 카테고리의 다른 글
[SpringBoot] Subscribe, Follow 기능 구현 & JPQL new operator 사용법 (1) | 2024.04.20 |
---|---|
[SpringBoot] Redis 캐싱을 통한 좋아요 조회 성능 비교 (0) | 2024.04.08 |
[SpringBoot] Docker를 통한 EC2환경 Redis 설치 & 테스트 통과하기 (0) | 2024.04.02 |
[SpringBoot] 좋아요 기능 구현 및 생각 (0) | 2024.03.27 |
[SpringBoot] 협업을 위한 Swagger 사용법 (0) | 2024.03.11 |