[Template] 카탈로그를 재시작에 견디게 하고 URL 스킴을 검증한다 [ #395 ] - #396
Merged
Merged
Conversation
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)). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93
This was referenced Sep 25, 2026
dldnsgkr
added a commit
that referenced
this pull request
Sep 25, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dvely_FE#117(41a20f3)이 프로덕션에 들어가면서 FE 의 더미 템플릿 목록이 사라졌다. 이제 다섯 화면이 전부GET /api/v1/templates하나만 본다 — 랜딩 / 갤러리 / 홈 탐색 / 생성 미리보기 / 프롬프트 얹기.카탈로그가 FE 전체의 단일 장애점이 됐는데, BE 는 두 군데가 비어 있었다.
DB 마이그레이션 있음 — V65 (신규 테이블 하나, 기존 테이블 무변경).
1. 재시작마다 cold start 였다 — 배포가 곧 그 순간이다
캐시가
private volatile Snapshot이라 메모리에만 있었다.request-timeout: 5s안에 GH Pages 가 답하지 못하면 그 상태가 된다즉 "배포 직후 + GH Pages 불통"이 겹치면 FE 다섯 화면이 동시에 죽는다. #117 이전에는 갤러리만 깨졌다.
고친 방식
성공한 갱신을
template_catalog_snapshot(단일 행)에 남기고, cold start 에서 네트워크가 실패했을 때만 되살린다. 정상 경로는 건드리지 않는다.복원에 세 조건이 걸리고, 하나라도 어긋나면 예전처럼 503 이다.
snapshot-max-age(기본24h)저장·복원 어느 실패도 서빙을 막지 않는다. 저장 실패는 WARN, 복원 실패는 ERROR 를 남기고 이 기능이 없던 때와 똑같이 동작한다.
복원한 스냅샷의 시각은 받은 시각이 아니라 지금으로 찍는다. 원래 시각으로 두면 곧바로 stale 이 되어 매 요청이 죽은 네트워크를 다시 때린다. 나이 판단은 이미 끝났으니 여기서는 스케줄링만 생각한다.
jar 에 폴백 카탈로그를 심는 안은 일부러 뺐다
낡은 목록을 보여주면 사용자가 이미 없어진 템플릿을 고르고,
TemplateCatalogGuard는 같은 스냅샷을 읽으니 통과시킨다. 그러면 씨딩에 가서 깨진다 — "고를 때는 200, 만들 때는 실패"는 그 guard 가 존재하는 이유 그 자체다(TemplateCatalogGuard:10-11).DB 스냅샷은 실제로 최근에 유효했던 값이고 나이 상한이 걸려 있어서 이 문제를 되살리지 않는다. jar 에 얼어붙은 목록은 몇 달 전 것일 수 있다.
파일이 아니라 DB 인 이유: 인스턴스가 여러 대일 때 한 대가 받아둔 것을 재시작한 다른 대가 쓸 수 있어야 한다. 파일은 그 공유가 안 되고 인스턴스 교체도 못 넘는다.
2. 카탈로그의 URL 을 아무도 검사하지 않았다
refresh()가 외부 JSON 을 바로 역직렬화해 통과시켰고, 검사는 "비어 있나" 하나였다. 이름이 비슷한TemplateCatalogGuard는templateType존재 확인이라 무관하다.sourceUrlTemplateSeedingService:73이^https://[A-Za-z0-9._~:/-]+\.tar\.gz$demoUrlsrc에thumbnailUrlimg src에가장 위험한 자리(셸
curl)가 가장 잘 막혀 있었고, 브라우저로 나가는 두 개가 비어 있었다.나쁜 템플릿 하나를 빼지 않고 문서를 거부한다. 조용히 빼면 카탈로그를 고친 사람은 자기가 무엇을 잘못했는지 모른 채 그 템플릿이 사라진 것만 본다. 거부하면 stale-while-error 가 직전 목록을 유지하면서 WARN 을 남긴다 — 목록은 계속 나가고 문제는 보인다.
thumbnailUrl은 nullable 이다(#318). 없는 것은 통과, 있으면 검사 — 썸네일 발행 순서에 따라 API 전체가 깨지지 않게.http는 허용한다. 코드 실행 벡터는javascript:·data:이고http는 아니다. https 페이지의 iframe 이 http 를 물면 그건 브라우저가 mixed content 로 막는 일이다. 여기서 https 를 강제하면 보안은 나아지지 않고 로컬 템플릿 서버만 깨진다.sourceUrl은 이 검사에 넣지 않았다. 싱크에 붙은 좁은 검사를 수신 지점으로 옮기거나 느슨하게 복제하지 않는다.심각도
출처가 우리가 통제하는
Dvely/qeploy-templatesGH Pages 이고 https 다. 실제 위험은 외부 공격자보다build.mjs버그다. 급하지 않지만 싸고, 이 서버가 유일한 병목이라 여기서 막으면 앞으로 붙는 소비자까지 덮인다. FE 도 자기 쪽에서 막았지만(Dvely_FE#117), #391 에서 배운 것과 같다 — 방어가 한 곳에만 있으면 그곳을 거치지 않는 경로가 생긴다.3.
@Transactional을 붙이면 안 되는 이유 — 그리고 그게 거짓 통과했던 기록처음엔
save에@Transactional을 붙였다. 버그다.커밋을 프록시가 메서드 밖에서 한다. 그러면 플러시·커밋 시점의 실패가
try/catch를 그냥 지나쳐 호출자에게 올라간다. 호출자는 그것을 "카탈로그 갱신 실패"로 읽고, cold start 라면 네트워크가 성공했는데도 503 을 낸다. 보조 장치가 본 기능을 죽인다.그리고 이건 동작 테스트로 막을 수 없다. 단위 테스트에는 프록시가 없어서 붙인 채로도 예외가 얌전히 catch 에 잡히고 테스트가 통과한다 — 실제로
serviceSurvivesSnapshotSaveFailure가 그렇게 거짓 통과했다.그래서 트랜잭션을 걸지 않고
repository.save하나로 끝낸다. 안쪽 트랜잭션은 호출이 돌아오기 전에 커밋되므로 실패가 catch 에 잡힌다. 더티체킹으로 갱신하지 않는 이유도 같다 — 트랜잭션이 없으면 조회한 엔티티는 detached 라 변경이 반영되지 않는다. ID 가 고정값이므로save는 merge 로 가고 행이 있으면 UPDATE, 없으면 INSERT 다.그리고 애노테이션 부재를 직접 단정하는 테스트를 뒀다. 동작으로 못 잡으니 결정 자체를 고정한다.
검증
전체 1757건 통과(기존 1740 + 신규 17), 실패 0, skip 12.
역검증 — 고친 것을 되돌리면 실제로 실패하는가
@Transactional재부착나이·출처 검사는 store 안에 있어서 클라이언트 쪽 되돌리기로는 엉뚱한 이유(NPE)로 실패했다. 그래서 그 두 검사만 따로 떼어 되돌려, 진짜 단정 실패로 잡히는 것까지 확인했다.
복원 왕복은 진짜 Jackson 을 태운다
스냅샷 저장소를 목으로 갈아치우면 직렬화/역직렬화가 테스트를 타지 않는다. 그런데 복원 경로는 평소에 돌지 않고 정작 필요한 순간에만 도는 코드다 — 거기가 조용히 깨져 있으면 재시작 내구성이 없는 것이고 아무도 모른다. 그래서 리포지토리만 메모리로 바꾸고 store 는 진짜를 쓴다.
contentHints·sourceUrl·thumbnailUrl까지 왕복하는지 단정한다.마이그레이션
로컬 MySQL 에 실제로 적용해 확인했다.
ddl-auto: validate통과 —Instant↔datetime(6)매핑이 맞다.머지 직전
develop의 마지막 마이그레이션이 V64 임을 재확인했다(AGENTS.md 함정 1).문서
.notion/api.md§17.3 확장 + §17.4 신규.Closes #395
🤖 Generated with Claude Code
https://claude.ai/code/session_013y8USoCXTsRTATAhy88M93