매초 DB UPDATE가 정말 부하일까? - 2편

juhyeok01·어제
post-thumbnail

이번 글에서는 DB 부하를 우려해 검증 없이 제외했던 1초 갱신 방식을 프로토타입으로 재평가하고, 기존 설계 판단을 수정한 과정을 설명한다.

문제 상황

측정하지 않은 부하를 근거로 대안을 제외하였다

설계 당시 매초 DB 점수를 갱신하며 사용자 수에 비례해 쓰기와 로그 생성량이 늘고 정리 비용까지 커질 것으로 예상했다. 이 우려를 근거로 1초 갱신을 제외하고 조회 시점 계산을 선택했다.

이후 설계를 돌아보며, 쓰기 비용이 발생한다는 사실만으로 해당 규모에서 DB가 처리하지 못한다고 판단한 점이 부족했다고 생각했다. 따라서, 실제 프로토타입을 만들어 테스트를 통해 검증하기로 하였다.

가설예상되는 현상
갱신이 1초 실행 주기를 따라가지 못한다처리 시간이 1초를 초과하여 1초 단위의 랭킹을 제공하지 못함
시간이 지날수록 자원 부족이나 경쟁으로 처리가 지연된다dirty page, undo log, redo 로그 자원의 증가에 따른 처리 성능 악화
데이터 반영과 이전 버전 정리가 쓰기를 따라가지 못함dirty page, checkpoint age, undo history가 지속 상승하고 부하 종료 후에도 회복되지 않음



실험 방법

프로토타입에는 사용자당 한 행의 점수 상태를 구성했다. 종료된 공부 시간의 누적값과 진행 중인 세션의 시작 시각을 저장하고, 1초마다 다음 SQL을 실행했다.

@Scheduled(fixedRateString = "${lab.tick-ms:1000}", scheduler = "tickScheduler")
public void tick() {
    // 실행 제어와 관측 코드 생략
    long startedMs = clock.millis();
    // 별도 Bean의 트랜잭션 프록시가 커밋을 마친 뒤 반환한다.
    int changed = updates.update(startedMs);
    // 커밋 후 완료 시각·처리 시간·변경 행 수 기록
}

@Transactional
public int update(long nowMs) {
    return sessions.updateScores(nowMs);
}
  • 스케줄러가 1초마다 업데이트를 하는 트리거 역할을 수행한다.
  • UPDATE는 별도 Spring Bean의 트랜잭션 서비스에서 실행했다.

@Modifying(clearAutomatically = true)
@Query(value = """
        UPDATE study_session
        SET total_ms = base_ms + GREATEST(0, :nowMs - active_start_ms),
            sampled_ms = :nowMs
        WHERE active_start_ms IS NOT NULL AND sampled_ms < :nowMs
        """, nativeQuery = true)
int updateScores(@Param("nowMs") long nowMs);
  • 갱신 점수 = 종료된 공부 시간의 누적값 + max(0, 서버 기준 시각 − 현재 세션 시작 시각)
  • 엔티티 5,000개를 읽어 애플리케이션에서 계산한 뒤 개별 저장하는 대신 DB 안에서 한 번의 SQL로 계산했다. 애플리케이션의 엔티티 로딩·반복 처리와 개별 SQL 호출을 줄이기 위한 선택이었다.
  • 단순히 1,000ms를 더하면 실행이 지연되거나 빠진 만큼 오차가 누적될 수 있다. 서버 시각과 세션 시작 시각의 차이로 계산하면 다음 실행에서 실제 경과 시간을 따라잡을 수 있다.

실험 조건

이번 비교에서는 1초마다 갱신을 시작하고, 처리에 필요한 0.x초 지연을 허용하는 기준을 사용했다.

