PC 브라우저에서는 호박 수프 카드에 호박 수프 그림이 붙어 있었다. 같은 주소를 폰으로 열면 호박 수프 카드에 카야 토스트 그림이 붙어 있었다. 서버는 이미 고쳤고, 다시 배포도 했다.
사진이 없는 공용 레시피에는 제목과 종류를 넣어 그린 SVG 플레이스홀더 seed-{레시피 Id}.svg 를 붙인다. 로컬에서 만든 이 파일들이 게시 패키지에 실려 운영 서버의 같은 이름 파일을 덮어쓰고 있었다. 로컬과 운영은 레시피 Id 가 다르니 그림이 엉뚱한 카드로 간 것이다. 업로드 폴더를 게시 대상에서 빼고, 앱이 시작할 때 플레이스홀더가 지금 제목·종류와 다르면 다시 그리게 했다. 서버의 파일은 제자리를 찾았다.
폰만 그대로였다. 폰 안의 무언가가 서버보다 먼저 대답하고 있었다.
서랍 세 칸짜리 서비스 워커
식단서랍은 홈 화면에 설치하는 PWA 다. 처음 넣은 sw.js 는 빈 fetch 핸들러뿐인 껍데기였고, 설치 조건을 맞추는 데는 그걸로 충분했다. 그다음 오프라인에서도 쓸 수 있게 캐시 전략을 넣었다.
요청은 세 갈래로 나눴다. CSS·JS·아이콘 같은 정적 셸은 캐시에 있으면 네트워크에 묻지 않는 cache-first. 레시피 사진이 있는 /Uploads/ 도 cache-first 다. 사진은 잘 바뀌지 않고, 조금 오래된 사진이 보여도 큰일은 아니라고 봤다. HTML 페이지는 network-first 로 두고, 네트워크가 안 될 때만 캐시나 오프라인 안내 페이지를 보여 준다. 쓰기 요청과 로그인·계정·관리자 화면은 손대지 않고 통과시킨다.
캐시 이름에는 static-{VERSION}, images-{VERSION}, pages-{VERSION} 처럼 버전이 붙고, 새 서비스 워커가 활성화되면 지금 버전이 아닌 캐시를 전부 지운다. 배포마다 버전이 바뀌면 배포마다 캐시가 비워진다는 계산이었다.

PWA 설치 창에 보여 주려고 manifest.webmanifest 의 screenshots 에 넣은 화면 중 하나다. 카드마다 붙은 그림이 문제의 플레이스홀더다. 이 그림들이 cache-first 로 폰에 저장돼 있었다. 서버 파일이 바뀌어도 서비스 워커는 서버에 묻지 않는다. 캐시를 비울 방법은 버전이 바뀌는 것 하나였다.
파일을 고치면 안 되는 파일
버전은 원래 손으로 적었다. 배포 때 고치는 걸 잊으면 브라우저는 옛 서비스 워커를 계속 쓴다. 그래서 소스의 sw.js 에는 __BUILD_VERSION__ 자리표시자만 두고, dotnet publish 가 끝난 뒤 MSBuild 타깃이 게시 산출물의 sw.js 를 열어 그 자리를 시각 문자열로 바꿔 쓰게 했다. 게시 결과물을 열어 보면 버전이 잘 들어가 있었다.
그 버전이 폰에는 한 번도 도착하지 않았다.
ASP.NET Core 9 의 MapStaticAssets 는 빌드할 때 wwwroot 를 훑어 정적 파일 목록을 만든다. 파일마다 크기와 해시를 적고, 미리 압축한 .br·.gz 도 만들어 둔다. 요청이 오면 그 목록을 믿고 응답한다. 게시 후에 sw.js 를 고친 건 목록이 다 만들어진 다음이었다.
결과는 두 갈래였다. 압축을 받지 않는 요청에는 원본이 나가는데, 목록의 Content-Length 와 실제 크기가 어긋나 500 이 났다. 압축을 받는 요청에는 미리 만든 sw.js.br 가 나갔고, 그건 치환 전 원본을 압축한 것이라 여전히 __BUILD_VERSION__ 이 들어 있었다. 휴대폰 브라우저는 Brotli 를 받는다. 배포를 몇 번 하든 폰은 글자 하나 다르지 않은 sw.js 를 받았고, 서비스 워커는 바뀌지 않았고, 이미지 캐시는 지워질 기회가 없었다.
게시 후에 정적 파일을 고치는 방식을 버렸다. 앱이 시작할 때 sw.js 를 한 번 읽어 버전을 채워 두고, /sw.js 요청은 MapStaticAssets 보다 앞선 미들웨어가 그 문자열로 직접 응답한다. Cache-Control 은 no-cache 다.
static string? BuildServiceWorkerScript(IWebHostEnvironment env)
{
var path = Path.Combine(env.WebRootPath, "sw.js");
if (!File.Exists(path)) return null;
var template = File.ReadAllText(path);
var asm = typeof(Program).Assembly;
var seed = template + asm.ManifestModule.ModuleVersionId + File.GetLastWriteTimeUtc(asm.Location).Ticks;
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(seed));
var version = Convert.ToHexString(hash)[..12].ToLowerInvariant();
return template.Replace("__BUILD_VERSION__", version);
}
버전은 시각이 아니라 해시다. sw.js 본문, 앱 어셈블리의 MVID, 어셈블리 빌드 시각을 이어 붙여 SHA-256 을 낸다. 코드든 sw.js 든 바뀐 채로 배포되면 버전이 달라진다. 압축본이 끼어들 틈이 없으니 압축을 받든 안 받든 같은 문자열이 나간다.
하나 더 막았다. 서비스 워커 캐시에 없는 이미지를 가지러 갈 때 fetch(req, { cache: 'no-cache' }) 로 브라우저 HTTP 캐시도 재검증한다. 서비스 워커 캐시를 비워도 브라우저가 들고 있던 옛 파일을 꺼내 주면 소용없기 때문이다. 같은 이름으로 다시 써지는 seed-*.svg 에는 서버도 no-cache 를 붙인다. 바뀐 게 없으면 304 라 비용은 작다. 게시 산출물을 Production 모드로 띄워 sw.js 가 압축 여부와 상관없이 같은 버전으로 나가는 걸 확인했다.
로그아웃한 뒤에도 남은 화면
캐시는 오래 남기는 게 일이라, 남으면 안 되는 것까지 남긴다.
pages-* 캐시에는 내 월간 메뉴, 내 레시피, 내 장보기 목록 같은 HTML 이 저장된다. 로그아웃하면 서버 세션은 끝나지만 이 캐시는 그대로였다. 같은 기기를 다른 사람이 쓰다가 오프라인이 되면, 서비스 워커는 성실하게 이전 사용자의 화면을 꺼내 줄 수 있었다. 로그인하지 않은 상태로 화면이 열리면 페이지 캐시를 지우는 부분 뷰를 만들었다.
@if (User.Identity?.IsAuthenticated != true)
{
<script>
if ('caches' in window) {
caches.keys().then(function (keys) {
keys.filter(function (k) { return k.indexOf('pages-') === 0; })
.forEach(function (k) { caches.delete(k); });
}).catch(function () { });
}
</script>
}
로그아웃하면 도착하는 랜딩 화면은 공통 레이아웃을 쓰지 않아서 양쪽에 넣었다. 지우는 건 pages-* 뿐이고, 누구의 것도 아닌 사진과 정적 파일은 남긴다.
들여다보는 김에 반대쪽 문제도 보였다. site.css·site.js 는 asp-append-version 으로 ?v=해시 를 달고 나가는데 헤더는 MapStaticAssets 기본값인 no-cache 여서, 페이지를 열 때마다 304 왕복이 붙었다. 내용이 바뀌면 v 가 바뀌어 다른 주소가 되니, v 가 붙은 .css·.js 응답만 public, max-age=31536000, immutable 로 바꿨다. 압축도 비어 있었다. 운영 IIS 의 동적 압축은 text/html 만 처리해서, 메뉴에 넣을 레시피를 고르는 JSON 목록이 702KB 그대로 나갔다. ASP.NET Core 응답 압축 미들웨어가 application/json 만 Brotli·Gzip(Fastest) 으로 맡게 하니, 로컬에서 같은 응답이 79.5KB 에서 15.8KB 로 줄었다.
어두운 폰이 그린 화면
폰에서만 다르게 보이는 문제가 하나 더 있었다. 다크 테마를 쓰는 안드로이드에서 랜딩 화면이 깨졌다.
식단서랍에는 라이트 화면만 있었다. 안드로이드 Chrome 과 삼성 인터넷은 다크를 지원하지 않는 페이지를 알아서 어둡게 뒤집는데, 뒤집힌 랜딩에서 히어로 배경의 SVG 무늬가 밝은 덩어리로 남았다. 첫 대응은 거절이었다. 레이아웃마다 <meta name="color-scheme" content="only light"> 를 넣어 라이트 전용이라고 선언했다.
삼성 인터넷은 그 선언을 무시하고 계속 뒤집었다. 게다가 SVG fill 은 반전 대상에서 빠져, 거의 흰색인 fill-gray-50 사각형이 글자 뒤에 흰 덩어리로 남았다. 검정에 가까운 색을 5% 만 칠하는 fill-gray-900/5 로 바꿔 지웠다. 라이트에서는 거의 같은 옅은 회색이고, 반전되면 드러나지 않는다.
거기서 생각을 바꿨다. 뒤집지 말라고 버틸 게 아니라 다크 화면을 직접 주면 뒤집을 이유가 없다. 뷰마다 dark: 클래스를 붙이는 대신 Tailwind v4 유틸리티가 색을 전부 var(--color-*) 로 참조한다는 점을 이용했다. 변수만 바꾸면 화면 전체가 따라온다.
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--color-gray-50: oklch(0.13 0.028 261.692); /* ← gray-950 */
--color-gray-100: oklch(0.278 0.033 256.848); /* ← gray-800 */
/* ... */
--color-gray-900: oklch(0.985 0.002 247.839); /* ← gray-50 */
}
.bg-white { background-color: oklch(0.21 0.034 264.665); }
}
gray 와 slate 팔레트는 50 과 900 이 맞바뀌게 뒤집고, --color-white 는 그대로 뒀다. 흰색을 뒤집으면 인디고 버튼 위 흰 글자까지 어두워지기 때문이다. 대신 카드 표면인 .bg-white 클래스만 어두운 색으로 덮었다. 메타 태그는 light dark 로 바꾸고 다크용 theme-color 도 넣었다. 다크를 지원한다고 선언하니 삼성 인터넷의 강제 반전도 끼어들지 않았다.
회색 밖의 색은 따로 손봐야 했다. 달력의 오늘 칸 bg-amber-50 은 어두운 화면에서 크림색으로 튀어서 반투명 틴트로 바꿨다. 아침 칩은 글자 text-amber-800 만 밝은 노랑이 되고 배경 bg-amber-100 은 그대로라 노랑 위에 노랑이었다. 눈으로 하나씩 찾아서는 끝이 없어서, 주요 화면 열세 곳의 글자 대비를 재 4.5:1 에 못 미치는 조합을 골랐다. 프로필 이니셜 2.5:1, '발행' 버튼 2.4:1 같은 것들이다. 연한 배경은 반투명 진한 톤으로, 진한 글자는 300 단계로 옮기니 이니셜이 6.7:1 이 됐다. 같은 측정을 라이트에 돌리자 12px 상태 글자와 주말 요일 색이 조금 모자라 700 으로 올렸다. 다크에서는 700 이 이미 300 으로 바뀌니 그대로 잘 보였다.
옛날 화면이 남는 문제와 폰이 화면을 멋대로 칠하는 문제는 뿌리가 같았다. 서버가 내보낸 것과 사용자가 보는 것 사이에 내가 직접 보지 않는 층이 하나 있었고, 그 층에게 무엇을 해야 하는지 분명히 알려 줘야 만든 화면이 그대로 도착했다.
다음 편은 이 연재의 마지막이다. 「오늘은 뭐 먹지?」를 붙잡아 두려고 시작한 서랍이 지금 어디까지 왔는지 돌아본다.