<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://dev.minahdev.cloud/feed.xml" rel="self" type="application/atom+xml" /><link href="https://dev.minahdev.cloud/" rel="alternate" type="text/html" /><updated>2026-09-29T03:42:26+00:00</updated><id>https://dev.minahdev.cloud/feed.xml</id><title type="html">개발 로그</title><subtitle>만들면서 겪은 것을 그때그때 적는다. 무엇이 잘못됐고 무엇을 골랐는지, 근거와 함께.</subtitle><author><name>김민아</name></author><entry><title type="html">재생성하면 업로드 사진이 사라졌다 — named volume, 그리고 어긋난 서빙 경로</title><link href="https://dev.minahdev.cloud/2026/09/21/uploads-volume/" rel="alternate" type="text/html" title="재생성하면 업로드 사진이 사라졌다 — named volume, 그리고 어긋난 서빙 경로" /><published>2026-09-21T12:00:00+00:00</published><updated>2026-09-21T12:00:00+00:00</updated><id>https://dev.minahdev.cloud/2026/09/21/uploads-volume</id><content type="html" xml:base="https://dev.minahdev.cloud/2026/09/21/uploads-volume/"><![CDATA[<p>커뮤니티 게시물에 사진을 올리는 기능이 있다. 백엔드 컨테이너를 재빌드하고 나면 올렸던 사진이 없어졌다. 고친 커밋은 <code class="language-plaintext highlighter-rouge">9d1b787</code>, 바뀐 줄은 세 줄이다.</p>

<h2 id="증상--재빌드하면-없다">증상 — 재빌드하면 없다</h2>

<p>올릴 때는 성공한다. <code class="language-plaintext highlighter-rouge">docker compose up --build</code> 로 백엔드를 다시 올리고 나면 그 사진들이 없다. DB 의 게시물 행은 남아 있다. 사라진 것은 파일뿐이다.</p>

<p>DB 는 남고 파일만 사라진다는 게 범위를 좁혀준다. Postgres·Redis·Neo4j 는 전부 볼륨을 달고 있고 업로드 파일만 아무것도 달려 있지 않았다.</p>

<h2 id="원인--쓰기-가능-레이어는-컨테이너와-수명을-같이-한다">원인 — 쓰기 가능 레이어는 컨테이너와 수명을 같이 한다</h2>

<p>이미지 레이어는 읽기 전용이다. 컨테이너를 만들면 그 위에 얇은 <strong>쓰기 가능 레이어</strong>가 하나 올라가고, 컨테이너 안에서 만든 파일은 전부 거기에 쌓인다. 이 레이어는 이미지가 아니라 <strong>컨테이너에 딸린 것</strong>이라 컨테이너가 없어지면 같이 없어진다.</p>

<p>여기서 갈리는 게 재시작과 재생성이다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">docker restart</code> — 같은 컨테이너를 다시 켠다. 쓰기 가능 레이어는 그대로다. 파일이 남는다.</li>
  <li><code class="language-plaintext highlighter-rouge">docker compose up --build</code> — 이미지가 바뀌었으니 컨테이너를 <strong>새로 만든다</strong>. 새 컨테이너는 새 쓰기 가능 레이어를 받는다. 이전 파일은 이전 컨테이너와 함께 사라진다.</li>
</ul>

<p>그래서 증상이 “가끔” 나는 것처럼 보였다. 재시작만 했을 때는 멀쩡했으니까.</p>

<p>저장 코드는 <code class="language-plaintext highlighter-rouge">minahai/apps/inbody/community_media.py</code> 다. 업로드마다 디렉터리를 보장하고 UUID 이름으로 쓴다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">APPS_DIR</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">__file__</span><span class="p">).</span><span class="n">resolve</span><span class="p">().</span><span class="n">parents</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
<span class="n">COMMUNITY_UPLOAD_DIR</span> <span class="o">=</span> <span class="n">APPS_DIR</span> <span class="o">/</span> <span class="s">"uploads"</span> <span class="o">/</span> <span class="s">"community"</span>
</code></pre></div></div>

<p>컨테이너 안에서 이 값은 <code class="language-plaintext highlighter-rouge">/app/apps/uploads/community</code> 다. Dockerfile 이 <code class="language-plaintext highlighter-rouge">WORKDIR /app</code> 에 <code class="language-plaintext highlighter-rouge">minahai/</code> 를 통째로 복사하니 <code class="language-plaintext highlighter-rouge">community_media.py</code> 는 <code class="language-plaintext highlighter-rouge">/app/apps/inbody/</code> 에 있고, <code class="language-plaintext highlighter-rouge">parents[1]</code> 이 <code class="language-plaintext highlighter-rouge">/app/apps</code> 가 된다.</p>

<p>그리고 이 디렉터리는 저장소에 없다. 이미지 안에도 없다. 런타임에 처음 생기는 디렉터리이고, 생긴 자리가 쓰기 가능 레이어다.</p>

<h2 id="bind-mount-가-아니라-named-volume">bind mount 가 아니라 named volume</h2>

<p>같은 <code class="language-plaintext highlighter-rouge">backend</code> 서비스에는 이미 마운트가 두 개 있었다. 둘 다 bind mount 다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 로컬 학습 산출물(best.pt)을 컨테이너에서 그대로 읽도록 마운트</span>
<span class="pi">-</span> <span class="s">./minahai/apps/star_craft/resources/yolo_train/runs:/app/apps/star_craft/resources/yolo_train/runs</span>
<span class="c1"># 크롤/스크랩 결과(crawled.jsonl·scraped.jsonl)를 호스트에서 바로 확인하도록 마운트</span>
<span class="pi">-</span> <span class="s">./minahai/resources:/app/resources</span>
</code></pre></div></div>

<p>주석에 고른 이유가 적혀 있다. <strong>호스트에서 직접 열어봐야 하는 것</strong>이라서 bind mount 다. 학습 산출물은 호스트에서 만들어 컨테이너가 읽고, 크롤 결과는 컨테이너가 쓴 걸 호스트에서 확인한다. 경로가 사람에게 보여야 하는 데이터다.</p>

<p>업로드 사진은 성격이 다르다. 호스트 탐색기로 열어볼 이유가 없고, 필요한 건 <strong>없어지지 않는 것</strong>뿐이다. bind mount 로 하면 호스트 경로가 계약에 들어온다. 저장소 워킹트리 안(<code class="language-plaintext highlighter-rouge">./minahai/apps/uploads</code>)으로 잡으면 사용자가 올린 사진이 git 저장소 디렉터리에 쌓이고, 저장소 밖으로 잡으면 그 절대경로가 머신마다 달라진다. 둘 다 원하는 게 아니다.</p>

<p>그래서 named volume 으로 했다. 추가된 세 줄이 전부다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">backend</span><span class="pi">:</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="c1"># 커뮤니티 사진 등 업로드 파일(apps/uploads/*)이 컨테이너 재생성·재빌드에도 남도록 볼륨에 둔다</span>
      <span class="pi">-</span> <span class="s">community_uploads:/app/apps/uploads</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">community_uploads</span><span class="pi">:</span>
</code></pre></div></div>

