[프로젝트 회고] 7STARBALL Meeting 조회 성능 개선과 동시성 제어
들어가며
7STARBALL 프로젝트에서 Meeting 기능을 구현한 뒤 가장 크게 부딪힌 부분은 조회 성능과 동시성 문제였다.
모임 조회 API는 단순히 Meeting 테이블만 읽는 기능이 아니었다.
- 모임 기본 정보
- 호스트 정보
- 장소 정보
- 태그 목록
- 참여자 수
- 사용자의 참여 역할
이런 데이터를 함께 내려줘야 했다. 처음에는 JPA 연관관계를 따라가며 DTO를 만들었지만, 데이터가 늘어나자 쿼리가 예상보다 많이 발생했다. 전형적인 N+1 문제였다.
포트폴리오를 정리하면서 다시 수치를 보니, 개선 전에는 요청당 SQL 호출이 매우 많았고 응답 시간도 불안정했다. 이 문제를 해결하기 위해 Native Query도 검토했고, 최종적으로는 Map Batch 방식으로 조회 구조를 바꾸었다.
문제 상황
Meeting 목록 조회는 화면에서 자주 호출되는 API였다.
사용자가 첫 화면에서 모임 리스트를 보고, 상태별 모임을 확인하고, 자신이 참여한 모임을 확인해야 했기 때문이다.
문제는 정규화된 구조였다.
정규화 자체는 나쁜 것이 아니지만, 화면에 필요한 응답을 만들기 위해 여러 테이블의 데이터가 필요했다. Meeting을 하나씩 순회하면서 호스트, 장소, 태그, 참여자 수를 조회하면 데이터 개수만큼 추가 쿼리가 발생했다.
초기에는 기능이 동작하는 것에 집중했지만, 부하 테스트와 로그를 보면서 문제가 분명해졌다.
1
2
3
모임 목록 조회 한 번에 너무 많은 SQL이 발생한다.
데이터가 많아질수록 응답 시간이 빠르게 늘어난다.
DB 커넥션 사용량도 함께 증가한다.
이 문제를 그냥 두면 사용자가 늘어났을 때 가장 먼저 병목이 될 가능성이 높았다.
Native Query를 먼저 고민했다
처음 떠올린 방법은 Native Query였다.
필요한 테이블을 한 번에 조인해서 DTO에 맞는 데이터를 바로 가져오면 쿼리 수를 줄일 수 있을 것 같았다. 실제로 Native Query를 사용하면 조회 횟수를 줄이는 효과는 있었다.
하지만 고민이 생겼다.
- SQL이 너무 길어진다.
- 응답 요구사항이 바뀔 때마다 SQL 수정 부담이 커진다.
- 컬럼명이나 테이블 구조 변경에 취약하다.
- JPA를 쓰는 장점을 많이 잃는다.
성능은 좋아질 수 있지만, 유지보수 비용이 커질 수 있다는 생각이 들었다.
그때 멘토링과 팀 내 논의를 통해 Map Batch 방식으로 접근해보자는 방향을 잡았다.
Map Batch 방식으로 변경
Map Batch 방식의 핵심은 간단했다.
먼저 Meeting 목록을 조회하고, 그 목록에 포함된 ID들을 기준으로 필요한 데이터를 한 번씩 모아서 가져온다. 그다음 Java 코드에서 Map으로 묶어 DTO를 조립한다.
흐름은 대략 이랬다.
- Meeting 목록을 커서 기반으로 조회한다.
- Meeting ID 목록을 만든다.
- 호스트 정보를 batch 조회한다.
- 장소 정보를 batch 조회한다.
- 태그 목록을 batch 조회한다.
- 참여자 수를
meetingId기준으로 group by 조회한다. - 조회 결과를 Map으로 변환한다.
- Meeting을 순회하면서 Map에서 필요한 값을 꺼내 응답 DTO를 만든다.
이 방식은 처음에는 코드가 조금 길어 보였다.
하지만 쿼리 흐름이 명확했고, 각 데이터 조회가 분리되어 있어 테스트하거나 수정하기도 좋았다.
무엇보다 N+1 문제를 피하면서도 거대한 Native Query 하나에 모든 것을 몰아넣지 않아도 된다는 점이 좋았다.
참여자 수 조회 최적화
모임 목록에서 참여자 수는 중요한 정보였다.
처음에는 각 모임마다 참여자 수를 따로 세는 방식이었지만, 이 역시 N+1의 원인이 될 수 있었다. 그래서 ParticipantRepository에 여러 Meeting ID를 받아 한 번에 카운트하는 쿼리를 추가했다.
1
countByMeetingIdIn(List<Long> meetingIds)
이렇게 가져온 결과를 Map<Long, Long> 형태로 변환했다.
1
meetingId -> participantCount
이후 DTO를 만들 때는 현재 Meeting ID로 Map에서 참여자 수를 꺼내면 됐다. 단순하지만 효과가 컸다.
동시성 문제와 비관적 락
조회 성능과 별개로 모임 참여 기능에는 동시성 문제가 있었다.
예를 들어 정원이 10명인 모임에 동시에 여러 사용자가 참여 요청을 보냈을 때, 단순히 현재 참여자 수를 조회하고 저장하는 방식이라면 정원을 초과할 수 있다.
이 문제를 막기 위해 참여/취소 로직에서 Meeting을 조회할 때 비관적 락을 적용했다.
1
findByIdWithLock(Long meetingId)
참여 요청이 들어오면 락을 건 상태로 Meeting을 조회하고, 정원 검증과 참여자 추가를 하나의 트랜잭션 안에서 처리했다.
이후 테스트에서는 다음 상황을 확인했다.
- 정원 초과 시
409 CONFLICT발생 - 여러 스레드가 동시에 참여 요청을 보내도 정원을 넘지 않음
- 참여자 추가와 상태 변경이 하나의 흐름으로 처리됨
이 작업을 하면서 단순히 “기능이 된다”와 “동시에 요청이 와도 안전하다”는 완전히 다른 문제라는 걸 느꼈다.
상태 변경 스케줄러
Meeting은 시간이 지나면 상태가 바뀌어야 했다.
예를 들어 종료 시간이 지난 모임은 계속 ONGOING이나 RECRUITING 상태로 남아 있으면 안 된다. 그래서 관리자 수동 API와 스케줄러를 함께 구현했다.
/api/admin/meetings/complete-expired- 10분마다 만료된 미팅을 조회
- 완료되지 않은 모임을
COMPLETED로 변경
운영 환경에서는 이런 자동화가 중요하다고 느꼈다. 사용자가 직접 상태를 바꾸지 않아도, 시간이 지나면 시스템이 자연스럽게 정리해줘야 하기 때문이다.
배운 점
이번 작업에서 배운 것은 크게 세 가지였다.
첫 번째는 N+1 문제를 단순히 fetch join 하나로만 생각하면 안 된다는 점이다.
화면이 필요로 하는 데이터 구조가 복잡하면, 조회 전략도 그에 맞게 설계해야 한다.
두 번째는 Native Query와 JPA 사이에서 균형을 잡는 것이 중요하다는 점이다.
성능만 보면 Native Query가 좋아 보일 수 있지만, 유지보수와 변경 가능성을 함께 봐야 했다.
세 번째는 동시성 제어는 테스트로 확인해야 한다는 점이다.
코드만 봐서는 안전해 보여도, 동시에 요청이 들어오는 상황을 직접 검증하지 않으면 확신하기 어렵다.
마무리
Meeting 도메인은 이번 프로젝트에서 가장 많이 고민한 영역이었다.
처음에는 CRUD를 만드는 것만으로도 바빴지만, 후반으로 갈수록 성능, 동시성, 상태 관리, 테스트까지 보게 되었다.
이 과정을 통해 백엔드 개발은 단순히 API를 만드는 일이 아니라, 데이터가 안전하고 빠르게 흐르도록 구조를 설계하는 일이라는 생각이 더 강해졌다.
아직 더 개선할 점은 많지만, 이번 경험은 앞으로 JPA와 트랜잭션을 다룰 때 좋은 기준점이 될 것 같다.
Leave a comment