[Preview] 자산마다 돌던 세션 UPDATE 를 60초당 1회로 · 해시 자산 캐시 [ #342 ] - #355
Merged
Conversation
프리뷰 게이트웨이는 요청마다 resolveGateway 를 지나고, 그 안에서 조회한 엔티티를 touch 로 고쳐 save 했다. 그래서 페이지 한 번의 로드가 문서 1 + 자산 N 개라면 같은 preview_sessions 행에 UPDATE 가 N+1 번 나가고 그만큼 쓰기 락이 잡혔다. 실측(실제 MySQL, performance_schema 다이제스트)으로 요청 20 건 → UPDATE 20 건이었다. 갱신을 시간 스로틀(60 초) 뒤로 보내고, 통과했을 때만 전용 @Modifying 단일 UPDATE (last_accessed_at · updated_at · expires_at 세 컬럼)로 내보낸다. 같은 20 건이 스로틀 안에서는 0 건, 스로틀을 넘긴 첫 요청이 있으면 1 건이 된다. "Sec-Fetch-Dest 가 문서 탐색일 때만 갱신" 쪽을 고르지 않았다. 두 가지 이유다. 열어둔 프리뷰가 XHR/SSE 로만 쓰이는 동안 연장이 끊겨 사용 중인 세션이 만료되고, 그 헤더는 게이트웨이의 인가 판정(문서 탐색에만 소유권 쿠키 요구)이 쓰는 신호라 세션 계층의 갱신 정책이 같은 입력에 얽히게 된다. 인가 경로는 손대지 않았다. 세션 조회 자체는 캐시하지 않는다 — accessToken 회전(소유자가 다시 열면 이전 주소는 404)을 판정하는 것이 그 조회이므로, 캐시는 유출된 주소의 수명을 늘린다. 유예(holdForBindingApproval)가 준 더 먼 만료를 앞당기지 않는 비교는 호출부에 남겨, 벌크 UPDATE 로 바뀐 뒤에도 같은 계약을 단정으로 고정했다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
createAndStartContainer·createDatabaseContainer 가 조건 없이 pullImageCmd 를 돌렸다. 이미 있는 이미지에도 레지스트리 왕복을 하고, 레지스트리가 느리거나 응답하지 않으면 awaitCompletion 상한인 3 분을 기다린 뒤에야 컨테이너 생성이 진행된다 — 프리뷰를 띄우는 사용자가 그 시간을 그대로 본다. inspectImageCmd 로 먼저 확인하고 없을 때만 pull 한다. 같은 처리를 코딩 에이전트 쪽은 이미 하고 있었다(CodingAgentContainerRunner#assertImagePresent). 다만 그쪽은 부재를 하드 실패로 본다 — 로컬 빌드 전용 이미지라 없으면 설정 오류이기 때문이다. 여기는 공개 베이스(node:20-alpine)라 첫 기동의 pull 이 정상 경로다. 선확인이 NotFound 가 아닌 이유로 실패하면 "없다"로 답해 pull 로 떨어진다 — 왕복을 줄이려는 변경이 새로운 실패 지점이 되지 않게 한다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
프리뷰·DB 컨테이너의 로그 드라이버에 상한이 없었다. 프리뷰 컨테이너는 사용자가 연결한 저장소를 실제로 빌드해 dev 서버로 돌리므로, 루프 안에서 한 줄을 찍는 코드가 TTL 동안 호스트 디스크를 채울 수 있다 — 그 디스크는 우리 것이다. HostConfig 에 json-file 드라이버와 max-size=10m · max-file=2 를 명시해 컨테이너 하나가 쓰는 로그를 20 MiB 로 묶는다. 드라이버를 못 박는 이유는 이 옵션이 json-file 의 것이기 때문이다 — 호스트 기본 드라이버가 다르면(journald 등) 옵션이 조용히 무시된다. 진단을 잃지 않는 근거: 로그 조회 API(getContainerLogs)는 꼬리만 읽으므로 필요한 것은 "최근"이고, 10 MiB 면 빌드 실패 원인을 찾기에 넉넉하다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
두 가지가 한 지점에 있었다. 응답: 모든 업스트림 응답을 ofByteArray 로 전량 버퍼링했다. 큰 이미지·번들 하나가 요청마다 그 크기만큼 힙을 쓰고, HTML 은 String 변환으로 한 번 더 복사됐다. 이제 비-HTML 은 ofInputStream 으로 받아 그대로 흘려보낸다. HTML 만 버퍼링하는 이유는 shim 주입·경로 재작성이 본문 전체를 봐야 해서다 — 그 문서도 8 MiB 상한을 두고, 넘으면 재작성을 포기하고 흘린다(백지·OOM 보다 낫다). 스트리밍 봉투를 StreamingResponseBody 가 아니라 InputStreamResource 로 뒀다. 전자는 MVC 비동기 디스패치를 켜는데, 이 앱에는 MVC 비동기 전용 executor 가 없어 SimpleAsyncTaskExecutor 가 요청마다 플랫폼 스레드를 새로 만든다 — 자산 수만큼 스레드가 생기니 버퍼링보다 나쁘다. Resource 는 요청 스레드에서 복사 버퍼로 흘러나가므로 톰캣 스레드 풀 경계를 그대로 둔다. SSE 경로(proxyEventStream)는 수가 적고 오래 사는 스트림이라 지금 형태가 맞아 손대지 않았다. 요청: readAllBytes() 로 무제한이었다. 이 경로는 서브리소스에 소유권 쿠키를 요구하지 않으므로(회전 accessToken 이 든 URL 자체가 자격) 유효한 프리뷰 주소 하나만 쥐면 로그인 없이 임의 크기 POST 를 보낼 수 있었다. 10 MiB 를 넘으면 413. Content-Length 를 먼저 보되 그것만 믿지 않는다 — 청크 전송은 길이를 안 싣고 실린 값이 사실이라는 보장도 없어, 실제로 읽는 양도 상한+1 로 끊는다. 인가 판정과 그 순서(세션 조회 → 쿠키/Sec-Fetch-Dest → 본문)는 그대로다. 버려지는 업스트림 응답은 본문을 닫아 커넥션을 반납한다(버퍼링 경로엔 없던 책임). Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
base 를 쓰는 프로젝트(GitHub Pages 용으로 /repo/ 를 커밋한 경우)는 자산마다 "틀린 경로로 먼저 묻고 → 접두를 벗겨 다시 묻는" 탐색을 반복했다. 자산 하나에 최대 3 회, 페이지 한 번이면 자산 수만큼 곱해진 컨테이너 왕복이다. 한 세션의 자산은 같은 빌드 산출물이라 base 도 하나다. 처음 알아낸 단수를 기억해 두 번째 자산부터 바로 적용한다 — 실측(테스트)으로 첫 자산 2 회, 그 뒤 1 회다. 기억이 틀리면(같은 세션에 base 가 다른 자산이 섞이면) 기억을 버리고 원래 경로로 돌아가 예전 탐색을 그대로 한다. 최적화가 자산을 깨뜨리지 않는다는 것이 이 되돌림의 목적이고, 테스트가 그 경로를 고정한다. 맵은 흡수가 일어난 세션만 담고(대다수 프로젝트는 들어오지 않는다) 상한 512 에 닿으면 통째로 비운다 — 세션 소멸을 알 길이 없으므로 무한 성장을 그렇게 막는다. 잃는 것은 다음 자산 한 번의 추가 왕복뿐이다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
전 자산이 no-store 라 해시가 박힌 불변 번들까지 새로고침마다 전량 재전송됐다. 이 경로에서 캐시의 유일한 위험은 public 이다. 프리뷰 주소의 accessToken 은 소유자가 다시 열 때마다 회전하고, 회전의 목적은 흘러나간 주소가 곧 죽는 것이다. 공유 캐시(앞단 CDN)가 응답을 담으면 원본을 거치지 않고 남에게 내주므로 그 회전이 무력화된다 — 그래서 어떤 분기에서도 public 을 쓰지 않고, 테스트가 그것을 고정한다. 브라우저 캐시는 회전을 약화시키지 않는다. 캐시 키가 토큰이 든 전체 URL 이라 회전 뒤의 문서는 새 토큰 주소를 참조해 캐시 미스가 되고(다시 인가를 받는다), 예전 주소의 캐시는 그 브라우저가 이미 유효한 토큰으로 받아간 바이트일 뿐이다. 세 갈래로 나눴다. - 문서: no-store, no-transform 그대로. 회전 토큰이 든 prefix 를 본문에 박아 내보내므로 담아두면 죽은 주소를 가리키는 문서가 되살아난다. 업스트림 ETag 도 넘기지 않는다 — 우리가 내보낸 본문(shim 주입)의 검증자가 아니다. - 내용 해시 자산: private, max-age=3600, immutable. - 그 밖의 자산: private, no-cache + 업스트림 ETag/Last-Modified 전달 + If-None-Match/If-Modified-Since 패스스루 + 304 릴레이. 매 요청이 원본에 닿으므로 세션 조회·인가·회전 판정은 예전과 똑같이 돌고, 본문만 안 흐른다. - 200·304 가 아니면 no-store. 404 를 담으면 컨테이너가 살아나도 깨진 화면이 남는다. 해시 판별은 좁게 잡았다: 번들러 산출물 확장자 + 구분자 뒤 16 진수 8 자 이상 + 그 안에 a~f 글자 하나 이상. 마지막 조건이 사람이 지은 날짜 이름을 걸러낸다 — photo-20260911.jpg 의 "20260911" 도 16 진수 8 자라, 이 조건이 없으면 파일을 갈아끼워도 한 시간 동안 예전 것이 보인다. 진짜 해시가 전부 숫자일 확률은 2% 남짓이고 걸러져도 재검증 경로로 가므로, 애매하면 캐시하지 않는 쪽이 기본값이다. 인가 판정(isAuthorized·Sec-Fetch-Dest·쿠키)은 한 줄도 건드리지 않았다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
이번 성능 변경이 보안 경계를 건드리지 않았다는 것을 회귀 가드로 남긴다. 인가 순서. 본문 상한(7-3)이 인가보다 앞에 오면, 소유권을 증명하지 못한 요청에 413 을 주게 되어 "이 프리뷰는 존재하고 본문만 컸다"를 알려주는 셈이고 인가 전에 본문을 만지기 시작한다는 뜻이다. 쿠키 없는 탐색은 11 MiB POST 여도 401, 없는 세션은 404, 조건부 요청(If-None-Match)을 들고 온 문서 탐색도 401 임을 고정한다. 스트리밍 봉투. ResponseEntity<Resource> 는 Spring 이 특별 취급한다 — Resource 를 돌려주면 Accept-Ranges 를 달고 Range 요청을 206 + ResourceRegion 으로 바꾸는데, 그 경로는 contentLength() 를 요구해 스트림 기반 자원의 본문을 깨뜨린다. InputStreamResource 만 그 취급에서 제외돼 있어(정확히 그 클래스일 때만) 지금은 안전하지만, 그것은 봉투 타입에 달린 성질이다. 다른 Resource 구현으로 바꾸면 프리뷰 앱의 <video>·PDF 같은 Range 요청이 조용히 깨지므로, 실제 MVC 를 태워 Range 요청도 200 + 전체 본문이라는 예전 동작을 고정한다. relay 의 Content-Type 설정이 두 분기에 중복돼 있던 것도 한 곳으로 모았다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
# Conflicts: # src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
6 tasks
Danto7632
added a commit
that referenced
this pull request
Sep 11, 2026
Issue #335~#345 / PR #346~#355 의 결과를 state.md §4.26 과 ROADMAP §1 에 적는다. 각 단위 담당이 병렬 충돌을 피해 문서를 남겨 둔 것을 머지 순서가 정해진 뒤 한 번에 반영한다. 수치는 전부 실측이다. 유휴 DB 쿼리는 10개 단위를 모두 머지한 develop 에서 다시 쟀다 (1,904 → 65/분). §2.34 의 "PR 미생성" 도 머지 사실로 정정했다. 다음 사람을 위해 이번에 드러난 사실 일곱 가지를 §4.26 에 남겼다 — 특히 유휴 비용의 99%가 SELECT 가 아니라 트랜잭션 의례였다는 것, Spring Data 의 IgnoreCase 가 인덱스를 죽이고 있었다는 것, *SchemaTest 도 ddl-auto=validate 도 인덱스를 검증하지 않는다는 것. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
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.
요약
프리뷰 프록시가 자산(JS/CSS/이미지) 하나마다 세션 행을 UPDATE 했다(#342). 페이지 1회 로드에 자산이 N개면 N번이다.
PreviewSessionService에서 충돌한다. 해소는 단순하다 — 양쪽이 서로 다른 메서드를 같은 자리에 넣었을 뿐이다(이 PR 의touchThrottled/keepFurther+ #351 의expire()javadoc). 통합 검증은 이미 마쳤다.7-1 실측 (실제 MySQL,
performance_schema다이제스트, 스키마 한정)다이제스트로도 확인: 전은 전컬럼 엔티티 UPDATE, 후는 3컬럼 UPDATE. 가드는
PreviewGatewayTouchWriteIntegrationTest— 구코드로 되돌리면 실패한다(20).이슈의 "3왕복" 은 실제로 2왕복이었다.
resolveGateway가 이미@Transactional이라save가 관리 엔티티 merge(추가 SELECT 없음)였다. 요청당 SELECT 1 + UPDATE 1. 쓰기 락 문제는 그대로 유효하다.Sec-Fetch-Dest대신 60초 시간 스로틀을 골랐다. 이유 둘:인가 경로는 한 줄도 건드리지 않았다. 세션 조회도 캐시하지 않는다 — 그 조회가 곧 회전 판정이라, 캐시는 유출 주소의 수명을 늘린다.
7-2 자산 캐시 — 회전 무력화 방어를 두 겹으로
전 자산이
no-store라 해시가 박힌 불변 자산까지 매번 재전송됐다. 열되, 토큰 회전을 무력화하지 않는 선까지만 열었다.public없음. 회전을 죽이는 유일한 경로가 공유 캐시(앞단 CDN)가 원본을 거치지 않고 남에게 내주는 것이라,private고정 + 테스트로 못박음no-store, no-transform그대로. 업스트림 ETag 도 넘기지 않는다 — 우리가 내보내는 건 shim 주입 재작성본이라 그 검증자가 아니다private, max-age=3600, immutable. 판별을 좁게: 번들러 확장자 + 구분자 뒤 16진수 8자 이상 + 그 안에 a~f 글자 하나 이상. 마지막 조건이photo-20260911.jpg("20260911" 도 16진수 8자)를 걸러낸다private, no-cache+ ETag/Last-Modified 전달 +If-None-Match패스스루 + 304 릴레이. 매 요청이 원본에 닿으므로 세션 조회·인가·회전 판정은 예전과 동일 빈도로 돌고, 본문만 안 흐른다no-store(404 를 캐시하면 컨테이너가 살아나도 깨진 화면이 남는다)잔여 위험: 손으로 지은 이름이 8자 이상 16진수 + 글자를 우연히 만족하고 내용이 바뀌면 최대 1시간 stale. 재현 조건이 좁고 하드 리로드로 해소된다.
나머지
7-3 비-HTML 은
ofInputStream스트리밍. 단StreamingResponseBody는 쓰지 않았다 — 이 앱엔 MVC 비동기 executor 가 없어SimpleAsyncTaskExecutor가 요청마다 플랫폼 스레드를 새로 만든다(자산 수만큼).InputStreamResource로 요청 스레드에서 흘려 톰캣 풀 경계를 유지했다(바이트코드로 Spring 의 Range 특별취급에서InputStreamResource가 제외됨을 확인하고 MVC 통과 테스트로 고정). HTML 만 버퍼링(8MiB 상한, 초과 시 재작성 포기·패스스루). 요청 본문 10MiB 초과 → 413(Content-Length 를 먼저 보되 실제 읽는 양도 상한+1로 끊음). SSE 경로는 손대지 않았다.7-4 세션당 흡수 단수 기억 — 첫 자산 2왕복, 이후 1왕복. 틀리면 기억을 버리고 예전 탐색으로 되돌린다. 맵은 흡수된 세션만 담고 상한 512 도달 시 전체를 비운다.
7-5
inspectImageCmd선확인 — NotFound 가 아닌 실패는 "없다" 로 답해 pull 로 떨어진다(새 실패 지점을 만들지 않는다). 7-6 프리뷰·DB 컨테이너에json-file/10m/2.검증
안 한 것
PreviewWorkspaceService·PreviewRuntimeLauncher·createAndStartContainer— Issue BB: [Preview] 프리뷰 컨테이너 사용자 코드 실행 격리 — 워크스페이스 소유자를 node 로 통일 (설계 확정, 미구현) #332 구역.DockerContainerService는 지목 지점만binding-approval-hold6시간 중 컨테이너 stop — Issue BB: [Preview] 프리뷰 컨테이너 사용자 코드 실행 격리 — 워크스페이스 소유자를 node 로 통일 (설계 확정, 미구현) #332 후속으로 제안.notion/미접촉Closes #342
https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr