반응(reaction) API 부하 테스트 — 문제 진단부터 4단계 개선까지

HamJina·4일 전

수치는 전부 측정 전용 환경에서 3회 반복 측정한 실측치다(평균 기준).

목표

  • 대상 API: POST /records/{recordId}/reaction — 활동 기록에 반응 남기기
  • 우선순위 판단 기준: 호출 빈도, 동시성 가능성(여러 명이 동시에 같은 데이터 접근), 데이터
    정합성(틀리면 안 되는 수치), 응답 속도 민감도. 네 조건을 다 만족하는 API라 첫 부하테스트 대상으로 골랐다.
  • 목표 TPS: 기준선(Step 1) 보정 TPS 대비 10배 이상. 인덱스 추가·비동기 큐·분산 락까지 다
    넣으면 그 정도는 나올 거라고 예상하고 시작했다.
  • 테스트 환경: 운영 EC2와 동일한 AMI·인스턴스 타입(t3.small)에 앱·Redis·RabbitMQ를
    올리고, RDS도 운영과 같은 엔진 버전(MySQL 8.0.43)으로 맞춘 별도 인스턴스를 썼다. 운영
    EC2에 직접 부하를 걸지 않은 이유는 아래 참고.

왜 운영 환경에 바로 부하를 걸지 않았나

  • 운영 EC2는 Redis·RabbitMQ가 앱과 같은 도커 네트워크에 있어 부하가 서비스 전체로 번진다
  • 스토어에 이미 출시된 서비스라 실사용자 장애로 이어진다
  • blue-green이 같은 인스턴스를 써서, 측정 중 배포가 일어나면 컨테이너 교체로 측정이 오염된다
  • 로컬(가정용 인터넷)에서 EC2로 쏘면 서버 한계가 아니라 내 업로드 대역폭·왕복 지연을 재게 된다

그래서 운영과 동일한 스펙의 측정 전용 EC2(대상) + 측정 전용 EC2(k6 발생기) +
별도 RDS를 새로 띄워서 측정했다. 시드 데이터는 게스트 유저 200명, 기록 1,000건.

k6를 고른 이유

  • 메모리를 적게 쓰면서 비교적 많은 요청을 보낼 수 있음
  • 사용법이 간단함

부하 테스트 결과를 보는 법

k6가 보여주는 값이 많아 보이지만, 파레토의 법칙에 따라 아래 3가지만 보면 된다.

  1. HTTP Request Rate — 1초당 처리한 요청 수 = Throughput(TPS). VU를 늘려도 더 이상
    증가하지 않는 지점이 현재 시스템의 최대 Throughput이다.
  2. HTTP Request Duration — 요청당 응답 시간 = Latency. VU가 늘어날수록 서버가 처리
    못한 요청이 대기하면서, Throughput은 안 느는데 Latency만 늘어나는 현상이 나타난다.
  3. HTTP Request Failed — 요청 실패 수. 실패가 있으면 원인을 반드시 분석한다.

해석 순서: ① Rate가 더 이상 늘지 않는 지점을 최대 Throughput으로 본다 → ② Duration이
비정상적으로 높지 않은지 확인한다 → ③ Failed가 있으면 원인을 분석한다.

두 가지 숫자를 구분해서 본다 — k6의 http_reqs(raw)는 DUPLICATE_REACTION(400)
거절 응답까지 포함한 전체 처리량이다. "새 반응이 실제로 기록된" 처리량은 상태코드별
Counter(*_success)로 따로 뽑았다. 아래 모든 단계에서 이 둘을 같이 적는다.


1. 기록에 반응 남기기

Step 1 — 기준선 측정

동기 처리 경로(ReactionService.reactToRecord)를 그대로 부하테스트했다.

// scripts/k6/reaction-test.js
import http from 'k6/http';
import { check } from 'k6';
import { setupGuestTokens, pickTarget, makeStatusCounters, tagStatus, BASE_URL } from './common.js';

export const options = {
  vus: 1000,
  duration: '10s',
};

const counters = makeStatusCounters('v1');

// 시드된 게스트 유저(measure_user_1..200) 각각으로 로그인해 서로 다른 토큰 200개를 발급한다.
// (실수했던 첫 시도: setup()에서 토큰을 1개만 발급했더니 1,000 VU가 전부 같은 유저로
//  요청하는 꼴이 돼서, 대부분 DUPLICATE_REACTION 거절 응답 속도를 재고 있었다 — 아래
//  "측정 스크립트 자체의 버그" 참고)
export function setup() {
  return { tokens: setupGuestTokens() };
}

