Skip to content

[Fix] 폴더 삭제 잠금이 다른 폴더와 다른 사용자에게 번지지 않게 한다 - #322

Merged
KiSeungMin merged 1 commit into
developfrom
fix/folder-lock-scope
Sep 19, 2026
Merged

KiSeungMin merged 1 commit into
developfrom
fix/folder-lock-scope

Conversation

@KiSeungMin

Copy link
Copy Markdown
Contributor

✔️ 연관 이슈

📝 작업 내용

폴더 하나를 지우는 동안 그 폴더와 아무 상관 없는 문제 등록이 줄을 서고, 그 대기가 커넥션 풀을 바닥내 다른 계정의 요청까지 실패하던 것을 고쳤습니다.

1. 원인은 잠금 두 개였습니다

어디 무슨 잠금을 잡았나 무엇이 막혔나
FolderService.deleteFoldersWithProblems idx_folder_user_id 등치 스캔으로 사용자 폴더 전체 배타 잠금 그 사용자의 모든 폴더로 들어오는 등록과 폴더 생성
ProblemReviewReminderService.cancelPendingByProblem problem_id 범위 UPDATE 로 uq_problem_review_reminder_seq 인덱스 끝 갭까지 next-key lock 모든 사용자의 새 문제 등록. 등록은 커밋 직후 예약을 INSERT 하는데 problem_id 가 계속 커지니 그 INSERT 가 전부 supremum 갭에 들어갑니다
  • 두 번째가 계정을 넘어가는 쪽입니다. 폴더 잠금만 좁힌 상태로 재현 테스트를 돌렸더니 다른 계정의 등록이 여전히 막혔고, performance_schema.data_lock_waits 에 problem_review_reminder.uq_problem_review_reminder_seq RECORD X,INSERT_INTENTION [supremum pseudo-record] 가 그대로 찍혔습니다.
  • 30.2초는 잠금이 아니라 커넥션 대기입니다. 기다리는 요청이 커넥션을 하나씩 물고 있어서 Hikari 풀이 바닥나면, 폴더와도 그 사용자와도 무관한 요청이 connection-timeout 기본값 30초를 채우고 실패합니다. dev/prod yml 에 spring.datasource.hikari 설정이 없어서 풀은 기본값 10개입니다.

2. 서브트리만 잠가도 고아 데이터가 안 생기는 이유

  • 내려가는 조회를 전부 잠금 조회로 뒀습니다. [Fix] 폴더 삭제와 문제 등록이 겹쳐도 삭제된 폴더를 가리키는 문제가 남지 않게 한다 #284 가 사용자 폴더 전체를 잡은 이유는 "하위 폴더를 알려면 먼저 읽어야 하고, 그 순간 스냅숏이 박힌다" 였습니다. 잠금 조회는 스냅숏을 만들지 않고 최신 행을 읽으므로, 한 단계씩 내려가며 잠그는 동안 스냅숏이 박히지 않습니다.
  • 잠근 뒤에 끼어들 자리가 없습니다. 어떤 폴더 아래에 폴더를 만들거나 옮기려면 findParentFolderForShare 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 기다렸다가 삭제된 부모를 보고 기존 FOLDER_NOT_FOUND 로 거절됩니다.
  • 먼저 들어온 생성도 빠지지 않습니다. 아직 안 잠근 단계에 생성이 먼저 들어왔으면 우리가 그 부모를 잠그려고 기다리는 동안 커밋되고, 그다음 잠금 조회가 최신 행을 읽어 잡아냅니다.
  • 전체 폴더 삭제(deleteAllUserFoldersWithProblems)는 그대로 뒀습니다. 어차피 사용자 폴더 전부가 대상이라 좁힐 것이 없고, 계정 정리 때만 타는 드문 경로입니다.

3. #291 의 갭 잠금은 폴더 쪽만 없어집니다

  • idx_folder_user_id 에는 이제 잠금이 안 잡힙니다. 기본 키 등치 조회는 REC_NOT_GAP 이라, 인접 사용자 구간을 덮던 갭이 사라집니다. 재현 테스트가 이 조건을 단언합니다.
  • 하위 폴더를 훑는 parent_folder_id 조회에는 갭 잠금이 남습니다. 다만 덮는 구간이 "그 사용자의 user_id 구간 전체" 에서 "삭제 대상 폴더를 부모로 가진 항목 앞뒤" 로 줄어듭니다.
  • [Fix] 폴더 삭제가 잠금을 쥔 채 이미지마다 브로커를 부르지 않게 한다 - 삭제 중 문제 등록이 잠금 대기로 실패할 수 있다 #291 의 본 내용(브로커 호출을 커밋 뒤로 미루기)은 이 PR 에서 손대지 않았습니다. 잠금 보유 시간이 길어지는 문제는 그대로이고, 다만 그동안 막히는 범위가 삭제 대상 서브트리로 줄었습니다.