항목실험 조건
DBMySQL 8.4.0, Docker, CPU 2코어·메모리 2GiB
DB 주요 용량버퍼풀 256MiB, redo 용량 256MiB
애플리케이션Java 21, Spring Boot 3.5.6, JVM heap 512MiB
대상사용자 5,000명, 모두 공부 중인 상태로 고정
갱신단일 작업자, 1,000ms 실행 주기
로그·내구성binlog ON, ROW/FULL, sync_binlog=1, innodb_flush_log_at_trx_commit=1, doublewrite ON
반복동일 조건 2회, 각 실행 예열 2분 → 본 측정 5분 → 갱신 중단 후 회복 2분
관측갱신별 처리 기록과 약 1초 간격의 DB·JVM 상태 관측


예열 후 측정해 초기 준비 비용과 지속 갱신 비용을 구분했다. 갱신을 멈춘 뒤에도 관측해, 쓰는 동안 남아 있던 데이터 반영·정리 대상이 회복되는지 확인했다. 로그·커밋의 내구성 설정을 유지해 변경 기록을 저장하는 비용도 측정에 포함했다.

측정 지표무엇을 의미하는가왜 측정했는가
처리 p50·p99·최대값커밋을 포함한 갱신 처리 시간의 중앙값·느린 구간·최장값평균적인 비용과 느린 순간에도 실행 주기를 따라가는지 확인
실패 수·처리 시간 1초 초과실패한 갱신과 실행 주기를 넘긴 작업 수처리 시간 평균에 가려지는 실패·지연 확인
실제 변경 행/초성공한 커밋의 변경 행 수 ÷ 실제 측정 시간목표한 5,000행/초를 실제로 처리했는지 확인
갱신 완료 간격이전 완료부터 다음 완료까지의 시간새 점수가 저장되는 간격의 흔들림 확인
DB CPU측정 구간의 DB CPU 사용량연산 자원 여유 확인. I/O·잠금 대기는 별도 지표로 확인
버퍼풀 데이터 점유·디스크 읽기메모리의 페이지 점유와 디스크에서 읽어야 했던 페이지 수작은 데이터가 메모리에 올라간 유리한 조건인지 확인
dirty page·checkpoint age데이터 파일 반영 대기와 redo 위치 사이의 거리데이터 반영이 쓰기 속도를 따라가는지 확인
undo history·purge 수행량이전 버전 이력의 길이와 정리 작업량이전 버전이 계속 쌓이는지, 정리가 수행되는지 확인
행 잠금·로그 버퍼·페이지 확보 대기해당 자원을 기다린 횟수·시간의 증가량자원 부족이나 경쟁이 실제 대기로 나타나는지 확인
redo·binlog 생성량, binlog cache disk use로그 작업량과 임시 파일 사용 비율처리 가능성과 별개로 발생하는 저장·I/O 비용 확인
분별 추세·부하 종료 후 회복같은 부하에서의 변화와 중단 후 상태일시적인 증가와 계속 누적되는 상태 구분
JVM heap·GC애플리케이션 메모리 사용과 회수 활동애플리케이션 측의 단기 메모리 상태 확인


이 실험에는 랭킹 API 조회, 세션 시작·종료 경쟁, 긴 조회 트랜잭션, replica 부하가 없었다. 따라서 조회 성능 개선이나 실제 운영의 정합성·장기 안정성을 검증한 실험은 아니다.

※ 기존 랭킹 요구사항의 “응답 최신성 최대 1초”는 위의 주기 기준보다 엄격하다. 운영 적용 전에는 허용 지연 상한과 요구사항을 일치시켜야 한다. 기준 변경 검토에 이를 구분했다.

실험 결과

1️⃣ 모든 갱신이 1초 안에 성공적으로 처리되었다

