문제
비누적(is_accumulated = false) 카테고리에 대해 동시 요청이 들어오면 중복 score가 생성될 수 있습니다.
AppendScoreSupport.kt:88-101 — findByUserIdAndCategoryTypeAndScoreStatus(..., REJECTED)로 조회 후 있으면 재사용, 없으면 신규 생성하는 exists-then-create 패턴
ScorePersistenceAdapter.kt:87-108 — QueryDSL fetchFirst()뿐이고 @Lock 없음
(user_id, category_id) 유니크 제약 없음 (ScoreJpaEntity에는 dg_project_id 인덱스만 존재)
SubmitProjectParticipationService.kt:79-82도 동일한 exists-then-throw 패턴
두 요청이 거의 동시에 들어오면 둘 다 "기존 score 없음"으로 판단해 각각 PENDING score를 만듭니다.
왜 CHECK로 해결할 수 없는가
is_accumulated가 category_tb에 있는데 MySQL CHECK는 같은 행만 참조 가능합니다. 따라서 "비누적 카테고리일 때만 (user_id, category_id) 유일" 같은 조건부 제약을 CHECK로 표현할 수 없습니다. MySQL은 부분 인덱스(partial/filtered unique index)도 지원하지 않습니다.
→ 별도 슬롯 테이블의 진짜 UNIQUE로 표현합니다.
제안하는 개선
1. 슬롯 테이블 추가
CREATE TABLE score_unique_slot_tb (
score_id BIGINT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
CONSTRAINT uk_score_unique_slot UNIQUE (user_id, category_id),
CONSTRAINT fk_score_unique_slot_score
FOREIGN KEY (score_id) REFERENCES score_tb (score_id) ON DELETE CASCADE,
CONSTRAINT fk_score_unique_slot_user
FOREIGN KEY (user_id) REFERENCES user_tb (user_id),
CONSTRAINT fk_score_unique_slot_category
FOREIGN KEY (category_id) REFERENCES category_tb (category_id)
);
INSERT INTO score_unique_slot_tb (score_id, user_id, category_id)
SELECT s.score_id, s.user_id, s.category_id
FROM score_tb s
JOIN category_tb c ON c.category_id = s.category_id
WHERE c.is_accumulated = FALSE;
ON DELETE CASCADE는 슬롯이 score에 종속된 값이므로 적절합니다.
위 INSERT는 기존 데이터에 (user_id, category_id) 중복이 있으면 실패합니다. 아래 쿼리로 먼저 확인하고, 있으면 개발 DB를 재생성해야 합니다.
SELECT s.user_id, s.category_id, COUNT(*) AS cnt
FROM score_tb s JOIN category_tb c ON c.category_id = s.category_id
WHERE c.is_accumulated = FALSE
GROUP BY s.user_id, s.category_id HAVING cnt > 1;
2. 유니크 위반을 도메인 예외로 번역 (필수)
이 단계를 빠뜨리면 사용자에게 500이 나갑니다. 이유:
GlobalExceptionHandler.kt:17-21에 DataIntegrityViolationException → DUPLICATE_RESOURCE 매핑이 있지만, 이 클래스는 @RestControllerAdvice(:9)라 Spring MVC REST 컨트롤러에만 적용됩니다. 이 프로젝트 API는 GraphQL이므로 사실상 죽은 코드입니다.
- GraphQL 경로는
GsmcExceptionResolver를 타는데, GsmcException만 분기하고 나머지는 전부 INTERNAL_SERVER_ERROR로 떨어집니다(:27-34).
슬롯 테이블의 유니크 위반은 동시 요청 시 두 번째 요청이 걸리는 게 설계된 정상 경로이므로, 반드시 의미 있는 도메인 에러로 번역해야 합니다.
DeveloperPersistenceAdapter.kt:53-58에 이미 있는 기존 패턴을 그대로 따릅니다.
try {
scoreUniqueSlotJpaRepository.saveAndFlush(ScoreUniqueSlotJpaEntity(scoreId, userId, categoryId))
} catch (e: DataIntegrityViolationException) {
throw GsmcException(ErrorCode.SCORE_ALREADY_EXISTS)
}
saveAndFlush가 핵심입니다. 그냥 save()면 SQL이 커밋 시점으로 밀려서 try 블록 밖에서 터집니다. 어댑터에서 도메인 예외로 번역하면 GsmcExceptionResolver에 도달할 땐 이미 GsmcException이라 글로벌 핸들러를 건드릴 필요가 없습니다.
3. 재제출 규칙 정리
현재 REJECTED만 재사용하고 나머지 상태는 중복 행을 만듭니다. REJECTED·PENDING은 기존 행 재사용, APPROVED만 에러로 정리합니다.
작업 목록
참고
docs/DB_CONSISTENCY_REVIEW.md [2]번. 진단 결과 위험도 "높음" 3건 중 하나이며, 유일하게 스키마 변경이 필요한 항목입니다(나머지 2건은 순수 코드 버그).
관련: #117 (CHECK 제약)
문제
비누적(
is_accumulated = false) 카테고리에 대해 동시 요청이 들어오면 중복 score가 생성될 수 있습니다.AppendScoreSupport.kt:88-101—findByUserIdAndCategoryTypeAndScoreStatus(..., REJECTED)로 조회 후 있으면 재사용, 없으면 신규 생성하는 exists-then-create 패턴ScorePersistenceAdapter.kt:87-108— QueryDSLfetchFirst()뿐이고@Lock없음(user_id, category_id)유니크 제약 없음 (ScoreJpaEntity에는dg_project_id인덱스만 존재)SubmitProjectParticipationService.kt:79-82도 동일한 exists-then-throw 패턴두 요청이 거의 동시에 들어오면 둘 다 "기존 score 없음"으로 판단해 각각 PENDING score를 만듭니다.
왜 CHECK로 해결할 수 없는가
is_accumulated가category_tb에 있는데 MySQL CHECK는 같은 행만 참조 가능합니다. 따라서 "비누적 카테고리일 때만 (user_id, category_id) 유일" 같은 조건부 제약을 CHECK로 표현할 수 없습니다. MySQL은 부분 인덱스(partial/filtered unique index)도 지원하지 않습니다.→ 별도 슬롯 테이블의 진짜
UNIQUE로 표현합니다.제안하는 개선
1. 슬롯 테이블 추가
ON DELETE CASCADE는 슬롯이 score에 종속된 값이므로 적절합니다.2. 유니크 위반을 도메인 예외로 번역 (필수)
이 단계를 빠뜨리면 사용자에게 500이 나갑니다. 이유:
GlobalExceptionHandler.kt:17-21에DataIntegrityViolationException→DUPLICATE_RESOURCE매핑이 있지만, 이 클래스는@RestControllerAdvice(:9)라 Spring MVC REST 컨트롤러에만 적용됩니다. 이 프로젝트 API는 GraphQL이므로 사실상 죽은 코드입니다.GsmcExceptionResolver를 타는데,GsmcException만 분기하고 나머지는 전부INTERNAL_SERVER_ERROR로 떨어집니다(:27-34).슬롯 테이블의 유니크 위반은 동시 요청 시 두 번째 요청이 걸리는 게 설계된 정상 경로이므로, 반드시 의미 있는 도메인 에러로 번역해야 합니다.
DeveloperPersistenceAdapter.kt:53-58에 이미 있는 기존 패턴을 그대로 따릅니다.saveAndFlush가 핵심입니다. 그냥save()면 SQL이 커밋 시점으로 밀려서 try 블록 밖에서 터집니다. 어댑터에서 도메인 예외로 번역하면GsmcExceptionResolver에 도달할 땐 이미GsmcException이라 글로벌 핸들러를 건드릴 필요가 없습니다.3. 재제출 규칙 정리
현재
REJECTED만 재사용하고 나머지 상태는 중복 행을 만듭니다.REJECTED·PENDING은 기존 행 재사용,APPROVED만 에러로 정리합니다.작업 목록
V{n}__add_score_unique_slot_table.sqlScoreUniqueSlotJpaEntity+ScoreUniqueSlotJpaRepositoryAppendScoreSupport에 슬롯 삽입 + 예외 번역ErrorCode.SCORE_ALREADY_EXISTS추가AppendScoreSupportTest에 재제출 시나리오 추가참고
docs/DB_CONSISTENCY_REVIEW.md[2]번. 진단 결과 위험도 "높음" 3건 중 하나이며, 유일하게 스키마 변경이 필요한 항목입니다(나머지 2건은 순수 코드 버그).관련: #117 (CHECK 제약)