코드 저장소.

분산 서버 전환 후 부하 테스트로 병목 구간 찾기2 본문

포폴/일정관리 프로젝트

분산 서버 전환 후 부하 테스트로 병목 구간 찾기2

slown 2026. 4. 30. 20:43

목차

1.들어가며 (이전 글에서 이어서)

2.측정 과정에서의 트러블 슈팅

3.회고

 

1.들어가며 (이전 글에서 이어서)

이전 포스팅에서 30VU → 50VU 구간의 안정성을 확보했습니다. 이번 글에서는 최종 목표인 60VU → 100VU구간에서 발생한 추가 병목 지점들을 기록합니다. 총 5번의 실패를 거쳐 60VU 에러율 0.05%를 달성하기까지의 과정입니다.

2.측정 과정에서의 트러블 슈팅

2-1. [1차] 60VU 테스트 실패 — DB 데드락

 

[현상] 60VU 테스트 시작 직후 에러율 16% 기록. 평균 응답 시간 1,105ms, 95% Line 1,868ms.  파란색 선(Average)이 우상향 곡선을 지속하다 17초 지점에서 에러 폭발했습니다. 요청 유입 속도가 처리 속도를 초과하면서 서버 내부 대기열(Queue)이 포화 상태에 도달한 것으로 판단.

  • HikariCP Active Connections: 서버 2대 모두 설정된 Max Pool Size에 딱 붙어 수평선을 유지하다 에러 발생 시점에 수직 하락. DB 커넥션 전량 소진 확인되었습니다.
  • Outbox Publish Latency: 에러 발생 시점에 Latency가 급등. DB 커넥션 부족이 Outbox 테이블 Insert 속도 저하로 전이된 것으로 확인되었습니다.

원인 분석

애플리케이션 로그를 확인하자 결정적인 메시지가 확인되었습니다.

단순 커넥션 부족이 아닌 DB 데드락(Deadlock) 이 실제 원인이었습니다. 서비스 로직의 실행 흐름을 분석해보면서 데드락 발생 구조를 파악했습니다.

 

구조는 아래와 같습니다. 

1. validateBulkConflict() 실행 → findOverlappingSchedulesInRange() 호출 
→ @Lock(LockModeType.PESSIMISTIC_WRITE) 적용.
→ SELECT 쿼리 실행 + Gap Lock / X-Lock 획득

2. scheduleRepositoryPort.saveAll() 실행 → INSERT 시도 + X-Lock 요청

 

 

데드락이 발생을 한 원인 다음과 같습니다.

 

validateBulkConflict() 실행

  • findOverlappingSchedulesInRange() 호출 시 @Lock(LockModeType.PESSIMISTIC_WRITE) 이 적용된 상태였습니다.
  • 현상: 조회 범위에 기존 데이터가 없는 경우, 해당 인덱스 구간에 X-Gap Lock이 획득됨.
  • 핵심 원인: InnoDB 특성상 Gap Lock은 배타적 락(X)이라 하더라도 서로 충돌하지 않고 공유될 수 있음. 이로 인해 트랜잭션 A와 B가 동시에 동일한 Gap 구간에 락을 점유함.

scheduleRepositoryPort.saveAll() 실행

  • INSERT 시도 시 해당 구간에 Insert Intention Lock(삽입 의도 락) 요청.
  • 충돌: 삽입 의도 락은 상대방 트랜잭션이 잡고 있는 Gap Lock이 해제될 때까지 대기해야 함

 

이번 테스트는 단일 일정 생성 API 기준으로, 요청 시간대는 분산되어 있었습니다. 그러나 60VU 전체가 동일한 memberId=1로 요청했기 때문에, 엔티티에 설정된 인덱스 (memberId, startTime, endTime) 기준으로 같은 파티션 내에 Gap Lock 경합이 집중되었습니다. 시간대가 달라도 동일 memberId 인덱스 구간 안에서 락이 겹치면서 데드락이 발생했으며, 이는 이후 2-5에서 userId를 1,000명으로 분산하여 해결된 근본 원인과 연결됩니다.

 

해결 조치

 

1.비관적 락 제거 — COUNT 쿼리로 교체

 

기존에 중복 체크 용도에 비관적 락이 불필요하다고 판단해서 제거를 했고, 반환 타입도 boolean에서 Long으로 변경했습니다. (boolean의 경우에는 경계 조건에서 결과가 불명확한 경우가 존재)

 

@Query("""
    SELECT COUNT(s) FROM Schedules s
    WHERE s.memberId = :userId
    AND s.isDeletedScheduled = false
    AND s.startTime < :rangeEnd
    AND s.endTime > :rangeStart
""")
Long countOverlappingSchedules(
    @Param("userId") Long memberId,
    @Param("rangeStart") LocalDateTime rangeStart,
    @Param("rangeEnd") LocalDateTime rangeEnd
);

 

단, COUNT 기반 검증은 TOCTOU(Time-of-Check to Time-of-Use) 경합 가능성이 남아있습니다. 동시 요청 두 개가 각각 COUNT=0을 확인한 뒤 동시에 INSERT하면 중복이 발생할 수 있습니다. 이를 보완하기 위해 DB 레벨 Unique Index를 최종 방어선으로 추가했습니다.

 

@Table(
    name = "schedules",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_member_starttime",
            columnNames = {"memberId", "startTime"}
        )
    }
)

 

계층                                          역할
COUNT 쿼리 1차 방어 — Gap Lock 없이 충돌 감지
Unique Index 2차 방어 — TOCTOU 통과한 중복 INSERT 차단

 

단, 분산 서버 환경에서 동일 사용자가 여러 기기로 동시 요청하는 케이스는 이 구조만으로 완전히 방어되지 않는다. 트래픽이 늘어나면 Redis 분산 락 도입을 고려하고 있습니다.

 

2.saveAll 전 정렬 적용 — Lock Ordering

 

데이터 삽입 순서를 시작 시간 기준으로 일관되게 정렬하여, 서로 다른 트랜잭션이 동일한 방향으로 락을 획득하도록 유도. 데드락 발생 확률을 최소화했습니다.

 

List<SchedulesModel> processedSchedules = schedulesToSave.stream()
    .map(m -> m.toBuilder()
        .scheduleType(scheduleClassifier.classify(m))
        .memberId(currentMemberId)
        .build())
    .sorted(Comparator.comparing(SchedulesModel::getStartTime)) // 인덱스 컬럼 기준 정렬
    .toList();

 

3.JDBC Batch Insert 활성화

 

이번 테스트는 단일 일정 생성 API를 대상으로 진행되었습니다. 하지만 반복 일정 등록 기능이 도입될 경우, 한 번의 요청으로 다수의 데이터(예: 30개)가 삽입됩니다.

  • 배치 처리 미적용 시: 30개의 INSERT가 개별적으로 발생하여, 한 트랜잭션 내에서 락 획득 횟수가 30배로 증가합니다. 이는 동시 요청과 겹칠 때 Gap Lock 경합의 기하급수적 증가를 초래합니다.

비록 현재 부하 테스트에서 직접 재현된 문제는 아니지만, 운영 환경에서 충분히 발생 가능한 구조적 위험으로 판단하여 JDBC Batch Insert를 선제적으로 적용했습니다.

 

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 30
        order_inserts: true
        order_updates: true
        jdbc.batch_versioned_data: true
# JDBC URL 파라미터 추가
# &rewriteBatchedStatements=true

 

JDBC URL 파라미터에도 &rewriteBatchedStatements=true를 추가하여 MySQL 드라이버가 배치를 실제 Batch 쿼리로 재작성하도록 유도했습니다.

 

[효과 및 트레이드오프]

 

  • 효과: 다수의 INSERT 쿼리를 한 번에 모아서 전송함으로써, DB 서버와의 네트워크 왕복 시간을 줄이고 락 획득 횟수를 획기적으로 감소시킵니다. 이는 Gap Lock 경합 문제를 사전에 차단하는 데 큰 도움이 됩니다.
  • 트레이드오프: Batch Insert는 개별 INSERT 쿼리의 실패를 추적하기 어렵고, EntityManager의 영속성 컨텍스트 관리가 복잡해질 수 있다는 단점이 있습니다. 이 부분은 예외 처리 로직을 보완하여 대응했습니다.

 

2-2. [2차] 60VU 테스트 실패 — 알림 테이블 미설치

[현상] 에러율 약 50%. JVM Heap, HikariCP, Kafka Consumer Lag 등 주요 지표는 모두 정상이나 에러율이 높게 유지됨. 평균 응답 시간 788ms.

지표 분석

  • HikariCP: 바닥 수준 유지. DB 커넥션 고갈 아님.
  • Kafka Consumer Lag: 0. 메시지 큐 파이프라인 정상.
  • JVM Heap: 여유 있음.
  • CircuitBreaker State: 대시보드에서 숫자 1 감지. 에러 누적으로 인한 서킷 OPEN 가능성 확인.

원인 분석

애플리케이션 로그를 확인한 결과 아래 메시지가 확인되었습니다.

Table 'schedule.push_notification' doesn't exist

 

알림 푸시 관련 테이블이 DB에 아예 생성되지 않은 상태였습니다. 50%의 요청이 테이블 조회 단계에서 계속 실패하고 있었던 것입니다. 원인은 배포시 jpa에 관한 설정을 none으로 해놓았기에 엔티티가 생성이 안된 것이었습니다. 

 

해결 조치

알림 푸시 테이블 DDL 스크립트 실행으로 해결.

 

2-3. [3차] 60VU 테스트 실패 — @Transactional 내부 외부 API 호출

[현상] 테스트 시작 후 약 1분까지는 정상 처리되다가, 1분이 경과한 시점부터 에러가 급증하는 패턴 발생.

지표 분석

  • Tomcat Thread: 시간이 지날수록 점진적으로 증가. 1분 시점에 임계치 도달 후 에러 폭발.
  • HikariCP: Tomcat Thread 고갈과 함께 동반 하락.
  • Kafka Consumer Lag: 컨슈머 처리 지연으로 인해 Lag 누적 후 리밸런싱 발생.

원인 분석

로그와 코드를 분석한 결과, NotificationEventConsumer의 실행 흐름이 다음과 같은 구조였습니다.

 

@Transactional  // ← 문제의 시작
public void handleWebNotification(NotificationEvents event) {
    // 1. DB 커넥션 획득
    notificationRepository.save(notificationHistory);  // DB 저장

    // 2. 외부 API 호출 (응답 지연 가능)
    webPushService.sendPush(event.getMemberId(), event);

    // 3. sendPush 응답 완료 후 커넥션 반납 ← 외부 API 응답 대기 시간만큼 커넥션 점유
}

 

외부 API 응답을 기다리는 동안 DB 커넥션이 계속 점유되며, 부하 상황에서 외부 서버 응답이 조금만 느려지면 커넥션 풀 전체가 "대기 중" 상태로 고갈됩니다. 추가로 웹푸시 발송이 동기식 루프로 처리되는 구조도 문제였습니다.

 

// 유저 기기 3대 = 동기적으로 3번 외부 HTTP 요청
for (PushSubscription sub : subs) {
    pushService.send(notification);  // 하나 완료 후 다음 실행
}

 

수백 번의 외부 HTTP 요청이 순차적으로 처리되면서 Kafka 컨슈머 스레드가 완전히 점유되었고, 메시지 처리가 지연되면서 Kafka 브로커가 리밸런싱을 시도하는 연쇄 장애로 이어졌습니다.

 

해결 조치

트랜잭션과 외부 API 호출을 완전히 분리했습니다.

// WebPushService.java
@Async("threadPoolTaskExecutor")  
public void sendPush(Long memberId, NotificationEvents event) {
    List<PushSubscription> subs = getActiveSubscriptions(memberId);
    for (PushSubscription sub : subs) {
        pushService.send(notification);
    }
}

 

 
항목                                           변경 전                                                       변경 후
Kafka 컨슈머 대기 sendPush 완료까지 점유 DB 저장 완료 즉시 다음 메시지 처리
DB 커넥션 점유 시간 외부 API 응답 대기 시간 포함 DB 저장 완료 즉시 반납
외부 API 처리 방식 메인 스레드에서 동기 처리 별도 스레드 풀에서 비동기 처리

 

단, @Async 분리로 웹푸시 발송 실패가 조용히 묻힐 수 있습니다. 트랜잭션 롤백이 되지 않기 때문에 알림 유실 가능성이 생깁니다. 이를 보완하기 위해 발송 실패 시 FailMessage 테이블에 기록하고, 기존 재처리 스케줄러가 WEB_PUSH 타입을 처리하도록 확장했습니다.

// 비동기로 처리를 한 경우에는 메인 스레드나 DB커넥션에 영향이 없음.
        for (PushSubscriptionModel sub : subs) {
            try {
                Notification notification = new Notification(
                        sub.getEndpoint(),
                        sub.getP256dh(),
                        sub.getAuth(),
                        payload
                );
                pushService.send(notification);
                log.info(" WebPush 발송 성공 - endpoint={}", sub.getEndpoint());
            } catch (Exception e) {
                log.error(" WebPush 발송 실패 - endpoint={}", sub.getEndpoint(), e);
                try {
                    failedMessageService.createFailMessage(
                            FailMessageModel.builder()
                                    .topic("web-push")
                                    .messageType("WEB_PUSH")
                                    .payload(payload)  // 이미 직렬화된 거 재사용
                                    .retryCount(0)
                                    .resolved(false)
                                    .eventId(event.getEventId())
                                    .exceptionMessage(e.getMessage())
                                    .nextRetryTime(LocalDateTime.now().plusSeconds(30)) // 추가
                                    .createdAt(LocalDateTime.now())
                                    .build()
                    );
                } catch (Exception saveEx) {
                    log.error("FailMessage 저장도 실패 - eventId={}", event.getEventId(), saveEx);
                }
            }
        }

 

2-4. [4차] 60VU 테스트 실패 — 502 Bad Gateway

 

[현상] HTTP Status 502 Bad Gateway와 409 Conflict가 동시에 폭발적으로 증가.

 

지표 분석

  • HikariCP Active Connections: 직선 우상향 → 30에서 수평 → 수직 폭락. 커넥션 풀 고갈의 전형적인 패턴.
  • HTTP Status: 502와 409가 동시에 급증.
  • JVM Heap: 100MB 근처에서 요동. GC 빈발로 인한 Stop-the-world 구간에서 Nginx가 먼저 연결을 끊어버린 것으로 추정.

원인 분석

 

502 발생 과정은 다음과 같습니다.

1. 커넥션 풀(30개) 전량 사용 중
2. 신규 요청 유입 → 커넥션 대기열 진입
3. Nginx proxy_read_timeout(5s) 초과
4. Nginx: "백엔드 서버 응답 없음" → 연결 차단
5. 클라이언트: 502 Bad Gateway 수신

 

또한 409(Conflict) 응답도 서버 자원을 동일하게 소비하는 구조였습니다.

톰캣 스레드 할당 → 비즈니스 로직 진입 → Redis/DB 체크 → 409 응답

 

거절 응답임에도 불구하고 스레드와 커넥션을 점유하는 과정을 그대로 거치기 때문에, 409가 대량으로 발생할수록 서버 자원 낭비가 심화됩니다.

 

해결 조치

 

1.Nginx 타임아웃 및 연결 설정 조정

location /api/ {
    proxy_pass http://backend-cluster/api/;
    proxy_http_version 1.1;
    proxy_set_header Connection "";         # Upstream Keepalive 활성화 핵심
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_connect_timeout 3s;
    proxy_read_timeout 10s;   # 5s → 10s 상향
    proxy_send_timeout 10s;
}

 

2.Redis Pre-check 도입 — DB 커넥션 획득 전 중복 요청 차단

String eventKey = "event:processed:" + event.getEventId();

// SETNX: 키 없으면 설정(true), 있으면 무시(false)
Boolean isNewEvent = redisTemplate.opsForValue()
    .setIfAbsent(eventKey, "processing", Duration.ofMinutes(10));

if (Boolean.FALSE.equals(isNewEvent)) {
    log.info("[Redis Filter] 이미 처리된 이벤트: {}", event.getEventId());
    ack.acknowledge();
    return;  // DB 트랜잭션 시작 전에 차단
}

 

3.HikariCP 커넥션 풀 확장 및 JVM 설정 조정

spring:
  datasource:
    hikari:
      maximum-pool-size: 60      # 50 → 60
      connection-timeout: 3000   # 5000ms → 3000ms

 

2-5. [5차] 60VU 테스트 실패 — JMeter userId 고정으로 인한 Row Lock 경합

 

[현상] 4차 대비 버티는 시간이 약 1분 10초로 소폭 늘었으나 여전히 에러율 비정상. HikariCP 커넥션이 30 근처에서 버티다 폭락하는 패턴 반복

 

지표 분석

  • HTTP Status 409: 초반에 급등 후 꺾이는 패턴. Redis Pre-check가 중복 요청을 정상적으로 차단하고 있음을 확인.
  • Kafka Consumer Lag: 1분 경과 시점에 약 12.5까지 급등. 처리 속도가 유입 속도를 따라가지 못하는 시점 확인.
  • HikariCP: 30 근처에서 유지되다 시스템 한계 도달 후 수직 폭락.

원인 분석

JMeter 스크립트를 재확인한 결과, 핵심적인 문제를 발견했습니다.

def userId = 1  // ← 모든 60VU가 단 한 명의 유저 데이터를 동시에 처리

 