<p>기존 설정과도 결이 맞는다. 살아남아야 하는 데이터는 이미 전부 named volume 이다 — <code class="language-plaintext highlighter-rouge">pgvector_data</code>, <code class="language-plaintext highlighter-rouge">redis_data</code>, <code class="language-plaintext highlighter-rouge">neo4j_data</code>, <code class="language-plaintext highlighter-rouge">pgadmin_data</code>, <code class="language-plaintext highlighter-rouge">n8n_data</code>. 사람이 들여다보는 것은 bind mount, 남아야 하는 것은 named volume. 업로드는 후자다.</p>

<p>빈 볼륨으로 시작하는 것도 문제가 안 된다. named volume 은 처음 붙을 때 비어 있지만, <code class="language-plaintext highlighter-rouge">main.py</code> 의 lifespan 이 기동할 때 <code class="language-plaintext highlighter-rouge">get_community_media_storage().ensure_dir()</code> 를 한 번 부르고 저장 함수도 매번 <code class="language-plaintext highlighter-rouge">ensure_dir()</code> 를 부른다. 디렉터리가 있다고 가정하는 코드가 없다. Dockerfile 에 <code class="language-plaintext highlighter-rouge">USER</code> 지시자가 없어서 root 로 돌기 때문에 새 볼륨 소유권으로 막히는 것도 없다.</p>

<p>한 가지 덜 눈에 띄는 연결고리가 있다. compose 파일 맨 위에 프로젝트명이 박혀 있다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 디렉터리명이 바뀌어도 기존 project_* 볼륨을 계속 쓰도록 프로젝트명을 고정한다.</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">project</span>
</code></pre></div></div>

<p>named volume 의 실제 이름에는 프로젝트명이 접두어로 붙는다. 이 볼륨은 <code class="language-plaintext highlighter-rouge">project_community_uploads</code> 가 된다. 프로젝트명을 고정해 둔 덕에 저장소 디렉터리 이름을 바꿔도 같은 볼륨을 계속 쓴다. 고정하지 않았다면 디렉터리명이 compose 프로젝트명이 되고, 디렉터리를 옮기는 순간 새 빈 볼륨이 붙어서 원래 증상이 그대로 재현된다. 볼륨으로 고쳤다고 끝이 아니라, 볼륨 이름이 안정적이어야 고친 게 유지된다.</p>

<p>이름 규칙은 하나 어긋난다. 다른 데이터 볼륨은 모두 <code class="language-plaintext highlighter-rouge">*_data</code> 인데 이것만 <code class="language-plaintext highlighter-rouge">community_uploads</code> 다. 동작에는 영향이 없다. <code class="language-plaintext highlighter-rouge">n8n_data</code> 만 <code class="language-plaintext highlighter-rouge">external: true</code> 로 선언돼 compose 가 만들지 않는 것도 이 파일에서 유일한 예외다. <code class="language-plaintext highlighter-rouge">community_uploads</code> 는 external 이 아니라서 처음 <code class="language-plaintext highlighter-rouge">up</code> 할 때 compose 가 만든다.</p>

<h2 id="확인하다가-발견한-것--쓰는-경로와-내보내는-경로가-다르다">확인하다가 발견한 것 — 쓰는 경로와 내보내는 경로가 다르다</h2>

<p>볼륨이 맞게 걸렸는지 보려고 파일을 내보내는 쪽을 열었다가 어긋난 걸 찾았다. <code class="language-plaintext highlighter-rouge">minahai/main.py</code> 에 정적 마운트가 있다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">_UPLOADS_ROOT</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">__file__</span><span class="p">).</span><span class="n">resolve</span><span class="p">().</span><span class="n">parent</span> <span class="o">/</span> <span class="s">"uploads"</span>
<span class="n">_UPLOADS_ROOT</span><span class="p">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">app</span><span class="p">.</span><span class="n">mount</span><span class="p">(</span><span class="s">"/uploads"</span><span class="p">,</span> <span class="n">StaticFiles</span><span class="p">(</span><span class="n">directory</span><span class="o">=</span><span class="nb">str</span><span class="p">(</span><span class="n">_UPLOADS_ROOT</span><span class="p">)),</span> <span class="n">name</span><span class="o">=</span><span class="s">"uploads"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">main.py</code> 는 컨테이너에서 <code class="language-plaintext highlighter-rouge">/app/main.py</code> 다. <code class="language-plaintext highlighter-rouge">parent</code> 는 <code class="language-plaintext highlighter-rouge">/app</code> 이므로 <code class="language-plaintext highlighter-rouge">_UPLOADS_ROOT</code> 는 <code class="language-plaintext highlighter-rouge">/app/uploads</code> 가 된다. 쓰는 쪽은 <code class="language-plaintext highlighter-rouge">/app/apps/uploads/community</code> 다. <strong><code class="language-plaintext highlighter-rouge">apps</code> 한 칸이 다르다.</strong></p>