export default function (data) {
  const { userIndex, recordId, reactionType } = pickTarget(__VU, __ITER);
  const token = data.tokens[userIndex];

  const res = http.post(
      `${BASE_URL}/records/${recordId}/reaction`,
      JSON.stringify({ reactionType }),
      { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` } }
  );

  tagStatus(counters, res.status);
  check(res, { '2xx': (r) => r.status >= 200 && r.status < 300 });
}
K6_WEB_DASHBOARD=true k6 run --summary-export=stage1-summary.json scripts/k6/reaction-test.js

결과 (3회 측정, 평균)

지표run1run2run3평균
Raw TPS (http_reqs)98.4107.2112.4106.0
보정 TPS (v1_success, 실제 반영된 요청만)91.297.5101.196.6
Request Duration p955,399ms4,648ms4,346ms4,798ms
  • 측정 대상 EC2 CPU: 평균 32%, 최대 75%
  • RDS CPU: 4~37%(낮음) / RDS 여유 메모리: 107~127MB(db.t4g.micro 1GB 중 — 빠듯)

보정 TPS 96.6/s(raw 106.0/s) — 목표(보정 기준 10배, 약 966/s)까지는 한참 남았다는 걸 첫 측정부터
알 수 있었다.


reactToRecord() 코드에서 병목이 우려되는 부분 짚어보기

@Transactional
public ReactToRecordResDto reactToRecord(Long recordId, RecordReactionType type, CustomUserDetails user) {
    User currentUser = userUtil.getCurrentUser(user); // socialId로 유저 조회

    ReportActivityRecordDto record = activityRecordUtil.getValidRecord(recordId);
    if (!isRecordOwner(currentUser, record)) {
        activityRecordUtil.validateAccess(currentUser.getId(), record.getWriterId(), record.isWriterDeleted(), record.getVisibility());
    }
    validateDuplicateReaction(recordId, currentUser.getId(), type);

    ActivityRecordReaction reaction = ActivityRecordReaction.of(activityRecordRepository.getReferenceById(recordId), userRepository.getReferenceById(currentUser.getId()), type);
    recordReactionRepository.save(reaction);

    int result = recordReactionCountRepository.increaseCount(recordId, type.toString());
    if (result == 0) {
        recordReactionCountRepository.save(ActivityRecordReactionCount.init(recordId, type));
    }
    reactionRankingService.incrementRankingScore(record.getRecordId());

    if (!isRecordOwner(currentUser, record)) {
        notificationService.processReactionNotification(currentUser, userRepository.getReferenceById(record.getWriterId()), type, record.getRecordId(), record.getImageUrl());
    }

    return ReactToRecordResDto.of(type, recordId);
}

한 줄씩 "이게 왜 느릴 수 있는가"를 따져봤다.

  1. userUtil.getCurrentUser(user)socialId로 유저를 조회한다. users 테이블에
    uk_users_social_id 유니크 인덱스가 이미 있어서(예전 이슈에서 추가됨) 이 줄 자체는
    병목이 아니었다 — 확인 결과 제외.
  2. getValidRecord(recordId)recordId는 PK라 조회는 빠르다. 다만 나중에 작성자
    정보가 필요해서 User를 미리 조인해온다.
  3. validateAccess(...) — 현재 유저가 작성자가 아닐 때만 실행되는 친구 관계 조회.
    friend_relationsidx_requester_target/idx_target_requester 복합 인덱스가 이미
    있어서 이 줄도 제외.
  4. validateDuplicateReaction(...)existsByRecordIdAndUserIdAndType으로 이미
    반응을 남겼는지 DB에 물어본다. 여기가 진짜 의심 지점이었다activity_record_reactions
    테이블에 (activity_record_id, reacted_user_id, reaction_type) 복합 인덱스가 없어서,
    요청마다 이 조회가 비효율적인 스캔에 가까웠을 것으로 추정.
  5. 반응 저장(insert) — 나중에 Redis Write-Back으로 옮길 대상.
  6. 카운트 증가(update/insert) — 이것도 나중에 Redis Write-Back으로 옮길 대상.

1,000 VU를 걸었는데 초당 106건밖에 처리하지 못했고, p95는 4.8초까지 늘어났다. 그런데 RDS
CPU는 최대 37%로 여유가 있었다. DB 서버가 바빠서 느린 게 아니라, 인덱스 없는 existsBy 조회가
커넥션을 오래 붙잡고 있어서 나머지 요청이 커넥션을 기다리며 줄을 선 것으로 봤다. 대기 중인
요청 스레드가 쌓이면서 EC2 CPU도 최대 75%까지 올라갔다.

개선 조치: activity_record_reactions(activity_record_id, reacted_user_id, reaction_type) 복합 유니크 인덱스(uk_record_user_type) 추가.


Step 2 — 복합 인덱스 추가 후 재측정

같은 스크립트, 같은 엔드포인트. 인덱스만 추가하고 다시 돌렸다.

지표run1run2run3평균
Raw TPS192.4211.3196.3200.0
보정 TPS181.0168.5177.2175.6
Request Duration p953,051ms2,798ms3,184ms3,011ms
  • 측정 대상 EC2 CPU: 평균 31%, 최대 55%(75%에서 하락)
  • RDS CPU: 25~32%

http_reqs: 200.0/s — 기준선 대비 raw 기준 약 1.9배(+89%), 보정 기준 +82%(96.6 → 175.6) 증가했다.
p95도 4,798ms → 3,011ms로 약 37% 줄고, EC2 CPU 최대치도 75% → 55%로 내려갔다. 중복확인 조회가
인덱스를 타면서 커넥션 점유 시간이 줄었다는 뜻이다. 인덱스 하나로 처리량이 거의 두 배가 된 걸 보면
진단이 맞았다고 볼 수 있지만, 목표(10배)까지는 아직 한참 남았다.

더 개선할 포인트 찾기

1. 동기 쓰기 구조 — 조회는 빨라졌지만, 요청 하나가 중복확인 조회 → 반응 insert
카운트 update까지 DB를 여러 번 왕복하고 커밋이 끝날 때까지 HTTP 스레드를 붙잡는 구조는
그대로다. 쿼리 하나하나를 더 빠르게 만드는 것보다, 요청 경로에서 DB 쓰기 자체를 빼내는 게
다음 단계라고 판단했다.

  • 해결 방향: 반응 저장과 카운트 반영을 Redis Write-Back 큐로 비동기 처리한다 — 요청
    스레드는 큐에 push만 하고 즉시 응답, 실제 DB 반영은 스케줄러가 배치로 나중에 처리.

2. 권한·유효성 체크 캐싱(getValidRecord, validateAccess) — 검토는 했지만 이번
라운드에서는 채택하지 않았다(이유는 글 마지막 "검토했지만 채택 안 함" 참고).

개선 조치: Redis Write-Back 큐(ReactionScheduler) 도입.


Step 3 — Redis Write-Back 큐 적용 (분산 락은 아직 없음)

중복확인은 여전히 DB existsBy... 조회지만, insert/count 반영을 Redis 큐로 비동기
처리한다(QueueOnlyReactionMeasurementService,
/records/{recordId}/reaction/measure/queue-only — 4단계 비교를 위해 이번에 만든
측정 전용 경로).

지표run1run2run3평균
Raw TPS571.2634.5588.3598.0
보정 TPS (queue_only_success)402.6441.8389.1411.2
Request Duration p951,893ms2,107ms1,826ms1,942ms
  • 측정 대상 EC2 CPU: 최대 68%대 → 백로그 처리 구간에서 5% 미만으로 급락
  • RDS CPU: 9~21%(Step 1·2보다 오히려 낮음 — 배치 반영이 밀려서)

http_reqs: 598.0/s — Step 2(200.0) 대비 raw 기준 약 3배(+199%), 보정 기준 +134%(175.6 → 411.2),
p95도 3,011ms → 1,942ms로 줄었다. 요청 스레드가 DB 커밋을 기다리지 않고 큐에 push만 하고 돌아오니
처리량이 크게 뛰었다.

다만 숫자만 보고 넘어가기엔 걸리는 게 있었다. raw와 보정 TPS의 격차가 Step 2에서는 약 24/s였는데,
Step 3에서는 약 187/s로 벌어졌다. 초당 187건 가까이가 실제로 반영되지 않았다는 뜻이라 원인을 파고들었다.

숫자 뒤에 숨은 문제 — 실측으로 찾은 진짜 문제

reaction_queue_size 지표를 실행 중에 계속 관찰했더니, 큐가 최대 20,521까지
쌓이는 걸 확인했다. 응답은 빠르게 돌려주지만 실제 DB 반영은 계속 밀리고 있었던 것이다.
Step 3의 TPS는 "요청을 받아준 속도"였지, "요청을 끝까지 처리한 속도"는 아니었다.

문제는 여기서 끝나지 않았다. 앱 로그에서 이런 에러가 반복됐다.

Lock wait timeout exceeded; try restarting transaction
[update users set ... where user_id=?]

ReactionScheduler가 최대 1,000건씩 묶어 INSERT하는 배치 트랜잭션이, FK 제약
(reacted_user_id → users) 때문에 해당 유저 행에 락을 걸고 있었다. 큐가 20,000건
넘게 밀린 상태에서 이 배치가 오래 걸리다 보니, 반응 기능과 전혀 무관한 게스트
로그인(유저 last_activity_at UPDATE)까지 최대 37초씩 블로킹
됐다. 심할 때는 k6
setup()의 로그인 시퀀스 전체가 60초 타임아웃으로 실패했다.

즉 "분산 락 없는 Write-Back 큐"는:

  • 중복 요청까지 전부 큐에 쌓인다(락이 없어서 걸러지지 않음)
  • 스케줄러 상한은 1초에 1,000건이지만, 배치가 FK 락 대기로 느려지면 실제 처리 속도는 그보다
    훨씬 떨어진다. 유입(약 598/s)이 상한보다 낮아도 큐가 계속 쌓인 이유다
  • 쌓인 큐를 처리하는 배치가 반응과 무관한 다른 쓰기 경로까지 마비시킬 수 있다

처리량은 올랐지만 이대로는 운영에 넣을 수 없는 구조였다. 해결의 핵심은 큐로 들어오는 양 자체를
줄이는 것, 특히 어차피 거절될 중복 요청이 큐에 들어가지 못하게 앞단에서 막는 것이었다.

개선 조치: Redis SETNX 기반 분산 락(ReactionRedisLockService)을 중복확인·큐 push
앞단에 추가 — 같은 (기록, 유저, 타입) 조합의 재요청을 TTL 5초 동안 원천 차단해서 큐
유입 자체를 억제한다.


Step 4 — Redis 분산 락(SETNX) 적용

1. 중복이 발생할 수 있는 시나리오: "클라이언트 연타"

사용자가 반응 버튼을 누를 때, 네트워크 상태가 불안정하거나 화면이 즉시 반응하지
않으면 사용자는 버튼을 여러 번 누르게 된다.

  • T=0ms: 첫 번째 클릭 요청이 서버에 도착
  • T=10ms: 두 번째 클릭 요청이 서버에 도착
  • 문제 발생: 비동기 큐(Write-Back) 구조에서 Redis 체크가 없다면(Step 3처럼), 두
    요청 모두 큐에 들어가고 나중에 스케줄러가 DB에 반영할 때 유니크 제약 위반으로 벌크
    저장이 실패한다 — Step 3에서 실제로 관측했다.

2. Redis setIfAbsent(SETNX)가 중복을 막는 원리

setIfAbsent는 Redis의 원자적(Atomic) 특성을 이용한 전략이다.

① 원자성 — Redis는 싱글 스레드로 명령을 처리하기 때문에, 1ms 안에 100개의 요청이
몰려와도 Redis 입장에서는 '누가 먼저 왔는지' 순서가 명확히 정해진다. 가장 먼저 도착한
요청은 lockKey를 생성하고 true를 반환받아 성공하고, 0.001초 뒤에 온 나머지 요청들은
이미 키가 존재하므로 false를 반환받아 즉시 예외로 거절된다.

② 찰나의 락Duration.ofSeconds(5)로 TTL을 짧게 둬서, 반응 데이터 자체가 아니라
"방금 반응했다"는 사실만 5초간 기억한다. 이 덕분에 중복 데이터가 큐에 들어가는 것 자체를
막고, 메모리 점유도 낮게 유지된다. 설령 중복이 큐에 들어가더라도 DB의 유니크 제약이
최종 방어선이 된다.

3. 측정 결과

// scripts/k6/reaction-test-redis.js — 엔드포인트만 /api/v2/records/{recordId}/reaction으로 다름
지표run1run2run3평균
Raw TPS1,032.6968.1966.3989.0
보정 TPS (redis_lock_success)952.4921.7938.8937.6
Request Duration p952,664ms2,358ms2,572ms2,531ms

http_reqs: 989.0/s — 기준선 대비 raw 기준 약 9.3배, 보정 기준 약 9.7배(96.6 → 937.6) 증가.
Step 3 대비로도 raw +65%, 보정 +128%(411.2 → 937.6). 목표였던 10배(약 966/s)에는 살짝 미치지 못했지만,
4단계 중 raw·보정 TPS 모두 최고치를 기록했다. 무엇보다 raw와 보정 TPS의 격차가 약 51/s로 줄어,
받아준 요청 대부분이 실제로 반영됐다.

p95는 기준선 4,798ms → 2,531ms로 약 47% 줄었다. Step 3(1,942ms)보다는 조금 늘었는데, 요청마다
Redis 락 획득 왕복이 하나 더 붙고 처리량도 약 1.65배 많아졌기 때문이다. 큐 적체와 락 대기
장애를 없앤 대가로는 충분히 받아들일 만한 수준이라고 판단했다.

  • 중복 거절(redis_lock_duplicate) 75~239건 — Step 3(수백~수천 건)과 비교하면
    압도적으로 적다. 이번엔 실패율이 높은 게 아니라 오히려 가장 낮았다 — 락이 정확히
    "진짜 중복"만 걸러내고 큐 유입 자체를 억제했다는 뜻이다.
  • 큐 적체: 3회 누적으로도 최대 6,112에서 멈췄다(Step 3의 20,521과 대조).

처리량과 안정성을 동시에 잡은 유일한 단계였다. 다만 목표로 잡았던 10배에는 살짝 못 미쳤다.
raw TPS가 약 989/s로 스케줄러의 초당 1,000건 상한에 거의 붙었고, 큐도 6,112건까지는 쌓였다.
이제는 애플리케이션 코드보다 배치 반영 상한과 인스턴스 자원이 다음 병목이 된 것으로 보인다 —
아래 "남은 병목" 참고.


종합 비교

단계Raw TPS(평균)보정 TPS(평균)p95(평균)중복/락 거절큐 최대 적체
① 기준선106.096.64,798ms낮음-
② 인덱스 추가200.0175.63,011ms낮음-
③ Redis Write-Back만598.0411.21,942ms매우 높음20,521
④ Redis 분산 락989.0937.62,531ms낮음6,112

목표 대비: 기준선(96.6) 대비 10배(약 966)를 목표로 잡았지만, 최종 달성치는
937.6(약 9.7배)으로 목표에 살짝 도달하지 못했다. 정직하게 남겨두는 이유는, 이 격차 자체가
다음에 무엇을 더 해야 하는지 보여주는 지표이기 때문이다 — "남은 병목과 다음 단계" 참고.

흐름을 정리하면 이렇다. ② 인덱스로 조회 비용을 줄였고(1.9배), ③ Write-Back 큐로 쓰기를 요청
경로 밖으로
뺐지만(raw 3배) 큐 적체라는 부작용이 생겼다. ④ 분산 락으로 큐 유입 자체를 억제
그 부작용을 잡으면서 처리량도 한 번 더 끌어올렸다. Step 3의 교훈은 raw TPS만 보면 개선처럼 보여도
보정 TPS·큐 적체를 같이 봐야 실제 처리 능력을 알 수 있다는 것이었다.

측정 스크립트 자체의 버그 (재측정 전 수치와의 차이)

처음 이 4단계를 측정했을 때는 k6 스크립트의 setup()이 게스트 토큰을 1개만 발급해서
1,000 VU 전부가 같은 유저로 요청했다. recordId(1~100) x type(4) = 400개 조합뿐이라,
대부분의 요청이 실제 쓰기 경로가 아니라 DUPLICATE_REACTION(400) 거절 응답을 처리한
속도였을 가능성이 크다. 위 표의 수치는 게스트 유저 200명을 실제로 발급하고 (기록,
유저, 타입) 조합 충돌을 최소화하도록 스크립트를 고친 뒤 재측정한 값이다.

남은 병목과 다음 단계

  • activity_record_reactions.existsBy... DB 조회는 4단계에서도 v1 경로(/records/{id}/reaction)에는 그대로 남아 있다 — v2(락) 경로로 트래픽을 전면 전환하지 않는 한 사라지지 않는다.
  • Redis Write-Back 큐(ReactionScheduler)는 1초마다 최대 1,000건만 MySQL로 내려쓴다. Step 4에서 raw TPS가 이미 약 989/s로 이 상한에 근접했고 3회 누적으로 6,112까지 쌓인 걸 보면, 이 상한 자체가 곧 병목이 될 수 있다 — 정확한 포화 유입률 측정은 별도 스파이크 테스트 이슈에서 다룬다.
  • 목표 10배에 못 미친 만큼, EC2 인스턴스 확장이나 getValidRecord/validateAccess 캐싱 같은 다음 레버가 남아 있다.
  • 이번 측정 중 별도로 발견한 사전 존재 버그: record_reaction_count 초기 행 생성(increaseCount 실패 시 save)이 동시 요청 시 MySQL 데드락을 일으킨다(check-then-insert 경쟁 상태) — INSERT ... ON DUPLICATE KEY UPDATE 원자적 upsert로 수정.

0개의 댓글