N+1 문제 해결로 응답속도 61% 개선! QueryDSL 성능 최적화 여정기

Jayson·2025년 7월 30일
0
post-thumbnail

N+1 문제 해결로 응답속도 61% 개선! QueryDSL 성능 최적화 여정기

  • 문제 인식: N+1 문제로 인한 심각한 성능 저하
  • 해결 과정: QueryDSL과 Tuple을 활용한 단일 쿼리 최적화
  • 성과 측정: JMeter로 입증한 평균 응답 속도 61.3% 단축, 처리량 153% 향상
  • 협업을 통한 성장: 동료의 리뷰로 완성도를 높이다

안녕하세요! 오늘은 많은 개발자들이 한 번쯤 마주치는 골칫거리, N+1 문제를 해결하며 공고 조회 API의 성능을 극적으로 개선한 경험을 공유하고자 합니다.

문제 인식: N+1 문제로 인한 심각한 성능 저하

저희 서비스의 홈 화면에는 사용자 맞춤형 인턴 공고를 보여주는 기능이 있습니다. 어느 날부터인가 데이터가 늘어남에 따라 이 API의 응답 속도가 눈에 띄게 느려지는 현상을 발견했습니다. 원인 파악에 착수한 결과, 코드의 특정 지점에서 N+1 문제가 발생하고 있음을 확인했습니다.

문제의 코드는 다음과 같았습니다.

기존 코드

List<HomeResponseDto> responseDtos = announcements.stream()
    .map(announcement -> {
        // 🚨 각 공고마다 스크랩 여부와 색상 정보를 개별 조회 (N+1 발생 지점)
        boolean isScrapped = scrapRepository.existsByInternshipAnnouncementIdAndUserId(announcement.getId(), userId);
        String color = scrapRepository.findColorByInternshipAnnouncementIdAndUserId(announcement.getId(), userId);
        return HomeResponseDto.of(announcement, isScrapped, color);
    })
    .toList();

announcements 리스트를 조회하는 1개의 쿼리 이후, 각 announcement 객체마다 스크랩 정보를 확인하기 위해 scrapRepository를 반복적으로 호출하고 있었습니다. 즉, 공고 100개를 조회하면 1 (공고) + 100 (스크랩) = 101번의 쿼리가 실행되는, 전형적인 N+1 문제였습니다. 데이터가 많아질수록 DB 호출 횟수가 기하급수적으로 증가하며 성능 저하를 일으키는 주범이었죠.

해결 과정: QueryDSL과 Tuple을 활용한 단일 쿼리 최적화

이 문제를 해결하기 위해 저희가 선택한 무기는 QueryDSL이었습니다. 여러 번의 DB 호출을 단 한 번의 복잡한 쿼리로 대체하는 전략을 세웠습니다.

핵심은 leftJoin과 QueryDSL의 Tuple을 활용하여, 공고 데이터와 해당 유저의 스크랩 정보를 한 번에 가져오는 것이었습니다.

개선 코드

// 1. 공고와 스크랩 정보를 한 번의 쿼리로 조회
List<Tuple> results = internshipRepository.findFilteredInternshipsWithScrapInfo(user, sortBy, startYear, startMonth);

List<HomeResponseDto> responseDtos = results.stream()
        .map(tuple -> {
            InternshipAnnouncement announcement = tuple.get(internshipAnnouncement);
            Long scrapId = tuple.get(scrap.id);
            String color = tuple.get(scrap.color.stringValue()); // color도 함께 조회

            boolean isScrapped = (scrapId != null); // 별도 쿼리 없이 스크랩 여부 판단
            return HomeResponseDto.of(announcement, isScrapped, color);
        })
        .toList();

findFilteredInternshipsWithScrapInfo 메서드 내부에서는 QueryDSL을 사용해 InternshipAnnouncementScrap 엔티티를 조인합니다. 이렇게 함으로써 단 한 번의 DB 호출로 필요한 모든 데이터를 가져올 수 있게 되었습니다. 불필요한 DB 접근이 사라지면서 성능 개선을 기대할 수 있었죠.

성과 측정: JMeter로 입증한 놀라운 변화

코드 수정만으로 만족할 순 없었습니다. 개선의 효과를 정량적으로 증명하기 위해 부하 테스트 도구인 JMeter를 활용해 리팩토링 전후의 성능을 비교 측정했습니다. 100명의 유저가 동시에 50,000개의 요청을 보내는 시나리오로 테스트를 진행했습니다.

성능 테스트 결과 비교

지표개선 전 (Before)개선 후 (After)개선율
평균 응답 속도 (Average)75ms29ms약 61.3% 단축 🚀
초당 처리량 (Throughput)1292.8/sec3272.5/sec약 153% 증가 (2.5배) 🔥
오류율 (Error %)0.20%0.20%변화 없음

결과는 놀라웠습니다. 평균 응답 속도는 약 61.3%나 단축되었고, 서버가 초당 처리할 수 있는 요청의 양은 2.5배 이상 증가했습니다. 숫자로 증명된 압도적인 성능 향상은 팀원 모두에게 큰 성취감을 안겨주었습니다.

협업을 통한 성장: 동료의 리뷰로 완성도를 높이다

여기서 끝이 아니었습니다. 코드 리뷰 과정에서 동료(정교)로부터 더 나은 코드를 위한 소중한 피드백을 받을 수 있었습니다.

주요 피드백 내용:

  1. "결과가 비어있을 경우, 명시적으로 빈 리스트를 반환하는 게 가독성에 좋을 것 같아요."
  2. "스크랩되지 않은 공고의 color 값은 명시적으로 null 처리하는 분기문이 필요해 보입니다."
  3. "stringValue()로 문자를 받아 변환하기보다, Enum 타입을 직접 받아 컴파일 타임에 타입을 체크하는 것이 런타임 오류를 줄일 수 있지 않을까요?"

동료의 날카로운 피드백 덕분에 놓치고 있던 예외 처리와 코드 안정성을 보강할 수 있었습니다. 혼자였다면 발견하지 못했을 부분들을 동료와의 건강한 토론을 통해 개선하며 코드의 완성도를 한 단계 더 높일 수 있었죠!

마무리하며

이번 리팩토링은 단순히 느린 API를 개선하는 작업을 넘어, N+1 문제의 심각성을 체감하고, QueryDSL을 활용한 효과적인 해결법을 익혔으며, JMeter를 통한 성능 측정의 중요성을 깨닫고, 동료와의 협업을 통해 더 나은 결과물을 만들어내는 값진 경험이었습니다.

작은 코드 변화가 서비스 전체의 안정성과 사용자 경험에 얼마나 큰 영향을 미치는지 다시 한번 느낄 수 있었습니다. 앞으로도 저희 Terning 팀은 끊임없이 코드의 건강 상태를 진단하고 개선하며 더 나은 서비스를 만들기 위해 노력하겠습니다. 긴 글 읽어주셔서 감사합니다! 😊

https://github.com/teamterning/Terning-Server/pull/176

profile
Small Big Cycle

0개의 댓글