Skip to content

수험번호 발급 안정성 및 동점자 선발 정책 보완 #399

Closed

Description

@kusuri12-09

Summary

수험번호 발급 안정성 및 동점자 선발 정책 보완

Description

발급 중 거리 조회 실패·번호 중복 위험 및 ID 기준 동점 처리를 개선한다.

Steps to Reproduce

  1. 여러 지원자의 번호 발급 중 거리 조회 한 건을 실패시킨다. 동시 발급 요청과 동점 지원자의 산출 결과를 확인한다.
  2. 아래 완료 기준과 응답·저장 결과를 대조한다.

Expected Result

확정한 실패 처리 정책으로 발급하고 수험번호 중복을 방지한다. 발급 후 화면을 갱신하며 합의된 동점자 기준을 적용한다.

Actual Result

기존 번호 보존은 개선되었지만 거리 조회 실패는 전체 발급을 중단한다. DB 번호 UNIQUE 제약이 없으며 동점 시 applicantId 순으로 선발한다.

Environment

미확인 — PDF 보고 내용과 로컬 코드 정적 분석 기준. 배포 환경 재현 필요.

Version / Commit

미확인 — 2026-10-06 로컬 코드 확인 기준이며 배포 버전과 동일함을 보장하지 않는다.

Logs / Stack Trace

별도 실행 로그 없음. 코드 확인 및 PDF 보고 내용을 근거로 작성.

Additional Notes

원본 이슈 ID: M11, L26
참고 구현: ApplicantService.kt, ExamineeNumberPolicy.kt, ScreeningPolicy.kt

완료 기준:

  • 거리 조회 실패 정책 확정·적용
  • 번호 중복 방지
  • 발급 후 캐시 갱신
  • 모집요강에 따른 동점자 규칙 확정·적용

이미 수정된 구현은 유지하고 남은 범위만 처리한다. 프런트엔드·운영 데이터는 이번 코드 조사 범위 밖이므로 구현 전 확인한다. 정책 결정이 필요한 항목은 기존 보안 검증을 제거하지 않고 확정된 정책에 따라 적용한다.

