diff --git a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java
index e5c85816..c22bd11e 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java
@@ -17,6 +17,7 @@
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HostConfig;
+import com.github.dockerjava.api.model.LogConfig;
import com.github.dockerjava.api.model.MemoryStatsConfig;
import com.github.dockerjava.api.model.Network;
import com.github.dockerjava.api.model.Ports;
@@ -80,6 +81,22 @@ public class DockerContainerService {
public static final long JAVA_MEMORY_LIMIT_BYTES = 2L << 30; // 2 GiB
private static final long NANO_CPUS = 1_000_000_000L; // 1.0 vCPU per session, fair-share
private static final long PIDS_LIMIT = 256L; // fork-bomb guard; ~4x observed npm install process counts
+
+ /**
+ * 컨테이너 로그 상한 (Issue #342, 7-6).
+ *
+ *
로그 드라이버에 상한이 없으면 dev 서버 stdout 이 TTL 동안 무제한으로 쌓인다 — 사용자 코드가
+ * 루프 안에서 찍는 로그 한 줄이 호스트 디스크를 채우는 경로이고, 그 컨테이너가 도는 것은 우리
+ * 호스트다. 크기 상한과 회전 개수를 둬 컨테이너 하나가 쓰는 로그를 유계로 만든다.
+ *
+ *
값의 근거: 로그 조회 API({@code getContainerLogs})는 꼬리만 읽으므로 진단에 필요한 것은
+ * "최근"뿐이다. 10 MiB × 2 개면 빌드 실패 원인을 찾기에 넉넉하고, 컨테이너당 20 MiB 로 묶인다.
+ * 드라이버를 {@code json-file} 로 명시하는 이유는 이 옵션이 그 드라이버의 것이라서다 — 호스트
+ * 기본 드라이버가 다르면(journald 등) 옵션이 조용히 무시된다.
+ */
+ private static final LogConfig BOUNDED_LOG_CONFIG = new LogConfig(
+ LogConfig.LoggingType.JSON_FILE,
+ Map.of("max-size", "10m", "max-file", "2"));
private static final String PREVIEW_NETWORK_NAME = "qeploy-preview";
// one-shot `stats` needs ~1s to sample a CPU delta (see getContainerStats); 3s is the
// point past which we degrade the /status response instead of blocking the caller.
@@ -174,6 +191,7 @@ public String createAndStartContainer(Long userId,
.withCapDrop(Capability.ALL)
.withCapAdd(Capability.CHOWN, Capability.SETUID, Capability.SETGID)
.withSecurityOpts(List.of("no-new-privileges"))
+ .withLogConfig(BOUNDED_LOG_CONFIG)
.withNetworkMode(PREVIEW_NETWORK_NAME))
.withLabels(labels)
.withCmd("tail", "-f", "/dev/null")
@@ -669,6 +687,7 @@ public String createDatabaseContainer(String networkName, String networkAlias, S
.withCapAdd(Capability.CHOWN, Capability.SETUID, Capability.SETGID,
Capability.DAC_OVERRIDE, Capability.FOWNER, Capability.SETFCAP)
.withSecurityOpts(List.of("no-new-privileges"))
+ .withLogConfig(BOUNDED_LOG_CONFIG)
.withNetworkMode(networkName))
.withAliases(networkAlias)
.withLabels(Map.of(AGENT_LABEL, "true"))
@@ -952,7 +971,24 @@ private void pullImageIfNeeded() {
pullImageIfNeeded(IMAGE);
}
+ /**
+ * 로컬에 없을 때만 pull 한다 (Issue #342, 7-5).
+ *
+ *
예전에는 컨테이너를 만들 때마다 조건 없이 {@code pullImageCmd} 를 돌렸다. 이미 있는
+ * 이미지에도 레지스트리 왕복을 하고, 네트워크가 느리거나 레지스트리가 응답하지 않으면 컨테이너
+ * 생성이 최대 3 분을 기다린 뒤에야 진행됐다 — 프리뷰를 띄우는 사용자가 그 시간을 그대로 본다.
+ * 같은 문제를 코딩 에이전트 쪽은 {@code inspectImageCmd} 선확인으로 이미 피하고 있다
+ * ({@code CodingAgentContainerRunner#assertImagePresent}).
+ *
+ *
다만 그쪽과 달리 여기서는 없으면 pull 한다. 코딩 에이전트 이미지는 로컬에서만 빌드하는
+ * 것이라 부재가 곧 설정 오류이지만, 이 이미지는 공개 베이스({@code node:20-alpine})라 첫 기동에
+ * 받아오는 것이 정상 경로다.
+ */
private void pullImageIfNeeded(String image) {
+ if (imagePresentLocally(image)) {
+ log.debug("Docker 이미지가 이미 로컬에 있음(pull 생략): {}", image);
+ return;
+ }
try {
dockerClient.pullImageCmd(image).start().awaitCompletion(3, TimeUnit.MINUTES);
log.info("Docker 이미지 준비 완료: {}", image);
@@ -961,6 +997,22 @@ private void pullImageIfNeeded(String image) {
}
}
+ /**
+ * 판정이 안 되면 "없다"로 답한다 — 그러면 호출부가 pull 로 떨어져 예전 동작이 된다. 선확인의
+ * 목적은 왕복을 줄이는 것이지 새로운 실패 지점을 만드는 것이 아니다.
+ */
+ private boolean imagePresentLocally(String image) {
+ try {
+ dockerClient.inspectImageCmd(image).exec();
+ return true;
+ } catch (NotFoundException e) {
+ return false;
+ } catch (RuntimeException e) {
+ log.debug("이미지 로컬 존재 확인 실패(pull 로 진행): image={} {}", image, e.getMessage());
+ return false;
+ }
+ }
+
private void putLabel(Map labels, String key, Object value) {
if (value != null) {
labels.put(key, String.valueOf(value));
diff --git a/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java b/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java
index 4b690eba..dffb7756 100644
--- a/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java
+++ b/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java
@@ -2,7 +2,9 @@
import com.example.dvely.preview.application.port.out.DeadPreviewSessionReclaimer;
import com.example.dvely.preview.application.result.PreviewSessionInfo;
+import java.io.ByteArrayInputStream;
import java.io.InputStream;
+import java.io.SequenceInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -11,6 +13,9 @@
import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -76,11 +81,47 @@ public class PreviewGatewayService {
*/
private static final Duration FETCH_TIMEOUT = Duration.ofSeconds(30);
+ /**
+ * 내용 해시가 박힌 불변 자산의 캐시 정책 (Issue #342, 7-2). {@code private} 는 협상 대상이 아니다 —
+ * 이유는 {@link #cacheControlFor} 참고.
+ */
+ private static final String IMMUTABLE_ASSET_CACHE = "private, max-age=3600, immutable";
+
+ /**
+ * 그 밖의 자산: 브라우저가 담아둘 수는 있으나 매번 원본에 물어봐야 한다. 그래서 세션 조회·
+ * 인가·토큰 회전 판정이 예전과 똑같이 요청마다 돌고, 바뀌지 않았으면 본문만 안 흐른다(304).
+ */
+ private static final String REVALIDATED_ASSET_CACHE = "private, no-cache";
+
+ /** 장기 캐시를 허용할 확장자 — 번들러가 내용 해시를 붙이는 산출물만. */
+ private static final java.util.Set IMMUTABLE_ASSET_EXTENSIONS = java.util.Set.of(
+ "js", "mjs", "cjs", "css", "map",
+ "woff", "woff2", "ttf", "otf", "eot",
+ "png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "ico");
+
+ /**
+ * 재작성을 위해 문서를 메모리에 모을 상한 (Issue #342, 7-3). 정상 {@code index.html} 은 수십 KB 라
+ * 이 상한과 자릿수가 다르다 — 여기 걸리는 것은 사용자 코드가 끝없이 내보내는 문서 쪽이다.
+ */
+ private static final int HTML_REWRITE_LIMIT_BYTES = 8 * 1024 * 1024;
+
private final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(CONNECT_TIMEOUT)
.build();
+ /**
+ * 세션당 흡수 단수를 기억하는 상한 (Issue #342, 7-4). 게이트웨이는 host-affine 이고 세션 TTL 은
+ * 30 분이라 동시에 살아 있는 세션 수는 이보다 훨씬 작다.
+ */
+ private static final int ABSORB_MEMO_CAPACITY = 512;
+
+ /**
+ * 세션 → 빌드 base 흡수 단수. 값이 있으면 그 세션의 자산은 선행 세그먼트를 그만큼 벗겨야 맞는다.
+ * base 가 없는 프로젝트(대다수)는 여기에 들어오지 않는다 — 흡수가 일어난 세션만 기록한다.
+ */
+ private final java.util.Map absorbedDepth = new java.util.concurrent.ConcurrentHashMap<>();
+
private final String contentSecurityPolicy;
private final boolean reclaimEnabled;
private final DeadPreviewSessionReclaimer reclaimer;
@@ -106,59 +147,68 @@ String sandboxPolicy() {
}
/** GET 편의 오버로드(본문 없음). 기존 호출부·테스트가 그대로 쓴다. */
- public ResponseEntity proxy(PreviewSessionInfo session,
- String gatewayPrefix,
- String path,
- String query) {
- return proxy("GET", session, gatewayPrefix, path, query, null, null);
+ public ResponseEntity proxy(PreviewSessionInfo session,
+ String gatewayPrefix,
+ String path,
+ String query) {
+ return proxy(session, gatewayPrefix, path, query, ProxiedRequest.get());
+ }
+
+ /**
+ * 게이트웨이가 컨테이너로 그대로 넘기는 요청 조각. 인자 수를 줄이려는 묶음이지 도메인 개념이 아니다.
+ */
+ public record ProxiedRequest(String method, byte[] body, String contentType,
+ String ifNoneMatch, String ifModifiedSince) {
+
+ public static ProxiedRequest get() {
+ return new ProxiedRequest("GET", null, null, null, null);
+ }
+
+ boolean isGet() {
+ return "GET".equalsIgnoreCase(method);
+ }
+
+ /** 조건부 헤더를 떼어낸 사본 — 문서·SPA 경로에는 넘기지 않기 위한 것(7-2). */
+ ProxiedRequest withoutConditions() {
+ return new ProxiedRequest(method, body, contentType, null, null);
+ }
}
/**
* 프리뷰 컨테이너로 요청을 프록시한다. GET 뿐 아니라 쓰기(POST/PUT/DELETE/PATCH)도 method·본문을 그대로
* 전달한다 — 에이전트가 만든 앱의 등록·폼이 동작하려면 필요하다. 응답의 HTML 재작성(base 흡수·경로 shim)은
* GET 문서에만 적용되고, 쓰기 응답(대개 JSON)은 그대로 돌려준다.
+ *
+ *
본문은 HTML 만 메모리에 모은다(Issue #342, 7-3). 예전에는 {@code ofByteArray} 로 모든
+ * 응답을 전량 버퍼링했다 — 큰 이미지·번들 하나가 요청마다 그 크기만큼 힙을 쓰고, HTML 은 String
+ * 변환으로 한 번 더 복사됐다. 이제 비-HTML 은 {@code ofInputStream} 으로 받아 그대로 흘려보낸다.
+ * HTML 만 버퍼링하는 이유는 shim 주입·경로 재작성이 본문 전체를 봐야 해서다.
+ *
+ *
스트리밍 봉투를 {@code StreamingResponseBody} 가 아니라 {@code InputStreamResource} 로 두는
+ * 것이 중요하다. 전자는 Spring MVC 의 비동기 디스패치를 켜고, 이 앱에는 MVC 비동기 전용 executor 가
+ * 없어 {@code SimpleAsyncTaskExecutor} 가 요청마다 플랫폼 스레드를 하나 새로 만든다 — 자산
+ * 수만큼 스레드가 생기는 셈이라 버퍼링보다 나쁘다(그래서 SSE 처럼 수가 적고 오래 사는 스트림에만
+ * 쓴다). {@code Resource} 는 {@code ResourceHttpMessageConverter} 가 요청 스레드에서 복사
+ * 버퍼로 흘려보내므로, 톰캣 스레드 풀 경계를 그대로 두고 버퍼링만 없앤다.
*/
- public ResponseEntity proxy(String method,
- PreviewSessionInfo session,
- String gatewayPrefix,
- String path,
- String query,
- byte[] requestBody,
- String requestContentType) {
+ public ResponseEntity proxy(PreviewSessionInfo session,
+ String gatewayPrefix,
+ String path,
+ String query,
+ ProxiedRequest request) {
+ HttpResponse upstream = null;
try {
String safePath = sanitizePath(path);
- HttpResponse response = fetch(method, session, safePath, query, requestBody, requestContentType);
- if ("GET".equalsIgnoreCase(method)) {
- response = absorbBuildBasePath(session, safePath, query, response);
- }
-
- String contentType = response.headers()
- .firstValue(HttpHeaders.CONTENT_TYPE)
- .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
- byte[] body = response.body();
- boolean html = contentType.contains(MediaType.TEXT_HTML_VALUE);
- if (html) {
- body = rewriteHtml(body, gatewayPrefix);
- }
- return ResponseEntity.status(response.statusCode())
- .header(HttpHeaders.CONTENT_TYPE, contentType)
- // HTML의 no-transform은 CDN이 문서를 건드리지 못하게 한다 (Issue #113).
- // Cloudflare는 이 zone의 HTML 응답에 자기 RUM beacon을 주입하는데, 프리뷰
- // 문서는 아래 sandbox로 불투명 오리진이라 그 beacon의 POST가 cross-origin이
- // 되어 콘솔에 CORS 에러만 남긴다(수집도 되지 않는다). 주입은 HTML에만
- // 일어나므로 HTML에만 붙여, 자산 응답의 압축은 그대로 둔다.
- .header(HttpHeaders.CACHE_CONTROL, html ? "no-store, no-transform" : "no-store")
- // HTML뿐 아니라 모든 프록시 응답에 붙인다. 프리뷰 앱이 자기 JS/워커를 어떤
- // Content-Type으로 내보내든 실행 컨텍스트는 동일하게 격리돼야 한다.
- // 불투명 오리진의 CORS 로드(module script 등, Issue #108)는 여기서가 아니라
- // SecurityConfig 의 /api/v1/previews/** 전용 CORS 설정이 허용한다 — 여기서
- // ACAO 를 또 달면 CorsFilter 의 것과 중복되어 브라우저가 거절한다.
- .header(CONTENT_SECURITY_POLICY, contentSecurityPolicy)
- .body(body);
+ upstream = request.isGet()
+ ? fetchForGet(session, safePath, query, request)
+ : fetch(session, safePath, query, request);
+ return relay(upstream, safePath, gatewayPrefix);
} catch (InterruptedException exception) {
+ closeQuietly(upstream);
Thread.currentThread().interrupt();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
} catch (Exception exception) {
+ closeQuietly(upstream);
// 안쪽 앱에 아예 도달하지 못했다(연결 거부/리셋). 컨테이너는 살아있어도 그 안의 서버 프로세스가
// 죽으면 이 자리에 온다 — attach·findCurrent 의 컨테이너-생존 확인으로는 못 걸러지는 사각이다.
// 한 번 더 빠르게 확인해 일시적 실패가 아니면 세션을 회수한다(EXPIRED + 컨테이너 제거). 그러면
@@ -171,6 +221,89 @@ public ResponseEntity proxy(String method,
}
}
+ /**
+ * 업스트림 응답을 브라우저로 넘긴다 — HTML 은 재작성해 버퍼로, 나머지는 스트림으로.
+ */
+ private ResponseEntity relay(HttpResponse upstream, String path, String gatewayPrefix)
+ throws java.io.IOException {
+ String contentType = upstream.headers()
+ .firstValue(HttpHeaders.CONTENT_TYPE)
+ .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+ boolean html = contentType.contains(MediaType.TEXT_HTML_VALUE);
+ ResponseEntity.BodyBuilder response = ResponseEntity.status(upstream.statusCode())
+ // HTML의 no-transform은 CDN이 문서를 건드리지 못하게 한다 (Issue #113).
+ // Cloudflare는 이 zone의 HTML 응답에 자기 RUM beacon을 주입하는데, 프리뷰
+ // 문서는 아래 sandbox로 불투명 오리진이라 그 beacon의 POST가 cross-origin이
+ // 되어 콘솔에 CORS 에러만 남긴다(수집도 되지 않는다). 주입은 HTML에만
+ // 일어나므로 HTML에만 붙여, 자산 응답의 압축은 그대로 둔다.
+ //
+ // 문서는 캐시하지 않는다(7-2): prefix(회전 토큰이 들어 있다)를 본문에 박아 내보내므로
+ // 담아두면 회전 뒤에 죽은 주소를 가리키는 문서가 되살아난다.
+ .header(HttpHeaders.CACHE_CONTROL,
+ html ? "no-store, no-transform" : cacheControlFor(upstream.statusCode(), path))
+ // HTML뿐 아니라 모든 프록시 응답에 붙인다. 프리뷰 앱이 자기 JS/워커를 어떤
+ // Content-Type으로 내보내든 실행 컨텍스트는 동일하게 격리돼야 한다.
+ // 불투명 오리진의 CORS 로드(module script 등, Issue #108)는 여기서가 아니라
+ // SecurityConfig 의 /api/v1/previews/** 전용 CORS 설정이 허용한다 — 여기서
+ // ACAO 를 또 달면 CorsFilter 의 것과 중복되어 브라우저가 거절한다.
+ .header(CONTENT_SECURITY_POLICY, contentSecurityPolicy);
+ if (!html) {
+ // 업스트림의 검증자를 그대로 넘겨 다음 요청이 조건부가 되게 한다. 재작성하는 문서에는
+ // 붙이지 않는다 — 그 검증자는 우리가 내보낸 본문의 것이 아니다(7-2).
+ forwardHeader(upstream, response, HttpHeaders.ETAG);
+ forwardHeader(upstream, response, HttpHeaders.LAST_MODIFIED);
+ }
+
+ if (upstream.statusCode() == HttpStatus.NOT_MODIFIED.value()) {
+ // 304 는 본문이 없다. 스트림을 닫아 커넥션만 반납하고 그대로 흘려보낸다 — 신선도 판정은
+ // 안쪽 앱이 했고, 게이트웨이는 인가를 거친 뒤 그 판정을 전달할 뿐이다.
+ closeQuietly(upstream);
+ return response.build();
+ }
+
+ // 304 가 아닌 응답에는 본문이 따라가므로 타입을 단다(304 는 본문이 없어 의미가 없다).
+ response.header(HttpHeaders.CONTENT_TYPE, contentType);
+ if (!html) {
+ // 업스트림이 길이를 알려줬으면 그대로 넘긴다(본문을 변형하지 않으므로 여전히 정확하다).
+ // 없으면 청크로 나간다 — 어느 쪽이든 본문은 힙을 거치지 않는다.
+ upstream.headers().firstValue(HttpHeaders.CONTENT_LENGTH)
+ .ifPresent(length -> response.header(HttpHeaders.CONTENT_LENGTH, length));
+ return response.body(new InputStreamResource(upstream.body()));
+ }
+
+ // 문서도 무제한으로 모으지는 않는다 — 이 본문을 만드는 것은 사용자 코드이고, 끝나지 않는
+ // 문서 하나가 힙을 통째로 먹을 수 있다. 상한을 넘으면 재작성을 포기하고 그대로 흘린다
+ // (shim 이 빠지는 것이 OOM 보다 낫고, 정상 index.html 은 이 상한과 자릿수가 다르다).
+ InputStream body = upstream.body();
+ byte[] document = body.readNBytes(HTML_REWRITE_LIMIT_BYTES + 1);
+ if (document.length > HTML_REWRITE_LIMIT_BYTES) {
+ log.warn("[PreviewGateway] 문서가 재작성 상한({} bytes)을 넘어 원본을 그대로 흘린다", HTML_REWRITE_LIMIT_BYTES);
+ return response.body(new InputStreamResource(
+ new SequenceInputStream(new ByteArrayInputStream(document), body)));
+ }
+ body.close();
+ byte[] rewritten = rewriteHtml(document, gatewayPrefix);
+ return response.contentLength(rewritten.length).body(new ByteArrayResource(rewritten));
+ }
+
+ private void forwardHeader(HttpResponse upstream,
+ ResponseEntity.BodyBuilder response,
+ String header) {
+ upstream.headers().firstValue(header).ifPresent(value -> response.header(header, value));
+ }
+
+ /** 버려지는 업스트림 응답의 본문을 닫는다 — 안 닫으면 커넥션이 반납되지 않는다. */
+ private void closeQuietly(HttpResponse response) {
+ if (response == null) {
+ return;
+ }
+ try {
+ response.body().close();
+ } catch (Exception ignored) {
+ // 이미 끊긴 스트림 — 닫기 실패는 알릴 것이 없다.
+ }
+ }
+
/**
* SSE({@code text/event-stream})를 스트리밍으로 프록시한다. 버퍼링 {@code proxy} 는 응답을
* {@code ofByteArray} 로 통째로 모아서 SSE 처럼 끝나지 않는 응답에선 영원히 막힌다 — 그래서 SSE 는
@@ -258,29 +391,95 @@ private boolean isInnerAppUnreachable(PreviewSessionInfo session) {
}
}
- /** GET 편의 오버로드(base 흡수의 내부 재시도용 — 본문 없음). */
- private HttpResponse fetch(PreviewSessionInfo session, String path, String query)
- throws java.io.IOException, InterruptedException {
- return fetch("GET", session, path, query, null, null);
- }
-
- private HttpResponse fetch(String method, PreviewSessionInfo session, String path, String query,
- byte[] body, String contentType)
+ private HttpResponse fetch(PreviewSessionInfo session, String path, String query,
+ ProxiedRequest request)
throws java.io.IOException, InterruptedException {
String target = "http://127.0.0.1:" + session.hostPort() + "/" + path;
if (query != null && !query.isBlank()) {
target += "?" + query;
}
+ byte[] body = request.body();
HttpRequest.BodyPublisher publisher = (body == null || body.length == 0)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body);
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
.timeout(FETCH_TIMEOUT)
- .method(method, publisher);
+ .method(request.method(), publisher);
+ String contentType = request.contentType();
if (contentType != null && !contentType.isBlank() && body != null && body.length > 0) {
builder.header(HttpHeaders.CONTENT_TYPE, contentType);
}
- return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
+ if (request.isGet()) {
+ // 브라우저의 조건부 요청을 안쪽 앱에 그대로 물어본다 — 바뀌지 않았으면 앱이 304 로 답하고
+ // 본문이 흐르지 않는다. 판정은 앱이 하므로 게이트웨이가 신선도를 추측하지 않는다(7-2).
+ forwardIfPresent(builder, HttpHeaders.IF_NONE_MATCH, request.ifNoneMatch());
+ forwardIfPresent(builder, HttpHeaders.IF_MODIFIED_SINCE, request.ifModifiedSince());
+ }
+ return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
+ }
+
+ private void forwardIfPresent(HttpRequest.Builder builder, String header, String value) {
+ if (value != null && !value.isBlank()) {
+ builder.header(header, value);
+ }
+ }
+
+ /**
+ * GET 한 건을 가져오되 빌드 base 어긋남을 흡수한다 — 같은 세션에서 두 번째 자산부터는 한 번에
+ * 맞힌다 (Issue #342, 7-4).
+ *
+ *
{@link #absorbBuildBasePath} 는 "틀린 경로로 먼저 물어보고, HTML 이 돌아오면 접두를 벗겨
+ * 다시 묻는다"로 동작한다. 그 탐색이 자산마다 반복되면 base 를 쓰는 프로젝트의 페이지 한 번은
+ * 자산 수 × 최대 3 회의 컨테이너 왕복이 된다. 한 세션의 자산은 같은 빌드 산출물이라 base 도
+ * 하나이므로, 처음 알아낸 단수를 기억해 다음 자산부터 바로 적용한다.
+ *
+ *
기억이 틀렸으면(같은 세션에 base 가 다른 자산이 섞인 경우) 기억을 버리고 원래 경로로 돌아가
+ * 예전 탐색을 그대로 한다 — 최적화가 자산을 깨뜨리지 않는다는 것이 이 되돌림의 목적이다.
+ */
+ private HttpResponse fetchForGet(PreviewSessionInfo session, String path, String query,
+ ProxiedRequest request)
+ throws java.io.IOException, InterruptedException {
+ // 조건부 헤더(If-None-Match 등)는 자산 경로에만 넘긴다 — 문서는 재작성해서 내보내므로 업스트림의
+ // 검증자와 우리가 준 본문이 같은 것을 가리키지 않고, 애초에 문서는 no-store 라 캐시가 없다(7-2).
+ ProxiedRequest asset = looksLikeStaticAsset(path) ? request : request.withoutConditions();
+ Integer remembered = absorbedDepth.get(session.sessionId());
+ if (remembered != null && looksLikeStaticAsset(path)) {
+ String shortcut = stripLeadingSegments(path, remembered);
+ if (shortcut != null) {
+ HttpResponse response = fetch(session, shortcut, query, asset);
+ if (!isHtml(response)) {
+ return response; // 기억한 단수로 한 번에 맞았다 — 왕복 1 회
+ }
+ closeQuietly(response);
+ absorbedDepth.remove(session.sessionId());
+ }
+ }
+ return absorbBuildBasePath(session, path, query, fetch(session, path, query, asset));
+ }
+
+ /** 선행 세그먼트 {@code count} 개를 벗긴다. 그만큼 벗길 수 없으면 null. */
+ private String stripLeadingSegments(String path, int count) {
+ String candidate = path;
+ for (int i = 0; i < count; i++) {
+ int slash = candidate.indexOf('/');
+ if (slash < 0 || slash == candidate.length() - 1) {
+ return null;
+ }
+ candidate = candidate.substring(slash + 1);
+ }
+ return candidate;
+ }
+
+ /**
+ * 세션은 TTL 로 사라지지만 이 맵은 그 소멸을 알지 못한다. 상한에 닿으면 통째로 비운다 — 잃는
+ * 것은 "다음 자산 한 번의 추가 왕복"뿐이고, 그 대가로 무한 성장을 막는다. base 를 쓰지 않는
+ * 프로젝트(대다수)는 애초에 여기 들어오지 않으므로 실제 크기는 훨씬 작다.
+ */
+ private void rememberAbsorbedDepth(PreviewSessionInfo session, int depth) {
+ if (absorbedDepth.size() >= ABSORB_MEMO_CAPACITY) {
+ absorbedDepth.clear();
+ }
+ absorbedDepth.put(session.sessionId(), depth);
}
/**
@@ -303,10 +502,10 @@ private HttpResponse fetch(String method, PreviewSessionInfo session, St
*
재시도가 실패하면 원래 응답을 그대로 돌려준다. 앱이 의도적으로 확장자 경로에서 HTML을
* 내보내는 경우(SPA가 처리하는 가짜 경로 등)에도 동작이 달라지지 않는다.
{@code private} 가 이 기능의 안전 조건이다. 프리뷰 주소의 accessToken 은 소유자가
+ * 프리뷰를 다시 열 때마다 회전하고, 회전의 목적은 "흘러나간 주소가 곧 죽는 것"이다. 응답이
+ * {@code public} 이면 앞단 CDN 같은 공유 캐시가 그 응답을 담아 원본을 거치지 않고 남에게
+ * 내주게 되고, 그러면 회전해도 캐시가 만료될 때까지 예전 주소가 계속 열린다 — 회전이 무력화되는
+ * 유일한 경로가 그것이라, 여기서는 어떤 분기에서도 {@code public} 을 쓰지 않는다.
+ *
+ *
브라우저 캐시만 남는 것은 회전을 약화시키지 않는다. 캐시 키가 토큰이 든 전체 URL 이므로
+ * 회전 후의 문서는 새 토큰 주소를 참조해 캐시 미스가 되고(다시 인가를 받는다), 예전 주소의
+ * 캐시는 그 브라우저가 이미 유효한 토큰으로 받아간 바이트일 뿐 새로 읽을 수 있는 것이
+ * 늘지 않는다.
+ *
+ *
200·304 가 아니면 캐시하지 않는다 — 404·502 를 캐시에 남기면 컨테이너가 되살아난 뒤에도
+ * 깨진 화면이 유지된다.
판별을 좁게 잡는다. 확장자가 번들러 산출물의 것이어야 하고, 구분자({@code -} 또는 {@code .})
+ * 뒤 확장자까지가 16 진수 8 자 이상이어야 하며, 그 안에 a~f 글자가 하나라도 있어야 한다.
+ * 마지막 조건이 날짜를 걸러낸다 — {@code photo-20260911.jpg} 의 "20260911" 도 16 진수 8 자라,
+ * 이 조건이 없으면 사람이 붙인 이름이 불변으로 취급돼 파일을 갈아끼워도 한 시간 동안 예전 것이
+ * 보인다. 진짜 내용 해시가 8 자 모두 숫자일 확률은 2% 남짓이고, 걸러져도 아래 재검증 경로로
+ * 가므로 손해가 없다 — 애매하면 캐시하지 않는 쪽이 이 판별의 기본값이다.
+ */
+ private boolean isContentHashedAsset(String path) {
+ String segment = lastSegment(path);
+ int dot = segment.lastIndexOf('.');
+ if (dot <= 0) {
+ return false;
+ }
+ if (!IMMUTABLE_ASSET_EXTENSIONS.contains(
+ segment.substring(dot + 1).toLowerCase(java.util.Locale.ROOT))) {
+ return false;
+ }
+ int separator = Math.max(segment.lastIndexOf('-', dot), segment.lastIndexOf('.', dot - 1));
+ if (separator <= 0) {
+ return false;
+ }
+ String hash = segment.substring(separator + 1, dot);
+ if (hash.length() < 8) {
+ return false;
+ }
+ boolean hasHexLetter = false;
+ for (int i = 0; i < hash.length(); i++) {
+ int digit = Character.digit(hash.charAt(i), 16);
+ if (digit < 0) {
+ return false;
+ }
+ hasHexLetter |= digit > 9;
+ }
+ return hasHexLetter;
+ }
+
+ private String lastSegment(String path) {
+ int lastSlash = path.lastIndexOf('/');
+ return lastSlash < 0 ? path : path.substring(lastSlash + 1);
+ }
+
/**
* 마지막 세그먼트에 확장자가 있고 그것이 HTML이 아니면 정적 자산 요청으로 본다.
* SPA 라우트({@code /todos/42})는 확장자가 없어 걸리지 않으므로 fallback 동작을 건드리지 않는다.
*/
private boolean looksLikeStaticAsset(String path) {
- int lastSlash = path.lastIndexOf('/');
- String lastSegment = lastSlash < 0 ? path : path.substring(lastSlash + 1);
+ String lastSegment = lastSegment(path);
int dot = lastSegment.lastIndexOf('.');
if (dot <= 0 || dot == lastSegment.length() - 1) {
return false;
@@ -344,7 +613,7 @@ private boolean looksLikeStaticAsset(String path) {
return !extension.equals("html") && !extension.equals("htm");
}
- private boolean isHtml(HttpResponse response) {
+ private boolean isHtml(HttpResponse> response) {
return response.headers()
.firstValue(HttpHeaders.CONTENT_TYPE)
.filter(type -> type.contains(MediaType.TEXT_HTML_VALUE))
diff --git a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
index 720c2e0a..7737ffdc 100644
--- a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
+++ b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
@@ -30,6 +30,12 @@
@RequiredArgsConstructor
public class PreviewSessionService implements DeadPreviewSessionReclaimer {
+ /**
+ * 게이트웨이 접근의 만료 연장을 이 간격으로 묶는다 (Issue #342, 7-1). 프리뷰 페이지 한 번의
+ * 로드가 자산 수만큼 같은 행을 UPDATE 하던 것을 이 간격당 한 번으로 줄인다.
+ */
+ private static final Duration TOUCH_THROTTLE = Duration.ofSeconds(60);
+
private final SpringDataPreviewSessionRepository repository;
private final DockerContainerService dockerService;
private final TaskStore taskStore;
@@ -246,6 +252,19 @@ public PreviewAccessGrant grantAccess(String sessionId, Long ownerUserId, Durati
);
}
+ /**
+ * 게이트웨이가 요청마다 부르는 세션 조회.
+ *
+ *
여기서 엔티티를 고치지 않는다는 것이 7-1 의 핵심이다(Issue #342). 예전에는 조회한
+ * 엔티티를 {@code touch} 로 고쳐 {@code save} 했고, 그 결과 프리뷰 페이지가 끌어오는 자산
+ * 하나하나(JS/CSS/이미지)마다 이 행에 더티 체크 UPDATE 와 쓰기 락이 걸렸다 — 자산이 N 개인
+ * 페이지 한 번에 UPDATE N 번이다. 이제 갱신은 {@link #touchThrottled} 가 스로틀을 통과할 때만
+ * 단일 UPDATE 로 나간다.
+ *
+ *
세션 조회 자체는 캐시하지 않는다. accessToken 은 소유자가 프리뷰를 다시 열 때마다
+ * 회전하고(이전 주소는 그 순간 404) 그 판정이 이 조회다. 캐시를 두면 회전이 다음 만료까지 미뤄져
+ * 유출된 주소의 수명을 늘리게 된다 — 줄일 수 있는 것은 쓰기뿐이다.
+ */
@Transactional
public Optional resolveGateway(String sessionId, String accessToken) {
return repository.findByIdAndAccessTokenAndStatus(
@@ -254,8 +273,13 @@ public Optional resolveGateway(String sessionId, String acce
PreviewSessionStatus.ACTIVE.name()
)
.filter(session -> session.getExpiresAt().isAfter(LocalDateTime.now()))
- .map(this::touch)
- .map(PreviewSessionEntity::toInfo);
+ .map(session -> {
+ touchThrottled(session);
+ // 갱신을 벌크 UPDATE 로 보냈으므로 이 엔티티의 expiresAt 은 갱신 전 값이다.
+ // 게이트웨이는 sessionId·ownerUserId·hostPort 만 쓰므로 문제가 없고, 만료를
+ // 응답에 싣는 경로(findCurrent·grantAccess)는 각자 따로 읽는다.
+ return session.toInfo();
+ });
}
/**
@@ -344,11 +368,36 @@ public void cleanupExpired() {
* 12:43 — 30분 뒤였다).
*/
private PreviewSessionEntity touch(PreviewSessionEntity session) {
- LocalDateTime next = nextExpiry();
- session.touch(next.isAfter(session.getExpiresAt()) ? next : session.getExpiresAt());
+ session.touch(keepFurther(nextExpiry(), session));
return repository.save(session);
}
+ /**
+ * 게이트웨이 접근의 만료 연장 — {@link #TOUCH_THROTTLE} 안에 이미 갱신됐으면 건너뛴다 (7-1).
+ *
+ *
"문서 탐색({@code Sec-Fetch-Dest})일 때만 갱신" 대신 시간 스로틀을 고른 이유는 두 가지다.
+ * 하나는 동작 보존이다 — 문서 탐색만 갱신하면, 열어둔 프리뷰가 XHR/SSE 로만 계속 쓰이는
+ * 동안에는 연장이 끊겨 사용 중인 세션이 만료된다. 다른 하나는 보안 경계다: {@code
+ * Sec-Fetch-Dest} 는 게이트웨이의 인가 판정(문서 탐색에만 소유권 쿠키 요구)이 쓰는 신호이므로,
+ * 세션 계층이 같은 헤더를 갱신 정책에 쓰기 시작하면 두 판정이 한 입력에 얽힌다.
+ *
+ *
TTL 은 30 분이므로 60 초 스로틀이 실제로 깎는 연장은 최대 60 초다. 그 대가로 자산 N 개의
+ * UPDATE N 번이 60 초당 1 번이 된다.
+ */
+ private void touchThrottled(PreviewSessionEntity session) {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime staleBefore = now.minus(TOUCH_THROTTLE);
+ if (!session.getLastAccessedAt().isBefore(staleBefore)) {
+ return;
+ }
+ repository.touchAccess(session.getId(), now, keepFurther(nextExpiry(), session), staleBefore);
+ }
+
+ /** 이미 걸려 있는 만료가 더 멀면 그것을 유지한다(유예 보존). */
+ private static LocalDateTime keepFurther(LocalDateTime next, PreviewSessionEntity session) {
+ return next.isAfter(session.getExpiresAt()) ? next : session.getExpiresAt();
+ }
+
/**
* 컨테이너 제거를 저장보다 먼저 한다. 예전에는 저장이 먼저였고, 제거가 실패하면
* 트랜잭션 롤백이 상태 변경까지 되돌려 세션이 ACTIVE 로 남아 다음 기회에 다시 회수됐다.
diff --git a/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java b/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java
index 17c7836f..448046d7 100644
--- a/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java
+++ b/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java
@@ -6,6 +6,9 @@
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SpringDataPreviewSessionRepository extends JpaRepository {
@@ -45,4 +48,30 @@ Optional findFirstByProjectIdAndOwnerUserIdAndStatusInOrde
// 하나만 남기고 나머지는 스스로 물러난다. ProjectPreviewService#provision 참고.
List findByProjectIdAndOwnerUserIdAndStatusIn(
Long projectId, Long ownerUserId, Collection statuses);
+
+ /**
+ * 게이트웨이 접근 흔적만 갱신하는 단일 UPDATE (Issue #342, 7-1).
+ *
+ *
엔티티를 고쳐 {@code save} 하는 대신 UPDATE 한 문장을 쓰는 이유는 호출 빈도다. 이
+ * 갱신은 프리뷰 페이지가 끌어오는 자산 하나하나마다 불린다 — 엔티티 경로는 그 요청마다 더티 체크
+ * UPDATE 와 쓰기 락을 만들었다.
+ *
+ *
{@code lastAccessedAt < :staleBefore} 조건이 스로틀 자체다. 호출부도 같은 조건을
+ * 미리 보고 대부분을 걸러내지만, 같은 페이지의 자산 요청 여럿이 동시에 같은 낡은 행을 읽었을 때는
+ * 그 확인이 전부 통과한다. MySQL 의 UPDATE 는 현재 커밋 값을 다시 읽으므로 그 중 먼저 커밋한
+ * 하나만 조건에 맞고 나머지는 0 행으로 끝난다.
+ *
+ *
{@code expiresAt} 은 호출부가 이미 정한 값을 그대로 받는다 — 유예({@code
+ * holdForBindingApproval})가 준 더 먼 만료를 앞당기지 않기 위한 비교는 호출부에 남는다.
+ * {@code updatedAt} 은 명시적으로 넣는다: 벌크 UPDATE 는 {@code @UpdateTimestamp} 를 거치지
+ * 않으므로 안 넣으면 이 경로에서만 갱신 시각이 멈춘다.
+ */
+ @Modifying(flushAutomatically = false, clearAutomatically = false)
+ @Query("update PreviewSessionEntity s "
+ + "set s.lastAccessedAt = :now, s.updatedAt = :now, s.expiresAt = :expiresAt "
+ + "where s.id = :sessionId and s.lastAccessedAt < :staleBefore")
+ int touchAccess(@Param("sessionId") String sessionId,
+ @Param("now") LocalDateTime now,
+ @Param("expiresAt") LocalDateTime expiresAt,
+ @Param("staleBefore") LocalDateTime staleBefore);
}
diff --git a/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java b/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java
index 3dbdf5ba..dd5b6c25 100644
--- a/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java
+++ b/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java
@@ -13,6 +13,8 @@
import java.util.Locale;
import java.util.Set;
import lombok.RequiredArgsConstructor;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -33,6 +35,12 @@ public class PreviewGatewayController {
static final String SEC_FETCH_DEST = "Sec-Fetch-Dest";
+ /**
+ * 요청 본문 상한 (Issue #342, 7-3). 프리뷰 앱의 폼·업로드가 쓰기에 충분하면서, 컨테이너 메모리
+ * 상한(1 GiB)에 비해 작다.
+ */
+ private static final int MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
+
/** 문서를 "여는" 요청들 — iframe/새 탭 진입과 그 변종. 여기에만 소유권 쿠키를 요구한다. */
private static final Set NAVIGATION_DESTS = Set.of("document", "iframe", "frame", "embed", "object");
@@ -56,7 +64,7 @@ public class PreviewGatewayController {
"/api/v1/previews/{sessionId}/{accessToken}",
"/api/v1/previews/{sessionId}/{accessToken}/**"
})
- public ResponseEntity proxy(
+ public ResponseEntity proxy(
@Parameter(description = "Preview 세션 ID") @PathVariable String sessionId,
@Parameter(description = "세션 발급 시 함께 생성된 1회성 접근 토큰(랜덤 UUID)") @PathVariable String accessToken,
@CookieValue(name = PreviewAccessCookies.COOKIE_NAME, required = false) String accessCookie,
@@ -75,21 +83,56 @@ public ResponseEntity proxy(
// 본문을 다 읽지 못하면(클라이언트 중단 등) 400 — 컨테이너로 반쪽 요청을 보내지 않는다.
byte[] body;
try {
- body = request.getInputStream().readAllBytes();
+ body = readBoundedBody(request);
+ } catch (RequestBodyTooLargeException e) {
+ return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build();
} catch (java.io.IOException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
return previewGatewayService.proxy(
- request.getMethod(),
session,
prefix,
path,
request.getQueryString(),
- body,
- request.getContentType()
+ new PreviewGatewayService.ProxiedRequest(
+ request.getMethod(), body, request.getContentType(),
+ // 브라우저의 조건부 요청을 안쪽 앱까지 전달한다 — 자산이 안 바뀌었으면 304 로
+ // 끝나 본문이 흐르지 않는다(Issue #342, 7-2). 인가는 예전과 똑같이 매 요청 돈다.
+ request.getHeader(HttpHeaders.IF_NONE_MATCH),
+ request.getHeader(HttpHeaders.IF_MODIFIED_SINCE))
);
}
+ /**
+ * 요청 본문에 상한을 둔다 (Issue #342, 7-3).
+ *
+ *
예전에는 {@code readAllBytes()} 로 무제한으로 읽었다. 이 경로는 서브리소스 요청에 소유권
+ * 쿠키를 요구하지 않으므로(회전 accessToken 이 든 URL 자체가 자격 — {@link #isAuthorized} 참고)
+ * 유효한 프리뷰 주소 하나만 쥐면 로그인 없이 임의 크기의 POST 를 보낼 수 있었고, 그 본문이
+ * 곧 힙이다. 상한을 넘으면 413 으로 끊는다.
+ *
+ *
{@code Content-Length} 를 먼저 보되 그것만 믿지는 않는다 — 청크 전송은 길이를 안 싣고, 실린
+ * 값이 사실이라는 보장도 없다. 그래서 실제로 읽는 양도 상한+1 로 끊는다. 상한은 프리뷰 앱의 폼·
+ * 업로드가 쓰기에 충분하고(10 MiB), 컨테이너 메모리 상한(1 GiB)에 비해 작다.
+ */
+ private byte[] readBoundedBody(HttpServletRequest request) throws java.io.IOException {
+ if (request.getContentLengthLong() > MAX_REQUEST_BODY_BYTES) {
+ throw new RequestBodyTooLargeException();
+ }
+ byte[] body = request.getInputStream().readNBytes(MAX_REQUEST_BODY_BYTES + 1);
+ if (body.length > MAX_REQUEST_BODY_BYTES) {
+ throw new RequestBodyTooLargeException();
+ }
+ return body;
+ }
+
+ /** 413 으로 갈라 나가기 위한 내부 신호. 밖으로 나가지 않으므로 스택트레이스를 만들지 않는다. */
+ private static class RequestBodyTooLargeException extends RuntimeException {
+ RequestBodyTooLargeException() {
+ super(null, null, false, false);
+ }
+ }
+
@Operation(
summary = "Preview 컨테이너 SSE 스트리밍 프록시",
description = "EventSource(text/event-stream) 요청을 스트리밍으로 프록시합니다. 버퍼링 프록시(위 proxy)는 " +
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java
index b123f0a5..1b7ba3f7 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java
@@ -20,6 +20,7 @@
import com.github.dockerjava.api.command.CreateNetworkCmd;
import com.github.dockerjava.api.command.InspectContainerCmd;
import com.github.dockerjava.api.command.InspectContainerResponse;
+import com.github.dockerjava.api.command.InspectImageCmd;
import com.github.dockerjava.api.command.InspectNetworkCmd;
import com.github.dockerjava.api.command.ListNetworksCmd;
import com.github.dockerjava.api.command.LogContainerCmd;
@@ -36,6 +37,7 @@
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HostConfig;
+import com.github.dockerjava.api.model.LogConfig;
import com.github.dockerjava.api.model.MemoryStatsConfig;
import com.github.dockerjava.api.model.Network;
import com.github.dockerjava.api.model.NetworkSettings;
@@ -311,6 +313,75 @@ void createAndStartContainerIgnoresConcurrentNetworkCreateConflict() {
verify(dockerClient).startContainerCmd("container-1");
}
+ // --- Issue #342 7-5: 로컬에 있는 이미지는 pull 하지 않는다 ---------------------------
+
+ /**
+ * 예전에는 컨테이너를 만들 때마다 조건 없이 pull 했다. 이미 있는 이미지에도 레지스트리 왕복을
+ * 하고, 레지스트리가 느리면 컨테이너 생성이 최대 3 분을 기다린다 — 프리뷰를 띄우는 사용자가 그
+ * 시간을 그대로 본다. 선확인이 성공하면 pull 이 아예 나가지 않아야 한다.
+ */
+ @Test
+ void createAndStartContainerSkipsThePullWhenTheImageIsAlreadyLocal() {
+ mockNetworkAlreadyExists(true);
+ when(dockerClient.inspectImageCmd(anyString())).thenReturn(mock(InspectImageCmd.class));
+ mockContainerCreation();
+
+ service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1");
+
+ verify(dockerClient, never()).pullImageCmd(anyString());
+ }
+
+ /**
+ * 반대로 없으면 받아온다 — 이 이미지는 공개 베이스(node:20-alpine)라 첫 기동에 pull 하는 것이
+ * 정상 경로다(로컬 빌드 전용인 코딩 에이전트 이미지와 다른 점).
+ */
+ @Test
+ void createAndStartContainerStillPullsWhenTheImageIsMissing() {
+ mockNetworkAlreadyExists(true);
+ InspectImageCmd inspect = mock(InspectImageCmd.class);
+ when(dockerClient.inspectImageCmd(anyString())).thenReturn(inspect);
+ when(inspect.exec()).thenThrow(new NotFoundException("no such image"));
+ mockContainerCreation();
+
+ service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1");
+
+ verify(dockerClient).pullImageCmd(anyString());
+ }
+
+ // --- Issue #342 7-6: 컨테이너 로그에 상한이 있다 -------------------------------------
+
+ /**
+ * 로그 드라이버에 상한이 없으면 dev 서버 stdout 이 TTL 동안 무제한으로 쌓인다 — 사용자 코드가
+ * 루프에서 찍는 로그 한 줄이 우리 호스트 디스크를 채우는 경로다. 드라이버까지 json-file 로 못
+ * 박아야 옵션이 조용히 무시되지 않는다.
+ */
+ @Test
+ void createAndStartContainerBoundsTheContainerLogSize() {
+ mockNetworkAlreadyExists(true);
+ mockContainerCreation();
+
+ service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1");
+
+ ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class);
+ verify(createCommand).withHostConfig(hostConfigCaptor.capture());
+ LogConfig logConfig = hostConfigCaptor.getValue().getLogConfig();
+ assertThat(logConfig).isNotNull();
+ assertThat(logConfig.getType()).isEqualTo(LogConfig.LoggingType.JSON_FILE);
+ assertThat(logConfig.getConfig()).containsEntry("max-size", "10m").containsEntry("max-file", "2");
+ }
+
+ /** 컨테이너 생성·기동 스텁. 위 세 테스트가 captor 로 쓰는 createCommand 를 남긴다. */
+ private CreateContainerCmd createCommand;
+
+ private void mockContainerCreation() {
+ createCommand = mock(CreateContainerCmd.class, RETURNS_SELF);
+ CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
+ when(dockerClient.createContainerCmd(anyString())).thenReturn(createCommand);
+ when(createCommand.exec()).thenReturn(createResponse);
+ when(createResponse.getId()).thenReturn("container-1");
+ when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class));
+ }
+
/**
* @param iccDisabled whether the mocked inspect response reports the isolation option as
* actually applied; the exact-match network is always found either way.
diff --git a/src/test/java/com/example/dvely/preview/PreviewGatewayTouchWriteIntegrationTest.java b/src/test/java/com/example/dvely/preview/PreviewGatewayTouchWriteIntegrationTest.java
new file mode 100644
index 00000000..e65c4362
--- /dev/null
+++ b/src/test/java/com/example/dvely/preview/PreviewGatewayTouchWriteIntegrationTest.java
@@ -0,0 +1,127 @@
+package com.example.dvely.preview;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.example.dvely.preview.application.service.PreviewSessionService;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * 7-1 (Issue #342) 의 실측 가드 — 프리뷰 페이지 1 회 로드가 {@code preview_sessions} 에
+ * 보내는 UPDATE 횟수를 실제 MySQL 에서 센다.
+ *
+ *
왜 단위 테스트로 부족한가: 예전 경로의 비용은 "엔티티를 고쳐 {@code save} 했다"가 아니라
+ * 그 결과로 나간 SQL 이다. 모킹된 리포지토리는 그 SQL 을 보여주지 않는다. 그래서
+ * {@code performance_schema} 의 문장 다이제스트 카운터를 전/후로 읽어 증가분을 센다.
+ *
+ *
세는 범위를 {@code SCHEMA_NAME = DATABASE()} 로 좁힌다 — 이 MySQL 컨테이너는 병렬 워크트리가
+ * 스키마만 달리해 공유하므로, 스키마를 안 좁히면 남의 테스트가 섞인다. 같은 스키마 안에서는
+ * {@code cleanupExpired} 스케줄러가 남의 테스트가 남긴 만료 행을 정리하며 UPDATE 를 낼 수 있어,
+ * 단정은 "요청 수보다 현저히 적다"로 둔다(고치기 전에는 요청 수와 같았다).
+ */
+@SpringBootTest
+class PreviewGatewayTouchWriteIntegrationTest {
+
+ /** 문서 1 + 자산 19 — 평범한 Vite 빌드 한 페이지가 내는 요청 수와 같은 자릿수. */
+ private static final int REQUESTS_PER_PAGE_LOAD = 20;
+
+ @Autowired
+ private PreviewSessionService sessionService;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Test
+ void onePageLoadDoesNotWriteTheSessionRowOncePerAsset() {
+ Assumptions.assumeTrue(performanceSchemaAvailable(), "performance_schema 가 꺼져 있어 셀 수 없다");
+ String sessionId = insertActiveSession(LocalDateTime.now());
+
+ long before = updateCount();
+ for (int i = 0; i < REQUESTS_PER_PAGE_LOAD; i++) {
+ assertThat(sessionService.resolveGateway(sessionId, accessTokenOf(sessionId))).isPresent();
+ }
+ long delta = updateCount() - before;
+
+ // 고치기 전: 요청마다 1 번 → 20. 고친 뒤: 스로틀 안이므로 0(다른 테스트가 남긴 만료 행을
+ // 스케줄러가 정리하며 내는 UPDATE 여유로 2 까지 허용한다).
+ assertThat(delta).isLessThanOrEqualTo(2);
+ }
+
+ /** 스로틀을 넘긴 첫 접근은 여전히 갱신한다 — 만료 연장이 죽어서는 안 된다. */
+ @Test
+ void anAccessAfterTheThrottleWindowStillWritesExactlyOnce() {
+ Assumptions.assumeTrue(performanceSchemaAvailable(), "performance_schema 가 꺼져 있어 셀 수 없다");
+ String sessionId = insertActiveSession(LocalDateTime.now().minusMinutes(5));
+
+ long before = updateCount();
+ for (int i = 0; i < REQUESTS_PER_PAGE_LOAD; i++) {
+ sessionService.resolveGateway(sessionId, accessTokenOf(sessionId));
+ }
+ long delta = updateCount() - before;
+
+ // 첫 요청이 갱신하고 나머지는 스로틀에 걸린다. 1 이 기대값이고, 스케줄러 여유로 3 까지 본다.
+ assertThat(delta).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(3);
+ LocalDateTime lastAccessed = jdbcTemplate.queryForObject(
+ "select last_accessed_at from preview_sessions where preview_session_id = ?",
+ LocalDateTime.class, sessionId);
+ assertThat(lastAccessed).isAfter(LocalDateTime.now().minusMinutes(1));
+ }
+
+ private boolean performanceSchemaAvailable() {
+ try {
+ jdbcTemplate.queryForObject(
+ "select count(*) from performance_schema.events_statements_summary_by_digest", Long.class);
+ return true;
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ /** 이 스키마에서 {@code preview_sessions} 를 고친 문장의 누적 실행 횟수. */
+ private long updateCount() {
+ Long count = jdbcTemplate.queryForObject(
+ """
+ select coalesce(sum(count_star), 0)
+ from performance_schema.events_statements_summary_by_digest
+ where schema_name = database()
+ and digest_text like 'UPDATE%'
+ and digest_text like '%preview_sessions%'
+ """,
+ Long.class);
+ return count == null ? 0L : count;
+ }
+
+ private String accessTokenOf(String sessionId) {
+ return sessionId.replace("-", "");
+ }
+
+ /**
+ * FK(user_id) 만 채우고 project/chat/task 는 NULL 로 둔다 — 이 테스트가 보는 것은 게이트웨이
+ * 조회가 내는 쓰기뿐이고, 그 경로는 이 컬럼들을 읽지 않는다.
+ */
+ private String insertActiveSession(LocalDateTime lastAccessedAt) {
+ String githubUserId = "u7-touch-" + UUID.randomUUID();
+ jdbcTemplate.update("insert into users (github_user_id, user_name) values (?, ?)",
+ githubUserId, "u7-touch");
+ Long userId = jdbcTemplate.queryForObject(
+ "select user_id from users where github_user_id = ?", Long.class, githubUserId);
+
+ String sessionId = UUID.randomUUID().toString();
+ jdbcTemplate.update(
+ """
+ insert into preview_sessions
+ (preview_session_id, access_token, user_id, container_id, host_port,
+ status, public_url, expires_at, last_accessed_at)
+ values (?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?)
+ """,
+ sessionId, accessTokenOf(sessionId), userId, "container-" + sessionId, 32768,
+ "https://qeploy.test/api/v1/previews/" + sessionId + "/" + accessTokenOf(sessionId) + "/",
+ LocalDateTime.now().plusMinutes(30), lastAccessedAt);
+ return sessionId;
+ }
+}
diff --git a/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java b/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java
index 4f32a34d..8af96c8e 100644
--- a/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java
+++ b/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java
@@ -14,6 +14,9 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -29,6 +32,9 @@ class PreviewGatewayServiceTest {
private HttpServer container;
private PreviewGatewayService service;
+ // 컨테이너로 실제로 나간 요청 수 — base 흡수의 왕복 수를 세기 위한 것(7-4).
+ private final java.util.concurrent.atomic.AtomicInteger upstreamRequests =
+ new java.util.concurrent.atomic.AtomicInteger();
// 안쪽 앱 무응답으로 회수 요청된 sessionId 를 기록한다(게이트웨이가 부르는 reclaimer 대역).
private final java.util.List reclaimed = new java.util.ArrayList<>();
@@ -36,6 +42,7 @@ class PreviewGatewayServiceTest {
void startFakeContainer() throws IOException {
container = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
container.createContext("/", exchange -> {
+ upstreamRequests.incrementAndGet();
byte[] body = "preview".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/html");
exchange.sendResponseHeaders(200, body.length);
@@ -73,7 +80,7 @@ void configuredFrameAncestorsWidenFramingButNeverTheSandbox() {
@Test
void sandboxesThePreviewDocumentSoItCannotReachTheParentOrigin() {
- ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+ ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
String policy = response.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY);
assertThat(policy).isNotNull();
@@ -88,7 +95,7 @@ void sandboxesThePreviewDocumentSoItCannotReachTheParentOrigin() {
/** 스크립트가 도는 미리보기가 목적이므로 격리가 실행 자체를 막아서는 안 된다. */
@Test
void stillAllowsTheScriptsAndFormsAPreviewNeeds() {
- ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+ ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
String policy = response.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY);
assertThat(policy).contains("allow-scripts").contains("allow-forms").contains("allow-popups");
@@ -105,7 +112,7 @@ void appliesTheSamePolicyToNonHtmlAssets() {
exchange.close();
});
- ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "app.js", null);
+ ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "app.js", null);
assertThat(response.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY))
.contains("sandbox");
@@ -118,15 +125,15 @@ void appliesTheSamePolicyToNonHtmlAssets() {
* 백지가 됐다(Issue #111). 접두를 벗겨 다시 물어보는 것이 이 테스트가 지키는 계약이다.
*/
@Test
- void absorbsTheBuildBasePathSoAssetsResolveToTheServedRoot() {
+ void absorbsTheBuildBasePathSoAssetsResolveToTheServedRoot() throws IOException {
serveAsset("/assets/app.js", "application/javascript", "console.log(1)");
- ResponseEntity response =
+ ResponseEntity response =
service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/app.js", null);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.contains("application/javascript");
- assertThat(new String(response.getBody(), StandardCharsets.UTF_8)).isEqualTo("console.log(1)");
+ assertThat(bodyOf(response)).isEqualTo("console.log(1)");
}
/**
@@ -139,15 +146,14 @@ void absorbsTheBuildBasePathSoAssetsResolveToTheServedRoot() {
void tellsTheCdnNotToInjectIntoThePreviewDocument() {
serveAsset("/assets/app.js", "application/javascript", "console.log(1)");
- ResponseEntity document = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
- ResponseEntity asset =
+ ResponseEntity document = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+ ResponseEntity asset =
service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null);
assertThat(document.getHeaders().getCacheControl()).contains("no-transform");
assertThat(asset.getHeaders().getCacheControl()).doesNotContain("no-transform");
- // 캐시 금지는 두 경우 모두 유지된다.
+ // 문서는 여전히 아예 담지 않는다(7-2 가 자산만 캐시 가능하게 했다).
assertThat(document.getHeaders().getCacheControl()).contains("no-store");
- assertThat(asset.getHeaders().getCacheControl()).contains("no-store");
}
/** 루트 자산(favicon 등)도 같은 경로로 살아난다. */
@@ -155,7 +161,7 @@ void tellsTheCdnNotToInjectIntoThePreviewDocument() {
void absorbsTheBasePathForRootLevelAssetsToo() {
serveAsset("/favicon.svg", "image/svg+xml", "");
- ResponseEntity response =
+ ResponseEntity response =
service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/favicon.svg", null);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).contains("image/svg+xml");
@@ -163,13 +169,13 @@ void absorbsTheBasePathForRootLevelAssetsToo() {
/** base 가 없는 프로젝트(대다수)는 첫 요청에서 끝나야 한다 — 추가 왕복도, 경로 변형도 없다. */
@Test
- void leavesAssetsThatAlreadyResolveUntouched() {
+ void leavesAssetsThatAlreadyResolveUntouched() throws IOException {
serveAsset("/assets/app.js", "application/javascript", "console.log(1)");
- ResponseEntity response =
+ ResponseEntity response =
service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null);
- assertThat(new String(response.getBody(), StandardCharsets.UTF_8)).isEqualTo("console.log(1)");
+ assertThat(bodyOf(response)).isEqualTo("console.log(1)");
}
/**
@@ -178,7 +184,7 @@ void leavesAssetsThatAlreadyResolveUntouched() {
*/
@Test
void keepsTheSpaFallbackForRoutesThatAreNotAssets() {
- ResponseEntity response =
+ ResponseEntity response =
service.proxy(session(), "/api/v1/previews/s/t/", "todos/42", null);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).contains("text/html");
@@ -187,7 +193,7 @@ void keepsTheSpaFallbackForRoutesThatAreNotAssets() {
/** 접두를 벗겨도 없는 자산은 원래 응답을 그대로 돌려준다 — 경로를 무한히 깎지 않는다. */
@Test
void fallsBackToTheOriginalResponseWhenStrippingDoesNotHelp() {
- ResponseEntity response =
+ ResponseEntity response =
service.proxy(session(), "/api/v1/previews/s/t/", "a/b/c/missing.js", null);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).contains("text/html");
@@ -201,7 +207,7 @@ void fallsBackToTheOriginalResponseWhenStrippingDoesNotHelp() {
*/
@Test
void doesNotSetItsOwnCorsHeaderBecauseTheCorsFilterOwnsIt() {
- ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+ ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isNull();
}
@@ -221,7 +227,7 @@ void reclaimsTheSessionWhenTheInnerAppIsUnreachable() throws IOException {
"session-dead", 1L, 11L, null, null, "container-dead", closedPort,
"https://qeploy.com/api/v1/previews/session-dead/token/", LocalDateTime.now().plusMinutes(30));
- ResponseEntity response = service.proxy(dead, "/api/v1/previews/s/t/", "", null);
+ ResponseEntity response = service.proxy(dead, "/api/v1/previews/s/t/", "", null);
assertThat(response.getStatusCode().value()).isEqualTo(502);
assertThat(reclaimed).containsExactly("session-dead"); // 안쪽 앱 死 → 세션 회수 요청
@@ -242,7 +248,7 @@ void reclaimDisabled_returns502ButNeverReclaims() throws IOException {
"session-dead", 1L, 11L, null, null, "container-dead", closedPort,
"https://qeploy.com/api/v1/previews/session-dead/token/", LocalDateTime.now().plusMinutes(30));
- ResponseEntity response = disabled.proxy(dead, "/api/v1/previews/s/t/", "", null);
+ ResponseEntity response = disabled.proxy(dead, "/api/v1/previews/s/t/", "", null);
assertThat(response.getStatusCode().value()).isEqualTo(502);
assertThat(reclaimed).isEmpty(); // 킬스위치 off — 회수 안 함
@@ -261,10 +267,10 @@ void doesNotReclaimAHealthySession() {
* HTML 에 fetch/XHR 를 감싸 그 요청을 프리뷰 prefix 아래로 재작성하는 shim 이 주입돼야 데이터가 앱에 닿는다.
*/
@Test
- void injectsApiPathShimIntoHtmlSoRootAbsoluteApiCallsReachTheApp() {
- ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+ void injectsApiPathShimIntoHtmlSoRootAbsoluteApiCallsReachTheApp() throws IOException {
+ ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
- String html = new String(response.getBody(), StandardCharsets.UTF_8);
+ String html = bodyOf(response);
assertThat(html).contains("window.fetch"); // fetch 래핑
assertThat(html).contains("XMLHttpRequest.prototype.open"); // XHR 래핑(axios 등)
assertThat(html).contains("/api/v1/previews/s/t"); // prefix(슬래시 뺀)가 shim 에 박힘
@@ -278,10 +284,10 @@ void injectsApiPathShimIntoHtmlSoRootAbsoluteApiCallsReachTheApp() {
* 문서에 주입된다는 계약을 회귀 가드로 고정한다.
*/
@Test
- void injectsNavigationShimSoRootAbsoluteLinksAndFormsStayInThePrefix() {
- ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+ void injectsNavigationShimSoRootAbsoluteLinksAndFormsStayInThePrefix() throws IOException {
+ ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
- String html = new String(response.getBody(), StandardCharsets.UTF_8);
+ String html = bodyOf(response);
assertThat(html).contains("addEventListener(\"click\",fixA,true)"); // 앵커 클릭 가로채기
assertThat(html).contains("addEventListener(\"auxclick\",fixA,true)"); // 중클릭(새 탭)도
assertThat(html).contains("addEventListener(\"submit\""); // 폼 action 가로채기
@@ -294,7 +300,7 @@ void injectsNavigationShimSoRootAbsoluteLinksAndFormsStayInThePrefix() {
* 프리뷰에서 동작하려면 필요하다. 앱이 method 와 본문을 되돌려주는 엔드포인트로 왕복을 확인한다.
*/
@Test
- void proxiesWriteMethodsWithTheirBodyToTheApp() {
+ void proxiesWriteMethodsWithTheirBodyToTheApp() throws IOException {
container.createContext("/api/entries", exchange -> {
byte[] in = exchange.getRequestBody().readAllBytes();
String out = "{\"method\":\"" + exchange.getRequestMethod() + "\",\"echo\":"
@@ -307,11 +313,12 @@ void proxiesWriteMethodsWithTheirBodyToTheApp() {
});
byte[] reqBody = "{\"name\":\"a\",\"message\":\"hi\"}".getBytes(StandardCharsets.UTF_8);
- ResponseEntity response = service.proxy(
- "POST", session(), "/api/v1/previews/s/t/", "api/entries", null, reqBody, "application/json");
+ ResponseEntity response = service.proxy(
+ session(), "/api/v1/previews/s/t/", "api/entries", null,
+ new PreviewGatewayService.ProxiedRequest("POST", reqBody, "application/json", null, null));
assertThat(response.getStatusCode().value()).isEqualTo(201); // 상태 그대로
- String body = new String(response.getBody(), StandardCharsets.UTF_8);
+ String body = bodyOf(response);
assertThat(body).contains("\"method\":\"POST\""); // 메서드 그대로 전달
assertThat(body).contains("\"name\":\"a\""); // 본문 그대로 전달
}
@@ -348,9 +355,241 @@ void streamsServerSentEventsWithTheStreamingContract() throws Exception {
assertThat(streamed).contains("data: one").contains("data: two");
}
+ // ── base 흡수 단수 기억 (Issue #342, 7-4) ─────────────────────────────────────────
+
+ /**
+ * base 를 쓰는 프로젝트에서는 자산마다 "틀린 경로로 먼저 묻고 → 접두를 벗겨 다시 묻는" 탐색이
+ * 반복됐다. 자산 수 × 최대 3 회의 컨테이너 왕복이다. 한 세션의 자산은 같은 빌드 산출물이라
+ * base 도 하나이므로, 두 번째 자산부터는 한 번에 맞아야 한다.
+ */
+ @Test
+ void remembersTheAbsorbedBaseSoLaterAssetsCostOneRoundTrip() throws IOException {
+ serveAsset("/assets/first.js", "application/javascript", "console.log(1)");
+ serveAsset("/assets/second.js", "application/javascript", "console.log(2)");
+
+ service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/first.js", null);
+ int firstAssetRoundTrips = upstreamRequests.get();
+ upstreamRequests.set(0);
+ ResponseEntity second =
+ service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/second.js", null);
+
+ assertThat(firstAssetRoundTrips).isEqualTo(2); // 첫 자산: 틀린 경로 1 + 벗긴 경로 1
+ assertThat(upstreamRequests.get()).isEqualTo(1); // 두 번째부터: 바로 맞는다
+ assertThat(bodyOf(second)).isEqualTo("console.log(2)");
+ }
+
+ /**
+ * 기억이 틀린 경우(같은 세션에 base 가 다른 자산이 섞임)에도 자산이 살아나야 한다 — 최적화가
+ * 동작을 바꾸지 않는다는 것이 이 되돌림의 목적이다.
+ */
+ @Test
+ void fallsBackToTheSearchWhenTheRememberedDepthDoesNotFit() throws IOException {
+ serveAsset("/assets/first.js", "application/javascript", "console.log(1)");
+
+ service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/first.js", null);
+ // 이번에는 접두가 없는 경로 — 기억한 단수를 적용하면 어긋난다.
+ ResponseEntity direct =
+ service.proxy(session(), "/api/v1/previews/s/t/", "assets/first.js", null);
+
+ assertThat(bodyOf(direct)).isEqualTo("console.log(1)");
+ }
+
+ // ── 자산 캐시 정책 (Issue #342, 7-2) ─────────────────────────────────────────────
+
+ /**
+ * 이 프로젝트에서 캐시의 유일한 위험은 {@code public} 이다. 프리뷰 주소의 accessToken 은
+ * 소유자가 다시 열 때마다 회전하고, 회전의 목적은 흘러나간 주소가 곧 죽는 것이다. 공유 캐시가
+ * 응답을 담으면 원본을 거치지 않고 남에게 내주게 되어 그 회전이 무력화된다 — 어떤 분기에서도
+ * {@code private} 여야 한다는 것이 이 테스트가 지키는 계약이다.
+ */
+ @Test
+ void neverLetsAnySharedCacheStoreAPreviewResponse() {
+ serveAsset("/assets/index-a1b2c3d4.js", "application/javascript", "console.log(1)");
+ serveAsset("/assets/app.js", "application/javascript", "console.log(2)");
+
+ String hashed = cacheControlOf("assets/index-a1b2c3d4.js");
+ String plain = cacheControlOf("assets/app.js");
+ String document = cacheControlOf("");
+
+ assertThat(hashed).contains("private").doesNotContain("public");
+ assertThat(plain).contains("private").doesNotContain("public");
+ // 문서는 아예 담지 않는다 — 회전 토큰이 든 prefix 를 본문에 박아 내보내기 때문이다.
+ assertThat(document).contains("no-store").doesNotContain("public");
+ }
+
+ /** 내용 해시가 박힌 자산만 장기 캐시한다. */
+ @Test
+ void cachesContentHashedAssetsForALongTime() {
+ serveAsset("/assets/index-a1b2c3d4.js", "application/javascript", "console.log(1)");
+
+ assertThat(cacheControlOf("assets/index-a1b2c3d4.js"))
+ .isEqualTo("private, max-age=3600, immutable");
+ }
+
+ /**
+ * 해시로 보이지 않는 이름은 매번 원본에 물어본다 — 세션 조회·인가·토큰 회전 판정이 예전과
+ * 똑같이 요청마다 돌고, 절약되는 것은 본문 전송뿐이다.
+ */
+ @Test
+ void makesEverythingElseRevalidateOnEveryRequest() {
+ serveAsset("/assets/app.js", "application/javascript", "console.log(1)");
+
+ assertThat(cacheControlOf("assets/app.js")).isEqualTo("private, no-cache");
+ }
+
+ /**
+ * 날짜가 붙은 사람이 지은 이름을 해시로 오인하면 안 된다 — {@code 20260911} 도 16 진수 8 자라,
+ * 글자 조건이 없으면 파일을 갈아끼워도 한 시간 동안 예전 것이 보인다.
+ */
+ @Test
+ void doesNotMistakeAHumanNamedFileForAContentHash() {
+ serveAsset("/img/photo-20260911.jpg", "image/jpeg", "x");
+ serveAsset("/img/logo-v2.png", "image/png", "y");
+
+ assertThat(cacheControlOf("img/photo-20260911.jpg")).isEqualTo("private, no-cache");
+ assertThat(cacheControlOf("img/logo-v2.png")).isEqualTo("private, no-cache");
+ }
+
+ /** 오류 응답은 캐시하지 않는다 — 404 를 담아두면 컨테이너가 되살아나도 깨진 화면이 유지된다. */
+ @Test
+ void neverCachesAnErrorResponse() {
+ container.createContext("/assets/gone-a1b2c3d4.js", exchange -> {
+ upstreamRequests.incrementAndGet();
+ exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/javascript");
+ exchange.sendResponseHeaders(404, -1);
+ exchange.close();
+ });
+
+ assertThat(cacheControlOf("assets/gone-a1b2c3d4.js")).isEqualTo("no-store");
+ }
+
+ /**
+ * 업스트림의 검증자를 넘겨주고, 브라우저가 그것을 되돌려주면 그대로 안쪽 앱에 물어본다. 신선도
+ * 판정은 앱이 하고 게이트웨이는 추측하지 않는다 — 바뀌지 않았으면 304 로 본문이 흐르지 않는다.
+ */
+ @Test
+ void passesValidatorsThroughSoAReloadTransfersNothing() {
+ container.createContext("/assets/app.js", exchange -> {
+ upstreamRequests.incrementAndGet();
+ String inm = exchange.getRequestHeaders().getFirst(HttpHeaders.IF_NONE_MATCH);
+ exchange.getResponseHeaders().add(HttpHeaders.ETAG, "\"v1\"");
+ if ("\"v1\"".equals(inm)) {
+ exchange.sendResponseHeaders(304, -1);
+ exchange.close();
+ return;
+ }
+ byte[] body = "console.log(1)".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/javascript");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+
+ ResponseEntity first =
+ service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null);
+ ResponseEntity revalidated = service.proxy(
+ session(), "/api/v1/previews/s/t/", "assets/app.js", null,
+ new PreviewGatewayService.ProxiedRequest("GET", null, null, "\"v1\"", null));
+
+ assertThat(first.getHeaders().getETag()).isEqualTo("\"v1\"");
+ assertThat(revalidated.getStatusCode().value()).isEqualTo(304);
+ assertThat(revalidated.getBody()).isNull(); // 본문이 흐르지 않는다
+ assertThat(revalidated.getHeaders().getETag()).isEqualTo("\"v1\"");
+ // 304 여도 격리는 그대로 붙는다.
+ assertThat(revalidated.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY))
+ .contains("sandbox");
+ }
+
+ /**
+ * 문서에는 업스트림 검증자를 넘기지 않는다 — 우리가 내보내는 본문은 shim 을 주입해 재작성한
+ * 것이라, 앱의 ETag 는 그 본문의 것이 아니다. 넘기면 브라우저가 shim 없는 원본을 되살린다.
+ */
+ @Test
+ void neverPassesTheDocumentValidatorThroughBecauseTheDocumentIsRewritten() {
+ container.createContext("/doc.html", exchange -> {
+ upstreamRequests.incrementAndGet();
+ byte[] body = "doc".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/html");
+ exchange.getResponseHeaders().add(HttpHeaders.ETAG, "\"doc-v1\"");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+
+ ResponseEntity document =
+ service.proxy(session(), "/api/v1/previews/s/t/", "doc.html", null);
+
+ assertThat(document.getHeaders().getETag()).isNull();
+ assertThat(document.getHeaders().getCacheControl()).contains("no-store");
+ }
+
+ private String cacheControlOf(String path) {
+ return service.proxy(session(), "/api/v1/previews/s/t/", path, null)
+ .getHeaders().getCacheControl();
+ }
+
+ /** 응답 본문을 문자열로 읽는다 — 스트리밍 봉투(Resource)로 바뀌어도 테스트가 같은 것을 본다. */
+ private String bodyOf(ResponseEntity response) throws IOException {
+ try (var in = response.getBody().getInputStream()) {
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ /**
+ * 비-HTML 자산은 힙에 모으지 않고 스트림으로 넘어간다 (Issue #342, 7-3). 예전에는 모든 응답을
+ * {@code ofByteArray} 로 전량 버퍼링해, 큰 이미지·번들 하나가 요청마다 그 크기만큼 힙을 썼다.
+ * 봉투 타입이 회귀 가드다 — {@code ByteArrayResource} 로 돌아가면 버퍼링이 돌아온 것이다.
+ */
+ @Test
+ void streamsNonHtmlAssetsInsteadOfBufferingThemInHeap() {
+ serveAsset("/assets/app.js", "application/javascript", "console.log(1)");
+
+ ResponseEntity asset =
+ service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null);
+
+ assertThat(asset.getBody()).isInstanceOf(InputStreamResource.class);
+ }
+
+ /** 큰 자산도 내용이 온전히 통과해야 한다 — 스트리밍으로 바꾼 뒤에도 바이트가 깎이지 않는다. */
+ @Test
+ void streamsALargeAssetWithoutLosingBytes() throws IOException {
+ byte[] large = new byte[3 * 1024 * 1024];
+ for (int i = 0; i < large.length; i++) {
+ large[i] = (byte) (i % 251);
+ }
+ container.createContext("/assets/big.bin", exchange -> {
+ exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/octet-stream");
+ exchange.sendResponseHeaders(200, large.length);
+ exchange.getResponseBody().write(large);
+ exchange.close();
+ });
+
+ ResponseEntity response =
+ service.proxy(session(), "/api/v1/previews/s/t/", "assets/big.bin", null);
+
+ assertThat(response.getBody()).isInstanceOf(InputStreamResource.class);
+ // 업스트림이 길이를 알려줬으므로 그대로 넘긴다(본문을 변형하지 않으니 여전히 정확하다).
+ assertThat(response.getHeaders().getContentLength()).isEqualTo(large.length);
+ try (var in = response.getBody().getInputStream()) {
+ assertThat(in.readAllBytes()).isEqualTo(large);
+ }
+ }
+
+ /**
+ * HTML 은 여전히 버퍼링한다 — shim 주입·경로 재작성이 본문 전체를 봐야 하기 때문이다. 문서가
+ * 스트림으로 새면 그 격리·보정이 통째로 빠진다.
+ */
+ @Test
+ void stillBuffersTheDocumentBecauseItHasToBeRewritten() {
+ ResponseEntity document = service.proxy(session(), "/api/v1/previews/s/t/", "", null);
+
+ assertThat(document.getBody()).isInstanceOf(ByteArrayResource.class);
+ }
+
/** 이 경로에만 실제 파일이 있는 상태를 만든다. 나머지 경로는 @BeforeEach 의 "/" 가 받아 index.html 을 돌려준다(serve -s 와 같은 동작). */
private void serveAsset(String path, String contentType, String content) {
container.createContext(path, exchange -> {
+ upstreamRequests.incrementAndGet();
byte[] body = content.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, contentType);
exchange.sendResponseHeaders(200, body.length);
diff --git a/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java b/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java
index d79fad26..43783f85 100644
--- a/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java
+++ b/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java
@@ -4,6 +4,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -30,6 +31,8 @@
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
class PreviewSessionServiceTest {
@@ -420,21 +423,99 @@ void aPreviewAccessNeverPullsTheHoldBackIn() {
// 유예를 걸어도 바로 뒤에 FE 가 프리뷰를 자동으로 띄우면 게이트웨이 접근이 일어난다.
// touch 가 만료를 무조건 now+ttl 로 덮어쓰던 시절에는 그 한 번으로 유예가 통째로
// 지워졌다 — 유예는 프리뷰를 한 번도 열지 않았을 때만 살아남았다(2026-08-18 운영 실측).
+ // 갱신이 단일 UPDATE 로 바뀐 뒤에도(7-1) 그 UPDATE 가 싣는 만료가 유예여야 한다.
SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class);
- PreviewSessionService service = new PreviewSessionService(
- repository, mock(DockerContainerService.class), mock(TaskStore.class),
- properties(), gatewayUrlResolver(), accessCookies(), mock(PreviewRuntimeConfigService.class)
- );
+ PreviewSessionService service = gatewayService(repository);
PreviewSessionEntity held = activeSessionExpiringIn(Duration.ofHours(6));
+ lastAccessedMinutesAgo(held, 5); // 스로틀을 지나 실제로 갱신이 나가게 한다
when(repository.findByIdAndAccessTokenAndStatus(
"session-1", "token-1", PreviewSessionStatus.ACTIVE.name()))
.thenReturn(Optional.of(held));
- when(repository.save(any(PreviewSessionEntity.class)))
- .thenAnswer(invocation -> invocation.getArgument(0));
service.resolveGateway("session-1", "token-1");
- assertThat(held.getExpiresAt()).isAfter(LocalDateTime.now().plusHours(5));
+ ArgumentCaptor expiresAt = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(repository).touchAccess(eq("session-1"), any(), expiresAt.capture(), any());
+ assertThat(expiresAt.getValue()).isAfter(LocalDateTime.now().plusHours(5));
+ }
+
+ // ── 게이트웨이 접근 갱신 스로틀 (Issue #342, 7-1) ──────────────────────────────────
+
+ /**
+ * 프리뷰 페이지 한 번의 로드는 문서 1 + 자산 N 개의 요청이고, 그 전부가 이 조회를 지난다.
+ * 예전에는 요청마다 엔티티를 고쳐 {@code save} 했으므로 같은 행에 UPDATE 가 N+1 번 나가고
+ * 그만큼 쓰기 락이 잡혔다. 스로틀 안에서는 쓰기가 아예 없어야 한다.
+ */
+ @Test
+ void assetRequestsWithinTheThrottleWindowWriteNothing() {
+ SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class);
+ PreviewSessionService service = gatewayService(repository);
+ // 방금 만든 행 = lastAccessedAt 이 now — 스로틀 안이다.
+ PreviewSessionEntity session = activeSessionExpiringIn(Duration.ofMinutes(30));
+ when(repository.findByIdAndAccessTokenAndStatus(
+ "session-1", "token-1", PreviewSessionStatus.ACTIVE.name()))
+ .thenReturn(Optional.of(session));
+
+ for (int i = 0; i < 20; i++) {
+ assertThat(service.resolveGateway("session-1", "token-1")).isPresent();
+ }
+
+ verify(repository, never()).touchAccess(anyString(), any(), any(), any());
+ verify(repository, never()).save(any(PreviewSessionEntity.class));
+ }
+
+ /** 스로틀을 넘긴 접근은 엔티티 저장이 아니라 단일 UPDATE 한 번으로 갱신한다. */
+ @Test
+ void anAccessOlderThanTheThrottleWindowIsRefreshedWithOneUpdate() {
+ SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class);
+ PreviewSessionService service = gatewayService(repository);
+ PreviewSessionEntity session = activeSessionExpiringIn(Duration.ofMinutes(30));
+ lastAccessedMinutesAgo(session, 5);
+ when(repository.findByIdAndAccessTokenAndStatus(
+ "session-1", "token-1", PreviewSessionStatus.ACTIVE.name()))
+ .thenReturn(Optional.of(session));
+
+ service.resolveGateway("session-1", "token-1");
+
+ ArgumentCaptor expiresAt = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(repository).touchAccess(eq("session-1"), any(), expiresAt.capture(), any());
+ assertThat(expiresAt.getValue()).isAfter(LocalDateTime.now().plusMinutes(29));
+ verify(repository, never()).save(any(PreviewSessionEntity.class));
+ }
+
+ /**
+ * 스로틀은 Sec-Fetch-Dest 를 보지 않는다. "문서 탐색일 때만 갱신"으로 바꾸면 열어둔
+ * 프리뷰가 XHR/SSE 로만 쓰이는 동안 연장이 끊겨 사용 중인 세션이 만료되고, 게이트웨이의 인가
+ * 판정이 쓰는 헤더가 갱신 정책에도 얽힌다. 서브리소스 요청 하나만으로도 연장돼야 한다.
+ */
+ @Test
+ void aSubresourceRequestStillExtendsTheExpiryOnceTheWindowHasPassed() {
+ SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class);
+ PreviewSessionService service = gatewayService(repository);
+ PreviewSessionEntity session = activeSessionExpiringIn(Duration.ofMinutes(2));
+ lastAccessedMinutesAgo(session, 5);
+ when(repository.findByIdAndAccessTokenAndStatus(
+ "session-1", "token-1", PreviewSessionStatus.ACTIVE.name()))
+ .thenReturn(Optional.of(session));
+
+ // resolveGateway 는 자산 요청과 문서 요청을 구분하지 않는다 — 같은 한 가지 경로다.
+ service.resolveGateway("session-1", "token-1");
+
+ ArgumentCaptor expiresAt = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(repository).touchAccess(eq("session-1"), any(), expiresAt.capture(), any());
+ assertThat(expiresAt.getValue()).isAfter(LocalDateTime.now().plusMinutes(29));
+ }
+
+ private PreviewSessionService gatewayService(SpringDataPreviewSessionRepository repository) {
+ return new PreviewSessionService(
+ repository, mock(DockerContainerService.class), mock(TaskStore.class),
+ properties(), gatewayUrlResolver(), accessCookies(), mock(PreviewRuntimeConfigService.class)
+ );
+ }
+
+ /** 엔티티에 lastAccessedAt 세터가 없으므로(도메인 불변식) 테스트만 필드를 되돌린다. */
+ private void lastAccessedMinutesAgo(PreviewSessionEntity session, int minutes) {
+ ReflectionTestUtils.setField(session, "lastAccessedAt", LocalDateTime.now().minusMinutes(minutes));
}
private PreviewSessionEntity activeSessionExpiringIn(Duration remaining) {
diff --git a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java
index 336b38f7..8e531e6e 100644
--- a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java
+++ b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java
@@ -24,6 +24,8 @@
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -55,23 +57,23 @@ void setUp() {
controller = new PreviewGatewayController(sessionService, gatewayService, accessCookies, properties);
when(sessionService.resolveGateway(SESSION_ID, ACCESS_TOKEN)).thenReturn(Optional.of(session()));
- when(gatewayService.proxy(anyString(), any(), anyString(), anyString(), any(), any(), any()))
- .thenReturn(ResponseEntity.ok("body".getBytes()));
+ when(gatewayService.proxy(any(), anyString(), anyString(), any(), any()))
+ .thenReturn(ResponseEntity.ok(new ByteArrayResource("body".getBytes())));
}
@Test
void rejectsARequestWithoutTheOwnershipCookieEvenWhenTheUrlIsCorrect() {
- ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request());
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
- verify(gatewayService, never()).proxy(anyString(), any(), anyString(), anyString(), any(), any(), any());
+ verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any());
}
@Test
void rejectsACookieIssuedForAnotherSession() {
String foreign = accessCookies.issue("other-session", OWNER, Duration.ofMinutes(30));
- ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request());
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@@ -80,7 +82,7 @@ void rejectsACookieIssuedForAnotherSession() {
void rejectsACookieIssuedForAnotherUser() {
String foreign = accessCookies.issue(SESSION_ID, 99L, Duration.ofMinutes(30));
- ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request());
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@@ -89,10 +91,10 @@ void rejectsACookieIssuedForAnotherUser() {
void servesTheOwnerWhoPresentsTheIssuedCookie() {
String cookie = accessCookies.issue(SESSION_ID, OWNER, Duration.ofMinutes(30));
- ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, cookie, request());
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, cookie, request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
- verify(gatewayService).proxy(anyString(), any(), anyString(), anyString(), any(), any(), any());
+ verify(gatewayService).proxy(any(), anyString(), anyString(), any(), any());
}
/** 세션 자체가 없으면(만료·오토큰) 쿠키 이전에 404다 — 존재 여부를 인가로 흘리지 않는다. */
@@ -100,7 +102,7 @@ void servesTheOwnerWhoPresentsTheIssuedCookie() {
void keepsReturningNotFoundForAnUnknownSession() {
when(sessionService.resolveGateway(SESSION_ID, "wrong")).thenReturn(Optional.empty());
- ResponseEntity response = controller.proxy(SESSION_ID, "wrong", null, request());
+ ResponseEntity response = controller.proxy(SESSION_ID, "wrong", null, request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@@ -112,8 +114,8 @@ void keepsReturningNotFoundForAnUnknownSession() {
*/
@Test
void servesSubresourceRequestsWithoutTheCookie() {
- ResponseEntity script = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("script"));
- ResponseEntity fetch = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("empty"));
+ ResponseEntity script = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("script"));
+ ResponseEntity fetch = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("empty"));
assertThat(script.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(fetch.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -133,11 +135,132 @@ void keepsRequiringTheCookieForDocumentNavigations() {
void canBeTurnedOffForEnvironmentsWhoseClientHasNotShippedTheAccessCallYet() {
properties.setRequireAccessCookie(false);
- ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request());
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
+ // ── 요청 본문 상한 (Issue #342, 7-3) ──────────────────────────────────────────────
+
+ /**
+ * 이 경로는 서브리소스 요청에 소유권 쿠키를 요구하지 않는다(회전 accessToken 이 든 URL 자체가
+ * 자격 — {@code isAuthorized} 참고). 그래서 유효한 프리뷰 주소 하나만 쥐면 로그인 없이 임의
+ * 크기의 POST 를 보낼 수 있었고, {@code readAllBytes()} 는 그것을 전부 힙에 올렸다. 선언된
+ * 길이가 상한을 넘으면 본문을 읽기도 전에 413 이어야 한다.
+ */
+ @Test
+ void rejectsARequestWhoseDeclaredBodyExceedsTheLimit() {
+ HttpServletRequest request = request("empty");
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getContentLengthLong()).thenReturn(11L * 1024 * 1024);
+
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE);
+ verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any());
+ }
+
+ /**
+ * 선언된 길이만 믿으면 안 된다 — 청크 전송은 길이를 안 싣고({@code -1}), 실린 값이 사실이라는
+ * 보장도 없다. 실제로 읽는 양도 끊어야 상한이 상한이다.
+ */
+ @Test
+ void rejectsABodyThatExceedsTheLimitEvenWhenNoLengthWasDeclared() {
+ HttpServletRequest request = request("empty");
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getContentLengthLong()).thenReturn(-1L); // 청크 전송
+ stubBodyOf(request, 11 * 1024 * 1024);
+
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE);
+ verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any());
+ }
+
+ /** 상한 안의 본문은 그대로 컨테이너로 간다 — 앱의 폼·등록이 계속 동작해야 한다. */
+ @Test
+ void stillProxiesABodyWithinTheLimit() {
+ HttpServletRequest request = request("empty");
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getContentLengthLong()).thenReturn(-1L);
+ stubBodyOf(request, 64 * 1024);
+
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(gatewayService).proxy(any(), anyString(), anyString(), any(), any());
+ }
+
+ /**
+ * 본문 상한이 인가보다 앞에 오면 안 된다. 소유권을 증명하지 못한 요청에 413 을 주면
+ * "이 프리뷰는 존재하고 본문만 컸다"를 알려주는 셈이고, 무엇보다 인가 전에 본문을 만지기
+ * 시작한다는 뜻이다. 순서는 세션 조회 → 쿠키/Sec-Fetch-Dest → 본문이어야 한다.
+ */
+ @Test
+ void checksOwnershipBeforeTheBodyLimit() {
+ HttpServletRequest request = request(); // Sec-Fetch-Dest 없음 → 탐색으로 간주, 쿠키 필요
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getContentLengthLong()).thenReturn(11L * 1024 * 1024);
+
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ }
+
+ /** 세션이 없으면 본문 크기와 무관하게 404 다 — 존재 여부를 413 으로 흘리지 않는다. */
+ @Test
+ void keepsReturningNotFoundForAnUnknownSessionEvenWithAnOversizedBody() {
+ when(sessionService.resolveGateway(SESSION_ID, "wrong")).thenReturn(Optional.empty());
+ HttpServletRequest request = request("empty");
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getContentLengthLong()).thenReturn(11L * 1024 * 1024);
+
+ ResponseEntity response = controller.proxy(SESSION_ID, "wrong", null, request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ /**
+ * 조건부 요청(브라우저 재검증)도 인가를 먼저 받는다 — 쿠키 없는 문서 탐색은 If-None-Match 를
+ * 들고 와도 401 이고, 304 로 새 나가지 않는다.
+ */
+ @Test
+ void stillRequiresTheCookieForAConditionalDocumentRequest() {
+ HttpServletRequest request = request("document");
+ when(request.getHeader(org.springframework.http.HttpHeaders.IF_NONE_MATCH)).thenReturn("\"v1\"");
+
+ ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any());
+ }
+
+ /** 지정한 바이트 수를 게으르게 내보내는 본문. 테스트가 10 MiB 배열을 미리 만들지 않게 한다. */
+ private void stubBodyOf(HttpServletRequest request, int bytes) {
+ ServletInputStream stream = new ServletInputStream() {
+ private int remaining = bytes;
+
+ @Override public int read() {
+ return remaining-- > 0 ? 'x' : -1;
+ }
+
+ @Override public boolean isFinished() {
+ return remaining <= 0;
+ }
+
+ @Override public boolean isReady() {
+ return true;
+ }
+
+ @Override public void setReadListener(ReadListener listener) { }
+ };
+ try {
+ when(request.getInputStream()).thenReturn(stream);
+ } catch (IOException ignored) {
+ // 스텁은 실제 IO 를 하지 않는다 — checked 예외는 형식상일 뿐.
+ }
+ }
+
private PreviewSessionInfo session() {
return new PreviewSessionInfo(
SESSION_ID, OWNER, 11L, null, null, "container-1", 32768,
diff --git a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java
index e2cd0e78..dfa00975 100644
--- a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java
+++ b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java
@@ -18,6 +18,7 @@
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.web.servlet.MockMvc;
@@ -52,8 +53,8 @@ void setUp() {
properties.setRequireAccessCookie(false);
when(sessionService.resolveGateway(SID, TOKEN)).thenReturn(Optional.of(session()));
- when(gatewayService.proxy(anyString(), any(), anyString(), any(), any(), any(), any()))
- .thenReturn(ResponseEntity.ok("doc".getBytes()));
+ when(gatewayService.proxy(any(), anyString(), anyString(), any(), any()))
+ .thenReturn(ResponseEntity.ok(new ByteArrayResource("doc".getBytes())));
when(gatewayService.proxyEventStream(any(), any(), any(), any()))
.thenReturn(ResponseEntity.ok(out -> { })); // no-op 스트림
@@ -68,7 +69,7 @@ void routesEventStreamAcceptToTheStreamingHandler() throws Exception {
mockMvc.perform(get(BASE).accept(MediaType.TEXT_EVENT_STREAM));
verify(gatewayService).proxyEventStream(any(), anyString(), any(), any());
- verify(gatewayService, never()).proxy(anyString(), any(), anyString(), any(), any(), any(), any());
+ verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any());
}
/**
@@ -80,7 +81,7 @@ void routesBrowserDocumentAcceptToTheBufferingProxyNotTheStreamingHandler() thro
mockMvc.perform(get(BASE)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"));
- verify(gatewayService).proxy(anyString(), any(), anyString(), any(), any(), any(), any());
+ verify(gatewayService).proxy(any(), anyString(), anyString(), any(), any());
verify(gatewayService, never()).proxyEventStream(any(), any(), any(), any());
}
diff --git a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayStreamingEnvelopeTest.java b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayStreamingEnvelopeTest.java
new file mode 100644
index 00000000..55555cf5
--- /dev/null
+++ b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayStreamingEnvelopeTest.java
@@ -0,0 +1,115 @@
+package com.example.dvely.preview.presentation;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.example.dvely.auth.infrastructure.config.JwtProperties;
+import com.example.dvely.preview.application.result.PreviewSessionInfo;
+import com.example.dvely.preview.application.service.PreviewGatewayService;
+import com.example.dvely.preview.application.service.PreviewSessionService;
+import com.example.dvely.preview.infrastructure.config.PreviewProperties;
+import com.example.dvely.preview.infrastructure.security.PreviewAccessCookies;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+/**
+ * 스트리밍 봉투가 Spring MVC 를 통과한 뒤에도 예전과 같은 응답을 내는지 고정한다
+ * (Issue #342, 7-3).
+ *
+ *
이 검증은 서비스 단위 테스트로 못 잡는다. {@code ResponseEntity} 는 Spring 이
+ * {@code AbstractMessageConverterMethodProcessor} 에서 특별 취급하기 때문이다 — Resource 를
+ * 돌려주면 {@code Accept-Ranges: bytes} 를 달고, {@code Range} 요청이 오면 본문을
+ * {@code ResourceRegion} 으로 바꿔 206 으로 내보낸다. 그 경로는 {@code contentLength()} 를 요구하는데
+ * 스트림 기반 자원은 그것을 세느라 스트림을 소진해버려 본문이 깨진다.
+ *
+ *
{@code InputStreamResource} 만은 그 특별 취급에서 제외돼 있어(그 클래스와 정확히 일치할
+ * 때만) 우리 자산 응답은 안전하다. 하지만 그것은 봉투 타입에 달린 성질이다 — 누군가
+ * 스트리밍 봉투를 다른 {@code Resource} 구현으로 바꾸면 프리뷰 앱의 {@code
+ */
+class PreviewGatewayStreamingEnvelopeTest {
+
+ private static final String SID = "session-1";
+ private static final String TOKEN = "token-1";
+ private static final String ASSET = "/api/v1/previews/" + SID + "/" + TOKEN + "/assets/app.js";
+ private static final String SCRIPT = "console.log(1)";
+
+ private HttpServer container;
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ container = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ container.createContext("/assets/app.js", exchange -> {
+ byte[] body = SCRIPT.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/javascript");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ container.start();
+
+ PreviewSessionService sessionService = mock(PreviewSessionService.class);
+ when(sessionService.resolveGateway(SID, TOKEN)).thenReturn(Optional.of(session()));
+ PreviewProperties properties = new PreviewProperties();
+ // 봉투만 본다 — 인가는 PreviewGatewayControllerTest 가 따로 고정한다.
+ properties.setRequireAccessCookie(false);
+
+ mockMvc = MockMvcBuilders.standaloneSetup(new PreviewGatewayController(
+ sessionService,
+ new PreviewGatewayService("'self'", false, id -> false),
+ new PreviewAccessCookies(
+ new JwtProperties("test-secret-key-that-is-long-enough-32", 3600000L, 7200000L)),
+ properties))
+ .build();
+ }
+
+ @AfterEach
+ void tearDown() {
+ container.stop(0);
+ }
+
+ /** 평범한 요청은 자산 전체를 200 으로 받는다. */
+ @Test
+ void streamsTheWholeAssetThroughMvc() throws Exception {
+ mockMvc.perform(get(ASSET).header(PreviewGatewayController.SEC_FETCH_DEST, "script"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(SCRIPT))
+ .andExpect(header().string(HttpHeaders.CONTENT_TYPE, "application/javascript"));
+ }
+
+ /**
+ * Range 요청도 예전(byte[] 봉투)과 똑같이 전체 본문 + 200 이어야 한다. 206 이 나오거나 본문이
+ * 비면, 봉투가 Spring 의 Range 특별 취급에 걸린 것이고 프리뷰의 미디어 재생이 깨진다.
+ */
+ @Test
+ void doesNotLetSpringTurnAStreamedAssetIntoARangeResponse() throws Exception {
+ mockMvc.perform(get(ASSET)
+ .header(PreviewGatewayController.SEC_FETCH_DEST, "script")
+ .header(HttpHeaders.RANGE, "bytes=0-3"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(SCRIPT))
+ .andExpect(header().doesNotExist(HttpHeaders.ACCEPT_RANGES));
+ }
+
+ private PreviewSessionInfo session() {
+ return new PreviewSessionInfo(
+ SID, 7L, 11L, null, null, "container-1", container.getAddress().getPort(),
+ "https://qeploy.com/api/v1/previews/" + SID + "/" + TOKEN + "/",
+ LocalDateTime.now().plusMinutes(30));
+ }
+}