60개의 가상 유저 전체가 userId=1의 데이터에 접근하고 있었습니다. 이 구조에서는 DB의 특정 userId 인덱스 행(Row)에 락 경합이 집중됩니다. 1분까지는 대기열이 감당 가능한 수준이었으나, 시간이 지남에 따라 누적된 대기가 임계치를 넘어 폭발한 것입니다.

또한 Redis Pre-check가 중복 이벤트를 걸러내더라도, 톰캣 스레드 할당과 Redis 조회까지의 과정은 동일하게 반복되므로 서버 자원 소비는 지속되었습니다.

 

해결 조치

// 변경 전
def userId = 1

// 변경 후: 1,000명의 유저로 분산
def userId = (int)(Math.random() * 1000) + 1

 

단 한 줄의 수정으로 특정 Row에 집중되던 락 경합을 분산시켰습니다.

 

2-6. [6차] 60VU 테스트 성공

 

[성과] 에러율 0.05%, 처리량(Throughput) 42.5/sec. 사실상 모든 요청에 정상 응답을 처리하며 안정성 확보.

  • HikariCP Active Connections: 25~30개 수준에서 안정적으로 유지. maximum-pool-size: 60 기준 절반 이하로 운용되며 확장 여력(Headroom) 확인.
  • Kafka Consumer Lag: 순간적으로 발생하더라도 즉시 0으로 수렴. 컨슈머의 처리 속도가 프로듀서 속도를 충분히 감당하고 있음을 확인.
  • Outbox Publish Latency: 평균 0.05~0.1ms 수준으로 매우 안정적. 트랜잭션 내 이벤트 발행 로직이 메인 비즈니스 로직에 부하를 주지 않음을 확인.
  • DLQ Retry Count: 0. 7,000건 이상의 요청 중 재처리 큐로 넘어간 건 단 한 건도 없음. 데이터 정합성 설계가 정상 작동함을 검증.

 

3.회고

이번 60VU 구간 테스트를 통해 "병목은 항상 예상하지 못한 곳에 있다"는 것을 다시 한번 실감했습니다. 데드락은 코드 구조의 문제다 비관적 락을 아무 의심 없이 사용하면, 동시 요청이 많아지는 순간 락이 꼬이면서 시스템 전체가 멈출 수 있습니다. 중복 체크처럼 단순한 검증 로직에는 COUNT 쿼리로 대체하고, 꼭 락이 필요하다면 데이터 삽입 순서를 일관되게 정렬하는 Lock Ordering을 적용하는 것이 유효했습니다.

 

@Transactional 범위는 가능한 좁게 유지해야 한다

트랜잭션 안에 외부 API 호출이 포함되어 있으면, 외부 서버가 1초만 느려져도 그 시간만큼 DB 커넥션이 묶입니다. 부하가 몰리는 상황에서 이 구조는 커넥션 풀 고갈로 직결됩니다. DB 작업과 외부 I/O는 반드시 분리해야 한다는 점을 다시 확인했습니다.

 

부하테스트 시나리오가 병목이 될 수 있다

모든 VU가 동일한 userId를 사용하면, 시스템 전체 성능이 아닌 특정 Row의 Lock 경합 성능을 테스트하게 됩니다. 현실적인 데이터 분산이 부하테스트의 신뢰도를 결정짓는 핵심 요소라는 점을 직접 경험했습니다.

 

Redis Pre-check는 DB의 최전선 방어선이다

중복 요청을 DB까지 진입시키지 않고 Redis에서 먼저 차단하면, 커넥션 풀 여유가 극적으로 개선됩니다. 불필요한 트랜잭션을 입구에서 막는 것이 내부 최적화보다 효과적인 경우가 많았습니다.

 

분산 환경에서는 테스트 시나리오가 한계를 숨긴다

userId 분산으로 테스트는 통과했지만, 동일 사용자가 여러 기기에서 동시 요청하는 케이스는 COUNT + Unique Index만으로 완전히 방어되지 않는다. 테스트 통과가 설계의 완성을 의미하지 않는다는 것을 다시 확인했다

 

다음 테스트 방향

 

60VU 구간의 안정성을 확보했으나, 최종 목표인 100VU 구간에서의 검증은 아직 남아 있습니다. 다음 단계에서는 아래 항목을 중심으로 추가 검증을 진행할 예정입니다.

  • 서버 간 부하 분산 균등성 유지 여부
  • DB 커넥션 포화 재발 시점
  • Kafka Consumer Lag 증가 여부 및 자가 회복 속도
  • 웹푸시 비동기 스레드 풀의 포화 임계치