
이번 글에서는 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);
}
@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);
이번 비교에서는 1초마다 갱신을 시작하고, 처리에 필요한 0.x초 지연을 허용하는 기준을 사용했다.
| 항목 | 실험 조건 |
|---|---|
| DB | MySQL 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 | 실행 2 |
|---|---|---|
| 처리 p50 | 98.93ms | 107.40ms |
| 처리 p99 | 299.55ms | 222.35ms |
| 최장 처리 | 617.26ms | 254.23ms |
| 갱신 실패 / 처리 시간 1초 초과 | 0 / 0 | 0 / 0 |
| DB CPU | 17.25% | 17.72% |
!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, 회복 마지막 모두 0 | checkpoint가 지속적으로 뒤처지는 추세 없음 |
| undo history | 최대 27 / 27, 회복 마지막 모두 1 | 지속 누적 없음. 바이트 수나 완전한 정리의 의미는 아님 |
| JVM heap 평균 / GC 횟수 | 274.31MiB·0회 / 274.37MiB·0회 | 단기 상태 확인. 장기 메모리 안정성을 입증하지 않음 |
검증하지 않은 1초 갱신 방식을 프로토타입으로 재평가하고, 측정 결과에 따라 기존 판단을 수정하며 설계 대안을 검증하는 기준을 구체화했다.
이번 경험에서는 자신의 판단에서 검증이 빠진 부분을 찾아내고, 가설을 실험으로 구체화해 결과에 따라 결론을 수정하는 역량을 발휘했다. 앞으로도 대안을 제외하는 이유를 측정 결과로 설명할 수 있도록 설계 과정을 개선하겠다.