레거시 광고 랭킹 시스템 성능 병목 현상 해결기 — .NET에서 Spring Webflux로
최대 4시간이 걸리던 광고 랭킹 갱신을 3분으로 줄이기까지, .NET & MS-SQL 레거시를 Spring Webflux + MongoDB 리액티브 아키텍처로 전면 재설계한 과정을 공유합니다.
참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.
시스템 아키텍처
들어가며
Gmarket AdTech팀에서 CPC 광고 랭킹 시스템을 담당하게 됐을 때, 처음 마주한 숫자는 충격적이었습니다. 광고주의 입찰가 변경이나 예산 소진 같은 중요한 이벤트가 랭킹에 실제로 반영되기까지 최대 4시간이 걸렸고, 그 사이에 예산이 이미 소진된 광고가 계속 노출되어 환불이 발생하고 광고주 CS가 쌓이고 있었습니다.
이 글은 .NET & MS-SQL 기반 레거시 시스템을 Spring Webflux + MongoDB 리액티브 아키텍처로 전면 재설계하면서 겪은 과정, 기술적 결정들, 그리고 그 결과를 기록한 글입니다.
문제 상황: 4시간의 공백
시스템 구조 파악부터
기존 시스템은 .NET으로 작성된 단일 애플리케이션이 MS-SQL RDB에서 랭킹 데이터를 읽어, 복잡한 로직을 거쳐 랭킹을 계산하고 캐시에 저장하는 구조였습니다.
문제는 이 전체 과정이 동기식, 블로킹 방식으로 동작한다는 점이었습니다.
[랭킹 갱신 트리거]
↓
[MS-SQL 조회] → 블로킹 대기
↓
[랭킹 계산 로직] → 단일 스레드 처리
↓
[MS-SQL 업데이트] → 블로킹 대기
↓
[캐시 갱신] → 완료
데이터 건수가 수백만 건에 달하는 상황에서, 이 구조가 만들어낸 결과는 다음과 같았습니다:
- 갱신 시간: 최대 4시간 소요
- 피크 타임 TPS: 30,000 수준에서 불안정
- CPU 사용률: 갱신 작업 중 급격히 치솟음
광고 랭킹이 4시간이나 오래된 데이터를 보여준다는 것은, 광고주 입장에서는 실시간으로 예산을 소진하면서도 정작 랭킹에는 반영되지 않는 상황을 의미했습니다.
기술적 결정: 왜 리액티브 스택인가?
처음에는 기존 .NET 시스템을 최적화하는 방향도 검토했습니다. 쿼리 최적화, 인덱스 추가, 캐싱 레이어 개선 등이 후보였죠. 하지만 프로파일링 결과는 명확했습니다.
근본적인 병목은 동기식 블로킹 I/O 자체에 있었습니다.
스레드가 DB 응답을 기다리는 동안 아무것도 하지 못하는 구조에서, 수백만 건의 데이터를 처리하려면 스레드를 무한정 늘리는 수밖에 없었습니다. 그리고 그것은 CPU 사용률 폭등으로 이어졌습니다.
결론은 논블로킹 I/O 기반의 리액티브 아키텍처로의 전환이었습니다.
기술 스택 선택
| 계층 | 기존 | 변경 |
|---|---|---|
| 애플리케이션 | .NET (동기식) | Spring Webflux (리액티브) |
| 데이터베이스 | MS-SQL (RDB) | MongoDB (NoSQL) |
| 메시지 큐 | 없음 | Kafka |
| 모니터링 | 없음 | Datadog |
MongoDB를 선택한 이유는 랭킹 데이터의 특성 때문이었습니다. 랭킹 조회는 극도로 읽기 중심이고, 복잡한 JOIN이 거의 없으며, 문서(Document) 단위로 조회하는 패턴이 지배적이었습니다. RDB의 행 기반 구조보다 문서 기반 구조가 훨씬 잘 맞았습니다.
구현: 3가지 핵심 변경
1. Spring Webflux로 논블로킹 처리 전환
Spring Webflux는 Reactor 라이브러리를 기반으로 합니다. 핵심은 Mono와 Flux를 활용한 리액티브 스트림 처리입니다.
// 기존 방식 (블로킹)
public List<RankingItem> getRankings(String adGroupId) {
return rankingRepository.findByAdGroupId(adGroupId); // 블로킹 대기
}
// 리액티브 방식 (논블로킹)
public Flux<RankingItem> getRankings(String adGroupId) {
return rankingRepository.findByAdGroupId(adGroupId) // 즉시 반환, 데이터는 비동기로
.filter(item -> item.isActive())
.sort(Comparator.comparingDouble(RankingItem::getScore).reversed());
}
스레드가 I/O 응답을 기다리지 않고 다른 작업을 처리할 수 있기 때문에, 적은 수의 스레드로 훨씬 많은 요청을 동시에 처리할 수 있게 됩니다.
2. MongoDB 모델링 최적화
랭킹 데이터를 MongoDB에 모델링할 때 가장 중요하게 고려한 것은 **역정규화(Denormalization)**였습니다.
RDB에서는 정규화를 통해 데이터를 여러 테이블에 분산시키고, 조회 시 JOIN으로 합칩니다. 하지만 NoSQL에서는 반대로 자주 함께 조회되는 데이터를 하나의 문서에 모두 담아두는 전략이 효과적입니다.
// 기존 RDB 구조 (JOIN 필요)
// ads 테이블 + rankings 테이블 + bidding 테이블 → JOIN
// MongoDB 역정규화 구조
{
"_id": "ranking_001",
"adGroupId": "ag_12345",
"score": 98.5,
"bid": 500,
"qualityScore": 0.92,
"adTitle": "...",
"advertiserName": "...",
"updatedAt": ISODate("2024-06-10T10:00:00Z"),
// 함께 조회될 모든 필드를 한 문서에
}
조회 시 여러 컬렉션을 참조할 필요가 없으니 응답 속도가 급격히 빨라졌습니다.
복합 인덱스 설계도 핵심이었습니다. 가장 빈번한 쿼리 패턴을 분석해 인덱스를 구성했습니다.
// 주요 조회 패턴: adGroupId + status + score 기반
db.rankings.createIndex(
{ adGroupId: 1, status: 1, score: -1 },
{ background: true }
)
모든 인덱스는 p99.9 기준으로 성능을 검증하며 튜닝했습니다. explain() 메서드로 실행 계획을 확인하고 IXSCAN(인덱스 스캔)이 되도록 반복 개선했습니다.
3. Kafka 기반 비동기 갱신
랭킹 갱신 트리거 포인트에서 모든 것을 API 동기 처리로 하면 무거운 작업이 API 응답 시간을 늦춥니다. 무거운 갱신 작업은 Kafka로 분리했습니다.
[갱신 트리거 API 호출]
↓
[Kafka 토픽에 이벤트 발행] → 즉시 응답
↓ (비동기)
[Ranking Consumer] → Kafka에서 메시지 소비
↓
[MongoDB 갱신 처리]
이 구조로 API 응답 시간은 빠르게 유지하면서, 백그라운드에서 안정적으로 대량의 갱신을 처리할 수 있게 됐습니다.
Datadog으로 병목 추적
개발 과정에서 가장 유용했던 것 중 하나는 Datadog 모니터링을 처음부터 붙인 것이었습니다. APM 트레이스로 요청 흐름을 추적하고, p99.9 기준 응답 시간을 모니터링하면서 숨어 있는 병목을 계속 찾아냈습니다.
예를 들어, 처음에는 MongoDB 인덱스를 잘 만들었다고 생각했는데, 특정 조건에서 인덱스를 타지 않는 쿼리를 Datadog APM에서 발견하고 인덱스를 재설계한 적도 있었습니다.
결과
전면 재설계 후 측정한 수치입니다.
| 지표 | 기존 | 개선 후 |
|---|---|---|
| 랭킹 갱신 시간 | 최대 4시간 | 3분 이내 |
| 주요 API p99.9 응답시간 | 100~200ms | 10ms 이내 |
| CPU 사용률 | (피크 시) 높음 | 60% 감소 |
| 피크 TPS 처리 | 30,000 (불안정) | 50,000 (안정) |
단순히 숫자가 좋아진 것을 넘어서, 광고주 CS로 이어지던 "랭킹이 반영이 안 돼요" 유형의 문의가 대폭 감소했습니다.
마치며: 리액티브 전환에서 배운 것
리액티브 프로그래밍은 처음에 진입 장벽이 있습니다. Mono와 Flux의 개념, 에러 처리 방식, 디버깅 방법이 명령형 프로그래밍과 다르기 때문입니다.
하지만 I/O 집약적이고 대용량 트래픽을 처리해야 하는 시스템에서는, 리액티브 스택이 가져다주는 효율성은 그 학습 비용을 충분히 뛰어넘습니다. 특은 스레드 자원을 낭비하지 않고 높은 동시성을 처리해야 하는 광고 랭킹 시스템 같은 유즈케이스에서 더욱 빛을 발합니다.
다음 글에서는 이 과정에서 함께 겪었던 Redis & Kafka 관련 이슈들을 다룰 예정입니다.