Skip to content

[Release] 카탈로그 내구성·URL 스킴 검증과 previewCreated [ #395 #392 ] - #400

Merged
dldnsgkr merged 4 commits into
mainfrom
develop
Sep 26, 2026
Merged

dldnsgkr merged 4 commits into
mainfrom
develop

Conversation

@dldnsgkr

Copy link
Copy Markdown
Collaborator

DB 마이그레이션 있음 — V65 (신규 테이블 하나, 기존 테이블 무변경).
운영 DB 백업 완료·검증: /home/ubuntu/dvely-prod-backup-20260926-180658.sql.gz (136K · 33/33 테이블 · 완료 마커 · gzip 무결성 OK).

커밋 PR 내용
761b14f #396 #395 카탈로그 재시작 내구성 + URL 스킴 검증 (V65)
7446866 #397 #395 스냅샷 왕복 실제 MySQL 테스트
da37a94 #398 문서 — 카탈로그 SPOF · previewUrl 계약 셋
cc26333 #399 #392 1단계 — previewCreated 추가, previewUrl 폐기 표시

이 릴리스가 지금 필요한 이유는 저장소 두 개에 걸친 연쇄를 막고 있어서다

#392 는 3단계로 나눠 FE 와 순서를 합의했다. 그런데 1단계가 운영에 없으면 연쇄가 여기서 멈춘다.

조건 지금
① BE previewCreated 추가 — develop · 운영 대기 ← 이 릴리스
② FE 둘 다 읽기 조건 없음 — 양쪽 서버에서 맞게 돈다 FE 승인 대기
③ BE 옛 previewUrl 제거 ①이 운영에 있어야 함 착수 불가

3단계는 FE 를 깨뜨리지 않고는 착수할 수 없다. ①이 운영에 가지 않으면 #392 는 영영 안 끝난다.

여기서 내가 한 번 잘못 안내했고, FE 가 잡았다

1단계를 develop 에 올린 뒤 FE 에 "언제든 2단계 넣으셔도 됩니다"라고 전했다. 틀렸다. FE 는 main 머지가 곧 프로덕션이라, 그 말대로 previewCreated 로 갈아탔으면 운영에서 영영 undefined 였다.

그리고 그게 특히 나쁜 모양이다 — 그 값을 쓰는 자리가 폴링 조기 중단이라 오류가 나지 않는다. 프리뷰가 생겨도 안 멈추고 태스크 종료까지 계속 폴링하며, 화면은 조금 늦게 갱신되는 것처럼 보일 뿐이다. 이 이슈가 고치려던 종류의 잘못을, 이 이슈를 고치는 과정에서 만들 뻔했다.

양쪽 OpenAPI 를 떠서 확인했다.

dev   TaskStatusResponse 15개 · previewCreated boolean · previewUrl deprecated=true
운영  TaskStatusResponse 14개 · previewCreated 없음    · previewUrl deprecated=false

FE 는 "둘 다 읽기"(task?.previewCreated || task?.previewUrl?.trim())로 넣기로 했다.


#395 — 카탈로그가 FE 전체의 단일 장애점이 됐다

Dvely_FE#117(41a20f3)이 더미 목록을 지운 뒤 다섯 화면이 전부 GET /api/v1/templates 하나만 본다 — 랜딩 / 갤러리 / 홈 탐색 / 생성 미리보기 / 프롬프트 얹기.

① 재시작마다 cold start 였다

캐시가 private volatile Snapshot 이라 메모리에만 있었다. request-timeout: 5s 안에 GH Pages 가 답하지 못하면 503 이고, 배포 = 재시작 = cold start 다. 즉 "배포 직후 + GH Pages 불통"이 겹치면 다섯 화면이 동시에 빈다. 예전엔 갤러리만 깨졌다.

성공 갱신을 template_catalog_snapshot(단일 행, V65)에 남기고 cold start 에서 네트워크가 실패했을 때만 복원한다. 복원 조건 셋 — 출처 URL 일치 · 나이 ≤ snapshot-max-age(기본 24h) · 스킴 재검사. 하나라도 어긋나면 예전처럼 503 이다. 저장·복원 어느 실패도 서빙을 막지 않는다.

jar 폴백은 일부러 뺐다. 낡은 목록을 보여주면 없어진 템플릿을 고르게 되고, TemplateCatalogGuard 는 같은 스냅샷을 읽으니 통과시켜 씨딩에서 깨진다 — "고를 때는 200, 만들 때는 실패"는 그 guard 가 존재하는 이유 그 자체다.

② 카탈로그의 URL 을 아무도 검사하지 않았다

외부 JSON 을 바로 역직렬화해 통과시키고 검사는 "비어 있나" 하나였다.

필드 전 후
sourceUrl 이미 안전 — 셸 curl 자리라 ^https://[A-Za-z0-9._~:/-]+\.tar\.gz$ 그대로
demoUrl 검사 없음 · FE 가 iframe src 에 http(s) 확인
thumbnailUrl 검사 없음 · FE 가 img src 에 있으면 확인 (nullable, #318)

가장 위험한 자리가 가장 잘 막혀 있었고 브라우저로 나가는 둘이 비어 있었다. 나쁜 템플릿 하나를 빼지 않고 문서를 거부해 stale-while-error 에 맡긴다 — 조용히 빼면 카탈로그를 고친 사람은 자기가 무엇을 잘못했는지 모른 채 그 템플릿이 사라진 것만 본다.

sourceUrl 을 넣지 않은 것도 판단이다. 싱크에 붙은 좁은 검사를 수신 지점으로 옮기거나 느슨하게 복제하지 않는다 — 그러면 좁은 쪽이 느슨한 쪽을 통과한 값을 다시 받는 구조가 된다.


@Transactional 로 한 번 틀렸다

save 에 붙였는데 커밋을 프록시가 메서드 밖에서 하므로 플러시 실패가 try/catch 를 지나쳐 올라간다. cold start 면 네트워크가 성공했는데도 503 이다 — 보조 장치가 본 기능을 죽인다.

단위 테스트로는 막을 수 없다. 프록시가 없어서 붙인 채로도 통과한다 — "저장 실패가 서빙을 막지 않는다" 테스트가 그렇게 거짓 통과했다. 트랜잭션을 빼고 repository.save 하나(merge)로 바꾸고, 애노테이션 부재를 직접 단정하는 테스트로 고정했다.


검증

전체 1765건 통과, 실패 0, skip 12.

역검증 8건 — 고친 것을 되돌리면 실제로 실패하는가

되돌린 것 실패
스킴 검사 제거 3건
복원 호출 제거 2건
저장 호출 제거 5건
나이 상한 검사 제거 1건 (단정 실패 — NPE 아님)
출처 일치 검사 제거 1건 (단정 실패)
@Transactional 재부착 1건
isBlank() 제거 (공백이 true 로) 1건
previewCreated 를 항상 false 로 1건

나이·출처는 store 안에 있어 클라이언트 쪽 되돌리기로는 엉뚱한 이유(NPE)로 실패했으므로 그 둘만 따로 떼어 단정 실패로 잡히는 것까지 봤다.

복원 왕복은 진짜 Jackson + 실제 MySQL 을 태운다 (#397)

복원은 평소에 돌지 않고 정작 필요한 순간에만 도는 코드라, 조용히 깨져 있으면 아무도 모른다. 민감도도 확인했다 — fetched_at 을 datetime(6) → datetime 으로 깎으면 실패한다(ddl-auto: validate 는 이 차이를 못 잡는다).

그 민감도 확인에서 거짓 통과를 한 번 봤다. "0건 실패"가 나왔는데 실은 gradle 이 실패하고 내가 직전 실행의 XML 을 읽고 있었다. 실패 수만 세면 "안 돌았다"와 "전부 통과"가 같은 값으로 보인다.

dev 실측

flyway V65 success · 스냅샷 행 payload=17272B · 17종 · 출처 URL 일치
GET /api/v1/templates → 200 · 17종 · thumbnailUrl 17종 · 스킴 위반 0
OpenAPI: previewUrl deprecated=true · previewCreated boolean
ERROR 0

배포 후 확인할 것

  • Flyway 64 → 65, template_catalog_snapshot 테이블 생성
  • 기동 후 첫 카탈로그 갱신에 스냅샷 행이 남는지
  • 운영 OpenAPI 에 previewCreated 등장 (FE 3단계의 선행 조건)
  • GET /api/v1/templates 200 · 17종 · ERROR 0

🤖 Generated with Claude Code

https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93

dldnsgkr and others added 4 commits September 26, 2026 01:11
Dvely_FE#117 이후 FE 다섯 화면(랜딩·갤러리·홈 탐색·생성 미리보기·프롬프트
얹기)이 전부 GET /api/v1/templates 하나만 본다. 카탈로그가 FE 전체의 단일
장애점이 됐는데 BE 는 두 군데가 비어 있었다.

1. 재시작마다 cold start 였다

캐시가 private volatile Snapshot 이라 메모리에만 있었다. 배포가 곧 재시작이고,
그 순간 GH Pages 가 request-timeout(5s) 안에 답하지 못하면 503 이다. 예전에는
갤러리만 깨졌다.

성공한 갱신을 template_catalog_snapshot(단일 행)에 남기고, cold start 에서
네트워크가 실패했을 때만 되살린다. 복원에는 출처 일치·나이 상한(기본 24h)·
스킴 재검사 세 조건이 걸리고, 하나라도 어긋나면 예전처럼 503 이다.

jar 에 폴백 카탈로그를 심는 안은 일부러 뺐다. 낡은 목록을 보여주면 없어진
템플릿을 고르게 되고, TemplateCatalogGuard 는 같은 스냅샷을 읽으니 통과시킨다
— "고를 때는 200, 만들 때는 실패"는 그 guard 가 존재하는 이유 그 자체다.
DB 스냅샷은 실제로 최근에 유효했던 값이고 나이 상한이 걸려 있다.

2. 카탈로그의 URL 을 아무도 검사하지 않았다

받은 JSON 을 CatalogDocument 로 바로 역직렬화해 통과시켰고 검사는 "비어 있나"
하나였다. FE 는 demoUrl 을 iframe src 에, thumbnailUrl 을 img src 에 넣는다.

수신 지점에서 http(s) 인지 본다. 나쁜 템플릿 하나를 빼지 않고 문서를 거부하고
stale-while-error 에 맡긴다 — 조용히 빼면 카탈로그를 고친 사람은 자기가 무엇을
잘못했는지 모른 채 그 템플릿이 사라진 것만 본다.

sourceUrl 은 포함하지 않는다. 셸 curl 에 들어가는 자리라 훨씬 좁은 검사가 이미
소비 지점에 걸려 있다. 싱크의 검사를 수신 지점으로 옮기거나 복제하지 않는다.

3. @transactional 을 붙이면 안 되는 이유를 테스트로 고정했다

처음엔 save 에 @transactional 을 붙였는데, 그러면 커밋을 프록시가 메서드 밖에서
하므로 플러시 실패가 try/catch 를 지나쳐 호출자에게 올라간다. cold start 라면
네트워크가 성공했는데도 503 이 된다 — 보조 장치가 본 기능을 죽인다.

이건 동작 테스트로 막을 수 없다. 단위 테스트에는 프록시가 없어서 붙인 채로도
통과한다(실제로 그렇게 한 번 거짓 통과했다). 그래서 애노테이션 부재를 직접
단정하는 테스트를 뒀다.

검증

전체 1757건 통과(기존 1740 + 신규 17), 실패 0.

역검증 6건 — 고친 것을 되돌리면 실제로 실패하는지 확인했다.
  스킴 검사 제거        → 3건 실패
  복원 호출 제거        → 2건 실패
  저장 호출 제거        → 5건 실패
  나이 상한 검사 제거   → 1건 실패 (단정 실패, NPE 아님)
  출처 일치 검사 제거   → 1건 실패 (단정 실패)
  @transactional 재부착 → 1건 실패

복원 왕복은 목이 아니라 진짜 Jackson 을 태운다. 복원은 평소에 돌지 않고 정작
필요한 순간에만 도는 코드라, 거기가 조용히 깨져 있으면 아무도 모른다.

V65 는 로컬 MySQL 에 실제로 적용해 ddl-auto: validate 통과를 확인했다
(Instant ↔ datetime(6)).


Claude-Session: https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#395 를 머지한 뒤 dev 에서 행을 직접 보다가 fetched_at 이 MySQL NOW() 와
9시간 차이나는 것을 봤다. 저장은 UTC 이고 NOW() 는 KST(JDBC URL 이
serverTimezone=Asia/Seoul)라서 그 자체는 정상이지만, 쓸 때와 읽을 때의 변환이
대칭인지는 확인된 적이 없었다.

기존 단위 테스트는 리포지토리를 목으로 두므로 JDBC 를 타지 않는다. 즉 나이
계산이 걸려 있는 구간이 테스트 밖에 있었다. 어긋나면 조용히 망가진다 —
나이가 부풀려지면 상한(기본 24h)에 먼저 걸려 복원이 안 되고, 줄어들면 너무
낡은 목록을 복원한다. 어느 쪽도 예외를 던지지 않는다.

실제 MySQL 위에서 다섯 가지를 본다.
  넣은 Instant 가 그대로 읽힌다 (정확히 같은 값)
  DB 를 거쳐 돌아온 나이가 실제 경과와 같다 (3시간 → 3시간)
  상한 판정이 실제 DB 값으로도 맞다 (25h 거부 / 상한 48h 로 늘리면 복원)
  두 번 저장하면 UPDATE 로 가고 행이 하나다 (트랜잭션 없이 merge)
  contentHints·tags·sourceUrl 까지 왕복한다

민감도도 확인했다. fetched_at 을 datetime(6) → datetime 으로 깎으면
"저장한 그 시각이 그대로 읽힌다" 가 실패한다.
  expected: 2026-09-25T15:45:34.659232Z
   but was: 2026-09-25T15:45:35Z
ddl-auto: validate 는 이 차이를 잡지 않는다.

그 민감도 확인에서 한 번 거짓 통과를 봤다 — 첫 시도에서 "0건 실패" 가 나왔는데,
gradle 은 실패했고 내가 직전 실행의 결과 XML 을 읽고 있었다. 결과 파일을 지우고
총 건수까지 세니 제대로 1건 실패로 나왔다. 실패 수만 세면 "테스트가 안 돌았다" 와
"전부 통과했다" 가 같은 값으로 보인다.

전체 1762건 통과(직전 1757 + 신규 5), 실패 0.


Claude-Session: https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
pending-actions.md
  §0 릴리스 현황 — 미릴리스 2건(#395, V65 포함이라 백업 필요)으로 갱신하고
     직전 릴리스(PR #394, #391+#390) 검증 결과를 남긴다
  §1-3 오리진 리전 — 09-26 측정을 세 번째 데이터 포인트로 추가. 사흘 연속이라
     일시적 사고로 볼 수 없다. 같은 시점 오리진 15/15 vs 엣지 9/15(SJC)
  §2-3 #318 — FE 갤러리도 프로덕션에 나갔으므로 닫을 수 있다
  §2-6 #392 신규 — previewUrl 이 계약 셋이라는 것과 FE 와 합의한 3단계 순서.
     dev 는 require-access-cookie 가 false 라 검증이 반드시 통과한다는 경고 포함
  §2-7 #395 신규 — 코드 완료, 릴리스 남음
  §4 FE 현황 — #115/#117/#118. 특히 #118 은 카탈로그 실패 시 다섯 화면이
     거짓말하던 것을 고쳤다(/project/new 가 멀쩡한 템플릿을 없어졌다고 단언)

ROADMAP.md §1 — 2026-09-26 항목. #395 두 구멍, jar 폴백을 뺀 이유,
@transactional 로 한 번 틀린 기록, 역검증 6건과 민감도 확인, 그 과정에서 본
거짓 통과("0건 실패"가 실은 낡은 XML), dev 실측.

api.md §17.3 확장 · §17.4 신규는 PR #396 에 함께 나갔다.


Claude-Session: https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…392 ] (#399)

#391 은 증상(승인 메시지의 죽는 링크)을 고쳤다. 뿌리는 같은 previewUrl 이름이
세 응답에 있고 계약이 서로 다른 것이다.

  POST /preview-sessions/{id}/access   최신 · 열린다 (쿠키 동시 발급)
  GET  /projects/{id}/preview-session  최신 · 쿠키를 이미 들고 있을 때만 → 401
  GET  /agent/tasks/{taskId}           낡은 스냅샷 · 안 열린다 → 404

"하나만 유효"가 아니라 "같은 이름, 서로 다른 계약 셋"이고 실패 코드도 갈린다.

이 커밋은 호환을 깨지 않는 1단계다. 태스크 응답에 previewCreated(boolean)를
추가하고 previewUrl 은 그대로 둔다. FE 는 main 머지가 곧 프로덕션이라 옮길
시점을 자기 쪽에서 정해야 하므로, 둘 다 나가는 기간을 먼저 만든다.

previewReady 가 아니라 previewCreated 인 이유

근거 컬럼 agent_run.preview_url 은 updateProgress·markDone 에서만 쓰이고
지우는 코드가 없다 — 프리뷰가 회수돼도 값이 남는다. 그래서 "ready" 는
거짓말이 된다. 컬럼이 실제로 말하는 것은 "이 작업에 프리뷰가 만들어졌다"
까지다. 지금 상태는 GET /projects/{id}/preview-session 의 status 가 말하고,
열 주소는 access 가 준다.

공백 문자열을 false 로 보는 것도 의도다. 옛 FE 가 previewUrl?.trim() 으로
공백을 걸러내던 판단을 서버가 대신한다 — 공백이 true 로 새면 FE 는 프리뷰가
생겼다고 믿고 폴링을 멈춘 뒤 아무것도 못 연다.

검증

전체 1765건 통과(직전 1762 + 신규 3), 실패 0.
역검증 2건 — blank 처리를 지우면 공백 테스트가, 항상 false 로 만들면
정상 케이스 테스트가 각각 실패한다.

문서

FRONTEND_API_GUIDE.md — 341행 표, 태스크 응답 경고에 previewCreated 안내와
  previewReady 가 아닌 이유, 세션 응답 previewUrl 의 실제 계약(낡지 않지만
  쿠키 없이는 401)과 dev 가 require-access-cookie=false 라 거짓 통과한다는 경고
api.md — 결과 필드에 같은 내용

남은 단계: ② FE 가 previewCreated 로 이전(시점은 FE 쪽 판단) ③ BE 가 옛
previewUrl 제거 + 세션 응답의 previewUrl 도 제거(FE 가 읽지 않는 것을 확인했다)


Claude-Session: https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dldnsgkr
dldnsgkr merged commit 3fa24a6 into main Sep 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant