Skip to content

스크랩 동시 생성 시 유니크 제약 위반이 500으로 새던 문제 수정 - #252

Merged
unam98 merged 1 commit into
mainfrom
feature/scrap-concurrent-toggle-fix
Aug 23, 2026
Merged

스크랩 동시 생성 시 유니크 제약 위반이 500으로 새던 문제 수정#252
unam98 merged 1 commit into
mainfrom
feature/scrap-concurrent-toggle-fix

Conversation

@unam98

@unam98 unam98 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

작업 배경

  • 백엔드 채용 준비 과정에서 "동시성을 다뤄본 경험"을 이력서에 구체적으로 녹이기 위해, Runnect 기존 코드에서 유사한 동시성 취약점이 더 있는지 점검하다가 발견함.
  • 스크랩 생성(ScrapService.createAndDeleteScrap)이 "기존 스크랩 조회 → 없으면 저장" 순서(TOCTOU)로 동작해, 더블탭이나 다중 기기로 동시 요청이 겹치면 둘 다 "스크랩 없음"을 보고 각자 저장을 시도할 수 있음. (user_id, public_course_id) 유니크 제약이 이미 DB 레벨에서 경합을 막고 있었지만, 그 예외를 서비스가 잡지 않아 그대로 500 + 불필요한 Slack/Sentry 알림으로 이어짐.
  • 같은 프로젝트의 HealthService엔 이미 동일 패턴(try/catch DataIntegrityViolationException → ConflictException 409)이 적용돼 있었는데, ScrapService엔 일관되게 적용돼 있지 않았음 — 새 기법 도입이 아니라 기존 컨벤션을 놓친 곳을 찾아 맞추는 작업.

변경 사항

영역 내용
common/constant/ErrorStatus.java ALREADY_EXIST_SCRAP_EXCEPTION(409) 추가
scrap/service/ScrapService.java scrapRepository.save()try/catch로 감싸 DataIntegrityViolationException 발생 시 ConflictException으로 변환 (HealthService와 동일 패턴)

영향 범위

  • 스크랩 생성 시 동시 요청 경합 상황에 한정. 정상 경로(경합 없음) 동작은 변화 없음.
  • 새로운 동시성 제어 기법(락, 재시도 등)을 도입하지 않음 — DB 유니크 제약이 이미 원자적으로 보장하던 것을, 애플리케이션이 그 결과(예외)를 우아하게 처리하도록만 고침. 오버엔지니어링 없이 최소 변경으로 해결.
  • 런타임 영향: 경합이 실제로 발생하는 극히 드문 경우에만 응답 코드가 500 → 409로 바뀜. 그 외 영향 없음.

검증 매트릭스

영향 범위 테스트 코드
동시 스크랩 요청 경합 시 409(ConflictException)로 처리됨 (실제 로컬 Postgres, 2스레드로 재현) 동시에_같은_코스를_스크랩하면_한쪽은_ConflictException으로_처리된다

Test Plan

  • 수정 전: 같은 재현 테스트로 DataIntegrityViolationException이 서비스 밖으로 그대로 새는 것을 먼저 확인
  • 수정 후: 같은 재현 테스트가 ConflictException으로 처리됨을 확인, 3연속 실행으로 플레이키니스 점검
  • ./gradlew test 전체 통과 (262개 테스트, 실패/에러 0건)
  • 로컬 테스트 데이터 정리 확인(잔존 row 0건)

🤖 Generated with Claude Code

스크랩 생성이 "기존 스크랩 조회 → 없으면 저장" 순서로 동작해, 동시
요청(더블탭, 다중 기기)이 겹치면 둘 다 "스크랩 없음"을 보고 각자
저장을 시도할 수 있었다. (user_id, public_course_id) 유니크 제약이
이미 DB 레벨에서 경합을 막고 있었지만, 그 예외를 서비스에서 잡지
않아 그대로 500 + 불필요한 Slack/Sentry 알림으로 이어졌다.

실제 로컬 Postgres에 대해 두 스레드로 재현해 DataIntegrityViolationException이
그대로 새는 것을 먼저 확인한 뒤, HealthService에 이미 있던 처리
패턴(try/catch → ConflictException 409)을 동일하게 적용했다. 새로운
동시성 제어 기법을 도입한 게 아니라, 이미 DB가 보장하던 원자성의
결과를 애플리케이션이 우아하게 처리하도록 고친 것이다.
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@unam98, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review or push new commits to the PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e7afa24-aec8-491e-bd52-2637c412400d

📥 Commits

Reviewing files that changed from the base of the PR and between 0a8a26e and 4cff35f.

📒 Files selected for processing (3)
  • src/main/java/org/runnect/server/common/constant/ErrorStatus.java
  • src/main/java/org/runnect/server/scrap/service/ScrapService.java
  • src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@unam98
unam98 merged commit 064aba5 into main Aug 23, 2026
2 checks passed
@unam98
unam98 deleted the feature/scrap-concurrent-toggle-fix branch August 23, 2026 12:54
unam98 pushed a commit that referenced this pull request Aug 23, 2026
CodeRabbit 리뷰 지적: recordActivityAndAwardStamp()가 REQUIRES_NEW라 호출
즉시 별도 트랜잭션으로 독립 커밋되는데, 이 호출이 메인 엔티티 저장보다
먼저(Scrap) 또는 저장 직후지만 같은 트랜잭션 커밋 전에(Course/Record) 실행되고
있었다. 메인 저장이 실패해 트랜잭션이 롤백돼도 이미 커밋된 스탬프/카운터는
되돌아가지 않는다.

실제로 재현: 같은 유저가 같은 코스를 동시에 스크랩하면 유니크 제약으로 한쪽만
저장에 성공하는데(#252), 수정 전에는 실패한 쪽도 스탬프/카운터가 반영돼
createdScrap이 실제 스크랩 개수(1)보다 많은 2로 기록됨을 테스트로 확인.

OptimisticLockRetrier에 runAfterCommit()을 추가해, 메인 트랜잭션이 실제로
커밋된 뒤에만(TransactionSynchronization.afterCommit) 재시도 로직이 실행되게
했다 — 이 프로젝트에 이미 있던 랭킹(Redis) 갱신의 afterCommit 패턴과 동일한
구조. 재시도가 모두 소진되는 극단적인 경우엔 메인 작업 자체는 이미 성공했으므로
예외를 던지지 않고 로그로만 남긴다.
unam98 added a commit that referenced this pull request Aug 23, 2026
* 유저 활동 카운터(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>
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.

2 participants