Skip to content

feat(F-EXT-001): 문서에 닿지 못한 것을 「AI 장애」와 가른다 — 운영자가 ai-service 를 재시작했다 (#556) - #591

Merged
hd0rable merged 4 commits into
mainfrom
feat/document-unreachable-556
Sep 10, 2026
Merged

hd0rable merged 4 commits into
mainfrom
feat/document-unreachable-556

Conversation

@hd0rable

@hd0rable hd0rable commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

#556 입니다. #548·#551 이 머지돼서 조건이 찼습니다.

Note

base 를 main 으로 돌렸습니다 — 부모(#586)가 머지됐습니다. 원래는 이렇게 쌓여 있었습니다:
#586 위에 쌓았습니다. 새 에러 코드가 명세서 §9 의 목록 줄을 건드리는데 #586 이
같은 줄을 고칩니다(「다섯 벌」→「여섯 벌」). 그 위가 아니면 충돌합니다. #586 이 머지되면
여기 base 를 main 으로 돌립니다.

무엇이 문제였나

ai-service 는 거부를 넷으로 갈라 주는데 raise() 가 셋을 하나로 뭉쳤습니다. 상태와
본문 코드가 둘 다 손에 있는데
예외 타입이 그것으로 갈리지 않았습니다.

전 — /internal/parse 가 거부하면

  400 DocumentPathRejected            → 502 AI_SERVICE_UNAVAILABLE   ✗
  404 DocumentNotFound                → 502 AI_SERVICE_UNAVAILABLE   ✗
  502 code=DOCUMENT_ACCESS_DENIED     → 502 AI_SERVICE_UNAVAILABLE   ✗
  422 DocumentUnreadable              → 400 DOCUMENT_UNPROCESSABLE   ✓ 하나만 갈렸다
      ↓
  운영자가 받는 문면: "채점 서비스에 연결할 수 없습니다"
      ↓
  ai-service 를 재시작한다  →  아무것도 안 고쳐진다  →  같은 502   ✗ 원인을 못 찾는다


후 — 같은 거부

  404 · 502 DOCUMENT_ACCESS_DENIED    → 502 DOCUMENT_UNREACHABLE     ◀━━
      ↓  "문서 파일에 닿지 못했습니다 — 업로드 볼륨의 마운트와 소유권을 확인하세요"
      ↓
  볼륨을 본다   ✓ 고칠 자리를 가리킨다

  422 DocumentUnreadable              → 400 DOCUMENT_UNPROCESSABLE   그대로
  400 DocumentPathRejected            → 502 AI_SERVICE_UNAVAILABLE    그대로 ❗아래
  그 밖                                → 502 AI_SERVICE_UNAVAILABLE   그대로

❗400 은 「배선 버그」가 아닌 것이 하나 섞여 있습니다 (리뷰 지적)

DocumentPathRejected 가 나는 자리가 다섯인데 성격이 갈립니다.

parsing.py:682·684·693·696   경로가 비었다 · NUL · 해소 실패 · 뿌리 밖    배선 버그 ✓
parsing.py:749               수동 파스 출력의 product_type 이 요청과 다르다  ❗데이터 상태

다섯째는 운영자가 실제로 만들 수 있는 상태(#441 의 JSON)이고 고칠 자리는 그 파일인데,
지금 문면은 ai-service 를 가리킵니다 — 이 PR 이 없애려는 그 고리입니다.

여기서 못 고칩니다 — ai-service 의 거부 표가 그 갈래에 body code 를 안 싣습니다
((400, None))라 서버가 넷과 다섯째를 구별할 재료가 없습니다. 그 표를 고치는 것이
선행이고 routes.py 는 남의 파일입니다. raise() javadoc 에 그 사실을 적었고 #598 로
뗐습니다.

#532 에서 찾은 상태가 정확히 이 자리입니다 — docker volume create 가 만든 볼륨은
root:root 이고 server 는 uid 10001 로 돕니다(결정 7.54).

새 코드를 하나 만든 판단

이슈가 "새 ApiError.code 가 필요한지 판단한다" 로 남겨 둔 것입니다. 필요합니다 —
기존 코드로는 셋 다 틀린 안내를 합니다.

후보 그 코드가 시키는 것 왜 안 되나
AI_SERVICE_UNAVAILABLE ai-service 를 본다 ai-service 는 멀쩡하다. 이 이슈 자체
DOCUMENT_UNPROCESSABLE 문서를 고쳐 다시 올린다 문서가 멀쩡하다. 올린 사람이 할 게 없다
INTERNAL_ERROR 잠시 후 다시 시도한다 재시도로 안 고쳐진다

404 와 DOCUMENT_ACCESS_DENIED 는 다음 행동이 같아서(배포에서 업로드 볼륨을 본다)
한 타입·한 코드입니다 — DOCUMENT_UNPROCESSABLE 이 «못 열림» 과 «PII 거부» 를 한 코드로
낸 것과 같은 판단이고, 문면이 어느 쪽인지 가릅니다.

상태는 502 입니다. 요청도 문서도 정상이고 못 읽는 것이 우리 배포라 4xx 로 두면 호출자
잘못으로 읽힙니다 — ai-service 가 같은 이유로 이 갈래를 502 에 뒀습니다(그쪽
_REFUSAL_RESPONSE 주석).

❗본문을 한 번만 읽는다 — 구조로

raise() 가 본문을 인자로 받습니다. resp.getBody() 는 스트림이라 두 번째 읽기가 빈
값을 주고, 갈래마다 따로 읽으면 앞 갈래가 뒤를 먹습니다 — parseFailure 가 그 함정을 이미
한 번 밟았습니다
(스키마 거부가 DocumentUnreadable 로 샜습니다). 읽는 자리를
failure() 하나로 올려 그 실수가 안 나게 했고, 쓰이지 않게 된 errorCode() 를 지웠습니다.

❗404 를 공용 갈래에 두지 않는다

/internal/parse 의 404 만 「문서에 닿지 못했다」입니다. 다른 내부 경로의 404 는 「그 라우트가
없다」이고, 그걸 볼륨 탓으로 읽으면 오진의 방향만 바뀝니다.

역검증 — 하나가 처음엔 초록이었습니다

DOCUMENT_ACCESS_DENIED 갈래를 죽인다     해당 테스트 빨강
404 갈래를 죽인다                        해당 테스트 빨강
404 를 모든 엔드포인트로 넓힌다            ❗처음엔 초록 → 그물을 더해서 빨강

세 번째가 통과했습니다. javadoc 이 근거로 든 구분을 코드가 안 지키고 있었는데 재는 것이
없었습니다.
/internal/score 404 가 AiServiceException 으로 남는 단정을 넣었습니다.

바꾼 것

  • core/aiservice/DocumentUnreachableException.java(신설) — AiServiceException 을
    상속해서, 이 타입을 모르는 옛 호출부는 지금처럼 502 로 다룹니다(갈래를 늘리는 변경이
    조용히 500 을 만들지 않습니다).
  • core/aiservice/AiServiceClient.java — raise() 를 상태·본문 코드로 가르고 본문을
    인자로 받게. parseFailure() 가 404 를 그 자리에서 가릅니다.
  • api/exception/GlobalExceptionHandler.java — 502 DOCUMENT_UNREACHABLE. 응답 본문에
    저장 경로를 안 싣습니다
    (#582 와 같은 규약) — 어디를 봐야 하는지만 적고 경로는 로그로.
  • 여섯 벌 — 핸들러 · openapi · CLAUDE.md · types.ts · errorText.ts ·
    명세서 §9(14종 → 15종 · 요약표). 문면 표에 「다시 올려라」를 안 적었습니다.
  • 테스트 다섯 — 404 / DOCUMENT_ACCESS_DENIED / 그 밖의 502 / 다른 엔드포인트의 404 /
    재추출이 502 DOCUMENT_UNREACHABLE 로 나가고 「채점 서비스」도 경로도 안 실린다.

이슈의 3번은 안 했습니다

"/ops/status 의 ai-service 카드가 이 구별을 쓸 수 있는지 본다" — 그 카드는 /healthz
왕복을 재고, 이 갈래는 파스 호출에서만 납니다. 카드가 이 사실을 쓰려면 「최근 실패의
원인」을 들고 있어야 하는데 그건 /ops/status 의 성격(지금 상태)과 다릅니다. #568 이
제안한 추출 카드 쪽에 더 가까워 보여서 그 판단은 그 이슈로 넘깁니다.

검증

./gradlew test 821건 통과(+5) · npm run build 통과.

리뷰가 변이 아홉으로 재현했고 그중 셋(path()→get() · errorBody 폴백 · 본문 두 번 읽기)은
리팩터링이 옮긴 기존 방어가 옮긴 뒤에도 재지는지를 봤습니다 — 셋 다 빨강입니다.

@gitIt-sehyeon @yoonjiseok (routes.py 표의 소비자가 이 코드입니다) @junseo2323

…556)

ai-service 는 거부를 넷으로 갈라 주는데 `raise()` 가 셋을 하나로 뭉쳤다. 상태와 본문 코드
둘 다 손에 있는데 예외 타입이 그것으로 갈리지 않았다.

  400 DocumentPathRejected              → 502 AI_SERVICE_UNAVAILABLE
  404 DocumentNotFound                  → 502 AI_SERVICE_UNAVAILABLE
  502 code=DOCUMENT_ACCESS_DENIED       → 502 AI_SERVICE_UNAVAILABLE
  422 DocumentUnreadable                → 400 DOCUMENT_UNPROCESSABLE   ← 하나만 갈렸다

증상이 전부 「채점 서비스에 연결할 수 없습니다」였다. 볼륨이 root:root 이고 server 가
uid 10001 로 도는 상태(#532 · 결정 7.54)에서 운영자는 그 문면을 보고 ai-service 를
재시작한다 — 아무것도 안 고치고 같은 502 가 다시 온다.

## 새 코드 하나 (DOCUMENT_UNREACHABLE · 502)

셋 중 둘(404 · DOCUMENT_ACCESS_DENIED)은 **다음 행동이 같다** — 배포에서 업로드 볼륨을
본다. 그래서 한 타입·한 코드로 낸다.

기존 코드로는 안 되는 이유가 각각 있다.

  AI_SERVICE_UNAVAILABLE  ai-service 를 재시작하게 만든다. 그게 이 이슈다
  DOCUMENT_UNPROCESSABLE  「문서를 고쳐 다시 올려라」다. 문서는 멀쩡하고 올린 사람이
                          할 수 있는 것이 없다 — 시도만 는다
  4xx                     요청도 문서도 정상이다. 못 읽는 것이 우리 배포다

400 DocumentPathRejected 는 그대로 둔다 — 그건 우리 배선이 잘못된 경로를 보낸 것이고,
같은 자리에 이미 IllegalStateException 갈래가 있다(스키마 거부).

## 본문을 한 번만 읽는다

`raise()` 가 본문을 인자로 받게 바꿨다. `resp.getBody()` 는 스트림이라 두 번째 읽기가
빈 값을 주고, 갈래마다 따로 읽으면 앞 갈래가 뒤를 먹는다 — `parseFailure` 가 그 함정을
이미 한 번 밟았다(스키마 거부가 DocumentUnreadable 로 샜다). 읽는 자리를 하나로 올려
그 실수가 구조적으로 안 나게 했다. 쓰이지 않게 된 `errorCode()` 는 지웠다.

## 404 를 공용 갈래에 두지 않는다

`/internal/parse` 의 404 만 「문서에 닿지 못했다」다. 다른 내부 경로의 404 는 「그 라우트가
없다」이고, 그것을 볼륨 탓으로 읽으면 오진의 방향만 바뀐다.

## 역검증

  DOCUMENT_ACCESS_DENIED 갈래를 죽인다      해당 테스트 빨강
  404 갈래를 죽인다                         해당 테스트 빨강
  404 를 모든 엔드포인트로 넓힌다             과잉 확대 단정 빨강  ← 처음엔 초록이라 그물을 더했다

세 번째가 처음에 통과했다. 「다른 경로의 404 는 문서 문제가 아니다」를 재는 것이 없어서,
javadoc 이 근거로 든 구분을 코드가 안 지키고 있었다.

## 여섯 벌

핸들러 · openapi · CLAUDE.md · types.ts · errorText.ts · 명세서 §9(14종 → 15종).
문면 표에 「다시 올려라」를 안 적었다 — 올린 사람이 할 수 있는 것이 없다.
응답 본문에 저장 경로를 안 싣는다(#582 와 같은 규약. 그 단정도 넣었다).

`./gradlew test` 821건 통과 · `npm run build` 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hd0rable hd0rable added server Spring (:8000) 계약 모듈 간 계약 — contracts/, openapi.yaml, 스키마 보안 역이용 방지·접근통제·PII·비밀정보 web Vite (:5173) labels Sep 10, 2026
@github-actions github-actions Bot added 리뷰대기: 오준서 오준서 이 배정됐고 아직 아무것도 제출하지 않았다 리뷰대기: 윤지석 윤지석 이 배정됐고 아직 아무것도 제출하지 않았다 리뷰대기: 정세현 정세현 이 배정됐고 아직 아무것도 제출하지 않았다 labels Sep 10, 2026

@gitIt-sehyeon gitIt-sehyeon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변이 아홉 개를 다 재현했고 갈래가 정확합니다. 특히 «본문을 한 번만 읽는다» 를 구조로 옮긴 것이 실제로 재집니다 — 되돌려 보면 그물이 뭅니다. 막지 않습니다. 다만 셋 중 하나가 아직 안 갈렸고, 그 하나가 배선 버그가 아니라 운영 상황입니다. 그리고 스택 머지 순서에 함정이 하나 있습니다.

재현한 것

./gradlew test    821 passed (+5)        npm run build    통과

변이 아홉입니다.

1. DOCUMENT_ACCESS_DENIED 갈래를 죽인다        빨강
2. parse 404 갈래를 죽인다                    빨강
3. 404 를 모든 엔드포인트로 넓힌다               빨강   ◀ 본문에서 「처음엔 초록」이던 그 자리
4. 상태를 502 대신 400 으로                    빨강
5. 응답 본문에 e.getMessage() 를 싣는다          빨강   ◀ 경로 비노출이 그물에 걸려 있다
6. 두 갈래를 AiServiceException 으로 되돌린다     빨강

7. raise() 의 path() 를 get() 으로              빨강   ◀ #293 의 방어가 옮긴 뒤에도 재진다
8. errorBody() 의 IOException 폴백을 없앤다       빨강
9. parseFailure 가 본문을 두 번 읽는다            빨강   ◀ 구조로 막았다는 주장이 실제로 재진다

7·8·9 를 따로 잰 이유는 리팩터링이 기존 방어를 옮겼기 때문입니다. errorCode() 가 사라지면서 #293 리뷰가 세운 «path() 를 get() 으로 바꾸면 문자열 detail 에서 NPE» 가 raise() 안으로 이동했는데, 옮긴 뒤에도 그물이 그대로 뭅니다. 9번이 특히 좋습니다 — "읽는 자리를 failure() 하나로 올려 그 실수가 구조적으로 안 나게 했다" 가 문면만이 아니라 재지는 주장입니다.

여섯 벌도 다 맞았습니다(ErrorCodeContractTest 초록 · 14종 → 15종 · 요약표까지).

❗1. 셋 중 하나가 아직 안 갈렸고, 그게 배선 버그가 아닙니다

본문의 표가 400 을 이렇게 둡니다.

400 DocumentPathRejected → 우리 배선 버그. 그대로

네 자리 중 셋은 맞는데 넷째가 다릅니다.

parsing.py:682   document_path 가 비었다              배선 버그
parsing.py:684   NUL 문자                            배선 버그
parsing.py:693   경로 해소 실패(순환 심볼릭 링크)         배선/환경
parsing.py:696   허용된 뿌리 밖                        배선 버그
parsing.py:749   ❗수동 파스 출력의 product_type 이 다르다   ← 데이터·운영 상황이다

마지막은 #441 의 수동 파스 출력(data/documents/x.json)을 놓은 사람이 상품유형을 틀리게 적은 상태입니다. 운영자가 실제로 만들 수 있는 상태이고, 지금 이렇게 흐릅니다.

400 DocumentPathRejected
  → routes.py 표가 body code 를 안 싣는다 (400, None)
  → raise() 에서 code == null
  → AiServiceException → 502 AI_SERVICE_UNAVAILABLE
  → "채점 서비스에 연결할 수 없습니다"
  → 운영자가 ai-service 를 재시작한다 → 아무것도 안 고쳐진다

이 PR 이 없애려는 그 고리 그대로입니다. 고칠 자리는 그 JSON 파일인데 문면은 ai-service 를 가리킵니다.

다만 여기서 고치시라는 게 아닙니다

DOCUMENT_UNREACHABLE 로 보내면 안 됩니다 — 볼륨을 보라고 하는데 볼륨은 멀쩡합니다. 맞는 자리는 DOCUMENT_UNPROCESSABLE("이 문서를 고쳐 다시 올려라")에 가까운데, 수동 파스 출력은 「올린 문서」가 아니라 옆에 놓은 파일이라 그 문면도 정확히는 아닙니다. 그리고 그러려면 ai-service 가 이 갈래에 body code 를 실어 줘야 합니다(지금 (400, None) 이라 서버가 넷을 구별할 방법이 없습니다) — routes.py 표를 건드리는 일이라 @yoonjiseok 님 파일이고 이 PR 범위 밖입니다.

본문 표의 400 줄만 「배선 버그」에서 「배선 버그 셋 + 수동 파스 출력 불일치 하나 — 뒤쪽은 남는다」로 갈라 적어 주시면 충분합니다. #556 이 «넷 중 셋이 뭉친다» 로 열렸는데 이 PR 이 둘을 갈랐으므로, 하나가 남는다는 사실이 어디엔가 있어야 다음 사람이 그것을 찾습니다. 이슈로 뗄지는 그쪽 판단에 맡기겠습니다 — 필요하면 제가 떼겠습니다.

❗2. 삭제된 메서드를 가리키는 {@link} 가 남았습니다

// AiServiceClient.java:785  (piiKinds 의 javadoc)
* <p>{@link #errorCode} 와 같은 이유로 {@code path()} 만 쓴다 — …

errorCode() 는 이 PR 이 지웠습니다. 파일에 남은 errorCode 는 이 한 줄뿐입니다.

그리고 아무것도 이걸 안 잡습니다. 재 봤습니다 — piiKinds 가 private 이라 javadoc 기본 범위 밖이고(그래서 ./gradlew javadoc 이 이 자리를 아예 안 봅니다), javadoc 태스크 자체가 CI 에 없습니다(ci.yml:236 은 ./gradlew test). 게다가 그 태스크는 이 브랜치 이전부터 다른 파일에서 이미 빨갛습니다(AccessGuard·AccessPolicy 등 23건).

raise() 안에 같은 이유가 그대로 적혀 있으니 {@link #raise} 로 돌리거나 링크를 풀면 됩니다. 사소한데 짚는 이유는, 이 레포가 이번 주에 낡은 주석만으로 PR 셋(#573·#583·#585)을 냈고 읽는 사람이 그 이름으로 근거를 찾으러 갈 자리이기 때문입니다.

❗3. 스택 머지 순서 — #586 을 --delete-branch 로 넣으면 이 PR 이 닫힙니다

base 가 test/error-code-sixth-copy-spec 입니다. 본문에 "#586 이 머지되면 여기 base 를 main 으로 돌립니다" 라고 적으셨는데, 순서가 반대면 늦습니다.

#586 을 --delete-branch 로 머지        → 이 PR 이 자동으로 닫힌다 (이 레포에서 실제로 그랬다)
이 PR 을 main 으로 먼저 재타깃          → 그 뒤엔 아무 순서로 넣어도 안전하다

#586 은 제가 방금 승인했으니 곧 들어갑니다. 재타깃을 먼저 하시거나, 넣는 분이 --delete-branch 를 빼 주시면 됩니다.

나머지 — 이견 없습니다

  • 새 코드를 만든 판단. 후보 셋이 각각 어떤 행동을 시키는지로 가른 표가 정확합니다. AI_SERVICE_UNAVAILABLE 이 이 이슈 자체이고, DOCUMENT_UNPROCESSABLE 은 올린 사람이 할 게 없고, INTERNAL_ERROR 는 재시도로 안 고쳐집니다.
  • 404 와 DOCUMENT_ACCESS_DENIED 를 한 타입에 둔 것. "다음 행동이 같다" 가 기준이고 DOCUMENT_UNPROCESSABLE 의 선례와 같습니다. 문면이 어느 쪽인지 가르는 것도 확인했습니다.
  • AiServiceException 을 상속한 것. 갈래를 늘리는 변경이 옛 호출부에서 500 으로 안 새는 것 — 이게 없으면 routes.py:108 이 적어 둔 그 사고(새 하위 타입이 아무 절에도 안 걸려 500)의 자바판이 됩니다.
  • 상태 502. ai-service 쪽 _REFUSAL_RESPONSE 표(routes.py:134)가 같은 이유로 4xx 를 안 쓰고, 그 판단과 짝이 맞습니다.
  • 응답에 경로를 안 실은 것. #582 규약과 같고 변이 5로 재집니다.
  • 이슈 3번(/ops/status 카드)을 미룬 것. 그 카드가 /healthz 왕복을 재는데 이 갈래는 파스 호출에서만 난다는 근거가 맞습니다. #568 의 추출 카드 쪽이 맞는 자리로 보입니다.

1·2 는 문면이고 3 은 머지 순서라, 코드에 이견이 없어서 코멘트로 둡니다. 1번만 갈라 적어 주시면 승인하겠습니다.

@github-actions github-actions Bot added 리뷰중: 정세현 정세현 이 코멘트·변경요청을 냈고 아직 승인하지 않았다 and removed 리뷰대기: 정세현 정세현 이 배정됐고 아직 아무것도 제출하지 않았다 labels Sep 10, 2026

@junseo2323 junseo2323 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

web 두 파일(제 영역) 승인합니다. 새 코드가 여섯 벌에 다 들어갔고 문면 규칙도 지킵니다. 그리고 제가 의심한 자리 하나가 이 PR 이 맞는 것으로 나왔습니다 — 아래 ①에 적습니다. 남은 것은 ②(값싼 두 줄)와 ③(이 PR 밖, 이슈 감)입니다.

web — 확인한 것

types.ts       유니온 + `// 502` 주석  →  webUnionStatusesMatchContract 가 읽는 형식과 같다
errorText.ts   전체 맵이라 유니온에 값이 늘면 tsc 가 문다 — 이 줄이 그 자리를 메운다
문면 규칙 둘    내부 상태 없음 ✓ / 다음에 할 일 있음 ✓ (「담당자에게 문의」)

FORBIDDEN 이 같은 뒷문장("담당자에게 문의해 주세요")인데 앞이 갈라 줍니다 — «볼 권한이 없어요» 는 차단이고 «문서 파일을 찾지 못했어요» 는 장애라, 화면에서 섞이지 않습니다. 다시 올리라고 안 한 것이 이 코드의 요점이고 그대로 지켜져 있습니다.

S03_Interview.tsx:823 의 describe() 는 자기 switch 를 갖고 있는데 default 가 받으므로 안 깨집니다. 애초에 이 코드가 그 화면에 닿지도 않습니다(업로드·재추출 경로).

① ❗「404 를 parse 전용으로」가 완전한지 재 봤습니다 — 맞습니다

본문의 근거가 "다른 내부 경로의 404 는 «라우트가 없다»" 인데, 저는 문서에 닿는 엔드포인트가 하나 더 있으면 그쪽이 빈다고 봤습니다. /internal/extract 가 failure() 를 쓰니까요(AiServiceClient:552).

안 빕니다. routes.py:252 의 extract 는 body.parsed_document 를 받습니다 — 파일을 안 엽니다. parsing.ParseRefused 계열(DocumentNotFound·DocumentAccessDenied)이 그 라우트에서 나올 수 없습니다.

/internal/parse     document_path 로 파일을 연다   ← 닿지 못함이 나는 유일한 자리
/internal/extract   parsed_document(이미 판 것)    ← 파일에 안 닿는다

그래서 404 를 parseFailure() 안에 둔 것이 좁은 게 아니라 정확합니다. DOCUMENT_ACCESS_DENIED 를 공용 raise() 에 둔 비대칭도 같은 이유로 무해합니다(낼 수 있는 라우트가 하나뿐).

② GlobalExceptionHandler 의 「네 벌」 두 줄 — 여기서 같이 걷어 주세요

:104   "코드 목록은 네 벌 대조 대상이라 늘리는 값이 싸지 않다"
:116   "네 벌(핸들러·openapi·CLAUDE.md·web/src/api/types.ts)"

#586 리뷰에서 @yoonjiseok 님이 ⓐ로 든 자리고, 이슈로 떼기로 정리된 것으로 압니다. 그런데 #586 이 그 수를 여섯으로 만들고, 이 PR 이 실제로 새 코드를 하나 만들면서 바로 그 파일에 핸들러를 더합니다. 그 괄호를 체크리스트로 쓰는 사람은 이제 둘을 빠뜨리고 CI 에서 두 번 되돌아옵니다 — #571 이 없애려던 그 왕복입니다.

두 줄이고 이 PR 이 이미 여는 파일입니다. everyDocumentClaimsTheRightNumberOfCopies 는 마크다운 셋만 보므로 자바 주석을 고쳐도 아무것도 안 깨집니다. 막지는 않겠습니다 — 이슈로 가도 이견 없습니다.

③ ❗/internal/parse 자신의 「라우트가 없다」 404 도 같은 갈래로 떨어집니다 (이 PR 밖)

본문이 «다른 엔드포인트의 404» 는 갈랐는데, 같은 엔드포인트의 두 가지 404 는 안 갈립니다.

DocumentNotFound        404 · detail = 평문 문자열          _REFUSAL_RESPONSE:125  (code 없음)
라우트가 없다             404 · detail = "Not Found"        FastAPI 기본

둘 다 평문 detail 이라 본문으로 구별이 안 됩니다. 이미지 세대가 갈려 그 라우트가 없는 배포에서 운영자가 받는 문면이 "업로드 볼륨의 마운트와 소유권을 확인하세요" 입니다 — 고칠 자리가 배포인 것은 맞아서 오진의 값이 크진 않은데, 문면이 볼륨을 콕 집습니다. 세대 어긋남은 이 팀이 실제로 한 번 밟은 사고입니다(핫재기동 뒤 /internal/score 422).

값싼 닫음은 ai-service 쪽입니다 — DocumentNotFound 에도 본문 코드를 주면((404, None) → (404, "DOCUMENT_NOT_FOUND")) DOCUMENT_ACCESS_DENIED 와 같은 모양이 되고 상태에 안 기댑니다. 그 표의 주석이 스스로 든 근거가 그것입니다: "상태 코드로는 이 사실을 말할 수 없으므로 본문에 코드를 싣는다." 계약이라 이 PR 에서 할 일은 아니고 @yoonjiseok 님 판단입니다. 원하시면 제가 이슈로 떼겠습니다.

머지 순서

base 가 test/error-code-sixth-copy-spec 입니다. #586 은 방금 제가 승인했으니 그것부터 들어가면 base 를 main 으로 돌리시면 됩니다. #585 가 조금 전 머지돼 errorText.ts 를 건드렸는데 파일 머리 주석(:21~28)이라 이 PR 의 표(:53~56)와 자리가 떨어져 있습니다 — 충돌은 안 날 자리인데 rebase 때 한 번 봐 주세요.

@github-actions github-actions Bot removed the 리뷰대기: 오준서 오준서 이 배정됐고 아직 아무것도 제출하지 않았다 label Sep 10, 2026
@hd0rable
hd0rable changed the base branch from test/error-code-sixth-copy-spec to main September 10, 2026 04:41
@hd0rable

Copy link
Copy Markdown
Member Author

부모(#586)가 머지돼서 base 를 main 으로 돌렸습니다. 이제 diff 가 이 PR 의 것만 남습니다.

main 을 병합해 합친 결과로 다시 쟀습니다 — ./gradlew test 821건 통과 ·
npm run build 통과.

@gitIt-sehyeon

Copy link
Copy Markdown
Contributor

base 재타깃 확인했습니다 — diff 가 이 PR 것만 남았고 #586 이 먼저 들어가서 순서도 안전합니다.

앞 리뷰의 1·2 는 아직 답을 못 받았습니다. 초록이라 지금 머지될 수 있는 상태라 한 번만 더 짚습니다.

  1. 본문 표의 400 줄 — DocumentPathRejected 를 던지는 네 자리 중 parsing.py:749(수동 파스 출력의 product_type 불일치)만 배선 버그가 아니라 운영 상황입니다. 지금은 502 AI_SERVICE_UNAVAILABLE 로 나가서 이 PR 이 없애려는 고리가 그 갈래에 그대로 남습니다. 여기서 고치시라는 게 아니고(routes.py 표를 건드려야 합니다) 표에 「하나가 남는다」만 적히면 됩니다 — #556 이 «넷 중 셋이 뭉친다» 로 열렸으니 남는 하나가 어디엔가 있어야 다음 사람이 찾습니다.

  2. AiServiceClient.java:785 의 {@link #errorCode} — 이 PR 이 지운 메서드를 가리킵니다. private 이라 javadoc 기본 범위 밖이고 그 태스크도 CI 에 없어서 아무것도 안 잡습니다. {@link #raise} 로 돌리면 됩니다.

둘 다 문면이라 막지 않았습니다. 이슈로 떼실 거면 그것도 좋습니다 — 다만 어느 쪽인지만 알려 주시면 제가 뒤에 안 걷어도 됩니다.

리뷰가 400 갈래를 다시 셌다. `DocumentPathRejected` 가 나는 자리가 다섯인데 내 표는
「우리 배선 버그」 하나로 적었다.

  parsing.py:682·684·693·696   경로가 비었다 · NUL · 해소 실패 · 뿌리 밖   배선 버그  ✓
  parsing.py:749               수동 파스 출력의 product_type 이 요청과 다르다  ❗데이터 상태

다섯째는 운영자가 실제로 만들 수 있는 상태(#441 의 JSON)이고 고칠 자리는 그 파일인데,
지금 문면은 AI_SERVICE_UNAVAILABLE 이라 ai-service 를 가리킨다 — 이 PR 이 없애려는 그
고리 그대로다.

여기서 못 고치는 이유는 구별할 재료가 안 오는 것이다: ai-service 의 거부 표가 그 갈래에
body code 를 안 싣는다(`(400, None)`). 그래서 서버는 넷과 다섯째를 구별할 방법이 없다.
그 사실을 `raise()` javadoc 에 적었다 — #556 이 「넷 중 셋이 뭉친다」로 열렸고 이 PR 이
둘을 갈랐으니, 하나가 남는다는 것이 코드에 있어야 다음 사람이 그것을 찾는다.

DOCUMENT_UNREACHABLE 로 보내지 않는다는 것도 같이 적었다 — 볼륨을 보라고 하는데 볼륨은
멀쩡하다.

## 삭제한 메서드를 가리키던 링크

`{@link #errorCode}` 가 남아 있었다 — 이 PR 이 지운 메서드다. 같은 이유가 `raise()` 안에
그대로 있어서 그쪽으로 돌렸다. 파일에 `errorCode` 언급이 0건이 됐다.

`javadoc` 이 이걸 안 잡는다는 것도 확인했다 — private 이라 기본 범위 밖이고 그 태스크가
CI 에 없다.

`./gradlew test` 821건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hd0rable

Copy link
Copy Markdown
Member Author

셋 다 처리했습니다(f2f252a).

1. 400 갈래 — 갈라 적고 이슈로 뗐습니다

다섯 자리를 제가 다시 셌고 지적대로입니다.

parsing.py:682·684·693·696   경로가 비었다 · NUL · 해소 실패 · 뿌리 밖     배선 버그 ✓
parsing.py:749               수동 파스 출력의 product_type 이 요청과 다르다   ❗데이터 상태

본문 표만 고치지 않고 raise() javadoc 에도 적었습니다 — PR 본문은 히스토리로 내려가고,
다음 사람이 «넷 중 하나가 남는다» 를 찾을 자리는 코드입니다. 거기에 왜 여기서 못 고치는지
(ai-service 표가 (400, None) 이라 구별할 재료가 안 온다)와 DOCUMENT_UNREACHABLE 로
보내지 않는 이유
(볼륨은 멀쩡하다)를 같이 적었습니다.

이슈로 떼는 것은 제가 했습니다 — #598. 선행이 routes.py 거부 표라 @yoonjiseok 님과
공동 담당으로 걸었고, «어느 코드로 낼지가 이 이슈의 결정» 이라는 것과 DOCUMENT_UNPROCESSABLE
이 왜 정확하지 않은지(수동 파스 출력은 「올린 문서」가 아니다)를 그쪽에 옮겼습니다.

2. 삭제한 메서드 링크

{@link #errorCode} → {@link #raise}. 같은 이유가 그 메서드 안에 그대로 있습니다.
파일에 errorCode 언급이 0건이 됐습니다.

javadoc 이 이걸 안 잡는다는 것도 확인했습니다 — private 이라 기본 범위 밖이고 그 태스크가
CI 에 없습니다. 짚어 주신 이유("읽는 사람이 그 이름으로 근거를 찾으러 갈 자리")가 이번 주에
낡은 주석 PR 셋이 나온 것과 같은 자리입니다.

3. 머지 순서 — 이미 재타깃했습니다

#586 이 머지된 직후에 base 를 main 으로 돌렸고 main 병합 후 재검증했습니다
(821건 통과). 지금 이 PR 은 어떤 순서로 넣어도 안전합니다. 본문 머리 알림도 그렇게 고쳤습니다.

변이 아홉을 다 돌려 주신 것 중 7·8·9(옮긴 방어가 옮긴 뒤에도 재지는지)가 제가 안 재본
방향입니다. 리팩터링이 #293 의 방어를 이동시켰는데 그것을 따로 확인해 주셔서, 「구조로
막았다」가 문면이 아니라 측정으로 남았습니다.

@gitIt-sehyeon gitIt-sehyeon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

승인합니다. 셋 다 확인했고, 1번을 PR 본문이 아니라 raise() javadoc 에 적으신 것이 제가 요청한 것보다 낫습니다.

errorCode 언급          0건        ({@link #raise} 로 갔다)
raise() javadoc         「아직 뭉쳐 있는 갈래가 하나 남는다」 절 신설
base                    main · 합친 결과로 821건 통과 (재확인)
이슈                    #598 · 담당 윤지석·강희진

PR 본문은 히스토리로 내려가고, 다음 사람이 «넷 중 하나가 남는다» 를 찾을 자리는 코드입니다.

맞습니다. 제가 «표에 한 줄» 로 요청했는데 그건 찾을 수 없는 자리에 적는 것이었습니다. 그리고 새 절이 제가 낸 것보다 한 칸 더 갑니다 — 왜 여기서 못 고치는지(ai-service 표가 (400, None) 이라 구별할 재료가 안 온다)와 DOCUMENT_UNREACHABLE 이 아닌 이유(볼륨은 멀쩡하다)가 같이 있어서, 이 갈래를 집어 드는 사람이 잘못된 코드로 보내는 것부터 막힙니다.

셈도 그쪽이 맞습니다 — 다섯 자리 중 넷이 배선 버그입니다. 제 리뷰 머리글이 「네 자리 중」이었는데 표에 다섯을 적어 놓고 문장만 하나 빠뜨렸습니다.

#598 에 routes.py 표를 선행으로 걸고 @yoonjiseok 님과 공동 담당으로 두신 것도 맞습니다 — 그 표가 안 고쳐지면 서버 쪽에서 할 수 있는 게 없습니다.

2·3 도 확인

{@link #raise} 로 간 자리에 같은 근거(path() vs get() · #293)가 그대로 있어서 링크가 실제로 답을 가리킵니다. base 는 main 이고 합친 결과가 821건입니다.

7·8·9 가 제가 안 재본 방향입니다.

리팩터링이 방어를 옮길 때만 생기는 자리라, 새 기능을 재는 변이로는 안 나옵니다. 이 PR 은 errorCode() 를 지우면서 #293 이 세운 것을 raise() 로 이동시켰는데, 그때 확인할 것이 「새 갈래가 도는가」가 아니라 「옛 그물이 옮긴 뒤에도 무는가」 입니다.

@github-actions github-actions Bot removed the 리뷰중: 정세현 정세현 이 코멘트·변경요청을 냈고 아직 승인하지 않았다 label Sep 10, 2026

@yoonjiseok yoonjiseok left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

승인합니다. /internal/parse 거부 표를 만든 쪽(#551)으로서 봤고, 수신 매핑이 제 표와 정확히 맞습니다. 그리고 이 PR 이 #571·#586 이후 처음 만들어지는 에러 코드라 그 두 PR 의 값이 여기서 처음 실전에 걸립니다 — 그것도 재봤습니다.

제 표와 대조

ai-service _REFUSAL_RESPONSE                        이 PR 의 수신
DocumentPathRejected   (400, None)                  502 AI_SERVICE_UNAVAILABLE   그대로 ❗아래
DocumentNotFound       (404, None)                  502 DOCUMENT_UNREACHABLE     ◀
DocumentUnreadable     (422, None)                  400 DOCUMENT_UNPROCESSABLE   그대로
DocumentAccessDenied   (502, "DOCUMENT_ACCESS_DENIED")  502 DOCUMENT_UNREACHABLE  ◀

404 와 DOCUMENT_ACCESS_DENIED 를 한 코드로 묶은 판단이 맞습니다 — "다음 행동이 같아서" 가 정확한 기준이고, 그게 DOCUMENT_UNPROCESSABLE 이 «못 열림»과 «PII 거부»를 한 코드로 낸 것과 같은 결입니다. 제 쪽에서 그 둘을 갈라 둔 이유는 고치는 자리가 달라서인데(업로드·마운트 ↔ 볼륨 소유권), 운영자가 볼 곳은 둘 다 업로드 볼륨이라 서버 층에서 합치는 게 맞습니다.

502 로 둔 근거도 제 쪽과 같습니다 — 요청도 문서도 정상이고 못 읽는 것이 우리 배포라 4xx 면 호출자 잘못으로 읽힙니다.

❗#571·#586 이 여기서 처음 값을 냅니다

DOCUMENT_UNREACHABLE 이 그 두 PR 이후 처음 생기는 코드입니다. 여섯 벌이 다 차 있고, 그중 예전에는 ./gradlew test 가 못 잡던 셋을 각각 빼서 재봤습니다.

web/src/lib/errorText.ts 에서 뺀다      10 tests, 1 failed    ← #571 이 넣은 다섯째
docs/functional-spec-v1.2.md 에서 뺀다   10 tests, 2 failed    ← #586 이 넣은 여섯째
web/src/api/types.ts 에서 뺀다          10 tests, 1 failed
복원                                    BUILD SUCCESSFUL

#571 전이었으면 앞의 것은 CI 의 web 스텝에서만 빨개졌고(#527 브랜치에서 5분 뒤 고침 커밋이 붙은 그 자리), #586 전이었으면 명세서 §9 는 아무도 안 잡았습니다. 지금은 서버 테스트 한 번에 여섯이 다 나옵니다 — 그 두 PR 이 노린 것이 이것이고, 이번이 첫 실물입니다.

참고로 그래서 npm run build 를 따로 안 돌렸습니다. CLAUDE.md 가 "코드를 더했으면 npm run build 까지 돌린다" 로 적어 둔 이유가 «다섯째는 tsc 만 잡는다» 였는데, #571 이후로는 그 문장의 전제가 바뀌었습니다. 그 문단도 언젠가 같이 손봐야 할 것 같습니다.

「본문을 한 번만 읽는다」가 구조로 간 것

resp.getBody() 는 스트림이라 두 번째 읽기가 빈 값을 주고 … parseFailure 가 그 함정을 이미 한 번 밟았습니다(스키마 거부가 DocumentUnreadable 로 샜습니다).

읽는 자리를 failure() 하나로 올려 다시 밟을 수 없게 만든 것이 좋습니다. 규약으로 적는 것과 구조로 못 하게 하는 것이 다른 층인데 뒤쪽입니다.

역검증에서 세 번째가 처음에 초록이었다는 것도 좋습니다 — "javadoc 이 근거로 든 구분을 코드가 안 지키고 있었는데 재는 것이 없었다". 「404 를 공용 갈래에 두지 않는다」가 이 PR 의 판단인데 그 판단을 무는 것이 없던 자리고, /internal/score 404 단정으로 닫으셨습니다. 저는 어제 #593 에서 정확히 같은 것을 놓쳤습니다(본문이 근거로 든 갈래를 그물에 안 걸었습니다).

#598 — 제 몫이라 모양을 적어 둡니다

ai-service 의 거부 표가 그 갈래에 body code 를 안 싣습니다((400, None))라 서버가 넷과 다섯째를 구별할 재료가 없습니다.

맞습니다. 그리고 표가 예외 타입으로 키를 잡습니다.

_REFUSAL_RESPONSE: dict[type[parsing.ParseRefused], tuple[int, str | None]]

다섯째(_manual_override 의 product_type 불일치)가 같은 DocumentPathRejected 를 던지므로, 코드를 붙이려면 하위 타입을 하나 만들어야 합니다. 그러면 자연히 갈립니다.

❗그리고 그걸 잊을 수가 없습니다 — #551 이 넣은 _assert_every_refusal_is_mapped 가 ParseRefused 하위를 재귀로 훑어서 미매핑이면 기동에서 죽습니다. 새 하위를 만들고 표에 안 넣으면 앱이 import 조차 안 됩니다.

즉 #598 은 「타입 하나 + 표 한 줄 + 문면」이고 순서는 제 쪽이 먼저입니다. 이 PR 이 그 사실을 raise() javadoc 에 적어 두신 것도 맞습니다 — 지금 상태가 「아직 뭉쳐 있다」는 것이 코드 옆에 남아야 다음 사람이 «넷 다 갈렸다» 로 안 읽습니다.

확인한 것

server 전건 (PR 브랜치 · JDK 17)                BUILD SUCCESSFUL (35s)
ErrorCodeContractTest · AiServiceClientTest ·
DocumentUploadWiringTest                        BUILD SUCCESSFUL
여섯 벌에 DOCUMENT_UNREACHABLE                   전부 있음 · 셋 빼기 변이 모두 빨강
수신 매핑 ↔ ai-service _REFUSAL_RESPONSE         일치
base                                            main (c3d8cfa) — #586 머지 후 재타깃 확인

DocumentUnreachableException 이 AiServiceException 을 상속해서 이 타입을 모르는 옛 호출부가 지금처럼 502 로 다뤄지는 것 — 갈래를 늘리는 변경이 조용히 500 을 만들지 않게 한 것도 좋습니다.

@github-actions github-actions Bot removed the 리뷰대기: 윤지석 윤지석 이 배정됐고 아직 아무것도 제출하지 않았다 label Sep 10, 2026
@hd0rable
hd0rable merged commit a1a9ead into main Sep 10, 2026
3 checks passed
hd0rable added a commit that referenced this pull request Sep 10, 2026
합친 결과로 재검증: ./gradlew test 822건 통과 (#591 포함 후)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

server Spring (:8000) web Vite (:5173) 계약 모듈 간 계약 — contracts/, openapi.yaml, 스키마 보안 역이용 방지·접근통제·PII·비밀정보

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants