{ "assetKey": "a14-f010-no-store-conditional", "kind": "terminal", "command": "set -e\nset -o pipefail\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nC=/shared/codebase/clean-architecture-backend-template\nM=$C/src/sample-portfolio\nCACHE=/root/.gradle/caches/modules-2/files-2.1\njava -version 2>&1 | grep version\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",testRuntimeClasspath,\"*) ;; *) continue ;; esac\n g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}\n # 잠금 파일의 좌표 하나가 캐시에 없다. 없는 것은 건너뛴다.\n jar=$(find \"$CACHE/$g/$n/$v\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1) || true\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$M/gradle.lockfile\")\n# 형제 모듈은 고정 리비전 산출물만 붙인다.\n# sample-portfolio 는 고정 리비전에 -plain 만 있으므로 둘 다 받는다.\nSIB=$(find \"$C/src\" -path '*/build/libs/*+21234e38cdb9*.jar' ! -name '*-testkit.jar' | sort | tr '\\n' ':')\ncat > \"$D/CacheConditionalProbe.java\" <<'JAVA'\nimport dev.caskeleton.adapter.inbound.web.filter.CacheControlFilter;\nimport dev.caskeleton.application.transaction.TransactionPort;\nimport dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController;\nimport dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase;\nimport dev.caskeleton.sample.portfolio.domain.worklog.Period;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLog;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository;\nimport dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField;\nimport java.time.LocalDate;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.function.Supplier;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.security.web.header.HeaderWriterFilter;\nimport org.springframework.security.web.header.writers.CacheControlHeadersWriter;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.MvcResult;\nimport org.springframework.test.web.servlet.request.MockMvcRequestBuilders;\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n/** 캐시 필터와 조건부 읽기 경로를 한 사슬에 올려 응답 헤더를 읽는다. */\npublic final class CacheConditionalProbe {\n\n private static final String ID = \"0190bd6e-7c3e-7abc-8def-0123456789ab\";\n\n /** 저장된 표현 하나. version 3 이므로 ETag 는 W/\"3\" 이다. */\n private static WorkLog sample() {\n return WorkLog.rehydrate(\n WorkLogId.of(ID), \"샘플\", WorkCategory.DATABASE, \"s\", \"c\",\n List.of(\"postgres\"), List.of(\"https://example.com\"),\n new Period(LocalDate.of(2025, 1, 1), null), 3L);\n }\n\n /** 조회만 하는 저장소. 나머지 연산은 이 프로브가 부르지 않는다. */\n private static final class OneRow implements WorkLogRepository {\n @Override public WorkLog save(WorkLog workLog) { throw new UnsupportedOperationException(); }\n @Override public Optional findById(WorkLogId id) { return Optional.of(sample()); }\n @Override public WorkLogPage findPage(\n int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) {\n throw new UnsupportedOperationException();\n }\n @Override public boolean existsById(WorkLogId id) { return true; }\n @Override public void deleteById(WorkLogId id) { throw new UnsupportedOperationException(); }\n }\n\n /** 경계만 흉내 내고 그대로 실행하는 트랜잭션 포트. */\n private static final class Direct implements TransactionPort {\n @Override public T inWrite(Supplier action) { return action.get(); }\n @Override public T inRootWrite(Supplier action) { return action.get(); }\n @Override public T inRead(Supplier action) { return action.get(); }\n @Override public T inNew(Supplier action) { return action.get(); }\n }\n\n /** 스스로 캐시 지시자를 붙이는 대조용 엔드포인트. */\n @RestController\n public static class OptsIn {\n @GetMapping(\"/opts-in\")\n public ResponseEntity get() {\n return ResponseEntity.ok().header(\"Cache-Control\", \"max-age=60\").body(\"ok\");\n }\n }\n\n private static void row(String label, MvcResult result) {\n System.out.printf(\" %-24s %-6d %-46s %s%n\", label,\n result.getResponse().getStatus(),\n String.valueOf(result.getResponse().getHeader(\"Cache-Control\")),\n String.valueOf(result.getResponse().getHeader(\"ETag\")));\n }\n\n public static void main(String[] args) throws Exception {\n GetWorkLogUseCase get = new GetWorkLogUseCase(new OneRow(), new Direct());\n WorkLogController controller =\n new WorkLogController(null, null, null, null, get, null, null);\n MockMvc mvc = MockMvcBuilders.standaloneSetup(controller, new OptsIn())\n .addFilters(new CacheControlFilter())\n .build();\n\n System.out.println();\n System.out.println(\"[조건부 읽기 경로에 세 번 요청한다]\");\n System.out.printf(\" %-24s %-6s %-46s %s%n\", \"요청\", \"상태\", \"Cache-Control\", \"ETag\");\n row(\"첫 요청\", mvc.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID).accept(MediaType.APPLICATION_JSON))\n .andReturn());\n row(\"받은 태그를 되돌려\", mvc.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID)\n .header(\"If-None-Match\", \"W/\\\"3\\\"\").accept(MediaType.APPLICATION_JSON))\n .andReturn());\n row(\"맞지 않는 태그\", mvc.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID)\n .header(\"If-None-Match\", \"W/\\\"2\\\"\").accept(MediaType.APPLICATION_JSON))\n .andReturn());\n\n System.out.println();\n System.out.println(\"[스스로 지시자를 붙이는 엔드포인트]\");\n row(\"직접 붙인 값\", mvc.perform(MockMvcRequestBuilders.get(\"/opts-in\")).andReturn());\n\n // 이 컨트롤러가 사는 모듈은 SecurityConfig 를 스캔에서 빼므로 프레임워크 기록기가 사슬에 남는다.\n MockMvc withWriter = MockMvcBuilders.standaloneSetup(controller, new OptsIn())\n .addFilters(new CacheControlFilter(),\n new HeaderWriterFilter(java.util.List.of(new CacheControlHeadersWriter())))\n .build();\n MockMvc writerOnly = MockMvcBuilders.standaloneSetup(controller, new OptsIn())\n .addFilters(new HeaderWriterFilter(java.util.List.of(new CacheControlHeadersWriter())))\n .build();\n\n System.out.println();\n System.out.println(\"[프레임워크 캐시 기록기가 사슬에 함께 있을 때]\");\n row(\"둘 다 있을 때\", withWriter.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID).accept(MediaType.APPLICATION_JSON))\n .andReturn());\n row(\"기록기만 있을 때\", writerOnly.perform(\n MockMvcRequestBuilders.get(\"/worklogs/\" + ID).accept(MediaType.APPLICATION_JSON))\n .andReturn());\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$SIB$cp\" -d \"$D/out\" \"$D/CacheConditionalProbe.java\" 2>&1 | grep -v '^$' || true\n# 부팅 로그는 시각을 달고 나오므로 끈다. 이 프로브가 읽는 것은 응답 헤더뿐이다.\nprintf '%s\\n' '' > \"$D/out/logback.xml\"\njava -Dstdout.encoding=UTF-8 -cp \"$D/out:$SIB$cp\" CacheConditionalProbe\n", "cwd": "/shared/codebase/clean-architecture-backend-template/src", "exitCode": 0, "executedAt": "2026-09-03T00:50:44+00:00", "sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "raw": "evidence/raw/a14-f010-no-store-conditional.txt", "svg": "evidence/rendered/a14-f010-no-store-conditional.svg", "rawSha256": "370a155d118b32adbf1395ea91f339a8d078c99a61d16e6138f8096013fb6fa6", "lines": 14, "redaction": "none — 요청 헤더와 표현은 이 프로브가 넣은 것이다" }