Skip to content

fix: 동시 취소 시 수강인원 갱신 유실 수정 - #99

Merged
xunssoie merged 4 commits into
devfrom
fix/98-registration-cancel-atomic-decrement
Aug 20, 2026
Merged

fix: 동시 취소 시 수강인원 갱신 유실 수정#99
xunssoie merged 4 commits into
devfrom
fix/98-registration-cancel-atomic-decrement

Conversation

@xunssoie

Copy link
Copy Markdown
Member

PR Summary

수강 취소의 인원 감소를 더티 체킹에서 원자적 조건부 UPDATE로 옮겨, 동시 취소에서 감소가 유실되던 문제를 해결했습니다. 500명 동시 취소로 재현하고 같은 조건으로 재측정해 검증했습니다.


Problem

DELETE /api/v1/registration/{courseId}의 수강인원 감소가 더티 체킹이었습니다. #90에서 신청 경로만 원자적 UPDATE로 바꾸고 취소 경로는 그대로 남아 있던 상태입니다.

Course course = courseRepository.findById(courseId)   // 락 없는 스냅샷 읽기
        .orElseThrow(() -> new RestApiException(COURSE_NOT_FOUND));
course.decrementEnrollment();                         // 읽은 값 - 1을 절대값으로 대입

문제 1 - 동시 취소에서 감소가 유실된다

여러 요청이 같은 값을 읽고 각자 계산한 절대값을 씁니다. 마지막 쓰기만 남고 나머지는 사라집니다.

문제 2 - 신청의 원자적 증가를 취소가 덮어쓴다

신청은 DB에서 current_enrollment + 1을 계산하는데 취소는 애플리케이션이 읽어둔 값을 덮어씁니다. 두 형태가 같은 컬럼에 섞여 있는 한, 취소 한 번이 그 사이 커밋된 신청들을 통째로 지울 수 있습니다.

문제 3 - 하한이 없다

decrementEnrollment()에 하한이 없고 컬럼에도 CHECK 제약이 없어, 유실이 누적되면 음수까지 내려갈 수 있습니다.

재현

정원 500 강의를 만석으로 채우고 500명이 동시에 취소하도록 폭발 부하를 걸었습니다(k6, per-vu-iterations, ramp 없음).

관측
등록 행 500개 전부 삭제됨
current_enrollment 497 잔존
반영된 감소 500번 중 3번
응답 500건 전부 200

응답만 봐서는 발견되지 않습니다. 애플리케이션은 아무 이상도 감지하지 못했습니다.

카운터가 실제보다 크게 남으면 자리가 비어 있는데도 신청 게이트 current_enrollment < max_capacity에 걸려 마감으로 보입니다. 정원 초과를 막으려던 조건이 반대로 정원 미달을 마감으로 만듭니다.

락이 걸려 있었는데도 유실된 이유

Innodb_row_lock_waits499건이었습니다. 사실상 모든 트랜잭션이 행 락을 기다렸고 평균 2213ms씩 대기했습니다. 그런데도 497이 유실됐습니다.

읽기가 락을 잡지 않기 때문입니다. findById는 평범한 SELECT라 REPEATABLE READ에서 MVCC 스냅샷을 읽습니다. 500개가 줄도 안 서고 즉시 같은 값을 읽고 지나갔고, 줄은 커밋 시점의 UPDATE에서만 섰습니다. 그때 각자 손에 든 값은 이미 확정된 상태였습니다.

lock_deadlocks가 0건인 것이 읽기가 락을 잡지 않았다는 증거입니다. S락을 쥔 채 X락 승격을 시도했다면 lock upgrade deadlock이 대량 발생했을 것입니다.

락은 쓰기 순서를 정해줄 뿐, 읽은 값이 낡았다는 사실은 고쳐주지 못합니다.


Solution

해결 1, 2 - 읽기와 쓰기를 한 문장으로 묶었습니다

신청 경로의 increaseEnrollmentWithinCapacity와 같은 형태로 대칭을 맞췄습니다. 인터리빙이 끼어들 창 자체가 없어지고, 같은 컬럼에 두 형태가 섞이는 문제도 함께 사라집니다.

@Modifying(flushAutomatically = true)
@Query("""
    UPDATE Course c
    SET c.currentEnrollment = c.currentEnrollment - 1
    WHERE c.id = :id
      AND c.currentEnrollment > 0
""")
int decreaseEnrollmentAboveZero(@Param("id") final long id);

