Skip to content

[Feat/#93] 대여 신청 재고 차감 낙관적 락 적용 - #94

Open
tnals0924 wants to merge 7 commits into
feat/#74-billilge-rental-apply-returnfrom
feat/#93-rental-stock-optimistic-lock
Open

tnals0924 wants to merge 7 commits into
feat/#74-billilge-rental-apply-returnfrom
feat/#93-rental-stock-optimistic-lock

Conversation

@tnals0924

Copy link
Copy Markdown
Member

#️⃣연관된 이슈

ex) #이슈번호, #이슈번호

PR #75(feat/#74-billilge-rental-apply-return) 위에 쌓은 PR이라 base를 그 브랜치로 두었다. #75가 머지되면 base를 main으로 바꾼다.

🎯 해결하려는 문제가 무엇인가요?

이 PR이 다루는 문제나 요구사항을 구체적으로 설명해주세요 (버그, 성능 이슈, 기능 요청 등)

대여 신청의 재고 차감(findById → Item.decreaseStock → save)에 동시성 보호가 없다(508b16b에서 비관적 락 제거). 같은 물품에 신청이 동시에 들어오면 두 요청이 같은 재고를 읽고 둘 다 통과해서, 재고가 음수가 되거나 차감 하나가 사라진다(lost update).

❓ 왜 해결해야 하나요?

지금 이 작업이 필요한 이유를 적어주세요. 답이 궁색하다면 아직 하지 않아도 될 작업일 수 있습니다

재고가 실제 물품 수와 어긋나면 없는 물품이 대여 처리된다. 신청이 한 물품에 몰리는 순간 바로 드러나는 문제라, 대여 신청 API를 내보내기 전에 막아 둔다.

⭐ 어떻게 해결했나요?

선택한 구현 방식을 요약해주세요

락 — 버전 컬럼 없는 낙관적 락

  • ItemJpaEntity에 @DynamicUpdate + @OptimisticLocking(type = OptimisticLockType.DIRTY)를 붙였다.
  • UPDATE가 update items set count=? where id=? and count=?(읽은 값)로 나간다. 그 사이 재고가 바뀌었으면 0건이 되어 OptimisticLockingFailureException이 난다.
  • 버전 컬럼이 없어서 도메인 Item, DB 스키마, ItemRepositoryImpl은 바뀌지 않는다.

재시도 — core:common의 LockExecutor

  • lockExecutor.executeOptimistic(action)은 시도마다 새 트랜잭션을 열고 action 전체를 실행한다.
  • 낙관적 락 충돌만 최대 3회 재시도한다(시도 사이 30~100ms 무작위 대기). 다 쓰면 OPTIMISTIC_LOCK_CONFLICT(409)로 실패한다.
  • 이미 트랜잭션 안에서 부르면 IllegalStateException을 던진다. 같은 트랜잭션에서 재시도하면 1차 캐시와 REPEATABLE READ 스냅샷이 예전 값을 계속 돌려줘서 매번 충돌하기 때문이다.

RentalApplyUseCase

  • @Transactional을 떼고 본문 전체를 lockExecutor.executeOptimistic(() -> { ... })로 감쌌다.
  • 회비 확인부터 이력 생성까지는 시도마다 한 트랜잭션이라 원자성은 그대로다. 컨트롤러는 바뀌지 않는다.

컨벤션 문서

  • core:common에 spring-context·spring-tx 허용 (architecture.md 2·2-1·4-2·7절, 00-index.md)
  • 충돌 재시도가 필요한 UseCase는 @Transactional 대신 LockExecutor 안에서 Service를 조합한다 (architecture.md 6-1절, coding-style.md 2-9절)

테스트 (Testcontainers MySQL, Docker가 없으면 건너뜀)

  • LockExecutorTest 7개: 재시도, 최대 횟수, 다른 예외는 재시도하지 않음, 트랜잭션 안 호출 거부, 대기 중 인터럽트
  • ItemOptimisticLockTest 5개: 동시 차감 충돌, SQL 직접 수정 감지, 다른 컬럼 동시 수정은 둘 다 반영, LockExecutor 재시도 성공, OSIV(EntityManager가 스레드에 묶인 상태)에서도 재시도 성공
  • RentalApplyConcurrencyTest 2개: 재고 5개에 20건 동시 신청 → 초과 차감 없음·이력 수 = 차감 수 / 같은 회원이 같은 대여품 5건 동시 신청 → 대여 중 이력 1건
  • 모듈마다 MySQL 컨테이너를 한 번만 띄우는 베이스 클래스(MySqlJpaTest, MySqlIntegrationTest)를 두고, 컨테이너는 tmpfs + 디스크 동기화 끔으로 설정했다.

🧩 이 PR의 한계 & 트레이드오프

구현하며 포기하거나 미룬 것, 감수한 트레이드오프(성능 vs 가독성 등)를 적어주세요. 없다면 없다고 적어주세요

  • 읽는 요청과 저장하는 요청이 다르면 막지 못한다. DIRTY는 같은 영속성 컨텍스트에서 읽은 값과만 비교한다. 나중에 물품 관리 API의 수정 화면에서 예전 재고 값으로 저장하면, 그 사이의 대여 차감을 오류 없이 덮어쓴다. 화면 단위 충돌 감지가 필요해지면 @Version + API로 버전 주고받기를 도입한다.
  • core:common이 순수 Java가 아니게 된다. 허용 범위를 spring-context·spring-tx로 문서에 못 박았지만, 이를 검사하는 ArchUnit 규칙은 없어서 리뷰로 지켜야 한다.
  • 재시도 로그가 없다. core:common에 slf4j가 없어서, 마지막 409만 GlobalExceptionHandler가 warn으로 남긴다.
  • Hibernate 전용 어노테이션을 쓴다. (@JdbcTypeCode처럼 이미 쓰고 있는 범주)
  • 통합 테스트는 Docker가 필요하고 로컬에서만 돈다. 테스트를 돌리는 CI 워크플로가 없다. Docker가 없으면 건너뛰어서 통과한 것처럼 보일 수 있다.
  • 컨테이너 설정이 두 베이스 클래스에 중복된다. 테스트 JVM이 모듈마다 따로라서다. java-test-fixtures로 합칠 수 있지만 새 빌드 방식이라 이번에는 넣지 않았다.

⛓️ 기존 기능에 미치는 영향

다른 모듈·기능에 미치는 파급 범위와 호환성 문제 여부를 적어주세요

  • 대여 신청: 트랜잭션을 여는 주체가 UseCase의 @Transactional에서 LockExecutor로 바뀐다. 동작과 원자성은 같고, 충돌하면 재시도·409가 추가된다.
  • items UPDATE는 바뀐 컬럼만 갱신한다(@DynamicUpdate). 지금 Item을 저장하는 경로는 재고 차감 하나뿐이다.
  • core:common에 의존하는 모듈의 런타임에 spring-tx·spring-context가 들어온다. 앱 전체(bootstrap)에는 원래 있던 의존이다.
  • 스키마 변경·마이그레이션은 없다.
  • 테스트 의존이 추가된다: Testcontainers(MySQL, JUnit), spring-boot-testcontainers, spring-jdbc(bootstrap 테스트용).

🔀 Edge Case & 실패 시나리오

정상 흐름이 아닌 예외 상황과 그 처리 방식을 적어주세요

  • 3번 모두 충돌 → 409 OPTIMISTIC_LOCK_CONFLICT("잠시 후 다시 시도해 주세요")
  • 재시도에서 재고가 모자라게 됨 → ITEM_OUT_OF_STOCK
  • 같은 회원이 같은 대여품을 동시에 신청 → 늦은 쪽이 충돌 후 재시도에서 먼저 커밋된 이력을 보고 RENTAL_ITEM_DUPLICATED. 중복 대여 경합도 함께 막힌다.
  • 운영진이 SQL로 count를 직접 바꾸는 중에 신청 → 충돌로 감지하고 재시도
  • LockExecutor를 트랜잭션 안에서 호출 → IllegalStateException(코드 실수라 500)
  • 재시도 대기 중 인터럽트 → 409, 인터럽트 플래그 유지
  • OSIV가 켜진 요청 → 롤백 때 JpaTransactionManager가 EntityManager를 비우므로 재시도는 DB에서 새로 읽는다(테스트로 확인)

📋 검토한 대안과 선택 이유

검토했으나 채택하지 않은 방식과, 지금 방식을 선택한 이유를 적어주세요

  • 컨트롤러에서 LockExecutor로 감싸기: web 계층에 락 로직이 들어가서 제외했다.
  • UseCase에서 TransactionTemplate을 직접 쓰거나, 트랜잭션 부분을 별도 빈으로 분리: 각각 새 패턴·클래스 분리가 필요하다. 대신 LockExecutor가 트랜잭션까지 열게 하고 core:common에 spring-tx를 허용했다. 호출부는 람다 안에서 Service를 조합하기만 하면 된다.
  • @Version 컬럼: 지금 save가 도메인 → 새 엔티티 → merge 방식이라 도메인 Item이 버전을 들고 있어야 하고, 마이그레이션도 필요하다. 운영진이 SQL로 재고를 고칠 때 버전도 올려야 한다. DIRTY는 이 셋이 모두 필요 없어서 택했다. 화면 단위 충돌 감지가 필요해지면 그때 도입한다.
  • 비관적 락: 508b16b에서 뺀 방식이다.

💬 리뷰 포인트

리뷰어가 특별히 봐주었으면 하는 부분이나 위험한 영역을 먼저 표시해주세요 (예: [r] 꼭 반영 / [c] 웬만하면 반영 / [a] 사소한 의견)

  • [r] core:common에 Spring 의존 허용 — 컨벤션 변경이라 합의가 필요하다 (architecture.md 변경분)
  • [r] LockExecutor의 트랜잭션 경계와 RentalApplyUseCase에서 @Transactional을 뗀 부분
  • [c] DIRTY 방식의 한계(수정 화면 덮어쓰기)를 물품 관리 API 설계 때 감수할지
  • [a] 재시도 횟수·대기 시간(3회, 30~100ms)

@coderabbitai

coderabbitai Bot commented Oct 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: billilge/stream-server/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 626a5437-40ea-4676-b50f-5d916f3a48a8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.


for (int attempt = 1; ; attempt++) {
try {
return transactionTemplate.execute(status -> action.get());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재시도시 매번 새로운 트랜잭션을 열어서 실행하는 점 확인했습니다!

indexes = @Index(name = "idx_items_name", columnList = "name")
)
@DynamicUpdate
@OptimisticLocking(type = OptimisticLockType.DIRTY)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Version 대신 OptimisticLockType.DIRTY을 사용해 version 컬럼 추가 없이 변경된 컬럼을 감지하는 방식으로 구현한 점 이해했습니다.

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