스크랩 동시 생성 시 유니크 제약 위반이 500으로 새던 문제 수정 - #252
Conversation
스크랩 생성이 "기존 스크랩 조회 → 없으면 저장" 순서로 동작해, 동시 요청(더블탭, 다중 기기)이 겹치면 둘 다 "스크랩 없음"을 보고 각자 저장을 시도할 수 있었다. (user_id, public_course_id) 유니크 제약이 이미 DB 레벨에서 경합을 막고 있었지만, 그 예외를 서비스에서 잡지 않아 그대로 500 + 불필요한 Slack/Sentry 알림으로 이어졌다. 실제 로컬 Postgres에 대해 두 스레드로 재현해 DataIntegrityViolationException이 그대로 새는 것을 먼저 확인한 뒤, HealthService에 이미 있던 처리 패턴(try/catch → ConflictException 409)을 동일하게 적용했다. 새로운 동시성 제어 기법을 도입한 게 아니라, 이미 DB가 보장하던 원자성의 결과를 애플리케이션이 우아하게 처리하도록 고친 것이다.
|
Warning Review limit reached
Next review available in: 59 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
CodeRabbit 리뷰 지적: recordActivityAndAwardStamp()가 REQUIRES_NEW라 호출 즉시 별도 트랜잭션으로 독립 커밋되는데, 이 호출이 메인 엔티티 저장보다 먼저(Scrap) 또는 저장 직후지만 같은 트랜잭션 커밋 전에(Course/Record) 실행되고 있었다. 메인 저장이 실패해 트랜잭션이 롤백돼도 이미 커밋된 스탬프/카운터는 되돌아가지 않는다. 실제로 재현: 같은 유저가 같은 코스를 동시에 스크랩하면 유니크 제약으로 한쪽만 저장에 성공하는데(#252), 수정 전에는 실패한 쪽도 스탬프/카운터가 반영돼 createdScrap이 실제 스크랩 개수(1)보다 많은 2로 기록됨을 테스트로 확인. OptimisticLockRetrier에 runAfterCommit()을 추가해, 메인 트랜잭션이 실제로 커밋된 뒤에만(TransactionSynchronization.afterCommit) 재시도 로직이 실행되게 했다 — 이 프로젝트에 이미 있던 랭킹(Redis) 갱신의 afterCommit 패턴과 동일한 구조. 재시도가 모두 소진되는 극단적인 경우엔 메인 작업 자체는 이미 성공했으므로 예외를 던지지 않고 로그로만 남긴다.
* 유저 활동 카운터(createdCourse 등)의 Lost Update 방지 RunnectUser.createdCourse/createdRecord/createdScrap/createdPublicCourse가 순수 in-memory ++ 로만 구현돼 있어, 동시 요청이 같은 유저 row를 커밋 전에 읽으면 한쪽 증가분이 조용히 유실될 수 있었다(로컬 Postgres 대상 재현 테스트로 실증, 다음 커밋 참고). RunnectUser에 @Version 낙관적 락을 추가하고, 카운터 증가 + 스탬프 지급을 메인 트랜잭션(코스/기록/스크랩 생성)에서 분리된 REQUIRES_NEW 트랜잭션 (UserStampService.recordActivityAndAwardStamp)으로 격리했다 — 낙관적 락 충돌이 코스/기록/스크랩 생성 자체까지 롤백시키지 않게 하기 위함이다. 충돌 시에는 OptimisticLockRetrier가 지터를 두고 재시도한다. * 유저 카운터 Lost Update 재현/수정 검증 테스트 추가 로컬 Postgres에 대해 실제 트랜잭션 두 개가 커밋 전 상태를 서로 보지 못하는 상황을 재현해, @Version 도입 전에는 조용한 데이터 손상 (assertion 실패)으로, 도입 후에는 명시적 충돌 감지 (ObjectOptimisticLockingFailureException)로 바뀌었음을 검증한다. 두 번째 테스트는 실제 프로덕션 경로(OptimisticLockRetrier + recordActivityAndAwardStamp)를 스레드 10개로 동시 호출해, 재시도로 충돌이 모두 해소되고 최종 카운트가 정확히 맞는지(호출부에는 예외가 전파되지 않는지) 검증한다. * 스탬프/카운터 갱신이 메인 저장 롤백과 무관하게 커밋되던 문제 수정 CodeRabbit 리뷰 지적: recordActivityAndAwardStamp()가 REQUIRES_NEW라 호출 즉시 별도 트랜잭션으로 독립 커밋되는데, 이 호출이 메인 엔티티 저장보다 먼저(Scrap) 또는 저장 직후지만 같은 트랜잭션 커밋 전에(Course/Record) 실행되고 있었다. 메인 저장이 실패해 트랜잭션이 롤백돼도 이미 커밋된 스탬프/카운터는 되돌아가지 않는다. 실제로 재현: 같은 유저가 같은 코스를 동시에 스크랩하면 유니크 제약으로 한쪽만 저장에 성공하는데(#252), 수정 전에는 실패한 쪽도 스탬프/카운터가 반영돼 createdScrap이 실제 스크랩 개수(1)보다 많은 2로 기록됨을 테스트로 확인. OptimisticLockRetrier에 runAfterCommit()을 추가해, 메인 트랜잭션이 실제로 커밋된 뒤에만(TransactionSynchronization.afterCommit) 재시도 로직이 실행되게 했다 — 이 프로젝트에 이미 있던 랭킹(Redis) 갱신의 afterCommit 패턴과 동일한 구조. 재시도가 모두 소진되는 극단적인 경우엔 메인 작업 자체는 이미 성공했으므로 예외를 던지지 않고 로그로만 남긴다. * 스크랩 동시성 테스트가 로컬 시드 데이터에 우연히 의존하던 문제 수정 이 테스트는 PublicCourseRepository를 목으로 대체하면서도 (user_id, public_course_id) FK 제약을 만족하려면 실제 course/public_course row가 DB에 있어야 한다는 걸 놓치고, 로컬 개발 DB에 우연히 남아있던 더미 데이터 (id=1)를 그대로 가정하고 작성했었다. PR #251 CI에서 재현: CI는 매번 새로 뜨는 빈 DB라 그 row가 아예 없어서, 두 스레드 모두 진짜 유니크 제약 위반이 아니라 FK 제약 위반으로 실패했다. 그런데 둘 다 같은 DataIntegrityViolationException → ConflictException 경로를 타기 때문에 겉보기엔 "정상 통과"처럼 보였다 — 실제로는 검증하려던 레이스와 무관한 이유로 우연히 같은 예외 타입이 나온 것뿐이었다. 진단 로그로 capturedException/스레드별 결과/실제 저장된 scrap 개수/public_course 실존 여부를 직접 찍어보고서야 확인했다. 이제 @beforeeach에서 필요한 course/public_course row를 테스트가 직접 INSERT(네이티브 SQL)해서 어떤 환경에서 실행해도 동일하게 동작하도록 했다. (로컬 Postgres 컨테이너에 postgis 패키지가 아예 설치돼 있지 않았던 것도 같이 확인해 postgresql-15-postgis-3를 설치했다 — 기존에 문서화된 "PostGIS 공유 라이브러리 결함"의 실제 원인이었다.) * CI Postgres 이미지를 PostGIS 포함 이미지로 교체 Course.path가 PostGIS geometry 컬럼인데 CI는 순정 postgres:15를 써서 course 테이블 생성 자체가 매번 실패하고 있었다. Hibernate가 ddl-auto 실패를 조용히 로그만 남기고 넘어가는 바람에 지금까지 드러나지 않았을 뿐, CI에 course 테이블이 존재한 적이 없었다 — 실제 Course/PublicCourse row가 필요 없던 테스트만 우연히 계속 통과해온 것이었다. ScrapConcurrencyTest에 실제 course/public_course row를 직접 만들도록 고치는 과정에서 발견했다. prod/staging과 동일한 postgis/postgis 이미지로 바꿔 CI가 실제 스키마로 검증하도록 한다. --------- Co-authored-by: 나미 <dnska6657@gmail.com>
작업 배경
ScrapService.createAndDeleteScrap)이 "기존 스크랩 조회 → 없으면 저장" 순서(TOCTOU)로 동작해, 더블탭이나 다중 기기로 동시 요청이 겹치면 둘 다 "스크랩 없음"을 보고 각자 저장을 시도할 수 있음.(user_id, public_course_id)유니크 제약이 이미 DB 레벨에서 경합을 막고 있었지만, 그 예외를 서비스가 잡지 않아 그대로 500 + 불필요한 Slack/Sentry 알림으로 이어짐.HealthService엔 이미 동일 패턴(try/catch DataIntegrityViolationException → ConflictException 409)이 적용돼 있었는데,ScrapService엔 일관되게 적용돼 있지 않았음 — 새 기법 도입이 아니라 기존 컨벤션을 놓친 곳을 찾아 맞추는 작업.변경 사항
common/constant/ErrorStatus.javaALREADY_EXIST_SCRAP_EXCEPTION(409) 추가scrap/service/ScrapService.javascrapRepository.save()를try/catch로 감싸DataIntegrityViolationException발생 시ConflictException으로 변환 (HealthService와 동일 패턴)영향 범위
검증 매트릭스
동시에_같은_코스를_스크랩하면_한쪽은_ConflictException으로_처리된다Test Plan
DataIntegrityViolationException이 서비스 밖으로 그대로 새는 것을 먼저 확인ConflictException으로 처리됨을 확인, 3연속 실행으로 플레이키니스 점검./gradlew test전체 통과 (262개 테스트, 실패/에러 0건)🤖 Generated with Claude Code