<p>정리하면 이렇다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>경로</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>저장 (<code class="language-plaintext highlighter-rouge">community_media.py</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">/app/apps/uploads/community</code></td>
    </tr>
    <tr>
      <td>볼륨 마운트 (<code class="language-plaintext highlighter-rouge">9d1b787</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">/app/apps/uploads</code></td>
    </tr>
    <tr>
      <td>정적 서빙 (<code class="language-plaintext highlighter-rouge">main.py</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">/app/uploads</code></td>
    </tr>
  </tbody>
</table>

<p>볼륨은 <strong>쓰는 경로와 정확히 맞다</strong>. 그래서 커밋이 하려던 일 — 바이트를 남기는 것 — 은 된다. 문제는 그 바이트를 꺼내는 URL 이다. 저장 함수는 <code class="language-plaintext highlighter-rouge">/uploads/community/&lt;uuid&gt;.jpg</code> 를 돌려주고, 프론트는 그걸 백엔드의 <code class="language-plaintext highlighter-rouge">GET /uploads/community/&lt;uuid&gt;.jpg</code> 로 프록시한다. 그 요청은 <code class="language-plaintext highlighter-rouge">StaticFiles</code> 를 타고 <code class="language-plaintext highlighter-rouge">/app/uploads/community/</code> 를 본다. 거기엔 아무것도 쓰이지 않는다.</p>

<p>백엔드 전체에 정적 마운트는 이 하나뿐이라(<code class="language-plaintext highlighter-rouge">app.mount</code>·<code class="language-plaintext highlighter-rouge">StaticFiles</code> 로 훑었다) 다른 마운트가 덮어주고 있는 것도 아니다. 그리고 <code class="language-plaintext highlighter-rouge">/app/uploads</code> 는 볼륨 밖이라 여전히 쓰기 가능 레이어 위에 있다. 이 커밋이 없애려던 그 자리다.</p>

<p>두 경로는 <code class="language-plaintext highlighter-rouge">ef42d7f</code>(2026-07-08) 에 같이 들어왔고 그 뒤로 둘 다 바뀌지 않았다. 볼륨을 붙인 <code class="language-plaintext highlighter-rouge">9d1b787</code> 이 지금 compose 파일에 남은 마지막 변경이다. 즉 커밋 메시지는 사실이지만 절반이다 — 파일이 사라지는 건 고쳤고, 사라지지 않은 파일을 내보내는 경로는 그대로다.</p>

<p>컨테이너를 띄워 확인한 게 아니라 코드를 읽어 얻은 결론이다. 결판내는 명령은 이 세 줄이다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose <span class="nb">exec </span>backend <span class="nb">ls</span> /app/apps/uploads/community
docker compose <span class="nb">exec </span>backend <span class="nb">ls</span> /app/uploads
docker volume inspect project_community_uploads
</code></pre></div></div>

<p>첫 줄에 파일이 있고 둘째 줄이 비어 있으면 위 표대로다.</p>

<h2 id="지금은-여기까지">지금은 여기까지</h2>

<p>업로드를 컨테이너 볼륨에 두는 건 임시 해법이다. 볼륨은 그 도커 호스트에 묶여 있어서 백엔드를 여러 대로 늘리면 각자 다른 사진을 갖게 되고, 호스트를 옮기면 볼륨을 따로 옮겨야 하고, 백업은 DB 와 별도로 챙겨야 한다. 용량도 한 방향으로만 자란다. 파일 하나 크기는 서버에서 막는다 — <code class="language-plaintext highlighter-rouge">save()</code> 가 사진 10MB · 동영상 50MB 를 넘으면 400 을 낸다. 개수는 아니다. <code class="language-plaintext highlighter-rouge">community_media.py</code> 에 <code class="language-plaintext highlighter-rouge">MAX_FILES_PER_POST = 4</code> 가 선언돼 있지만 백엔드 어디에서도 쓰이지 않고(그 이름으로 훑어도 선언된 줄 하나뿐이다), 실제로 4개를 막는 건 프론트의 <code class="language-plaintext highlighter-rouge">MAX_COMMUNITY_MEDIA</code> 로 버튼을 비활성화하는 것뿐이다. 즉 한 글당 용량 상한은 UI 에만 있다. 그리고 지우는 경로는 어느 쪽에도 없다.</p>

<p>객체 저장소로 가는 길이 이 저장소에 아주 없지는 않다. <code class="language-plaintext highlighter-rouge">minahai/apps/admin/</code> 의 이미지 업로드는 이미 <code class="language-plaintext highlighter-rouge">S3ImageStoragePort</code> 와 boto3 구현(<code class="language-plaintext highlighter-rouge">S3ImageStorageAdapter</code>, 동기 라이브러리라 <code class="language-plaintext highlighter-rouge">asyncio.to_thread</code> 로 넘긴다)을 갖고 있다. 커뮤니티 쪽도 헥사고날이라 <code class="language-plaintext highlighter-rouge">CommunityMediaPort</code> 뒤에 <code class="language-plaintext highlighter-rouge">CommunityMediaLocalAdapter</code> 하나가 꽂혀 있을 뿐이다. 바꿀 자리는 그 어댑터 하나다.</p>

<p>다만 커뮤니티 미디어를 S3 로 옮긴다는 커밋도 문서도 저장소에 없다. 계획이 있다고 쓸 근거가 없으니 여기서 끊는다. 지금 상태는 “재생성에는 안 죽는다”까지다.</p>

<h2 id="남는-교훈">남는 교훈</h2>

<p>컨테이너 안에 파일을 쓰는 코드는 전부 기본값이 휘발이다. 볼륨을 붙이지 않았다면 남는다고 가정한 쪽이 틀린 것이고, 재시작으로는 증상이 안 나기 때문에 한참 멀쩡해 보인다. 무엇이 사라지고 무엇이 남는지(DB 는 남고 파일만 사라졌다)를 먼저 갈라보면 원인이 금방 좁혀진다.</p>

<p>그리고 파일 저장은 <strong>쓰는 경로와 내보내는 경로 두 개</strong>다. 둘이 각자 자기 기준으로 경로를 계산하면 어긋나고, 어긋나도 업로드는 성공하기 때문에 조용하다. 볼륨을 붙일 때는 마운트 지점이 쓰는 쪽과 맞는지만 보지 말고 내보내는 쪽과도 같은 경로인지 같이 확인해야 한다. 한쪽만 맞으면 바이트는 남고 화면은 비어 있다.</p>]]></content><author><name>김민아</name></author><category term="Docker" /><category term="인프라" /><category term="디버깅" /><summary type="html"><![CDATA[커뮤니티 게시물에 사진을 올리는 기능이 있다. 백엔드 컨테이너를 재빌드하고 나면 올렸던 사진이 없어졌다. 고친 커밋은 9d1b787, 바뀐 줄은 세 줄이다.]]></summary></entry><entry><title type="html">면접이 끝나도 카메라가 안 꺼지던 것 — 끝나는 길이 셋이었다</title><link href="https://dev.minahdev.cloud/2026/09/16/release-the-camera/" rel="alternate" type="text/html" title="면접이 끝나도 카메라가 안 꺼지던 것 — 끝나는 길이 셋이었다" /><published>2026-09-16T12:00:00+00:00</published><updated>2026-09-16T12:00:00+00:00</updated><id>https://dev.minahdev.cloud/2026/09/16/release-the-camera</id><content type="html" xml:base="https://dev.minahdev.cloud/2026/09/16/release-the-camera/"><![CDATA[<p>지원자가 「면접 종료」를 누르고 「면접이 완료되었습니다」가 떠도
<strong>카메라 표시등이 계속 켜져 있었다.</strong> 다른 탭으로 옮겨도 그대로고,
앱을 죽여야 꺼졌다. 근거 커밋은 <code class="language-plaintext highlighter-rouge">36bb7c5</code>.</p>

<h2 id="dispose는-오지-않는다">dispose()는 오지 않는다</h2>

<p>놓는 코드를 <code class="language-plaintext highlighter-rouge">dispose()</code> 에만 뒀던 게 원인이다.</p>

<p>지원자 셸이 <code class="language-plaintext highlighter-rouge">IndexedStack</code> 으로 탭을 살려 둔다. 탭을 옮겨도 위젯이
버려지지 않고 상태가 유지되는 구조다 — 그게 이 셸을 그렇게 짠 이유이기도 하다.
그런데 그 말은 <strong>이 화면이 앱이 살아 있는 한 dispose 되지 않는다</strong>는 뜻이었다.</p>

<h2 id="눈에-안-보이는-쪽이-더-나빴다">눈에 안 보이는 쪽이 더 나빴다</h2>

<p>카메라 표시등은 눈에라도 보였다. 진짜 문제는 그 뒤에 있었다.</p>

<p>lie-detection 소켓도 안 닫혀서 <strong>서버가 끝난 세션을 계속 판정하고 있었다.</strong>
실기기로 면접을 끝낸 뒤에 <code class="language-plaintext highlighter-rouge">/ai/health</code> 를 봤더니 <code class="language-plaintext highlighter-rouge">live.scored</code> 와 <code class="language-plaintext highlighter-rouge">no_face</code> 가
10초에 하나씩 나란히 올라가고 있었다. 아무도 앞에 없는 화면을
서버가 계속 채점하고 있던 것이다.</p>

<h2 id="끝나는-길이-셋이다">끝나는 길이 셋이다</h2>

<p>세어 보니 면접이 끝나는 경로가 셋이었고, 어느 것도 자원을 안 놓고 있었다.</p>

<ol>
  <li><strong>「면접 종료」 버튼</strong> → <code class="language-plaintext highlighter-rouge">_finish()</code> — 서버에 알리기만 했다</li>
  <li><strong>서버가 <code class="language-plaintext highlighter-rouge">InterviewDone</code> 을 보냄</strong> — 질문을 다 답하면 이 길이다. 사실 더 흔하다</li>
  <li><strong><code class="language-plaintext highlighter-rouge">_load()</code> 가 done·expired 를 받음</strong></li>
</ol>

<p>셋 다 <code class="language-plaintext highlighter-rouge">_releaseCall()</code> 을 부르게 했다. 두 번 불러도 안전하게 만들었다.</p>

<p>순서에도 함정이 있었다. <code class="language-plaintext highlighter-rouge">_finish()</code> 는 <strong>서버가 끝을 확인한 뒤에</strong> 놓는다 —
먼저 놓으면 finish 가 실패했을 때 카메라만 꺼지고 면접은 살아 있는,
더 이상한 자리가 된다.</p>

<p>카메라 정리는 <code class="language-plaintext highlighter-rouge">_dropCall()</code> 로 갈라 <strong>동기</strong>로 뒀다.
<code class="language-plaintext highlighter-rouge">dispose()</code> 가 렌더러를 닫는 것과 엇갈리면 죽은 렌더러의 <code class="language-plaintext highlighter-rouge">srcObject</code> 를 건드린다.</p>

<h2 id="테스트를-못-붙였다">테스트를 못 붙였다</h2>

<p>이건 솔직히 적어둔다. 이 화면에는 테스트를 한 줄도 못 붙였다.</p>

<p><code class="language-plaintext highlighter-rouge">MicService</code> 와 소켓을 하드코딩해서 만들고 있어서 주입구가 WebRTC 하나뿐이다.
위젯 테스트를 돌리면 플랫폼 채널에 걸려 죽는다.
주입구를 내는 건 별도 작업으로 남겼다.</p>

<p>그리고 그게 <strong>이 버그가 살아남은 까닭</strong>이다. 화면에 테스트가 없으면
“끝나는 길이 셋”같은 건 아무도 세어보지 않는다.</p>

<h2 id="남는-교훈">남는 교훈</h2>

<p><code class="language-plaintext highlighter-rouge">dispose()</code> 를 정리 지점으로 믿으려면 <strong>그 위젯이 정말 버려지는지</strong>부터 봐야 한다.
탭을 살려 두는 셸, 캐시, 라우트 스택 — 위젯을 살려 두는 구조는 흔하다.</p>

<p>그리고 자원을 쥐는 화면은 <strong>끝나는 길을 세어야 한다.</strong>
버튼 하나만 보고 있으면 나머지 둘은 조용히 빠져나간다.</p>]]></content><author><name>김민아</name></author><category term="앱" /><category term="Flutter" /><category term="디버깅" /><summary type="html"><![CDATA[지원자가 「면접 종료」를 누르고 「면접이 완료되었습니다」가 떠도 카메라 표시등이 계속 켜져 있었다. 다른 탭으로 옮겨도 그대로고, 앱을 죽여야 꺼졌다. 근거 커밋은 36bb7c5.]]></summary></entry><entry><title type="html">개발 서버에서는 안 보이던 버그 — Lightning CSS가 내 접두사를 중복으로 봤다</title><link href="https://dev.minahdev.cloud/2026/09/04/prod-only-css-bug/" rel="alternate" type="text/html" title="개발 서버에서는 안 보이던 버그 — Lightning CSS가 내 접두사를 중복으로 봤다" /><published>2026-09-04T12:00:00+00:00</published><updated>2026-09-04T12:00:00+00:00</updated><id>https://dev.minahdev.cloud/2026/09/04/prod-only-css-bug</id><content type="html" xml:base="https://dev.minahdev.cloud/2026/09/04/prod-only-css-bug/"><![CDATA[<p>로그인 화면에 노드망 배경을 올리고 카드를 유리(<code class="language-plaintext highlighter-rouge">backdrop-filter</code>)로 바꿨다.
개발 서버에서는 멀쩡했다. 배포 번들을 뜯어보고 나서야 알았다.
근거 커밋은 <code class="language-plaintext highlighter-rouge">70ef0ec</code>.</p>

