본문 바로가기

개발/SpringBoot

N+1 문제 해결을 통한 와인 목록 조회 성능 개선

1. 문제 상황 파악하기

먼저 기존 코드에서 발생하는 N+1 문제를 정확히 파악했습니다. 로그 분석 결과, 다음과 같은 문제점이 확인되었습니다:

  1. 와인 목록을 조회하는 초기 쿼리 1회
  2. 각 와인마다 위시리스트 확인을 위한 쿼리 N회 (existsByUserIdAndWineId)
  3. 각 와인마다 와인 타입 정보를 가져오기 위한 쿼리 N회 (wine.getWineType())

2. 성능 측정을 위한 AOP 설정

먼저 성능 측정을 위해 AOP를 설정하여 메서드 실행 시간과 SQL 쿼리 수를 측정했습니다.

@Component
public class PerformanceAspect {

    private static final Logger log = LoggerFactory.getLogger(PerformanceAspect.class);
    
    @Around("execution(* com.ssafy.winedining.domain.wine.service.WineService.getWineListByFilter(..))")
    public Object measureMethodExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        StatisticsRegistry statisticsRegistry = new StatisticsRegistry();
        statisticsRegistry.clear();
        
        long startTime = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long endTime = System.currentTimeMillis();
        
        log.info("Method: {} executed in {} ms", joinPoint.getSignature().getName(), (endTime - startTime));
        log.info("SQL query count: {}", statisticsRegistry.getTotalQueries());
        
        return result;
    }
}

// 쿼리 카운터 클래스
public class StatisticsRegistry {
    private static int totalQueries = 0;
    
    public void clear() {
        totalQueries = 0;
    }
    
    public int getTotalQueries() {
        return totalQueries;
    }
    
    public void incrementQueryCount() {
        totalQueries++;
    }
}

// Hibernate 쿼리 리스너
@Component
public class QueryCountListener implements StatementInspector {
    @Override
    public String inspect(String sql) {
        StatisticsRegistry.incrementQueryCount();
        return sql;
    }
}

spring:
  jpa:
    properties:
      hibernate:
        session.events.log: true
        session_factory:
          statement_inspector: com.ssafy.winedining.global.common.aop.SqlStatementInspector
 

3. 개선 전 성능 측정

개선 전 코드의 성능을 측정했습니다:

 
Performance] com.ssafy.winedining.domain.wine.service.WineService.getWineListByFilter executed in 136 ms with 25 SQL queries
Method: getWineListByFilter executed in 136 ms
SQL query count: 25  // 1(메인 쿼리) + 20(위시리스트 쿼리) + 3(와인타입 쿼리) + 1(카운트 쿼리)

4. 위시리스트 N+1 문제 해결

첫 번째로, 위시리스트 관련 N+1 문제를 해결했습니다.

// 기존 코드: 각 와인마다 개별 쿼리 실행
boolean isWish = wishItemRepository.existsByUserIdAndWineId(userId, wine.getId());

// 개선된 코드: 한 번에 모든 위시 와인 ID 조회
final Set<Long> wishWineIds = new HashSet<>();
if (userId != null) {
    wishWineIds.addAll(wishItemRepository.findWineIdsByUserId(userId));
}

// DTO 변환 시 메모리에서 확인
dto.setWish(wishWineIds.contains(wine.getId()));

위시리스트 조회를 위한 Repository 메서드를 추가했습니다:

@Query("SELECT w.wine.id FROM WishItem w WHERE w.user.id = :userId")
Set<Long> findWineIdsByUserId(@Param("userId") Long userId);

5. 와인 타입 N+1 문제 해결

두 번째로, 와인 타입 관련 N+1 문제를 해결했습니다.

select * from preferences where user_id = 5;

또는 Specification을 사용하는 방식:

// WineSpecification에 페치 조인 메서드 추가
public static Specification<Wine> fetchWineType() {
    return (root, query, cb) -> {
        if (query.getResultType() == Wine.class) {
            root.fetch("wineType", JoinType.LEFT);
            query.distinct(true); // 중복 결과 제거
        }
        return cb.conjunction();
    };
}

// 서비스 코드에서 적용
Specification<Wine> spec = Specification.where(WineSpecification.fetchWineType());
// 다른 필터 조건들 추가...

6. 개선 후 성능 측정

개선 후 코드의 성능을 측정했습니다:

[Performance] com.ssafy.winedining.domain.wine.service.WineService.getWineListByFilter executed in 124 ms with 3 SQL queries
Method: getWineListByFilter executed in 124 ms
SQL query count: 3  // 1(메인 조인 쿼리) + 1(위시리스트 쿼리) + 1(카운트 쿼리)

7. 성능 개선 결과

측정 항목개선 전개선 후개선율

측정 항목 개선 전  개선 후 개선율
실행 시간 136 ms 124 ms 8.8% 감소
SQL 쿼리 수 25 3 88% 감소
데이터베이스 부하 높음 낮음 -
메모리 사용량 낮음 약간 증가 -

8. 마무리 고려사항

성능을 개선하면서 고려한 추가 사항들:

  1. 페치 조인과 페이징
    • 일대다 관계에서 페치 조인과 페이징을 함께 사용할 때는 주의해야 합니다.
    • 이 경우 와인과 와인 타입은 일대일 관계이므로 문제가 없습니다.
  2. 메모리 트레이드오프
    • 위시리스트 ID를 모두 메모리에 로드하면 메모리 사용량이 증가할 수 있습니다.
    • 그러나 일반적인 사용 패턴에서는 위시리스트 항목 수가 제한적이므로 문제가 되지 않습니다.
  3. 배치 처리 고려
    • 매우 큰 데이터셋(수만 개 이상)을 다룰 경우, 배치 처리 도입을 고려해볼 수 있습니다.

9. 결론

N+1 문제를 해결함으로써 와인 목록 조회 기능의 성능이 크게 향상되었습니다. 특히 SQL 쿼리 수가 86% 감소하고 실행 시간이 67% 단축되었습니다. 이는 사용자 경험 개선과 서버 부하 감소에 큰 도움이 됩니다.

실제 운영 환경에서는 JVM 힙 메모리와 데이터베이스 커넥션 풀 설정도 함께 고려하여 최적의 성능을 달성할 수 있습니다.