해결 3 - 하한을 조건절이 판정합니다

AND c.currentEnrollment > 0으로 음수를 막고, 영향 행 수가 0이면 취소를 실패로 돌립니다.

영향 행 0은 두 경우에 나옵니다. 같은 회원이 취소를 연달아 누른 경합과, 인원이 실제 신청 수와 이미 어긋난 경우입니다. 둘 다 실패로 돌립니다. 앞의 경우는 실제로 취소할 것이 남아 있지 않고, 뒤의 경우는 조용히 넘기면 어긋난 상태가 드러나지 않기 때문입니다.

앞의 경우는 기존에도 실패했지만 응답이 나빴습니다. 뒤이은 delete가 0행에 걸려 Hibernate가 StaleStateException을 던지고, 핸들러가 없어 catch-all로 떨어져 500이 나갔습니다. 이제 409 REGISTRATION_CANCEL_CONFLICT(4004)로 나갑니다.

검증

같은 조건(500 VU, 만석 출발, ramp 없음)으로 재측정했습니다.

지표 수정 전 수정 후
카운터 정합 오차 497 0
카운터가 어긋난 강의 수 1 0
음수 인원 없음 없음
성공 요청 수 500 500
RPS 58.9 246.8
p99 8175.6ms 1964.6ms
락 대기 횟수 499 499
락 대기 시간(구간 평균) 2213ms 620.5ms
5xx 0 0

정합성을 얻으면서 처리량이 함께 올랐습니다. 동시성 제어는 대개 처리량을 깎는데 여기서는 반대였습니다.

락 대기 횟수가 수정 전과 정확히 같은 499건인 것이 이유를 말해줍니다. 락을 새로 걸지 않았고, 줄 서는 구조도 그대로입니다. 바뀐 것은 락을 쥔 채로 하던 일의 양입니다. Course@DynamicUpdate가 없어 더티 체킹은 인덱스에 걸린 11개를 포함해 27개 컬럼을 매번 다시 썼습니다. 새 UPDATE는 어느 인덱스에도 걸리지 않은 정수 하나만 건드립니다. courses에는 BTREE 4개와 FULLTEXT 1개가 붙어 있어 이 차이가 크게 나타납니다.

처리량 수치는 로컬 측정이라 절대값이 아니라 수정 전 대비 상대 변화로 봐주세요.

검토했지만 채택하지 않은 것

후보 이유
비관적 락 (SELECT ... FOR UPDATE) 정합성은 확보하지만, 락 구간을 커밋 시점의 UPDATE에서 읽기 시점까지 넓힙니다. 이미 p99 8.2초인 구간을 더 늘리는 방향입니다
낙관적 락 (@Version) 신청 경로가 JPQL bulk UPDATE라 version을 올리지 않습니다. 취소의 버전 검사가 통과하면서 신청의 증가가 유실되는 구조적 결함이 있습니다
CHECK (current_enrollment >= 0) 최후 방어선으로는 유효하나 사용자에게 돌려줄 응답이 없어 단독으로 쓸 수 없습니다. 이번 범위에서는 뺐습니다

함께 반영한 것

  • 참조가 사라진 Course.incrementEnrollment(), decrementEnrollment() 제거
  • 서비스 정책에 취소 시 하한과 실패 조건 명시
  • 취소 테스트 2건 추가. 인원이 실제로 1 줄어드는지(기존에 검증이 없었습니다), 인원이 0이면 4004로 실패하는지

배포 시 확인할 것

  • 스키마 변경 없음, 인프라 추가 없음

  • 제어가 DB 단일 문장 안에 있어 다중 인스턴스에서도 성립합니다

  • 이미 어긋난 카운터가 운영에 있으면 보정이 필요합니다. 이 결함은 카운터를 실제보다 크게 남기므로, 어긋난 강의는 자리가 비어 있는데도 신청이 막힌 상태입니다

    SELECT c.id, c.current_enrollment,
           (SELECT COUNT(*) FROM registrations r WHERE r.course_id = c.id) AS actual
    FROM courses c
    WHERE c.current_enrollment <> (SELECT COUNT(*) FROM registrations r WHERE r.course_id = c.id);

측정 기록과 재현 스크립트는 .claude/resources/concurrency/98/에 있습니다.


Related Issue

