You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
동점자 규칙은 요강 확인 뒤 도메인 단일 키 목록으로 적용합니다. 기본 순서는 총점, 교과, 출석, 봉사, submittedAt, applicantId입니다. 구성 점수는 기존 ScoreCalculator 재계산 패턴으로 전달합니다.
Design ChoicesDesign Choice 1: 거리 조회 실패를 어떻게 처리하는가
거리 순서는 같은 전형·지역 그룹 안에서 번호 순번을 결정합니다. 일부 지원자만 건너뛰면 같은 배치 안의 거리 순서가 깨집니다.
Options Considered:
전체 중단 유지: 현재 동작을 유지하고 실패한 지원자 목록만 보고합니다.
지원자 단위 건너뛰기: 실패한 지원자만 제외하고 나머지는 발급합니다.
그룹 단위 보류: 거리 미확정 대기 지원자가 있는 전형·지역 그룹만 이번 실행에서 발급하지 않습니다. 다른 그룹은 발급합니다.
재시도 후 전체 중단: 한정된 횟수로 재시도한 뒤 실패하면 전체를 중단합니다.
Chosen Option: 3
Rationale: 장애가 발생해도 영향받지 않은 그룹은 발급하고, 한 배치 안의 거리 순서 불변식도 지킵니다. 재실행하면 보류된 그룹을 이어서 발급합니다. 운영 부서 확인이 필요합니다.
Design Choice 2: 번호 중복을 어떤 수단으로 막는가
저장 시점의 행 잠금은 번호 공간 전체를 직렬화하지 않습니다.
Options Considered:
DB UNIQUE 제약만 추가합니다.
발급 잠금만 추가합니다.
UNIQUE 제약과 발급 전용 잠금 행을 함께 사용합니다.
Chosen Option: 3
Rationale: UNIQUE 제약은 모든 쓰기 경로의 최종 불변식이 됩니다. 잠금은 정상적인 동시 요청을 충돌 없이 직렬화합니다.
Design Choice 3: 동시 발급 요청이 들어오면 대기하는가
대기하는 요청은 BFF의 gRPC deadline을 넘을 수 있습니다.
Options Considered:
잠금을 얻을 때까지 대기한 뒤, 최신 스냅샷에서 이미 발급된 번호를 건너뜁니다.
NOWAIT(잠금 타임아웃 0)로 즉시 실패하고 409 "발급 진행 중"을 반환합니다.
Chosen Option: 2
Rationale: 클라이언트 deadline을 넘긴 뒤에 서버만 커밋하는 모호한 상태를 피합니다. 관리자는 목록을 다시 조회하면 결과를 확인할 수 있습니다.
Design Choice 4: "발급 후 캐시 갱신"은 무엇을 의미하는가
백엔드에는 무효화할 지원자 응답 캐시가 없습니다.
Options Considered:
백엔드 캐시를 새로 도입하고 발급 시 무효화합니다.
관리자 API에 Cache-Control: no-store를 적용하고, 프런트엔드가 발급 성공 후 목록을 다시 조회합니다.
발급 응답에 발급된 번호 목록을 담아 프런트엔드가 직접 반영합니다.
Chosen Option: 2
Rationale: 존재하지 않는 캐시를 만들지 않습니다. 브라우저와 중간 캐시의 오래된 응답을 차단합니다. 개인정보 응답의 보안 측면에서도 적절합니다.
Design Choice 5: 어떤 동점자 규칙을 적용하는가
공식 모집요강 원문이 저장소에 없습니다. applicantId는 접수 순서를 보장하지 않습니다.
Options Considered:
applicantId 순서를 유지합니다.
submittedAt 오름차순으로 바꿉니다.
모집요강의 구성 점수 순서를 적용하고, 마지막 결정 키로 submittedAt과 applicantId를 사용합니다.
Chosen Option: 3
Rationale: 기본 키 순서는 totalScore → 교과(subjectScore) → 출석(attendanceScore) → 봉사(volunteerScore) → submittedAt → applicantId입니다. 구현 전에 2027학년도 입학전형 요강의 동점자 조항으로 이 순서를 확정해야 합니다. 키 순서는 도메인의 한 곳에서만 정의합니다.
Design Choice 6: 구성 점수를 어떻게 확보하는가
Options Considered:
제출 시 구성 점수를 컬럼에 저장하고 기존 데이터를 백필합니다.
요약 조회 시 기존 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)도 추가합니다.
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 불변식으로 중복을 차단합니다. 이 단계는 영속성 계층과 마이그레이션에 집중합니다.
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 호출자까지 전달합니다. 발급 후 목록·상세 조회가 오래된 응답을 받지 않게 합니다.
기존 필드(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 정렬 교체 작업의 키 목록만 바꿉니다.
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 모델: 동점자 키 목록을 교체합니다. 구성 점수 필드를 추가합니다.
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.
Activity
coderabbitai commentedon Oct 6, 2026
Coding Plan
Summary
no-store와 프런트엔드 재조회로 구현합니다. 백엔드에 불필요한 캐시를 새로 만들지 않습니다.submittedAt,applicantId입니다. 구성 점수는 기존ScoreCalculator재계산 패턴으로 전달합니다.Design Choices
Design Choice 1: 거리 조회 실패를 어떻게 처리하는가
거리 순서는 같은 전형·지역 그룹 안에서 번호 순번을 결정합니다. 일부 지원자만 건너뛰면 같은 배치 안의 거리 순서가 깨집니다.
Options Considered:
Chosen Option: 3
Rationale: 장애가 발생해도 영향받지 않은 그룹은 발급하고, 한 배치 안의 거리 순서 불변식도 지킵니다. 재실행하면 보류된 그룹을 이어서 발급합니다. 운영 부서 확인이 필요합니다.
Design Choice 2: 번호 중복을 어떤 수단으로 막는가
저장 시점의 행 잠금은 번호 공간 전체를 직렬화하지 않습니다.
Options Considered:
Chosen Option: 3
Rationale: UNIQUE 제약은 모든 쓰기 경로의 최종 불변식이 됩니다. 잠금은 정상적인 동시 요청을 충돌 없이 직렬화합니다.
Design Choice 3: 동시 발급 요청이 들어오면 대기하는가
대기하는 요청은 BFF의 gRPC deadline을 넘을 수 있습니다.
Options Considered:
Chosen Option: 2
Rationale: 클라이언트 deadline을 넘긴 뒤에 서버만 커밋하는 모호한 상태를 피합니다. 관리자는 목록을 다시 조회하면 결과를 확인할 수 있습니다.
Design Choice 4: "발급 후 캐시 갱신"은 무엇을 의미하는가
백엔드에는 무효화할 지원자 응답 캐시가 없습니다.
Options Considered:
Cache-Control: no-store를 적용하고, 프런트엔드가 발급 성공 후 목록을 다시 조회합니다.Chosen Option: 2
Rationale: 존재하지 않는 캐시를 만들지 않습니다. 브라우저와 중간 캐시의 오래된 응답을 차단합니다. 개인정보 응답의 보안 측면에서도 적절합니다.
Design Choice 5: 어떤 동점자 규칙을 적용하는가
공식 모집요강 원문이 저장소에 없습니다.
applicantId는 접수 순서를 보장하지 않습니다.Options Considered:
applicantId순서를 유지합니다.submittedAt오름차순으로 바꿉니다.submittedAt과applicantId를 사용합니다.Chosen Option: 3
Rationale: 기본 키 순서는
totalScore→ 교과(subjectScore) → 출석(attendanceScore) → 봉사(volunteerScore) →submittedAt→applicantId입니다. 구현 전에 2027학년도 입학전형 요강의 동점자 조항으로 이 순서를 확정해야 합니다. 키 순서는 도메인의 한 곳에서만 정의합니다.Design Choice 6: 구성 점수를 어떻게 확보하는가
Options Considered:
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가 항상 성립해야 합니다.EXAMINEE_NUMBER_LIMIT_EXCEEDEDTask 2: 서비스에서 지원자별 거리 실패 수집
발급 전체를 중단하지 않고 실패를 수집합니다.
ApplicantService.issueAll()의 거리 조회 루프를 바꿉니다.AdminDomainException(DISTANCE_SERVICE_UNAVAILABLE)만 지원자 단위로 잡아서 실패 ID로 기록합니다.issuedCount = 0이고 실패 목록을 채웁니다.Task 3: 거리 응답 검증 보강
잘못된 거리 값이 번호 순서에 들어가지 않게 합니다.
KakaoDistanceAdapter.kt에서 다음 값을unavailable()로 처리합니다.result_code검사, API 키 비노출입니다.Task 4: 정책·서비스·어댑터 테스트 갱신
AdminPolicyTest.kt에 다음 테스트를 추가합니다.AdminApplicationModuleTest.kt(371–401)의 "거리 실패 시 저장 없음" 테스트를 새 정책에 맞게 바꿉니다. 실패 그룹은 저장하지 않고, 다른 그룹은 저장하고, 실패 ID를 반환하는지 검증합니다.KakaoDistanceAdapterTest.kt에 음수 거리 거부 테스트와 범위 밖 좌표 거부 테스트를 추가합니다.🤖 Prompt for AI agents
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)로 변환합니다.Task 2: 전체 기존 번호 예약
screening과applicants양쪽의 번호를 모두 예약합니다.LocalApplicantDataAdapter.kt에 조회를 추가합니다. 이 조회는 상태와 관계없이 두 테이블의 non-null 번호 합집합을 반환합니다.ExamineeNumberPolicy.issue()는 추가 예약 번호 집합을 받습니다. 유효한 번호만 해당 접두사의 순번을 예약합니다. 잘못된 형식의 번호는 기존 규칙대로 예약하지 않습니다.applicants에만 기록된 번호Task 3: UNIQUE 제약 마이그레이션
systems/application/application-bootstrap/src/main/resources/db/migration/에 다음 순번의 Flyway 마이그레이션을 추가합니다.uk_applicants_examinee_numberuk_screening_examinee_numbertools/administration-migration/README.md에 다음 내용을 추가합니다.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검사도 유지합니다.GrpcAdministrationAdapter.kt87–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
Phase 3: 발급 결과 전달과 화면 갱신
목표: 새 결과 필드를 HTTP 호출자까지 전달합니다. 발급 후 목록·상세 조회가 오래된 응답을 받지 않게 합니다.
Task 1: 발급 응답 필드 확장
data_json은 JSON이므로 proto 변경 없이 필드를 추가할 수 있습니다.ApplicantResponses.kt(76–80)와ResponseMapper.kt(64–69)에deferredCount,failedApplicantIds를 추가합니다.issuedCount,skippedCount,totalTargets)의 이름과 의미는 유지합니다.Task 2: 관리자 API 캐시 차단
systems/admin/admin-bootstrap/.../AdminConfig.kt에 SpringWebContentInterceptor를 등록합니다./api/v11/admin/**에CacheControl.noStore()를 적용합니다.AdminAuthorizationInterceptor등록과 검증 순서를 바꾸지 않습니다.Cache-Control: no-store헤더Task 3: 프런트엔드 후속 작업 정의
🤖 Prompt for AI agents
Phase 4: 동점자 선발 규칙 교체
목표: 확정된 모집요강 규칙으로 1차 선발 정렬을 바꿉니다. 구현 전에 정책을 확정해야 합니다.
Task 1: 정책 확정
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에 요강 조항을 인용합니다.submittedAt은 오름차순입니다.id입니다. 결정적 순서를 유지하기 위해서입니다.INVALID_STATUS_TRANSITION, 제외 조건은 바꾸지 않습니다.Task 4: 선발 테스트 갱신
AdminPolicyTest.kt의 "동점이면 지원자 번호가 빠른 지원자를 우선 합격시킨다" 테스트(179)를 새 규칙 이름과 기대값으로 바꿉니다.submittedAt우선🤖 Prompt for AI agents
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에만 있는 번호는 예약되지 않습니다.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-domainErrorCode: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 오름차순 저장 순서를 바꾸지 않습니다.deferredCount가 0보다 크면 "거리 조회 실패로 일부 그룹 발급 보류" 안내와 재실행 버튼을 표시합니다. 409EXAMINEE_NUMBER_ISSUANCE_IN_PROGRESS를 받으면 "다른 발급 진행 중" 안내 후 목록을 다시 조회합니다.🚀 Next Steps
🤖 All AI agent prompts combined
💡 Iterate on the plan with:
@coderabbitai <feedback>Merge pull request #446 from EntryDSM/fix/399-kakao-distance-failure-…
fix(application): 공백 수험번호 출력과 PDF 병합 계층 분리 #399
Merge pull request #447 from EntryDSM/fix/399-first-pass-download-wit…
Merge pull request #449 from EntryDSM/fix/399-admin-combined-essays
Merge pull request #457 from EntryDSM/feat/399-postal-fallback-draft-…