<h2 id="번들에-표준-선언이-0개였다">번들에 표준 선언이 0개였다</h2>

<p>빌드 결과에서 <code class="language-plaintext highlighter-rouge">backdrop-filter</code> 를 세어 봤다.</p>

<ul>
  <li>표준 <code class="language-plaintext highlighter-rouge">backdrop-filter</code> — <strong>0개</strong></li>
  <li><code class="language-plaintext highlighter-rouge">-webkit-backdrop-filter</code> — <strong>29개</strong></li>
</ul>

<p>내가 호환성을 챙긴다고 접두사를 손으로 같이 써 둔 것이 원인이었다.
Lightning CSS 가 그 둘을 중복 선언으로 보고 <strong>표준 쪽을 지웠다.</strong>
남은 것은 접두사뿐이니, 접두사를 모르는 브라우저에서는 유리가 통째로 사라진다.</p>

<h2 id="개발-서버가-끝까지-숨겨줬다">개발 서버가 끝까지 숨겨줬다</h2>

<p>개발 서버는 미니파이를 하지 않는다. 그래서 내 CSS 가 그대로 살아 있었고,
화면은 정상으로 보였다. <strong>프로덕션 빌드에서만 나는 버그였다.</strong></p>

<p>고치는 건 간단했다 — 손으로 쓴 접두사 29개를 전부 걷고 도구에 맡겼다.
이제 번들에 표준 29 + 접두사 29 로 짝이 맞는다.
대상은 이 커밋의 로그인 카드만이 아니라 앞 커밋에서 만든 유리 전부였다.</p>

<h2 id="확인하는-방법을-바꿨다">확인하는 방법을 바꿨다</h2>

<p>여기서 배운 게 버그 자체보다 크다.</p>

<p>로그인과 공개 지원 폼은 <strong>개발 모드에서 아예 볼 수가 없다.</strong>
<code class="language-plaintext highlighter-rouge">DEV_USER</code> 가 자동 로그인해버려서 그 라우트로 갈 수가 없기 때문이다.
그래서 그 화면들은 프로덕션 빌드를 <code class="language-plaintext highlighter-rouge">4173</code> 포트에 띄워 확인하기로 했다.</p>

<p>같은 방법으로 하나 더 실증했다 — 목 데이터와 <code class="language-plaintext highlighter-rouge">?preview</code> 표본이
배포 번들에 남지 않는다는 것. <code class="language-plaintext highlighter-rouge">import.meta.env.DEV</code> 안에 두면
빌드에서 죽은 코드로 제거된다. 이건 “그럴 것이다”가 아니라
번들을 열어서 확인해야 하는 종류의 일이었다.</p>

<h2 id="남는-교훈">남는 교훈</h2>

<p>개발 서버와 프로덕션 빌드는 <strong>다른 프로그램</strong>이다.
미니파이·트리셰이킹·CSS 변환이 전부 프로덕션에만 붙는다.
“로컬에서 되니까 됐다”가 통하지 않는 구간이 있고, 유리 효과처럼
눈에 보이는 것도 그 구간에 들어갈 수 있다.</p>

<p>그리고 <strong>호환성을 손으로 챙기지 말 것.</strong> 도구가 이미 하고 있고,
내가 거들면 도구가 내 것을 중복으로 보고 지운다.</p>]]></content><author><name>김민아</name></author><category term="프론트" /><category term="빌드" /><category term="디버깅" /><summary type="html"><![CDATA[로그인 화면에 노드망 배경을 올리고 카드를 유리(backdrop-filter)로 바꿨다. 개발 서버에서는 멀쩡했다. 배포 번들을 뜯어보고 나서야 알았다. 근거 커밋은 70ef0ec.]]></summary></entry><entry><title type="html">로그인 응답 시간으로 계정 존재 여부가 샜다 — 예외를 잡는 대신 입력을 정규화한다</title><link href="https://dev.minahdev.cloud/2026/08/26/login-timing-leak/" rel="alternate" type="text/html" title="로그인 응답 시간으로 계정 존재 여부가 샜다 — 예외를 잡는 대신 입력을 정규화한다" /><published>2026-08-26T12:00:00+00:00</published><updated>2026-08-26T12:00:00+00:00</updated><id>https://dev.minahdev.cloud/2026/08/26/login-timing-leak</id><content type="html" xml:base="https://dev.minahdev.cloud/2026/08/26/login-timing-leak/"><![CDATA[<h2 id="내용은-같아졌는데-시간이-갈렸다">내용은 같아졌는데 시간이 갈렸다</h2>

<p>앞선 커밋 <code class="language-plaintext highlighter-rouge">cfdf5f1</code> 에서 로그인 실패 응답의 <strong>내용</strong>을 통일했다. OAuth 로 가입한 계정의 <code class="language-plaintext highlighter-rouge">password_hash</code> 는 <code class="language-plaintext highlighter-rouge">!oauth-no-password</code> 같은 자리표시자라 bcrypt 가 이걸 해시로 못 읽고 <code class="language-plaintext highlighter-rouge">ValueError("Invalid salt")</code> 를 던졌고, <code class="language-plaintext highlighter-rouge">minahai/main.py</code> 의 로그인 핸들러가 그 문자열을 그대로 401 본문에 실어 보냈다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">except</span> <span class="nb">ValueError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="k">raise</span> <span class="n">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">401</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">))</span> <span class="k">from</span> <span class="n">e</span>
</code></pre></div></div>

<p>없는 계정은 “아이디 또는 비밀번호가 올바르지 않습니다” 를 받고 OAuth 계정만 <code class="language-plaintext highlighter-rouge">Invalid salt</code> 를 받으니, 로그인 폼만 두들겨도 어떤 아이디가 OAuth 로 존재하는지 알 수 있었다. 이게 사용자 열거(user enumeration)다. 공격자가 유효한 아이디 목록을 먼저 확보하면 크리덴셜 스터핑·표적 피싱의 대상이 좁혀지고, “이 사람이 이 서비스를 쓴다”는 사실 자체도 새는 정보다.</p>

<p>문제는 내용을 통일한 뒤에도 남아 있었다. 답은 같아졌는데 <strong>그 답이 나오는 시간</strong>이 갈렸다.</p>

<h2 id="원인--실패-경로는-bcrypt-를-아예-돌지-않았다">원인 — 실패 경로는 bcrypt 를 아예 돌지 않았다</h2>

<p>당시 <code class="language-plaintext highlighter-rouge">minahai/apps/users/app/use_cases/login_interactor.py</code> 는 이렇게 생겼다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">user</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"아이디 또는 비밀번호가 올바르지 않습니다."</span><span class="p">)</span>
<span class="k">try</span><span class="p">:</span>
    <span class="n">matched</span> <span class="o">=</span> <span class="n">bcrypt</span><span class="p">.</span><span class="n">checkpw</span><span class="p">(</span>
        <span class="n">schema</span><span class="p">.</span><span class="n">password</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">),</span>
        <span class="n">user</span><span class="p">.</span><span class="n">password_hash</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">),</span>
    <span class="p">)</span>
<span class="k">except</span> <span class="p">(</span><span class="nb">ValueError</span><span class="p">,</span> <span class="nb">TypeError</span><span class="p">):</span>
    <span class="n">matched</span> <span class="o">=</span> <span class="bp">False</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">matched</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"아이디 또는 비밀번호가 올바르지 않습니다."</span><span class="p">)</span>
</code></pre></div></div>

<p>세 경로의 비용이 서로 다르다.</p>

<ul>
  <li>계정이 없으면 <code class="language-plaintext highlighter-rouge">user is None</code> 에서 바로 던진다. bcrypt 를 한 번도 돌지 않는다.</li>
  <li>OAuth 자리표시자면 <code class="language-plaintext highlighter-rouge">checkpw</code> 가 해시를 파싱하다 즉시 <code class="language-plaintext highlighter-rouge">ValueError</code> 로 죽는다. 역시 실제 연산은 없다.</li>
  <li>비밀번호 계정만 bcrypt 전체 연산을 돈다.</li>
</ul>

<p>bcrypt 는 일부러 느리게 설계된 함수다. 그 느림이 여기서는 신호가 된다. 응답 시간만 재도 “이 아이디가 비밀번호 계정으로 존재하는가” 를 알 수 있었다. 내용을 아무리 똑같이 맞춰도 타이밍 채널은 그대로였다.</p>

<h2 id="tryexcept-로-감싸면-반대-방향으로-샌다">try/except 로 감싸면 반대 방향으로 샌다</h2>

<p>먼저 떠오르는 방법은 예외를 잡아서 메꾸는 쪽이다. <code class="language-plaintext highlighter-rouge">checkpw</code> 를 그대로 두고, 실패했을 때 더미 해시로 한 번 더 돌려 시간을 채우는 식이다.</p>

<p>이건 틀렸다. 그러면 ‘틀린 비밀번호’ 경로만 bcrypt 를 두 번 돌게 된다. 정확히 말하면 이렇게 갈린다.</p>

<ul>
  <li>없는 계정 / OAuth 계정: 예외 → 더미 한 번 = bcrypt 1회</li>
  <li>존재하는 비밀번호 계정: 정상 계산 → 실패하면 또 더미 = bcrypt 2회</li>
</ul>

<p>원래 누출은 “존재하는 계정이 더 느리다” 였는데, 이 방법은 “존재하는 계정이 <strong>두 배</strong> 느리다” 로 바꾼다. 방향이 반대로 뒤집히는 것도 아니고 더 크게 벌어진다. 예외를 사후에 보상하는 구조는 보상 횟수 자체가 정보가 된다.</p>

<h2 id="고른-방법--입력을-정규화한다">고른 방법 — 입력을 정규화한다</h2>

<p>그래서 예외를 잡는 대신 <strong>입력을 정규화</strong>했다. 유효한 bcrypt 해시가 아니면 미리 더미로 바꿔놓고, 어느 경로든 정확히 한 번, 같은 비용으로 계산한다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 실패 경로에서도 같은 비용을 쓰게 만드는 더미 해시.
</span><span class="n">_DUMMY_HASH</span> <span class="o">=</span> <span class="n">bcrypt</span><span class="p">.</span><span class="n">hashpw</span><span class="p">(</span><span class="sa">b</span><span class="s">"timing-equalizer"</span><span class="p">,</span> <span class="n">bcrypt</span><span class="p">.</span><span class="n">gensalt</span><span class="p">()).</span><span class="n">decode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">user</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">_repository</span><span class="p">.</span><span class="n">find_user</span><span class="p">(</span><span class="n">LoginQuery</span><span class="p">(</span><span class="n">user_id</span><span class="o">=</span><span class="n">schema</span><span class="p">.</span><span class="n">userId</span><span class="p">))</span>

