이 글은 2026-08-06 하루의 기록이다. 오전엔 단계 전환 게이트의 수동 점검을 시스템이 먼저 판정하도록 뒤집었고, 낮부터 저녁까지는 외부 접속 로그인이 깨지는 문제를 쫓다가 HTTPS 전환과 Keycloak 컨테이너 이관까지 갔다. 밤엔 게이트 미달 항목에 “그 자리로 가는 길”을 붙였다.
배경 — 사람이 확인하는 칸이 너무 많았다
단계 전환 게이트에는 자동 점검과 수동 점검이 있었다. 자동 점검은 시스템이 판정하지만, 수동 점검은 체크리스트 문구만 깔아 두고 사람이 전부 확인해야 했다.
“단계 전환시, 수동 점검 - 사람이 확인 부분을 시스템이 1차적으로 확인 후 사용자가 검토하는 흐름으로 개선”
체크리스트에 적힌 것들은 대부분 시스템이 이미 알고 있는 사실이었다. 정합성 ERROR 건수, 다이어그램 드리프트, 식별자 미부여 건수, 시험 결과 미기재 건수 — 전부 어딘가에서 이미 계산되고 있는데 게이트에서만 사람에게 물어보고 있었다.
문제
- 수동 점검을 시스템이 먼저 판정하게 하되, 판정 축을 새로 만들지 않으려면 어디서 근거를 끌어오나.
- “판정을 못 했다”와 “확인했는데 이상 없다”를 화면이 구분해서 말하고 있나.
- 외부에서 접속하면 왜 로그인이
localhost:8080으로 가나. - 미달 항목이 “무엇이 왜 미달인지”를 말하지 못하는데, 어디를 고쳐야 하는지는 어떻게 알려주나.
- 분석에서 통과한 정합성이 설계에서 다시 깨지는 건 데이터 문제인가 게이트 문제인가.
결정 1 — 판정 축을 새로 만들지 않는다
server/gate_manual.py를 새로 만들어 단계별 수동 체크리스트를 시스템이 먼저 판정하게 했다. 자동 점검과 같은 삼값(충족/미달/판정 불가)에 실측 근거를 붙인다.
핵심은 판정 근거를 기존 원천에서 인용한다는 것이다.
| 항목 | 1차 판정 근거 |
|---|---|
| 정합성 ERROR 0 | 자동 점검 ①(스키마+추적성) 결과 인용 |
| 다이어그램 라운드트립 | diagram_consistency.run (비파괴) |
| 요구사항·기능 정의 확정 | 항목 수 · 식별자 미부여 건수 |
| 결과서 개정 완료 | 시험 4종의 결과 미기재 건수 |
| 구현 Task Done | 자동 점검 ③ Jira 결과 |
같은 사실을 두 벌로 계산하면 화면마다 답이 갈린다. 게이트는 판정을 새로 만드는 곳이 아니라 흩어진 판정을 모아 보여주는 곳이다.
/phase/gate 응답에 manual[]을 추가하고 ?manual=false로 생략할 수 있게 했다. 점검 하나가 터져도 그 항목만 판정 불가로 떨어지고 나머지는 판정한다 — 게이트가 새 실패 지점이 되면 곤란하다.
화면에서는 각 항목에 판정 배지와 근거를 달고 그 옆에 사람 확인 체크박스를 뒀다. [충족 n건 확인]으로 시스템이 충족으로 본 항목은 한 번에 수용할 수 있지만, 미달·판정 불가는 개별로만 확인 가능하다 — 의도적인 마찰이다. 시스템 판정과 어긋난 확인은 전환 비고에 자동으로 기록된다.
결정 2 — “판정 없음”과 “미점검”은 다른 말이다
만들고 나니 곧바로 질문이 돌아왔다.
“‘시스템 판정 없음’이 시스템이 확인했을 땐 이상 없다는 거야?”
정반대였다. 그 문구는 서버가 manual을 주지 못했을 때(구버전 서버·응답 실패) 쓰는 폴백이라 판정칸을 비워 둔 상태였다. 그런데 배지는 정상 응답일 때의 판정 불가와 같은 라벨을 달고 있었다. 문구가 “이상 없음”으로 읽히도록 방치한 게 잘못이라 상태를 넷으로 갈랐다.
| 배지 | 의미 |
|---|---|
| 충족 | 점검했고 조건 만족 — 실측 근거가 붙음 |
| 미달 | 점검했고 조건 미충족 |
| 판정 불가 | 점검은 했지만 결론 낼 원천이 없음 (Jira 미연결 등) |
| 미점검 | 시스템이 보지 못함 — 근거 자체가 없음 |
폴백 항목은 ok: null + unchecked: true로 표시하고 문구도 “시스템이 점검하지 못했습니다 — 사람이 직접 확인하세요”로 바꿨다. 어느 배지든 사람 확인 체크는 여전히 필수다.
사용자에게 “모른다”를 “괜찮다”로 읽히게 만드는 문구가 가장 위험하다. 판정하지 못한 것과 판정한 결과 이상이 없는 것은 정반대의 정보다.
결정 3 — 서버 기준 주소와 브라우저 기준 주소는 다르다
낮에 외부 접속 로그인이 깨진다는 보고가 들어왔다. 로그인 시 localhost:8080/realms/...로 연결돼서 사이트에 연결할 수 없다는 것이었다.
원인은 코드가 아니라 브라우저에게 서버 기준 주소를 그대로 알려주고 있었기 때문이다. KEYCLOAK_ISSUER=http://localhost:8080/...을 웹 UI가 로그인 리다이렉트 주소로 그대로 썼다. 서버 입장에선 맞는 주소지만, 외부 접속자의 브라우저에게 localhost는 자기 PC다.
두 주소를 분리했다.
KEYCLOAK_ISSUER=http://localhost:8080/realms/artifact-transformer # 서버 → Keycloak
KEYCLOAK_PUBLIC_URL=http://at-lan.pabloair.com:8080/realms/artifact-transformer # 브라우저 → Keycloak
/api/auth/config가 authBase를 내려주고 프런트가 authorize/token/logout을 이 주소로 호출한다. 토큰 검증은 두 발급자를 모두 허용하도록 했다 — Keycloak은 KC_HOSTNAME을 고정하지 않으면 접속에 쓰인 호스트로 iss를 찍기 때문이다. 물론 목록 밖 발급자는 거부한다.
주소를 서버가 임의로 바꿔치기하지는 않았다. Keycloak이 앱과 다른 호스트에 있을 수 있어 추측이 더 위험하다. 대신 “외부 접속인데 로그인 주소가 loopback”인 조합을 감지해 로그인 화면과 시스템 진단에 경고를 띄웠다.
그런데 고쳐지지 않았다. 다시 해도 여전히 localhost:8080으로 갔다.
실측해 보니 서버 설정은 정상이었다. 진짜 범인은 빌드였다.
grep -c authBase frontend/dist/assets/*.js # → 0
/ui는 frontend/dist의 Vite 빌드 산출물을 그대로 서빙하는데, 그 번들이 8월 4일자라 방금 고친 auth.ts가 들어있지 않았다. 브라우저는 계속 옛 코드로 issuer를 쓰고 있었던 것이다. npm run build를 돌리고, 재빌드 후에도 브라우저가 옛 번들을 부르는 걸 막으려 /ui의 index.html에 Cache-Control: no-cache, must-revalidate를 붙였다.
서버 설정을 고쳤는데 화면이 그대로면 설정을 더 의심하기 전에 브라우저가 실제로 받은 코드부터 확인해야 한다. 번들에 그 문자열이 있는지 세어 보는 게 가장 빠르다.
결정 4 — HTTPS를 켜고, Keycloak 컨테이너를 통째로 옮기다
HTTPS를 켜 달라는 요청이 이어졌다. 켜기 전에 하나가 걸렸다 — HTTPS 페이지에서 http://at-lan...:8080으로 나가는 토큰 교환은 브라우저의 mixed content 차단에 걸린다. 앱만 HTTPS로 바꾸면 로그인이 깨진다.
만든 것은 셋이다.
tools/make_dev_cert.py— 자체 서명 인증서 생성기. SAN에 지정 호스트와localhost/127.0.0.1/::1을 함께 넣는다(브라우저는 CN을 안 보고, 서버도 loopback으로 자기를 부른다).server/__main__.py—python -m server진입점. env에서 TLS 설정을 읽는다. 인증서를 반쪽만 주거나 파일이 없으면 평문으로 뜨지 않고 즉시 종료한다.frontend/vite.config.ts— dev 서버도 같은 인증서를 읽어 HTTPS로 뜬다. 인증서 경로를 두 곳에 적으면 반드시 갈라지므로 같은 값을 재사용한다.
issuer만 평문 loopback으로 남긴 건 의도적이다. 자체 서명 인증서는 브라우저처럼 예외 수락이 없는 서버 측 SSL 검증을 통과하지 못한다. 토큰 iss는 https로 찍히지만 앞서 넣은 이중 발급자 허용이 이를 받아준다.
그리고 저녁에 로그인이 계속 막혔다. authorize URL은 완벽했다 — 8443 HTTPS 주소, code_challenge_method=S256, redirect_uri도 정확. Keycloak이 거부하는 것 같다는 짐작으로 그 URL을 그대로 호출해 봤다.
https://at-lan.pabloair.com:8443 → Connection refused
http://at-lan.pabloair.com:8080 → 200
Keycloak이 거부한 게 아니라 HTTPS 리스너 자체가 없었다. 컨테이너를 확인하니 start-dev로, 8080만 열고, 볼륨 없이 떠 있었다. HTTPS를 켜려면 컨테이너를 다시 만들어야 하는데 볼륨이 없어서 realm 설정이 컨테이너 안에만 있었다.
백업부터 받았는데 여기서 한 번 더 걸렸다. 관리자 REST 덤프는 Google IdP 시크릿이 **********로 마스킹돼 복원이 불완전하다. 그래서 JSON 재구성 대신 컨테이너의 /opt/keycloak/data를 통째로 도커 볼륨에 옮기는 방식으로 갔다. 구 컨테이너는 지우지 않고 keycloak-pre-https로 이름만 바꿔 롤백용으로 남겼다.
이관 후 확인한 결과는 Google IdP 시크릿 보존, 사용자·역할 그대로, 문제의 authorize URL이 200 Sign in to artifact-transformer였다.
결정 5 — 미달 항목에 좌표를 달다
“게이트 점검에서 미달이거나 확인이 필요한 사항에 대해 해당 이슈에 대해 상세히 표출해주고 해당 위치로 이동 할 수 있게 해줘”
gate_manual.issue()로 근거 한 건을 {설명, 부가설명, target}으로 만들고, 자동·수동 점검 전 항목에 issues[]와 issueCount를 실었다. 라운드트립 드리프트는 다이어그램 세션으로, 스키마 ERROR는 해당 JSON의 정확한 경로로, Jira는 이슈 링크로 보낸다.
목록은 50건에서 자르되 issueCount로 전체 건수를 남긴다. 화면에도 “외 N건”으로 표시해 잘린 사실을 숨기지 않는다.
여기서 그치지 않고 좌표를 두 단계 더 정밀하게 만들었다. 먼저 드리프트를 건별 행으로 펼쳐 +/-/~ 경로 — 이전값 → 새값을 보여주고, 클릭하면 JSON 편집기로 이동하면서 기존 강조 장치(.at-hl)에 그 자리의 값을 걸어 둔다.
다음은 PlantUML이었다.
“다이어그램도 선택시 변경 된, 또는 접합성이 깨진 부분 강조 표출해줘(plantuml 코드니까 가능)”
맞는 말이다. .puml은 텍스트니까 서버가 파일·줄까지 계산할 수 있다. _find_in_puml을 만들면서 함정이 하나 있었는데, 단순 첫 일치를 쓰면 표준 머리주석 배너에 같은 낱말이 있어서 주석으로 보내버린다는 것이었다. 우선순위를 정했다.
① 선언 줄 (package / !startsub / actor / usecase / C4 Container 등)
② 주석 아닌 줄
③ 없으면 좌표 없음
FDSP 실측에서 배너 3행을 짚던 것이 실제 선언부로 교정됐다.
+ packages[0].id — (없음) → DAT | FDSP-UCDG-DAT.puml:31 ← !startsub DAT
+ packages[0].name — (없음) → 데이터 | FDSP-UCDG-DAT.puml:36 ← package "데이터" as DAT {
화면에서는 dgfocus.ts(신규, hl.ts와 같은 초경량 구독)로 “이 .puml의 이 줄” 요청을 카드에 전달한다. 근거를 클릭하면 해당 다이어그램 카드가 자동으로 펼쳐지고, 뷰어 전용 모드였으면 분할로 전환해 코드를 띄운다. textarea는 DOM 강조를 못 얹으므로 setSelectionRange로 네이티브 선택을 잡았다 — 브라우저가 선택 색을 입히고, 그 자리에서 바로 편집할 수 있다.
가독성 정리도 함께 했다. 미달 항목은 근거를 펼친 채 시작하고(어차피 열어 볼 것에 클릭을 요구하지 않는다), 미달 → 판정 불가 → 통과 순으로 정렬하며, 헤더에 [남은 것만] 필터를 기본으로 켰다.
결정 6 — 점검 시점이 틀렸다
“분석 단계에선 게이트에서 CDG에 대한 정합성이 통과 되었는데, 설계 단계에선 다시 UCDG 에 대한 정합성에서 구조 변경이 있다고 표출 되고 있어”
두 가지가 겹쳐 있었다. 하나는 실제 데이터 드리프트였다 — UCDG(.puml)에는 패키지 DAT/MON이 선언돼 있는데 usecase.json에는 packages 키 자체가 없었다.
다른 하나가 게이트 설계 결함이었다.
| 수정 전 | 수정 후 | |
|---|---|---|
| 분석 게이트 | 라운드트립 항목 없음 | BCDG·UCDG |
| 설계 게이트 | 5종 전부 (분석 것까지) | CDG·SQDG·ERD |
분석 게이트는 통과한 게 아니라 애초에 검사를 안 했다. 그래서 분석 소유 다이어그램의 드리프트가 분석이 끝나 산출물이 잠긴 뒤에야 설계 게이트에서 터졌다. 고치려면 단계 복귀부터 해야 하는 상태였다.
diagram_consistency.run(keys=...)에 종류 필터를 추가하고 각 단계가 자기 소유 다이어그램만 보게 했다. 구현은 설계 3종, 인도는 전 5종을 전수 검사한다. 서버 문구와 meta.ts의 일치는 기존 파리티 테스트가 계속 강제한다.
같은 맥락에서 이전 단계로 돌아가는 길도 뚫었다. 복귀 기능 자체는 있었지만 ProjectView 안에만 있어서 게이트에서 막혔을 때 도달할 방법이 없었다. RollbackModal을 공용으로 추출하고, 게이트 패널 헤더에 [← 분석 복귀…]를, 완료된 단계 산출물을 가리키는 근거 줄에는 [복귀 필요] 배지를 달았다.
결정 7 — 고치는 손: AI와 결정적 경로를 갈라 두다
마지막은 미달 이슈에 실제로 고치는 수단을 붙이는 작업이었다. 기존 핸드오프 패턴(at.assist.pending 예약 → 문서 이동 → AI 팔레트가 지시를 채운 채 열림)을 assistHandoff.ts로 공용화해 재사용했다. AI는 제안까지만 하고 반영·저장은 사람이 누르는 불변식은 그대로다.
[AI 수정]은 산출물(JSON)을 가리키고 · AI가 켜져 있고 · 잠긴 단계가 아닐 때만 뜬다. 이전 단계 소유는 AI로 고쳐도 저장이 막히므로 대신 [복귀 필요]가 뜬다.
그리고 하나를 일부러 AI에서 뺐다.
라운드트립 드리프트의 정답은
parse동기화다. 다이어그램이 구조의 원천이니 결정적으로 되는 일에 LLM을 끼우지 않는다.
드리프트 근거에는 [AI 수정] 대신 [.puml→JSON 동기화] 버튼을 띄웠다. 이 경로를 위해 diagramSync.ts를 신설해 DiagramSection과 게이트가 동기화 잡 등록·폴링을 공유하게 했다.
의도치 않은 함정
조용한 실패가 기능 없음으로 둔갑했다. HTTPS 전환 후 “로그인이 비활성화됐다”는 보고가 있었는데, 실제로는 8000 포트가 아직 평문 HTTP로 떠 있어서 https 페이지의 /api/auth/config 호출이 실패한 것이었다. 그런데 프런트가 그 실패를 삼키고 enabled: false(SSO 미설정)로 취급했다. 증상보다 이 삼킴이 더 문제라서, 설정을 못 읽으면 데이터 로드로 넘어가지 않고 사유 원문과 점검 목록을 띄우는 연결 실패 화면을 만들었다.
uvicorn을 직접 부르면 TLS env를 읽지 않는다. python -m uvicorn server.app:app --host 0.0.0.0 --port 8000은 AT_TLS_*와 무관하게 평문으로 뜬다. 로그의 0.0.0.0은 바인드 주소일 뿐이고 핵심 신호는 앞의 http://다. 기동 로그 첫 줄의 스킴만 보면 된다.
WinError 10054는 양성 잡음이다. ConnectionResetError: [WinError 10054] 트레이스백이 HTTPS 전환 후 부쩍 늘었는데, 클라이언트가 연결을 끊었을 때 Windows asyncio proactor 루프가 올리는 잡음이라 요청 처리와 무관하다. 다만 로그를 도배해 진짜 오류를 묻으므로 Windows에서 winerror == 10054인 레코드만 버리는 필터를 걸었다. 다른 ConnectionResetError는 그대로 남긴다.
경고 메시지의 출처가 앱이 아닐 수도 있다. react-dom.production.min.js:10에서 나오는 aria-label 경고를 받았는데, 소스와 node_modules 전체에 그런 문자열이 없었다. React 자체도 그런 경고를 내지 않는다 — 브라우저 확장일 가능성이 높다. 다만 지적 내용 자체는 맞아서 랜드마크·탭 그룹 7곳에 접근 가능한 이름을 전부 붙였다.
마무리
이날의 흐름은 “모른다”를 말할 수 있게 만드는 것으로 요약된다. 게이트가 판정하지 못한 항목을 미점검으로 분리하고, 잘린 목록은 전체 건수를 남기고, API 연결 실패를 “SSO 없음”으로 둔갑시키지 않고, 좌표를 못 찾은 근거는 클릭 불가로 둔다.
각각의 교훈:
- 판정을 새로 만들지 말고 인용하라 — 같은 사실을 두 벌로 계산하면 화면마다 답이 갈린다.
- 설정을 고쳤는데 안 변하면 번들부터 의심하라 — 빌드 산출물을 서빙하는 구조에서 “고쳤는데 화면이 그대로”의 절반은 재빌드 누락이다.
- 거부당했다고 생각한 요청이 애초에 도달하지 못했을 수 있다 — 8443은 Keycloak이 거부한 게 아니라 리스너가 없었다.
- 점검 시점이 늦으면 고칠 수 없는 상태로 발견된다 — 잠긴 단계의 산출물을 가리키는 미달 항목은 그 자체로 막다른 길이다.
다음에 남는 것:
- 단계별 라운드트립 검사 범위가 실사용에서 오탐 없이 자기 단계에서 잡히는지 관찰.
- FDSP의 UCDG ↔
usecase.json드리프트 실제 해소 — 분석 복귀 후 처리할지 CLI로 돌릴지 결정 필요. - 자체 서명 인증서의 두 origin(앱 8000, Keycloak 8443) 예외 수락이 실사용자에게 얼마나 걸림돌이 되는지 확인.
tests/test_assist.py2건 실패는 이번 변경 전에도 동일하게 재현되므로 별건으로 조사.