교내 물품 대여 서비스를 운영하면서 생겼던 N+1 문제를 해결하기 위해 고민했던 시간을 회고하려 글을 작성한다. 처음 시작했던 프로젝트라 정말 N+1이 뭔지도 모르고 막 코드를 작성하고 재밌어했었는데.. 하지만 문제가 생기고 해결해 나가는 과정이 진짜 유의미한 시간이라고 생각한다.
문제 상황
첫 번째로 확인한 N+1이 발생한 상황은 모든 동아리원 정보를 확인하는 관리자 페이지에서 간헐적으로 응답이 느려지는 현상이다. 관리자 페이지이고 전체 이용자 수가 500명 정도인 소규모 프로젝트여서 크게 체감되는 부분은 아니었지만 어떤 부분에서 오류가 발생하는지 확인하고 해결해보고자 한다.
// 모든 학생의 가입된 동아리 정보
public List<MemberJoinedClubDTO> findJoinedClubsForAllMember(){
List<Object[]> members = em.createQuery("SELECT m.id, m.name, m.studentId FROM Member m", Object[].class)
.getResultList();
return members.stream().map(member -> {
Long memberId = (Long) member[0];
String name = (String) member[1];
String studentId = (String) member[2];
List<String> clubs = em.createQuery(
"SELECT c.name FROM JoinClub jc JOIN jc.club c WHERE jc.member.id = :memberId", String.class)
.setParameter("memberId", memberId)
.getResultList();
return new MemberJoinedClubDTO(memberId, name, studentId, clubs);
}).toList();
}
문제 상황은 다음과 같다. 학생(Member)과 동아리(Club)의 연관 정보를 저장하는 JoinClub 테이블이 존재하고, 모든 학생의 가입된 동아리 정보를 가져오기 위해 JoinClub의 모든 데이터를 가져오려고 한다.
처음 코드를 작성할 땐 Data JPA를 사용할 줄 몰랐기에 직접 EntityManager로 쿼리를 작성했었다. 모든 학생의 가입된 동아리 정보를 조회하기 위해 모든 멤버에서 필요한 정보를 뽑고 멤버 id를 순회하며 멤버의 동아리를 모두 조회하는 메서드를 작성했었다. 그때는 이게 N+1인지 모르고 작성했었다... 이렇게 하는 것이 중복된 행 없이 조회하는 최선인 줄 알았지만... 이제 N+1 문제가 무엇인지 알았으니 개선해보자.
개선 방안으로는 다음과 같은 방법을 생각했다.
1. 멤버를 조회하여 모든 멤버의 id를 조회
2. 멤버 id를 모아 in 키워드를 사용해서 한 번의 쿼리로 중계 테이블에서 모든 동아리를 가져오기
DB 조회 단 두 번에 끝내는 것이 목표이다. 또한 불필요한 자료형 캐스팅을 막기 위해 DTO를 사용해서 조회하는 방안으로 수정할 예정이다.
또한 이런 방법 말고도 아래처럼 한 번에 필요한 모든 필드를 가져오는 방법이나 Fetch Join, EntityGraph로 연관를 한 번에 가져오는 방법도 존재한다. 하지만 멤버를 기준으로 페이지네이션하기 어려워지기 때문에 이번엔 in을 사용해서 가져오기로 결정했다.
SELECT m.id, m.name, m.studentId, c.name
FROM Member m
LEFT JOIN JoinClub jc ON jc.member = m
LEFT JOIN jc.club c
* 여기서 JoinClub은 ON으로 직접 조인하고 Club은 jc.club만 한 이유는 club은 엔티티의 연관 관계를 타고 조인했기 때문에 매핑 조건이 필요하지 않다. JPA가 내부적으로 자동으로 ON jc.club.id = c.id 와 같은 조건을 만들어준다.
코드 수정
먼저 Repository 계층이다. 멤버를 조회하여 모든 멤버의 id를 조회하는 메서드, 멤버 id들로 모든 클럽의 이름을 조회하는 메서드로 분리하였다. 추가로 보기 편하도록 이름순 정렬도 넣었다.
public List<MemberRow> findAllMemberRow() {
return em.createQuery("""
select new MemberRow(m.id, m.name, m.studentId)
from Member m
order by m.name asc
""", MemberRow.class).getResultList();
}
public List<ClubRow> findAllClubRow(List<Long> ids) {
return em.createQuery("""
select new ClubRow(jc.member.id, jc.club.name)
from JoinClub jc
join jc.club c
where jc.member.id in :ids
order by jc.member.id, c.name asc
""", ClubRow.class)
.setParameter("ids", ids)
.getResultList();
}
이후 Service 계층에서 두 메서드를 이용하여 반환형에 맞게 멤버 하나당 동아리 이름을 리스트로 묶어주었다.
@Transactional(readOnly = true)
public List<MemberJoinedClubDTO> findAllJoinedClubs() {
List<MemberRow> members = joinClubRepository.findAllMemberRow();
if (members.isEmpty()) return List.of();
List<Long> ids = members.stream()
.map(MemberRow::id)
.toList();
List<ClubRow> clubRows = joinClubRepository.findAllClubRow(ids);
Map<Long, List<String>> clubsByMember = new HashMap<>();
for (Long id : ids) clubsByMember.put(id, new ArrayList<>());
for (ClubRow r : clubRows) {
clubsByMember.get(r.memberId()).add(r.clubName());
}
return members.stream()
.map(m -> new MemberJoinedClubDTO(
m.id(), m.name(), m.studentId(),
clubsByMember.getOrDefault(m.id(), List.of())
))
.toList();
}
멤버의 id가 비어있는 값이 들어오게 된다면 MySQL 문법상 오류가 나기 때문에 먼저 처리해주었다.
기존에는 api를 요청하면 동아리원의 수만큼 추가적인 쿼리가 발생했지만 이제 단 두번의 쿼리문으로 모든 동아리원의 동아리가 조회된다!


오른쪽 쿼리는 멤버 id가 where 절에 in으로 묶여 한 번에 쿼리로 해결되는 것을 볼 수 있다. 전체 JoinClub 테이블의 row 수는 약 1000개 정도이고 Member 테이블의 row 수는 약 500개 정도이다. 이로써 줄어드는 쿼리의 양은 499개로 엄청난 단축이라고 볼 수 있다. 가벼운 쿼리라도 DB 연결은 네트워크를 사용하고 왕복 누적으로 인한 지연이 크기 때문에 N+1 문제는 일어나지 않도록 설계하는 것이 중요하다.


로컬에서 nGrinder로 테스트 해본 결과 평균적인 성능이 향상된 것을 확인할 수 있다!
혹시 틀리거나 수정할 내용이 있다면 언제든 댓글로 피드백 주세요!!
'Computer Science > Spring Boot' 카테고리의 다른 글
| Spring Security - 인증/인가와 FilterChain (w. JWT, Authorization) (0) | 2025.09.12 |
|---|---|
| 재고 관리에서 발생한 동시성 문제 해결 (0) | 2025.09.03 |
| JPA N+1 문제 (0) | 2025.09.02 |
| Spring MVC와 Servlet (0) | 2025.08.29 |
| 스프링 컨테이너와 빈(Bean) - Lifecycle Callback과 Scope (0) | 2025.08.24 |