<span class="c1"># 어느 경로로 가든 bcrypt 를 정확히 한 번, 같은 비용으로 돌린다.
</span><span class="n">stored</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">password_hash</span> <span class="k">if</span> <span class="n">user</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="k">else</span> <span class="n">_DUMMY_HASH</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">stored</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="s">"$2"</span><span class="p">):</span>
    <span class="n">stored</span> <span class="o">=</span> <span class="n">_DUMMY_HASH</span>

<span class="n">matched</span> <span class="o">=</span> <span class="n">verify_password</span><span class="p">(</span><span class="n">schema</span><span class="p">.</span><span class="n">password</span><span class="p">,</span> <span class="n">stored</span><span class="p">)</span>
<span class="k">if</span> <span class="n">user</span> <span class="ow">is</span> <span class="bp">None</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">matched</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"아이디 또는 비밀번호가 올바르지 않습니다."</span><span class="p">)</span>
</code></pre></div></div>

<p>세 가지가 같이 바뀌었다.</p>

<p>첫째, <code class="language-plaintext highlighter-rouge">user is None</code> 조기 반환이 없어졌다. 계정이 없어도 더미로 계산을 돌리고, <code class="language-plaintext highlighter-rouge">user is None</code> 판정은 bcrypt 이후로 미룬다. 조기 반환은 읽기 좋은 코드지만 여기서는 그 자체가 누출이다.</p>

<p>둘째, 판정 기준이 “알려진 자리표시자인가” 가 아니라 “bcrypt 해시처럼 생겼는가”(<code class="language-plaintext highlighter-rouge">$2</code> 접두어)다. <code class="language-plaintext highlighter-rouge">!oauth-no-password</code> 상수는 지금 <code class="language-plaintext highlighter-rouge">apps/auth/services.py</code>·<code class="language-plaintext highlighter-rouge">apps/users/auth/mobile_service.py</code>·<code class="language-plaintext highlighter-rouge">apps/users/oauth/oauth_router.py</code> 세 곳에 각각 따로 박혀 있다. 자리표시자 문자열과 비교하는 방식이었다면 네 번째 복사본이 생기는 순간 조용히 깨진다. 접두어 검사는 그 목록을 몰라도 된다.</p>

<p>셋째, <code class="language-plaintext highlighter-rouge">_DUMMY_HASH</code> 는 모듈 로드 때 한 번만 만든다. 요청마다 <code class="language-plaintext highlighter-rouge">gensalt()</code> 를 새로 돌리면 그 비용이 또 경로별로 달라질 여지가 생긴다.</p>

<p>한 가지 짚어둘 것은 <code class="language-plaintext highlighter-rouge">minahai/core/matrix/security.py</code> 의 <code class="language-plaintext highlighter-rouge">verify_password</code> 안에도 try/except 가 여전히 있다는 점이다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">verify_password</span><span class="p">(</span><span class="n">raw</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">hashed</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">bcrypt</span><span class="p">.</span><span class="n">checkpw</span><span class="p">(</span><span class="n">raw</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">),</span> <span class="n">hashed</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">))</span>
    <span class="k">except</span> <span class="p">(</span><span class="nb">ValueError</span><span class="p">,</span> <span class="nb">TypeError</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">False</span>
</code></pre></div></div>

<p>이건 남겨두는 최후 방어선이고, 로그인 경로에서 실제로 시간을 맞추는 것은 그 앞의 정규화다. 예외 처리를 지운 게 아니라, <strong>예외에 기대지 않게</strong> 만든 것이다.</p>

<h2 id="확인--세-분포가-겹친다">확인 — 세 분포가 겹친다</h2>

<p>각 경로를 15회씩 재서 중앙값을 봤다.</p>

<table>
  <thead>
    <tr>
      <th>경로</th>
      <th>중앙값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>없는 계정</td>
      <td>268ms</td>
    </tr>
    <tr>
      <td>OAuth 계정</td>
      <td>274ms</td>
    </tr>
    <tr>
      <td>비밀번호 계정</td>
      <td>261ms</td>
    </tr>
  </tbody>
</table>

<p>중앙값 편차 4.8%, 세 분포가 서로 겹친다. 완전히 같은 값을 만드는 것이 목표가 아니다. bcrypt 연산이 한 번 들어간 뒤로는 경로 간 차이가 요청마다 생기는 잡음에 묻혀야 하고, 분포가 겹친다는 것이 그 확인이다.</p>

<h2 id="곁가지--이쪽은-버그-수정이-아니다">곁가지 — 이쪽은 버그 수정이 아니다</h2>

<p>같은 커밋에서 <code class="language-plaintext highlighter-rouge">schedule_access_interactor.py</code> 의 <code class="language-plaintext highlighter-rouge">bcrypt.checkpw</code> 직접 호출도 <code class="language-plaintext highlighter-rouge">verify_password</code> 헬퍼로 바꿨다.</p>

<p>이건 <strong>버그 수정이 아니라 중복 제거</strong>다. 이 경로의 <code class="language-plaintext highlighter-rouge">password_hash</code> 는 같은 파일의 <code class="language-plaintext highlighter-rouge">set_password</code> 에서 <code class="language-plaintext highlighter-rouge">bcrypt.hashpw</code> 로만 만들어지므로 항상 유효한 해시다. 자리표시자가 들어올 길이 없어서 실제로 터지지는 않았다. 구분해서 적어두는 이유는, 나중에 이 줄을 보고 “여기도 같은 취약점이 있었다” 고 읽으면 사실이 아니기 때문이다.</p>

<p>덧붙여 이 정리는 반쪽이다. 검증은 헬퍼로 모았지만 <code class="language-plaintext highlighter-rouge">set_password</code> 는 아직 <code class="language-plaintext highlighter-rouge">bcrypt.hashpw</code>·<code class="language-plaintext highlighter-rouge">bcrypt.gensalt</code> 를 직접 부른다. <code class="language-plaintext highlighter-rouge">security.py</code> 에 <code class="language-plaintext highlighter-rouge">hash_password</code> 가 있는데도 그렇다. 해싱 쪽 중복은 남아 있다.</p>

<p>그리고 이 수정에는 회귀 테스트가 없다. <code class="language-plaintext highlighter-rouge">apps/auth/tests/test_security.py</code> 에 <code class="language-plaintext highlighter-rouge">verify_password</code> 왕복 테스트 하나가 있을 뿐, 자리표시자 입력이나 경로별 비용을 검증하는 테스트는 없다. 측정은 손으로 15회씩 돌린 값이고, 다음에 누가 조기 반환을 다시 넣으면 아무것도 막아주지 않는다.</p>

<h2 id="남는-교훈">남는 교훈</h2>

<p>응답을 같게 만드는 것과 응답 비용을 같게 만드는 것은 다른 일이다. 인증처럼 “존재 여부” 자체가 비밀인 경로에서는 내용·상태코드·헤더만 맞춰봐야 절반이고, 각 분기가 실제로 어떤 연산을 몇 번 하는지까지 세어야 한다.</p>

<p>그리고 타이밍을 맞출 때는 <strong>예외를 사후에 보상하지 말고 입력을 사전에 정규화하는 쪽</strong>을 고르는 편이 안전하다. 보상은 “보상이 필요했는가” 라는 새 정보를 만들고, 그 정보는 대개 원래 막으려던 것과 같은 것을 알려준다. 분기를 지워버릴 수 있으면 분기마다 비용을 맞춰 넣는 것보다 낫다.</p>

<p>마지막으로, 조기 반환처럼 평소에 권장되는 습관이 보안 경로에서는 그대로 누출이 될 수 있다. 읽기 좋은 코드와 시간이 같은 코드가 충돌할 때는 왜 그렇게 썼는지 주석으로 남겨야 한다. 안 남기면 다음 사람이 “이 <code class="language-plaintext highlighter-rouge">if</code> 를 위로 올리면 깔끔한데” 하고 되돌려놓는다.</p>]]></content><author><name>김민아</name></author><category term="보안" /><category term="백엔드" /><category term="FastAPI" /><summary type="html"><![CDATA[내용은 같아졌는데 시간이 갈렸다]]></summary></entry><entry><title type="html">로그아웃해도 다시 로그인되던 문제 — 폐기 요청이 취소되고 있었다</title><link href="https://dev.minahdev.cloud/2026/08/10/logout-race/" rel="alternate" type="text/html" title="로그아웃해도 다시 로그인되던 문제 — 폐기 요청이 취소되고 있었다" /><published>2026-08-10T12:00:00+00:00</published><updated>2026-08-10T12:00:00+00:00</updated><id>https://dev.minahdev.cloud/2026/08/10/logout-race</id><content type="html" xml:base="https://dev.minahdev.cloud/2026/08/10/logout-race/"><![CDATA[<p>로그아웃을 눌러도 잠시 뒤 다시 로그인 상태로 돌아왔다. <strong>배포된 도메인에서만</strong> 재현된다. 로컬 개발 서버에서는 몇 번을 눌러도 멀쩡했다. 이 글은 그 차이가 왜 생겼는지와 무엇을 고쳤는지의 기록이다. 근거 커밋은 <code class="language-plaintext highlighter-rouge">e77156e</code> 하나다.</p>

