From 277daef22918fa0ae71663d66da0f769e166b370 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 20:06:21 +0900 Subject: [PATCH] =?UTF-8?q?[Fix]=20=ED=8F=B4=EB=8D=94=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=9E=A0=EA=B8=88=EC=9D=B4=20=EB=8B=A4=EB=A5=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EC=99=80=20=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=97=90=EA=B2=8C=20=EB=B2=88=EC=A7=80=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- .../folder/repository/FolderRepository.java | 32 +- .../backend/folder/service/FolderService.java | 72 ++-- .../ProblemReviewReminderRepository.java | 31 +- .../ProblemReviewReminderService.java | 14 +- .../FolderDeleteLockBlastRadiusTest.java | 337 ++++++++++++++++++ .../ProblemReviewReminderSenderTest.java | 2 +- 6 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index 07debf20..389c6fb8 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -36,10 +36,14 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByIdInForShare(@Param("folderIds") Collection folderIds); /** - * 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) + * 전체 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) * - *

삭제할 폴더만이 아니라 사용자 폴더 전체를 잡는 이유는 하위 폴더 때문이다. 하위 폴더 목록은 - * 잠그기 전에는 알 수 없고, 하위 폴더로 들어오는 등록도 막아야 한다. + *

폴더를 골라 지우는 경로는 이걸 쓰지 않는다. 사용자 폴더 전체를 잡으면 삭제와 아무 상관 없는 + * 폴더로 들어오는 등록까지 전부 줄을 서고, 기다리는 요청이 커넥션을 하나씩 물고 있어서 + * 커넥션 풀이 바닥난다. 그러면 다른 사용자의 요청까지 커넥션을 못 받고 죽는다. (#319) + * 그쪽은 {@link #lockAllByIdIn} 과 {@link #lockAllByParentFolderIdIn} 으로 삭제 대상 서브트리만 잡는다. + * + *

여기는 어차피 사용자 폴더 전부가 삭제 대상이라 범위를 줄일 것이 없다. 계정 정리 때만 타는 드문 경로다. * *

반드시 트랜잭션의 첫 조회여야 한다. REPEATABLE READ 는 첫 일반 조회 시점에 스냅숏을 만든다. * 잠금보다 먼저 일반 조회를 하면, 잠금을 기다리는 동안 커밋된 등록이 그 스냅숏에 보이지 않아 @@ -49,6 +53,28 @@ public interface FolderRepository extends JpaRepository, FolderRep @Query("select f from Folder f where f.userId = :userId") List lockAllByUserId(@Param("userId") Long userId); + /** + * 삭제 대상 폴더를 기본 키로 배타 잠금(FOR UPDATE)한다. (#319) + * + *

{@link #lockAllByUserId} 와 달리 {@code idx_folder_user_id} 등치 스캔을 타지 않아 + * next-key lock 의 갭이 인접 사용자 구간까지 덮지 않는다. 기본 키 등치 조회는 {@code REC_NOT_GAP} 이다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.id in :folderIds") + List lockAllByIdIn(@Param("folderIds") Collection folderIds); + + /** + * 주어진 폴더들의 바로 아래 하위 폴더를 배타 잠금(FOR UPDATE)으로 읽는다. (#319) + * + *

삭제 대상 서브트리를 한 단계씩 내려가며 잠그는 데 쓴다. 일반 조회로 내려가면 안 된다. + * 일반 조회는 그 시점의 스냅숏을 고정하므로, 아직 잠그지 못한 하위 폴더에 그 뒤로 커밋된 + * 문제나 폴더가 보이지 않아 #233 의 고아 데이터가 그대로 돌아온다. 잠금 조회는 스냅숏이 아니라 + * 최신 행을 읽는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.parentFolder.id in :parentFolderIds") + List lockAllByParentFolderIdIn(@Param("parentFolderIds") Collection parentFolderIds); + /** * 훈장 '정리의 신' 판정용. 루트 폴더는 빼고 센다. * diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index 2f1645b5..8e7a26f9 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -186,11 +186,8 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { } public void deleteFoldersWithProblems(Long userId, List folderIds) { - // 같은 폴더로 들어오는 문제 등록과 순서를 맞춘다. 이 줄보다 앞에 조회를 두면 안 된다. (#233) - folderRepository.lockAllByUserId(userId); - - // 삭제할 모든 폴더의 ID 조회 (하위 폴더 포함) - Set allFolderIds = getAllFolderIdsIncludingSubFolders(userId, folderIds); + // 삭제 대상 서브트리를 잠그면서 하위 폴더까지 모은다. 이 줄보다 앞에 조회를 두면 안 된다. (#233, #319) + Set allFolderIds = lockSubtreeAndCollectFolderIds(userId, folderIds); problemService.deleteAllByFolderIds(userId, allFolderIds); @@ -206,40 +203,57 @@ public void deleteAllUserFoldersWithProblems(Long userId) { deleteAllUserFolders(userId); } - public Set getAllFolderIdsIncludingSubFolders(Long userId, List folderIds) { - Set allFolderIds = new HashSet<>(); + /** + * 삭제 대상 폴더와 그 하위 폴더 전부를 배타 잠금으로 잡으면서 ID 를 모은다. (#233, #319) + * + *

호출자의 트랜잭션에서 가장 먼저 실행돼야 한다. 여기서 쓰는 조회는 전부 잠금 조회라 + * REPEATABLE READ 스냅숏을 고정하지 않는다. 그래서 잠금을 다 잡은 뒤에 일어나는 일반 조회가 + * "잠금을 잡은 시점 이후" 를 보게 되고, 잠금을 기다리다 커밋된 등록도 삭제 대상에 들어온다. + * 이 앞에 일반 조회를 한 줄이라도 두면 그 순간 스냅숏이 박혀 #233 의 고아 문제가 되살아난다. + * + *

한 단계씩 내려가도 빠지는 폴더는 없다. 어떤 폴더 아래에 새 폴더를 만들거나 옮기려면 + * {@link #findParentFolderForShare} 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 + * 그쪽이 기다렸다가 삭제된 부모를 보고 거절된다. 아직 안 잠근 단계에서 먼저 들어온 생성은 + * 우리가 그 부모를 잠그려고 기다리는 동안 커밋되고, 그다음 잠금 조회가 최신 행을 읽어 잡아낸다. + * + *

사용자 폴더 전체를 잡던 예전 방식({@code lockAllByUserId})은 삭제와 무관한 폴더로 들어오는 + * 등록까지 줄 세웠고, 기다리는 요청이 커넥션을 문 채로 풀을 바닥내 다른 사용자까지 죽였다. (#319) + */ + private Set lockSubtreeAndCollectFolderIds(Long userId, List folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return Set.of(); + } - for (Long folderId : folderIds) { - Folder folder = findFolderEntity(folderId, userId); + Map lockedTargets = folderRepository.lockAllByIdIn(new LinkedHashSet<>(folderIds)).stream() + .collect(Collectors.toMap(Folder::getId, folder -> folder)); + // 검증 순서는 예전과 같게 요청받은 순서대로 본다. 없음 → 소유자 불일치 → 루트 순이다. + for (Long folderId : folderIds) { + Folder folder = lockedTargets.get(folderId); + if (folder == null) { + throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); + } + validateFolderOwner(folder, userId); if (folder.getParentFolder() == null) { throw new ApplicationException(FolderErrorCase.ROOT_FOLDER_CANNOT_REMOVE); } - allFolderIds.add(folder.getId()); - allFolderIds.addAll(getSubFolderIdsRecursive(folder)); } - return allFolderIds; - } - - private Set getSubFolderIdsRecursive(Folder folder) { - Set subFolderIds = new HashSet<>(); - collectSubFolderIds(folder, subFolderIds); - return subFolderIds; - } + // 이미 잠근 폴더는 다시 타고 들어가지 않는다. 부모-자식에 순환이 남아 있어도(과거 데이터) 한 번만 훑는다. + Set allFolderIds = new LinkedHashSet<>(lockedTargets.keySet()); + Collection currentLevel = new ArrayList<>(allFolderIds); - /** - * 이미 방문한 폴더는 다시 타고 들어가지 않는다. - * - *

부모-자식 관계에 순환이 남아 있으면(과거 데이터 등) 단순 재귀는 StackOverflowError 로 - * 삭제 요청 전체를 500 으로 떨어뜨린다. 방문 집합으로 한 번만 훑는다. - */ - private void collectSubFolderIds(Folder folder, Set collectedIds) { - for (Folder subFolder : folder.getSubFolderList()) { - if (collectedIds.add(subFolder.getId())) { - collectSubFolderIds(subFolder, collectedIds); + while (!currentLevel.isEmpty()) { + List nextLevel = new ArrayList<>(); + for (Folder subFolder : folderRepository.lockAllByParentFolderIdIn(currentLevel)) { + if (allFolderIds.add(subFolder.getId())) { + nextLevel.add(subFolder.getId()); + } } + currentLevel = nextLevel; } + + return allFolderIds; } /** diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java index 16177889..cadc3a96 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; public interface ProblemReviewReminderRepository extends JpaRepository { @@ -107,14 +108,36 @@ int recoverStuckRows( @Param("stuckBefore") LocalDateTime stuckBefore ); - @Modifying - @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") - int cancelByProblem( + /** + * 취소할 예약의 ID 만 먼저 읽는다. 잠금을 잡지 않는 일반 조회다. (#319) + * + * @see #cancelByIdIn + */ + @Query("SELECT r.id FROM ProblemReviewReminder r WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + List findPendingIdsByProblem( @Param("problemId") Long problemId, - @Param("canceled") ProblemReviewReminderStatus canceled, @Param("pendingStatuses") List pendingStatuses ); + /** + * 예약을 기본 키로 취소한다. (#319) + * + *

예전에는 {@code WHERE problem_id = ?} 로 한 번에 UPDATE 했다. 그러면 REPEATABLE READ 에서 + * {@code uq_problem_review_reminder_seq(problem_id, sequence)} 를 범위로 훑으면서 next-key lock 이 + * 마지막 일치 항목 뒤의 갭까지 잡는다. {@code problem_id} 는 계속 커지므로 그 갭은 대개 + * supremum(인덱스 끝) 이고, 그러면 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 전부 막힌다. 문제 등록은 커밋 직후 예약을 넣기 때문에 + * ({@code scheduleForNewProblems}), 폴더 하나 지우는 동안 다른 계정의 등록까지 잠금 대기에 걸렸다. + * + *

기본 키 등치 조회는 {@code REC_NOT_GAP} 이라 갭을 잡지 않는다. + */ + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.id IN :ids") + int cancelByIdIn( + @Param("ids") Collection ids, + @Param("canceled") ProblemReviewReminderStatus canceled + ); + @Modifying @Query("UPDATE ProblemReviewReminder r SET r.problemMemoSnapshot = :memo, r.problemReferenceSnapshot = :reference WHERE r.problemId = :problemId AND r.status = :scheduled AND r.deletedAt IS NULL") int refreshSnapshot( diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index c0ce8340..c821e971 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -74,9 +74,21 @@ public void scheduleForNewProblems(Long userId, List취소 대상을 먼저 읽고 기본 키로 UPDATE 한다. {@code problem_id} 조건으로 바로 UPDATE 하면 + * next-key lock 이 인덱스 끝의 갭까지 잡아서, 폴더 삭제가 커밋될 때까지 다른 계정의 문제 등록이 + * 전부 예약 INSERT 에서 막혔다. (#319, {@link ProblemReviewReminderRepository#cancelByIdIn}) + */ @Transactional public void cancelPendingByProblem(Long problemId) { - int count = repository.cancelByProblem(problemId, CANCELED, PENDING_STATUSES); + List pendingIds = repository.findPendingIdsByProblem(problemId, PENDING_STATUSES); + if (pendingIds.isEmpty()) { + return; + } + + int count = repository.cancelByIdIn(pendingIds, CANCELED); if (count > 0) { log.info("[ReviewReminder] 문제 삭제로 알림 취소 - problemId: {}, {}건", problemId, count); } diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java new file mode 100644 index 00000000..f304145e --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java @@ -0,0 +1,337 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.service.FolderService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problem.support.ProblemTestSupport; +import com.aisip.OnO.backend.support.TestContainers; +import com.aisip.OnO.backend.user.entity.User; +import com.github.gavlyukovskiy.boot.jdbc.decorator.DecoratedDataSource; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * 폴더 삭제 잠금이 삭제 대상 밖으로 번지지 않는지 본다. (#319) + * + *

폴더 하나를 지우는 동안 두 군데서 잠금이 새어 나갔다. + *

    + *
  • #233 을 막으려고 넣은 폴더 잠금이 삭제 대상 서브트리가 아니라 사용자 폴더 전체를 잡았다. + * 삭제와 아무 상관 없는 폴더로 들어오는 등록까지 삭제가 끝날 때까지 기다렸다.
  • + *
  • 문제마다 도는 복습 알림 취소가 {@code problem_id} 범위 UPDATE 라 + * {@code uq_problem_review_reminder_seq} 인덱스 끝의 갭까지 next-key lock 으로 잡았다. + * {@code problem_id} 는 계속 커지므로 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 막혔다.
  • + *
+ * + *

기다리는 요청은 하나씩 커넥션을 물고 있다. 그래서 대기가 길어지면 Hikari 풀(기본 10개)이 바닥나고, + * 폴더와도 그 사용자와도 무관한 요청까지 {@code connection-timeout}(기본 30초) 뒤에 실패한다. + * dev 에서 잰 30.2초가 이 값이다. + * + *

그래서 여기서는 고아 데이터가 아니라 영향 범위를 본다. + *

    + *
  1. 삭제 트랜잭션이 잠그는 {@code folder} 행이 삭제 대상 서브트리뿐인지 ({@code performance_schema.data_locks})
  2. + *
  3. 삭제와 무관한 폴더로 들어오는 등록이 막히지 않는지
  4. + *
  5. 다른 계정의 등록이 막히지 않는지
  6. + *
  7. 등록이 몰려 들어와도 전부 막히지 않는지
  8. + *
+ * + *

고아 데이터 쪽 계약은 {@link FolderDeleteProblemRegisterRaceTest} 가 그대로 들고 있다. + * 잠금 범위를 줄이는 변경은 두 파일이 같이 통과해야 한다. + */ +@DisplayName("동시성 - 폴더 삭제 잠금의 영향 범위") +class FolderDeleteLockBlastRadiusTest extends ProblemTestSupport { + + private static final long STEP_TIMEOUT_SECONDS = 20; + + /** + * 잠금이 번지지 않으면 이 안에 끝난다. + * + *

번지면 잠금 대기({@code innodb_lock_wait_timeout} 기본 50초)나 + * 커넥션 대기(Hikari {@code connection-timeout} 기본 30초)로 넘어가므로, 몇 초짜리 여유로 갈린다. + */ + private static final long NO_BLOCKING_TIMEOUT_SECONDS = 8; + + @Autowired + private ProblemService problemService; + + @Autowired + private FolderService folderService; + + @Autowired + private DataSource dataSource; + + private ExecutorService executor; + private final CountDownLatch release = new CountDownLatch(1); + + private User owner; + private Folder ownerRoot; + private Folder target; + private Folder targetChild; + private Folder untouched; + + private User other; + private Folder otherRoot; + + private int poolSize; + + @BeforeEach + void setUpFoldersAndPool() { + owner = fixtures.createUser(); + ownerRoot = fixtures.createRootFolder(owner.getId()); + target = fixtures.createFolder(owner.getId(), "삭제 대상", ownerRoot); + targetChild = fixtures.createFolder(owner.getId(), "삭제 대상 하위", target); + untouched = fixtures.createFolder(owner.getId(), "삭제와 무관한 폴더", ownerRoot); + saveProblem(owner.getId(), target); + + other = fixtures.createUser(); + otherRoot = fixtures.createRootFolder(other.getId()); + + poolSize = maximumPoolSize(); + executor = Executors.newFixedThreadPool(poolSize + 4); + } + + /** + * 한 번에 몰아 넣을 등록 수. 커넥션 풀의 절반까지만 쓴다. + * + *

문제 등록은 커밋 직후 복습 알림 예약을 {@code AFTER_COMMIT} + {@code REQUIRES_NEW} 로 넣는다. + * 그동안 바깥 트랜잭션의 커넥션이 아직 반납되기 전이라, 요청 하나가 커넥션 두 개를 동시에 쥔다. + * 그래서 동시 등록이 풀 크기에 닿으면 폴더 삭제가 없어도 풀이 서로를 기다리며 멈추고, + * 전부 {@code connection-timeout} 을 채운다. 실제로 이 테스트에서 8건을 몰아 넣으면 + * "사용중 10, 유휴 0, 커넥션 대기중인 스레드 8" 로 멈춘다. + * + *

그건 폴더 삭제 잠금과는 다른 원인이라 여기서 고정하지 않는다. 이 테스트가 보는 것은 + * "폴더 삭제가 무관한 등록을 붙잡고 있는가" 뿐이므로, 풀이 문제가 되지 않는 선까지만 몰아 넣는다. + */ + private int concurrentRegistrationCount() { + return Math.max(2, poolSize / 2); + } + + @AfterEach + void releaseHeldTransaction() throws InterruptedException { + // 단언이 중간에 실패해도 세워 둔 트랜잭션이 커밋되고 스레드가 끝나야 다음 테스트의 DB 정리가 막히지 않는다. + release.countDown(); + executor.shutdown(); + assertThat(executor.awaitTermination(STEP_TIMEOUT_SECONDS * 4, TimeUnit.SECONDS)).isTrue(); + } + + @Test + @DisplayName("삭제 트랜잭션은 삭제 대상 서브트리 밖의 폴더 행을 잠그지 않는다") + void deleteLocksOnlyTargetSubtree() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Set lockedFolderIds = lockedFolderRowIds(); + Set lockedIndexNames = lockedFolderIndexNames(); + + assertThat(lockedFolderIds) + .as("사용자 폴더 전체를 잡으면 루트와 무관한 폴더까지 잠겨서, 그 폴더로 들어오는 등록이 다 줄을 선다") + .containsExactlyInAnyOrder(target.getId(), targetChild.getId()); + assertThat(lockedIndexNames) + .as("idx_folder_user_id 등치 스캔은 next-key lock 이라 갭이 인접 사용자 구간까지 덮는다 (#291 댓글 실측)") + .doesNotContain("idx_folder_user_id"); + } + + @Test + @DisplayName("삭제와 무관한 폴더로 들어오는 등록은 삭제를 기다리지 않는다") + void registrationIntoUnrelatedFolderIsNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "무관한 폴더 등록", null, untouched.getId(), null, null, null), + owner.getId())); + + assertCompletesWithoutBlocking(registration, "삭제 대상이 아닌 폴더로 들어오는 등록"); + } + + @Test + @DisplayName("다른 계정의 문제 등록은 남의 폴더 삭제를 기다리지 않는다") + void registrationByAnotherUserIsNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "다른 계정 등록", null, otherRoot.getId(), null, null, null), + other.getId())); + + assertCompletesWithoutBlocking(registration, "폴더를 지운 사용자와 아무 관계도 없는 다른 계정의 문제 등록"); + } + + @Test + @DisplayName("삭제와 무관한 폴더로 등록이 몰려도 전부 삭제를 기다리지 않는다") + void concurrentRegistrationsIntoUnrelatedFolderAreNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + List> registrations = new ArrayList<>(); + for (int i = 0; i < concurrentRegistrationCount(); i++) { + String memo = "동시 등록 " + i; + registrations.add(executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, memo, null, untouched.getId(), null, null, null), + owner.getId()))); + } + + for (int i = 0; i < registrations.size(); i++) { + assertCompletesWithoutBlocking(registrations.get(i), (i + 1) + "번째 동시 등록"); + } + } + + /** 작업을 트랜잭션 안에서 실행하고, 커밋하기 직전에 {@link #release} 가 풀릴 때까지 붙잡아 둔다. */ + private void holdUntilReleased(Runnable work) throws Exception { + CountDownLatch workDone = new CountDownLatch(1); + Future future = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + work.run(); + workDone.countDown(); + awaitRelease(); + })); + + if (!workDone.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + release.countDown(); + future.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + fail("먼저 잡아야 할 삭제 트랜잭션이 %d초 안에 잠금을 잡지 못했다", STEP_TIMEOUT_SECONDS); + } + } + + private void awaitRelease() { + try { + if (!release.await(STEP_TIMEOUT_SECONDS * 4, TimeUnit.SECONDS)) { + throw new IllegalStateException("release 신호를 받지 못했다"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private void assertCompletesWithoutBlocking(Future future, String what) { + try { + future.get(NO_BLOCKING_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + fail("%s 가 폴더 삭제 때문에 %d초 안에 끝나지 않았다.%n커넥션 풀: %s%n대기 사슬:%n%s", + what, NO_BLOCKING_TIMEOUT_SECONDS, poolStats(), lockWaitChain()); + } catch (Exception e) { + throw new AssertionError(what + " 가 실패했다", e); + } + } + + /** 커넥션을 못 받아 막힌 것인지 바로 보이게 찍는다. */ + private String poolStats() { + var pool = hikariDataSource().getHikariPoolMXBean(); + return "최대 %d, 사용중 %d, 유휴 %d, 커넥션 대기중인 스레드 %d".formatted( + poolSize, pool.getActiveConnections(), pool.getIdleConnections(), + pool.getThreadsAwaitingConnection()); + } + + /** 왜 막혔는지 바로 보이도록 {@code performance_schema} 의 대기 사슬을 그대로 찍는다. */ + private String lockWaitChain() { + StringBuilder chain = new StringBuilder(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT r.OBJECT_NAME, r.INDEX_NAME, r.LOCK_TYPE, r.LOCK_MODE, r.LOCK_DATA, + b.LOCK_MODE, b.LOCK_DATA + FROM performance_schema.data_lock_waits w + JOIN performance_schema.data_locks r ON r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID + JOIN performance_schema.data_locks b ON b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + chain.append(" 요청 %s.%s %s %s [%s] <- 보유 %s [%s]%n".formatted( + resultSet.getString(1), resultSet.getString(2), resultSet.getString(3), + resultSet.getString(4), resultSet.getString(5), + resultSet.getString(6), resultSet.getString(7))); + } + } catch (SQLException e) { + chain.append(" (대기 사슬을 읽지 못했다: ").append(e.getMessage()).append(')'); + } + return chain.isEmpty() ? " (잠금 대기 없음 - 커넥션 풀 대기일 수 있다)" : chain.toString(); + } + + /** + * 지금 잠겨 있는 {@code folder} 행의 기본 키 집합. + * + *

{@code X,GAP} 처럼 갭만 잡은 잠금은 뺀다. 갭 잠금은 그 행을 잡은 것이 아니라 앞의 빈 구간을 잡은 것이다. + * {@code X,REC_NOT_GAP} 도 'GAP' 으로 끝나므로 {@code LIKE '%GAP'} 로 거르면 전부 빠진다. + */ + private Set lockedFolderRowIds() throws Exception { + Set lockedIds = new LinkedHashSet<>(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT DISTINCT LOCK_DATA + FROM performance_schema.data_locks + WHERE OBJECT_SCHEMA = DATABASE() + AND OBJECT_NAME = 'folder' + AND INDEX_NAME = 'PRIMARY' + AND LOCK_TYPE = 'RECORD' + AND LOCK_MODE NOT LIKE '%,GAP' + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String lockData = resultSet.getString(1); + if (lockData != null && lockData.chars().allMatch(Character::isDigit)) { + lockedIds.add(Long.parseLong(lockData)); + } + } + } + return lockedIds; + } + + /** 지금 {@code folder} 테이블에서 잠금이 잡힌 인덱스 이름들. */ + private Set lockedFolderIndexNames() throws Exception { + Set indexNames = new LinkedHashSet<>(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT DISTINCT INDEX_NAME + FROM performance_schema.data_locks + WHERE OBJECT_SCHEMA = DATABASE() + AND OBJECT_NAME = 'folder' + AND LOCK_TYPE = 'RECORD' + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + indexNames.add(resultSet.getString(1)); + } + } + return indexNames; + } + + /** performance_schema 는 테스트 계정 권한으로 볼 수 없어 root 로 붙는다. */ + private Connection rootConnection() throws SQLException { + return DriverManager.getConnection( + TestContainers.mysql().getJdbcUrl(), "root", TestContainers.mysql().getPassword()); + } + + private int maximumPoolSize() { + return hikariDataSource().getMaximumPoolSize(); + } + + /** p6spy 가 DataSource 를 감싸고 있어서 원본을 한 겹 벗겨야 Hikari 가 나온다. */ + private HikariDataSource hikariDataSource() { + DataSource candidate = dataSource instanceof DecoratedDataSource decorated + ? decorated.getRealDataSource() + : dataSource; + if (candidate instanceof HikariDataSource hikari) { + return hikari; + } + throw new IllegalStateException("HikariDataSource 를 꺼내지 못했다: " + dataSource.getClass()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java index a5d48856..09a0dcd6 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java @@ -87,7 +87,7 @@ void doesNotSendWhenClaimFails() { @DisplayName("취소된 행도 선점되지 않는다") void doesNotSendCanceledRow() { ProblemReviewReminder reminder = saveReminder(LocalDateTime.now().minusHours(1)); - inTransaction(() -> reminderRepository.cancelByProblem(reminder.getProblemId(), CANCELED, List.of(SCHEDULED))); + inTransaction(() -> reminderRepository.cancelByIdIn(List.of(reminder.getId()), CANCELED)); sender.send(reminder, LocalDateTime.now());