BindProject

[Backend] 유저 프로필 모듈 구현4-1편 N+1 문제 완전 정복: 유저 프로필 조회 최적화 여정

dding-shark 2025. 6. 23. 21:37
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