<h2 id="증상--배포한-곳에서만-로그아웃이-풀린다">증상 — 배포한 곳에서만 로그아웃이 풀린다</h2>

<p>로그아웃을 부르는 화면은 두 군데다. 마이페이지(<code class="language-plaintext highlighter-rouge">minahview/components/mypage/my-profile.tsx</code>)와 프로필 시트(<code class="language-plaintext highlighter-rouge">minahview/components/nav/profile-sheet.tsx</code>). 둘 다 같은 함수를 갖고 있었고, 그때 모습은 이랬다.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">logout</span><span class="p">()</span> <span class="p">{</span>
  <span class="nx">clearLoggedInUserId</span><span class="p">()</span>
  <span class="nb">window</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="nx">AUTH_SESSION_EVENT</span><span class="p">))</span>
  <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span>
<span class="p">}</span>
</code></pre></div></div>

<p>세 줄 다 동기 호출처럼 보인다. 문제는 첫 줄이 동기 호출이 아니었다는 것이다.</p>

<h2 id="원인--이동이-폐기-요청을-끊는다">원인 — 이동이 폐기 요청을 끊는다</h2>

<p><code class="language-plaintext highlighter-rouge">minahview/lib/auth-session.ts</code> 의 <code class="language-plaintext highlighter-rouge">clearLoggedInUserId</code> 는 이랬다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nx">clearLoggedInUserId</span><span class="p">():</span> <span class="k">void</span> <span class="p">{</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="nx">PACE_USER_ID_KEY</span><span class="p">)</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="nx">PACE_USER_ROLE_KEY</span><span class="p">)</span>
  <span class="nx">notifyAuthChange</span><span class="p">()</span>
  <span class="c1">// 서버 세션(httpOnly 쿠키)도 함께 폐기.</span>
  <span class="k">if</span> <span class="p">(</span><span class="k">typeof</span> <span class="nb">window</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">undefined</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">void</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/auth/logout</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span> <span class="p">}).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{})</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">localStorage</code> 두 키는 그 자리에서 지워진다. 하지만 서버 세션 폐기는 <code class="language-plaintext highlighter-rouge">void fetch</code> 다 — 요청을 띄우는 데서 끝나고 결과를 보지 않는다. 그리고 호출부의 다음 줄이 <code class="language-plaintext highlighter-rouge">window.location.href = "/"</code> 다.</p>

<p>브라우저는 문서를 옮기는 순간 진행 중이던 요청을 끊는다. 서버는 <code class="language-plaintext highlighter-rouge">POST /api/auth/logout</code> 을 받지 못하고, httpOnly 세션 쿠키는 그대로 남는다. 수명은 짧지 않다 — <code class="language-plaintext highlighter-rouge">minahview/lib/session.ts</code> 에 <code class="language-plaintext highlighter-rouge">SESSION_TTL_SECONDS = 60 * 60 * 24 * 7</code>, 7일이다.</p>

<p>여기서 되살아난다. 새 문서가 뜨면 루트 레이아웃(<code class="language-plaintext highlighter-rouge">minahview/app/layout.tsx</code>)에 붙어 있는 <code class="language-plaintext highlighter-rouge">SessionHydrator</code> 가 <code class="language-plaintext highlighter-rouge">/api/auth/me</code> 를 부른다. 쿠키가 유효하니 200 이고, 응답의 <code class="language-plaintext highlighter-rouge">userId</code>·<code class="language-plaintext highlighter-rouge">role</code> 이 <code class="language-plaintext highlighter-rouge">localStorage</code> 캐시에 다시 쓰인다. 방금 지운 두 키가 돌아온다. 이 구조는 의도한 것이다 — 로그인의 진실원은 httpOnly 쿠키이고 <code class="language-plaintext highlighter-rouge">localStorage</code> 는 동기 UI 를 위한 캐시일 뿐이다. 쿠키가 살아 있으면 캐시를 지운 쪽이 이길 방법이 없다.</p>

<p>되살리는 경로는 하나 더 있다. <code class="language-plaintext highlighter-rouge">minahview/components/require-auth.tsx</code> 도 캐시가 비어 있으면 <code class="language-plaintext highlighter-rouge">hydrateSessionFromServer()</code> 를 부른 뒤 재판정한다. 즉 쿠키가 살아남은 이상 어느 화면으로 가든 복구된다.</p>

<p><strong>로컬에서 재현되지 않은 이유가 이 문제의 핵심이다.</strong> 왕복이 1ms 라 이동이 시작되기 전에 요청이 이미 나가서 대부분 성공한다. 그래서 개발 중에는 멀쩡해 보였다. 배포 도메인에서는 그 여유가 없다. 버그가 없었던 게 아니라 경합에서 이길 확률이 달랐을 뿐이다.</p>

<h2 id="고친-것-둘">고친 것 둘</h2>

<p><strong>첫째, 기다린다.</strong> <code class="language-plaintext highlighter-rouge">clearLoggedInUserId</code> 가 프라미스를 돌려주고, 호출부 두 곳이 이동 전에 <code class="language-plaintext highlighter-rouge">await</code> 한다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nx">clearLoggedInUserId</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="nx">PACE_USER_ID_KEY</span><span class="p">)</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="nx">PACE_USER_ROLE_KEY</span><span class="p">)</span>
  <span class="nx">notifyAuthChange</span><span class="p">()</span>
  <span class="k">if</span> <span class="p">(</span><span class="k">typeof</span> <span class="nb">window</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">undefined</span><span class="dl">"</span><span class="p">)</span> <span class="k">return</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">resolve</span><span class="p">()</span>
  <span class="k">return</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/auth/logout</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span> <span class="na">keepalive</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}).</span><span class="nx">then</span><span class="p">(</span>
    <span class="p">()</span> <span class="o">=&gt;</span> <span class="kc">undefined</span><span class="p">,</span>
    <span class="p">()</span> <span class="o">=&gt;</span> <span class="kc">undefined</span><span class="p">,</span>
  <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>실패도 <code class="language-plaintext highlighter-rouge">undefined</code> 로 접는다. 폐기 요청이 에러를 냈다고 화면에 남아 있을 이유는 없다. <code class="language-plaintext highlighter-rouge">keepalive: true</code> 는 문서가 바뀌어도 요청이 살아남게 하는 표식이다. 기다리는 것이 본 수단이고 <code class="language-plaintext highlighter-rouge">keepalive</code> 는 그래도 이동이 끼어들 때를 위한 보험이다 — 둘 중 하나만 하지 않았다.</p>

<p>읽다가 눈에 걸린 것이 하나 있다. 호출부는 <code class="language-plaintext highlighter-rouge">await</code> 뒤에 <code class="language-plaintext highlighter-rouge">AUTH_SESSION_EVENT</code> 를 한 번 더 던지는데, <code class="language-plaintext highlighter-rouge">clearLoggedInUserId</code> 안의 <code class="language-plaintext highlighter-rouge">notifyAuthChange()</code> 가 <code class="language-plaintext highlighter-rouge">fetch</code> 전에 이미 같은 이벤트를 던진다. 리스너가 재판정만 하므로 동작이 깨지지는 않지만 한쪽은 필요 없다. 지금 코드에도 그대로 남아 있다.</p>

<p><strong>둘째, 지울 때도 구울 때와 같은 옵션을 쓴다.</strong> 로그아웃 라우트가 이랬다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 전</span>
<span class="nx">res</span><span class="p">.</span><span class="nx">cookies</span><span class="p">.</span><span class="kd">set</span><span class="p">(</span><span class="nx">SESSION_COOKIE</span><span class="p">,</span> <span class="dl">""</span><span class="p">,</span> <span class="p">{</span> <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">,</span> <span class="na">maxAge</span><span class="p">:</span> <span class="mi">0</span> <span class="p">})</span>
<span class="c1">// 후</span>
<span class="nx">res</span><span class="p">.</span><span class="nx">cookies</span><span class="p">.</span><span class="kd">set</span><span class="p">(</span><span class="nx">SESSION_COOKIE</span><span class="p">,</span> <span class="dl">""</span><span class="p">,</span> <span class="p">{</span> <span class="p">...</span><span class="nx">sessionCookieOptions</span><span class="p">(),</span> <span class="na">maxAge</span><span class="p">:</span> <span class="mi">0</span> <span class="p">})</span>
</code></pre></div></div>

<p>쿠키를 <strong>구울 때는</strong> <code class="language-plaintext highlighter-rouge">sessionCookieOptions()</code> 를 쓰면서 <strong>지울 때는</strong> <code class="language-plaintext highlighter-rouge">path</code> 와 <code class="language-plaintext highlighter-rouge">maxAge</code> 두 개만 손으로 적고 있었다. 빠진 것은 이 세 개다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nx">sessionCookieOptions</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">return</span> <span class="p">{</span>
    <span class="na">httpOnly</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="na">sameSite</span><span class="p">:</span> <span class="dl">"</span><span class="s2">lax</span><span class="dl">"</span> <span class="k">as</span> <span class="kd">const</span><span class="p">,</span>
    <span class="na">secure</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">production</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">maxAge</span><span class="p">:</span> <span class="nx">SESSION_TTL_SECONDS</span><span class="p">,</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>브라우저는 속성이 맞아야 같은 쿠키로 보고 지우므로, 같은 옵션에 <code class="language-plaintext highlighter-rouge">maxAge</code> 만 0 으로 바꿔 덮어쓰게 했다. 문제의 모양이 여기서 드러난다 — 쿠키를 굽는 곳은 셋(로그인·OAuth·역할 전환)인데 전부 이 헬퍼를 쓰고, 옵션을 손으로 적는 곳은 지우는 한 군데뿐이었다.</p>

<h2 id="확인한-것과-확인하지-못한-것">확인한 것과 확인하지 못한 것</h2>

<p>확인한 것. 쿠키통에 세션 쿠키를 넣고 <code class="language-plaintext highlighter-rouge">POST /api/auth/logout</code> 을 불러, 응답이 <code class="language-plaintext highlighter-rouge">Set-Cookie: &lt;세션쿠키&gt;=; Path=/; Max-Age=0; HttpOnly; SameSite=lax</code> 로 실제 삭제 지시를 내보내는 것을 봤다.</p>

<p>확인하지 못한 것이 둘이다. 하나는 경합 자체다. 로컬에서는 원래 재현되지 않으니 로컬이 초록이라고 고쳐졌다는 근거가 되지 않는다. <strong>배포 도메인에서의 확인이 남아 있다.</strong> 커밋 본문에도 그렇게 적어 뒀다.</p>

<p>다른 하나는 위 검증 출력에서 바로 보인다 — <code class="language-plaintext highlighter-rouge">Secure</code> 가 없다. <code class="language-plaintext highlighter-rouge">secure</code> 는 <code class="language-plaintext highlighter-rouge">NODE_ENV === "production"</code> 일 때만 붙기 때문이다. 문제는 배포에서만 났는데 검증은 배포에서만 붙는 속성이 빠진 상태로 했다는 뜻이다. 이 한 칸은 여전히 비어 있다.</p>

<p>덧붙여, 이 커밋 뒤에 같은 파일들을 네 번 더 건드렸지만(<code class="language-plaintext highlighter-rouge">9f18eb8</code>·<code class="language-plaintext highlighter-rouge">692a8be</code>·<code class="language-plaintext highlighter-rouge">2d57089</code>·<code class="language-plaintext highlighter-rouge">b2cd127</code>) 로그아웃 경로의 코드는 커밋 당시와 같다. 호출부도 여전히 두 곳이다.</p>

<h2 id="남는-교훈">남는 교훈</h2>

<p>fire-and-forget 은 화면이 그대로 남아 있을 때만 fire-and-forget 이다. 문서를 옮기는 코드가 뒤에 붙는 순간 그 요청은 “보냈다”가 아니라 “보내려고 했다”가 되고, 로컬의 1ms 왕복은 그 차이를 가려 준다. 그리고 상태를 만드는 코드와 없애는 코드가 같은 옵션 출처를 공유하지 않으면, 굽는 쪽만 헬퍼를 쓰는 동안 지우는 쪽이 조용히 어긋난다 — 삭제는 생성의 대칭이어야 하고, 대칭은 같은 함수에서 나올 때만 유지된다.</p>]]></content><author><name>김민아</name></author><category term="프론트" /><category term="디버깅" /><category term="보안" /><summary type="html"><![CDATA[로그아웃을 눌러도 잠시 뒤 다시 로그인 상태로 돌아왔다. 배포된 도메인에서만 재현된다. 로컬 개발 서버에서는 몇 번을 눌러도 멀쩡했다. 이 글은 그 차이가 왜 생겼는지와 무엇을 고쳤는지의 기록이다. 근거 커밋은 e77156e 하나다.]]></summary></entry></feed>