그냥 코딩

API 성능 병목과 데이터 누락, RabbitMQ 비동기 구조로 해결해보기

컴공코딩러 2025. 4. 24. 15:00

 

 

 

 

 

 

 

 

운영 중인 앱: 축잘알 테스트 (구글 플레이)

 


 

🐇 비동기 처리로 API 성능과 데이터 정확성을 모두 잡을 수 있을까?

– 퀴즈 결과 저장 API에 JMeter로 부하 테스트를 걸어봤다



혼자 기획하고 개발 한 퀴즈 앱 ‘축잘알 테스트’에서는
사용자가 퀴즈를 풀고 나면 결과를 저장하는 API가 있다.

앱의 특성상, 사용자 수가 많아질수록 가장 자주 호출되는 API가 바로 이 퀴즈 결과 저장 API다.
그리고 단순 조회가 아니라 데이터를 변경하는 API이기도 하다.

그래서 직접 JMeter를 활용해
이 API에 고부하 테스트를 걸어보고,
동기/비동기 구조에 따른 응답 속도와 데이터 정확성을 비교해보기로 했다.


 

🎯 실험 배경 

 

  • 테스트 대상: 퀴즈 결과 저장 API
  • 단일 사용자(nickName: tttt)가 여러 번 푼다는 가정
  • 실제 앱에서는 5, 10, 15, 20문제를 풀 수 있지만,
    테스트에선 3개 문제를 풀고 2개 맞춘 것으로 고정

 

{
  "nickName": "Test",
  "totalQuizCount": 3,
  "solvedQuizCount": 2
}
  • 기대 결과:
    • 요청 수: 40,000건
    • 기대 누적값:
      • totalQuizCount: 3 × 40,000 = 120,000
      • solvedQuizCount: 2 × 40,000 = 80,000
  • JMeter 조건:
    • Threads: 2000
    • Ramp-up: 30초
    • Loop: 20

① ❌ 동기 + 객체 연산 방식

예시코드
FrontUserInfo user = userRepository.findByNickName(...);
user.setTotalQuizCount(user.getTotalQuizCount() + 3 (사용자가 푼 문제) );
user.setSolvedQuizCount(user.getSolvedQuizCount() + 2 (사용자가 맞춘 문제) );
userRepository.save(user);
 

기본적인 방식.
하지만 DB에서 값을 읽고 객체에서 더한 뒤 저장하는 방식은
동시성 문제가 그대로 드러난다.

📸결과:

항목
totalQuizCount ❌ 12,045
solvedQuizCount ❌ 8,030
평균 응답 시간 1,404ms
Throughput 684.8/sec

→ 응답은 OK지만, 데이터는 대부분 유실됨.
→ race condition으로 인해 정합성 완전 실패.


② ⚠️ 동기 + SQL 쿼리 연산 방식

다음은 DB에서 직접 연산하도록 구조를 변경했다.

@Modifying
@Query("UPDATE FrontUserInfo f SET " +
        "f.totalQuizCount = f.totalQuizCount + :total, " +
        "f.solvedQuizCount = f.solvedQuizCount + :solved " +
        "WHERE f.nickName = :nickName")
void incrementQuizCount(@Param("nickName") String nickName,
                        @Param("total") long total,
                        @Param("solved") long solved);

결과:

항목
totalQuizCount ⚠️ 119,778
solvedQuizCount ⚠️ 79,852
평균 응답 시간 6,621ms
Throughput 240.8/sec

→ race condition은 거의 해결,
→ 하지만 여전히 222건 누락,
→ 성능은 여전히 낮은 수준.


③ ✅ 비동기 처리 (RabbitMQ)

마지막으로, 메시지 큐를 사용한 구조로 바꿨다.
API는 데이터를 MQ에 넣고 바로 응답하며,
실제 저장은 Consumer가 순차적으로 처리한다.

@PostMapping("/front-user/update")
public String updateUserAsync(@RequestBody QuizResultMessage msg) {
    rabbitProducer.send(msg);
    return "OK";
}

@RabbitListener(...)
public void consumeQuizResult(QuizResultMessage msg) {
    userRepository.incrementQuizCount(...);
}

 

API는 요청만 받아서 RabbitMQ에 메시지를 넣고,
백그라운드 Consumer가 순차적으로 DB를 갱신한다.
→ 병목 없이, race condition이 완전히 사라진 구조.

 

결과:

항목
totalQuizCount ✅ 120,000
solvedQuizCount ✅ 80,000
평균 응답 시간 ✅ 1ms
Throughput ✅ 1,333/sec

→ 완벽한 저장 + 빠른 응답
→ race condition 없음


📊 전체 비교 요약

 

구조 응답시간 Throughput 저장 결과 정합성
동기 + 객체 연산 1,404ms 684.8/sec 12,045
동기 + SQL 쿼리 연산 6,570ms 242.3/sec 119,778 ⚠️
비동기 + MQ 1ms 1333/sec 120,000

💡 실험을 통해 얻은 인사이트

  • 동기 구조는 단순해 보이지만,
    데이터 정합성에 치명적인 문제(race condition) 가 존재함
  • SQL 레벨에서 연산을 처리하면 정합성은 어느 정도 개선되지만,
    성능 병목은 여전
  • 비동기 구조에서는 요청을 큐에 쌓아두고 순차 처리하기 때문에
    정합성은 물론, 응답 속도도 압도적으로 개선

✅ 마무리

이번 실험을 통해
"빠르게 처리하는 것"보다
"정확하게 처리하는 구조가 더 중요하다" 는 걸 확실히 느꼈다.

RabbitMQ 기반의 비동기 구조는
실제 서비스에서 높은 트래픽, 데이터 정합성, 안정성을 모두 만족시키기 위한
실질적인 해법이 될 수 있다는 점을 배웠다.