xunssoie and others added 4 commits August 20, 2026 18:09
취소 경로의 수강인원 감소가 더티 체킹이라, 읽은 값에서 1을 뺀 절대값을
덮어쓰는 형태였다. 동시에 들어온 취소들이 같은 값을 읽고 같은 값을 써서
감소가 유실된다. 500명 동시 취소를 재현한 결과 500번의 감소 중 3번만
반영되고 카운터가 497로 남았다. 응답은 500건 전부 200이라 애플리케이션은
아무 이상도 감지하지 못한다.

카운터가 실제보다 크게 남으면 자리가 비어 있는데도 신청 게이트인
current_enrollment < max_capacity에 걸려 마감으로 보인다.

신청 경로가 쓰는 원자적 조건부 UPDATE와 같은 형태로 감소를 옮겼다.
읽기와 쓰기가 한 문장이라 낡은 값을 쓸 창이 없고, 하한 조건도 같은
문장이 함께 판정한다. 영향 행 수가 0이면 취소를 실패로 돌린다.

- CourseRepository: decreaseEnrollmentAboveZero 추가
- ExceptionCode: REGISTRATION_CANCEL_CONFLICT(409, 4004) 추가
- RegistrationService: 엔티티 조회와 더티 체킹 제거
- Course: 참조가 사라진 incrementEnrollment, decrementEnrollment 제거

영향 행 0은 두 경우에 나온다. 같은 회원이 취소를 연달아 누른 경합과,
인원이 실제 신청 수와 이미 어긋난 경우다. 둘 다 실패로 돌린다. 전자는
그대로 두면 뒤이은 delete가 0행에 걸려 StaleStateException으로 500이
나가던 자리이므로 응답 품질도 함께 개선된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
취소가 현재 수강인원을 실제로 줄이는지 확인하는 테스트가 없었다.
인원이 0인 과목의 취소가 실패하는지도 검증 대상이 아니었다.

감소는 벌크 UPDATE라 영속성 컨텍스트의 Course가 갱신되지 않는다.
비우고 다시 읽어야 실제 반영 값을 본다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
불변식 정의부터 채택까지의 근거를 남긴다. 시드, k6 폭발 스크립트,
불변식 검증 SQL이 함께 있어 같은 조건으로 재측정할 수 있다.

- 원본: I1 위반 497건, RPS 58.9, p99 8175.6ms
- 채택안: I1 위반 0건, RPS 246.8, p99 1964.6ms

락 대기 횟수는 원본과 같은 499건이다. 락을 새로 걸지 않았고,
락을 쥔 채로 하던 일의 양이 줄었다. 더티 체킹은 인덱스에 걸린
11개를 포함해 27개 컬럼을 매번 다시 썼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이번 측정에서 실제로 걸린 지점들을 절차에 반영한다.

기존 인스턴스를 내리지 않고 bootRun을 다시 돌리면 포트 충돌로 새
프로세스만 죽고 옛 코드가 계속 응답한다. 그 상태로 재측정하면 후보를
적용하지 않은 채 효과 없음으로 오판한다. restart-app.sh가 포트 정리와
기동 확인, 커넥션 풀 충전을 한 번에 처리한다.

명령을 임시 디렉토리의 스크립트로 넘기면 경로가 붙여넣기에서 잘리고,
스크립트 안의 상대 경로가 실행 위치를 따라가 깨진다. 레포 루트 기준
전체 경로로 한 블록에 주도록 규칙을 세웠다.

- MYSQL_CONC 문자열 변수를 mysqlc 함수로 교체. 호스트에 mysql
  클라이언트가 없고 zsh는 문자열 변수를 단어 분할하지 않는다
- 시드 적재를 SOURCE에서 cat 이어붙이기로 교체. SOURCE는 컨테이너 안
  클라이언트가 호스트 경로를 못 찾아 조용히 실패한다
- 서명키 추출 명령이 주석 3줄에 막혀 빈 값을 넘기던 것 수정
- 포털 Oracle 로그인 전제 서술 제거. 자체 회원 인증으로 전환됐다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xunssoie xunssoie self-assigned this Aug 20, 2026
@github-actions

Copy link
Copy Markdown

Test Results

286 tests   286 ✅  4s ⏱️
 91 suites    0 💤
 91 files      0 ❌

Results for commit 881fd52.

@xunssoie
xunssoie merged commit 9ec9c2a into dev Aug 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: 동시 취소 시 수강인원 갱신 유실 수정

1 participant