배경 및 목표

DB CPU가 50~60%까지 상승하여 전체 서비스 응답에 영향을 주고 있었습니다. 모니터링 결과, 두 가지 원인이 확인되었습니다.

  • N건을 단건 쿼리로 조회 (루프 안에서 SELECT N번 실행)
  • 3초 주기 polling에서 불필요한 COUNT 쿼리가 매번 실행

개별 쿼리는 가벼워 보이지만, 동시 접속자가 많아지면 누적되어 심각한 부하를 만들고 있었습니다.

왜 중요했는가

개별 쿼리는 가벼워 보여 리뷰에서도 문제로 보이지 않지만, 3초 주기 polling과 동시 접속자 수가 곱해지면서 Reader DB CPU를 50~60%까지 사용하는 문제가 발생했습니다.

제약

제약선택지에 미친 영향
DB 부하를 줄이는 것이 목적연산을 DB에 일임하지 않거나 네트워크 왕복 횟수를 줄이도록 합니다.
BE 단독으로 해결SSE로 polling을 걷어내는 방안도 검토했지만 가장 간단하게 BE에서 처리할 수 있는 방법을 우선으로 하였습니다.
응답 본문 변경 불가API 응답 형식은 그대로 두고 내부 조회 방식만 바꿔야 했습니다

목표

  • 조회로 인한 Reader DB CPU 부하를 낮춰 전체 서비스 응답을 안정화합니다.
  • 동시 접속자가 늘어도 쿼리 수가 누적되지 않도록 조회 횟수를 최소화합니다.

맡은 역할과 판단

구분내용
담당 범위원인 분석부터 개선까지 단독으로 담당했습니다
직접 내린 판단1. 반복 단건 조회를 IN절 벌크 조회로 전환
2. 조합 위치를 DB가 아닌 애플리케이션 로직으로 변경하여 DB 부하 감소
3. polling 경로의 COUNT 쿼리를 이미 조회한 컬렉션 크기로 대체
협업, 합의응답 본문이 전혀 바뀌지 않아 프론트와 맞출 사항이 없었습니다.

해결 방법과 해결 후보군

1. IN절 벌크 조회 + 메모리 groupBy

N건 단건 쿼리를 1건의 IN절 벌크 쿼리로 변환했습니다.

// ❌ 기존: N번 단건 쿼리
memberIds.forEach { id ->
    val result = repository.findById(id)  // SELECT ... WHERE id = ?
}
 
// ✅ 개선: 1번 IN절 벌크
val results = repository.findAllByIdIn(memberIds)  // SELECT ... WHERE id IN (?, ?, ...)
val grouped = results.groupBy { it.memberId }      // 메모리에서 조합
후보설명채택 여부
DB에서 GROUP BY쿼리 한 번에 그룹핑까지❌ DB 부하를 줄이는 것이 목적인데 연산을 다시 DB에 부담
앱 메모리 groupBy벌크 조회 후 애플리케이션에서 조합✅ 이미 조회한 데이터를 가공하여 추가 DB 부하 0

DB 부하를 줄이는 것이 목적이었기 때문에, 이미 조회된 데이터를 애플리케이션 메모리에서 조합하는 방식을 채택했습니다.

2. COUNT 쿼리 제거

3초 주기 polling에서 진행률을 파악하기 위해 실행하던 COUNT 쿼리를 이미 조회한 컬렉션의 크기로 대체했습니다.

// ❌ 기존: 매 polling마다 COUNT 쿼리 실행
val totalCount = repository.count(condition)  // SELECT COUNT(*) ...
 
// ✅ 개선: 이미 조회된 데이터의 크기 활용
val totalCount = results.size

polling 주기가 3초로 짧았기 때문에 COUNT 쿼리가 생각보다 큰 누적 부하를 만들고 있었습니다.

결과

지표기존개선
쿼리 수201개3개 (98% 감소)
Reader CPU50~60%30~40%