정적 파일
`App`은 따로 요청하지 않아도 `classpath:/public/*`를 루트에서 제공하며, `staticFiles("/assets")`는 다른 디렉터리를 지정한다. 모든 응답은 리소스에서 유도한 `ETag`와 `Last-Modified`를 실어 나르므로, 새로 고침은 파일을 다시 보내는 대신 본문 없는 304로 돌아온다. 기본값 `Cache-Control: no-cache`는 "캐시하되, 먼저 나에게 확인하라"는 뜻이며, 결코 바뀌지 않는 이름에 알맞다. 핑거프린트가 붙은 이름에는 그렇게 말해 준다:
app.staticFiles(new StaticFiles("/public")
.hostedPath("/assets") // classpath:/public/* at /assets/*
.maxAge(Duration.ofDays(365))); // only when the name carries a content hash
라우트가 먼저 매칭되므로, 라우트가 파일을 가릴 수 있다. 디렉터리는 결코 제공되지 않는다.
정적 파일은 매칭된 라우트가 없으므로 beforeRoute`와 `afterRoute`를 건너뛴다.
`beforeRequest("/admin/*", …)`는 정적 파일 조회 전에 `/admin 아래로 제공되는 파일을 보호할 수 있다.
응답 필터는 정적 파일에도 실행되며, CORS, 압축, 보안 헤더도 마찬가지다.
디스크의 디렉터리
`StaticFiles.directory(path)`는 사용자 업로드, jar 옆에 마운트된 볼륨, 별도의 빌드가 기록하는 디렉터리처럼 클래스패스에 없는 파일을 제공한다:
app.staticFiles(
new StaticFiles("/public"), // classpath:/public/* at /*
StaticFiles.directory(Path.of("/srv/uploads")) // /srv/uploads/* at /uploads/*
.hostedPath("/uploads"));
staticFiles(…)`는 루트를 필요한 만큼 받으며, 주어진 순서로 읽는다.
파일을 가진 첫 번째 루트가 응답하고, 나머지에는 묻지 않는다.
그 밖의 모든 것은 어느 쪽이든 같다: 같은 검증자, 같은 조건부 요청, 같은 `Cache-Control, 같은 `hostedPath`다.
루트는 생성 시점이 아니라 요청마다 읽으므로, 애플리케이션이 시작된 뒤에 마운트된 볼륨은 재시작이 필요 없고, 끝내 마운트되지 않은 볼륨은 부팅에 실패하는 대신 404로 응답한다.
인자 없이 호출된 `staticFiles()`는 아무것도 제공하지 않고 모든 경로를 라우팅에 맡긴다.
사전 압축된 형제 파일
precompressed()`는 빌드가 에셋 옆에 남겨 둔 `.br 또는 .gz 파일로 응답한다.
gzip() 홀로는 변하지 않는 같은 에셋을 요청할 때마다 다시 압축하며, JDK에 인코더가 없으므로 brotli로는 전혀 응답할 수 없다:
app.gzip(); // still deflates what has no sibling
app.staticFiles(new StaticFiles("/public")
.precompressed()); // app.css.br / app.css.gz answer app.css
두 형제 파일이 모두 있고 클라이언트가 둘 다 받을 수 있으면 Brotli가 이긴다.
둘 중 더 작고, 여기서 다른 무엇도 만들어 낼 수 없는 쪽이다.
응답은 어느 쪽이든 원본을 기술한다.
.gz 확장자는 타입이 아니라 인코딩이므로 `Content-Type`은 원본의 것이고, `ETag`와 `Last-Modified`도 마찬가지이므로, 인코딩을 넘나들며 재검증하는 브라우저는 304를 그대로 유지한다.
인코딩된 응답에서는 그 태그가 약한(weak) 것으로 표시되는데, `gzip()`이 자기 태그를 그렇게 표시하는 것과 같은 이유다.
형제 파일이 없는 에셋이나 어느 인코딩도 읽지 못하는 클라이언트는 `gzip()`까지 포함해 이전과 정확히 같게 응답받는다. 옆에 놓인 파일보다 오래된 형제 파일은 건너뛴다: 그것은 다시 실행되지 않은 빌드이며, 그것을 제공하는 것은 더는 존재하지 않는 내용을 제공하는 일이기 때문이다. `precompressed()`가 켜진 루트에서 나가는 모든 응답은 형제 파일을 찾았든 아니든 `Vary: Accept-Encoding`을 실어 나르므로, 공유 캐시가 읽지 못하는 클라이언트에 인코딩된 바이트를 건네는 일은 없다.
명시하기 전까지는 꺼져 있는데, 예전 파이프라인이 남긴 `.gz`는 아무도 제공할 뜻이 없던 파일이기 때문이다.
디렉터리 안에 머물기
요청은 그것이 해석되는 파일이 모든 심볼릭 링크를 따라간 뒤에도 실제 경로가 루트 자체의 실제 경로 아래에 있는 일반 파일일 때만 응답된다. 디렉터리 루트는 클래스패스 조회에는 없는 경로 탐색(path-traversal) 공격면이므로, 가드는 명시적이며 컨테이너에 기대지 않는다. 경로 어디에든 있는 `..`는 가드가 실행되기 전에 거부되고, 루트 밖을 가리키는 심볼릭 링크는 가드가 거부한다. 가드가 거절한 것은 이 루트가 가진 파일이 아니므로, 라우팅은 계속되고 응답은 평소의 404다. 둘 중 어느 쪽이었는지 말해 주는 메시지는 없다.