From 46ff9c036b89147eb2ad4d51fbd2a56cafa42a01 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 7 Aug 2026 18:03:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(board):=20=EB=B0=98=EC=9D=91=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=9D=BD=20=ED=9A=8D=EB=93=9D=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=82=AD=EC=A0=9C=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=A5=BC=20404=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스코프 검증 통과 직후·Post 락 획득 시점 사이에 게시글이 삭제되면 lockForUpdate 의 findOrFail 이 ModelNotFoundException 을 던지는데, 컨트롤러가 이 예외를 모르므로 일반 500 으로 새어나가 사용자가 "반응 처리에 실패했습니다" 라는 원인 불명 메시지만 보게 되던 문제를 수정. 이미 존재를 확인한 게시글이 사라진 것과 같은 의미이므로 PostNotFoundException 으로 변환해 404 로 일관 처리. --- .../src/Services/ReactionService.php | 12 ++++++- .../Unit/Services/ReactionServiceTest.php | 36 +++++++++++++++++++ .../tests/scenarios/board-reaction.yaml | 1 + 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/modules/_bundled/sirsoft-board/src/Services/ReactionService.php b/modules/_bundled/sirsoft-board/src/Services/ReactionService.php index ceb58c74..08d36d71 100644 --- a/modules/_bundled/sirsoft-board/src/Services/ReactionService.php +++ b/modules/_bundled/sirsoft-board/src/Services/ReactionService.php @@ -107,7 +107,17 @@ class ReactionService // 판단하도록 보장한다. 잠금 없이 existing 을 먼저 읽으면 다른 트랜잭션이 // 그 사이 상태를 바꿔도 반영되지 않아, 이미 지워진 반응을 다시 지우거나 // 캐시 카운트가 실제 반응 행 수와 어긋나는 결함으로 이어진다. - $this->reactionRepository->lockPostForReaction($post->id); + // + // 스코프 검증 통과 직후·락 획득 시점 사이에 게시글이 삭제되는 레이스에서는 + // findOrFail() 이 ModelNotFoundException 을 던진다. 컨트롤러는 이 예외를 + // 모르므로 그대로 두면 일반 500 으로 새어나가 사용자가 원인을 알 수 없다 — + // 이미 위에서 존재를 확인한 게시글이 사라진 것과 같은 의미이므로 동일하게 + // PostNotFoundException 으로 변환한다. + try { + $this->reactionRepository->lockPostForReaction($post->id); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + throw new PostNotFoundException($post->id); + } $existing = $this->reactionRepository->findByUserAndTarget( $userId, diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php index 3926f2b1..9c087978 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php @@ -2,12 +2,14 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Services; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; use Modules\Sirsoft\Board\Exceptions\ReactionNotAllowedException; use Modules\Sirsoft\Board\Models\Reaction; use Modules\Sirsoft\Board\Models\ReactionType; +use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface; use Modules\Sirsoft\Board\Services\ReactionService; use Modules\Sirsoft\Board\Tests\BoardTestCase; @@ -237,4 +239,38 @@ class ReactionServiceTest extends BoardTestCase $this->expectException(PostNotFoundException::class); $this->service->react($reactor->id, $this->board, 999999, $this->likeId); } + + /** + * 스코프 검증 통과 직후·락 획득 시점 사이에 게시글이 삭제되는 레이스 상황에서도 + * PostNotFoundException 으로 일관되게 처리된다. + * + * lockPostForReaction() 은 findOrFail() 을 사용해 락을 거는데, 이 시점에 게시글이 + * 없으면 Eloquent 의 ModelNotFoundException 이 던져진다. 이를 감싸지 않으면 컨트롤러의 + * catch 블록 어디에도 걸리지 않아 일반 \Exception 으로 떨어져 500 에러가 되고, 사용자는 + * "반응 처리에 실패했습니다"만 보게 되어 게시글이 없다는 실제 원인을 알 수 없다. + * + * @scenario case=post_deleted_between_scope_check_and_lock + * @effects post_not_in_board_returns_404 + */ + public function test_react_wraps_model_not_found_during_lock_as_post_not_found(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $repository = $this->mock(ReactionRepositoryInterface::class); + $repository->shouldReceive('lockPostForReaction') + ->once() + ->with($postId) + ->andThrow(new ModelNotFoundException()); + + $service = new ReactionService( + $repository, + app(\Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface::class), + app(\Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface::class), + ); + + $this->expectException(PostNotFoundException::class); + $service->react($reactor->id, $this->board, $postId, $this->likeId); + } } diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml index 474e30b4..3f2ea981 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml @@ -30,6 +30,7 @@ axes: - inactive_type_react_blocked - use_reaction_off_react_blocked - post_not_in_board + - post_deleted_between_scope_check_and_lock - detail_shows_active_types_even_zero - detail_hidden_when_no_active_types - detail_hidden_when_use_reaction_off