Activity

  1. coderabbitai commented on Oct 6, 2026

    @coderabbitai

    Coding Plan

    Summary

    • 거리 조회 실패는 지원자 단위로 수집합니다. 해당 전형·지역 그룹만 보류하고 나머지는 발급합니다. 이렇게 하면 배치 안의 거리 순서를 지키면서 부분 발급을 허용합니다. 결과는 새 응답 필드로 보고합니다.
    • 번호 중복은 세 가지로 막습니다. NOWAIT 발급 잠금으로 할당을 직렬화합니다. 두 테이블의 모든 번호를 예약합니다. 이름 있는 UNIQUE 제약으로 모든 쓰기 경로에서 차단합니다.
    • "캐시 갱신"은 관리자 API의 no-store와 프런트엔드 재조회로 구현합니다. 백엔드에 불필요한 캐시를 새로 만들지 않습니다.
    • 동점자 규칙은 요강 확인 뒤 도메인 단일 키 목록으로 적용합니다. 기본 순서는 총점, 교과, 출석, 봉사, submittedAt, applicantId입니다. 구성 점수는 기존 ScoreCalculator 재계산 패턴으로 전달합니다.
    Design Choices
    Design Choice 1: 거리 조회 실패를 어떻게 처리하는가

    거리 순서는 같은 전형·지역 그룹 안에서 번호 순번을 결정합니다. 일부 지원자만 건너뛰면 같은 배치 안의 거리 순서가 깨집니다.

    Options Considered:

    1. 전체 중단 유지: 현재 동작을 유지하고 실패한 지원자 목록만 보고합니다.
    2. 지원자 단위 건너뛰기: 실패한 지원자만 제외하고 나머지는 발급합니다.
    3. 그룹 단위 보류: 거리 미확정 대기 지원자가 있는 전형·지역 그룹만 이번 실행에서 발급하지 않습니다. 다른 그룹은 발급합니다.
    4. 재시도 후 전체 중단: 한정된 횟수로 재시도한 뒤 실패하면 전체를 중단합니다.

    Chosen Option: 3

    Rationale: 장애가 발생해도 영향받지 않은 그룹은 발급하고, 한 배치 안의 거리 순서 불변식도 지킵니다. 재실행하면 보류된 그룹을 이어서 발급합니다. 운영 부서 확인이 필요합니다.

    Design Choice 2: 번호 중복을 어떤 수단으로 막는가

    저장 시점의 행 잠금은 번호 공간 전체를 직렬화하지 않습니다.

    Options Considered:

    1. DB UNIQUE 제약만 추가합니다.
    2. 발급 잠금만 추가합니다.
    3. UNIQUE 제약과 발급 전용 잠금 행을 함께 사용합니다.

    Chosen Option: 3

    Rationale: UNIQUE 제약은 모든 쓰기 경로의 최종 불변식이 됩니다. 잠금은 정상적인 동시 요청을 충돌 없이 직렬화합니다.

    Design Choice 3: 동시 발급 요청이 들어오면 대기하는가

    대기하는 요청은 BFF의 gRPC deadline을 넘을 수 있습니다.

    Options Considered:

    1. 잠금을 얻을 때까지 대기한 뒤, 최신 스냅샷에서 이미 발급된 번호를 건너뜁니다.
    2. NOWAIT(잠금 타임아웃 0)로 즉시 실패하고 409 "발급 진행 중"을 반환합니다.

    Chosen Option: 2

    Rationale: 클라이언트 deadline을 넘긴 뒤에 서버만 커밋하는 모호한 상태를 피합니다. 관리자는 목록을 다시 조회하면 결과를 확인할 수 있습니다.

    Design Choice 4: "발급 후 캐시 갱신"은 무엇을 의미하는가

    백엔드에는 무효화할 지원자 응답 캐시가 없습니다.

    Options Considered:

    1. 백엔드 캐시를 새로 도입하고 발급 시 무효화합니다.
    2. 관리자 API에 Cache-Control: no-store를 적용하고, 프런트엔드가 발급 성공 후 목록을 다시 조회합니다.
    3. 발급 응답에 발급된 번호 목록을 담아 프런트엔드가 직접 반영합니다.

    Chosen Option: 2

    Rationale: 존재하지 않는 캐시를 만들지 않습니다. 브라우저와 중간 캐시의 오래된 응답을 차단합니다. 개인정보 응답의 보안 측면에서도 적절합니다.

    Design Choice 5: 어떤 동점자 규칙을 적용하는가

    공식 모집요강 원문이 저장소에 없습니다. applicantId는 접수 순서를 보장하지 않습니다.

    Options Considered:

    1. applicantId 순서를 유지합니다.
    2. submittedAt 오름차순으로 바꿉니다.
    3. 모집요강의 구성 점수 순서를 적용하고, 마지막 결정 키로 submittedAt과 applicantId를 사용합니다.

    Chosen Option: 3

    Rationale: 기본 키 순서는 totalScore → 교과(subjectScore) → 출석(attendanceScore) → 봉사(volunteerScore) → submittedAt → applicantId입니다. 구현 전에 2027학년도 입학전형 요강의 동점자 조항으로 이 순서를 확정해야 합니다. 키 순서는 도메인의 한 곳에서만 정의합니다.

    Design Choice 6: 구성 점수를 어떻게 확보하는가

    Options Considered:

    1. 제출 시 구성 점수를 컬럼에 저장하고 기존 데이터를 백필합니다.
    2. 요약 조회 시 기존 ScoreCalculator.calculateBreakdown()으로 다시 계산해서 전달합니다.

    Chosen Option: 2

    Rationale: 신청서 조회의 기존 패턴과 같습니다. 마이그레이션과 백필이 필요 없습니다.

    💡 User Tips

    Regenerate the plan with different choices with @coderabbitai <feedback>.

    Implementation Steps

    Phase 1: 거리 조회 실패 정책 적용

    목표: 거리 조회 실패를 지원자 단위로 수집하고 그룹 단위로 보류합니다. 장애가 없는 그룹의 발급은 계속합니다. 응답에 실패 정보를 담습니다. 이 단계는 도메인 정책, 애플리케이션 서비스, 거리 어댑터를 함께 바꿉니다. 세 계층이 하나의 실패 정책을 구성하기 때문입니다.

    Task 1: 발급 정책에 그룹 보류 규칙 추가

    거리 미확정 대기 지원자가 있는 그룹을 이번 실행의 할당 대상에서 제외합니다.

    • ExamineeNumberPolicy.issue()가 일부 항목이 빠진 거리 맵을 받도록 변경합니다. distances.getValue() 대신 누락 여부로 판단합니다.
    • 할당 규칙을 다음처럼 바꿉니다. 전형·지역 접두사 그룹의 대기 지원자 중 한 명이라도 거리가 없으면 그 그룹 전체를 할당하지 않습니다.
    • 결과 모델에 deferredCount(보류 그룹의 대기 지원자 수)를 추가합니다. 결과 모델에 failedApplicantIds(거리 조회 실패 지원자 ID)도 추가합니다.
    • 불변식을 유지합니다. issuedCount + skippedCount + deferredCount = totalTargets가 항상 성립해야 합니다.
    • 기존 규칙은 그대로 둡니다. 대상은 다음과 같습니다.
      • 적격성 판단
      • 유효 번호 예약
      • 잘못된 형식 번호 보존
      • 최대 순번 다음부터 할당
      • 999 초과 시 EXAMINEE_NUMBER_LIMIT_EXCEEDED
    Task 2: 서비스에서 지원자별 거리 실패 수집

    발급 전체를 중단하지 않고 실패를 수집합니다.

    • ApplicantService.issueAll()의 거리 조회 루프를 바꿉니다. AdminDomainException(DISTANCE_SERVICE_UNAVAILABLE)만 지원자 단위로 잡아서 실패 ID로 기록합니다.
    • 다른 예외는 넓게 잡지 않습니다. 그대로 전파해서 트랜잭션을 롤백합니다.
    • 실패 로그에는 지원자 ID만 남깁니다. 주소, URL, API 키는 남기지 않습니다.
    • 대기 지원자가 모두 실패하면 성공 응답을 반환합니다. 이때 issuedCount = 0이고 실패 목록을 채웁니다.
    Task 3: 거리 응답 검증 보강

    잘못된 거리 값이 번호 순서에 들어가지 않게 합니다.

    • KakaoDistanceAdapter.kt에서 다음 값을 unavailable()로 처리합니다.
      • 음수 거리
      • 유한하지 않은 좌표
      • 위도·경도 범위를 벗어난 좌표
    • 기존 동작을 유지합니다. 대상은 timeout 설정, non-2xx 실패, result_code 검사, API 키 비노출입니다.
    • 재시도는 추가하지 않습니다. 그룹 보류와 재실행이 일시 장애를 처리합니다.
    Task 4: 정책·서비스·어댑터 테스트 갱신
    • AdminPolicyTest.kt에 다음 테스트를 추가합니다.
      • 실패 그룹이 보류되는 경우
      • 다른 그룹은 정상 발급되는 경우
      • 카운트 불변식
      • 재실행 시 보류 그룹이 발급되는 경우
    • AdminApplicationModuleTest.kt(371–401)의 "거리 실패 시 저장 없음" 테스트를 새 정책에 맞게 바꿉니다. 실패 그룹은 저장하지 않고, 다른 그룹은 저장하고, 실패 ID를 반환하는지 검증합니다.
    • KakaoDistanceAdapterTest.kt에 음수 거리 거부 테스트와 범위 밖 좌표 거부 테스트를 추가합니다.
    🤖 Prompt for AI agents
    거리 조회 실패를 지원자별로 수집하고 해당 전형·지역 그룹의 발급만 보류합니다. 도메인 정책, 서비스, 거리 어댑터를 함께 변경하여 정상 그룹의
    발급과 실패 정보 반환을 보장합니다.
    
    - `ExamineeNumberPolicy.issue()`는 일부 항목이 없는 거리 맵을 받도록 변경합니다.
    `distances.getValue()` 대신 누락 여부를 확인합니다. 접두사 그룹의 대기 지원자 중 한 명이라도 거리가 없으면 그룹 전체를
    할당 대상에서 제외합니다.
    - 결과에 `deferredCount`와 `failedApplicantIds`를 추가합니다. `issuedCount + skippedCount
    + deferredCount = totalTargets`를 유지합니다.
    - 적격성 판단, 유효 번호 예약, 잘못된 형식 번호 보존, 최대 순번 다음부터 할당, 999 초과 시
    `EXAMINEE_NUMBER_LIMIT_EXCEEDED` 규칙을 유지합니다.
    - `ApplicantService.issueAll()`에서
    `AdminDomainException(DISTANCE_SERVICE_UNAVAILABLE)`만 지원자 단위로 처리합니다. 실패 ID를
    수집합니다. 다른 예외는 전파하여 트랜잭션을 롤백합니다.
    - 실패 로그에는 지원자 ID만 기록합니다. 주소, URL, API 키는 기록하지 않습니다. 모든 대기 지원자의 거리 조회가 실패해도
    `issuedCount = 0`과 실패 목록을 담은 성공 응답을 반환합니다.
    - `KakaoDistanceAdapter.kt`에서 음수 거리, 유한하지 않은 좌표, 위도·경도 범위 밖 좌표를 `unavailable()`로
    처리합니다. timeout, non-2xx 실패, `result_code` 검사, API 키 비노출을 유지합니다. 재시도는 추가하지 않습니다.
    - `AdminPolicyTest.kt`에서 그룹 보류, 다른 그룹 발급, 카운트 불변식, 재실행 발급을 검증합니다.
    `AdminApplicationModuleTest.kt`(371–401)는 실패 그룹 미저장, 정상 그룹 저장, 실패 ID 반환을 검증하도록
    바꿉니다. `KakaoDistanceAdapterTest.kt`에 음수 거리와 범위 밖 좌표 거부 테스트를 추가합니다.
    - 그룹 보류 정책은 담당자 확인이 끝난 뒤 병합합니다.
    

    Phase 2: 수험번호 중복 방지

    목표: 번호 할당을 직렬화하고, 모든 기존 번호를 예약하고, DB 불변식으로 중복을 차단합니다. 이 단계는 영속성 계층과 마이그레이션에 집중합니다.

    Task 1: 발급 전용 잠금 도입

    동시 issueAll() 요청이 같은 스냅샷에서 할당하지 않게 합니다.

    • application-administration-application에 발급 잠금 포트를 추가합니다. 예: acquireIssuanceLock().
    • application-administration-adapter-out에 단일 행 잠금 엔티티와 저장소를 추가합니다. 저장소는 기존 AdminJpaRepositories.kt 패턴(@Lock(PESSIMISTIC_WRITE))을 따릅니다. 잠금 타임아웃 0(NOWAIT) 힌트를 적용합니다.
    • ApplicantService.issueAll()은 트랜잭션의 첫 문장으로 잠금을 획득합니다. MySQL REPEATABLE READ에서는 첫 일반 읽기 시점에 read view가 생성됩니다. 따라서 잠금 획득이 스냅샷 조회보다 먼저 실행되어야 합니다.
    • 잠금 획득 실패와 잠금 타임아웃 예외는 새 오류 코드 EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS(409)로 변환합니다.
    • 테스트 DB 방언이 NOWAIT를 지원하는지 확인합니다. 지원하지 않으면 짧은 잠금 타임아웃으로 대체합니다.
    • 거리 조회는 잠금을 보유한 상태로 실행됩니다. 이 잠금은 발급 요청끼리만 막고, 지원자 행은 잠그지 않습니다.
    Task 2: 전체 기존 번호 예약

    screening과 applicants 양쪽의 번호를 모두 예약합니다.

    • LocalApplicantDataAdapter.kt에 조회를 추가합니다. 이 조회는 상태와 관계없이 두 테이블의 non-null 번호 합집합을 반환합니다.
    • ExamineeNumberPolicy.issue()는 추가 예약 번호 집합을 받습니다. 유효한 번호만 해당 접두사의 순번을 예약합니다. 잘못된 형식의 번호는 기존 규칙대로 예약하지 않습니다.
    • 이 변경으로 다음 번호도 예약됩니다.
      • 직접 RPC로 applicants에만 기록된 번호
      • 조회 대상 상태에서 빠진 지원자의 번호
    Task 3: UNIQUE 제약 마이그레이션
    • systems/application/application-bootstrap/src/main/resources/db/migration/에 다음 순번의 Flyway 마이그레이션을 추가합니다.
    • 다음 이름의 UNIQUE 인덱스를 추가합니다.
      • uk_applicants_examinee_number
      • uk_screening_examinee_number
    • 같은 마이그레이션에서 발급 전용 잠금의 단일 잠금 행 테이블을 생성하고 초기 행을 넣습니다.
    • NULL 값은 여러 개 허용됩니다. 잘못된 형식의 기존 번호도 UNIQUE 대상입니다. 기존 번호를 수정하거나 삭제하지 않습니다.
    • 운영 데이터에 중복이 있으면 인덱스 생성이 실패합니다. 이것이 의도한 보호 장치입니다. tools/administration-migration/README.md에 다음 내용을 추가합니다.
      • 배포 전 중복 탐지 SQL
      • "결과 0건이어야 배포 가능" 조건
      • migrate.py가 중복 번호에서 1062로 실패할 수 있다는 주의 사항
    • 엔티티 매핑(ApplicantJpaEntity.kt, ScreeningJpaEntity.kt)에 동일한 제약 이름을 선언합니다. 스키마 검증이 활성화된 경우 불일치를 막기 위해서입니다.
    Task 4: 제약 위반 변환
    • LocalApplicantDataAdapter.save()에서 screening 저장을 flush해서 위반을 트랜잭션 안에서 드러나게 합니다.
    • 제약 이름이 일치하는 DataIntegrityViolationException만 새 오류 코드 EXAMINEE_NUMBER_CONFLICT(409)로 변환합니다. 다른 무결성 오류는 그대로 둡니다.
    • application-adapter-out의 ApplicantPersistenceAdapter.kt(saveAndFlush)에서도 uk_applicants_examinee_number 위반을 application 모듈의 오류 체계로 변환합니다. 이 변환은 직접 RPC 경로에 적용됩니다.
    • 기존 require(existing == null || existing == requested) 교체 금지 규칙을 유지합니다. statusVersion 검사도 유지합니다.
    • admin BFF의 오류 재구성(GrpcAdministrationAdapter.kt 87–104)이 새 코드 두 개를 409로 매핑하는지 확인합니다.
    Task 5: 동시성·제약 통합 테스트
    • ApplicationCorrectionAdapterTest.kt(162–179)의 2스레드 패턴을 따릅니다. Spring 프록시를 거친 ApplicantService.issueAll() 동시 호출 테스트를 추가합니다. 기대 결과는 다음과 같습니다.
      • 한 요청은 성공합니다.
      • 다른 요청은 EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS로 실패합니다.
      • 번호 중복이 없습니다.
    • 다음 테스트를 추가합니다.
      • applicants에만 있는 번호가 예약되는 경우
      • 중복 번호 저장이 EXAMINEE_NUMBER_CONFLICT로 실패하고 롤백되는 경우
    • 기존 issuedNumberSurvivesTypeRegionAndAddressCorrectionAndIsNotReused가 계속 통과해야 합니다.
    🤖 Prompt for AI agents
    발급 전용 잠금으로 번호 할당을 직렬화하고 두 테이블의 기존 번호를 모두 예약합니다. 이름 있는 UNIQUE 제약을 추가하여 동시 발급과 직접
    RPC를 포함한 모든 쓰기 경로의 중복을 차단합니다.
    
    - `application-administration-application`에 발급 잠금 포트를 추가합니다. 포트 예시는
    `acquireIssuanceLock()`입니다. `application-administration-adapter-out`에 단일 행 잠금
    엔티티와 저장소를 추가합니다. `AdminJpaRepositories.kt`의 `@Lock(PESSIMISTIC_WRITE)` 패턴과 잠금
    타임아웃 0(NOWAIT) 힌트를 사용합니다.
    - `ApplicantService.issueAll()`은 트랜잭션의 첫 문장으로 잠금을 획득합니다. MySQL REPEATABLE READ의
    read view가 생성되는 일반 읽기와 스냅샷 조회보다 먼저 실행합니다. 잠금을 보유한 상태로 거리 조회를 수행합니다. 발급 잠금은 발급
    요청끼리만 직렬화하고 지원자 행은 잠그지 않습니다.
    - 잠금 획득 실패와 타임아웃을 `EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS`(409)로 변환합니다. 테스트 DB가
    NOWAIT를 지원하지 않으면 짧은 잠금 타임아웃을 사용합니다.
    - `LocalApplicantDataAdapter.kt`에서 상태와 관계없이 `screening`과 `applicants`의 non-null
    번호 합집합을 조회합니다. 서비스에서 예약 번호 조회를 추가합니다. `ExamineeNumberPolicy.issue()`에 추가 예약 번호
    집합을 전달합니다. 유효한 번호만 접두사별 순번을 예약합니다. 직접 RPC로 `applicants`에만 기록된 번호와 조회 대상 상태 밖의
    번호도 포함합니다. 잘못된 형식의 번호는 예약하지 않습니다.
    - `systems/application/application-bootstrap/src/main/resources/db/migration/`에
    다음 순번의 Flyway 마이그레이션을 추가합니다. `uk_applicants_examinee_number`,
    `uk_screening_examinee_number` UNIQUE 인덱스와 단일 잠금 행 테이블 및 초기 행을 추가합니다. 여러 NULL을
    허용합니다. 잘못된 형식의 기존 번호에도 UNIQUE를 적용합니다. 기존 번호는 수정하거나 삭제하지 않습니다.
    - `ApplicantJpaEntity.kt`와 `ScreeningJpaEntity.kt`에 같은 제약 이름을 선언하여 스키마 검증 불일치를
    막습니다. `tools/administration-migration/README.md`에 배포 전 중복 탐지 SQL, "결과 0건이어야 배포
    가능" 조건, `migrate.py`의 중복 번호 1062 실패 주의 사항을 기록합니다. 운영 중복은 자동 수정하지 않습니다. 운영 부서와 정정
    절차를 합의합니다.
    - `LocalApplicantDataAdapter.save()`에서 screening 저장을 flush합니다. 제약 이름이 일치하는
    `DataIntegrityViolationException`만 `EXAMINEE_NUMBER_CONFLICT`(409)로 변환합니다. 다른
    무결성 오류는 유지합니다. `ApplicantPersistenceAdapter.kt`의 `saveAndFlush` 경로에서도
    `uk_applicants_examinee_number` 위반을 application 모듈 오류 체계로 변환합니다.
    - administration 도메인 ErrorCode에 새 오류 코드 두 개를 추가합니다.
    `GrpcAdministrationAdapter.kt`(87–104)의 BFF 오류 재구성이 두 코드를 409로 매핑하는지 확인합니다.
    - `require(existing == null || existing == requested)`, `statusVersion` 검사, 기존
    번호와 잘못된 형식 번호 보존, ID 오름차순 저장 순서를 유지합니다.
    - `ApplicationCorrectionAdapterTest.kt`(162–179)의 2스레드 패턴으로 Spring 프록시를 거친 동시
    발급을 테스트합니다. 한 요청 성공, 다른 요청의 발급 진행 중 오류, 중복 없음, applicants 전용 번호 예약, 중복 저장 충돌과
    롤백을 검증합니다. `issuedNumberSurvivesTypeRegionAndAddressCorrectionAndIsNotReused`가
    계속 통과하도록 합니다.
    

    Phase 3: 발급 결과 전달과 화면 갱신

    목표: 새 결과 필드를 HTTP 호출자까지 전달합니다. 발급 후 목록·상세 조회가 오래된 응답을 받지 않게 합니다.

    Task 1: 발급 응답 필드 확장
    • data_json은 JSON이므로 proto 변경 없이 필드를 추가할 수 있습니다.
    • BFF의 ApplicantResponses.kt(76–80)와 ResponseMapper.kt(64–69)에 deferredCount, failedApplicantIds를 추가합니다.
    • 기존 필드(issuedCount, skippedCount, totalTargets)의 이름과 의미는 유지합니다.
    Task 2: 관리자 API 캐시 차단
    • systems/admin/admin-bootstrap/.../AdminConfig.kt에 Spring WebContentInterceptor를 등록합니다. /api/v11/admin/**에 CacheControl.noStore()를 적용합니다.
    • 기존 AdminAuthorizationInterceptor 등록과 검증 순서를 바꾸지 않습니다.
    • BFF 웹 테스트에서 다음을 검증합니다.
      • 목록, 상세, 발급 응답의 Cache-Control: no-store 헤더
      • 새 응답 필드
    Task 3: 프런트엔드 후속 작업 정의
    • 프런트엔드는 이번 범위 밖입니다. "후속 에이전트 지침"에 요구사항을 기록합니다.
    • identity 프로젝션은 번호를 노출하지 않습니다. 따라서 변경하지 않습니다.
    🤖 Prompt for AI agents
    발급 결과의 보류 수와 실패 지원자 ID를 HTTP 응답까지 전달합니다. 관리자 API에 `no-store`를 적용하고 프런트엔드 재조회
    요구사항을 후속 작업으로 기록합니다.
    
    - `data_json`은 JSON이므로 발급 응답을 위한 proto 변경 없이 필드를 확장합니다. BFF
    `ApplicantResponses.kt`(76–80)와 `ResponseMapper.kt`(64–69)에 `deferredCount`,
    `failedApplicantIds`를 추가합니다. `issuedCount`, `skippedCount`, `totalTargets`의 이름과
    의미를 유지합니다.
    - `systems/admin/admin-bootstrap/.../AdminConfig.kt`에 Spring
    `WebContentInterceptor`를 등록합니다. `/api/v11/admin/**`에 `CacheControl.noStore()`를
    적용합니다. 기존 `AdminAuthorizationInterceptor` 등록과 검증 순서는 유지합니다.
    - BFF 웹 테스트에서 목록, 상세, 발급 응답의 `Cache-Control: no-store` 헤더와 새 응답 필드를 검증합니다.
    - 프런트엔드는 이번 범위 밖으로 유지합니다. 후속 지침에 발급 성공 후 지원자 목록과 열린 상세 화면 재조회를 기록합니다.
    `deferredCount > 0`이면 "거리 조회 실패로 일부 그룹 발급 보류" 안내와 재실행 버튼을 표시하도록 기록합니다. 409
    `EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS`이면 "다른 발급 진행 중" 안내 후 목록을 다시 조회하도록 기록합니다.
    - 번호를 노출하지 않는 identity 프로젝션은 변경하지 않습니다. 백엔드 캐시는 새로 도입하지 않습니다.
    - `AdminAuthorizationInterceptor`, `GrpcAdministrationAdapter.call()`,
    `AdministrationGrpcService.respond()`의 ID·역할·크기 검증을 제거하거나 우회하지 않습니다.
    

    Phase 4: 동점자 선발 규칙 교체

    목표: 확정된 모집요강 규칙으로 1차 선발 정렬을 바꿉니다. 구현 전에 정책을 확정해야 합니다.

    Task 1: 정책 확정
    • 입학 담당자에게 2027학년도 입학전형 요강의 동점자 조항을 확인합니다. 확인할 내용은 다음과 같습니다.
      • 키 순서
      • 검정고시 적용 방식
      • 가산점 포함 여부
    • 확정 결과를 documents/features/admin-applicant-via-grpc/context.md의 50행과 91행에 반영합니다. 두 행은 현재 applicantId 규칙을 설명합니다.
    • 요강이 구성 점수를 사용하지 않으면 구성 점수 전달 작업을 생략합니다. 이 경우 ScreeningPolicy 정렬 교체 작업의 키 목록만 바꿉니다.
    Task 2: 구성 점수를 선발 입력까지 전달
    • contracts/proto/application.proto의 관리자 지원자 메시지(92행 total_score 부근)에 optional 필드를 추가합니다. 추가할 필드는 subject_score, attendance_score, volunteer_score, additional_score입니다.
    • ApplicationCommandService.kt의 지원자 요약 생성(174–179)에서 totalScore가 있는 제출 이후 상태만 대상으로 ScoreCalculator.calculateBreakdown()을 호출합니다. 예외를 넓게 삼키지 않습니다.
    • LocalApplicantDataAdapter.kt의 매핑에 다음 처리를 추가합니다. totalScore와 같은 has…() 패턴으로 필드를 읽고, 관리자 Applicant 모델에 nullable 구성 점수 필드를 추가합니다.
    Task 3: `ScreeningPolicy` 정렬 교체
    • ScreeningPolicy.kt의 comparator를 도메인의 단일 키 목록으로 교체합니다. KDoc에 요강 조항을 인용합니다.
    • 비교 규칙은 다음과 같습니다.
      • 점수 비교는 소수 셋째 자리 기준으로 합니다. 부동소수 오차를 피하기 위해서입니다.
      • 구성 점수가 null이면 뒤로 정렬(nullsLast)합니다. 제외하지는 않습니다.
      • submittedAt은 오름차순입니다.
      • 마지막 키는 id입니다. 결정적 순서를 유지하기 위해서입니다.
    • 정원 hard cut, 재실행 재평가, INVALID_STATUS_TRANSITION, 제외 조건은 바꾸지 않습니다.
    Task 4: 선발 테스트 갱신
    • AdminPolicyTest.kt의 "동점이면 지원자 번호가 빠른 지원자를 우선 합격시킨다" 테스트(179)를 새 규칙 이름과 기대값으로 바꿉니다.
    • 다음 테스트를 추가합니다.
      • 총점 동점에서 교과 점수 우선
      • 모든 점수 동점에서 submittedAt 우선
      • 3명 이상이 정원 경계에서 동점인 경우
      • 소수 셋째 자리 동점
    • application 모듈 테스트에서 요약에 구성 점수가 실리는지 검증합니다.
    🤖 Prompt for AI agents
    2027학년도 모집요강의 동점자 조항을 확정한 뒤 1차 선발 정렬을 교체합니다. 확정된 키 순서를 도메인의 한 곳에서 정의하고 필요한 구성
    점수를 선발 입력까지 전달합니다.
    
    - 구현 전에 입학 담당자에게 키 순서, 검정고시 적용 방식, 가산점 포함 여부를 확인합니다. 기본 검토 순서는 `totalScore` →
    `subjectScore` → `attendanceScore` → `volunteerScore` → `submittedAt` →
    `applicantId`입니다. 담당자 확인이 끝난 뒤 병합합니다.
    - 확정 결과를 `documents/features/admin-applicant-via-grpc/context.md`의 50행과 91행에
    반영합니다. 요강이 구성 점수를 사용하지 않으면 구성 점수 전달 작업을 생략하고 정렬 키 목록만 변경합니다.
    - 구성 점수를 사용하는 경우 `contracts/proto/application.proto`의 관리자 지원자 메시지에서 92행
    `total_score` 부근에 optional `subject_score`, `attendance_score`,
    `volunteer_score`, `additional_score`를 추가합니다.
    - `ApplicationCommandService.kt`의 지원자 요약 생성(174–179)에서 `totalScore`가 있는 제출 이후
    상태만 대상으로 `ScoreCalculator.calculateBreakdown()`을 호출합니다. 예외를 넓게 삼키지 않습니다. 구성 점수
    저장 컬럼이나 백필은 추가하지 않습니다.
    - `LocalApplicantDataAdapter.kt`에서 `totalScore`와 같은 `has…()` 패턴으로 구성 점수를 읽습니다.
    관리자 `Applicant` 모델에 nullable 구성 점수 필드를 추가합니다.
    - `ScreeningPolicy.kt`의 comparator를 확정된 도메인 단일 키 목록으로 교체합니다. KDoc에 요강 조항을 인용합니다.
    점수는 소수 셋째 자리 기준으로 비교합니다. null 구성 점수는 `nullsLast`로 뒤에 정렬하되 제외하지 않습니다.
    `submittedAt`은 오름차순으로 정렬합니다. 마지막 키 `id`로 결정적 순서를 유지합니다.
    - 정원 hard cut, 재실행 재평가, `INVALID_STATUS_TRANSITION`, 제외 조건을 유지합니다.
    - `AdminPolicyTest.kt`의 기존 동점자 ID 우선 테스트(179)의 이름과 기대값을 변경합니다. 총점 동점에서 교과 점수 우선,
    모든 점수 동점에서 `submittedAt` 우선, 정원 경계에서 3명 이상 동점, 소수 셋째 자리 동점을 테스트합니다. application
    모듈 테스트에서 요약의 구성 점수 전달을 검증합니다.
    
    Research

    티켓 요약

    수험번호 발급 중 거리 조회 실패 처리 정책을 확정하고 적용합니다. 동시 발급과 직접 RPC 경로에서도 수험번호 중복을 DB 수준에서 차단합니다. 발급 후에는 화면이 항상 최신 데이터를 다시 읽도록 보장합니다. applicantId 기준인 동점자 선발 규칙은 모집요강 기준 규칙으로 교체합니다. 이미 반영된 기존 번호 보존, 저장 시 행 잠금, 버전 충돌 검사, 관리자 권한 검증은 그대로 유지합니다.

    코드베이스 요약

    발급 흐름은 다음 순서로 진행됩니다. ApplicantController.issueExamineeNumbers()(admin BFF), GrpcAdministrationAdapter.issueAll(), AdministrationGrpcService.issueExamineeNumbers(), ApplicantService.issueAll(), ExamineeNumberPolicy.issue() 순입니다. ApplicantService.issueAll()은 하나의 트랜잭션 안에서 다음을 수행합니다.

    • 스냅샷을 조회합니다.
    • KakaoDistanceAdapter로 거리를 순차 조회합니다.
    • 정책을 적용합니다.
    • LocalApplicantDataAdapter.saveAll()로 저장합니다.

    현재 문제는 다음과 같습니다.

    • 거리 조회가 한 건만 실패해도 DISTANCE_SERVICE_UNAVAILABLE로 전체 발급이 중단됩니다.
    • applicants.examinee_number(V009)와 screening.examinee_number(V015)에는 UNIQUE 제약이 없습니다.
    • 행 잠금은 번호 할당이 끝난 뒤 저장 시점에만 걸립니다. 따라서 서로 다른 스냅샷에서 같은 번호를 할당할 수 있습니다.
    • 발급은 번호를 screening에서만 읽습니다. 따라서 applicants에만 있는 번호는 예약되지 않습니다.
    • 직접 RPC 경로(ApplicationCommandService.updateExamineeNumber())는 다른 지원자와의 번호 중복을 검사하지 않습니다.

    조회 경로는 다음과 같습니다.

    • 관리자 목록·상세는 screening을 동기적으로 다시 읽습니다.
    • 백엔드에는 지원자 응답 캐시가 없습니다.
    • 성공 응답에 캐시 헤더를 명시하지 않습니다.

    선발 경로는 다음과 같습니다.

    • ScreeningPolicy.evaluate()는 totalScore 내림차순, id 오름차순으로 정렬합니다.
    • applicantId는 초안 생성 시점의 IDENTITY 값입니다. 제출 순서와 다를 수 있습니다.
    • 구성 점수(ScoreBreakdown)는 저장하지 않습니다. 읽을 때 ScoreCalculator가 다시 계산합니다.
    • 관리자 Applicant 모델은 totalScore만 가집니다.
    • 저장소에는 동점자 규칙을 정한 공식 모집요강 문서가 없습니다.

    파일별 변경 요약 및 후속 에이전트 지침

    파일별 변경 요약

    • application-administration-domain/.../policy/ExamineeNumberPolicy.kt: 그룹 보류 규칙을 추가합니다. 외부 예약 번호 집합을 받습니다. 결과에 deferredCount와 failedApplicantIds를 추가합니다.
    • application-administration-domain/.../policy/ScreeningPolicy.kt, Applicant 모델: 동점자 키 목록을 교체합니다. 구성 점수 필드를 추가합니다.
    • application-administration-domain ErrorCode: EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS와 EXAMINEE_NUMBER_CONFLICT를 추가합니다.
    • application-administration-application/.../ApplicantService.kt: 잠금을 먼저 획득합니다. 지원자별 거리 실패를 수집합니다. 예약 번호 조회를 추가합니다.
    • application-administration-application: 발급 잠금 포트를 추가합니다.
    • application-administration-adapter-out/.../KakaoDistanceAdapter.kt: 음수 거리와 범위 밖 좌표를 거부합니다.
    • application-administration-adapter-out/.../LocalApplicantDataAdapter.kt: 번호 합집합을 조회합니다. screening 저장을 flush하고 제약 위반을 변환합니다. 구성 점수를 매핑합니다.
    • application-administration-adapter-out/.../repository/, entity/: 잠금 엔티티와 저장소를 추가합니다. ScreeningJpaEntity.kt에 제약 이름을 선언합니다.
    • application-adapter-out/.../ApplicantPersistenceAdapter.kt, ApplicantJpaEntity.kt: 제약 위반을 변환합니다. 제약 이름을 선언합니다.
    • application-application/.../ApplicationCommandService.kt: 요약에 구성 점수를 포함합니다.
    • application-bootstrap/.../db/migration/: UNIQUE 인덱스 2개와 잠금 테이블을 추가합니다.
    • contracts/proto/application.proto: 관리자 지원자 메시지에 구성 점수 optional 필드를 추가합니다.
    • admin-adapter-in/.../ApplicantResponses.kt, ResponseMapper.kt: 발급 응답 필드를 추가합니다.
    • admin-bootstrap/.../AdminConfig.kt: no-store 인터셉터를 등록합니다.
    • admin-adapter-out/.../GrpcAdministrationAdapter.kt: 새 오류 코드를 409로 매핑하는지 확인합니다.
    • tools/administration-migration/README.md, documents/features/admin-applicant-via-grpc/context.md: 중복 탐지 절차와 확정된 동점자 규칙을 문서화합니다.
    • 테스트 파일: AdminPolicyTest.kt, AdminApplicationModuleTest.kt, KakaoDistanceAdapterTest.kt, ApplicationCorrectionAdapterTest.kt(또는 같은 패키지의 신규 통합 테스트), BFF 웹 테스트를 갱신합니다.

    후속 에이전트 지침

    • 보안 유지: AdminAuthorizationInterceptor, GrpcAdministrationAdapter.call(), AdministrationGrpcService.respond()의 ID·역할·크기 검증을 제거하거나 우회하지 않습니다.
    • 기존 동작 보존: 기존 번호 보존, 잘못된 형식 번호 보존, 교체 금지 규칙, statusVersion 충돌 검사, ID 오름차순 저장 순서를 바꾸지 않습니다.
    • 정책 게이트: 거리 실패 정책(그룹 보류)과 동점자 키 순서는 담당자 확인이 끝난 뒤 병합합니다.
    • 운영 데이터 게이트: 마이그레이션 배포 전에 운영 DB에서 중복 탐지 SQL 결과가 0건인지 확인합니다. 중복이 있으면 자동으로 수정하지 않습니다. 운영 부서와 정정 절차를 합의합니다.
    • 프런트엔드: 발급 성공 응답을 받으면 지원자 목록과 열린 상세 화면을 다시 조회합니다. deferredCount가 0보다 크면 "거리 조회 실패로 일부 그룹 발급 보류" 안내와 재실행 버튼을 표시합니다. 409 EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS를 받으면 "다른 발급 진행 중" 안내 후 목록을 다시 조회합니다.

    🚀 Next Steps

    🤖 All AI agent prompts combined
    Task: 1
    
    거리 조회 실패를 지원자별로 수집하고 해당 전형·지역 그룹의 발급만 보류합니다. 도메인 정책, 서비스, 거리 어댑터를 함께 변경하여 정상 그룹의
    발급과 실패 정보 반환을 보장합니다.
    
    - `ExamineeNumberPolicy.issue()`는 일부 항목이 없는 거리 맵을 받도록 변경합니다.
    `distances.getValue()` 대신 누락 여부를 확인합니다. 접두사 그룹의 대기 지원자 중 한 명이라도 거리가 없으면 그룹 전체를
    할당 대상에서 제외합니다.
    - 결과에 `deferredCount`와 `failedApplicantIds`를 추가합니다. `issuedCount + skippedCount
    + deferredCount = totalTargets`를 유지합니다.
    - 적격성 판단, 유효 번호 예약, 잘못된 형식 번호 보존, 최대 순번 다음부터 할당, 999 초과 시
    `EXAMINEE_NUMBER_LIMIT_EXCEEDED` 규칙을 유지합니다.
    - `ApplicantService.issueAll()`에서
    `AdminDomainException(DISTANCE_SERVICE_UNAVAILABLE)`만 지원자 단위로 처리합니다. 실패 ID를
    수집합니다. 다른 예외는 전파하여 트랜잭션을 롤백합니다.
    - 실패 로그에는 지원자 ID만 기록합니다. 주소, URL, API 키는 기록하지 않습니다. 모든 대기 지원자의 거리 조회가 실패해도
    `issuedCount = 0`과 실패 목록을 담은 성공 응답을 반환합니다.
    - `KakaoDistanceAdapter.kt`에서 음수 거리, 유한하지 않은 좌표, 위도·경도 범위 밖 좌표를 `unavailable()`로
    처리합니다. timeout, non-2xx 실패, `result_code` 검사, API 키 비노출을 유지합니다. 재시도는 추가하지 않습니다.
    - `AdminPolicyTest.kt`에서 그룹 보류, 다른 그룹 발급, 카운트 불변식, 재실행 발급을 검증합니다.
    `AdminApplicationModuleTest.kt`(371–401)는 실패 그룹 미저장, 정상 그룹 저장, 실패 ID 반환을 검증하도록
    바꿉니다. `KakaoDistanceAdapterTest.kt`에 음수 거리와 범위 밖 좌표 거부 테스트를 추가합니다.
    - 그룹 보류 정책은 담당자 확인이 끝난 뒤 병합합니다.
    ===============================================================================
    
    Task: 2
    
    발급 전용 잠금으로 번호 할당을 직렬화하고 두 테이블의 기존 번호를 모두 예약합니다. 이름 있는 UNIQUE 제약을 추가하여 동시 발급과 직접
    RPC를 포함한 모든 쓰기 경로의 중복을 차단합니다.
    
    - `application-administration-application`에 발급 잠금 포트를 추가합니다. 포트 예시는
    `acquireIssuanceLock()`입니다. `application-administration-adapter-out`에 단일 행 잠금
    엔티티와 저장소를 추가합니다. `AdminJpaRepositories.kt`의 `@Lock(PESSIMISTIC_WRITE)` 패턴과 잠금
    타임아웃 0(NOWAIT) 힌트를 사용합니다.
    - `ApplicantService.issueAll()`은 트랜잭션의 첫 문장으로 잠금을 획득합니다. MySQL REPEATABLE READ의
    read view가 생성되는 일반 읽기와 스냅샷 조회보다 먼저 실행합니다. 잠금을 보유한 상태로 거리 조회를 수행합니다. 발급 잠금은 발급
    요청끼리만 직렬화하고 지원자 행은 잠그지 않습니다.
    - 잠금 획득 실패와 타임아웃을 `EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS`(409)로 변환합니다. 테스트 DB가
    NOWAIT를 지원하지 않으면 짧은 잠금 타임아웃을 사용합니다.
    - `LocalApplicantDataAdapter.kt`에서 상태와 관계없이 `screening`과 `applicants`의 non-null
    번호 합집합을 조회합니다. 서비스에서 예약 번호 조회를 추가합니다. `ExamineeNumberPolicy.issue()`에 추가 예약 번호
    집합을 전달합니다. 유효한 번호만 접두사별 순번을 예약합니다. 직접 RPC로 `applicants`에만 기록된 번호와 조회 대상 상태 밖의
    번호도 포함합니다. 잘못된 형식의 번호는 예약하지 않습니다.
    - `systems/application/application-bootstrap/src/main/resources/db/migration/`에
    다음 순번의 Flyway 마이그레이션을 추가합니다. `uk_applicants_examinee_number`,
    `uk_screening_examinee_number` UNIQUE 인덱스와 단일 잠금 행 테이블 및 초기 행을 추가합니다. 여러 NULL을
    허용합니다. 잘못된 형식의 기존 번호에도 UNIQUE를 적용합니다. 기존 번호는 수정하거나 삭제하지 않습니다.
    - `ApplicantJpaEntity.kt`와 `ScreeningJpaEntity.kt`에 같은 제약 이름을 선언하여 스키마 검증 불일치를
    막습니다. `tools/administration-migration/README.md`에 배포 전 중복 탐지 SQL, "결과 0건이어야 배포
    가능" 조건, `migrate.py`의 중복 번호 1062 실패 주의 사항을 기록합니다. 운영 중복은 자동 수정하지 않습니다. 운영 부서와 정정
    절차를 합의합니다.
    - `LocalApplicantDataAdapter.save()`에서 screening 저장을 flush합니다. 제약 이름이 일치하는
    `DataIntegrityViolationException`만 `EXAMINEE_NUMBER_CONFLICT`(409)로 변환합니다. 다른
    무결성 오류는 유지합니다. `ApplicantPersistenceAdapter.kt`의 `saveAndFlush` 경로에서도
    `uk_applicants_examinee_number` 위반을 application 모듈 오류 체계로 변환합니다.
    - administration 도메인 ErrorCode에 새 오류 코드 두 개를 추가합니다.
    `GrpcAdministrationAdapter.kt`(87–104)의 BFF 오류 재구성이 두 코드를 409로 매핑하는지 확인합니다.
    - `require(existing == null || existing == requested)`, `statusVersion` 검사, 기존
    번호와 잘못된 형식 번호 보존, ID 오름차순 저장 순서를 유지합니다.
    - `ApplicationCorrectionAdapterTest.kt`(162–179)의 2스레드 패턴으로 Spring 프록시를 거친 동시
    발급을 테스트합니다. 한 요청 성공, 다른 요청의 발급 진행 중 오류, 중복 없음, applicants 전용 번호 예약, 중복 저장 충돌과
    롤백을 검증합니다. `issuedNumberSurvivesTypeRegionAndAddressCorrectionAndIsNotReused`가
    계속 통과하도록 합니다.
    ===============================================================================
    
    Task: 3
    
    발급 결과의 보류 수와 실패 지원자 ID를 HTTP 응답까지 전달합니다. 관리자 API에 `no-store`를 적용하고 프런트엔드 재조회
    요구사항을 후속 작업으로 기록합니다.
    
    - `data_json`은 JSON이므로 발급 응답을 위한 proto 변경 없이 필드를 확장합니다. BFF
    `ApplicantResponses.kt`(76–80)와 `ResponseMapper.kt`(64–69)에 `deferredCount`,
    `failedApplicantIds`를 추가합니다. `issuedCount`, `skippedCount`, `totalTargets`의 이름과
    의미를 유지합니다.
    - `systems/admin/admin-bootstrap/.../AdminConfig.kt`에 Spring
    `WebContentInterceptor`를 등록합니다. `/api/v11/admin/**`에 `CacheControl.noStore()`를
    적용합니다. 기존 `AdminAuthorizationInterceptor` 등록과 검증 순서는 유지합니다.
    - BFF 웹 테스트에서 목록, 상세, 발급 응답의 `Cache-Control: no-store` 헤더와 새 응답 필드를 검증합니다.
    - 프런트엔드는 이번 범위 밖으로 유지합니다. 후속 지침에 발급 성공 후 지원자 목록과 열린 상세 화면 재조회를 기록합니다.
    `deferredCount > 0`이면 "거리 조회 실패로 일부 그룹 발급 보류" 안내와 재실행 버튼을 표시하도록 기록합니다. 409
    `EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS`이면 "다른 발급 진행 중" 안내 후 목록을 다시 조회하도록 기록합니다.
    - 번호를 노출하지 않는 identity 프로젝션은 변경하지 않습니다. 백엔드 캐시는 새로 도입하지 않습니다.
    - `AdminAuthorizationInterceptor`, `GrpcAdministrationAdapter.call()`,
    `AdministrationGrpcService.respond()`의 ID·역할·크기 검증을 제거하거나 우회하지 않습니다.
    ===============================================================================
    
    Task: 4
    
    2027학년도 모집요강의 동점자 조항을 확정한 뒤 1차 선발 정렬을 교체합니다. 확정된 키 순서를 도메인의 한 곳에서 정의하고 필요한 구성
    점수를 선발 입력까지 전달합니다.
    
    - 구현 전에 입학 담당자에게 키 순서, 검정고시 적용 방식, 가산점 포함 여부를 확인합니다. 기본 검토 순서는 `totalScore` →
    `subjectScore` → `attendanceScore` → `volunteerScore` → `submittedAt` →
    `applicantId`입니다. 담당자 확인이 끝난 뒤 병합합니다.
    - 확정 결과를 `documents/features/admin-applicant-via-grpc/context.md`의 50행과 91행에
    반영합니다. 요강이 구성 점수를 사용하지 않으면 구성 점수 전달 작업을 생략하고 정렬 키 목록만 변경합니다.
    - 구성 점수를 사용하는 경우 `contracts/proto/application.proto`의 관리자 지원자 메시지에서 92행
    `total_score` 부근에 optional `subject_score`, `attendance_score`,
    `volunteer_score`, `additional_score`를 추가합니다.
    - `ApplicationCommandService.kt`의 지원자 요약 생성(174–179)에서 `totalScore`가 있는 제출 이후
    상태만 대상으로 `ScoreCalculator.calculateBreakdown()`을 호출합니다. 예외를 넓게 삼키지 않습니다. 구성 점수
    저장 컬럼이나 백필은 추가하지 않습니다.
    - `LocalApplicantDataAdapter.kt`에서 `totalScore`와 같은 `has…()` 패턴으로 구성 점수를 읽습니다.
    관리자 `Applicant` 모델에 nullable 구성 점수 필드를 추가합니다.
    - `ScreeningPolicy.kt`의 comparator를 확정된 도메인 단일 키 목록으로 교체합니다. KDoc에 요강 조항을 인용합니다.
    점수는 소수 셋째 자리 기준으로 비교합니다. null 구성 점수는 `nullsLast`로 뒤에 정렬하되 제외하지 않습니다.
    `submittedAt`은 오름차순으로 정렬합니다. 마지막 키 `id`로 결정적 순서를 유지합니다.
    - 정원 hard cut, 재실행 재평가, `INVALID_STATUS_TRANSITION`, 제외 조건을 유지합니다.
    - `AdminPolicyTest.kt`의 기존 동점자 ID 우선 테스트(179)의 이름과 기대값을 변경합니다. 총점 동점에서 교과 점수 우선,
    모든 점수 동점에서 `submittedAt` 우선, 정원 경계에서 3명 이상 동점, 소수 셋째 자리 동점을 테스트합니다. application
    모듈 테스트에서 요약의 구성 점수 전달을 검증합니다.
    
    💡 Iterate on the plan with: @coderabbitai <feedback>
    Example Feedback
    - @coderabbitai You can skip phase 3. Add a simple unit test case for phase 2.
    - @coderabbitai For design choice 1 go ahead with option 3 and replan.
    

  2. added 5 commits that reference this issue on Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions