정적 파일

App은 따로 요청하지 않아도 classpath:/public/*를 루트에서 제공하며, staticFiles("/assets")는 다른 디렉터리를 지정한다. 모든 응답은 리소스에서 유도한 ETag와 Last-Modified를 담으므로, 새로 고침은 파일을 다시 보내는 대신 본문 없는 304로 돌아온다. 2000년 이전의 수정 시각은 재현 가능한 빌드가 모든 파일에 똑같이 찍는 스탬프다. 예컨대 Jib은 이미지의 모든 파일에 1970-01-01T00:00:01Z를 찍는다. 그런 파일은 대신 내용의 CRC-32로 태그를 붙이고, Last-Modified는 담지 않는다. 그 태그를 위해 내용은 파일 버전마다 한 번 읽으며, 디렉터리 루트는 파일의 변경 시각(change time)으로도 버전을 가리는데, cp -a나 rsync -a 같은 복사는 스탬프는 유지해도 변경 시각은 유지할 수 없기 때문이다. 따라서 길이가 그대로인 한 글자 수정도 다음 릴리스에서 브라우저에 도달한다. 기본값 Cache-Control: no-cache는 "캐시하되, 먼저 나에게 확인하라"는 뜻이며, 내용이 바뀌어도 이름은 그대로인 파일에 알맞다. 핑거프린트가 붙은 이름에는 긴 maxAge를 설정한다:

app.staticFiles(StaticFiles.classpath("/public")
        .hostedPath("/assets") // classpath:/public/* at /assets/*
        .maxAge(Duration.ofDays(365))); // only when the name carries a content hash

hostedPath는 패턴이 아니라 접두사를 받는다: "/assets"가 그 아래 전부를 덮고, "/assets/"는 IllegalArgumentException을 던진다. 필터 경로와 Cors.forPath는 반대로 패턴이어서, 그 아래를 덮으려면 끝의 가 필요하다.

라우트가 먼저 매칭되므로, 라우트가 파일을 가릴 수 있다. 디렉터리는 결코 제공되지 않는다.

정적 파일은 매칭된 라우트가 없으므로 beforeRoute와 afterRoute를 건너뛴다. beforeRequest("/admin/*", …​)는 정적 파일 조회 전에 /admin 아래로 제공되는 파일을 보호할 수 있다. 응답 필터는 정적 파일에도 실행되며, CORS, 압축, 보안 헤더도 마찬가지다. 파일은 본문을 쓸 때에만 열리므로, 필터가 응답을 바꾸거나 예외를 던져도 파일이 열린 채 남지 않는다. HEAD와 304도 마찬가지로 아무것도 열지 않으며, 파일이 디렉터리에 있든, 풀어 둔 클래스패스에 있든, jar 안에 있든 같다. app.gzip() 아래에서 압축될 HEAD는 Content-Length를 담지 않는데, 그 값은 파일 전체를 압축해야만 알 수 있기 때문이다. 수정 시각이 스탬프인 파일에 대한 첫 요청만은 예외로, 태그를 구하려고 파일을 한 번 읽는다.

디스크의 디렉터리

StaticFiles.directory(path)는 사용자 업로드, jar 옆에 마운트된 볼륨, 별도의 빌드가 기록하는 디렉터리처럼 클래스패스에 없는 파일을 제공한다:

app.staticFiles(
        StaticFiles.classpath("/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(StaticFiles.classpath("/public")
        .precompressed()); // app.css.br / app.css.gz answer app.css

두 형제 파일이 모두 있고 클라이언트가 둘 다 받을 수 있으면 Brotli가 이긴다. Brotli는 둘 중 더 작고, 코어의 다른 어떤 부분도 만들어 낼 수 없다. 응답은 어느 쪽이든 원본을 기술한다. .gz 확장자는 타입이 아니라 인코딩이므로 Content-Type은 원본의 것이다. ETag와 Last-Modified도 원본의 것이므로, 인코딩을 넘나들며 재검증하는 브라우저는 304를 그대로 받는다. 인코딩된 응답에서는 그 태그가 약한(weak) 것으로 표시되는데, gzip()이 자기 태그를 그렇게 표시하는 것과 같은 이유다.

형제 파일이 없는 에셋이나 어느 인코딩도 읽지 못하는 클라이언트는 gzip()까지 포함해 이전과 정확히 같게 응답받는다. 옆에 놓인 파일보다 오래된 형제 파일은 건너뛴다: 다시 실행되지 않은 빌드가 남긴 파일이며, 이를 제공하면 더는 존재하지 않는 내용을 제공하게 된다. precompressed()가 켜진 루트에서 나가는 모든 응답은 형제 파일을 찾았든 아니든 Vary: Accept-Encoding을 담으므로, 공유 캐시가 읽지 못하는 클라이언트에 인코딩된 바이트를 건네는 일은 없다.

precompressed()는 호출하기 전까지 꺼져 있는데, 예전 파이프라인이 남긴 .gz는 아무도 제공할 뜻이 없던 파일이기 때문이다.

디렉터리 안에 머물기

요청이 가리키는 파일이 모든 심볼릭 링크를 따라간 뒤에도 일반 파일이고 그 실제 경로가 루트 자체의 실제 경로 아래에 있을 때만 요청에 응답한다. 디렉터리 루트는 클래스패스 조회에는 없는 경로 탐색(path-traversal) 공격면이므로, 가드는 명시적이며 컨테이너에 기대지 않는다. 경로 어디에든 있는 ..는 가드가 실행되기 전에 거부되고, 루트 밖을 가리키는 심볼릭 링크는 가드가 거부한다. 가드가 거절한 요청은 이 루트에 없는 파일로 취급되므로, 라우팅은 계속되고 응답은 평소의 404다. 404는 파일이 거절되었는지 없었는지 말해 주지 않는다.