👤 사용자 영향

  • 폴더를 지우는 동안 기다리는 것은 그 폴더와 그 하위 폴더로 들어오는 등록뿐입니다. 같은 사용자라도 다른 폴더로 들어오는 등록과 폴더 생성은 안 막힙니다.
  • 다른 계정은 이제 영향을 받지 않습니다. 폴더 조회도, 문제 등록도 막히지 않습니다.
  • 삭제와 겹친 등록은 기존과 같은 FOLDER_NOT_FOUND(404) 를 받습니다. 새 에러 코드는 없습니다.

🔌 API 호환성

  • 요청/응답 DTO 변경 없음
  • 기존 Flutter 앱 버전과 호환 확인 (응답 형식이 그대로라 따로 확인하지 않았습니다)
  • breaking change 있음

🗄️ DB migration

  • 없음
  • 있음: migration 파일과 기존 데이터 호환성 확인

🔐 인증/권한

  • 영향 없음
  • 사용자별 데이터 소유권 검증 확인 (findFolderEntity(folderId, userId) 를 잠금 조회 결과 검증으로 옮기면서 validateFolderOwner 와 루트 폴더 거절이 그대로 남아 있는지, 요청받은 순서대로 판정되는지 확인했습니다)
  • 관리자/특수 권한 영향 확인

✅ 검증 결과

  • 재현 테스트 4개를 먼저 만들었고 수정 전 코드에서 4개 모두 실패했습니다 (FolderDeleteLockBlastRadiusTest). 순서는 기존 경합 테스트와 같게 삭제 트랜잭션을 커밋 직전에 세워 두고 잽니다. sleep 은 쓰지 않았습니다.
테스트 무엇을 보나
삭제 대상 서브트리 밖의 폴더 행을 잠그지 않는다 performance_schema.data_locks 의 folder PRIMARY 레코드 잠금이 대상 폴더와 그 하위 폴더뿐인지, idx_folder_user_id 에 잠금이 없는지
무관한 폴더로 들어오는 등록은 안 막힌다 같은 사용자, 삭제 대상이 아닌 폴더
다른 계정의 등록은 안 막힌다 복습 알림 갭 잠금이 계정을 넘어가던 부분
등록이 몰려도 전부 안 막힌다 동시 등록 여러 건 + 삭제

⚠️ 이 PR 로 해결되지 않은 것 (별도 판단이 필요합니다)

문제 등록 한 건이 커넥션을 두 개 동시에 씁니다. 이게 30초짜리 전역 실패를 만든 마지막 조각인데, 폴더 삭제와는 다른 원인이라 이 PR 에서 건드리지 않았습니다.

  • ProblemReviewReminderService.handleProblemCreated 가 AFTER_COMMIT + REQUIRES_NEW 라, 바깥 트랜잭션의 커넥션이 아직 반납되기 전에 예약 INSERT 용 커넥션을 하나 더 빌립니다.
  • 그래서 동시 등록이 풀 크기(기본 10)에 닿으면 폴더 삭제가 없어도 풀이 서로를 기다리며 멈춥니다. 재현 테스트에서 8건을 몰아 넣으면 "사용중 10, 유휴 0, 커넥션 대기중인 스레드 8" 로 멈추고 전부 connection-timeout 을 채웁니다. [Fix] 폴더 삭제 잠금이 전역 장애로 번진다 - 등록이 60초 뒤 504 인데 저장은 되고 다른 사용자까지 실패한다 #319 에 적힌 "한 번은 500 에 30.4초" 가 이 모양입니다.
  • 재현 테스트는 이 선을 넘지 않는 만큼만(풀 크기의 절반) 몰아 넣도록 두고, 그 이유를 테스트에 적어 뒀습니다.
  • 손보는 방법은 예약 생성을 진짜 비동기로 빼거나 등록 트랜잭션 안으로 넣는 쪽인데, 알림 발송 경로라 영향 범위를 따로 봐야 할 것 같습니다. spring.datasource.hikari.maximum-pool-size 가 dev/prod 양쪽에 없어서 기본값 10 으로 도는 것도 같이 보시면 좋겠습니다(Tomcat threads.max 는 50 입니다).

