Skip to content

[score] 비누적 카테고리 중복 점수 신청 race condition (유니크 제약 부재) #118

Description

@jyx-07

문제

비누적(is_accumulated = false) 카테고리에 대해 동시 요청이 들어오면 중복 score가 생성될 수 있습니다.

  • AppendScoreSupport.kt:88-101findByUserIdAndCategoryTypeAndScoreStatus(..., 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_accumulatedcategory_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-21DataIntegrityViolationExceptionDUPLICATE_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만 에러로 정리합니다.

작업 목록

  • V{n}__add_score_unique_slot_table.sql
  • ScoreUniqueSlotJpaEntity + ScoreUniqueSlotJpaRepository
  • AppendScoreSupport에 슬롯 삽입 + 예외 번역
  • ErrorCode.SCORE_ALREADY_EXISTS 추가
  • AppendScoreSupportTest에 재제출 시나리오 추가

참고

docs/DB_CONSISTENCY_REVIEW.md [2]번. 진단 결과 위험도 "높음" 3건 중 하나이며, 유일하게 스키마 변경이 필요한 항목입니다(나머지 2건은 순수 코드 버그).

관련: #117 (CHECK 제약)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions