From eb17a87af96c7fffbe6c7521ec65a6a2ba3f7577 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:08:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20tournament=5Fitems=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=ED=82=A4=EB=A5=BC=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=ED=96=89=20=ED=95=9C=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 뺀 후보를 다시 담으면 500 이 났다 (prod 24시간 ERROR 19건 전부 이 오류) - 원인: 빼기는 deleted_at 만 채우는 soft delete 인데 uk_tournament_items 가 삭제 행까지 셈 - 담기의 중복 검사는 살아 있는 행만 보므로 통과하고, insert 에서 Duplicate entry 로 터짐 - 전용 처리기가 없어 handleUnexpected 까지 내려가 500 과 ERROR 로그 - generated 컬럼으로 활성 행만 유일하게 판정 - uk_tournaments_active_invite_code 선례와 같은 수단 (MySQL 이 partial unique index 미지원) - 삭제 행은 active_snapshot_id 가 NULL 이고 MySQL unique 는 NULL 중복을 허용 - 옛 키를 같은 마이그레이션에서 떼었다 - 그 키가 곧 결함의 원인이라 남기면 고쳐지지 않음 - destructive 단계 배포 규약의 두 근거가 안 걸림: 인덱스만 떼므로 데이터 손실 없고, 제약을 푸는 방향이라 배포 중 옛 코드가 깨지지 않음 - 조회 경로도 유지: 선행 컬럼이 tournament_id 로 같고 idx_* 두 개가 따로 있음 - 애플리케이션 코드는 손대지 않았다 - 중복 검사가 이미 활성 행 기준이라 재담기가 새 행 insert 로 통과 - 진짜 중복(이미 담긴 후보)은 그대로 409 TOURNAMENT-009 - 회귀 가드 2건: 재담기 200 · 이미 담긴 후보 409 - 대안: 삭제 행 되살리기 - 기각: 옛 id 를 유지해 ORDER BY id ASC 에서 재담은 후보가 원래 자리로 돌아감 close #1246 --- ...8__unique_tournament_items_active_only.sql | 22 +++++++++ .../controller/TournamentIntegrationTest.kt | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql diff --git a/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql b/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql new file mode 100644 index 00000000..84f26ecf --- /dev/null +++ b/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql @@ -0,0 +1,22 @@ +-- 뺀 후보를 다시 담으면 500 이 났다(#1246). +-- 빼기는 deleted_at 만 채우는 soft delete 인데 uk_tournament_items (tournament_id, snapshot_id) 는 +-- 삭제 표시된 행까지 센다. 담기의 중복 검사는 살아 있는 행만 보므로 통과하고, 위시가 가리키는 +-- snapshot 이 그대로라 같은 조합으로 insert 해 Duplicate entry 로 터진다. 전용 처리기가 없어 500 이 나갔다. +-- +-- MySQL 은 partial unique index 를 지원하지 않으므로 generated column 으로 흉내낸다 +-- (uk_tournaments_active_invite_code 와 같은 수단). +-- 활성 행(deleted_at IS NULL): active_snapshot_id = snapshot_id → 같은 후보의 중복 출전 차단 +-- 삭제 행(deleted_at IS NOT NULL): active_snapshot_id = NULL → MySQL unique 는 NULL 여러 개 허용 +-- +-- 옛 키를 같은 마이그레이션에서 떼는 이유: 그 키가 곧 결함의 원인이라 남기면 고쳐지지 않는다. +-- destructive 를 단계 배포하라는 규약의 두 근거가 여기엔 걸리지 않는다. +-- 데이터 손실: 인덱스만 떼므로 없음 +-- 배포 중 공존: 유니크 제약을 떼는 것은 제한을 푸는 방향이라 옛 코드가 깨지지 않음 +-- 조회 경로도 잃지 않는다 - idx_tournament_items_tournament_id 와 idx_tournament_items_snapshot_id 가 +-- 따로 있고, 새 키의 선행 컬럼도 tournament_id 로 같다. +-- +-- 기존 데이터는 새 키를 이미 만족한다 - 옛 키가 더 강한 제약이었다(삭제 행까지 유일 판정). +ALTER TABLE tournament_items + ADD COLUMN active_snapshot_id BIGINT GENERATED ALWAYS AS (IF(deleted_at IS NULL, snapshot_id, NULL)) STORED, + ADD UNIQUE KEY uk_tournament_items_active (tournament_id, active_snapshot_id), + DROP INDEX uk_tournament_items; diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 22ae0ef4..9788db8e 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -2308,6 +2308,54 @@ class TournamentIntegrationTest : IntegrationTestSupport() { assertTrue(remaining.none { it.getId() == itemId }) } + // #1246 회귀: 빼기는 soft delete 라 행이 남고, 담기의 중복 검사는 살아 있는 행만 본다. 유니크 키가 + // 삭제 행까지 세던 동안엔 같은 후보를 다시 담는 순간 Duplicate entry 로 500 이 났다(prod 24시간 ERROR 19건). + // 키를 활성 행 한정으로 바꿔(uk_tournament_items_active) 재담기가 새 행 insert 로 통과한다. + @Test + fun `POST tournaments-id-items-wish 는 뺀 후보를 다시 담아도 200 을 반환한다`() { + val mockMvc = buildMockMvc() + val tournamentId = createTournament(mockMvc) + val itemId = saveWishItem() + addItemsToTournament(mockMvc, tournamentId, userId, itemId) + val cardId = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).first().getId() + mockMvc + .perform( + delete("/api/v1/tournaments/$tournamentId/items/$cardId") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":[$itemId]}"""), + ).andExpect(status().isOk) + + // 되살린 행이 아니라 새 행이다 - 옛 행은 삭제 표시 그대로 남아 대기실 순서가 담은 순서를 지킨다. + val active = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId) + assertEquals(1, active.size) + assertNotEquals(cardId, active.first().getId(), "삭제 행을 되살리면 대기실 순서가 원래 자리로 돌아간다") + } + + // 활성 행끼리의 중복은 그대로 막아야 한다 - 유니크를 푼 것이 아니라 판정 대상을 활성 행으로 좁힌 것이다. + @Test + fun `POST tournaments-id-items-wish 는 이미 담긴 후보를 또 담으면 409 를 반환한다`() { + val mockMvc = buildMockMvc() + val tournamentId = createTournament(mockMvc) + val itemId = saveWishItem() + addItemsToTournament(mockMvc, tournamentId, userId, itemId) + + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":[$itemId]}"""), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value(TournamentErrorCode.DUPLICATE_TOURNAMENT_ITEM.code)) + } + @Test fun `DELETE tournaments-id-items-itemId 에서 IN_PROGRESS 토너먼트이면 409 를 반환한다`() { val mockMvc = buildMockMvc() From 975eb8f1f9728a316a891db7eda91c7b694fca37 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:28:46 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=ED=82=A4=20=EC=A0=84=ED=99=98=EC=9D=84=20=EC=98=A8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20DDL=20=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STORED 를 VIRTUAL 로 바꾸고 한 문장을 세 문장으로 분리 - 원인: STORED 추가는 테이블을 재작성하며 그 동안 DML 을 막아, 마이그레이션이 도는 사이 후보 담기·빼기가 멈춤 - 가상 컬럼 추가는 다른 ALTER 동작과 같은 문장에 둘 수 없음 (두면 다시 재작성으로 떨어짐) - 값은 인덱스가 들고 컬럼을 직접 조회하지 않아 STORED 의 이점이 없음 - ALGORITHM=INPLACE · LOCK=NONE 명시 - 원인: 온라인으로 가능하다는 가정을 DB 가 검증하게 함. 틀리면 조용히 재작성으로 떨어지는 대신 문장이 실패해 배포 전에 드러남 - 세 문장 모두 수용됨을 실측 - 역probe: DROP INDEX 에 ALGORITHM=INSTANT 를 넣으니 빌드 실패 - 결과: 문장이 실제 실행되고 MySQL 이 ALGORITHM 을 검증함이 확인됨 - 테스트 컨테이너와 운영이 둘 다 mysql:8.4 - 선례(tournaments.active_invite_code)는 STORED 였음 - 그 선택을 따르지 않음. 재작성 회피가 이쪽 이점 --- ...8__unique_tournament_items_active_only.sql | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql b/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql index 84f26ecf..aa862a14 100644 --- a/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql +++ b/src/main/resources/db/migration/V20261021230348__unique_tournament_items_active_only.sql @@ -8,7 +8,15 @@ -- 활성 행(deleted_at IS NULL): active_snapshot_id = snapshot_id → 같은 후보의 중복 출전 차단 -- 삭제 행(deleted_at IS NOT NULL): active_snapshot_id = NULL → MySQL unique 는 NULL 여러 개 허용 -- --- 옛 키를 같은 마이그레이션에서 떼는 이유: 그 키가 곧 결함의 원인이라 남기면 고쳐지지 않는다. +-- VIRTUAL 이고 세 문장으로 나눈 이유: STORED 는 테이블을 재작성하며 그 동안 DML 을 막아, 마이그레이션이 +-- 도는 사이 후보 담기·빼기가 멈춘다. 가상 컬럼 추가는 다른 ALTER 동작과 같은 문장에 둘 수 없어 +-- (그렇게 두면 다시 재작성으로 떨어진다) 문장을 분리한다. 값은 인덱스가 들고 있고 컬럼을 직접 +-- 조회하지 않으므로 STORED 의 이점이 없다. 선례(tournaments)와 다른 선택이고, 이쪽이 그 선례보다 낫다. +-- +-- ALGORITHM·LOCK 을 명시하는 이유: 셋 다 온라인으로 가능하다는 가정을 DB 가 검증하게 한다. 가정이 +-- 틀리면 조용히 재작성으로 떨어지는 대신 문장이 실패해 배포 전에 드러난다. +-- +-- 옛 키는 같은 마이그레이션에서 뗀다. 그 키가 곧 결함의 원인이라 남기면 고쳐지지 않는다. -- destructive 를 단계 배포하라는 규약의 두 근거가 여기엔 걸리지 않는다. -- 데이터 손실: 인덱스만 떼므로 없음 -- 배포 중 공존: 유니크 제약을 떼는 것은 제한을 푸는 방향이라 옛 코드가 깨지지 않음 @@ -17,6 +25,16 @@ -- -- 기존 데이터는 새 키를 이미 만족한다 - 옛 키가 더 강한 제약이었다(삭제 행까지 유일 판정). ALTER TABLE tournament_items - ADD COLUMN active_snapshot_id BIGINT GENERATED ALWAYS AS (IF(deleted_at IS NULL, snapshot_id, NULL)) STORED, + ADD COLUMN active_snapshot_id BIGINT GENERATED ALWAYS AS (IF(deleted_at IS NULL, snapshot_id, NULL)) VIRTUAL, + ALGORITHM = INPLACE, + LOCK = NONE; + +ALTER TABLE tournament_items ADD UNIQUE KEY uk_tournament_items_active (tournament_id, active_snapshot_id), - DROP INDEX uk_tournament_items; + ALGORITHM = INPLACE, + LOCK = NONE; + +ALTER TABLE tournament_items + DROP INDEX uk_tournament_items, + ALGORITHM = INPLACE, + LOCK = NONE;