🚀 배포 리스크

  • 삭제 대상에 조상과 자손이 함께 들어간 일괄 등록과 겹치면 교착이 날 수 있습니다. 삭제는 서브트리를 위에서 아래로 잠그고, v2 일괄 등록은 폴더를 기본 키 오름차순으로 공유 잠금합니다. 폴더는 부모를 먼저 만들어야 생기므로 보통 자손의 id 가 더 커서 두 순서가 같은데, 폴더를 나중에 만든 폴더 밑으로 옮긴 경우에만 순서가 뒤집힙니다. 예전 lockAllByUserId 는 항상 id 오름차순이라 이 창이 없었습니다. 교착이 나면 MySQL 이 한쪽을 즉시 롤백하므로 그 요청 하나가 500 이 되고, 지금처럼 전역으로 번지지는 않습니다.
  • 폴더를 한 단계씩 내려가느라 트리 깊이만큼 쿼리가 늘었습니다. 예전에는 조회 한 번이었고 지금은 깊이 + 1 번입니다. 실제 폴더 트리는 얕아서 문제가 될 것 같지 않습니다.
  • 복습 알림 취소가 문제마다 조회 한 번씩 늘었습니다. 문제 60개짜리 폴더면 60번입니다. 갭 잠금을 없애는 대가라 이쪽이 낫다고 봤습니다.
  • 이미 운영 DB 에 생긴 고아 데이터는 이 PR 로 정리되지 않습니다. 확인 쿼리는 [Fix] 폴더 삭제와 문제 등록이 겹쳐도 삭제된 폴더를 가리키는 문제가 남지 않게 한다 #284, [Fix] 전체 폴더 삭제와 폴더 생성/수정에도 폴더 잠금을 넣는다 #294 본문에 있는 것을 그대로 쓰면 됩니다.

↩️ 롤백/대응 방법

  • 스키마 변경이 없어서 이 PR 을 되돌리면 그대로 이전 동작으로 돌아갑니다.
  • 폴더 쪽과 복습 알림 쪽은 서로 독립이라 한쪽만 되돌려도 됩니다. 다만 폴더 쪽만 남기면 다른 계정의 등록이 다시 막히고, 복습 알림 쪽만 남기면 같은 사용자의 무관한 폴더 등록이 다시 막힙니다.

- #233 을 막으려고 넣은 lockAllByUserId 가 사용자 폴더 전체를 배타 잠금으로 잡아서, 삭제와 아무 상관 없는 폴더로 들어오는 문제 등록까지 삭제가 끝날 때까지 줄을 섰다. 기다리는 요청이 커넥션을 하나씩 물고 있어서 Hikari 풀(기본 10개)이 바닥나면 다른 사용자의 단순 조회까지 connection-timeout 30초를 채우고 실패했다
- deleteFoldersWithProblems 는 이제 삭제 대상 서브트리만 잡는다. lockAllByIdIn 으로 대상 폴더를 기본 키로 잠그고, lockAllByParentFolderIdIn 으로 한 단계씩 내려가며 하위 폴더를 잠근다. 내려가는 조회를 전부 잠금 조회로 둬야 REPEATABLE READ 스냅숏이 박히지 않아서, 잠금을 기다리다 커밋된 등록도 삭제 대상에 그대로 들어온다
- 그 사이에 새 폴더가 끼어들 자리는 없다. 어떤 폴더 아래에 폴더를 만들거나 옮기려면 findParentFolderForShare 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 기다렸다가 삭제된 부모를 보고 FOLDER_NOT_FOUND 로 거절된다
- 전체 폴더 삭제(deleteAllUserFoldersWithProblems)는 어차피 사용자 폴더 전부가 대상이라 lockAllByUserId 를 그대로 뒀다
- 복습 알림 취소가 문제마다 problem_id 범위 UPDATE 를 돌면서 uq_problem_review_reminder_seq 인덱스 끝의 갭까지 next-key lock 으로 잡고 있었다. problem_id 는 계속 커지므로 그 갭은 대개 supremum 이고, 폴더 삭제가 커밋될 때까지 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 사용자와 무관하게 막혔다. 취소 대상을 먼저 읽고 기본 키로 UPDATE 하도록 바꿨다
- performance_schema 로 잠금 범위를 고정하는 FolderDeleteLockBlastRadiusTest 를 추가했다. 삭제 트랜잭션이 잠그는 folder 행이 삭제 대상 서브트리뿐인지, idx_folder_user_id 에 잠금이 안 잡히는지, 무관한 폴더와 다른 계정의 등록이 안 막히는지를 본다
- 고아 데이터 쪽 계약은 FolderDeleteProblemRegisterRaceTest 9개가 그대로 통과한다

Closes #319
@KiSeungMin KiSeungMin added the fix 버그, 오류 수정 label Sep 19, 2026
@KiSeungMin KiSeungMin self-assigned this Sep 19, 2026
@KiSeungMin
KiSeungMin merged commit 3541ca8 into develop Sep 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix 버그, 오류 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant