배경 및 목표

공채 시스템을 도입하면서 한 공고에 10,000명 이상의 지원자가 동시에 조회되는 상황이 생겼습니다. 테스트 시 지원자 목록을 열었을 때 응답이 27초까지 걸렸습니다.

  • 지원자당 약 10개의 부가정보 테이블을 필터 존재 여부와 관계없이 무조건 LEFT JOIN
  • 곱연산이 발생하여 데이터량이 기하급수적으로 증가
  • DB CPU 100%까지 치솟아 전체 서비스에 영향

목표

  • 공채 시 10,000명 지원자 조회를 안정적인 응답속도로 처리한다.
  • scale up 없이 쿼리 구조 개선으로 근본 해결한다.

해결 방법과 해결 후보군

1. 인프라 증설이 아닌 쿼리 구조 개선 선택

EXPLAIN 분석 결과, 병목의 원인이 쿼리 구조 자체에 있었습니다. mysql을 scale up하더라도 데이터가 늘어나면 같은 문제가 재발할 것이므로, 근본적으로 쿼리 구조를 바꾸는 방향을 선택했습니다. 캐싱도 검토했으나, 지원자 데이터의 실시간 변경 특성상 캐시 무효화가 빈번하여 적합하지 않았습니다.

2. 동적 JOIN 쿼리 구현 (QueryDSL)

필터 파라미터가 존재할 때만 해당 테이블을 JOIN하도록 변경했습니다. 기존에는 10개 테이블을 항상 JOIN했지만, 필터가 없으면 JOIN 0개, 필터 1개면 JOIN 1개만 수행하도록 QueryDSL 기반으로 동적 쿼리를 구성했습니다.

-- ✅ 기본 쿼리 (필터 없음): JOIN 0개
SELECT * FROM 지원자 LIMIT 0, 100
 
-- ✅ 부가정보 1 필터만 있을 때: JOIN 1개만
SELECT * FROM 지원자
LEFT JOIN 부가정보_1 ON 부가정보_1.지원자id = 지원자.id
WHERE 부가정보_1.조건 = ?
LIMIT 0, 100

QueryDSL에서는 필터 파라미터의 존재 여부를 확인하여 조건적으로 JOIN을 추가하는 applyFilter 함수를 구현했습니다.

fun <T> JPAQuery<T>.applyFilter(filter: Filter?): JPAQuery<T> {
    if (filter == null) return this
    this.applyJoin(filter)         // 1단계: 필터 조건에 따른 선택적 JOIN
    this.conditionForA(filter.a)   // 2단계: 각 필터 조건 적용
    this.conditionForB(filter.b)
    return this
}
 
private fun <T> JPAQuery<T>.applyJoin(filter: Filter): JPAQuery<T> {
    if (!filter.a.isNullOrEmpty() || !filter.d.isNullOrEmpty()) {
        this.joinTableAAndD()  // a, d 필터가 있을 때만 JOIN
    }
    if (filter.b != null) {
        this.joinTableB()      // b 필터가 있을 때만 JOIN
    }
    return this
}

3. 중복 JOIN 방지 확장 함수

여러 필터가 같은 테이블을 참조할 수 있으므로, 이미 JOIN된 테이블인지 확인하는 확장 함수(isJoined)를 만들었습니다.

fun <Q, T> JPAQuery<Q>.isJoined(
    expression: Expression<T>,
    type: JoinType,
    condition: Predicate
) = this.metadata.joins
    .any { it.target == expression && it.type == type && it.condition == condition }
 
// 사용 예시: 이미 JOIN되어 있지 않을 때만 JOIN 수행
private fun <T> JPAQuery<T>.applyBTableJoin(): JPAQuery<T> {
    val joinCondition = members.id.eq(bTable.memberId)
    this.isJoined(bTable, JoinType.LEFTJOIN, joinCondition)
        .takeIf { !it } ?.let { this.leftJoin(bTable).on(joinCondition) }
    return this
}

JPAQuery의 메타데이터에서 기존 JOIN 목록을 조회하여 동일한 expression, joinType, condition 조합이 이미 존재하면 JOIN을 건너뛰는 방식입니다. 복잡한 필터 조합에서도 불필요한 중복 JOIN이 발생하지 않도록 보장했습니다.

4. nGrinder 부하 테스트 검증

실제 운영 환경과 유사한 조건(서버 5대, 2GB 메모리, DB 커넥션 서버당 50개)에서 nGrinder로 5분간 부하 테스트를 진행했습니다. 테스트 데이터는 지원자 10,000명, 부가정보 지원자당 10개 테이블 × 2건으로 구성했고, 주요 필터 Top 4 조합을 기준으로 동시 유저 100/150/200명 단계로 검증했습니다.


결과

지표기존개선개선율
평균 응답시간27,885ms148ms99.5%
TPS1.11,3391,217배
CPU 사용률100%10% 초반대폭 감소
동시 처리50명200명4배 확장

scale up 없이, 쿼리 구조 개선만으로 달성한 결과입니다. 사용자 증가에 따른 선형 증가도 확인하여, 현재 서버 스펙만으로 충분한 성능을 확보했습니다.


모니터링

  • nGrinder 부하 테스트로 TPS·응답시간·CPU를 관측한다(서버 5대, 동시 100/150/200).
  • 운영 중 DB CPU 사용률과 조회 API 응답시간을 관측한다.