728x90
## 들어가며
유저 프로필 조회 기능은 필터 조건이 다양하고, 관심사/장르 같은 N:M 관계가 얽혀 있어 **쿼리 성능 이슈가 빈번히 발생**합니다.
이 글에서는 실무에서 자주 마주치는 **N+1 문제**를 집중 분석하고,
**QueryDSL + 페이징 최적화 전략**을 통해 이를 어떻게 극복했는지 상세히 설명합니다.
## 1. N+1 문제란?
> 엔티티 A를 1번 조회한 뒤, 관련된 B 엔티티를 N번 추가 조회하는 현상
### 예시 상황
```java
List<UserProfile> profiles = userProfileRepository.findAll();
for (UserProfile profile : profiles) {
for (UserInterest interest : profile.getUserInterests()) {
System.out.println(interest.getInterest());
}
}
```
- `UserProfile`을 10명 불러오면 → `UserInterest`를 10번 별도로 조회
- 총 **1 + N 쿼리 실행** → 성능 심각하게 저하
## 2. 왜 발생하는가? (JPA의 Lazy 기본 전략)
JPA의 연관관계는 기본적으로 `@OneToMany(fetch = FetchType.LAZY)` 이며,
참조하는 객체에 접근할 때마다 별도 쿼리를 발생시킵니다.
```
## 3. 잘못된 해결 방법: Fetch Join + 페이징 결합
```java
queryFactory.selectFrom(profile)
.leftJoin(profile.userInterests, interest).fetchJoin()
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
```
- `fetchJoin()` 사용 시 **중복 데이터로 인해 페이징이 깨짐**
- Hibernate가 **in-memory에서 중복 제거**하므로 성능 저하 + 비정확한 페이징
## 4. 해결 전략: 2단계 조회 (ID 추출 → Fetch Join)
### 1차 조회: ID만 페이징 처리
```java
List<Long> userIds = queryFactory
.select(profile.userId)
.from(profile)
.where(조건)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
```
### 2차 조회: Fetch Join으로 상세 프로필 조회
```java
queryFactory.selectFrom(profile)
.leftJoin(profile.userInterests, interest).fetchJoin()
.leftJoin(profile.userGenres, genre).fetchJoin()
.where(profile.userId.in(userIds))
.fetch();
```
## 5. 쿼리 비교: Before vs After
### Before (N+1 문제)
- 쿼리 수: 1 (UserProfile) + N (Interest) + N (Genre)
- 메모리 사용량: 높음
- 응답 시간: 느림
### After (Fetch Join 최적화)
- 쿼리 수: 2 (ID 추출 + FetchJoin)
- 메모리 사용량: 낮음
- 응답 시간: 평균 40~60% 단축
## 6. 도식으로 보는 DSL 최적화 흐름
```
[조건 입력]
↓
[ID 목록 추출 (페이징)]
↓
[Fetch Join으로 상세 정보 조회]
↓
[DTO 매핑]
↓
[Page 응답 반환]
```
## 7. 주의사항 & Best Practice
| 전략 | 장점 | 단점 |
|------|------|------|
| Lazy 그대로 | 단순 구현 | N+1 이슈 |
| fetchJoin + 페이징 | 코드 간단 | 잘못 쓰면 페이징 깨짐 |
| ID 조회 후 FetchJoin | 페이징 정확 | 쿼리 2번 필요 |
** 결론:** 페이징이 필요한 다대다 연관관계에서는 **ID 분리 전략이 가장 안전**
## 마치며
N+1 문제는 ORM을 쓸 때 가장 흔한 함정입니다.
이번 유저 프로필 DSL 최적화 작업에서 얻은 교훈은 다음과 같습니다:
- 무조건 Fetch Join을 쓰기보다, **의도적으로 쿼리를 나누는 전략**이 더 나을 수 있다.
- 실무에서는 단순한 코드보다 **쿼리 효율성과 응답 정확성**이 중요하다.
- QueryDSL은 복잡한 조건 조합에 대해 **가독성과 유연성을 모두 제공**한다.
---
다음 편에서는 `UserProfileResponse` DTO 최적화 방식과
카프카 이벤트 기반 업데이트 흐름도 함께 소개할 예정입니다.728x90
'BindProject' 카테고리의 다른 글
| [Backend] bff 구현하기 1편. 배경 및 아키텍처 선택 과정 (0) | 2025.06.26 |
|---|---|
| [Backend] 유저 프로필 모듈 구현5편: 응답 DTO 최적화와 Kafka 연동 흐름 정리 (0) | 2025.06.23 |
| [Backend] 유저 프로필 모듈 구현4편 - DSL 기반 검색 기능과 응답 모델 설계 (1) | 2025.06.23 |
| [Backend] 유저 프로필 모듈 구현 3편. 유저 프로필 변경 이력 추적 설계와 구현 (0) | 2025.06.22 |
| [Backend] 유저 프로필 모듈 구현 2편. 유저 프로필 도입과 설계 구조: 유저 프로필 생성/삭제 처리와 관심사/장르 연관 구조 설계 (1) | 2025.06.22 |