Skip to content

활성 유저 확인과 쓰기 사이의 check-then-use 경합 차단 #776

Description

@sevineleven

UserService.findActiveById 로 활성 여부를 확인한 뒤 save / saveAndFlush 로 쓰는 경로는 check-then-use 다. 확인과 쓰기 사이에 softDelete 가 커밋되면 탈퇴한 유저에 쓰기가 반영될 수 있다.

  • updateProfile / updateProfileImageUrl / promoteToMember — user 자기 행을 UPDATE 한다. 탈퇴 cascade 가 닉네임·프로필을 익명화한 뒤 그 위에 원래 닉네임이 다시 써질 수 있다 (PII 가 tombstone 에 복원됨). 더 나아가, User 엔티티에 @DynamicUpdate 가 없어 Hibernate 가 전체 컬럼을 UPDATE 하므로 findActiveById 시점 스냅샷의 deletedAt=null 이 그대로 써져 tombstone 자체가 부활(탈퇴 계정이 되살아남) 할 수 있다. 이는 이 이슈를 단순 데이터 정합성 문제가 아니라 [P7] findById tombstone 미필터 — 활성 조회 메서드 분리 #691 이 막으려던 "탈퇴 계정 부활" 의 옆문으로 격상시킨다.
  • WishlistService.requireMember 이후 WishPersistenceService.persist* — 탈퇴 cascade 의 wish 하드삭제가 지나간 뒤 위시가 생성되면, 죽은 유저를 가리키는 행이 영구히 남는다.

같은 행을 두 트랜잭션이 UPDATE 하므로 InnoDB 행 락으로 직렬화는 되지만, 탈퇴가 먼저 커밋되면 뒤 UPDATE 가 최신 커밋을 읽고 tombstone 위에 쓴다. BaseEntity@Version 이 없어 낙관적 잠금도 걸리지 않는다.

도달 조건이 좁다 — 같은 유저가 탈퇴와 수정을 거의 동시에 쏴야 하고, 탈퇴 후엔 토큰이 denylist 로 막히므로 창이 access token 잔여 수명보다 훨씬 짧다. 그래서 급한 결함은 아니나, 구조적으로 열려 있는 건 사실이라 따로 판단해 닫는다.

무엇을

아래 중 하나를 골라 적용한다. 셋 다 blast radius 가 달라 설계 판단이 필요하다.

  • 비관적 락 — 쓰기 전에 SELECT ... FOR UPDATE 로 유저 행을 잡는다. 확실하지만 락 순서에 따라 데드락이 새로 생길 수 있어 쓰기 경로 전수 점검이 필요하다.
  • 낙관적 락BaseEntity@Version 추가. 모든 엔티티에 version 컬럼 마이그레이션이 필요하고 충돌 시 재시도 정책을 정해야 한다.
  • 영속화 경로 재검증WishPersistenceService.persist* 등 쓰기 트랜잭션 안에서 활성 여부를 한 번 더 본다. 가장 국소적이지만 경합 자체를 없애진 못하고 창을 좁힌다.

참고

출처: PR #773 CodeRabbit 리뷰

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

refactor구조 개선, 외부 동작 불변

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions