Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/web-inbound-and-http-surface/case/case-a14-f010-no-store.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

18 KiB

kind, slug, title, topic, project, status, sourceRevision, evidenceCapturedOn, rootTreeNode, body, assets, evidence, source
kind slug title topic project status sourceRevision evidenceCapturedOn rootTreeNode body assets evidence source
CASE a14-f010-no-store 저장 금지와 조건부 읽기가 같은 응답에 실리고, 둘을 이으라고 만든 이름은 아무도 읽지 않는다 web-inbound-and-http-surface clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 2026-09-03 case:a14-f010-no-store case-a14-f010-no-store.body.md
key file
a14-f010-no-store ../../../final/evidence/rendered/a14-f010-no-store.svg
key file
a14-f010-no-store-conditional ../../../final/evidence/rendered/a14-f010-no-store-conditional.svg
../../../final/evidence/raw/a14-f010-no-store.txt
../../../final/evidence/raw/a14-f010-no-store-conditional.txt
원본 분석 절은 final/document.md#a14#L899 이다.

저장 금지와 조건부 읽기가 같은 응답에 실리고, 둘을 이으라고 만든 이름은 아무도 읽지 않는다

이 저장소에는 조건부 읽기를 구현한 자리가 둘이다. 둘 다 같은 응답에 저장 금지를 함께 싣는다. 한쪽은 캐시 필터의 기본값이 그대로 남아서, 다른 한쪽은 자기 정책의 출하 기본값이 그래서다. 둘을 조정하라고 만든 어휘가 있지만 부르는 코드가 없다.

관계

  • 캐시 헤더를 소유하는 것은 24줄 상수 두 개다 이 사례에서 확인한 캐시 헤더 기본값을 개념으로 정리한 문서다.
  • maxArrayElements가 선언만 되고 강제되지 않으며, 바이트 예산 백스톱도 없다 같은 리프의 다른 P2 다.
  • 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다 같은 일을 하는 어휘가 여럿일 때 쓴 규칙이다.
  • 내부 소비자 부재와 public 계약 필요성을 따로 묻는다 참조 0 을 어떻게 읽을지 정할 때 쓴 규칙이다.
  • runtime_memberships를 먼저 읽고 심각도를 정한다 이 리프가 픽스처 배포에도 실리는지를 가릴 때 쓴 규칙이다.

문제

응답 하나에 개체 태그와 저장 금지가 함께 실리는지, 실린다면 그 저장 금지를 누가 쓰는지 확인했다.

결론

필터의 기본값은 예외를 허용하도록 설계돼 있다. 헤더가 사슬 앞에서 얹히므로 뒤에서 자기 값을 쓰는 쪽이 이긴다. 대조용 엔드포인트로 확인했다. 원문은 필터가 모든 응답에 저장 금지를 붙인다고 적었는데, 붙는 것은 덮어쓸 수 있는 기본값이다.

응답에 개체 태그를 쓰는 파일은 다섯이고, 그중 둘은 서로를 가리킬 뿐 밖에서 오는 호출이 없어 어느 응답에도 태그를 얹지 못한다. 남는 셋이 만드는 경로는 둘이다. 파일서버 내려받기와 픽스처 모듈의 단건 조회다.

두 자리가 저장 금지를 받는 경로는 서로 다르다.

내려받기 쪽은 지시자까지 스스로 정해서 필터의 값을 덮는다. 그러니 거기 실린 저장 금지는 그 경로의 출하 기본값이다. 태그와 304 결정을 만든 컴포넌트가 같은 응답에서 캐시를 무력화한다.

픽스처 모듈 쪽은 아무것도 쓰지 않아서 필터의 값이 그대로 남는다. 이 경로는 프로브로 왕복해 봤다. 세 요청 모두 태그와 저장 금지를 함께 받았고 304 도 예외가 아니었다.

Cache-Control 값을 정하는 컴포넌트는 두 배포에서 다르다. 플랫폼 배포에서는 보안 설정이 프레임워크 캐시 기록기를 꺼서 기본값을 얹는 것이 필터 하나만 남는다. 픽스처 모듈은 그 설정을 스캔에서 빼고 자기 체인으로 대신하는데, 거기에는 그 끄기가 없다. 그래서 픽스처 쪽 사슬에는 Spring Security 의 캐시 헤더 기록기가 그대로 있다. 그런데도 헤더가 같은 것은 그 기록기가 값이 이미 있으면 손대지 않기 때문이다. 필터 없이 돌린 배치가 그것을 보여 준다.

무엇이 무효가 되는지는 좁게 잡아야 한다. RFC 9111 의 저장 금지는 캐시에 거는 지시라, 끊기는 것은 재검증 경로다. 응답을 캐시에 맡기지 않고 태그만 자기 상태에 저장해 두는 소비자는 영향을 받지 않는다. 쓰기 쪽 일치 조건도 무관하다. 거기서는 클라이언트가 아무것도 쌓아 둘 이유가 없다.

이 어긋남을 없애라고 만들어 둔 것이 있다. 캐시 패키지가 프로파일과 지시자와 Vary 규칙을 네 파일 삼백열 줄로 담고 있는데, 그 타입을 부르는 자리는 전부 자기 패키지 안이거나 자기 시험이다. 연산 프로파일이 캐시 정책을 이름으로 들고 다니는데, 그 이름을 읽는 코드도 0 이다. 조건부 읽기 쪽에도 같은 자리가 있다. 원문에는 없는 것이다. 304 판정을 전담하라고 만든 클래스를 프로덕션에서 부르는 코드가 없다. 같은 패키지의 ETags 가 같은 비교를 따로 구현해 두었고, 조회 컨트롤러는 그 클래스를 부른다.

목록이 발행하는 프로파일 하나는 내려받기 경로가 손으로 쓴 것과 같은 문자열을 이미 갖고 있다. 이으려면 이름부터 맞춰야 한다. 연산 쪽 기본 이름이 그 목록에 없는 이름이기 때문이다.

모듈 경계가 이 패키지를 Spring 에서 떼어 둔 이유를 적어 두었다. 세 자리가 프로파일을 나눠 쓰게 하려는 것이었는데, 셋 중 어느 것도 그렇게 하고 있지 않다. 원문은 그 셋이 존재하지 않는다고 적었는데, 서블릿 기록기와 반응형 기록기는 파일서버 내려받기 경로에 있다. 이 패키지를 참조하지 않을 뿐이다.

지시자를 문자열로 쥔 main 자바 파일이 여덟인데 여섯은 이 패키지 바깥이다. 자바 밖에도 셋이 더 있다. 플랫폼 설정 파일의 프로퍼티 기본값과, 환경 파일 둘이 같은 문자열을 각각 대입으로 다시 쓴다.

권고는 필터가 상수 대신 프로파일 목록을 읽고, 연산에 맞는 프로파일이 없을 때만 저장 금지로 떨어지게 하는 것이다. 저장 금지 기본값 자체는 민감한 API 에 옳다. 바꿀 것은 예외가 없다는 점이다.

판정은 P2 다. 다만 두 경로의 도달성이 다르다.

내려받기는 파일서버 플랫폼 마스터 스위치가 켜져야 존재하고, 그 출하값은 세 곳 모두 거짓이다. 픽스처 모듈의 단건 조회는 다르다. config/architecture/modules.json 이 선언한 런타임 컴포지션은 app-bootstrap 과 sample-portfolio 둘이고, 픽스처 모듈이 그중 하나다. 그 모듈의 조회 컨트롤러에는 스위치가 없다. 프로브가 받은 세 응답이 그 배포에서 오늘 나오는 값이다.

기본 플랫폼 배포에서 무조건 도는 것은 필터뿐이고, 거기에는 아직 낭비할 조건부 읽기가 없다. 스위치를 켜면 내려받기 경로에서도 같은 일이 일어난다. 서버는 캐시를 거치는 소비자에게 매번 전부를 보내고, 태그 계산과 조건부 평가는 매 요청 돌면서 아무것도 아끼지 못한다. 어느 쪽도 오류를 내지 않으므로 조용하다.

검증 환경

OpenJDK : 21.0.12 확인 방식 : 필터와 컨트롤러 조합을 바꿔 세 사슬에서 여섯 번 왕복, 태그를 쓰는 자리와 그 조건, 리프의 런타임 소속과 캐시 어휘 계수 소스 수정 : x

재현 조건

원문 근거는 분석 문서의 #L899 절이다.

  1. 필터가 붙이는 두 헤더와 그것을 사슬 앞에 두는 이유를 읽는다.
  2. 보안 설정이 프레임워크 기록기를 끄는 자리와, 그 설정을 스캔에서 빼는 모듈을 읽는다.
  3. 개체 태그를 응답에 쓰는 파일을 전수로 찾고 각자를 켜는 조건을 읽는다.
  4. 내려받기 기록기가 태그와 지시자를 스스로 쓰는 두 줄과 그 출하 기본값을 읽는다.
  5. 픽스처 모듈의 조회 경로와 그 모듈이 캐시 지시자를 정하는지 확인한다.
  6. 필터와 컨트롤러를 한 사슬에 올려 태그 없이, 맞는 태그로, 틀린 태그로 요청한다.
  7. 대조용 엔드포인트 하나를 같은 사슬에 올려 덮어쓰기가 되는지 본다.
  8. 프레임워크 기록기를 사슬에 넣은 배치와 그것만 남긴 배치를 각각 돌린다.
  9. config/architecture/modules.json 에서 선언된 런타임 컴포지션과 이 리프의 runtime_memberships 를 읽는다.
  10. 캐시 패키지의 타입을 부르는 자리, 연산 프로파일의 캐시 이름을 읽는 자리, 지시자 문자열을 들고 있는 자리를 센다.

본문

필터는 사슬에 들어가기 전에 두 헤더를 얹는다.

// CacheControlFilter.java:24-34
  static final String DEFAULT_CACHE_CONTROL = "no-store";
  static final String DEFAULT_VARY = "Accept, Accept-Encoding, Authorization";

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    response.setHeader(ApiHeaders.CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
    response.setHeader(ApiHeaders.VARY, DEFAULT_VARY);
    chain.doFilter(request, response);
  }

기본값을 먼저 얹고 사슬을 부르므로 뒤에서 자기 헤더를 쓰는 엔드포인트가 이긴다. 클래스 자바독이 그것을 옵트인이라고 부른다.

소유자는 배포마다 다르다

:::evidence key="a14-f010-no-store" alt="코드베이스 정적 검색 출력 158줄. 필터의 자바독과 두 상수와 본문, 보안 설정이 프레임워크 캐시 기록기를 끄는 네 줄과 그 설정을 빼는 픽스처 모듈과 그 모듈이 적어 둔 제외 이유와 대신 두는 체인, 응답에 개체 태그를 쓰는 파일 다섯과 그중 서로를 가리킬 뿐 밖에서 호출이 없는 둘, 시험 밖에서 부르는 자리가 없는 304 판정 클래스와 같은 패키지에서 실제로 호출되는 ETags, 레지스트리가 선언한 런타임 컴포지션과 이 리프의 소속, 그 304 판정 클래스의 자바독 첫 줄, 내려받기 컨트롤러를 켜는 조건과 그 스위치의 출하값, 내려받기 기록기가 쓰는 태그와 지시자와 설정 파일의 기본값, 픽스처 모듈의 조회 경로, 캐시 패키지 네 파일의 줄 수와 그 타입을 부르는 파일별 횟수, 모듈 경계가 적은 근거, 캐시 헤더를 쓰는 자리, 연산 프로파일의 캐시 이름과 그것을 읽는 자리 수, 지시자 문자열을 들고 있는 자리, 목록이 발행하는 프로파일 이름이 차례로 보인다." caption="캐시 정책을 정하는 자리와 그것을 쓰는 자리 — 158줄 · exit 0" zoom="true" :::

플랫폼 쪽 보안 설정은 프레임워크의 캐시 헤더 기록기를 끄고 주석에 이유를 적는다. 캐시 헤더 정책은 이 필터가 갖는다는 것이다. 기본값을 얹는 자리가 하나라는 뜻이고, 그 뒤에 자기 값을 쓰는 기록기는 별개다.

픽스처 모듈은 그 보안 설정을 컴포넌트 스캔에서 뺀다. 공개 데모라 IdP 없이 돌아야 한다는 이유다. 대신 /** 를 잡는 자기 체인을 두는데, 그 체인은 캐시 헤더 기록기를 끄지 않는다. 그래서 그 배포에는 프레임워크 기록기가 남는다.

태그를 싣는 경로는 내려받기와 픽스처 조회 둘이다

응답에 개체 태그를 쓰는 main 파일은 다섯이다. 아웃바운드 S3 클라이언트는 뺐다. 그중 WebSuccessResponseWebResponseContract 를 부르고 WebResponseContract 는 자바독으로 그것을 되가리키는데, 둘 다 밖에서 오는 호출이 없다. 남는 것은 내려받기 기록기 둘과 픽스처 모듈의 조회 컨트롤러이고, 내려받기의 서블릿 쪽과 반응형 쪽은 같은 결정을 두 전송으로 그린 것이므로 태그를 싣는 경로는 둘이다.

같은 자리에 하나 더 있다. ConditionalReadEvaluator 가 GET 과 HEAD 를 304 로 답해도 되는지 판정하라고 만들어져 있는데, 자기 시험 말고는 부르는 코드가 없다. 같은 conditional 패키지에 ETags 가 따로 있고, 그 클래스가 같은 비교를 한다. 픽스처 컨트롤러는 그 비교를 ETags.matches 로 부른다. 한 패키지에 판정기가 둘 있고, 프로덕션이 부르는 쪽은 전담 클래스가 아니다.

내려받기 기록기는 태그를 쓴 뒤 지시자도 자기가 쓴다.

// MvcDownloadResponseWriter.java:37,43
    response.setHeader(HttpHeaders.ETAG, descriptor.representation().strongEtag());
    ...
    response.setHeader(HttpHeaders.CACHE_CONTROL, descriptor.cacheControl());

setHeader 이므로 필터가 얹어 둔 값은 덮인다. 거기 실리는 저장 금지는 필터의 것이 아니라 내려받기 정책 자신의 출하 기본값이다. DownloadPolicy:25 가 설계 기본값으로 정하고, application.yml:880 이 프로퍼티 기본값으로, .env.env.example 이 각각 대입으로 같은 문자열을 다시 쓴다. 조건부 요청은 별도로 평가되어 304 결정까지 간다.

한 컴포넌트가 강한 태그와 304 결정을 만들어 놓고, 같은 응답에 캐시더러 아무것도 남기지 말라고 적는 셈이다.

다만 이 경로는 켜야 존재한다. 컨트롤러가 app.fileserver-platform.enabled 에 걸려 있고 그 스위치의 출하값은 세 곳 모두 거짓이다.

사슬에 올려 받은 헤더

:::evidence key="a14-f010-no-store-conditional" alt="JVM 프로브 출력 14줄. 조건부 읽기 경로에 태그 없이, 맞는 태그로, 틀린 태그로 보낸 세 요청의 상태와 캐시 지시자와 개체 태그, 스스로 지시자를 붙이는 엔드포인트 한 줄, 프레임워크 기록기를 함께 넣은 배치와 그것만 남긴 배치 두 줄이 보인다." caption="세 사슬에서 받은 상태와 헤더 — 14줄 · exit 0" zoom="true" :::

픽스처 모듈의 단건 조회를 필터와 함께 한 사슬에 올리고 같은 자원에 세 번 요청했다. 저장소는 버전 3 인 표현 하나만 돌려준다.

세 번 다 저장 금지와 W/"3" 이 함께 나온다. 맞는 태그를 되돌려 보내면 304 가 오는데 그 304 도 저장 금지를 달고 있다.

넷째 줄이 대조군이다. 자기 지시자를 붙이는 엔드포인트에서는 max-age=60 이 그대로 나온다. 덮어쓰기는 실제로 동작하고, 조회 경로가 그것을 쓰지 않을 뿐이다.

마지막 두 줄은 프레임워크 기록기를 넣은 배치다. 둘이 함께 있으면 헤더는 그대로다. 그 기록기가 이미 값이 있으면 쓰지 않기 때문이다. 필터를 빼고 기록기만 남기면 네 지시자짜리 다른 문자열이 나온다.

무엇이 무효가 되는가

// WorkLogController.java:143-147
    String etag = etagOf(workLog);
    if (ETags.matches(ifNoneMatch, etag)) {
      return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build();
    }
    return ResponseEntity.ok().eTag(etag).body(WorkLogWebMapper.toResponse(workLog));

RFC 9111 의 저장 금지는 캐시에 거는 지시다. 규격을 지키는 캐시는 표현도 태그도 남기지 않으므로, 캐시를 통해 재검증하는 클라이언트는 다음 요청에 붙일 것이 없다. 태그를 애플리케이션 상태로 따로 들고 있는 소비자는 여전히 조건부 요청을 보낼 수 있다. 무효가 되는 것은 캐시 재검증 쪽이다.

쓰기 쪽 일치 조건은 다르다. 방금 받은 태그를 같은 세션에서 되돌려 보내는 낙관적 동시성이라 보관이 필요 없다.

프로파일 이름과 그 이름을 읽는 코드

캐시 패키지는 네 파일이다. 헤더 값 28줄, 정책 레코드 72줄, 프로파일 목록 120줄, Vary 규칙 90줄이다. 그 타입들을 이름으로 부르는 자리는 전부 그 패키지 안이거나 그 패키지의 시험이고, 밖에서 부르는 파일은 없다.

연산 프로파일 쪽도 같다. WebOperationProfileCachePolicyName 을 필드로 들고 다니지만 cachePolicy() 를 읽는 자리가 0 이다.

목록이 발행하는 프로파일은 sensitive immutable-asset revalidated browser-private 넷이고, sensitive 의 지시자가 private, no-store 다. 파일서버가 손으로 다시 쓴 그 문자열이다. 다만 연산 쪽 기본 이름은 no-store 라 목록에 그런 이름이 없다. 이름부터 맞지 않는다.

모듈 경계는 이 패키지를 프레임워크에서 떼어 두면 기록기 둘과 문서 생성기가 같은 프로파일을 나눠 쓸 수 있다고 적는다. 그 셋 중 이 패키지의 프로파일을 적용하는 것은 없다. 저장소에는 서블릿 기록기와 반응형 기록기라 부를 만한 쌍이 둘 있지만 어느 쪽도 이 패키지를 참조하지 않는다.

지시자를 문자열로 들고 있는 main 자바 파일은 여덟이고, 그중 여섯이 이 패키지 밖이다. 자바가 아닌 자리도 셋이다. 설정 파일의 프로퍼티 기본값과 환경 파일 둘의 대입이다.

확인하지 못한 것

실제 클라이언트나 중간 캐시로 두 요청을 보내 재검증이 일어나지 않는 것을 재현하지는 않았다. 실제 클라이언트나 중간 캐시의 동작은 재현하지 않았고, 서버가 보내는 헤더까지만 확인했다.

프로브는 사슬 셋을 세웠다. 필터와 컨트롤러, 필터와 프레임워크 기록기, 기록기 단독이다. 기록기가 든 두 사슬은 200 응답으로만 돌렸다. 경로 접두사를 붙이는 설정이 없어 요청 경로도 배포와 다르다. 헤더에는 영향이 없다고 읽었다.

파일서버 내려받기 경로는 읽기만 했고 돌려 보지 않았다. 내려받기 응답에 태그와 지시자가 함께 실린다는 것은 기록기의 두 줄과 출하 기본값에서 읽은 것이고, 마스터 스위치를 켠 배포를 띄워 본 것도 아니다.