지표실행 1실행 2
처리 p5098.93ms107.40ms
처리 p99299.55ms222.35ms
최장 처리617.26ms254.23ms
갱신 실패 / 처리 시간 1초 초과0 / 00 / 0
DB CPU17.25%17.72%
  • “매초 5,000명 갱신을 처리하지 못할 것”이라는 예상은 관측 결과와 달랐다. 갱신 실패와 처리 시간 1초 초과가 없었고, 목표에 근접한 변경량을 처리했다.
  • 처리 p50·p99는 한 번의 갱신에 드는 시간이다. 가장 오래 걸린 작업은 617.26ms였고, 관측한 모든 갱신은 1초 안에 처리됐다.
  • CPU 수치는 1코어 사용률 100%를 기준으로 한 값이다. 두 실행 평균은 17.49%로, 설정된 2코어 전체 예산의 약 8.75%였다.

2️⃣ 자원 누적으로 인한 부하도 확인되지 않았다

!02-db-cleanup-recovery.png

지표와 의미실행 1 / 실행 2관측 결과의 해석
버퍼풀 데이터 점유 최대32.92 / 34.14MiB버퍼풀 256MiB 안에 들어가는 작은 데이터 집합
dirty page:최대 8.38 / 8.25MiB, 회복 마지막 모두 0데이터 파일 반영이 지속적으로 뒤처지는 추세 없음
checkpoint age:최대 30.66 / 31.50MiB, 회복 마지막 모두 0checkpoint가 지속적으로 뒤처지는 추세 없음
undo history최대 27 / 27, 회복 마지막 모두 1지속 누적 없음. 바이트 수나 완전한 정리의 의미는 아님
JVM heap 평균 / GC 횟수274.31MiB·0회 / 274.37MiB·0회단기 상태 확인. 장기 메모리 안정성을 입증하지 않음
  • 측정 구간에서 위 지표의 지속 상승이나 관측한 자원 대기 증가가 없었다. 갱신 중단 후에도 감소해, 데이터 반영과 이전 버전 정리가 계속 뒤처지는 현상을 관측하지 못했습니다.
  • dirty·checkpoint·undo의 관측에서 지속 상승 추세가 없었다. 갱신을 중단한 뒤 dirty와 checkpoint는 0으로 회복됐다. 정리 대상이 존재했지만 계속 누적되지는 않았다.
  • 핵심은 최대값 하나의 크기보다 같은 부하에서 계속 증가하는지, 실제 대기를 만드는지, 부하를 해제하면 회복되는지였다. 이번 5분 측정에서는 자원 부족과 정리 지연이 지속적인 병목으로 나타나지 않았다.

최종 결론과 회고

검증하지 않은 1초 갱신 방식을 프로토타입으로 재평가하고, 측정 결과에 따라 기존 판단을 수정하며 설계 대안을 검증하는 기준을 구체화했다.

  • 초기 설계에서는 매초 갱신에 따른 쓰기와 로그 비용이 커질 것이라는 예상만으로 1초 갱신 방식을 제외했다. 비용이 발생한다는 사실과 그 비용을 DB가 감당하기 어렵다는 판단 사이에 실제 검증이 없었다.
  • 이 경험을 통해 설계 대안의 비용을 예상하는 것뿐 아니라, 그 비용이 실제 제약이 되는지 검증하는 과정이 필요하다는 것을 배웠다. 조회 시점 계산을 선택할 이유는 있었지만, 다른 대안을 제외하는 근거까지 충분히 확인하지는 못했다.
  • 다음 설계에서는 대안별 우려를 먼저 검증 가능한 질문으로 바꾸겠다. 이후 핵심 비용을 확인할 수 있는 최소 프로토타입을 만들고, 요구사항에서 정한 허용 지연과 자원 예산을 기준으로 대안을 비교하겠다.

이번 경험에서는 자신의 판단에서 검증이 빠진 부분을 찾아내고, 가설을 실험으로 구체화해 결과에 따라 결론을 수정하는 역량을 발휘했다. 앞으로도 대안을 제외하는 이유를 측정 결과로 설명할 수 있도록 설계 과정을 개선하겠다.

profile
백엔드 개발자를 지망하는 컴퓨터공학과 4학년 학생입니다 https://github.com/Juhye0k

0개의 댓글