안녕하세요! 오늘은 많은 개발자들이 한 번쯤 마주치는 골칫거리, N+1 문제를 해결하며 공고 조회 API의 성능을 극적으로 개선한 경험을 공유하고자 합니다.
저희 서비스의 홈 화면에는 사용자 맞춤형 인턴 공고를 보여주는 기능이 있습니다. 어느 날부터인가 데이터가 늘어남에 따라 이 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이었습니다. 여러 번의 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을 사용해 InternshipAnnouncement
와 Scrap
엔티티를 조인합니다. 이렇게 함으로써 단 한 번의 DB 호출로 필요한 모든 데이터를 가져올 수 있게 되었습니다. 불필요한 DB 접근이 사라지면서 성능 개선을 기대할 수 있었죠.
코드 수정만으로 만족할 순 없었습니다. 개선의 효과를 정량적으로 증명하기 위해 부하 테스트 도구인 JMeter를 활용해 리팩토링 전후의 성능을 비교 측정했습니다. 100명의 유저가 동시에 50,000개의 요청을 보내는 시나리오로 테스트를 진행했습니다.
성능 테스트 결과 비교
지표 | 개선 전 (Before) | 개선 후 (After) | 개선율 |
---|---|---|---|
평균 응답 속도 (Average) | 75ms | 29ms | 약 61.3% 단축 🚀 |
초당 처리량 (Throughput) | 1292.8/sec | 3272.5/sec | 약 153% 증가 (2.5배) 🔥 |
오류율 (Error %) | 0.20% | 0.20% | 변화 없음 |
결과는 놀라웠습니다. 평균 응답 속도는 약 61.3%나 단축되었고, 서버가 초당 처리할 수 있는 요청의 양은 2.5배 이상 증가했습니다. 숫자로 증명된 압도적인 성능 향상은 팀원 모두에게 큰 성취감을 안겨주었습니다.
여기서 끝이 아니었습니다. 코드 리뷰 과정에서 동료(정교)로부터 더 나은 코드를 위한 소중한 피드백을 받을 수 있었습니다.
주요 피드백 내용:
color
값은 명시적으로 null
처리하는 분기문이 필요해 보입니다."stringValue()
로 문자를 받아 변환하기보다, Enum 타입을 직접 받아 컴파일 타임에 타입을 체크하는 것이 런타임 오류를 줄일 수 있지 않을까요?"동료의 날카로운 피드백 덕분에 놓치고 있던 예외 처리와 코드 안정성을 보강할 수 있었습니다. 혼자였다면 발견하지 못했을 부분들을 동료와의 건강한 토론을 통해 개선하며 코드의 완성도를 한 단계 더 높일 수 있었죠!
이번 리팩토링은 단순히 느린 API를 개선하는 작업을 넘어, N+1 문제의 심각성을 체감하고, QueryDSL을 활용한 효과적인 해결법을 익혔으며, JMeter를 통한 성능 측정의 중요성을 깨닫고, 동료와의 협업을 통해 더 나은 결과물을 만들어내는 값진 경험이었습니다.
작은 코드 변화가 서비스 전체의 안정성과 사용자 경험에 얼마나 큰 영향을 미치는지 다시 한번 느낄 수 있었습니다. 앞으로도 저희 Terning 팀은 끊임없이 코드의 건강 상태를 진단하고 개선하며 더 나은 서비스를 만들기 위해 노력하겠습니다. 긴 글 읽어주셔서 감사합니다! 😊