<?xml version="1.0" encoding="UTF-8" ?>
  <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>SUMI.log</title>
        <link>https://blog.ssumi.space</link>
        <description>프론트엔드 개발 블로그 — JavaScript, TypeScript, React, Vue.js, 스타일링, 트러블슈팅</description>
        <language>ko</language>
        <atom:link href="https://blog.ssumi.space/rss.xml" rel="self" type="application/rss+xml"/>
        <item>
          <title>아직도 AI에게 코딩만 부탁하시나요? 디버깅, 검증도 맡길 수 있습니다</title>
          <link>https://blog.ssumi.space/blog/claude-code-playwright-mcp-introduction</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/claude-code-playwright-mcp-introduction</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>스크린샷 첨부 대신 AI에게 직접 페이지를 열어 확인하게 시키는 Playwright MCP를, 평소 쓰는 패턴과 함께 소개합니다.</description>
          <content:encoded><![CDATA[저도 한동안 그랬습니다. 문제가 생기면 일단 스크린샷부터 찍었습니다. Claude Code 창에 이미지를 끌어다 놓고 이렇게 말했습니다.

![[스크린샷 2026-05-12 오후 4.25.11.png]]
_에러 화면을 캡처해서 Claude Code에 그대로 던지던 평소 모습._

```d2
direction: right

a: "사람\n스크린샷 찍기"
b: "AI\n추측해서 수정"
c: "사람\n새로고침해서 확인"

a -> b
b -> c
c -> a: "안 되면" {
  style.stroke: "#E53935"
  style.stroke-dash: 4
}
```

AI는 화면을 보고 **그럴듯한 추측**을 내놓고, 저는 코드를 적용한 다음 브라우저를 새로고침해서 확인합니다. 안 되면 다시 스크린샷, 다시 첨부, 다시 추측. 이 루프를 몇 번 돌고 나서야 **매번 스크린샷을 찍는 게 부담스러워졌습니다.**

이 글에서는 그 루프를 깨준 도구, Playwright MCP가 무엇이고 어떤 장면에서 유용한지를 제 실제 사용 패턴과 함께 소개합니다.

## 그래서 Playwright MCP가 뭔가요

한 줄로 말하면, **AI에게 브라우저를 직접 조작하고 관찰할 눈과 손을 달아주는 도구**입니다. 영문권 글에서도 비슷한 결의 표현을 씁니다.

> "giving your AI a pair of eyes and hands in the browser"
> — Abhishek kumar singh, [Playwright MCP: The Frontend Developer's Secret Weapon in 2026](https://medium.com/@abhishek070189/playwright-mcp-the-frontend-developers-secret-weapon-in-2026-c4fd3f4b6ede)

기존에 우리가 AI와 협업하던 방식은 대부분 "내가 본 것을 옮겨 전달"하는 구조였습니다. 스크린샷을 찍고, 콘솔 로그를 복사하고, 네트워크 탭의 응답을 텍스트로 붙여 넣고. 그렇게 AI가 받는 건 **내가 한 번 가공한 정보**입니다. Playwright MCP가 바꾸는 지점이 여기입니다. AI가 직접 페이지를 열어서, 직접 보고, 직접 클릭합니다.

> [Playwright](https://playwright.dev/)란?
> Microsoft가 만든 브라우저 자동화 프레임워크입니다. 원래는 웹 앱 E2E 테스트용으로 쓰던 도구인데, 코드로 Chrome·Firefox·Safari를 띄우고 클릭·입력·스크롤·캡처까지 다 시킬 수 있어요. 외부 사이트를 긁는 크롤링 봇과는 결이 달라요. 개발자가 자기 앱이 잘 동작하는지 자동으로 검증하려고 쓰는 QA 도구입니다.

![[playwright-dev-hero.png]]
_Playwright 공식 사이트 첫 화면. "AI agents를 위한 브라우저 자동화"라는 문구가 가장 먼저 눈에 들어옵니다. (출처: playwright.dev)_

> [MCP(Model Context Protocol)](https://modelcontextprotocol.io/)란?
> AI가 외부 도구·데이터에 접근할 수 있도록 표준화한 프로토콜입니다.

그래서 **Playwright MCP**는 이름 그대로, Playwright의 브라우저 조작 능력을 MCP 표준에 얹어 AI가 호출할 수 있게 만든 서버입니다. AI에게 *"브라우저 자동화 도구를 다룰 줄 아는 손"* 을 붙여주는 셈이에요.

```d2
direction: down

ai: "Claude Code\n(AI 클라이언트)"
mcp: "Playwright MCP\n(서버)"
pw: "Playwright\n(자동화 엔진)"
chrome: "Chrome 브라우저"

ai -> mcp: "MCP 표준 호출"
mcp -> pw: "브라우저 조작"
pw -> chrome: "페이지 열기·클릭·관찰"
```

_자연어 한 줄이 MCP를 거쳐 Playwright로, 다시 실제 Chrome 브라우저로 전달되는 흐름._

## 이런 장면에서 유용합니다

저에게 가장 체감이 컸던 건 처음에 적은 그 사례, **에러 화면을 직접 확인시키는 일**이었습니다. 같은 사례를 두 흐름으로 풀어 보겠습니다.

### Before, 스크린샷 첨부 루프

```d2
direction: right

b1: "사람\n스크린샷 찍기"
b2: "AI\n추측해서 수정"
b3: "사람\n새로고침해서 확인"

b1 -> b2
b2 -> b3
b3 -> b1: "안 되면" {
  style.stroke: "#E53935"
  style.stroke-dash: 4
}
```

평소에는 이런 흐름으로 일했습니다.

1. 에러 페이지에서 스크린샷을 찍습니다
2. Claude Code에 이미지를 첨부하면서 이렇게 말합니다
   > 🗣️ `[Image]` 여전히 이렇게 나오는데 고쳐줘 — http://localhost:3000/ai-service/500
3. AI는 화면에 보이는 것만 가지고 원인을 추측합니다
4. 제안을 적용한 뒤, 제가 브라우저를 새로고침해서 확인합니다
5. 안 되면 다시 1번부터

이 루프의 비용은 두 군데에서 발생했습니다. 하나는 **정보의 손실**입니다. 스크린샷에는 그 순간 화면에 보인 것만 담깁니다. 콘솔에 찍힌 스택 트레이스, 네트워크 탭의 4xx/5xx 응답, DOM에 실제로 들어가 있는 마크업은 빠집니다. AI는 빠진 정보를 *추측*으로 메우는데, 추측이 빗나가면 다음 루프로 넘어갑니다.

또 하나는 **검증을 매번 제가 한다는 것**이었습니다. AI가 코드를 수정하면, 그게 실제로 통하는지는 제가 새로고침해서 봐야 알 수 있었습니다. 사실상 사람이 마지막 단계의 QA를 떠안고 있는 셈이었습니다.

### After, AI에게 직접 열어보게 하기

```d2
direction: right

a1: "사람\n자연어 한 줄"
a2: "AI\n페이지 열기 · 관찰 · 수정 · 재확인" {
  style.stroke: "#4CAF50"
  style.stroke-width: 2
}

a1 -> a2
```

Playwright MCP를 붙인 뒤로는 이렇게 바꿨습니다.

> 🗣️ playwright로 http://localhost:3000/ai-service/500 확인해서 어떤 에러인지 봐줘

이 한 줄에서 일어나는 일은 이전과 결이 다릅니다. AI가 실제 Chrome 창에서 페이지를 열고, 콘솔 메시지를 읽고, 네트워크 응답 코드를 확인하고, DOM 구조를 훑어봅니다. 받아 든 정보는 "스크린샷 한 장"이 아니라 "직접 들어가서 본 페이지 전체"입니다.

수정 후 검증도 같은 방식으로 넘길 수 있습니다.

> 🗣️ 방금 수정한 거 다시 열어서 잘 되는지 확인해줘

이게 특수한 워크플로우가 아니라는 점도 짚어둘 만합니다. GitHub Copilot의 Coding Agent도 같은 방식(코드를 수정한 뒤 Playwright MCP로 실제 브라우저에서 검증)으로 동작합니다 ([Microsoft Developer Blog](https://developer.microsoft.com/blog/the-complete-playwright-end-to-end-story-tools-ai-and-real-world-workflows)). 업계가 이미 같은 방향으로 가고 있는 셈입니다.

### 차이가 어디서 오는가

기술적으로 풀면 한 줄로 정리됩니다. **AI는 픽셀이 아니라 페이지의 의미 구조를 읽습니다.** Playwright MCP는 페이지의 [접근성 트리(accessibility tree)](https://developer.mozilla.org/en-US/docs/Glossary/Accessibility_tree), 브라우저가 내부적으로 가진 의미 단위의 구조를 AI에게 넘겨줍니다.

> "agents can use Playwright's accessibility tree to understand the true structure of a page"
> — Shipyard, [Taking screenshots of your app with the Playwright MCP server](https://shipyard.build/blog/playwright-mcp-screenshots/)

**스크린샷 한 장과는 정보의 결 자체가 다릅니다.**

## 빠르게 시작하려면

Claude Code 사용자라면 [공식 플러그인 마켓플레이스](https://claude.com/plugins/playwright)에서 한 번에 설치하는 게 가장 빠릅니다. Claude Code 안에서 `/plugin` 을 입력하고 Playwright 플러그인을 고르면 끝납니다.

설치를 확인했다면 평소 쓰는 Chrome을 켜두고 이렇게 한 번 불러보세요.

> 🗣️ Playwright로 https://github.com 열어서 페이지 제목 알려줘

처음 호출하면 어느 탭에 연결할지 선택하는 화면이 뜨고, 선택한 탭에서 페이지가 열립니다. 여기까지 되면 글에서 다룬 사례들도 그대로 시킬 수 있습니다.

## 마무리

Playwright MCP를 붙이고 나서는 에러 확인을 AI에게 그대로 넘기게 됐습니다. 매번 매끄럽게만 흘러가지는 않아요. 브라우저를 띄우는 비용이 가볍지 않고, AI가 엉뚱한 요소를 클릭하거나 잘못된 페이지로 가는 경우도 가끔 있습니다. 시각적 레이아웃처럼 한눈에 보고 끝나는 버그라면 여전히 스크린샷 첨부가 더 빠를 때도 있고요.

그래도 "확인"을 통째로 넘길 수 있는 자리에선 충분히 값을 합니다. 한 번 붙여두고 평소 쓰던 디버깅 사례에 대입해 보면 결이 빠르게 잡힐 거예요.

## 참고

본문에서 인용·참조한 자료입니다. 모두 2026년 5월 12일 기준으로 확인했습니다.

- Playwright MCP 공식 문서 — [playwright.dev/mcp](https://playwright.dev/mcp/introduction)
- Microsoft Developer Blog, "The Complete Playwright End-to-End Story" — [developer.microsoft.com/blog](https://developer.microsoft.com/blog/the-complete-playwright-end-to-end-story-tools-ai-and-real-world-workflows)
- Abhishek kumar singh, "Playwright MCP: The Frontend Developer's Secret Weapon in 2026" (Medium) — [medium.com/@abhishek070189](https://medium.com/@abhishek070189/playwright-mcp-the-frontend-developers-secret-weapon-in-2026-c4fd3f4b6ede)
- Shipyard, "Taking screenshots of your app with the Playwright MCP server" — [shipyard.build/blog](https://shipyard.build/blog/playwright-mcp-screenshots/)]]></content:encoded>
          <category>DevOps</category>
          <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Claude Code의 Playwright MCP, 내 Chrome 프로필 그대로 쓰기</title>
          <link>https://blog.ssumi.space/blog/claude-code-playwright-mcp-chrome-profile</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/claude-code-playwright-mcp-chrome-profile</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>매번 새 프로필로 뜨는 Playwright MCP를 평소 쓰는 Chrome 프로필에 연결하는 방법을 정리했습니다.</description>
          <content:encoded><![CDATA[Playwright MCP로 작업할 때 매번 새로운 빈 Chrome 프로필이 열려 불편하지 않으셨나요?

평소 로그인해둔 GitHub, 사내 어드민, Google 계정… 이런 세션이 전부 없는 상태로 시작하다 보니 AI를 이용한 테스트 자동화가 시작되기 전부터 손이 많이 갔습니다.

정리해 보면 이런 문제였습니다.

- 테스트 대상 사이트에 로그인이 필요한 경우 매번 로그인부터 해야 합니다
- SSO/2FA가 걸린 사내 시스템은 자동화 자체가 어렵습니다
- 평소 쓰던 확장 프로그램, 북마크, 쿠키도 전부 없는 상태입니다

참고로 매번 새 프로필이 뜨는 건 워크스페이스마다 브라우저 환경을 격리하려는 [의도된 동작](https://github.com/microsoft/playwright-mcp#user-profile)이라, `--user-data-dir`로 평소 프로필을 직접 가리키는 우회는 권장되지 않습니다. 

그래서 다른 방향의 해법으로 등장한 것이 **Playwright MCP Bridge** 라는 Chrome 확장 프로그램이에요. MCP 서버가 새 브라우저를 띄우는 대신, 이미 열려 있는 내 Chrome 탭에 그대로 붙어 주거든요.

공식 문서도 같은 표현을 씁니다.

> "The Playwright Extension connects to your existing browser tabs, reusing your logged-in sessions, cookies, and installed extensions."
> — [Playwright MCP – Browser Extension](https://playwright.dev/mcp/configuration/browser-extension)

## 설정 방법

> [!TIP]
> 아래 과정이 번거롭다면 글 하단에 [한 번에 적용하는 프롬프트](#한-번에-적용하는-프롬프트)를 준비해 두었어요. 
> Claude Code에 그대로 붙여 넣으면 토큰 입력부터 플러그인 감지, 설정 파일 수정, 재시작, 동작 확인까지 단계별로 알아서 진행됩니다.

### 1. Chrome 확장 프로그램 설치

Chrome 웹 스토어에서 [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) 를 설치합니다. 
Playwright 공식 문서의 [Browser Extension](https://playwright.dev/mcp/configuration/browser-extension) 페이지도 같은 웹 스토어 링크를 안내합니다.

![[스크린샷 2026-05-07 오후 4.13.32.png]]
_Chrome 웹 스토어에서 한 번에 설치할 수 있습니다._

> [!TIP]
> 2025년 8월 [Debbie O'Brien의 글](https://dev.to/debs_obrien/testing-in-a-logged-in-state-with-the-playwright-mcp-browser-extension-4cmg)에는 압축 파일을 받아 풀고 개발자 모드를 켠 뒤 "압축해제된 확장 프로그램 로드"를 거쳐야 한다고 안내되어 있는데, 지금은 그 과정이 필요하지 않습니다.

설치 후 확장 프로그램 아이콘을 클릭하면 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 값이 표시됩니다.
이 값을 복사해 두세요.

![[스크린샷 2026-05-07 오후 4.14.15.png]]
![[Pasted image 20260507161400.png]]

_확장 아이콘을 누르면 토큰이 보입니다. 이 값을 그대로 MCP 설정의 `env`에 넣어 둡니다._

MCP 서버 설정의 `env`에 이 토큰을 함께 넣어 두면, 매번 연결을 새로 승인하지 않아도 확장이 MCP 서버를 식별해 자동으로 붙어 줍니다.

### 2. 기존 Playwright 플러그인 확인

> [!WARNING]
> Claude Code의 플러그인 마켓플레이스(`claude-plugins-official` 등)에서 Playwright 플러그인을 이미 설치했다면, 같은 이름의 MCP가 이미 등록되어 있는 상태입니다.
>
> 이때 `~/.claude.json`이나 `.mcp.json`에 같은 이름(`playwright`)으로 MCP 설정을 추가해도 **플러그인 설정이 우선순위에서 이겨서 새 설정이 그대로 무시됩니다.** 도구는 정상적으로 호출되지만 `--extension` 플래그가 빠진 기본 버전이 돌고 있는 상태라, "왜 자꾸 새 창이 뜨지?" 하면서 한참 헤멜 수 있어요.

먼저 플러그인 설치 여부부터 확인합니다.

```bash
claude plugin list
```

목록에 `playwright`가 있으면 다음 단계로 넘어가면 됩니다. 없다면 두 가지 선택지가 있습니다.

- **공식 플러그인 설치**: 마켓플레이스에서 Playwright 플러그인을 설치하고 그 플러그인의 설정을 수정합니다. 자동 업데이트가 되는 장점이 있습니다.
- **사용자 설정으로 직접 추가**: 플러그인 없이 `~/.claude.json` 또는 `.mcp.json`에 직접 MCP 설정을 추가합니다. dotfiles로 관리하기 좋습니다.

### 3. MCP 설정 추가

#### Case A: Playwright 플러그인이 이미 설치되어 있다면

플러그인의 `.mcp.json`을 직접 수정합니다. 위치는 보통 `~/.claude/plugins/` 하위에 있는데, 정확한 경로는 환경마다 다르니 직접 찾아야 합니다. Claude Code에 "Playwright 플러그인의 .mcp.json 위치 찾아줘"라고 시키면 알아서 찾아 줍니다.

기존 설정에 `--extension` 플래그와 토큰만 추가하면 됩니다.

```json
{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest", "--extension"],
      "env": {
        "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "여기에-복사한-토큰"
      }
    }
  }
}
```

> [!WARNING]
> 플러그인이 업데이트되거나 재설치되면 이 변경 사항이 사라질 수 있습니다. 그럴 때는 다시 수정해 주면 되는데, 글 하단의 자동화 프롬프트를 다시 돌리는 편이 가장 빠르더라고요.

#### Case B: 플러그인 없이 직접 설정한다면

설정 위치는 두 가지 중에 고르면 됩니다.

- **프로젝트 단위**: 프로젝트 루트의 `.mcp.json`
- **글로벌**: `~/.claude.json`

설정 내용은 위와 동일합니다.

어느 경우든 핵심은 **`--extension` 플래그**입니다. [microsoft/playwright-mcp README](https://github.com/microsoft/playwright-mcp) Configuration 섹션에서 이 플래그를 "Connect to a running browser instance (Edge/Chrome only). Requires the 'Playwright Extension' to be installed."로 정의해 두었는데, 풀어 말하면 새 브라우저를 띄우지 않고 이미 떠 있는 Chrome/Edge에 확장 프로그램을 통해 붙으라는 모드입니다. 이 플래그가 빠지면 MCP는 본래 동작인 "새 프로필 + 새 창"으로 돌아갑니다.

### 4. Claude Code 재시작

설정이 적용되려면 재시작이 필요합니다.

```bash
# 현재 세션 종료 (Ctrl+C 두 번 또는 /exit)
# 같은 디렉토리에서 다시 실행
claude

# MCP 서버 등록 확인
claude mcp list
```

목록에 `playwright`가 보이면 정상입니다.

### 5. 동작 확인

평소 쓰는 Chrome 프로필을 열어둔 상태로, Claude Code에서 간단한 명령을 내려봅니다.

> "Playwright로 https://github.com 페이지를 열어서 제목을 알려줘"

첫 호출이라면 Chrome에 어느 탭에 연결할지 선택하는 페이지가 뜹니다.

만약 새 Chrome 창이 따로 열린다면 확장 프로그램이 인식되지 않은 거예요. 이때 점검할 항목은 다음과 같습니다.

- `chrome://extensions`에서 Playwright MCP Bridge가 활성화되어 있는지
- 토큰 값이 설정 파일에 정확히 들어갔는지
- **실제로 어느 설정 파일이 적용되고 있는지** (플러그인이 설치되어 있다면 사용자 설정이 가려지고 있을 가능성)
- Claude Code 재시작을 한 번 더 시도

## 실제로 써 보니

가장 체감되는 건 **사내 시스템 자동화**였어요. SSO 로그인이 걸린 어드민 페이지를 자동화하려면 원래는 세션을 열 때 마다 로그인 흐름을 직접 처리하거나 storageState를 따로 만들어야 했는데, 이제는 평소처럼 로그인해둔 탭을 그대로 쓰면 끝입니다.

Debbie O'Brien도 같은 가치를 강조해요.

> "Test real user sessions without worrying about logging-in… Run automation against enterprise apps that require authentication."
> — [dev.to, Debbie O'Brien](https://dev.to/debs_obrien/testing-in-a-logged-in-state-with-the-playwright-mcp-browser-extension-4cmg)

추가로 좋았던 점은 이렇습니다.

- 평소 쓰던 확장 프로그램(개발자 도구, React DevTools 등)도 그대로 동작합니다
- 쿠키와 세션이 유지되니 OTP 같은 것을 다시 받을 필요가 없습니다
- 자동화 도중에 직접 탭을 조작하는 것도 가능해요

주의할 점도 있습니다.

- 확장은 Chrome 또는 Edge 같은 Chromium 계열에서만 동작합니다. README의 `--extension` 플래그 정의가 "Edge/Chrome only"로 못 박혀 있고, Firefox·WebKit은 일반 `--browser` 옵션으로만 띄울 수 있어요 ([microsoft/playwright-mcp README](https://github.com/microsoft/playwright-mcp))
- 평소 쓰는 프로필이다 보니 자동화 도중 실수로 중요한 동작이 일어날 수 있어요. 민감한 작업이라면 **별도 Chrome 프로필을 만들어** 거기에 확장 프로그램을 설치하는 방법도 고려해 볼 만합니다
- **플러그인 마켓플레이스의 Playwright를 쓰고 있다면**, 사용자 설정에 같은 이름으로 추가해도 적용되지 않는다는 점을 기억해 두세요

## 한 번에 적용하는 프롬프트

같은 설정을 매번 반복하기 번거로워서, Claude Code에 그대로 붙여 넣으면 단계별로 진행해 주는 프롬프트를 만들어 두었습니다. 
토큰만 준비해 두고 아래 프롬프트를 붙여 넣으면 됩니다. 플러그인 설치 여부도 자동으로 감지해서 적절한 위치에 설정을 추가해 줍니다.

````markdown
# Connect Playwright MCP to Existing Chrome Profile

I want to fix the issue where Playwright MCP launches a fresh Chrome profile every time, which means I can't use my logged-in sessions. Please configure it to connect to my existing Chrome profile via the Chrome Web Store extension method.

## Steps

### Step 1: Guide me to install the Chrome extension

Please ask me the following in Korean:

- Chrome 웹 스토어에서 Playwright MCP Bridge 확장 프로그램을 설치해주세요:
  https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm
- 설치 후 Chrome 툴바의 확장 프로그램 아이콘을 클릭하면 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 값이 표시됩니다.
- 그 토큰 값을 복사해서 채팅에 붙여넣어 주세요.

**Important: Wait until I provide the token before moving to the next step.** Do not proceed without it.

### Step 2: Detect existing Playwright plugin

Before deciding where to apply the config, check whether a Playwright plugin from the Claude Code plugin marketplace is already installed. This matters because plugin-provided MCP servers take priority over user-defined ones with the same name, which would cause the new config to be silently ignored.

Run the following to detect installed plugins:

```bash
claude plugin list
```

Look for any plugin named `playwright` (typically from `claude-plugins-official` marketplace).

Branch based on the result:

#### Case A: Playwright plugin is already installed

Inform me in Korean:

> Playwright 플러그인이 이미 설치되어 있어서, 사용자 설정(`~/.claude.json` 또는 `.mcp.json`)에 같은 이름으로 추가하면 플러그인 설정에 가려져 적용되지 않습니다. 따라서 **플러그인의 `.mcp.json`을 직접 수정**하는 방식으로 진행하겠습니다.

Then:

1. Locate the plugin's `.mcp.json`. Typically under `~/.claude/plugins/` or similar — find the actual path on this machine.
2. Show me the current content and ask for confirmation before editing.
3. Modify the `playwright` entry to add `--extension` to args and the `PLAYWRIGHT_MCP_EXTENSION_TOKEN` env var while preserving any other existing settings.

Resulting config should look like:
```json
{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest", "--extension"],
      "env": {
        "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<token-I-provided>"
      }
    }
  }
}
```

Also warn me in Korean:

> ⚠️ 플러그인이 업데이트되거나 재설치되면 이 변경 사항이 사라질 수 있습니다. 그 경우 이 프롬프트를 다시 실행하면 됩니다.

Skip Step 3 (which is for non-plugin installs) and go directly to Step 4.

#### Case B: Playwright plugin is NOT installed

Ask me in Korean:

> Playwright MCP를 설치하는 방법은 두 가지입니다. 어느 쪽으로 진행할까요?
>
> 1. **공식 플러그인 설치**: `claude-plugins-official` 마켓플레이스의 playwright 플러그인을 설치합니다. 플러그인 시스템으로 자동 업데이트가 가능합니다.
> 2. **사용자 설정으로 직접 추가**: 플러그인 없이 `~/.claude.json` 또는 프로젝트의 `.mcp.json`에 직접 MCP 설정을 추가합니다. dotfiles 등으로 관리하기 쉽습니다.

Wait for my answer.

- If I choose option 1: guide me through installing the plugin first (e.g., via `/plugin` or the appropriate command), then once installed, treat it as Case A and modify the plugin's `.mcp.json`.
- If I choose option 2: proceed to Step 3.

### Step 3: Ask me where to apply the config (only for Case B option 2)

Ask me in Korean which location to add the MCP config to:

- **프로젝트 단위 설정**: 현재 프로젝트 루트의 `.mcp.json`에 추가 (해당 프로젝트에서만 사용)
- **사용자 글로벌 설정**: `~/.claude.json`에 추가 (모든 프로젝트에서 사용)

Wait for my answer, then add or update the config in that file:

- If the file does not exist, create it.
- If it exists, read the current contents and add a `playwright` entry under `mcpServers`, or overwrite it if one already exists.
- Leave any other MCP server configs untouched.

Config to add:
```json
{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest", "--extension"],
      "env": {
        "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<token-I-provided>"
      }
    }
  }
}
```

After applying, verify the file is valid JSON.

### Step 4: Restart Claude Code

Tell me in Korean to restart Claude Code so the config takes effect:

- 현재 Claude Code 세션을 종료한 뒤(`Ctrl+C` 두 번 또는 `/exit` 입력), 동일한 디렉토리에서 다시 실행해주세요:
  ```bash
  claude
  ```
- 재시작 후 MCP 서버가 정상 등록되었는지 확인하려면:
  ```bash
  claude mcp list
  ```
  목록에 `playwright`가 보이면 정상입니다.

Then ask me explicitly: **"재시작 하셨나요? 완료하셨다면 알려주세요."**

Wait for my confirmation before proceeding to Step 5.

### Step 5: Verify Playwright actually opens

Once I confirm the restart, walk me through a verification flow in Korean:

1. 평소 사용하는 Chrome 프로필이 열려 있는지 확인해주세요. (열려있지 않다면 먼저 열어주세요.)
2. 다음과 같이 간단한 테스트 명령을 내려달라고 안내해주세요:
   > "Playwright로 https://github.com 페이지를 열어서 제목을 알려줘"
3. 명령 실행 시 다음 흐름이 나타나는지 확인해주세요:
   - 첫 호출이라면 어느 탭에 연결할지 선택하는 페이지가 Chrome에 뜸
   - 작업할 탭을 선택하면 해당 탭에서 페이지 이동이 일어남
   - 새 Chrome 창이 따로 뜨지 않고, 평소 쓰던 프로필 안에서 동작함
4. 만약 새 Chrome 창이 따로 열린다면 확장 프로그램이 제대로 인식되지 않은 것이므로:
   - Chrome 확장 프로그램 페이지(`chrome://extensions`)에서 Playwright MCP Bridge가 활성화되어 있는지 확인
   - 토큰 값이 설정 파일에 정확히 들어갔는지 확인
   - 실제 실행 중인 MCP 프로세스가 어떤 설정 파일에서 시작되었는지 확인 (플러그인 vs 사용자 설정)
   - Claude Code를 다시 한 번 재시작

If anything fails, help me debug based on the symptom.

### Step 6: Final notes

Once verification succeeds, share the following with me in Korean:

- 사용 시에는 평소 쓰던 Chrome 프로필이 열려 있는 상태여야 합니다.
- 처음 Playwright MCP를 호출하면 어느 탭에 연결할지 선택하는 페이지가 뜹니다. 작업하려는 탭을 선택하세요.
- SSO/2FA가 필요한 사내 시스템도 이미 로그인된 세션을 그대로 활용할 수 있습니다.

## Ground rules

- Do not move to the next step until I respond.
- The token, plugin choice, and config location must come from me directly.
- All user-facing messages (questions, instructions, final notes) must be in Korean. Internal reasoning and tool usage can be in English.
- When a Playwright plugin is detected, prefer modifying its config directly to avoid the silent override problem caused by name collision.
````

## 마무리

확장 프로그램 방식으로 바꾸니 MCP가 평소 쓰던 브라우저의 일부처럼 자연스럽게 붙어서, 사내 시스템 자동화처럼 인증이 까다로운 작업이 한결 수월해졌어요. 

같은 번거로움 겪고 계신 분께 도움이 되었으면 좋겠습니다 :)

## 참고

본문에서 인용·참조한 자료입니다. 모두 2026년 5월 7일 기준으로 확인했습니다.

- microsoft/playwright-mcp 공식 README — [github.com/microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp)
  - User profile / Browser Extension 섹션, `--extension` 플래그 정의
- Playwright MCP 공식 문서, Browser Extension 페이지 — [playwright.dev/mcp/configuration/browser-extension](https://playwright.dev/mcp/configuration/browser-extension)
  - 확장 프로그램 동작 방식, Chrome 웹 스토어 설치 안내
- Debbie O'Brien, "Testing in a Logged-In State with the Playwright MCP Browser Extension" (dev.to, 2025-08-21) — [dev.to/debs_obrien/...](https://dev.to/debs_obrien/testing-in-a-logged-in-state-with-the-playwright-mcp-browser-extension-4cmg)
  - 초기 압축 해제 설치 방식, 사내 시스템 자동화 사용 사례]]></content:encoded>
          <category>DevOps</category>
          <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Radix UI vs Base UI: 어떤 Headless 라이브러리를 선택할까</title>
          <link>https://blog.ssumi.space/blog/radix-ui-vs-base-ui</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/radix-ui-vs-base-ui</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Headless UI 라이브러리 Radix UI와 Base UI의 설계 철학, API, 접근성, 개발 경험을 비교합니다</description>
          <content:encoded><![CDATA[## 왜 Headless UI 라이브러리인가

프론트엔드 개발의 흐름이 커스텀 디자인 시스템 쪽으로 이동하면서, 팀들은 스타일링에 대한 완전한 제어권을 원하면서도 다음을 유지하고 싶어합니다.

- 올바른 접근성
- 예측 가능한 동작
- 크로스 브라우저 일관성
- 조합 가능한 API

Headless UI 라이브러리는 로직, 상태 관리, 접근성, 인터랙션에 집중하고 스타일링은 개발자에게 맡깁니다.\
Tailwind CSS, 커스텀 디자인 시스템, shadcn 스타일의 컴포넌트 아키텍처와 특히 잘 어울리는 접근법입니다.

### Radix UI와 Base UI의 등장

**Radix UI**는 다음을 제공하며 인기를 얻었습니다.

- 프로덕션 수준의 컴포넌트
- 강력한 접근성 기본 지원
- 안정적이고 잘 문서화된 API
- 명확한 구조의 최소한 스타일링

**Base UI**는 프리미티브 우선 접근법을 취합니다.

- 극도로 로우레벨의 빌딩 블록
- DOM 구조에 대한 최대한의 제어
- 레이아웃과 스타일링에 대한 가정 최소화
- 커스텀 컴포넌트 시스템 구축에 최적화

두 라이브러리 모두 비슷한 문제를 풀지만, 추상화 수준, 제어권, 개발자 자유도 측면에서 서로 다른 트레이드오프를 선택합니다.

### 이 글에서 다루는 것

핵심 질문은 "어느 쪽이 더 좋은가?"가 아니라, **"내 프로젝트, 팀, 장기적 목표에 어느 쪽이 맞는가?"** 입니다.

이 글에서는 다음을 다룹니다.

- Radix UI와 Base UI의 개념적 차이
- API, 접근성 모델, 개발 경험 비교
- 실제 프로젝트에서의 의사결정 포인트
- 각 라이브러리가 적합한 시나리오

---

## Radix UI란

**Radix UI**는 React용 오픈소스 Headless UI 컴포넌트 라이브러리입니다.\
복잡한 접근성과 인터랙션 세부사항을 직접 다루지 않고도 고품질 UI 컴포넌트를 만들 수 있도록 도와줍니다.

### 핵심 특징

**스타일 미포함**

- CSS가 포함되지 않음
- 모든 스타일링을 직접 제어
- Tailwind CSS와 잘 동작

**열린 구조와 커스터마이징**

- 컴포넌트가 부분(Trigger, Content 등)으로 분리됨
- 컴포넌트를 감싸거나 확장 가능
- 자체 props, 이벤트, ref 추가 가능

**기본적으로 비제어 방식**

- 상태를 직접 관리할 필요 없음
- 필요하면 제어 방식으로 전환 가능

**개발 경험**

- TypeScript 타입 완벽 지원
- 컴포넌트 간 일관된 패턴
- `asChild` prop으로 렌더링할 HTML 요소 제어

### 접근성 접근법

접근성은 Radix UI의 핵심 강점입니다.

- WAI-ARIA 디자인 패턴 준수
- 포커스 트랩 처리 (다이얼로그 내부 등)
- Esc, Tab, 방향키 등 키보드 인터랙션 지원
- 스크린 리더와 잘 동작

트레이드오프는 접근성 로직이 Radix의 컴포넌트 구조에 밀접하게 결합되어 있어, 커스텀 레이아웃을 구현할 때 제약이 될 수 있다는 점입니다.

### 적합한 사용 사례

복잡한 인터랙션 로직을 직접 구축하지 않고 빠르게 접근성을 갖춘 컴포넌트를 원할 때 적합합니다.\
포커스 관리, 키보드 내비게이션, ARIA 패턴을 기본으로 제공합니다.

다이얼로그, 드롭다운, 툴팁 같은 일반적인 UI 패턴에 적합하며, 안정성이 중요한 대시보드나 SaaS 애플리케이션에서 특히 강점을 보입니다.\
shadcn/ui + Tailwind CSS 조합으로 프로덕션 수준의 프리미티브를 구축할 때도 좋은 선택입니다.

![Radix UI 장단점](https://wpblog.shadcnspace.com/wp-content/uploads/2026/02/radix-ui-1024x538.webp)

---

## Base UI란

**Base UI**는 유연성과 제어에 초점을 맞춘 Headless UI 라이브러리입니다.\
Radix UI가 완성된 프리미티브를 제공하는 반면, Base UI는 직접 조합하는 동작 빌딩 블록을 제공합니다.

Base UI의 철학은 **"접근성 우선, 구조는 나중에"** 입니다.

접근 가능한 인터랙션을 위한 로직은 제공하지만, 마크업과 레이아웃, 스타일링은 개발자에게 맡깁니다.

### 핵심 특징

**로우레벨 프리미티브**

- 미리 정의된 레이아웃 없음
- 강제되는 컴포넌트 구조 없음
- 컴포넌트 구성 방식을 직접 결정

**유연한 API**

- `initialFocus`, `finalFocus` 같은 props
- 커스텀 시스템에 쉽게 통합

**환경 간 테스트 완료**

- 브라우저, 디바이스, 스크린 리더, 플랫폼 전반에서 테스트

### 컴포넌트 아키텍처

Base UI 컴포넌트는 직접 사용하기보다 조합하도록 설계되었습니다.\
완성된 컴포넌트를 import하는 대신 다음과 같은 방식으로 작업합니다.

- Base UI의 동작을 자체 컴포넌트에 부착
- 상태가 어디에 위치할지 직접 결정
- DOM 구조를 완전히 제어

개념적으로 비교하면 이렇습니다.

- **Radix UI**: "여기 Dialog 구조가 있습니다."
- **Base UI**: "여기 dialog 동작이 있습니다 — 원하는 방식으로 적용하세요."

이런 특성 때문에 커스텀 디자인 시스템을 구축할 때 Base UI가 이상적입니다.

### 접근성 모델

Base UI는 접근성을 핵심 우선순위로 두면서도 개발자에게 더 많은 제어권을 줍니다.\
방향키, Enter, Esc를 이용한 키보드 내비게이션 같은 필수 동작을 처리하고, 포커스를 자동으로 관리하며, 접근 가능한 라벨과 폼 요소를 위한 구조를 제공합니다.

개발자는 포커스 상태(`:focus`, `:focus-visible`)의 스타일링, 적절한 색상 대비 유지, 접근 가능한 이름 제공을 직접 책임져야 합니다.\
접근성에 대한 더 깊은 이해가 필요하지만, 그만큼 유연성도 높아집니다.

### 적합한 사용 사례

복사-붙여넣기 가능한 코드 블록을 제공하는 컴포넌트 레지스트리 플랫폼에 적합합니다.\
사용자가 마크업과 스타일링을 커스터마이징하는 애니메이션이 풍부한 인터랙티브 컴포넌트에 특히 유용합니다.

이런 시나리오에서는 미리 정의된 구조보다 유연성이 더 중요합니다.\
Base UI가 동작과 접근성을 처리하고, 구조와 비주얼 디자인은 전적으로 개발자의 몫입니다.

![Base UI 장단점](https://wpblog.shadcnspace.com/wp-content/uploads/2026/02/base-ui-1024x538.webp)

---

## 핵심 차이점 비교 (코드 포함)

### 설계 철학

**Radix UI**는 모범 사례를 따르는 미리 정의된 구조를 제공합니다.

**Base UI**는 동작만 제공하고, 구조는 개발자가 결정합니다.

### 컴포넌트 추상화 수준

#### Radix UI (구조화된 컴포넌트)

```jsx

function RadixDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>열기</Dialog.Trigger>

      <Dialog.Portal>
        
        <Dialog.Content className="content">
          <Dialog.Title>다이얼로그 제목</Dialog.Title>
          <Dialog.Description>다이얼로그 설명</Dialog.Description>

          <Dialog.Close>닫기</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
```

이 방식에서는 다음과 같이 합니다.

- Radix의 컴포넌트 트리를 따름
- 제공된 파트에 스타일을 입힘
- 올바른 동작이 자동으로 적용됨

#### Base UI (동작 우선)

```jsx

function BaseDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>열기</Dialog.Trigger>

      <Dialog.Popup className="content">
        <h2>다이얼로그 제목</h2>
        <p>다이얼로그 설명</p>

        <button>닫기</button>
      </Dialog.Popup>
    </Dialog.Root>
  );
}
```

이 방식에서는 다음과 같이 합니다.

- 어떤 요소를 사용할지 직접 결정
- 레이아웃과 구조를 직접 구성
- 다이얼로그 동작을 자체 마크업에 부착

### 스타일링 유연성

**Radix UI**에서는 스타일링이 자유롭지만 구조는 고정됩니다.\
Overlay, Content 등 제공된 파트를 기준으로 스타일을 입힙니다.

```jsx

```

**Base UI**에서는 필수 레이아웃도, 필수 요소 타입도 없습니다.\
커스텀 디자인을 맞추기 더 쉽습니다.

```jsx

```

Base UI에서는 미리 정의된 조각에 제약받지 않습니다.

### 커스터마이징 제어

#### Dialog Close 커스터마이징 비교

두 라이브러리 모두 다이얼로그 내부에 닫기 버튼을 렌더링하는 방법을 제공합니다.\
차이는 그 동작에 대해 얼마나 많은 제어권을 갖는가입니다.

##### Radix UI — Boolean 방식

Radix UI에서 닫기는 보통 전용 `Dialog.Close` 컴포넌트나 단순한 boolean 기반 prop 패턴으로 처리합니다.

```jsx

```

- `close`는 boolean
- true면 기본 닫기 버튼이 렌더링됨
- 설정 옵션이 제한적
- 스타일링은 보통 외부에서 처리

닫기 기능은 의견이 반영된(opinionated), 미리 구조화된 형태로 API 노출 면적이 최소화되어 있습니다.\
커스텀 렌더링 로직, 고급 스타일링, 동적 동작이 필요하면 수동으로 별도 로직을 구현해야 합니다.

![Radix UI 다이얼로그](https://wpblog.shadcnspace.com/wp-content/uploads/2026/02/radix-ui-dialog-1024x538.webp)

##### Base UI — 객체 기반 제어

Base UI에서는 close prop이 객체를 받습니다.

```jsx
<DialogContent
  close={{
    nativeButton: true,
    className: 'absolute top-4 right-4',
    style: { color: 'red' },
    render: props => ,
  }}
/>
```

**설정 가능한 옵션:**

| 속성         | 타입                            | 용도                          |
| ------------ | ------------------------------- | ----------------------------- |
| nativeButton | boolean                         | 네이티브 `<button>` 요소 사용 |
| className    | string \| function              | 스타일 완전 커스터마이징      |
| style        | React.CSSProperties \| function | 인라인 스타일 제어            |
| render       | ReactElement \| function        | 기본 렌더링 교체              |

##### 비교 요약

| 기능                 | Radix UI       | Base UI     |
| -------------------- | -------------- | ----------- |
| 단순 활성화/비활성화 | Boolean        | Object      |
| 스타일링 제어        | 제한적         | 완전한 제어 |
| 렌더링 교체          | 수동 우회 필요 | 내장 지원   |
| 동적 스타일 로직     | 외부 처리      | 기본 지원   |
| 네이티브 요소 제어   | 간접적         | 명시적      |

![Base UI 커스터마이징](https://wpblog.shadcnspace.com/wp-content/uploads/2026/02/When-to-choose-Base-UI-1-1024x538.webp)

#### 실질적 차이

**Radix** — 닫기 버튼을 활성화합니다. 커스터마이징하려면 추가 구조나 오버라이드가 필요합니다.

**Base UI** — 닫기 버튼을 설정합니다. API가 처음부터 깊은 커스터마이징을 위해 설계되었습니다.

#### 왜 중요한가

컴포넌트 라이브러리가 성장할수록 이 차이가 드러납니다.

- Boolean API는 간단하지만 경직됨
- 객체 기반 API는 확장성이 더 좋음
- 고급 디자인 시스템은 설정 기반 제어에서 이점을 얻음

Base UI는 추상화 계층을 깨뜨리지 않으면서 개발자에게 제어권을 줍니다.

### 애니메이션과 트랜지션

**Radix UI**는 `data-state` 속성을 사용합니다.

```css
[data-state='open'] {
  animation: fadeIn 200ms ease;
}
```

**Base UI**는 애니메이션에 대한 가정이 없어 어떤 방식이든 호환됩니다.

```jsx

```

Base UI가 더 적합한 경우:

- 애니메이션이 복잡할 때
- 모션 라이브러리를 사용할 때
- 레이아웃이 동적으로 변할 때

### 학습 곡선

**Radix UI**는 시작이 쉽습니다.\
명확한 멘탈 모델과 빠른 초기 셋업을 제공합니다.

**Base UI**는 배우는 데 시간이 더 걸립니다.\
시스템 수준의 사고가 필요하지만, 복잡도가 높아질수록 투자한 시간이 빛을 발합니다.

---

## 개발 경험 비교

개발 경험은 단순히 문서화나 TypeScript 타입 수준의 문제가 아닙니다.\
UI를 만들고 유지보수하면서 느끼는 마찰의 정도가 핵심입니다.

### API 설계

**Radix UI**의 API는 컴포넌트 간에 일관됩니다.\
Root, Trigger, Content 같은 예측 가능한 패턴을 따릅니다.\
하나의 컴포넌트를 배우면 나머지도 이미 아는 것이나 마찬가지입니다.

> "Dialog가 어떻게 동작하는지 알면, Dropdown이나 Tooltip도 이미 아는 겁니다."

**Base UI**의 API는 더 유연하고 덜 의견적(opinionated)입니다.\
컴포넌트 구조에 대한 가정이 적어, 훅과 프리미티브로 작업하는 느낌에 가깝습니다.

> "도구를 받고, 어떻게 사용할지는 내가 결정합니다."

### 조합 패턴

**Radix UI**는 미리 정의된 슬롯을 통한 조합을 권장합니다.\
표준 레이아웃에는 잘 동작하지만, 특이한 구조에서는 유연성이 떨어집니다.

```jsx
<Dialog.Root>
   
   
</Dialog.Root>
```

**Base UI**는 자체 컴포넌트를 통한 조합을 권장합니다.\
커스텀 레이아웃과 재사용 가능한 블록에 잘 동작하며, 필수 컴포넌트 트리가 없습니다.

```jsx
<Dialog.Root>
   
   
</Dialog.Root>
```

Base UI는 컴포넌트를 조합하는 방식에서 더 많은 자유를 줍니다.

### TypeScript 지원

두 라이브러리 모두 TypeScript를 잘 지원합니다.\
Radix UI는 완전히 타입이 지정된 API와 매우 안정적인 자동완성을 제공합니다.\
Base UI도 강력한 TypeScript 지원을 제공하되, 커스텀 조합을 지원하기 위해 타입이 더 유연한 편입니다.

대부분의 팀에게 두 라이브러리 모두 탄탄한 TypeScript 경험을 제공합니다.

### Tailwind & shadcn 스타일 시스템과의 통합

**Radix UI**는 Tailwind CSS와 매우 잘 동작하며, shadcn 스타일 컴포넌트에 적합합니다.\
구조가 복사-붙여넣기 컴포넌트 패턴에 잘 맞습니다.

**Base UI**는 컴포넌트 레지스트리 구축에 이상적입니다.\
원시 마크업과 로직을 노출하기 쉽고, 애니메이션이 있는 커스터마이징 가능한 코드 블록에 더 적합합니다.

고도로 커스터마이징 가능한 컴포넌트를 사용자가 자기 프로젝트에 맞게 수정하도록 제공하는 것이 목표라면, Base UI가 더 자연스럽게 맞습니다.

### 개발 경험 요약

| 영역       | Radix UI       | Base UI                    |
| ---------- | -------------- | -------------------------- |
| 조합 방식  | 슬롯 기반      | 자유 형식                  |
| 상태 제어  | 쉬운 기본값    | 명시적 제어                |
| TypeScript | 우수           | 우수                       |
| API 스타일 | 일관적, 구조화 | 유연, 로우레벨             |
| 최적 용도  | 앱 개발        | 디자인 시스템 & 레지스트리 |

---

## Radix UI를 선택해야 할 때

Radix UI는 강력한 기본값, 예측 가능한 동작, 바로 사용할 수 있는 접근성이 필요하면서\
컴포넌트 로직을 직접 설계하는 데 시간을 쓰고 싶지 않을 때 가장 잘 맞습니다.

### 적합한 프로젝트 시나리오

다음과 같은 경우 Radix UI를 강력히 고려하세요.

**바로 사용 가능한 접근성이 필요한 경우** — Radix가 키보드 내비게이션, 포커스 관리, ARIA 역할을 처리합니다.\
접근성 전문가가 아니어도 올바른 컴포넌트를 만들 수 있습니다.

**UI 시스템이 아닌 제품 UI를 만드는 경우** — 대시보드, 관리자 패널, SaaS 앱, 내부 도구에서 빠르게 진행할 수 있습니다.

**검증된 프로덕션 수준의 프리미티브가 필요한 경우** — Radix는 많은 실제 프로젝트에서 사용되었고,\
엣지 케이스가 이미 처리되어 있습니다.

**명확하고 안정적인 API를 선호하는 팀** — 컴포넌트 API가 일관되고 갑자기 변경될 가능성이 낮습니다.

**Tailwind나 shadcn 스타일 컴포넌트와 조합하는 경우** — Radix는 스타일이 입혀진 컴포넌트 뒤의 "로직 레이어"로서 매우 잘 동작합니다.

**적합한 프로젝트 예시:** SaaS 대시보드, 관리자 패널, 내부 도구, MVP와 스타트업 제품, 안정성 우선의 디자인 시스템

### 장단점

**장점:**

- 바로 사용 가능한 강력한 접근성
- 프로덕션 수준의 컴포넌트
- 일관되고 예측 가능한 API
- 우수한 TypeScript 지원
- Tailwind CSS와 탁월한 호환
- 큰 커뮤니티와 생태계

**단점:**

- 다소 의견적인(opinionated) 구조
- DOM 마크업에 대한 제어가 적음
- 커스텀 레이아웃에서 제한적
- 추상화 레이어가 더 많음

### 한 마디로

"제품을 빠르고 안전하게 만든다"가 목표라면, Radix UI가 보통 더 나은 선택입니다.\
좋은 기본값, 적은 의사결정, 적은 버그를 제공합니다.

---

## Base UI를 선택해야 할 때

Base UI는 모든 프로젝트에 최적인 선택이 되려고 하지 않습니다.\
유연성, 제어, 커스터마이징이 완성된 동작보다 중요할 때 빛을 발합니다.

### 적합한 프로젝트 시나리오

다음을 구축한다면 Base UI를 선택하세요.

#### 1. 컴포넌트 레지스트리 플랫폼

복사-붙여넣기 가능한 UI 컴포넌트를 제공하고, API만이 아니라 전체 컴포넌트 코드를 공유하며, 개발자가 구조와 로직과 스타일을 편집하길 기대하는 플랫폼.\
Base UI는 무거운 추상화 뒤에 로직을 숨기지 않기 때문에 이런 용도에 잘 맞습니다.

#### 2. 여러 레지스트리의 UI 블록을 사용하는 웹사이트

다양한 레지스트리에서 UI 블록을 가져오고, 오픈소스 라이브러리에서 컴포넌트를 복사하며, 레이아웃과 섹션과 인터랙션을 혼합하고, 서드파티 코드를 빠르게 적용해야 하는 경우.\
Base UI는 라이브러리와 싸우지 않고 복사한 코드를 완전히 제어할 수 있게 합니다.

엄격한 컴포넌트 API, 숨겨진 내부 로직, 의견적인 구조에 갇히지 않습니다.\
대신 평범하고 이해하기 쉬운 React 코드를 얻습니다.

#### 3. 높은 커스터마이징이 필요한 디자인 시스템

디자인 시스템이 자주 변하고, 프로젝트마다 마크업과 동작을 커스터마이징하며, 경직된 컴포넌트가 아닌 일관된 패턴을 원하는 경우.\
Base UI는 시스템이 진화하면서 컴포넌트를 재구성할 자유를 줍니다.

#### 4. 애니메이션 중심 인터페이스

애니메이션이 브랜드 아이덴티티의 일부이고, Framer Motion이나 CSS 애니메이션을 사용하며, 트랜지션과 타이밍에 대한 완전한 제어가 필요한 경우.\
Base UI는 애니메이션을 컴포넌트 로직 바깥에 두어, 본래 있어야 할 위치에 놓습니다.

#### 5. 코드에 대한 소유권을 원하는 팀

UI 코드의 모든 줄을 이해하고 싶고, 설정보다 조합을 선호하며, 벤더 종속을 원하지 않고, 장기 유지보수성을 중시하는 팀에 적합합니다.

### 한 마디로

레지스트리에서 UI 블록을 가져와 사용하면서 완전한 제어가 필요하다면, Base UI가 더 나은 기반입니다.

### 장단점

**장점:**

- DOM 구조에 대한 최대한의 제어
- 컴포넌트 레지스트리에 적합
- 유연한 조합 기반 API
- 애니메이션과 잘 동작
- 벤더 종속 없음
- 커스터마이징 가능한 시스템에 이상적

**단점:**

- 가파른 학습 곡선
- 개발자 책임이 더 큼
- 안내가 적음
- 접근성에 대한 인식 필요
- 초기 셋업이 더 많음

---

## 마무리

Radix UI와 Base UI는 모두 모던 React 인터페이스를 구축하기 위한 강력한 도구이지만, 컴포넌트 설계에 대한 접근법이 다릅니다.

**Radix UI**는 잘 테스트된 프리미티브에 강력한 접근성과 구조화된 API를 제공하여, 안정적인 애플리케이션 인터페이스를 구축하는 데 좋은 선택입니다.

**Base UI**는 유연성에 초점을 맞춰 컴포넌트 구조, 로직, 동작을 수정할 수 있는 더 큰 자유를 개발자에게 줍니다.

올바른 선택은 결국 UI를 어떻게 만들고 싶은지, 그리고 기반 코드에 대한 제어를 얼마나 원하는지에 달려 있습니다.

결국 가장 좋은 도구는 자신의 워크플로우에 맞고,\
더 나은 인터페이스를 만드는 데 도움이 되는 것입니다.]]></content:encoded>
          <category>UI 라이브러리</category>
          <pubDate>Fri, 24 Apr 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>공식 i18next-cli가 네임스페이스를 통째로 지우려 했다</title>
          <link>https://blog.ssumi.space/blog/i18n-unused-keys-official-cli-failure</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/i18n-unused-keys-official-cli-failure</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>i18next 공식 CLI를 돌렸더니 네임스페이스 전체를 경고 없이 삭제 대상으로 잡았습니다. I18N_KEYS 상수 객체 패턴이 공식 도구와 충돌한 과정과, 커스텀 스크립트를 택한 이유.</description>
          <content:encoded><![CDATA[v0에서 v1으로 넘어가면서 번역 키가 눈에 띄게 불어났습니다.
화면은 바뀌는데 JSON 리소스에 남은 옛 키는 아무도 안 지우니까요.

자동으로 정리하려고 공식 추천 CLI인 `i18next-cli`를 돌렸는데, 수십 곳에서 쓰이는 네임스페이스를 통째로 삭제 대상으로 잡았습니다. 경고 하나 없이요.

이 글에서는 왜 그런 일이 벌어졌는지, 그리고 공식 도구를 버리고 커스텀 스크립트를 택한 과정을 정리합니다.

## 미사용 키가 쌓인 배경

저희 프로젝트는 [`react-i18next`](https://react.i18next.com/)와 JSON 리소스 구조로 번역을 관리합니다. 네임스페이스 18개, `ko.json` 기준 리프 키 1,000개 이상 규모입니다.

v0→v1 전환에서 기능과 라우트가 한꺼번에 빠지면서, **지워야 할 키**와 **넣어야 할 키**가 동시에 불어났습니다. 한두 개씩 손으로 지우는 걸로는 감당이 안 됐습니다.

자동 삭제를 논의하면서 팀에서 가장 먼저 합의한 건, "안전한 삭제"의 기준이었습니다.

| 기준               | 의미                                                                 |
| ------------------ | -------------------------------------------------------------------- |
| **런타임 안전**    | 사용자에게 번역 키 문자열이 그대로 노출되지 않는다                   |
| **로케일 쌍 유지** | `ko.json`과 `en.json`에서 같은 키 트리를 유지한다                    |
| **동적 키 예외**   | ``t(`prefix.${variable}`)`` 같은 패턴은 일괄 삭제하지 않는다         |

이 세 기준이 이후 도구 선택과 스크립트 설계의 판단 근거가 되었습니다.

## I18N_KEYS라는 상수 객체

저희 레포에서는 네임스페이스 문자열을 `I18N_KEYS`라는 상수 객체로 모아두고, 컴포넌트에서는 `useTranslation(I18N_KEYS.FEATURE)` 형태로 씁니다.

```ts title="src/shared/lib/i18n/const/const.ts"

  COMMON: 'common',
  DASHBOARD: 'dashboard',
  PROJECT: 'project',
  // ... 18개 네임스페이스
} as const;
```

에디터 자동완성, 심볼 리네임, 참조 검색에서 문자열 리터럴보다 확실히 편했습니다.

그런데 이 패턴에는 트레이드오프가 있었습니다. `useTranslation(I18N_KEYS.COMMON)`은 AST에서 `MemberExpression`이라는 노드 타입으로 보입니다. "이게 곧 `'common'`이다"를 알려면 `I18N_KEYS`가 정의된 다른 파일까지 따라가야 하는데, 대부분의 i18next 플러그인이 이 크로스 파일 추적을 지원하지 않습니다.

## extract 결과, 사용처 0건

[`i18next-parser`](https://github.com/i18next/i18next-parser)가 아카이브된 이후 공식 저장소에서 후속 도구로 안내하는 게 [`i18next-cli`](https://github.com/i18next/i18next-cli)였습니다. SWC 기반에 `extract`, `status`, `sync` 명령을 갖추고 있어서, 먼저 이 도구부터 돌려봤습니다.

저희 코드에는 이런 컴포넌트가 있습니다.

```tsx
const { t } = useTranslation(I18N_KEYS.FEATURE_DETAIL);
```

사람 눈에는 `I18N_KEYS.FEATURE_DETAIL`이 `'feature-detail'`이라는 게 바로 보입니다.

그런데 플러그인 입장에서는 이 코드가 `useTranslation(someIdentifier.SOMETHING)`으로 읽힙니다. AST 노드 타입으로 말하면 `MemberExpression`이고, 플러그인이 값을 알려면 다른 파일을 따라가야 합니다. `i18next-cli`는 이 크로스 파일 해석을 지원하지 않았습니다.

`extract` 결과에서 `feature-detail` 네임스페이스의 사용처가 **0건**으로 잡혔습니다. 실제로는 수십 곳에서 쓰이고 있었습니다.

여기에 sync를 걸면 어떻게 될까요. diff를 확인했더니 `feature-detail` 네임스페이스의 JSON 블록이 **통째로 삭제 대상**이었습니다.

```diff
- "feature-detail": {
-   "title": "기능 상세",
-   "description": "이 기능은...",
-   "field": {
-     "name": "필드 이름",
-     "type": "필드 타입",
-     "required": "필수 여부"
-   }
- }
```

단순히 "키를 못 찾았다"가 아니었습니다. 플러그인이 불확실한 결과를 경고 없이 확정 결과처럼 내놓았습니다. "이 네임스페이스는 어디서도 쓰이지 않는다"라고 **자신 있게 잘못된 방향으로 동작**한 겁니다.

만약 "해석 불가"라는 경고가 나왔다면, 그건 도구의 한계이지 결함은 아닐 수 있습니다. 그런데 경고 없이 전체 삭제를 제안한다면, 자동화 파이프라인에 넣을 수가 없습니다. 한 번이라도 사람이 놓치면 프로덕션에서 번역이 깨지니까요.

> 관련 이슈: [i18next-cli #188](https://github.com/i18next/i18next-cli/issues/188) — namespace/keyPrefix 스코프 해석 문제

## 다른 후보도 같은 벽에 부딪혔다

[`i18next-scanner`](https://github.com/i18next/i18next-scanner)도 살펴봤습니다. `removeUnusedKeys` 옵션이 있어서 카탈로그 정리에는 강점이 있었지만, `MemberExpression`을 만나면 값을 해석하지 못하는 건 동일했습니다. 플러그인의 입력 가정이 문자열 리터럴이라는 점에서 `i18next-cli`와 근본적으로 같은 한계를 공유하고 있었습니다.

ESLint 플러그인은 방향이 달랐습니다. "코드에서 쓰이는 키가 JSON에 있는지" 역방향으로 검증하는 데는 유용하지만, "JSON에 있는데 코드에서 안 쓰이는 키"를 찾아서 제거하는 워크플로에는 맞지 않았습니다.

| 도구                        | 평가                                                  | 결과             |
| --------------------------- | ----------------------------------------------------- | ---------------- |
| **i18next-cli**             | `I18N_KEYS` 간접 참조에서 네임스페이스 전체를 미사용 판정 | **검토 후 제외** |
| i18next-scanner             | 같은 AST 한계 공유                                    | 제외             |
| i18next-parser              | 아카이브 상태                                         | 제외             |
| ESLint 플러그인             | 역방향 검증에만 유용                                  | 보조             |
| **ts-morph 커스텀 스크립트** | `I18N_KEYS`를 직접 해석 가능                          | **채택**         |

## 플러그인이 우리 패턴을 모른다면

도구들을 돌려보고 나니 문제의 본질이 선명해졌습니다. **플러그인이 저희 패턴을 모른다**는 것이었습니다.

그런데 `I18N_KEYS`는 외부 라이브러리가 아니라 저희 코드베이스 안에 있습니다. `useTranslation(I18N_KEYS.X)` 호출을 AST로 파싱하면, `X`가 어떤 값인지 **같은 프로세스 안에서** 조회할 수 있습니다.

[ts-morph](https://ts-morph.com/)를 선택한 건 TypeScript AST를 프로그래밍 방식으로 탐색할 수 있기 때문이었습니다. `I18N_KEYS` 구조가 바뀌면 스크립트도 고쳐야 한다는 유지 비용은 생기지만, 공식 도구가 자신 있게 잘못된 결과를 내놓는 것보다는 직접 관리하되 오탐이 없는 쪽이 자동화 파이프라인에 넣기에 안전하다고 판단했습니다.

- 공식 추천 `i18next-cli`는 `MemberExpression`을 해석하지 못해, 네임스페이스 전체를 **경고 없이** 삭제 대상으로 잡았다
- 다른 후보도 같은 AST 한계를 공유했다
- `I18N_KEYS`는 저희 코드베이스 안에 있으니, 직접 해석하면 오탐 없이 추적할 수 있었다

그래서 ts-morph로 `I18N_KEYS`를 직접 읽는 스크립트를 만들기 시작했습니다. `--apply` 모드와 30% 안전 가드까지 붙여서요. 다음 글 [ts-morph와 GitHub Actions로 번역 키 자동 정리 파이프라인 만들기](/blog/i18n-unused-keys-custom-pipeline)에서 그 설계와 자동화 과정을 다룹니다.]]></content:encoded>
          <category>DevOps</category>
          <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>ts-morph와 GitHub Actions로 번역 키 자동 정리 파이프라인 만들기</title>
          <link>https://blog.ssumi.space/blog/i18n-unused-keys-custom-pipeline</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/i18n-unused-keys-custom-pipeline</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>I18N_KEYS를 진실원으로 삼는 AST 수집기의 설계. 네임스페이스 해석, 키 수집, 화이트리스트, 30% 안전 가드, 그리고 GitHub Actions 주간 cron 자동 PR까지.</description>
          <content:encoded><![CDATA[공식 CLI가 `I18N_KEYS` 간접 참조를 해석하지 못하는 걸 확인하고 나니, 남은 과제는 하나였습니다.

`I18N_KEYS.FEATURE_DETAIL`이 어떤 문자열을 가리키는지, **같은 프로세스 안에서** 추적하는 것.

이 글에서는 ts-morph로 만든 미사용 키 탐지 스크립트의 설계와, GitHub Actions 주간 cron으로 자동 PR을 올리는 파이프라인까지의 과정을 정리합니다.

## I18N_KEYS를 진실원으로 삼는다

> [ts-morph](https://ts-morph.com/)란?
> TypeScript Compiler API를 감싸서 AST 탐색·조작을 간결하게 해주는 라이브러리입니다.
> 파일 간 심볼 추적, 노드 타입 판별, 코드 수정까지 하나의 API로 처리할 수 있습니다.

ts-morph는 TypeScript AST를 직접 읽고 심볼을 따라갈 수 있습니다. `I18N_KEYS.FEATURE_DETAIL = 'feature-detail'`처럼 같은 코드베이스에 정의된 값을 런타임 없이 조회할 수 있어서, 공식 CLI가 못 하는 바로 그 한 가지를 해결하는 셈이었습니다.

[앞선 글](/blog/i18n-unused-keys-official-cli-failure)에서 정한 "안전한 삭제" 세 기준이 스크립트 설계 전반에 걸쳐 대응됩니다.

| "안전한 삭제" 기준 | 스크립트에서의 대응                               |
| ------------------ | ------------------------------------------------- |
| 로케일 쌍 유지     | `--apply` 시 `ko.json`/`en.json` 양쪽 동시 삭제  |
| 런타임 안전        | 네임스페이스 해석을 보수적으로 판정                  |
| 동적 키 예외       | 화이트리스트로 명시적 보존                        |

파일 구성은 이렇게 잡았습니다.

```
fe/scripts/i18n/
  find-unused-i18n.ts   # 메인: AST 수집, diff, --apply
  i18n-preserve.ts      # 화이트리스트: 동적/간접 참조 보존 패턴
```

```json
{
  "scripts": {
    "i18n:find-unused": "tsx scripts/i18n/find-unused-i18n.ts",
    "i18n:prune": "tsx scripts/i18n/find-unused-i18n.ts --apply"
  }
}
```

`i18n:find-unused`는 리포트만 출력하는 dry-run이고, `i18n:prune`은 실제로 JSON에서 키를 삭제합니다.

## 네임스페이스 해석 규칙

스크립트에서 가장 먼저 해야 할 일은 네임스페이스를 정확히 판정하는 것이었습니다.

ts-morph로 `src/**/*.{ts,tsx}`를 프로젝트로 로드한 뒤, `useTranslation()` 호출마다 다음 규칙을 순서대로 적용했습니다.

| 호출 형태                            | 해석 결과                                               |
| ------------------------------------ | ------------------------------------------------------- |
| `useTranslation(I18N_KEYS.FEATURE)`  | `I18N_KEYS.FEATURE`의 값을 추적해서 `'feature'`로 해석  |
| `useTranslation('common')`           | 리터럴 그대로 `'common'`                                |
| `useTranslation()` (인자 없음)       | `defaultNS`, 즉 `'common'`                              |
| `useTranslation(someRuntimeVar)`     | `unknown` 처리. 해당 네임스페이스 전체 키를 "사용 중"으로 간주    |

네 번째 행이 핵심입니다. 런타임 변수로 네임스페이스가 결정되는 경우, "모른다"고 무시하면 해당 네임스페이스의 키가 전부 미사용으로 판정됩니다. 앞선 글에서 공식 CLI가 보여준 오탐과 정확히 같은 문제입니다. 그래서 **보수적으로, 해당 네임스페이스 전체를 "사용 중"으로 간주**했습니다.

팀 코드에서는 `t`를 alias해서 쓰는 패턴도 자주 등장했습니다.

```tsx
const { t: tFeature } = useTranslation(I18N_KEYS.FEATURE);
tFeature('some.key');
```

스크립트는 destructuring의 alias를 추적해서, 같은 스코프 안에서 `tFeature('some.key')` 호출도 `feature` 네임스페이스의 키로 수집합니다. 이 처리가 빠지면 alias된 모든 호출이 누락되니, 생각보다 영향 범위가 컸습니다.

## 키 수집과 화이트리스트

네임스페이스가 결정되면, `t()` / `i18n.t()` 호출에서 실제 키를 수집합니다.

| 인자 형태                                | 처리 방식                               |
| ---------------------------------------- | --------------------------------------- |
| `t('a.b.c')` StringLiteral               | 정적 키로 수집                          |
| ``t(`literal`)`` NoSubstitutionTemplate  | 정적 키로 수집                          |
| `i18n.t('key', { ns: I18N_KEYS.X })`     | ns를 재지정해서 해당 네임스페이스에 수집   |
| ``t(`prefix.${variable}`)``              | prefix만 추출, 화이트리스트로 보존      |
| `t(someVar)` 변수 키                     | 수집 불가. 로그에 경고 출력             |

네 번째와 다섯 번째가 까다로웠습니다. ``t(`permission.${value}`)``처럼 템플릿 리터럴에 변수가 섞이면 `permission.`이라는 접두사까지만 확정할 수 있고, `t(someVar)`처럼 변수로만 된 키는 정적 분석으로 할 수 있는 게 없습니다.

여기서 화이트리스트가 등장합니다. `i18n-preserve.ts`에 네임스페이스별 정규식이나 접두사를 배열로 관리했습니다.

| 동적 호출 패턴                | 보존 대상                         | 근거                                        |
| ----------------------------- | --------------------------------- | ------------------------------------------- |
| `commonTranslation(status)`   | `ACTIVE`, `COMPLETED` 등 상태 키  | status 변수로 키가 결정되는 공통 번역 함수  |
| `tProject(row.project_type)`  | `PERSONAL`, `SHARED` 등           | 프로젝트 타입을 동적으로 참조               |
| `t(day)`                      | `MON`, `TUE`, `WED` 등 요일 키    | 요일 변수를 직접 키로 사용                  |

전부 **런타임에 값이 결정되는 변수를 키로 직접 넘기는 곳**입니다.

전부 자동으로 잡겠다는 욕심을 버리고, 잡히지 않는 건 명시적으로 선언하기로 했습니다. 대신 기준은 분명하게 뒀습니다. 코드에서 동적 패턴이 확인된 것만 넣고, "혹시 몰라서"는 넣지 않았습니다.

수집과 보존 로직이 갖춰졌으니, 이제 실제로 키를 지우는 단계로 넘어갑니다.

## --apply와 30% 안전 가드

수집이 끝나면 핵심 공식은 세 줄입니다.

```
used     = AST 수집 키  ∪  화이트리스트 매치
catalog  = flatten(ko.json[ns])  ∪  flatten(en.json[ns])
unused   = catalog \ used
```

`--apply`를 붙이면 세 단계로 동작합니다.

1. `ko.json`과 `en.json` 양쪽에서 `unused` 리프 키를 **동시에 삭제**
2. 리프 삭제로 비어버린 중간 노드는 함께 정리하되, 최상위 네임스페이스 노드는 유지
3. `JSON.stringify(obj, null, 2) + '\n'`으로 쓰고, `pnpm lint --fix`로 포맷을 맞춤

첫 번째 단계에서 양쪽을 동시에 삭제하는 건, "로케일 쌍 유지" 기준에 대응합니다. 한쪽만 지우면 다른 로케일에 유령 키가 남아서, `ko.json`과 `en.json`의 키 트리가 어긋나거든요.

그리고 빼놓을 수 없는 장치가 하나 더 있습니다. 미사용으로 판정된 키가 전체 카탈로그의 30%를 넘으면, 스크립트가 **중단**합니다.

```
unused.size > catalog.size * 0.3  →  abort
```

정상적으로 동작할 때 전체의 30% 이상이 한꺼번에 미사용이 되는 상황은 현실적이지 않습니다. `I18N_KEYS` 파일 경로가 변경됐거나, 화이트리스트 파일이 빠져 있거나, ts-morph 프로젝트 로드 범위가 좁아서 소스가 누락된 상황이 훨씬 가능성이 높습니다.

30%라는 수치는 저희 카탈로그 규모에서 경험적으로 잡은 임계값입니다. 절대적인 기준은 아니지만, "뭔가 이상하다"를 감지하는 데는 충분했습니다.

## 주간 cron이 PR을 올리는 구조

로컬에서 잘 돌아가는 스크립트가 완성됐지만, 한 가지가 걸렸습니다. **사람이 주기적으로 돌려야 한다**는 거였습니다.

한 달쯤 지나니까 정확히 그렇게 됐습니다. 스크립트는 있는데 아무도 안 돌리는 상태.

그래서 GitHub Actions 주간 cron에 물렸습니다. 매주 일요일 02:00 KST에 돌아가고, 미사용 키가 있으면 자동으로 PR이 올라옵니다.

전체 파이프라인의 흐름입니다.

1. **스케줄 트리거** — 매주 일요일 02:00 KST
2. **환경 준비** — checkout, pnpm setup, install
3. **리포트 생성** — `--json`으로 미사용 키 목록
4. **삭제 적용** — `--apply`로 실제 JSON에서 키 제거
5. **포맷 정리** — `pnpm lint --fix`
6. **변경 감지** — diff가 없으면 PR 없이 종료
7. **PR 본문 생성** — 현황 표·체크리스트·스냅샷을 마크다운으로 조립
8. **PR 생성** — `peter-evans/create-pull-request`

<details>
<summary>`.github/workflows/i18n-prune.yml` 전체 보기</summary>

```yaml
name: i18n prune unused keys

on:
  schedule:
    - cron: '0 17 * * 6' # 매주 일요일 02:00 KST (UTC 17:00 토요일)
  workflow_dispatch:
    inputs:
      dry_run:
        description: '삭제 없이 리포트만 확인'
        type: boolean
        default: false
      force:
        description: '30% 안전 가드 무시'
        type: boolean
        default: false

permissions:
  contents: write
  pull-requests: write

jobs:
  prune:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - run: pnpm install --frozen-lockfile
        working-directory: fe

      - name: Generate unused keys report
        run: pnpm exec tsx scripts/i18n/find-unused-i18n.ts --json > /tmp/i18n-report.json
        working-directory: fe

      - name: Prune unused keys
        if: ${{ !inputs.dry_run }}
        run: pnpm exec tsx scripts/i18n/find-unused-i18n.ts --apply ${{ inputs.force && '--force' || '' }}
        working-directory: fe

      - name: Fix lint
        if: ${{ !inputs.dry_run }}
        run: pnpm lint --fix
        working-directory: fe

      - name: Check diff
        id: diff
        run: |
          git diff --quiet && echo "no_changes=true" >> $GITHUB_OUTPUT || true

      - name: Build PR body
        if: steps.diff.outputs.no_changes != 'true'
        run: node .github/scripts/build-i18n-pr-body.cjs /tmp/i18n-report.json > /tmp/pr-body.md
        working-directory: fe

      - name: Create Pull Request
        if: steps.diff.outputs.no_changes != 'true'
        uses: peter-evans/create-pull-request@v6
        with:
          branch: chore/i18n-prune-${{ github.run_id }}
          commit-message: 'chore(i18n): remove unused translation keys'
          title: 'chore(i18n): remove unused translation keys'
          body-path: /tmp/pr-body.md
          labels: i18n, automated
```

</details>

일요일 새벽에 돌려서 월요일 오전에 PR을 확인하고 머지하면 한 주를 깔끔하게 시작할 수 있게 했습니다. `workflow_dispatch`로 수동 실행도 열어뒀는데, 화이트리스트 수정 직후 바로 결과를 확인할 수 있어서 유용했습니다.

자동으로 삭제까지는 하지만, 머지는 사람이 합니다. diff를 읽고, 의심 가는 키가 있으면 화이트리스트에 추가한 뒤 다음 주에 다시 돌리면 됩니다.

## 수동 스모크 체크리스트

화이트리스트로 보존했더라도, 리뷰어가 PR 머지 전에 확인해야 할 화면이 있습니다. 동적 키가 집중된 곳입니다.

- **권한 셀렉트** — ``t(`permission.${value}`)`` 패턴을 쓰는 컴포넌트
- **히스토리/피드백 테이블** — 필드명을 `FIELD_MAP`으로 순회하며 `t()`에 넘기는 컬럼
- **편집 모달** — 모달 제목을 `mode`(create/edit)로 분기하는 패턴
- **연동 관리** — `i18n.t('key', { ns })` 간접 호출을 사용하는 화면

이 목록을 PR 템플릿에 체크리스트로 넣어두면 리뷰어가 빠뜨리지 않습니다.

---

돌아보면 이런 흐름이었습니다.

- `I18N_KEYS`를 진실원으로 삼아 네임스페이스를 정확히 해석했고
- 동적 키는 화이트리스트로, 런타임 변수는 보수적 판정으로 오탐을 막았고
- `--apply`와 30% 안전 가드로 삭제 범위를 통제했고
- GitHub Actions 주간 cron이 PR을 올려서, 사람이 확인하고 머지하는 구조로 만들었습니다

화이트리스트 관리는 여전히 수동이고, 미번역 비율 모니터링은 아직 손도 못 댔습니다.

그래도 매주 월요일 아침에 PR이 올라와 있으면, diff 확인하고 스모크 테스트 한 번 돌리면 끝입니다. 예전처럼 JSON 파일 열어서 키 하나하나 추적하던 때를 생각하면, 충분히 나아졌습니다.]]></content:encoded>
          <category>DevOps</category>
          <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>React Router는 자체 ErrorBoundary를 갖고 있다</title>
          <link>https://blog.ssumi.space/blog/react-router-errorboundary</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/react-router-errorboundary</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>ErrorBoundary를 감쌌는데 에러가 안 잡히는 상황. React Router가 내부에서 먼저 에러를 잡고 있었고, errorElement로 해결한 과정을 공유한다.</description>
          <content:encoded><![CDATA[프로젝트에서 네트워크 에러가 발생했을 때, 사용자에게 보여줄 에러 화면을 만들고 있었습니다.

`ErrorBoundary`로 감싸면 되니까 간단할 거라고 생각했습니다. `main.tsx`에도 하나, `App.tsx`에도 하나, 두 겹으로 감쌌습니다.

그런데 실제로 에러가 터졌을 때, 제가 만든 fallback UI는 어디에도 보이지 않았습니다. 대신 화면에 떠 있던 건 이것이었습니다.

> **Unexpected Application Error!**

React Router가 보여주는 기본 에러 UI였습니다. 분명 `ErrorBoundary`를 두 겹이나 감쌌는데, 왜 내 fallback이 아니라 React Router의 기본 UI가 뜨는 걸까요?

이 글에서는 ErrorBoundary가 에러를 잡지 못한 원인을 추적하고, React Router의 `errorElement`로 해결한 과정을 공유합니다.

## ErrorBoundary를 감쌌는데 왜 안 잡힐까

당시 에러 처리 구조는 이랬습니다.

```tsx
// main.tsx
}
>
  
</ErrorBoundary>
```

```tsx
// App.tsx
}
>
  
</ErrorBoundary>
```

[`@suspensive/react`](https://suspensive.org/docs/react/ErrorBoundary)의 `ErrorBoundary`를 사용하고 있었고, `RouterProvider` 바깥과 `QueryClientProvider` 바깥 양쪽에 배치해 뒀어요. 어디서 에러가 터지든 둘 중 하나가 잡아줄 거라고 생각했습니다.

그런데 실제 에러가 발생한 지점은 이랬습니다.
Axios 인터셉터에서 네트워크 에러를 throw하고, 그 에러가 `useSuspenseQuery`를 쓰는 컴포넌트까지 올라가는 구조였습니다. 에러는 라우트 컴포넌트 안에서 발생했고, 당연히 위쪽 `ErrorBoundary`로 bubble up될 거라고 기대했습니다.

그런데 그 에러는 제 `ErrorBoundary`까지 도달하지 않았습니다. 누군가가 중간에서 먼저 잡고 있었거든요.

## React Router가 먼저 잡고 있었다

"Unexpected Application Error!"라는 텍스트가 계속 보여서, 이 문자열을 그대로 검색해 봤습니다. [React Router 소스 코드](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/components.tsx)에서 찾았습니다.

`createBrowserRouter`는 React Router v6.4에서 도입되어 [v7까지 이어지는 API](https://reactrouter.com/en/main/routers/create-browser-router)인데, 각 라우트에 **내부 error boundary**를 내장하고 있었습니다. `errorElement`를 명시하지 않으면, React Router가 기본 에러 UI를 보여주는 구조예요.

그래서 제가 이해한 에러 전파 흐름은 이랬습니다.

```
라우트 컴포넌트에서 에러 발생
  → React Router 내부 error boundary가 catch
  → 기본 에러 UI 렌더 ("Unexpected Application Error!")
  → 외부 ErrorBoundary까지 도달하지 않음
```

이걸 보고 나서야 이해가 됐습니다. React의 에러 전파 규칙상, 트리에서 가장 가까운 error boundary가 먼저 catch하거든요. 제 `ErrorBoundary`는 **구조적으로 에러가 도달할 수 없는 위치**에 있었던 겁니다.

당시 라우트 설정을 보면 문제가 명확해집니다.

```tsx
// Router.tsx

  {
    element: ,
    children: [...모든 라우트],
    // errorElement가 없음 → React Router 기본 에러 UI 사용
  },
]);
```

`errorElement`를 지정하지 않았으니, React Router가 알아서 기본 에러 UI를 보여준 겁니다. 제가 만든 `ErrorBoundary`는 이 구조 바깥에 있어서 아무 역할도 하지 못했습니다.

그렇다면 React Router 안에서 발생한 에러는 React Router의 방식으로 잡아야 합니다.

## errorElement로 해결하기

그게 바로 [`errorElement`](https://reactrouter.com/en/main/route/error-element)였습니다.

`useRouteError()` 훅으로 에러 객체를 받아서, 기존에 만들어 둔 `ErrorState` 컴포넌트에 넘기는 구조로 만들었습니다.

```tsx
// Router.tsx
function RouterErrorBoundary() {
  const error = useRouteError();
  const message =
    error instanceof Error
      ? error.message
      : '페이지 로드 중 오류가 발생했습니다.';
  return ;
}

  {
    element: ,
    errorElement: ,
    children: [...모든 라우트],
  },
]);
```

변경은 딱 두 가지입니다.

- `RouterErrorBoundary` 컴포넌트를 만들고
- 루트 라우트에 `errorElement`로 등록

이렇게 하면 라우트 내부에서 발생한 에러가 React Router의 내부 error boundary를 거쳐 제가 만든 `ErrorState` UI로 렌더됩니다. "Unexpected Application Error!" 대신 일관된 에러 화면을 보여줄 수 있게 됐습니다.

해결하고 나니 한 가지가 더 눈에 들어왔습니다. `App.tsx`에 감싸 둔 `ErrorBoundary`가 의미 없는 레이어가 된 겁니다. 라우트 레벨 에러는 이제 `errorElement`가 처리하니까요. 그래서 중복 레이어를 정리했습니다.

| 위치                         | 변경     | 이유                                                      |
| ---------------------------- | -------- | --------------------------------------------------------- |
| `App.tsx`의 `ErrorBoundary`  | **제거** | `errorElement`가 라우트 에러를 처리하므로 중복            |
| `main.tsx`의 `ErrorBoundary` | **유지** | `QueryClientProvider` 등 provider 에러를 잡는 최후 안전망 |

## 정리하기

처음에는 "ErrorBoundary를 충분히 감쌌으니까 에러는 잡히겠지"라고 생각했습니다. 그런데 `createBrowserRouter`는 자체 error boundary를 내장하고 있어서, 외부에서 아무리 감싸도 라우트 레벨 에러는 React Router가 먼저 잡아요.

- **원인**: React Router 내부 error boundary가 트리에서 더 가까이 있어서, 외부 `ErrorBoundary`보다 먼저 에러를 catch한다
- **해결**: 루트 라우트에 `errorElement`를 등록하고, `useRouteError()`로 에러를 받아 처리한다
- **구분**: React `ErrorBoundary`는 렌더링 에러, React Router `errorElement`는 라우트 레벨 에러. 둘은 다른 레이어다

"Unexpected Application Error!" 화면이 보인다면, `ErrorBoundary`를 더 감싸는 게 아니라 `errorElement`를 확인해 보세요. 이 글에서는 루트 라우트 하나에 `errorElement`를 둔 경우만 다뤘는데, 중첩 라우트별로 다른 에러 UI를 보여줘야 하거나 loader/action에서 발생하는 에러를 분리 처리하는 패턴은 또 다른 맥락이에요.]]></content:encoded>
          <category>React</category>
          <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>시뮬레이터에선 되던 앱이 실기기에서 터진 이유</title>
          <link>https://blog.ssumi.space/blog/flutter-real-device-localhost</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/flutter-real-device-localhost</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Flutter 시뮬레이터에서 잘 되던 API 호출이 실기기에서 Connection refused, localhost가 가리키는 곳이 달랐다. 원인을 찾고 device_info_plus로 자동 감지까지 구현한 과정.</description>
          <content:encoded><![CDATA[> **웹 개발자의 Flutter 입문기.** 웹에서 당연하던 것들이 모바일에서는 다릅니다.
>
> 이번 글에서는 시뮬레이터에서 실기기로 넘어가면서 만난 첫 번째 벽, localhost 네트워크 문제를 해결한 과정을 공유할게요.

## 어느 날 실기기에서 Connection refused

Flutter로 사이드 프로젝트를 만들고 있습니다. 백엔드는 Express + Prisma로 Mac에서 로컬 서버를 돌리고, Flutter 앱은 `localhost:3100`으로 API를 호출하는 구조입니다.

iOS 시뮬레이터에서 개발하는 동안은 아무 문제가 없었습니다. 로그인, 방 생성, 타임라인 조회까지 전부 잘 됐습니다.
그러다 카메라 기능을 붙여야 할 차례가 왔는데, 카메라는 시뮬레이터에서 테스트할 수 없어서 실제 iPhone을 연결해야 했습니다.

별 문제 없을 줄 알았습니다. 그런데 실기기에서 앱을 실행하고 로그인 버튼을 누르자마자 이런 에러가 떴습니다.

```
Connection refused (OS Error: Connection refused, errno = 61)
address = localhost, port = 50035
```

`localhost:3100`으로 설정했는데 포트가 50035? 처음엔 포트 충돌인 줄 알았습니다. 서버를 재시작해보고, 포트를 바꿔보고, 방화벽 설정도 확인해봤습니다. 전부 소용없었습니다.

그래서 에러 메시지를 다시 들여다봤습니다. 포트 50035는 요청 측 임시 포트였고, 진짜 문제는 `address = localhost`였습니다.

## 시뮬레이터와 실기기의 localhost는 다른 곳을 가리킨다

웹 개발할 때는 `localhost`가 항상 "내 컴퓨터"입니다. 브라우저와 서버가 같은 머신에서 돌아가니까요. 시뮬레이터도 Mac 위에서 돌아가는 프로세스라 마찬가지였고요.

하지만 제가 놓치고 있던 건, 실제 iPhone은 독립된 기기라는 점입니다. iPhone에서 `localhost`는 iPhone 자기 자신을 가리킵니다. Mac에서 돌고 있는 Express 서버와는 전혀 관계가 없습니다.

| 환경                  | `localhost`가 가리키는 곳 |     Mac 서버 접근      |
| --------------------- | ------------------------- | :--------------------: |
| iOS 시뮬레이터        | Mac (호스트 머신)         |           ✅           |
| Android 에뮬레이터    | 에뮬레이터 자체           | ❌ (`10.0.2.2`로 우회) |
| 실제 iPhone / Android | 기기 자체                 |           ❌           |

이걸 이해하고 나니 시뮬레이터에서 왜 한 번도 문제가 안 됐는지도 바로 납득이 갔습니다. Docker를 써봤다면 `host.docker.internal`이 떠오를 수도 있습니다. **실행 환경이 호스트와 분리되면, `localhost`는 더 이상 "내 컴퓨터"가 아닙니다.**

그래서 해결 방향은 명확했습니다. 실기기에서는 `localhost` 대신 Mac의 실제 IP 주소(예: `192.168.0.10`)로 요청을 보내면 됩니다. 같은 Wi-Fi에 연결되어 있으면 기기에서 Mac에 도달할 수 있거든요.

그런데 단순히 `localhost`를 IP로 바꾸면 되는 걸까요?

## device_info_plus로 자동 감지

가장 빠른 방법은 `api_constants.dart`에서 `localhost`를 Mac IP로 하드코딩하는 겁니다. 5초면 끝나지만, 시뮬레이터로 돌아가면 다시 바꿔야 하고, Wi-Fi가 바뀌면 또 바꿔야 합니다. 이런 불편은 금방 쌓입니다.

그래서 [`device_info_plus`](https://pub.dev/packages/device_info_plus)라는 Flutter 패키지를 써봤습니다. 현재 기기가 시뮬레이터인지 실기기인지를 런타임에 감지해주는 패키지입니다.

```dart
// api_constants.dart

const String devHostIp = '192.168.0.10'; // Wi-Fi 바뀌면 여기만 수정

Future<String> getDevHost() async {
  final deviceInfo = DeviceInfoPlugin();

  if (Platform.isIOS) {
    final iosInfo = await deviceInfo.iosInfo;
    return iosInfo.isPhysicalDevice ? devHostIp : 'localhost';
  }

  if (Platform.isAndroid) {
    final androidInfo = await deviceInfo.androidInfo;
    return androidInfo.isPhysicalDevice ? devHostIp : '10.0.2.2';
  }

  return 'localhost';
}
```

`isPhysicalDevice`가 `true`면 실기기니까 Mac IP로, `false`면 시뮬레이터니까 `localhost`로 보내는 겁니다. 시뮬레이터 ↔ 실기기를 오가도 코드를 건드릴 필요가 없어졌습니다.

그런데 이 함수 하나를 추가했을 뿐인데, 생각보다 손이 많이 갔습니다.

## 호스트 주소 하나 바꿨을 뿐인데

`getDevHost()`는 `async` 함수입니다. 이걸 호출하는 쪽도 `async`가 되고, 그 위도 `async`가 돼야 합니다. 문제는 `DioClient`가 동기 생성자로 URL을 받고 있었다는 겁니다.

Dart에서는 생성자를 `async`로 만들 수 없습니다. 그래서 기존의 `DioClient(String baseUrl)` 생성자를 private으로 바꾸고, async factory 메서드를 만들어야 했습니다.

```dart
class DioClient {
  DioClient._(String baseUrl) {
    _dio = Dio(BaseOptions(baseUrl: baseUrl));
  }

  static Future<DioClient> create() async {
    final host = await getDevHost();
    return DioClient._('http://$host:3100/api/v1');
  }
}

// DI 등록도 await가 필요해짐
final dioClient = await DioClient.create();
```

`DioClient` 하나 바뀌었을 뿐인데, DI 설정에서 `await`가 필요해지고, 앱 초기화 흐름까지 `async`가 전파됐습니다. 동기 → 비동기 전환이 한 함수에서 끝나지 않고 호출 체인을 따라 올라간다는 걸 직접 체감한 순간이었습니다.

웹에서도 동기 함수에 API 호출 하나를 추가하면 `async/await`가 연쇄적으로 퍼지긴 하지만, Flutter에서는 생성자 자체를 async로 바꿀 수 없어서 factory 패턴까지 도입해야 했습니다. 호스트 주소 하나 바꾸려던 것치고는 손이 꽤 많이 갔습니다.

이렇게 바꾸고 나서 실기기에서도 정상적으로 API가 호출됐습니다. 시뮬레이터로 돌아가도 코드를 건드릴 필요 없이 그대로 동작했고요.

## 정리하기

- **시뮬레이터의 `localhost`는 Mac이지만, 실기기의 `localhost`는 기기 자신입니다.** 웹에서는 의식하지 않았던 차이가 모바일에서는 첫 번째 벽이 됩니다.
- **하드코딩 대신 [`device_info_plus`](https://pub.dev/packages/device_info_plus)로 자동 감지하면, 시뮬레이터 ↔ 실기기 전환 비용이 0이 돼요.** 다만 Wi-Fi가 바뀌면 `devHostIp`는 여전히 수동 수정이 필요해요.
- **동기 함수를 비동기로 바꾸면 호출 체인 전체에 `async`가 전파됩니다.** Flutter에서는 생성자를 async로 만들 수 없어서 factory 패턴까지 필요했습니다.

다음 편에서는 localhost를 해결한 뒤에도 이어진 실기기 테스트 삽질을 이야기할게요.]]></content:encoded>
          <category>Flutter</category>
          <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Orval 코드 생성 시 500개 파일 diff가 생긴다면</title>
          <link>https://blog.ssumi.space/blog/orval-codegen-spec-version-header-removal</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/orval-codegen-spec-version-header-removal</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Orval이 생성하는 파일 헤더의 OpenAPI spec version 때문에 500개 파일에 불필요한 diff가 생기는 문제를, 커스텀 header 함수로 해결한 경험.</description>
          <content:encoded><![CDATA[## 500개 파일이 바뀌었는데, 실제 변경은 0줄

PR을 올렸는데 Changed files가 500개가 넘었던 적 있으신가요?

저희 프로젝트에서는 [Orval](https://orval.dev)을 사용해 [OpenAPI](https://swagger.io/specification/) 스펙으로부터 API 서비스 코드를 자동 생성하고 있습니다. API 타입, 요청 함수, React Query 훅까지 한 번에 만들어주니까 편하긴 한데, 어느 날 PR을 열었더니 파일 500여 개에 diff가 잡혀 있었습니다.

실제로 바뀐 비즈니스 로직은 하나도 없었습니다. 원인은 Orval이 모든 생성 파일 상단에 삽입하는 주석 헤더의 `OpenAPI spec version: 1.1.6d` 한 줄이었습니다. 이 값은 백엔드에서 Swagger 문서를 설정할 때 필수로 지정하는 스펙 버전인데, 백엔드가 이 스펙 버전을 올릴 때마다 Orval로 생성된 **모든 파일**의 헤더가 바뀌는 겁니다.

`v1.1.5` → `v1.1.6`처럼 눈에 띄는 버전 변경이라면 감수할 수도 있겠지만, 실제로는 `v1.1.6a` → `v1.1.6b`처럼 패치 수준의 변경에서도 500개 파일 전체에 diff가 생깁니다.

## 진짜 문제는 diff 수가 아니라 리뷰가 불가능해지는 것

파일이 500개 바뀌는 것 자체보다, 그로 인해 생기는 부수적인 문제가 더 컸습니다.

- **PR 리뷰 불가능**: 실제 비즈니스 로직 변경이 헤더 변경 500건 사이에 묻힙니다. 리뷰어가 "진짜 변경"을 찾아 헤매야 합니다
- **Git 히스토리 오염**: `git blame`으로 코드 변경 이력을 추적할 때, 의미 없는 헤더 변경이 끼어듭니다
- **리뷰어 피로**: 자동 생성 파일이라 어차피 무시해야 하는 건 알지만, 500개를 스크롤하면서 "이건 괜찮겠지"를 반복하는 건 은근한 부담이에요

결국 핵심 질문은 하나였습니다. **Orval이 생성하는 헤더에서 `OpenAPI spec version` 줄만 제거할 수 있는가?**

## Orval 소스에서 찾은 해결 단서

이 질문에 답하려면 Orval의 헤더 생성 방식을 알아야 했어요. [공식 문서의 output configuration](https://orval.dev/docs/reference/configuration/output#header)에서 `header` 옵션을 찾아봤지만, 구체적인 사용법이 잘 드러나지 않았습니다. 그래서 빌드된 소스 코드를 직접 열어봤어요.

`node_modules/.pnpm/orval*/node_modules/orval/dist/index.js`에서 헤더 생성 로직을 찾을 수 있었습니다. ([Orval 소스 코드](https://github.com/orval-labs/orval)의 빌드 결과물이에요.)

```js
var getDefaultFilesHeader = ({ title, description, version } = {}) => [
  `Generated by ${package_default.name} v${package_default.version}`,
  `Do not edit manually.`,
  ...(title ? [title] : []),
  ...(description ? [description] : []),
  ...(version ? [`OpenAPI spec version: ${version}`] : []), // [!code highlight]
];
```

`version` 파라미터가 있으면 `OpenAPI spec version: ...` 줄을 추가하는 구조였습니다. 그리고 바로 아래에서 `output.override.header` 옵션이 세 가지 모드를 지원한다는 것도 확인했습니다.

```js
header: outputOptions.override?.header === false
  ? false // 헤더 완전 제거
  : isFunction(outputOptions.override?.header)
    ? outputOptions.override?.header // 커스텀 함수 // [!code highlight]
    : getDefaultFilesHeader; // 기본값
```

커스텀 함수를 넘기면 원하는 정보만 골라 남길 수 있겠다는 생각이 들었습니다.

## 커스텀 header 함수로 해결하기

`header: false`로 완전히 제거하는 방법도 있었지만, "Do not edit manually" 경고는 남겨두는 게 팀에게 유용하다고 판단했습니다. 자동 생성 파일을 수동으로 편집하다 다음 생성 시 덮어씌워지는 실수를 막아주니까요.

그래서 커스텀 함수로 `version` 파라미터만 무시하는 방식을 선택했습니다.

```ts
// orval.config.ts
const header = ({
  title,
  description,
}: {
  title?: string;
  description?: string;
}) => [
  `Generated by orval`,
  `Do not edit manually.`,
  ...(title ? [title] : []),
  ...(description ? [description] : []),
  // version(OpenAPI spec version) 제외 // [!code highlight]
];
```

저희 프로젝트에는 API 설정이 여러 개(`baseApi`는 메인 API, `pyApi`는 Python 기반 AI 서버)인데, 각각에 동일한 `header` 함수를 넣어줬습니다.

```ts
// orval.config.ts

  baseApi: {
    output: {
      override: {
        header,
        // ... 기타 설정
      },
    },
    // ...
  },
  pyApi: {
    output: {
      override: {
        header,
        // ... 기타 설정
      },
    },
    // ...
  },
});
```

적용 후 생성된 파일의 헤더는 이렇게 바뀝니다.

**Before**: 스펙 버전과 Orval 버전이 포함되어, 어느 쪽이든 변경될 때마다 전 파일에 diff 발생

```ts
/**
 * Generated by orval v7.11.2
 * Do not edit manually.
 * eXemble-AIC
 * OpenAPI spec version: 1.1.6d
 */
```

**After**: 변동 요소를 제거한 안정적인 헤더

```ts
/**
 * Generated by orval
 * Do not edit manually.
 * eXemble-AIC
 */
```

Orval 버전(`v7.11.2`)도 함께 빠졌는데, 이것 역시 Orval 자체를 업데이트할 때마다 전 파일 diff를 유발하는 원인이었기 때문에 의도적으로 제외했습니다.

## 적용 결과

Orval을 실행하는 스크립트(`pnpm generate:api`)를 돌려보니, 생성된 파일에서 스펙 버전 줄이 깔끔하게 사라진 걸 확인할 수 있었습니다. 프로젝트명(`eXemble-AIC`, OpenAPI 스펙의 title 필드)과 "Do not edit manually" 경고는 그대로 유지되고요.

이후 스펙 버전이 `v1.1.6d` → `v1.1.6e`로 올라가는 상황에서도, 프론트엔드 자동 생성 파일에는 더 이상 헤더 때문에 diff가 발생하지 않습니다. PR에는 실제로 변경된 API 타입과 요청 함수만 남게 됐습니다. 500개가 아니라, 진짜 바뀐 파일만요. `git blame`에 헤더 변경 커밋이 끼어드는 일도 사라졌고, 리뷰어가 500개 파일을 스크롤할 필요도 없어졌습니다.

## 레퍼런스

- [Orval 공식 문서 - output configuration](https://orval.dev/docs/reference/configuration/output#header)
- [Orval GitHub](https://github.com/orval-labs/orval)]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Claudian으로 블로그 글 리뷰를 맡기기까지</title>
          <link>https://blog.ssumi.space/blog/obsidian-claudian-blog-review-workflow</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/obsidian-claudian-blog-review-workflow</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Obsidian 안에서 Claude Code를 쓸 수 있는 Claudian 플러그인을 발견하고, 설치부터 블로그 글 리뷰 워크플로우까지 녹여낸 경험을 정리했습니다.</description>
          <content:encoded><![CDATA[글을 쓰는 건 어찌저찌 하겠는데, 검토가 늘 문제였습니다.
혼자 쓰고 혼자 읽으니까 매번 비슷한 곳에서 빈틈이 생기는 것 같았습니다.

예를들자면 도입에서 꺼낸 이야기를 마무리에서 회수하지 않는다거나, 장과 장 사이 연결이 어색한데 정작 저는 익숙해져서 모르는 상황들이 있었습니다.

물론 글쓰기 가이드도 만들어 뒀습니다.
글을 쓰며 체크해야겠다고 생각한 것들을 계속해서 모으다 보니 체크리스트의 양이 방대해졌습니다.
그런데 막상 글을 작성할 때 하나하나 대조하다 보면 어느새 귀찮아져서 대충 넘어가게 됩니다.
가이드를 만든 사람이 가이드를 안 보는 상황. 그래서 이걸 AI한테 맡기면 어떨까 싶었습니다.

## 글은 Obsidian, 리뷰는 터미널

VS Code 터미널에서 Claude Code를 쓸 수는 있었는데, 문제는 글을 쓰는 곳이 Obsidian이라는 점이었습니다.

초안을 쓰다가 리뷰를 받고 싶으면 VS Code를 열고, 터미널 띄우고, 파일 경로 지정하고, 결과 받아서 다시 Obsidian으로 돌아와야 했습니다.
한두 번은 괜찮았는데 고치고 다시 요청하고 또 고치고, 이 왕복을 반복하다 보니 점점 귀찮아졌습니다.

그리고 "이 초안을 리뷰해 줘"만으로는 부족했습니다.
기존 글들의 톤이나 frontmatter 구조, 글쓰기 가이드를 함께 알아야 일관된 피드백이 나오는데, 터미널에서는 그 맥락을 매번 다시 잡아줘야 하거든요.

## Claudian이라는 플러그인

[hyorish03님의 블로그 글](https://hyorish03.tistory.com/55)에서 처음 알게 됐습니다.
Obsidian vault 안에서 Claude Code CLI를 바로 쓸 수 있게 해주는 커뮤니티 플러그인인데, 처음에는 반신반의했습니다.
그런데 써보니까 터미널과 결정적으로 달랐습니다.

채팅 패널을 열면 **vault 자체가 작업 디렉토리**가 되고, 지금 열어놓은 노트가 자동으로 대화 컨텍스트에 잡힙니다.
터미널에서 매번 파일 경로를 타이핑하던 게 그냥 사라진 셈이죠.

`@03-review-guide.md`처럼 다른 파일을 멘션할 수도 있어서, "이 초안을 가이드 기준으로 리뷰해 줘"가 한 문장으로 끝나요.
텍스트를 드래그해서 수정 요청하면 diff 미리보기가 뜨고 바로 적용도 되고요.
터미널 결과를 복사해서 붙여넣던 그 과정이 통째로 없어졌습니다.

왼쪽에서 글을 쓰고 오른쪽 사이드바에서 AI와 대화하는 레이아웃. 글쓰기와 리뷰가 한 화면 안에서 끝나더라고요.

## BRAT로 설치하기

Claudian은 아직 Obsidian 공식 마켓에 없어서, [BRAT](/blog/obsidian-brat-plugin-install)라는 플러그인을 통해 설치했습니다.
커맨드 팔레트(`⌘ + P`)에서 **BRAT: Add a beta plugin for testing** → `https://github.com/YishenTu/claudian` 입력하면 끝입니다.

수동으로 하려면 [GitHub Releases](https://github.com/YishenTu/claudian/releases)에서 파일을 받아 `.obsidian/plugins/claudian/`에 넣는 방법도 있습니다.
설치 과정과 스크린샷은 [hyorish03님의 가이드](https://hyorish03.tistory.com/55)에 잘 정리되어 있으니 참고하시면 좋겠습니다.

## 여기서 빛난 content 폴더 분리

Claudian을 쓰려면 vault가 곧 작업 디렉토리가 되기 때문에, 어떤 폴더를 vault로 여느냐가 중요합니다.

[이전 글](/blog/nextjs-blog-content-folder-separation)에서 콘텐츠를 `content/` 폴더로 분리해 뒀던 게 여기서 딱 맞아떨어졌습니다.
`content/`를 vault로 열면 발행 글, 초안, 글쓰기 가이드가 전부 한 공간에 들어옵니다.
그래서 "기존 글과 가이드를 참고해서 이 초안 리뷰해 줘"라고 한마디 하면 바로 동작하는 구조가 된 겁니다.

콘텐츠가 `app/blog/posts/`에 코드와 섞여 있었을 때는 이런 게 불가능했습니다.
폴더 분리를 해놓으니까 AI 활용이 자연스럽게 얹혀진 셈이죠.

## 리뷰를 실제로 써보니

초안을 열고 Claudian 채팅을 띄우면 자동으로 컨텍스트가 잡혀요.
`@03-review-guide.md`를 멘션하며 리뷰를 요청하면 가이드의 10개 항목 기준으로 피드백이 오고, 그걸 보면서 바로 같은 화면에서 고치면 됩니다.

나중에는 `CLAUDE.md`에 리뷰 가이드 참조 규칙을 넣어둬서, 굳이 가이드를 멘션하지 않아도 자동으로 참조되게 만들었습니다.

리뷰뿐 아니라 글을 처음 구체화하는 단계에서도 유용했습니다.
이 글의 독자가 누구인지, 핵심 서사가 뭔지, 장을 어떻게 나눌지를 AI와 대화로 좁히고 나서 본문을 쓰는 흐름이 자리잡았습니다.
사실 지금 이 글도 그 과정을 거쳐 나온 겁니다.

## 써보고 나서 든 생각

여러 편에 적용해 보니 셀프 리뷰에서 놓치던 부분들이 잡히기 시작했습니다. "도입에서 꺼낸 이야기를 마무리에서 회수했는가", "장과 장의 연결이 자연스러운가", 이런 구조적 피드백이 특히 도움이 됐습니다.

Obsidian 하나만 열어두면 쓰기, 리뷰, 수정이 한 화면에서 끝나니까 컨텍스트 스위칭도 사라졌고요.

의외로 중요했던 건 AI에게 리뷰를 맡기는 것보다 ==리뷰 기준을 먼저 정의해 두는 것==이었습니다.
글쓰기 가이드가 없었다면 뭘 기준으로 봐달라고 할지도 모호했을 겁니다.

AI 리뷰는 구조적/형식적 피드백에는 강한데, "이 경험이 독자에게 공감이 될까?" 같은 건 여전히 제 판단의 영역이라고 생각합니다.
도구는 기준이 있을 때 가장 잘 동작했습니다.

## 정리하며

돌아보면, AI한테 리뷰를 맡긴 것보다 그 전에 리뷰 기준을 정리해 둔 게 더 큰 일이었습니다.
기준이 없었으면 뭘 시켜야 할지도 몰랐을 겁니다.

기존 글, 가이드, 초안이 한 공간에 모여 있어야 한다는 것.
저는 그게 갖춰지고 나서야 도구가 자연스럽게 붙었습니다.

## 레퍼런스

- [Claudian 설치 및 활용 가이드 - hyorish03](https://hyorish03.tistory.com/55)
- [Claudian GitHub](https://github.com/YishenTu/claudian)
- [Claudian 설치/설정 가이드 - WenHaoFree](https://blog.wenhaofree.com/en/posts/articles/obsidian-claudian-integration-guide/)
- [Anthropic Claude Code 공식 문서](https://docs.anthropic.com/en/docs/claude-code/overview)
- 이전 글: [글쓰기 도구와 코드가 섞여 있던 블로그를 정리한 이야기](/blog/nextjs-blog-content-folder-separation) (시리즈 1편)]]></content:encoded>
          <category>블로그 개발</category>
          <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>글쓰기 도구와 코드가 섞여 있던 블로그를 정리한 이야기</title>
          <link>https://blog.ssumi.space/blog/nextjs-blog-content-folder-separation</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/nextjs-blog-content-folder-separation</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Next.js 블로그에서 콘텐츠를 app 폴더 밖으로 분리하고, Obsidian vault로 열어 글 전용 환경을 만든 과정을 정리했습니다.</description>
          <content:encoded><![CDATA[이 블로그를 만들고 글을 쓰다 보[]()니,
나름 자리잡혀 있다고 생각했던 과정에서 불편함이 하나둘 쌓이기 시작했습니다.

## 세 가지 불편함

**코드와 글이 같은 폴더에 섞여 있었습니다.**
모든 포스트가 `app/blog/posts/*.mdx`에 들어 있었는데, `app` 폴더는 라우트와 컴포넌트가 사는 곳(Next.js App Router)이거든요.
거기에 글 파일까지 섞이니 경계가 흐릿해졌습니다.

**Obsidian에서 글만 볼 수도 없었습니다.**
평소 메인 노트 도구로 쓰고 있어서 프로젝트 폴더를 통째로 열어봤는데, 컴포넌트, 라우트, 설정 파일이 함께 보여서 "글만 보는 뷰"를 만들기가 어려웠습니다.

**초안은 프로젝트 밖에서 따로 놀고 있었고요.**
아이디어와 초안은 Obsidian에 적어두고, 발행할 만하면 MDX 파일을 새로 만들어 옮기고, frontmatter를 수동으로 채우는 식.
정리하면 이런 상태였습니다.

- 코드와 콘텐츠가 `app` 아래에 섞여 있고
- 초안은 프로젝트 밖에서 따로 돌고
- Obsidian에서는 글만 골라볼 방법이 없었습니다

## 글은 글끼리 모으면 되지 않을까

생각해 보면 단순한 이야기였습니다.
`app` 폴더는 라우트와 컴포넌트가 사는 곳이고, 글은 결국 데이터에 가깝습니다.
그러면 글만 따로 모아두면 되지 않을까 라고 생각했습니다.

그래서 기존 `app/blog/posts/*.mdx`를 프로젝트 루트의 `content/posts/*.mdx`로 옮겨봤습니다.

```
변경 전: app/blog/posts/*.mdx
변경 후: content/posts/*.mdx
```

`getBlogPosts()` 함수가 `fs`와 `path`로 MDX를 읽고 있었기 때문에, 경로 상수 하나만 바꾸면 기존 구조를 깨지 않고 이동할 수 있었습니다.

옮기고 나니 자연스럽게 다음 생각이 이어지더라고요.
초안도 여기에 두면 되겠다 싶었습니다.

frontmatter에 `status: draft` 같은 필드를 넣어서 관리하는 방법도 떠올랐지만, 폴더만 보고 "이건 초안, 이건 발행 글"이라고 바로 알 수 있는 게 더 편할 것 같았습니다.

```
content/
├── posts/       ← 발행 글
├── drafts/      ← 초안
└── docs/guide/  ← 글쓰기 가이드
```

그리고 이 `content/` 폴더를 Obsidian vault로 열었습니다.

## 달라진 워크플로우

이 구조가 잡히고 나니 글쓰기 흐름이 자연스럽게 바뀌었습니다.

초안은 Obsidian에서 `content/drafts/`에 `.md` 파일을 만들고 자유롭게 써요.
같은 vault 안에 기존 발행 글과 글쓰기 가이드가 있으니, 이전 글의 톤이나 frontmatter 구조를 바로 참고할 수 있는 게 좋았습니다.

초안이 준비되면 `drafts/`에서 `posts/`로 파일을 옮기고, 확장자를 `.mdx`로 바꾸고, frontmatter를 채웁니다.
`publish: false`로 먼저 올려서 실제 블로그에서 렌더링을 확인한 뒤, `publish: true`로 공개하는 식이에요.

그리고 하나 더.
vault 안에 글쓰기 가이드(`docs/guide/`)와 기존 글이 함께 있으니, AI에게 "가이드 기준으로 이 초안을 리뷰해 줘"라고 요청하면 맥락이 바로 잡힙니다.

## 정리하며

돌이켜보면 가장 큰 변화는 기술적인 것이 아니었습니다.

**"글을 어디에 둘까"** 라는 고민이 사라졌고, Obsidian을 글 전용 뷰로 쓸 수 있게 되었습니다.
그리고 AI를 워크플로우에 자연스럽게 붙일 수 있는 토대가 되었습니다.]]></content:encoded>
          <category>블로그 개발</category>
          <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Obsidian 이미지를 Next.js 블로그에서 렌더링하기 - 커스텀 remark 플러그인</title>
          <link>https://blog.ssumi.space/blog/obsidian-image-mdx-remark-plugin</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/obsidian-image-mdx-remark-plugin</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Obsidian의 ![[이미지.png]] 문법이 MDX에서 깨지는 문제를 커스텀 remark 플러그인으로 해결한 과정</description>
          <content:encoded><![CDATA[이 블로그는 Obsidian에서 글을 쓰고 Next.js로 발행하는 구조입니다. Obsidian에서 스크린샷을 붙여넣으면 `![[스크린샷.png]]` 같은 위키링크 문법이 자동으로 삽입되는데, Obsidian 안에서는 이 문법으로 이미지가 잘 보입니다.

그런데 블로그에 올리고 나서 확인해보니, 이미지가 있어야 할 자리에 `![[스크린샷.png]]`라는 텍스트만 덩그러니 남아 있었습니다. 이미지가 4개 들어간 글 두 개를 발행하면서 이 문제를 본격적으로 마주했습니다.

처음에는 단순히 문법 차이 때문이라고 생각했는데, 파고 들어가 보니 문제가 두 겹으로 겹쳐 있었습니다.

- 문법 변환만으로는 부족하고, 이미지 경로도 함께 바꿔야 했음
- 해결 방식도 "원본 MDX를 건드릴 것인가, 렌더링 파이프라인에서 처리할 것인가"를 선택해야 했음

## 왜 Obsidian 이미지가 블로그에서 깨지는 걸까

MDX 렌더러([next-mdx-remote](https://github.com/hashicorp/next-mdx-remote))는 표준 마크다운 이미지 문법(`![alt](src)`)만 인식합니다. Obsidian의 `![[...]]`는 Obsidian 전용 [위키링크 문법](https://help.obsidian.md/Linking+notes+and+files/Embed+files)이라, 표준 마크다운 파서([remark](https://github.com/remarkjs/remark))가 이 패턴을 만나면 이미지로 인식하지 못하고 일반 텍스트 노드로 처리합니다.

```
Obsidian에서:  ![[스크린샷.png]]  →  이미지가 보임 ✅
블로그에서:    ![[스크린샷.png]]  →  텍스트로 출력 ❌
```

매번 `![[파일명.png]]`를 `![](파일명.png)`으로 수동 변환할 수도 있겠지만, 이미지가 많아지면 실수가 생기고, 무엇보다 Obsidian에서 다시 편집할 때 이미지 미리보기가 깨져요. 글쓰기 도구의 편의성을 포기하고 싶지는 않았습니다.

그런데 문법을 자동 변환하면 끝나는 문제인가 싶었는데, 한 가지 더 걸리는 게 있었습니다.

## 문법만의 문제가 아니었다

문법을 바꿔도 이미지가 보이지 않을 수 있습니다. Obsidian이 이미지를 저장하는 위치(`content/` 디렉토리)는 Next.js에서 정적 파일로 서빙되지 않기 때문입니다. 웹에서 접근하려면 `public/` 디렉토리에 이미지가 있어야 하거든요.

정리하면 두 가지 문제가 겹쳐 있었습니다.

| 문제                 | 설명                                                     |
| -------------------- | -------------------------------------------------------- |
| **문법 불일치**      | `![[파일명.png]]`는 remark가 인식하지 못하는 비표준 문법 |
| **이미지 저장 위치** | `content/`에 저장된 이미지는 Next.js에서 서빙 불가       |

문법을 변환하면서 이미지 URL도 함께 바꿔야 한다는 뜻이었습니다. 이 두 가지를 한 번에 처리할 수 있는 방법이 뭘까 고민하다가, 마크다운 렌더링 파이프라인의 어느 단계에서 개입할지를 먼저 결정해야 한다는 걸 알게 되었습니다.

## 어느 단계에서 개입할 것인가

접근 방식을 두 가지로 검토했습니다.

|                   | MDX 파일 전처리                                        | remark 플러그인 (AST 변환)       |
| ----------------- | ------------------------------------------------------ | -------------------------------- |
| **방식**          | 빌드 전 스크립트로 `![[...]]`를 `![](...)` 텍스트 치환 | 렌더링 파이프라인에서 AST를 조작 |
| **원본 MDX**      | 수정됨                                                 | 그대로 유지                      |
| **Obsidian 편집** | 이미지 미리보기 깨짐                                   | 정상 동작                        |
| **구현 난이도**   | 단순                                                   | 플러그인 작성 필요               |

**remark 플러그인을 선택했습니다.** Obsidian에서의 편집 경험을 그대로 유지할 수 있고, 변환 로직이 렌더링 단계에만 존재해서 관심사가 분리되기 때문입니다.

> 📌 **AST(Abstract Syntax Tree)**는 텍스트를 구조화된 트리로 표현한 것입니다. 마크다운에서 `# 제목` 뒤에 본문이 오면, 파서는 이걸 "heading 노드 아래에 text 노드, 그 다음 paragraph 노드" 같은 트리로 바꿉니다. 이 트리를 조작하면 원본 텍스트를 직접 건드리지 않고도 렌더링 결과를 바꿀 수 있습니다.
>
> [remark](https://github.com/remarkjs/remark)는 unified 생태계의 마크다운 처리 라이브러리로, 마크다운을 [MDAST(Markdown AST)](https://github.com/syntax-tree/mdast)라는 트리로 파싱합니다. 플러그인으로 이 트리를 조작한 뒤, 다시 HTML이나 JSX로 변환하는 구조입니다. [플러그인 작성 가이드](https://unifiedjs.com/learn/guide/create-a-plugin/)에 전체 흐름이 잘 설명되어 있습니다.

방향이 정해졌으니, 실제로 AST에서 `![[...]]`가 어떻게 표현되는지 확인해봤습니다.

## AST에서 위키링크 이미지는 어떻게 보이는가

remark가 `![[image.png]]`를 만나면, 파서가 인식하지 못하므로 `text` 타입 노드의 `value` 안에 문자열 그대로 남아요. 이미지가 아니라 그냥 텍스트인 것이죠.

플러그인이 해야 할 일은 이 텍스트 노드를 찾아서 `image` 타입 노드로 교체하는 것이었습니다.

1. AST를 재귀 순회하면서 `text` 노드를 검사
2. 정규식으로 `![[파일명.확장자]]` 패턴을 찾음
3. 매칭된 텍스트를 `image` 노드로 교체하고, URL에 `/images/blog/` 경로를 붙임
4. 이미지 전후에 다른 텍스트가 있으면 별도 노드로 분리

```
Before AST:
paragraph
  └─ text: "앞 텍스트 ![[스크린샷.png]] 뒤 텍스트"

After AST:
paragraph
  ├─ text: "앞 텍스트 "
  ├─ image: { url: "/images/blog/스크린샷.png", alt: "스크린샷.png" }
  └─ text: " 뒤 텍스트"
```

이미지 URL에 `/images/blog/` 경로를 붙여서, 앞서 정리한 두 가지 문제(문법 변환과 이미지 URL 변환)를 동시에 처리하는 구조입니다.

이 변환을 구현하면서 예상보다 신경 쓸 부분이 몇 가지 있었습니다.

## 구현과 삽질

### 외부 의존성 없이 직접 순회

처음에는 AST 순회에 `unist-util-visit`를 사용하려 했습니다. remark 플러그인 예제에서 자주 보이는 유틸리티입니다.

그런데 pnpm 환경에서 문제가 생겼습니다. `unist-util-visit`는 remark의 transitive dependency이긴 하지만, pnpm의 엄격한 의존성 관리 정책 때문에 직접 설치하지 않으면 import할 수 없습니다. 물론 `pnpm add unist-util-visit`로 설치하면 되지만, 50줄도 안 되는 단순한 순회 로직을 위해 의존성을 하나 더 추가하는 건 과하다고 느꼈습니다.

그래서 직접 재귀 순회를 구현했습니다.

```typescript
function visitNode(node: Node) {
  if (!('children' in node)) return;

  const parent = node as Parent;
  let i = 0;

  while (i < parent.children.length) {
    // [!code highlight]
    const child = parent.children[i];
    // text 노드에서 위키링크 패턴을 찾아 image 노드로 교체
    // ...
    visitNode(child); // [!code highlight]
    i++;
  }
}
```

`for`가 아니라 `while`을 쓴 이유는 뒤에서 나옵니다.

### 정규식 `lastIndex` 함정

첫 번째 이미지는 잘 변환되는데, 같은 문단에 두 번째 이미지가 있으면 매칭이 건너뛰어지더라고요.

원인은 전역 플래그(`g`)가 있는 정규식의 `lastIndex` 동작이었습니다. `IMAGE_PATTERN.test(child.value)`로 매칭 여부를 먼저 확인하면, `test()` 호출이 `lastIndex`를 이동시켜요. 이후 `exec()` 루프가 시작될 때 `lastIndex`가 0이 아니라 이전 매칭 직후 위치를 가리키고 있어서, 앞쪽 매칭이 빠지는 것이었습니다.

```typescript
if (isTextNode(child) && IMAGE_PATTERN.test(child.value)) {
  IMAGE_PATTERN.lastIndex = 0; // [!code highlight]
  // 이 한 줄이 없으면 두 번째 이미지부터 누락
}
```

원인을 모른 채 정규식 패턴을 이리저리 바꿔보다가, MDN의 [RegExp.prototype.exec() 문서](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec)를 읽고 나서야 `lastIndex`의 동작을 제대로 이해했습니다.

### 한글 파일명과 노드 교체

나머지 두 가지도 사소하지만 빠뜨리면 바로 버그가 됩니다.

**한글 파일명 인코딩.** Obsidian이 생성하는 스크린샷 파일명에는 한글과 공백이 포함됩니다. `스크린샷 2026-03-12 오후 5.29.34.png` 같은 이름이죠. 이걸 그대로 URL에 넣으면 브라우저에 따라 깨질 수 있어서, `encodeURIComponent`로 인코딩했습니다.

```typescript
newNodes.push({
  type: 'image',
  url: `${IMAGE_BASE_PATH}${encodeURIComponent(fileName)}`, // [!code highlight]
  alt: fileName,
} as ImageNode);
```

**AST splice 후 인덱스 관리.** `parent.children.splice()`로 노드를 교체하면 배열 길이가 변합니다. 하나의 `text` 노드가 3개의 노드로 바뀌면, 인덱스를 1만 증가시키면 방금 삽입한 노드를 다시 방문하게 됩니다. 앞서 `while`을 쓴 이유가 이것입니다. 교체된 노드 수만큼 건너뛰어야 하거든요.

```typescript
parent.children.splice(i, 1, ...(newNodes as Node[]));
i += newNodes.length; // [!code highlight]
```

### 전체 코드

이 네 가지 포인트가 모두 반영된 전체 플러그인 코드입니다. 앞서 조각으로 본 부분이 어디에 위치하는지 하이라이트로 표시했습니다.

<details>
<summary>전체 플러그인 코드 보기</summary>

```typescript

interface TextNode extends Node {
  type: 'text';
  value: string;
}

interface ImageNode extends Node {
  type: 'image';
  url: string;
  alt: string;
}

const IMAGE_PATTERN = /!\[\[([^\]]+\.(png|jpe?g|gif|svg|webp))\]\]/gi;
const IMAGE_BASE_PATH = '/images/blog/';

function isTextNode(node: Node): node is TextNode {
  return node.type === 'text';
}

function visitNode(node: Node) {
  if (!('children' in node)) return;

  const parent = node as Parent;
  let i = 0;

  while (i < parent.children.length) {
    const child = parent.children[i];

    if (isTextNode(child) && IMAGE_PATTERN.test(child.value)) {
      IMAGE_PATTERN.lastIndex = 0; // [!code highlight]
      const newNodes: (TextNode | ImageNode)[] = [];
      let lastIndex = 0;
      let match: RegExpExecArray | null;

      while ((match = IMAGE_PATTERN.exec(child.value)) !== null) {
        if (match.index > lastIndex) {
          newNodes.push({
            type: 'text',
            value: child.value.slice(lastIndex, match.index),
          } as TextNode);
        }

        const fileName = match[1];
        newNodes.push({
          type: 'image',
          url: `${IMAGE_BASE_PATH}${encodeURIComponent(fileName)}`, // [!code highlight]
          alt: fileName,
        } as ImageNode);

        lastIndex = match.index + match[0].length;
      }

      if (lastIndex < child.value.length) {
        newNodes.push({
          type: 'text',
          value: child.value.slice(lastIndex),
        } as TextNode);
      }

      parent.children.splice(i, 1, ...(newNodes as Node[]));
      i += newNodes.length; // [!code highlight]
    } else {
      visitNode(child);
      i++;
    }
  }
}

const remarkObsidianImages: Plugin = () => {
  return (tree: Node) => {
    visitNode(tree);
  };
};

```

</details>

MDX 렌더링 파이프라인에 등록하면 적용이 끝납니다.

```typescript
// mdx.tsx: remarkPlugins에 등록
const components = await compileMDX({
  source: content,
  options: {
    remarkPlugins: [remarkObsidianImages],
  },
});
```

## 검증

`pnpm build`가 성공한 뒤, 두 가지를 확인했습니다.

- **블로그 쪽.** 이미지가 포함된 포스트 2개(`/blog/frontend-sha256-login-hashing`, `/blog/http-error-i18n-fallback`)에서 스크린샷 4장이 모두 정상 렌더링되었고, 이미지 전후 텍스트가 잘리거나 중복되는 문제도 없었습니다.
- **Obsidian 쪽.** 빌드 후에도 Obsidian에서 해당 글을 열었을 때 `![[...]]` 문법과 이미지 미리보기가 정상 동작했습니다. 앞서 "원본을 건드리지 않는다"는 판단 덕분에, 빌드와 편집이 서로 간섭하지 않는 구조가 유지되었습니다.

## 운영하면서 알게 된 것

빌드와 렌더링은 해결했지만, 실제로 글을 발행하면서 한 가지 더 신경 쓸 부분이 생겼습니다. 플러그인이 문법 변환과 이미지 URL 변환을 처리하지만, 이미지 파일 자체는 `public/images/blog/`에 있어야 합니다.

이 부분은 두 가지 운영 방식을 검토했습니다.

| 방식                             | 장점                           | 단점                             |
| -------------------------------- | ------------------------------ | -------------------------------- |
| **수동 이동** (현재)             | vault와 프로젝트 구조가 분리됨 | 발행할 때마다 이미지를 옮겨야 함 |
| **Obsidian 첨부 폴더 설정** 변경 | 이동 단계 생략 가능            | vault가 프로젝트 구조에 결합됨   |

현재는 수동 이동을 유지하고 있습니다. 이미지가 더 많아지면 이동을 자동화하는 스크립트를 만들 생각입니다.

## 마무리

블로그에 올린 글에서 이미지가 텍스트로 보이는 문제를 remark AST 변환 플러그인으로 해결했습니다. 정리하면 이렇습니다.

- **문제**: Obsidian `![[이미지.png]]`가 MDX에서 텍스트로 출력. 문법 불일치 + 이미지 저장 위치 불일치가 겹쳐 있었음
- **판단**: 원본 MDX를 건드리지 않기 위해 remark 플러그인(AST 변환) 선택
- **해결**: `text` → `image` 노드 교체 + URL에 `/images/blog/` 경로 부여
- **결과**: Obsidian 편집 경험 유지, 빌드와 편집이 서로 간섭하지 않는 구조

돌아보면, "어느 단계에서 개입할 것인가"가 가장 중요한 판단이었습니다. MDX 원본을 전처리하는 방법이 더 빨랐겠지만, Obsidian의 편집 경험을 포기해야 했습니다. 렌더링 파이프라인에서 처리하기로 한 덕분에, 글을 쓸 때는 Obsidian 문법을 그대로 쓰고, 발행할 때는 자동으로 변환되는 워크플로우가 만들어졌습니다.

한계도 있습니다. 현재는 이미지 파일만 처리합니다. Obsidian의 다른 위키링크 `[[노트 링크]]`나 `![[파일.pdf]]` 같은 임베드는 대상이 아닙니다. 블로그 안에서 노트 간 링크가 필요해지면 플러그인을 확장할 수 있을 겁니다. 정규식 패턴과 변환 대상 노드 타입만 추가하면 되니까요.]]></content:encoded>
          <category>블로그 개발</category>
          <pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>파일 업로드 리팩토링기 #5. 50MB를 올리고 나서야 비율이 안 맞는 걸 알았다</title>
          <link>https://blog.ssumi.space/blog/file-upload-refactoring-content-validation</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/file-upload-refactoring-content-validation</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>메타데이터 검증으로 충분하다고 생각했지만, PDF 페이지 비율 문제를 만나면서 콘텐츠 검증이라는 새로운 레이어가 필요해졌습니다. 함수 주입 vs 설정 기반, 동기 vs 비동기 경계를 설계한 과정을 다룹니다.</description>
          <content:encoded><![CDATA[## 검증을 통과한 파일이 문제를 일으켰다

4편까지 구현한 검증은 파일의 크기, 확장자, 개수, 중복 여부를 확인했습니다.
`File` 객체의 `name`과 `size` 속성만 보면 되니 동기적이고 빠릅니다.
이 검증만으로 충분하다고 생각했습니다.

### 극단적인 페이지 비율

그런데 AI 파이프라인에서 문제가 생겼습니다.
특정 PDF가 전처리 단계에서 글자가 누락되거나 레이아웃이 깨지는 현상이 발생한 것입니다.

원인을 추적해보니, 해당 PDF는 웹 페이지를 캡처한 것이라 **하나의 페이지가 비상식적으로 길었습니다.**

- **일반적인 A4**: 가로:세로 비율 약 0.7
- **문제의 PDF**: 가로:세로 비율 약 0.1 (세로가 가로의 10배)

### 왜 프론트엔드에서 걸러야 했나

이 프로젝트의 AI 파이프라인은 PDF를 이미지로 변환한 뒤 분석합니다.
본래라면 서버 쪽에서 window-slicing을 적용해야 하지만, 당시 AI 파이프라인에 리소스를 투입할 수 없는 상황이었습니다.
window-slicing 없이 극단적인 비율의 페이지가 그대로 들어가면, AI 모델의 인식률이 떨어지고 전처리 결과도 불안정해집니다.

그래서 AI 팀에서 프론트엔드에 우선 비율 검증을 요청했습니다.
서버에서 거부할 수도 있지만, 50MB짜리 PDF를 업로드하고 나서야 "비율이 안 맞습니다"라고 알려주는 것은 좋은 UX가 아닙니다.

**업로드 전에, 클라이언트에서 파일 콘텐츠를 분석해서 걸러야 했습니다.**

## 1. 메타데이터 검증과 콘텐츠 검증은 다른 레이어다

### 동기 검증과 비동기 검증의 차이

4편까지의 검증과 이번에 필요한 검증은 본질적으로 다릅니다.

| 구분       | 메타데이터 검증 (4편)    | 콘텐츠 검증 (이 글)                |
| ---------- | ------------------------ | ---------------------------------- |
| 검증 대상  | `File.name`, `File.size` | 파일 바이너리 내용                 |
| 실행 방식  | **동기**                 | **비동기** (파일 파싱 필요)        |
| 소요 시간  | 즉시 (< 1ms)             | PDF 페이지 수에 비례 (수백ms~수초) |
| 라이브러리 | 불필요                   | pdf-lib (PDF), Image API (이미지)  |

PDF라면 페이지 구조를 해석해야 하고, 이미지라면 디코딩해서 해상도를 알아내야 합니다.

### addFiles 흐름의 변화

이 차이 때문에 4편의 `addFiles` 흐름에 콘텐츠 검증을 그냥 끼워 넣을 수 없었습니다.
별도의 비동기 레이어로 설계해야 했습니다.

```tsx
// 4편까지: 메타데이터 검증만 있을 때 (동기)
addFiles(fileList) {
  if (파일 수 초과) return;
  for (file of fileList) {
    if (크기 초과 || 확장자 불일치 || 중복) continue;
    enqueue(file); // → QUEUED → 업로드 시작
  }
}

// 5편: 콘텐츠 검증이 추가되면 (비동기)
addFiles(fileList) {
  if (파일 수 초과) return;
  for (file of fileList) {
    if (크기 초과 || 확장자 불일치 || 중복) continue;
    validFiles.push(file);
  }
  // 메타데이터 통과한 파일들만 비동기 검증
  const results = await Promise.all(validFiles.map(콘텐츠_검증));
  const passed = results.filter(통과한_것만);
  enqueue(passed); // → QUEUED → 업로드 시작
}
```

## 2. 구체적 요구사항

| 항목      | 조건                            |
| --------- | ------------------------------- |
| 대상 파일 | PDF, 이미지 (jpg, png, webp 등) |
| 비율 제한 | 0.4 ≤ (width / height) ≤ 5.0    |
| 검증 시점 | 업로드 전 (클라이언트)          |
| 검증 범위 | PDF: 모든 페이지, 이미지: 전체  |

이 임계값은 AI 파이프라인 팀과 협의하여 정한 기준입니다.
비율 0.4 미만이면 세로로 극단적으로 긴 문서(영수증, 모바일 스크린샷 연결, 웹 페이지 캡처), 비율 5.0 초과면 가로로 극단적으로 긴 문서(스프레드시트 캡처, 파노라마)에 해당하며, 이 범위 밖에서 window-slicing 결과가 불안정해지는 것을 확인한 뒤 설정했습니다.

PDF는 **페이지별로** 비율이 다를 수 있으므로, 모든 페이지를 확인해야 합니다.
한 페이지라도 기준을 벗어나면 어떤 페이지가 문제인지 사용자에게 알려야 합니다.

## 3. 첫 번째 시도. 함수 주입

### 검증 함수를 통째로 주입받는 설계

가장 먼저 떠올린 설계는 검증 함수를 통째로 주입받는 것이었습니다.

```ts
// 첫 번째 설계: 검증 함수를 외부에서 주입
interface FileManagerOptions {
  // ... 기존 옵션 (3편)
  contentValidator?: (file: File) => Promise

## 4. 최종 설계. 설정 기반으로

기본 검증은 훅이 내장하고, 사용처는 **임계값 오버라이드와 추가 검증만** config로 전달하는 방식으로 바꿨습니다.

```ts
interface ContentValidationConfig {
  /** 파일 크기 제한 (bytes). 기본: 50MB. false면 스킵 */
  maxFileSize?: number | false;
  /** 비율 제한. 기본: { min: 0.4, max: 5.0 }. false면 스킵 */
  aspectRatio?: { min: number; max: number } | false;
  /** 기본 검증 이후 실행할 추가 validator */
  extraValidator?: (file: File) => Promise

사용처가 늘어날수록 누락 확률은 높아지고, 그 실수의 결과는 비율이 깨진 파일이 AI 파이프라인에 들어가는 것입니다.

설정 기반은 유연성에 약간의 제약을 두는 대신, **기본 검증을 덮어쓰는 게 아니라 조절**하게 합니다.
`false`로 명시적으로 꺼야 기본 검증이 빠지니, "몰라서 빠트리는" 상황이 발생하지 않습니다.

## 5. 검증 레이어의 분리

설계에서 한 가지 더 신경 쓴 점은, `useFileStaging` 훅이 **파일 포맷에 대한 지식을 갖지 않는 것**입니다.

```
useFileStaging
  → contentValidation config를 받음
  → buildContentValidator()로 config를 실행 가능한 함수로 변환
    → createAspectRatioValidator(): 파일 타입별 분기
      → PDF면 pdf-lib로 페이지 비율 확인
      → 이미지면 Image API로 크기 확인
```

훅은 `contentValidation` config만 알고, 실제로 PDF를 어떻게 파싱하는지, 이미지 크기를 어떻게 읽는지는 모릅니다.
`buildContentValidator`가 config를 받아 하나의 `(file: File) => Promise

> PDF 페이지 크기를 읽는 구체적인 구현(pdf-lib, `Image` API 활용)은 이 시리즈의 설계 판단과는 별개의 구현 레시피입니다. 별도 포스트에서 다룰 예정입니다.

## 6. 검증 실패 파일은 상태에 추가하지 않는다

콘텐츠 검증 실패 파일을 어떻게 처리할지도 판단이 필요했습니다.
두 가지 선택지가 있었습니다.

- **A. ERROR 상태로 추가**: 4편의 업로드 에러처럼, 파일을 목록에 추가하되 ERROR 상태로 표시
- **B. 아예 추가하지 않음**: 검증 실패 파일은 state에 넣지 않고, 토스트로만 알림

업로드 에러와 검증 실패는 성격이 다릅니다.

업로드 에러는 네트워크 문제처럼 **재시도하면 해결될 수 있는** 일시적 실패입니다.
그래서 4편에서 ERROR 상태를 유지하고 `retryFile`을 제공했습니다.

반면 콘텐츠 검증 실패는 **파일 자체의 문제**입니다.
PDF 페이지 비율이 0.2인 파일은 재시도해도 비율이 바뀌지 않습니다.
사용자가 해야 할 일은 "재시도"가 아니라 "다른 파일을 선택하는 것"입니다.

그래서 B를 선택했습니다.
검증 실패 파일은 state에 추가하지 않고, `toast.error`와 `onValidationError` 콜백으로 어떤 파일이 왜 실패했는지를 알립니다.

## 7. 시리즈를 마치며

1편에서 프로젝트를 grep했을 때, `postFile`과 `patchFile`이 5곳에 흩어져 있었습니다.
같은 API를 호출하면서 에러 처리는 4가지 패턴으로 제각각이었습니다.
"이건 정리해야 한다"는 것만 분명했고, 어디서부터 손을 대야 할지는 불분명했습니다.

돌아보면, 이 시리즈에서 일관되게 적용한 원칙은 두 가지였습니다.

**"공통 로직은 훅이 내장하고, 차이는 설정으로 흡수한다."**
`confirmDir`을 바꾸면 5곳의 `patchFile` 중복이 사라지고, `concurrency`를 바꾸면 동시 업로드 수가 제한되고, `contentValidation`을 바꾸면 검증 기준이 달라집니다.
사용처에서는 "무엇이 다른지"만 선언하면 되고, "어떻게 처리할지"는 훅이 담당합니다.

**"상태 전이를 명시적으로 관리한다."**
v1에서 에러 시 파일을 삭제하던 것을 ERROR 상태로 바꾸고, 업로드 시작 전 QUEUED 상태를 추가하고, 확정 후 CONFIRMED 상태를 도입한 것 모두, 각 단계가 어떤 상태인지를 코드에서 읽을 수 있게 하기 위한 것이었습니다.

| 편  | 핵심 질문                                  | 판단                                 |
| --- | ------------------------------------------ | ------------------------------------ |
| 1편 | 중복이 어디에 있는가                       | 같은 API, 다른 에러 처리 5곳         |
| 2편 | v1은 어디까지 해결했는가                   | 업로드만, 에러 복구·확정·진행률 부재 |
| 3편 | 훅의 책임을 어디까지 넓힐 것인가           | API 3개 + 상태 모델 + 설정 객체      |
| 4편 | 설계를 어떻게 코드로 옮기는가              | 에러 복구·큐·취소·확정 통합          |
| 5편 | 메타데이터 너머의 검증을 어떻게 설계하는가 | 설정 기반 콘텐츠 검증 레이어         |

이 시리즈를 통해 정리하고 싶었던 것은 특정 기술의 사용법이 아니라, **"왜 이렇게 설계했는가"라는 판단의 과정**이었습니다.
같은 문제를 만난 누군가에게, 그 판단의 맥락이 참고가 되었으면 합니다.]]></content:encoded>
          <category>사용자 경험</category>
          <pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>파일 업로드 리팩토링기 #2. 에러 나면 파일이 사라지는 훅</title>
          <link>https://blog.ssumi.space/blog/file-upload-refactoring-v1-analysis</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/file-upload-refactoring-v1-analysis</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>업로드 상태를 통합하기 위해 만든 커스텀 훅이, 에러 시 파일을 Map에서 바로 삭제해버리는 구조였습니다. v1의 설계를 분석하고 한계를 정리합니다.</description>
          <content:encoded><![CDATA[## 업로드만 관리하는 훅이 있었습니다

1편에서 파일 API 호출이 11곳에 흩어져 있고, 에러 처리·상태 관리·캐시 무효화가 제각각이라는 문제를 다뤘습니다.

사실 이 문제를 해결하기 위한 시도가 이미 있었습니다.
`useTempFileManager`라는 훅이 만들어져 있었고, 일부 사용처에서 이 훅을 통해 파일 업로드를 처리하고 있었습니다.

이 글에서는 v1이 어떤 구조로 설계되었고, 어디까지 해결했으며, 왜 한계가 있었는지를 분석합니다.

## 1. v1의 구조. 두 개의 훅

v1은 역할에 따라 두 개의 훅으로 나뉘어 있었습니다.

```mermaid
flowchart TD
    A[useTempFileManager] -->|콜백으로 이벤트 수신| B[useFileUpload]
    B -->|HTTP multipart| C[fileApi.postFile]

    A -.- D["고수준: 파일 목록 상태 관리 + UI 인터페이스"]
    B -.- E["저수준: API mutation"]

    style D fill:none,stroke:none,color:#888
    style E fill:none,stroke:none,color:#888
```

- **`useFileUpload`**: "파일 하나를 서버에 보내는 것"만 담당합니다. TanStack Query의 `useMutation`으로 업로드 API를 호출하고, 성공/실패 이벤트를 콜백으로 상위 훅에 전달합니다.
- **`useTempFileManager`**: "사용자가 선택한 파일 목록 전체"를 관리합니다. `Map<string, FileAttachment>`으로 각 파일의 상태(업로드 중, 완료, 에러)를 추적하고, `useFileUpload`의 콜백을 받아 상태를 전이시킵니다.

### 왜 둘로 나눴을까

`useMutation`은 **하나의 비동기 요청**을 다루는 도구입니다. 파일을 서버에 보내고, 성공하면 응답을 반환하고, 실패하면 에러를 전달합니다.
하지만 파일 업로드 UI에서는 사용자가 파일을 **여러 개 동시에** 선택할 수 있습니다.
각 파일이 어떤 상태인지(업로드 중인지, 완료됐는지, 실패했는지)를 UI에 보여주려면, 파일 목록 전체를 추적하는 별도의 상태가 필요합니다.

> `useMutation`이 "파일 하나를 보내는 파이프"라면, `Map`은 "지금 파이프를 통과 중인 파일들의 현황판"에 해당합니다.

v1은 이 두 관심사를 분리해서, API 호출은 `useFileUpload`에, 현황판 관리는 `useTempFileManager`에 맡기는 구조를 택했습니다.

### 1-1. useFileUpload (API 호출 담당)

이 훅은 자체적으로 파일 상태를 관리하지 않습니다.
파일이 성공했는지, 실패했는지를 콜백 인터페이스(`onUploadStart`, `onUploadSuccess`, `onUploadError`)로 상위 훅에 알려줄 뿐입니다.
"무슨 일이 일어났는지"만 전달하고, "그래서 UI를 어떻게 바꿀지"는 상위 훅이 결정합니다.

### 1-2. useTempFileManager (상태 관리 담당)

`useFileUpload`가 전달하는 콜백을 받아서, `Map<string, FileAttachment>`에 담긴 파일의 상태를 전이시킵니다.
파일 선택 → Map에 추가 → 업로드 시작 → 성공/실패에 따라 상태 변경.
컴포넌트는 이 훅이 반환하는 `files` 배열과 유틸 함수들만 사용하면 됩니다.

이 글의 핵심인 에러 처리 부분만 보면 이렇습니다.

```tsx
const fileUpload = useFileUpload({
  onUploadError: fileId => {
    deleteFile(fileId); // ← 에러 시 Map에서 삭제
  },
});
```

### 1-3. 사용처

| 사용처                       | 사용 방식                                     |
| ---------------------------- | --------------------------------------------- |
| 채팅 입력 컴포넌트           | 훅 직접 사용                                  |
| 데이터소스 업로드 다이얼로그 | 훅 사용 + `patchFile` 별도 mutation           |
| 파일 업로드 컴포넌트         | 훅 직접 사용                                  |
| 그 외 7개 파일               | `FileAttachment` / `FileStatus` 타입만 import |

3곳에서 훅을 사용하고 있었고, 7개 파일은 훅의 타입만 가져다 쓰고 있었습니다.

## 2. v1의 한계. 무엇이 부족했는가

### 2-1. 에러 나면 파일이 사라진다

v1의 가장 큰 문제는 업로드 실패 시 파일을 Map에서 **바로 삭제**한다는 점이었습니다.

```tsx
onUploadError: (fileId) => {
  deleteFile(fileId); // ← Map에서 제거
},
```

이로 인해 세 가지가 불가능했습니다.

- 사용자가 **어떤 파일이 실패했는지** UI에서 확인할 수 없었습니다
- **왜 실패했는지** 에러 메시지를 보여줄 수 없었습니다 (에러 정보를 저장하지 않음)
- **재시도**할 수 없었습니다 (원본 `File` 객체를 보관하지 않으므로 다시 파일을 선택해야 함)

### 2-2. 라이프사이클이 업로드에서 끝난다

v1이 관리하는 범위를 정리하면 이렇습니다.

```mermaid
flowchart LR
    A[선택] --> B[업로드\npostFile] --> C[완료]
    C -.-> D[확정\npatchFile]
    C -.-> E[삭제\ndeleteFile]
    C -.-> F[다운로드\ngetFile]

    subgraph v1["v1이 관리하는 범위"]
        A
        B
        C
    end

    subgraph outside["사용처에서 직접 구현"]
        D
        E
        F
    end

    style v1 fill:#e8f5e9,stroke:#4caf50
    style outside fill:#fff3e0,stroke:#ff9800
```

1편에서 정리한 파일 업로드 흐름은 "임시 업로드(`postFile`) → 최종 확정(`patchFile`)"의 2단계였습니다.
v1은 이 중 **1단계인 임시 업로드만** 래핑했습니다.

1편에서 가장 중복이 심했던 `patchFile`(5곳)은 v1에 포함되지 않았습니다.
결과적으로, 훅을 사용하는 곳조차 2단계인 확정은 별도로 구현해야 했습니다.

```tsx
// 데이터소스 업로드 다이얼로그
// useTempFileManager로 업로드까지는 관리하지만, 이동은 별도 mutation
const { files, handleFileInputChange } = useTempFileManager();

const { mutateAsync: patchFile } = useMutation({
  mutationFn: (req: FilePatchRequest) => fileApi.patchFile(req),
  onError: () => { toast.error(t('uploadFailed')); },
  onSuccess: async () => { queryClient.invalidateQueries({ ... }); },
});
```

> "산발적 로직"의 절반만 해결된 셈이었습니다.

### 2-3. 그 외 한계들

에러 처리와 라이프사이클 외에도 부족한 점이 여럿 있었습니다.

- **업로드 과정이 블랙박스.** 진행률 추적 없이 `UPLOADING` → `COMPLETED`로 점프합니다. 대용량 파일 업로드 시 사용자에게 줄 수 있는 피드백이 "업로드 중..."뿐이었습니다.
- **클라이언트 검증이 없다.** 파일 크기 제한, 허용 확장자, 최대 파일 수 등의 검증 로직이 훅에 없었습니다. 유일한 검증은 중복 체크(이름+크기)뿐이고, 100MB 파일이든 허용되지 않는 확장자든 검증 없이 업로드가 시작됩니다.
- **초기 파일 세팅이 안 된다.** 서버에서 가져온 기존 파일 목록을 `COMPLETED` 상태로 세팅하는 방법이 없었습니다. "수정" 화면에서 기존 첨부파일을 보여주려면 훅 외부에서 별도로 상태를 관리해야 했습니다.
- **동시성 제어가 없다.** 파일 10개를 선택하면 10개의 `mutate`가 동시에 실행됩니다. 서버 부하나 브라우저 커넥션 제한(보통 6개)을 고려한 큐잉이 없었습니다.
- **타입이 훅 파일에 종속되어 있다.** `FileAttachment`와 `FileStatus`를 훅 파일에서 export하고 있어서, 훅을 사용하지 않는 7개 파일이 타입 때문에 훅에 의존하고 있었습니다. 나중에 훅을 수정할 때 영향 범위가 불필요하게 넓어지는 구조입니다.

## 3. v1이 해결한 것과 남은 것

v1은 **업로드 상태 관리**라는 좁은 범위에서는 확실히 문제를 해결했습니다.
3곳에서 중복되던 업로드 mutation과 파일 리스트 상태 관리를 하나로 통합했습니다.

하지만 파일 라이프사이클의 일부만 커버한 설계 때문에, 통합의 효과가 반감되었습니다.

- 업로드는 훅이 관리하지만, 바로 다음 단계인 확정(`patchFile`)은 사용처가 직접 구현
- 에러가 나면 파일이 사라져서, 사용자가 실패를 인지할 수도 재시도할 수도 없음
- 훅을 쓰는 곳조차 `patchFile` 별도 mutation이 필요

이 한계들을 정리하면, v2가 풀어야 할 문제가 명확해집니다.

| v1 한계           | v2에서 해결할 방향                            |
| ----------------- | --------------------------------------------- |
| 업로드만 래핑     | 전체 라이프사이클 관리 (업로드 + 확정 + 삭제) |
| 에러 시 파일 삭제 | ERROR 상태 유지 + 에러 정보 저장 + 재시도     |
| 진행률 없음       | 파일별 progress 추적                          |
| 검증 없음         | 설정 가능한 검증 레이어                       |
| 초기 파일 불가    | initialFiles 옵션                             |
| 동시성 미제어     | 큐 기반 동시성 제한                           |
| 타입 훅 종속      | 타입 분리                                     |

다음 편에서는 이 요구사항들을 바탕으로 v2의 인터페이스와 상태 모델을 어떻게 설계했는지를 다룹니다.]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>파일 업로드 리팩토링기 #3. 훅의 책임을 어디까지 넓힐 것인가</title>
          <link>https://blog.ssumi.space/blog/file-upload-refactoring-v2-design</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/file-upload-refactoring-v2-design</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>v1이 업로드만 래핑했던 한계를 넘어, 어떤 API를 훅에 포함하고 어떤 API는 제외할지 판단 기준을 세우고, v2의 인터페이스와 상태 모델을 설계합니다.</description>
          <content:encoded><![CDATA[## v1의 한계가 곧 v2의 요구사항이었습니다

2편에서 v1의 한계를 일곱 가지로 정리했습니다.
업로드만 래핑한 좁은 범위, 에러 시 파일 삭제, 진행률 부재, 검증 부재, 초기 파일 불가, 동시성 미제어, 타입 종속.

이 한계들을 뒤집으면 v2가 풀어야 할 문제 목록이 됩니다.
이 글에서는 그 목록을 바탕으로 v2의 인터페이스와 상태 모델을 어떻게 설계했는지를 다룹니다.

## 1. 어떤 API를 훅에 포함할 것인가

### 1-1. v1의 문제는 범위가 좁았던 것

2편에서 봤듯이, v1은 9개 파일 API 중 `postFile`(임시 업로드) 하나만 래핑했습니다.
1편에서 가장 중복이 심했던 `patchFile`(확정)은 포함되지 않았고, 결과적으로 훅을 쓰는 곳조차 확정 로직을 별도로 구현해야 했습니다.

그렇다고 9개 API를 전부 훅에 넣는 것이 답은 아닙니다.
파일 이동, 이름 변경, 복사, 디렉토리 생성 같은 기능은 파일 탐색기에서만 쓰입니다.
이것까지 포함하면 훅이 "파일 업로드 관리"가 아니라 "파일 시스템 클라이언트"가 됩니다.

### 1-2. 판단 기준. 업로드 중심 라이프사이클

기준을 하나 세웠습니다.

| API                                                              | v1  | v2  | 판단 이유                                            |
| ---------------------------------------------------------------- | :-: | :-: | ---------------------------------------------------- |
| `postFile` (임시 업로드)                                         |  O  |  O  | 업로드 핵심                                          |
| `patchFile` (확정/이동)                                          |  X  |  O  | 가장 중복이 심한 API. 업로드 직후 흐름에 속함        |
| `deleteFile` (삭제)                                              |  X  |  O  | 파일 제거 시 서버와 동기화 필요                      |
| `getFile` / `getFileByPath`                                      |  X  |  X  | 다운로드는 별도 유틸로 유지. 훅의 "관리" 책임과 분리 |
| `patchFileMove` / `patchName` / `postFileCopy` / `postDirectory` |  X  |  X  | 파일 탐색기 전용 기능. 업로드 관리 훅의 범위를 넘음  |

9개 중 3개(`postFile`, `patchFile`, `deleteFile`)만 선택했습니다.
"전부" 또는 "하나"가 아니라, 라이프사이클이라는 기준으로 경계를 그은 것이 v2 설계의 첫 번째 판단이었습니다.

## 2. 파일의 상태를 어떻게 표현할 것인가

### 2-1. v1의 상태 모델

v1은 네 가지 상태를 정의하고 있었지만, 실제로 사용된 건 둘뿐이었습니다.

```
UPLOADING  ← 사용됨
COMPLETED  ← 사용됨
ERROR      ← 정의만 되어 있고 한 번도 사용되지 않음
DELETED    ← 정의만 되어 있고 한 번도 사용되지 않음
```

파일이 선택되면 바로 `UPLOADING`으로 시작하고, 성공하면 `COMPLETED`로 전이합니다.
실패하면 `ERROR`로 가는 대신 Map에서 삭제됩니다.

"선택됨" 상태나 "대기 중" 상태가 없으니, 검증이나 동시성 제어를 끼워넣을 틈이 없었습니다.

### 2-2. v2의 상태 모델

v2에서는 라이프사이클의 각 단계를 상태로 표현합니다.

```ts
enum FileStatus {
  IDLE = 'idle', // 선택됨, 검증 전
  QUEUED = 'queued', // 검증 통과, 업로드 대기 중
  UPLOADING = 'uploading', // 서버에 업로드 진행 중
  COMPLETED = 'completed', // 임시 경로에 업로드 완료
  ERROR = 'error', // 실패 (재시도 가능)
  CONFIRMED = 'confirmed', // 최종 경로로 이동 완료
}
```

v1과 비교하면 세 가지가 달라졌습니다.

**`IDLE` 추가.** 파일이 선택되었지만 아직 검증을 거치지 않은 상태입니다.
v1에서는 선택과 동시에 업로드가 시작됐지만, v2에서는 파일 크기나 확장자를 먼저 확인하고 나서 업로드를 시작합니다.

**`QUEUED` 추가.** 검증을 통과했지만 아직 업로드가 시작되지 않은 대기 상태입니다.
v1에서는 파일 10개를 선택하면 10개의 `mutate`가 동시에 실행됐습니다.
v2에서는 동시 업로드 수를 제한하고, 나머지는 큐에서 대기합니다.

**`CONFIRMED` 추가.** 임시 경로에 업로드된 파일이 최종 경로로 이동 완료된 상태입니다.
v1에서는 확정을 훅이 관리하지 않았으니 이 상태가 필요 없었지만, v2에서는 확정까지 훅이 관리하므로 "업로드 완료"와 "확정 완료"를 구분할 수 있게 됩니다.

상태 전이를 정리하면 이렇습니다.

```mermaid
stateDiagram-v2
    [*] --> IDLE : 파일 선택
    IDLE --> QUEUED : 검증 통과
    QUEUED --> UPLOADING : 슬롯 확보
    UPLOADING --> COMPLETED : 성공
    UPLOADING --> ERROR : 실패
    ERROR --> QUEUED : 재시도
    COMPLETED --> CONFIRMED : 확정(patchFile)
```

### 2-3. ERROR를 상태로 유지하는 이유

2편에서 다뤘듯이, v1은 에러가 나면 파일을 Map에서 삭제했습니다.
v2에서 `ERROR`를 상태로 유지하려면, 에러 정보를 파일 객체 안에 담아야 합니다.
이 부분은 바로 다음 섹션에서 다룹니다.

## 3. 파일 하나를 어떤 정보로 표현할 것인가

### 3-1. v1의 FileAttachment

v1의 `FileAttachment`는 서버 응답 스키마를 거의 그대로 따르고 있었습니다.
파일명, 크기, MIME 타입, 서버 경로 정도만 담고 있었고, 클라이언트에서 필요한 정보(진행률, 에러 정보, 원본 File 객체)는 없었습니다.

### 3-2. v2의 ManagedFile

v2에서는 서버 스키마와 분리된 클라이언트 모델을 정의했습니다.

```ts
interface ManagedFile {
  /** 클라이언트 추적용 고유 ID */
  id: string;
  /** 파일명 */
  fileName: string;
  /** 파일 크기 (bytes) */
  fileSize: number;
  /** MIME 타입 */
  contentType?: string;
  /** 현재 라이프사이클 상태 */
  status: FileStatus;
  /** 업로드 진행률 (0~100) */
  progress: number;
  /** 서버가 반환한 전체 경로 (업로드 완료 후) */
  fullPath: string;
  /** 에러 정보 (ERROR 상태일 때) */
  error?: {
    message: string;
    isRetryable: boolean;
  };
  /** 원본 File 객체 (재시도용) */
  rawFile?: File;
}
```

v1 대비 핵심 변경은 세 가지입니다.

**`progress` 추가.** 파일별 업로드 진행률을 0~100으로 추적합니다.
v1에서는 `UPLOADING` → `COMPLETED`로 점프했지만, v2에서는 사용자에게 실시간 피드백을 줄 수 있습니다.

**`error` 추가.** 실패 원인 메시지와 재시도 가능 여부를 담습니다.
네트워크 에러처럼 재시도하면 해결될 수 있는 에러와, 파일 크기 초과처럼 재시도해도 소용없는 에러를 구분할 수 있습니다.

**`rawFile` 추가.** 브라우저의 원본 `File` 객체를 보관합니다.
v1에서는 에러 시 파일을 삭제했기 때문에 재시도하려면 사용자가 다시 파일을 선택해야 했습니다.
`rawFile`을 보관하면 "재시도" 버튼 하나로 같은 파일을 다시 업로드할 수 있습니다.

## 4. 설정 객체. 5곳의 차이를 선언적으로 흡수하기

### 4-1. 사용처마다 다른 것

1편에서 봤던 5개 사용처를 다시 떠올려보면, 사용처마다 달랐던 건 결국 몇 가지 옵션이었습니다.

- 허용하는 파일 종류와 크기가 다릅니다
- 한 번에 올릴 수 있는 파일 수가 다릅니다
- 확정 시 이동할 경로가 다릅니다
- 호출하는 업로드 API가 권한에 따라 다릅니다

이 차이들을 훅 외부에서 선언적으로 주입할 수 있으면, 5곳의 로직을 각각 구현할 필요가 없어집니다.

### 4-2. FileManagerOptions

```ts
interface FileManagerOptions {
  /** 권한에 따라 호출되는 업로드 API가 달라짐 */
  permission?: 'user' | 'manager';

  /** ── 검증 ── */
  /** 최대 파일 수 */
  maxFiles?: number;
  /** 파일당 최대 크기 (bytes) */
  maxFileSize?: number;
  /** 허용 확장자 (예: ['.pdf', '.csv', '.xlsx']) */
  accept?: string[];

  /** ── 업로드 ── */
  /** 최대 동시 업로드 수 (기본: 3) */
  concurrency?: number;
  /** 업로드 경로 UUID (외부 주입. 없으면 자동 생성) */
  uploadPath?: string;

  /** ── 확정 ── */
  /** 확정 시 이동할 목적지 경로 */
  confirmDir?: string;

  /** ── 초기값 ── */
  /** 서버에서 가져온 기존 파일 목록 (COMPLETED 상태로 세팅) */
  initialFiles?: FileDetail[];

  /** ── 콜백 ── */
  onAllUploaded?: () => void;
  onValidationError?: (file: File, reason: string) => void;
  onConfirmSuccess?: (response: FileMoveRs) => void;
}
```

### 4-3. 사용처별 설정 예시

v1의 `useTempFileManager`라는 이름은 "임시 파일 관리"라는 좁은 범위를 반영하고 있었습니다.
v2는 선택부터 확정까지의 전체 흐름을 관리하므로, 이름을 `useFileStaging`으로 바꿨습니다.

같은 훅을 설정만 바꿔서 사용합니다.

```tsx
// 채팅 파일 첨부: 간단한 업로드
const chatFiles = useFileStaging({
  permission: 'user',
  maxFiles: 5,
  maxFileSize: 10_000_000, // 10MB
  confirmDir: '/uploads/chat',
});

// 데이터소스 업로드: 대용량, 다양한 확장자
const dataSourceFiles = useFileStaging({
  permission: 'manager',
  maxFiles: 50,
  maxFileSize: 500_000_000, // 500MB
  accept: ['.csv', '.json', '.parquet', '.xlsx'],
  concurrency: 3,
  confirmDir: currentPath, // 사용자가 탐색기에서 선택한 경로
});

// 학습 데이터셋: 특정 포맷만, 수정 시 기존 파일 표시
const trainingFiles = useFileStaging({
  permission: 'manager',
  accept: ['.jsonl'],
  maxFiles: 1,
  confirmDir: `/uploads/training/${modelId}`,
  initialFiles: existingFiles, // 서버에서 가져온 기존 파일
});
```

1편에서 5곳이 각각 `useMutation`을 정의하던 `patchFile`이, `confirmDir` 옵션 하나로 흡수됩니다.
에러 처리가 제각각이던 문제도 훅 내부에서 통일된 방식으로 처리하게 됩니다.

## 5. 훅이 반환하는 것

### 5-1. 반환 인터페이스

v1과 비교하면 세 가지 영역이 추가되었습니다.

**에러 복구.** `retryFile`로 실패한 파일을 재시도하고, `hasErrorFiles`로 에러 파일이 있는지 확인할 수 있습니다.
v1에서는 에러 시 파일이 사라졌기 때문에 이 인터페이스 자체가 불가능했습니다.

**업로드 제어.** `cancelUpload`로 진행 중인 업로드를 취소하고, `totalProgress`로 전체 진행률을 추적할 수 있습니다.

**확정 관리.** `confirmFiles`로 임시 경로의 파일을 최종 경로로 이동시킵니다.
v1에서 사용처마다 별도로 구현하던 `patchFile` mutation이 이 함수 안에 들어갑니다.
`isConfirming`과 `confirmError`로 확정 진행 상태와 실패 여부도 확인할 수 있습니다.

### 5-2. 확정 에러 코드

`confirmFiles`가 실패할 때 반환하는 에러 코드입니다.

```ts
type ConfirmErrorCode = 'NO_FILES' | 'HAS_UPLOADING' | 'CONFIRM_FAILED';
```

- **`NO_FILES`**: 확정할 파일이 없습니다. 파일을 선택하지 않았거나, 모든 파일이 에러 상태일 때.
- **`HAS_UPLOADING`**: 아직 업로드 중인 파일이 있습니다. 모든 업로드가 끝난 뒤에 확정해야 합니다.
- **`CONFIRM_FAILED`**: API 호출 자체가 실패했습니다.

## 6. 타입을 훅에서 분리하기

2편에서 지적했듯이, v1에서는 `FileAttachment`와 `FileStatus`가 훅 파일 안에 정의되어 있었습니다.
훅을 사용하지 않는 7개 파일이 타입 때문에 훅에 의존하는 구조였습니다.

v2에서는 타입을 별도 파일로 분리했습니다.

```
hooks/file/
  ├── file.types.ts          ← ManagedFile, FileStatus, FileManagerOptions
  ├── useFileUpload.ts       ← 업로드 mutation (저수준)
  ├── useFileConfirm.ts      ← 확정 mutation (저수준, 새로 추가)
  └── useFileStaging.ts      ← 파일 상태 관리 (고수준)
```

`file.types.ts`에 모든 타입을 모았습니다.
타입만 필요한 파일은 `file.types.ts`에서 import하고, 훅이 필요한 파일은 `useFileStaging.ts`에서 import합니다.

훅 구조도 2편에서 본 v1의 패턴을 유지합니다.
`useFileUpload`와 `useFileConfirm`이 저수준 훅으로 각각 업로드와 확정 API를 담당하고, `useFileStaging`이 고수준 훅으로 이 둘을 조합하여 파일 상태를 관리합니다.
v1에서 `useFileUpload` → `useTempFileManager`였던 구조에 `useFileConfirm`이 하나 더 추가된 형태입니다.

## 7. 설계를 마치며

이 글에서 내린 설계 판단을 정리하면 세 가지입니다.

**훅의 범위를 라이프사이클로 정했습니다.** 9개 API 중 업로드 흐름에 속하는 3개만 선택했습니다.
"전부 넣기"와 "하나만 넣기" 사이에서, 기준 없이 타협하는 대신 라이프사이클이라는 경계를 명시적으로 세웠습니다.

**에러를 상태로 유지하기로 했습니다.** v1의 가장 큰 문제였던 "에러 시 삭제"를 뒤집어, `ERROR` 상태를 유지하고 에러 정보와 원본 파일을 보관하는 구조를 설계했습니다.

**사용처의 차이를 설정 객체로 흡수했습니다.** 5곳이 각각 다르게 구현하던 로직이, 설정 객체의 옵션 차이로 표현됩니다.
코드 중복이 아닌 선언적 차이로 바뀌는 것입니다.

다음 편에서는 이 설계를 실제로 구현하면서 만난 문제들을 다룹니다.
에러 복구, 업로드 진행률 추적, 동시성 제어를 어떻게 풀었는지가 핵심입니다.]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>파일 업로드 리팩토링기 #4. 에러가 나도 파일이 남는 훅</title>
          <link>https://blog.ssumi.space/blog/file-upload-refactoring-v2-implementation</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/file-upload-refactoring-v2-implementation</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>3편에서 설계한 인터페이스를 구현합니다. 에러 복구, 업로드 진행률, 동시성 제어, 취소, 확정 통합까지 v2의 핵심 구현을 다룹니다.</description>
          <content:encoded><![CDATA[## 설계를 코드로 만드는 시간

3편에서 `FileStatus`, `ManagedFile`, `FileManagerOptions` 인터페이스를 설계했습니다.
타입을 정의하는 것과 그 타입대로 동작하는 코드를 만드는 것은 다른 문제입니다.

이 글에서는 설계를 구현으로 옮기면서 만난 판단들, v1에서 어떤 부분이 달라졌고 왜 그렇게 바꿨는지를 다룹니다.

먼저 v1과 v2의 차이를 한눈에 보겠습니다.

| 개선 항목       | v1                         | v2                            |
| --------------- | -------------------------- | ----------------------------- |
| 에러 복구       | 에러 시 Map에서 삭제       | ERROR 상태 유지 + 재시도      |
| 업로드 진행률   | UPLOADING → COMPLETED 점프 | 파일별 0~100% 실시간 추적     |
| 클라이언트 검증 | 중복 체크만                | 설정 기반 검증 레이어         |
| 동시성 제어     | 전부 동시 업로드           | 큐 기반 최대 N개 제한         |
| 업로드 취소     | 불가능                     | AbortController로 파일별 취소 |
| 확정 통합       | 5곳에서 각자 mutation      | `confirmFiles()`로 통합       |

각 항목이 어떤 설계 판단에서 비롯되었는지 하나씩 살펴보겠습니다.

## 1. useState에서 useReducer로

각 항목을 구현하기 전에, 먼저 상태 관리 방식 자체가 바뀌어야 했습니다.

v1은 `useState

## 2. 에러 복구. 삭제 대신 상태 전이

2편에서 다뤘던 v1의 가장 큰 문제, "에러가 나면 파일이 사라지는" 동작을 해결합니다.

```mermaid
flowchart LR
    subgraph v1["v1: 에러 → 삭제"]
        A1[UPLOADING] -->|에러 발생| A2[Map에서 삭제]
        A2 -.->|재시도 불가| A3["사용자가 파일 재선택"]
    end
    subgraph v2["v2: 에러 → 상태 유지 → 재시도"]
        B1[UPLOADING] -->|에러 발생| B2[ERROR]
        B2 -->|재시도| B3[QUEUED]
        B3 -->|슬롯 확보| B4[UPLOADING]
    end
    style v1 fill:#fff3e0,stroke:#ff9800
    style v2 fill:#e8f5e9,stroke:#4caf50
```

### 2-1. 에러를 상태로 유지

핵심 변경은 한 줄입니다.
에러가 발생했을 때 `deleteFile` 대신 `dispatch({ type: 'UPLOAD_ERROR' })`를 호출하는 것.

```tsx
// v1: 에러 시 파일을 Map에서 삭제
onUploadError: fileId => {
  deleteFile(fileId);
};

// v2: 에러 시 상태를 ERROR로 전이, 에러 정보 보존
onUploadError: (fileId, error) => {
  dispatch({
    type: 'UPLOAD_ERROR',
    payload: { id: fileId, error: toFileError(error) },
  });
  processQueue();
};
```

하지만 단순히 삭제를 하지 않는 것만으로는 부족합니다.
에러를 **어떤 정보와 함께** 보존할지가 중요합니다.

v2에서는 에러 정보에 `isRetryable` 필드를 추가했습니다.
서버 500/429 에러나 네트워크 에러처럼 재시도하면 해결될 가능성이 있는 에러와, 파일 크기 초과처럼 재시도해도 소용없는 에러를 구분합니다.
UI에서는 이 값을 보고 "재시도" 버튼을 노출할지 결정할 수 있습니다.

### 2-2. 재시도

3편에서 `ManagedFile`에 `rawFile`(원본 File 객체)을 추가한 이유가 여기서 드러납니다.

```tsx
// v1: 재시도 불가. 파일이 삭제되어 원본 File 객체가 사라짐
// → 사용자가 탐색기에서 같은 파일을 다시 선택해야 함

// v2: rawFile이 남아있으므로 재시도 가능
retryFile(fileId);
// 내부: ERROR → QUEUED 전이 후 큐에 재등록
```

리듀서의 `RETRY_FILE` 액션은 두 가지를 확인합니다.
현재 상태가 `ERROR`인지, `rawFile`이 존재하는지.
조건을 충족하면 상태를 `QUEUED`로 전이하고 큐에 다시 추가합니다.

v1에서는 파일이 Map에서 삭제되면서 원본 File 객체도 함께 사라졌기 때문에, 이런 재시도 자체가 불가능했습니다.

## 3. 업로드 진행률

v1은 `UPLOADING` → `COMPLETED`로 점프했습니다.
대용량 파일을 올릴 때 사용자가 받는 피드백은 "업로드 중..."뿐이었습니다.

```tsx
// v1: 상태만 있고 진행률은 없음
{ status: 'UPLOADING' }  →  { status: 'COMPLETED' }

// v2: 파일별 진행률을 실시간 추적
{ status: 'UPLOADING', progress: 0 }
{ status: 'UPLOADING', progress: 45 }
{ status: 'UPLOADING', progress: 100 }
{ status: 'COMPLETED', progress: 100 }
```

구현 방식은 단순합니다.
저수준 훅인 `useFileUpload`에서 axios의 `onUploadProgress` 콜백을 통해 진행률을 받고, 고수준 훅인 `useFileStaging`이 그 콜백을 받아 `dispatch({ type: 'UPDATE_PROGRESS' })`로 `ManagedFile.progress`를 갱신합니다.

이 설계에서 주목할 점은 **저수준 훅은 진행률을 보고만 하고, 어떻게 저장할지는 모른다**는 것입니다.
2편에서 다뤘던 저수준/고수준 분리가 여기서도 동일하게 적용됩니다.

`useFileUpload`는 "진행률이 바뀌었다"는 이벤트를 콜백으로 올리고, `useFileStaging`이 그것을 리듀서 상태로 관리합니다.
전체 진행률(`totalProgress`)은 개별 파일 progress의 평균으로 계산합니다.

## 4. 클라이언트 검증

v1의 유일한 검증은 중복 체크(이름+크기)뿐이었습니다.
파일 크기가 제한을 넘거나, 허용하지 않는 확장자의 파일을 올리면, 서버까지 갔다가 에러를 받고서야 사용자가 알 수 있었습니다.

v2는 3편에서 설계한 `FileManagerOptions`의 설정값을 기반으로, 서버에 보내기 전에 검증합니다.

```tsx
// v1: 중복만 체크
addFiles(fileList) {
  for (file of fileList) {
    if (isDuplicate(file)) continue;
    uploadFile(file);
  }
}

// v2: 설정 기반 검증 레이어
addFiles(fileList) {
  if (현재 파일 수 + 새 파일 수 > maxFiles) return; // 전체 거부

  for (file of fileList) {
    if (isDuplicate(file)) continue;
    if (file.size > maxFileSize) continue; // 크기 초과
    if (!accept.includes(ext)) continue;  // 확장자 불일치
    enqueue(file); // 검증 통과 → QUEUED
  }
}
```

검증 판단이 두 단계로 나뉘는 점이 중요합니다.
**파일 수 초과**는 전체를 거부하고(한 번에 5개 제한인데 7개를 선택했다면, 2개만 골라 넣는 것보다 전체를 거부하는 편이 사용자 의도에 가깝습니다), **개별 파일 검증**(크기, 확장자, 중복)은 파일별로 판단하여 통과한 것만 큐에 넣습니다.

> 한 가지 주의할 점은, 이 검증이 파일의 **메타데이터**(크기, 확장자, 개수)만 확인한다는 것입니다. 파일 **콘텐츠** 자체를 분석하는 검증(예를 들어 PDF 페이지 수나 이미지 해상도)은 이 레이어의 책임이 아닙니다. 이것은 5편에서 다룹니다.

## 5. 동시성 제어

1편에서 언급했듯이, 이 프로젝트는 다중 업로드 API 없이 단건 `postFile`을 반복 호출하는 구조입니다.
v1에서는 이 반복 호출에 제한이 없었습니다.

파일 10개를 선택하면 10개의 `mutate`가 동시에 실행됐습니다.
파일이 몇 개 안 될 때는 문제가 없지만, 브라우저의 HTTP 커넥션 제한(보통 6개)이나 서버 부하를 고려하면, 동시 업로드 수를 제한하는 큐가 필요합니다.

```tsx
// v1: 모든 파일을 즉시 동시 업로드
addFiles(fileList) {
  for (file of fileList) {
    mutate(file); // 10개면 10개 동시에
  }
}

// v2: 큐에 넣고 최대 N개씩 처리
addFiles(fileList) {
  for (file of fileList) {
    enqueue(file); // QUEUED 상태
  }
  processQueue(); // 슬롯이 빈 만큼만 시작
}
```

```mermaid
flowchart TD
    A[processQueue 호출] --> B{활성 업로드 < concurrency\n AND 큐에 파일 있음?}
    B -->|Yes| C[큐에서 파일 꺼냄]
    C --> D[활성 카운트 +1]
    D --> E[uploadFile 실행]
    E --> B
    B -->|No| F[대기]
    E -->|완료/실패| G[활성 카운트 -1]
    G --> A
```

3편에서 설계한 `QUEUED` 상태가 여기서 의미를 갖습니다.
큐에 들어갔지만 아직 업로드가 시작되지 않은 파일은 UI에서 "대기 중"으로 표시됩니다.
`concurrency` 옵션의 기본값은 3입니다.

## 6. 업로드 취소

v1에서는 한 번 시작된 업로드를 중단할 방법이 없었습니다.
사용자가 실수로 큰 파일을 올렸더라도 완료될 때까지 기다려야 했습니다.

v2에서는 `AbortController`를 파일별로 관리합니다.

```tsx
// v1: 취소 불가
// 업로드가 시작되면 완료될 때까지 기다려야 함

// v2: 파일별 AbortController로 개별 취소
cancelUpload(fileId);
// 내부: controller.abort() → HTTP 요청 중단
//       → 슬롯 반환 → processQueue()
```

설계 판단 하나를 공유하면, `cancelUpload`은 별도 함수가 아니라 `removeFile`에 위임합니다.
"업로드를 취소한다"와 "파일을 목록에서 제거한다"가 사용자 입장에서 같은 동작이기 때문입니다.

`removeFile` 안에서 해당 파일에 연결된 AbortController가 있으면 abort하고, 슬롯을 반환한 뒤 `processQueue`로 다음 파일을 시작합니다.

> 취소를 별도 상태("취소됨")로 관리하는 방법도 있었지만, 목록에서 사라지는 편이 더 직관적이라고 판단했습니다.

## 7. 확정 통합. 5곳의 patchFile이 하나로

1편에서 가장 중복이 심했던 `patchFile`을 해결합니다.
5곳에서 4가지 에러 처리 패턴으로 각각 `useMutation`을 정의하고 있었던 코드가, 설정 하나로 정리됩니다.

### 7-1. 사전 검증이 필요한 이유

확정을 호출하기 전에, 현재 파일 상태를 먼저 확인해야 합니다.
아직 업로드 중인 파일이 있는데 확정을 시도하면 서버에서 에러가 나고, 파일이 하나도 없는데 확정을 호출하면 불필요한 API 요청이 발생합니다.

v1에서는 이 검증을 5곳에서 각자 구현했습니다.
어떤 곳은 업로드 중인지 체크했고, 어떤 곳은 체크하지 않았습니다.
v2에서는 `confirmFiles()` 안에 사전 검증을 통합했습니다.

```mermaid
flowchart TD
    A[confirmFiles 호출] --> B{파일이 있는가?}
    B -->|No| C["NO_FILES 에러"]
    B -->|Yes| D{업로드 중/대기 중\n파일이 있는가?}
    D -->|Yes| E["HAS_UPLOADING 에러"]
    D -->|No| F{COMPLETED 파일이\n있는가?}
    F -->|No| C
    F -->|Yes| G[patchFile API 호출]
    G -->|성공| H["CONFIRMED 상태 전이"]
    G -->|실패| I["CONFIRM_FAILED 에러"]
```

3편에서 설계한 `ConfirmErrorCode` 타입(`'NO_FILES' | 'HAS_UPLOADING' | 'CONFIRM_FAILED'`)이 이 검증 결과를 표현합니다.

```tsx
// v1: 5곳에서 각자 patchFile mutation 정의 + 각자 에러 처리
const patchMutation = useMutation({ mutationFn: patchFile, onError: ... });

// v2: confirmDir만 설정하면 검증 + API 호출 + 에러 코드까지 통합
const { confirmFiles, confirmError } = useFileStaging({
  confirmDir: '/uploads/documents',
});
```

`confirmDir`만 다르게 설정하면, 5곳의 중복 mutation이 사라집니다.

### 7-2. 마이그레이션

기존 사용처에서 v2로 전환한 결과입니다.

| 사용처                   | v1                             | v2                                                                    |
| ------------------------ | ------------------------------ | --------------------------------------------------------------------- |
| 채팅 입력                | `useTempFileManager('user')`   | `useFileStaging({ permission: 'user', confirmDir: '/uploads/chat' })` |
| 데이터 업로드 다이얼로그 | 훅 + 별도 `patchFile` mutation | `useFileStaging({ confirmDir: currentPath })` (mutation 제거)         |
| 파일 업로드 컴포넌트     | `useTempFileManager()`         | `useFileStaging({ ... })`                                             |
| 학습 데이터 모달         | 직접 `patchFile` mutation      | `useFileStaging({ confirmDir, initialFiles })`                        |
| 평가 데이터셋 페이지     | 직접 `postFile` + `patchFile`  | `useFileStaging({ accept: ['.jsonl'], confirmDir })`                  |

가장 변화가 큰 곳은 "데이터 업로드 다이얼로그"입니다.
v1에서는 `useTempFileManager`로 업로드까지만 관리하고, `patchFile`은 별도 `useMutation`으로 정의해야 했습니다.
v2에서는 `confirmDir`을 설정하면 `confirmFiles()` 한 줄로 확정까지 처리됩니다.

타입 import도 정리됩니다.

```tsx
// v1: 훅을 사용하지 않는데 타입 때문에 훅에 의존

// v2: 타입만 독립적으로 import

```

## 8. v2가 해결한 것

| v1 한계            | v2 구현                                                 |
| ------------------ | ------------------------------------------------------- |
| 에러 시 파일 삭제  | ERROR 상태 유지 + 에러 정보 + `retryFile()`             |
| 진행률 없음        | `onUploadProgress` → `ManagedFile.progress` 실시간 추적 |
| 검증 없음          | `maxFiles`, `maxFileSize`, `accept` 설정 기반 검증      |
| 동시성 미제어      | 큐 기반 `concurrency` 제한 + `QUEUED` 상태              |
| 취소 불가          | `AbortController` 파일별 관리 + `cancelUpload()`        |
| patchFile 5곳 중복 | `confirmFiles()` + `confirmDir` 옵션                    |
| 타입 훅 종속       | `file.types.ts` 분리                                    |

1편에서 발견한 "같은 API, 다른 에러 처리 5곳"이, 설정 객체 하나와 통합된 에러 코드로 정리되었습니다.
2편에서 지적한 "에러 나면 파일이 사라지는 훅"이, 에러를 상태로 유지하고 재시도할 수 있는 훅으로 바뀌었습니다.

하지만 이 검증은 파일의 **메타데이터**(크기, 확장자, 개수)에 한정됩니다.
파일을 업로드한 뒤에야 "이 PDF는 페이지 비율이 안 맞습니다"라고 거부당하는 상황이 남아 있었습니다.

다음 편에서는 메타데이터 너머의 검증, 파일 콘텐츠를 업로드 전에 분석하는 레이어를 어떻게 설계했는지를 다룹니다.]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>파일 업로드 리팩토링기 #1. 같은 API, 다른 에러 처리 5곳</title>
          <link>https://blog.ssumi.space/blog/file-upload-refactoring-scattered-logic</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/file-upload-refactoring-scattered-logic</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>하나의 파일 API를 5개 컴포넌트에서 각각 useMutation으로 정의하고, 에러 처리가 제각각인 문제를 정리합니다.</description>
          <content:encoded><![CDATA[## 이 시리즈에 대해

사내 AI 플랫폼의 프론트엔드를 개발하고 있습니다.
이 플랫폼에서는 사용자가 PDF, 이미지 등의 문서를 업로드하면, AI가 분석하거나 학습 데이터로 활용합니다.

채팅에 파일을 첨부하거나, 학습 데이터셋을 올리거나, 평가용 문서를 등록하는 등 파일 업로드가 필요한 곳이 서비스 곳곳에 있습니다.

이 시리즈는 그 파일 업로드 코드를 정리한 과정을 다룹니다.
산발적으로 흩어져 있던 파일 관리 로직을 하나의 훅으로 통합하면서, 어떤 문제를 발견했고 어떤 설계 판단을 했는지를 기록합니다.

### 시리즈를 통해 바뀐 것

```tsx
// Before: 5곳에서 각각 useMutation 정의, 에러 처리 제각각
const { mutateAsync: patchFile } = useMutation({
  mutationFn: fileApi.patchFile,
  onError: () => toast.error(t('uploadFailed')),
});

// After: 설정 하나로 통합
const { confirmFiles, addFiles, files } = useFileStaging({
  maxFiles: 50,
  accept: ['.pdf', '.csv'],
  confirmDir: currentPath,
});
```

| Before                                | After                              |
| ------------------------------------- | ---------------------------------- |
| 같은 API를 5곳에서 각자 mutation 정의 | `useFileStaging` 하나로 통합       |
| 에러 처리 4가지 패턴 공존             | 통일된 에러 코드 + ERROR 상태 유지 |
| 에러 시 파일 삭제 → 재시도 불가       | ERROR 상태 유지 + `retryFile()`    |
| 진행률 없이 "업로드 중..."            | 파일별 0~100% 실시간 추적          |
| 50MB 올린 후에야 비율 에러            | 클라이언트 콘텐츠 검증             |

이번 1편에서는 이 파편화를 처음 발견한 시점과, 구체적으로 어떤 문제들이 있었는지를 정리합니다.

## 파일 업로드 코드가 프로젝트 곳곳에 흩어져 있었습니다

새 페이지에 파일 업로드를 붙여야 했습니다. 이미 다른 페이지에 비슷한 기능이 있으니, 그 코드를 참고하면 금방 끝날 거라고 생각했습니다.

그런데 프로젝트를 검색해보니 생각보다 많은 곳이 나왔습니다.

- 같은 파일 API를 호출하는 곳이 **11곳**
- 그중 `patchFile` 하나만 **5곳**에서 각각 별도의 `useMutation`으로 정의

하나씩 열어보니 에러 처리가 제각각이었습니다.
어떤 곳은 `ApiError`를 분기하고, 어떤 곳은 `toast.error`만 띄우고, 어떤 곳은 에러 처리 자체가 없었습니다.

MVP부터 v1까지 빠르게 구축하는 과정에서, 기존 코드를 참고해 만들어진 것들이 조금씩 달라지며 11개의 파편이 되어 있었습니다.

## 1. 배경. 하나의 컨트롤러, 11개의 사용처

프로젝트에서 파일을 다루는 곳은 생각보다 많았습니다.

- 채팅 파일 첨부
- 데이터소스 업로드
- 벡터 문서 추가
- 파인튜닝 데이터셋 업로드
- 에이전트 평가 데이터셋 업로드

최소 5개 페이지에서 파일을 다루고 있었고, 이 페이지들은 모두 동일한 백엔드 API(`fileApi`)를 호출합니다.
백엔드는 파일 업로드, 이동, 삭제, 복사, 다운로드까지 **9개 API**를 하나의 컨트롤러에서 제공하고 있었습니다.

### 1-1. 파일 업로드의 흐름

이 프로젝트에서 파일 업로드는 두 단계로 이루어집니다.

```mermaid
sequenceDiagram
    participant U as 사용자
    participant C as 프론트엔드
    participant S as 서버

    U->>C: 파일 선택
    C->>S: 1단계. postFile (임시 경로에 업로드)
    S-->>C: 임시 파일 정보 반환

    U->>C: 제출 버튼 클릭
    C->>S: 2단계. patchFile (최종 경로로 이동)
    S-->>C: 이동 완료
```

사용자가 파일을 선택하면 바로 서버에 업로드되지만, 아직 **임시 경로**에 저장된 상태입니다.
이후 사용자가 "제출" 버튼을 누르는 등 확정 액션을 취하면, 임시 경로에 있던 파일을 최종 경로로 이동시킵니다.

### 왜 임시 경로를 거칠까?

처음 이 구조를 설계할 때, 채팅 첨부파일은 일정 기간이 지나면 만료되는 것이 기획이었습니다. 확정 전 파일에 만료 기간을 두어 자동 삭제할 수 있어야 했고, 그래서 "일단 임시 저장소에 올리고, 확정되면 영구 저장소로 옮긴다"는 2단계 방식이 자리 잡았습니다.

이 흐름에서 핵심 API는 두 가지입니다.

- **`postFile`**: 임시 경로에 업로드
- **`patchFile`**: 최종 경로로 이동

이 글에서 반복적으로 등장하는 이름이니 기억해두면 좋습니다.

### postFile은 단건 API

`postFile`은 파일 **하나**를 받아 서버에 올리는 것만 처리합니다.

처음에는 데이터소스 페이지에서 파일 하나만 업로드하면 되는 상황에 맞춰 만들어졌습니다. 이후 다른 페이지에서 여러 파일을 동시에 업로드해야 하는 요구가 생겼지만, 다중 업로드 API를 새로 만드는 대신 기존 단건 API를 반복 호출하는 방식으로 빠르게 구현되었습니다.

문제는, 각 프론트엔드 페이지가 이 API들을 **독립적으로** `useMutation`을 정의하여 호출하고 있다는 점이었습니다.

## 2. 문제의 실체. 같은 API, 다른 모든 것

### 2-1. 전체 API 사용 지도

백엔드가 제공하는 9개 파일 API와 프론트엔드에서 호출하는 위치를 정리해봤습니다.

```
fileApi
├── postFile (업로드)
│   ├── 공통 훅 내부             ← useTempFileManager
│   └── 평가 데이터셋 페이지     ← 직접 mutation
│
├── patchFile (확정/이동) ← ⚠️ 5곳에서 각각 별도 mutation
│   ├── 데이터소스 페이지   → toast + 캐시 무효화
│   ├── 채팅 페이지         → 에러 처리 없음
│   ├── 문서 추가 모달      → 에러 처리 없음
│   ├── 파인튜닝 페이지     → toast + ApiError 분기
│   └── 평가 데이터셋 페이지 → toast + ApiError 분기
│
├── deleteFile (삭제)
│   └── 파일탐색기 훅       ← 별도 mutation
│
├── patchFileMove (이동)
│   └── 사이드바            ← 별도 mutation
│
├── patchName (이름 변경)
│   └── 파일탐색기 훅       ← 별도 mutation
│
├── postFileCopy (복사)
│   └── 파일탐색기 훅       ← 별도 mutation
│
├── postDirectory (디렉토리 생성)
│   └── 폴더생성 훅         ← 별도 mutation
│
├── getFile (ID로 다운로드)
│   └── 다운로드 유틸       ← 유틸 함수
│
└── getFileByPath (경로로 다운로드)
    └── 다운로드 유틸       ← 유틸 함수
```

9개 API가 11개 파일에 흩어져 있고, 특히 `patchFile`은 5곳에서 중복 정의되어 있었습니다.

이 트리를 처음 그려봤을 때, 단순히 "중복이 많다"가 아니라 **"같은 작업인데 동작이 다를 수 있겠다"**는 생각이 들었습니다.
그래서 가장 중복이 심한 `patchFile`의 에러 처리를 사용처별로 비교해봤습니다.

### 2-2. 같은 API, 다른 에러 처리

`patchFile`은 임시 경로에 업로드된 파일을 최종 경로로 이동시키는 API입니다.
5곳이 이 API를 호출하는데, 에러 처리가 전부 달랐습니다.

```tsx
// ① 데이터소스 페이지: toast만
onError: () => {
  toast.error(t('uploadFailed'));
};

// ② 파인튜닝 페이지: ApiError 분기 + toast + description
onError: (error: Error) => {
  if (error instanceof ApiError) {
    toast.error(error?.title ?? t('errorFileMoveFailed'), {
      description: error?.detail,
    });
  }
};

// ③ 채팅 페이지: 에러 처리 없음
// await로 직접 호출, try-catch 없음
fileRequests = await fileApi.patchFile({
  files: files || [],
  to_dir: '/uploads/chat',
});
```

정리하면 네 가지 패턴이 공존하고 있었습니다.

| 패턴                            | 사용처                                            |
| ------------------------------- | ------------------------------------------------- |
| `ApiError` 분기 + `toast.error` | 파인튜닝 페이지, 파일탐색기, 평가 데이터셋 페이지 |
| `toast.error`만                 | 데이터소스 페이지                                 |
| 에러 처리 없음                  | 채팅 페이지, 문서 추가 모달                       |
| `console.error`만               | 파일 복사 (파일탐색기)                            |

사용자 입장에서 보면, 같은 "파일 이동" 작업을 다른 페이지에서 수행했을 때 어떤 곳은 토스트가 뜨고 어떤 곳은 조용히 실패합니다.
같은 서비스 안에서 일관되지 않은 경험을 하게 되는 것입니다.

### 2-3. 파일 상태를 각자 관리

에러 처리만 다른 게 아니었습니다. 업로드된 파일 목록을 추적하는 방식도 사용처마다 달랐습니다.

```tsx
// 평가 데이터셋 페이지: react-hook-form fieldArray로 관리
const { fields, append, remove } = useFieldArray({ name: 'files' });

// 채팅 페이지: 파일 상태 추적 없이 await로 직접 호출
if (files && files.length > 0) {
  fileRequests = await fileApi.patchFile({ ... });
}

// 문서 추가 모달: ref에 파일 리스트 저장
const uploadFileListRef = useRef

### 2-4. 캐시 무효화 누락 위험

파일을 업로드하거나 이동한 뒤에는 관련 쿼리 캐시를 무효화해야 합니다.
파일 목록이 업데이트되어야 사용자가 방금 업로드한 파일을 볼 수 있으니까요.

그런데 어떤 쿼리 키를 무효화할지는 각 사용처가 직접 판단하고 있었습니다.
새로운 사용처가 추가될 때 캐시 무효화를 빠뜨리기 쉬운 구조입니다.

실제로 빠뜨린 곳이 있었는지는 확인하지 못했지만, **빠뜨릴 수 있는 구조 자체가 문제**라고 봤습니다.

## 3. 이 상황이 만들어진 과정

### 공통화를 미리 하지 않은 이유

이 코드를 보면 "왜 처음부터 공통 훅을 안 만들었지?"라는 생각이 들 수 있습니다.
하지만 당시 상황을 돌아보면, 공통화를 미리 하지 않은 것은 게으름이 아니라 합리적인 선택이었습니다.

MVP부터 v1까지 몇 달 만에 빠르게 구축된 서비스였습니다. 당시 상황은 이랬습니다.

- **API 스펙이 계속 변했습니다.** 백엔드와 프론트엔드가 동시에 개발을 진행했고, 파일 관련 API 스펙 자체가 안정되지 않은 상태였습니다.
  이 상태에서 공통 훅을 만들면, 스펙이 바뀔 때마다 훅을 수정해야 하고 그 훅을 사용하는 모든 곳에 영향이 갑니다.
- **담당 개발자가 달랐습니다.** 채팅은 A가, 데이터소스는 B가, 파인튜닝은 C가 개발하는 상황에서 "파일 업로드가 전체적으로 어디에서 어떻게 호출되고 있는지"를 한 사람이 파악하기 어려웠습니다.
- **공통화를 논의하는 것 자체가 리소스였습니다.** 전체 사용처를 먼저 파악해야 하는데, 초기 구축 단계에서 그 여유가 없었습니다.

### 복사가 중복이 되기까지

그래서 과정은 이렇게 흘러갔습니다.

1. 처음 데이터소스 페이지에서 파일 업로드 + 이동 로직을 구현
2. 채팅에 파일 첨부가 필요해져서 채팅 페이지에 구현. 기존 코드를 참고했지만, 에러 처리는 "나중에 붙이자"고 넘어감
3. 파인튜닝에 데이터셋 업로드가 필요해져서 또 참고. 이번에는 더 꼼꼼하게 `ApiError` 분기를 추가
4. 평가 데이터셋도 필요해져서 또 참고
5. **복사할 때마다 에러 처리, 캐시 무효화, 대상 경로가 미세하게 달라짐**

파일 관련 기능이 한꺼번에 필요해진 게 아닙니다.
페이지가 하나씩 추가되면서 필요해졌고, 각 페이지 개발자가 가장 합리적으로 할 수 있는 일은 "이미 동작하는 코드를 참고하는 것"이었습니다.

두 번째 사용처가 생겼을 때는 "아직 두 곳이니까", 세 번째가 생겼을 때는 "지금 일정이 급하니까".
이렇게 미루다 보면 어느 순간 5곳이 되어 있습니다.

## 4. 정리가 필요하다는 판단

사실 이 문제를 부분적으로 해결하기 위한 시도는 이미 있었습니다.
`useTempFileManager`라는 훅이 만들어져 있었고, 일부 사용처에서 이 훅을 통해 파일 업로드를 처리하고 있었습니다.

하지만 이 훅은 **9개 API 중 `postFile`(업로드) 하나만 래핑**하고 있었습니다.
가장 중복이 심한 `patchFile`(이동)을 포함한 나머지 API는 여전히 사용처에서 직접 호출하는 구조로 남아 있었습니다.

다음 편에서는 이 `useTempFileManager` v1이 어떻게 설계되었고, 어디까지 해결했으며, 왜 한계가 있었는지를 다룹니다.]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>배포했는데 왜 안 바뀌죠 - Version Polling으로 SPA 업데이트 알림 배너 만들기</title>
          <link>https://blog.ssumi.space/blog/version-polling-spa-update-notification</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/version-polling-spa-update-notification</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>SPA에서 배포 후에도 사용자 화면이 갱신되지 않는 문제를, Service Worker 없이 version.json 폴링과 Cache API로 해결한 과정.</description>
          <content:encoded><![CDATA[## "테스트 할 때는 강력 새로고침 부탁드립니다"

프론트엔드에서 에러 처리를 개선하고 개발서버에 배포한 뒤, 팀 채팅에 확인 요청을 남겼습니다.

> "해당 부분 수정하여 개발서버에 반영하였는데 이제는 잘 동작하는지 한번 확인 부탁드릴 수 있을까요? 테스트 할 때는 **강력 새로고침** 부탁드립니다."

배포할 때마다 이 메시지를 보내고 있었습니다. 개발서버든 운영서버든, 수정 사항을 반영한 뒤에는 항상 "강력 새로고침 해주세요"를 덧붙여야 했습니다. 강력 새로고침을 하지 않으면 이전 화면이 그대로 보이기 때문입니다.

개발팀 내부에서야 안내할 수 있지만, 실제 사용자에게 이걸 기대할 수는 없었습니다. 운영서버에서도 배포 후 "아직 이전 화면이 보인다"는 CS가 반복적으로 올라오고 있었습니다.

### SPA에서 배포가 즉시 반영되지 않는 이유

이건 SPA의 동작 방식 때문이에요.

```
MPA: 페이지 이동할 때마다 서버에 HTML을 요청
사용자: /home 클릭 → 서버에서 HTML 다운로드 (v2 반영 ✅)

SPA: 최초 1회만 번들을 다운로드, 이후는 클라이언트에서 라우팅
사용자: /home 클릭 → 이미 가진 JS로 렌더링 (v1 그대로 ❌)
```

저희 프로젝트는 React + Vite 기반 SPA입니다. SPA는 탭을 닫거나 새로고침하지 않는 한 새 코드를 받아올 기회가 없습니다. 여기에 브라우저 캐시, CDN 캐시, Service Worker 캐시까지 겹치면 일반 새로고침으로도 이전 버전이 보일 수 있습니다. 그래서 매번 "강력 새로고침"을 부탁하고 있었던 거죠.

### ClickUp에서 본 업데이트 배너

이 문제를 고민하던 중, ClickUp을 쓰면서 눈에 들어온 것이 있었습니다. 화면 하단에 "업데이트가 준비되었습니다" 배너가 뜨고, 사용자가 원할 때 새로고침하도록 유도하는 UX였습니다. 사용자의 작업 흐름을 끊지 않으면서도 새 버전을 안내하는 방식이 깔끔하다고 느꼈고, 이걸 우리 프로젝트에도 적용하기로 했습니다.

구체적으로 필요한 것은 네 가지였습니다.

| 항목         | 설명                                                      |
| ------------ | --------------------------------------------------------- |
| 감지 조건    | FE 빌드(클라이언트 번들)에 변경이 있을 때                 |
| 알림 UI      | 하단 고정 배너 + Refresh 버튼 + 닫기(X) 버튼              |
| Refresh 동작 | 강력 새로고침: 브라우저/SW 캐시를 무시하고 최신 빌드 로드 |
| 닫기 동작    | 배너 일시 숨김                                            |

이 글에서는 이 요구사항을 어떻게 설계하고 구현했는지 정리해 보겠습니다.

## 왜 Service Worker가 아닌가

이런 "새 버전 알림" UX를 구현하는 가장 널리 알려진 방법은 Service Worker예요. SW의 `onupdatefound` 이벤트를 감지해서 사용자에게 업데이트를 안내하는 패턴인데, Vite 환경에서는 [`vite-plugin-pwa`의 Prompt for Update 전략](https://vite-pwa-org.netlify.app/guide/prompt-for-update.html)이 이를 쉽게 구현할 수 있도록 지원하고 있기도 합니다.

팀 내부에서도 처음 나온 아이디어가 `vite-plugin-pwa`였습니다. Service Worker 기반의 업데이트 감지는 빌드마다 Workbox가 파일 해시를 비교해서 변경을 감지하기 때문에 정확도가 높은 편입니다.

하지만 논의를 이어가면서, 우리 프로젝트에서는 SW를 쓰기 어려운 이유를 알게 됐습니다.

- 프로젝트가 향후 마이크로프론트엔드 구조로 전환될 가능성이 있었는데, SW 캐시 범위 설정이 까다로워질 수 있었습니다
- Service Worker는 HTTPS 환경에서만 동작하는데, 온프레미스로 납품되는 서비스 특성상 고객사 환경이 HTTP일 가능성도 배제할 수 없었습니다

우리에게 필요한 건 **"새 빌드가 나왔는지 확인"** 하나뿐이었습니다. PWA 수준의 SW 관리는 이 목적에 비해 과도하다고 판단했습니다.

그래서 빌드 시 `version.json`을 생성하고, 클라이언트가 주기적으로 폴링하는 방식을 선택했습니다. 외부 의존성 없이 Vite 커스텀 플러그인으로 자체 구현할 수 있다는 점도 결정에 영향을 줬습니다. 전체 흐름은 빌드 시점과 런타임, 두 단계로 나뉩니다.

```
[빌드 시]
Vite Plugin → dist/version.json 생성 (git commit hash)
Vite define → __APP_VERSION__ 전역 상수 주입

[런타임]
60초 폴링 → fetch('/version.json') → 현재 __APP_VERSION__과 비교
→ 불일치 시 배너 표시 → 사용자 클릭 → Cache API 삭제 + location.reload()
```

이 흐름에서 가장 먼저 결정해야 했던 것은 **버전 값을 무엇으로 할 것인가**였습니다.

## 빌드 단계, 버전 값을 심는다

### git commit hash를 선택한 이유

버전 값을 뭘로 할지 고민하면서 떠올린 선택지는 세 가지였습니다.

| 방식                   | 장점                             | 단점                                                          |
| ---------------------- | -------------------------------- | ------------------------------------------------------------- |
| `Date.now()`           | 구현이 간단합니다                | 동일 코드라도 빌드마다 값이 달라져 불필요한 알림이 발생합니다 |
| `package.json` version | 의미가 명시적입니다              | 매번 수동으로 버전을 올려야 합니다                            |
| **git commit hash**    | 코드 변경 시에만 값이 변경됩니다 | git 환경이 필요합니다                                         |

`Date.now()`는 가장 간단하지만, CI에서 캐시 미스로 재빌드가 발생하는 것만으로도 사용자에게 불필요한 업데이트 알림이 뜰 수 있었습니다. `package.json` version은 명시적이지만 사람이 올려야 하므로 빠뜨릴 위험이 있었습니다.

결국 git commit hash를 선택했습니다. **코드가 실제로 바뀌지 않았는데 재빌드만으로 알림이 뜨는 것을 방지**할 수 있다는 점이 결정적이었습니다. git 환경이 필요하다는 제약이 있지만, CI/CD 파이프라인에서는 사실상 제약이 아니었습니다.

### Vite 설정

방식이 정해졌으니 코드를 보겠습니다. 빌드 시점에 두 가지를 합니다. `define` 옵션으로 현재 git hash를 전역 상수로 주입하고, 커스텀 플러그인으로 `dist/version.json` 파일을 생성합니다.

```typescript
// vite.config.ts

function getGitCommitHash() {
  return execSync('git rev-parse --short HEAD').toString().trim();
}

  define: {
    __APP_VERSION__: JSON.stringify(getGitCommitHash()),
  },
  plugins: [versionPlugin()],
});
```

`versionPlugin`은 Vite의 `writeBundle` 훅을 사용해서 빌드 산출물이 생성된 뒤 `version.json`을 함께 만듭니다.

```typescript
function versionPlugin(): Plugin {
  return {
    name: 'version-plugin',
    writeBundle(options) {
      const outDir = options.dir || 'dist';
      const version = getGitCommitHash();
      writeFileSync(`${outDir}/version.json`, JSON.stringify({ version }));
    },
  };
}
```

이렇게 하면 빌드된 JS 번들에는 `__APP_VERSION__`이 `"a1b2c3d"` 같은 문자열로 치환되어 들어가고, `dist/version.json`에도 동일한 해시가 기록됩니다. `version.json`은 빌드 산출물이므로 `.gitignore`에 추가해야 합니다.

빌드 시점의 준비는 끝났습니다. 이제 런타임에서 이 버전을 어떻게 감지하는지 보겠습니다.

## 런타임에서 새 버전을 감지한다

60초마다 `version.json`을 가져와 현재 번들의 버전과 비교하는 훅이에요.

```typescript
function useUpdateChecker() {
  const [updateAvailable, setUpdateAvailable] = useState(false);

  const checkVersion = useCallback(async () => {
    if (document.hidden) return;

    try {
      const res = await fetch(`/version.json?t=${Date.now()}`, {
        cache: 'no-store',
      });
      const { version } = await res.json();
      if (version !== __APP_VERSION__) {
        setUpdateAvailable(true);
      }
    } catch {
      // fetch 실패는 조용히 무시: 네트워크 오류로 알림을 띄우면 안 돼요
    }
  }, []);

  useEffect(() => {
    checkVersion();
    const interval = setInterval(checkVersion, 60_000);

    const onVisibilityChange = () => {
      if (!document.hidden) checkVersion();
    };
    document.addEventListener('visibilitychange', onVisibilityChange);

    return () => {
      clearInterval(interval);
      document.removeEventListener('visibilitychange', onVisibilityChange);
    };
  }, [checkVersion]);

  return updateAvailable;
}
```

이 훅에는 세 가지 판단이 들어가 있습니다.

**`document.hidden` 체크입니다.** 사용자가 다른 탭에서 작업 중일 때 폴링 요청을 보내는 건 불필요한 네트워크 비용입니다. 비활성 탭에서는 스킵하고, `visibilitychange` 이벤트로 탭이 다시 활성화되면 즉시 체크하도록 했습니다. 업무용 서비스 특성상 탭을 여러 개 열어두는 경우가 많았는데, 이 처리만으로도 불필요한 폴링이 눈에 띄게 줄었습니다.

![[스크린샷 2026-03-23 오전 10.20.34.png]]

**fetch 실패 시 조용히 무시합니다.** 네트워크가 일시적으로 끊겼다고 해서 에러를 표시할 이유가 없습니다. 다음 폴링에서 다시 시도하면 됩니다.

**폴링 주기는 60초로 정했습니다.** 30초로 줄이면 네트워크 요청이 과도해지고, 5분으로 늘리면 사용자가 오래된 화면에서 작업하다 데이터 불일치를 겪을 가능성이 높아져요. 60초가 "빠르게 알려주되, 부담 없는" 균형점이라고 판단했습니다.

그런데 버전 불일치를 감지했다고 해서 끝이 아니에요. 사용자가 실제로 새 버전을 받을 수 있어야 합니다.

## 새로고침할 때 캐시까지 지운다

사용자가 배너의 Refresh 버튼을 클릭했을 때, 단순한 `location.reload()`만으로는 부족했습니다. 브라우저가 캐시된 리소스를 다시 사용할 수 있기 때문입니다. Cache API로 SW 캐시까지 삭제한 뒤 reload합니다.

```typescript
const handleRefresh = useCallback(() => {
  if ('caches' in window) {
    caches.keys().then(names => {
      for (const name of names) caches.delete(name);
    });
  }
  window.location.reload();
}, []);
```

`caches` API가 없는 브라우저에서도 `reload()`는 정상 동작하므로, 분기 처리만으로 안전하게 대응할 수 있었습니다.

### version.json 캐시 우회

이 구현에서 가장 신경 썼던 부분이 있습니다. `version.json` 자체가 브라우저 캐시에 걸리면 업데이트를 영원히 감지하지 못합니다. 이를 방지하기 위해 두 가지 방어를 적용했습니다.

- **`cache: 'no-store'`** fetch 옵션으로 브라우저 캐시를 우회합니다
- **`?t=${Date.now()}`** 타임스탬프 쿼리를 추가해서 CDN 캐시도 우회합니다

CDN을 사용하는 환경이라면, 서버 측에서 `version.json`에 대해 `Cache-Control: no-cache` 헤더를 설정하는 것도 함께 고려해야 합니다.

## 마무리

Service Worker 없이도 `version.json` 폴링만으로 충분히 신뢰할 수 있는 업데이트 감지가 가능했습니다. Vite 플러그인 하나와 React 훅 하나로 구현이 끝나기 때문에 유지보수 부담도 적었습니다.

한계도 있습니다. 폴링 방식이므로 최대 60초의 지연이 발생합니다. WebSocket이나 Server-Sent Events를 사용하면 즉시 감지가 가능하지만, 업데이트 알림 하나를 위해 서버 인프라를 추가하는 건 과도하다고 판단했습니다.

만약 실시간성이 중요해지는 시점이 온다면, 그때 SSE 기반으로 전환하는 것도 어렵지 않을 겁니다. 폴링 로직을 SSE 구독으로 교체하면 되니까요.

## 참고

- [Vite define 옵션](https://vitejs.dev/config/shared-options.html#define): 빌드 시 전역 상수 주입
- [Vite Plugin API - writeBundle 훅](https://vitejs.dev/guide/api-plugin.html): 빌드 후 파일 생성
- [Cache API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Cache): 브라우저 캐시 프로그래밍 방식 삭제
- [Page Visibility API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API): `document.hidden`으로 탭 활성 상태 감지]]></content:encoded>
          <category>사용자 경험</category>
          <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>백엔드 에러 메시지가 화면에 그대로 나왔다 - i18n 키 매핑과 fallback으로 에러 UI 설계하기</title>
          <link>https://blog.ssumi.space/blog/http-error-i18n-fallback</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/http-error-i18n-fallback</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>API 에러 응답의 detail 메시지가 사용자 화면에 그대로 노출되는 걸 발견하고, HTTP 상태 코드 기반 i18n 키 매핑과 배열 키 fallback으로 에러 화면을 선언적으로 관리한 과정.</description>
          <content:encoded><![CDATA[## 이 메시지를 사용자가 봐도 되는 건가?

[이전 글](/blog/interceptor-error-path-unification)에서 인터셉터를 정비해 모든 에러 경로가 `ApiError`를 반환하도록 통일했습니다.
이제 `error.detail`을 안심하고 쓸 수 있게 되었는데, 막상 화면에 렌더링해보니 문제가 생겼습니다.

운영 서버에서 버그를 제보받고 디버깅을 하던 중, API 요청이 실패했을 때 화면에 표시되는 메시지를 마주쳤습니다.

`예기치 않은 서버 오류가 발생했습니다: (sqlalcheny....`

![[스크린샷 2026-03-13 오후 5.22.39.png]]

백엔드가 응답 body의 `detail` 필드에 내려주는 메시지가 그대로 렌더링되고 있었습니다.
디버깅하기에는 어떤 에러인지 한눈에 보여서 편했지만, 문득 이걸 사용자가 봐도 되는 건가 하는 생각이 들었습니다.

서버 개발자와 논의해봤습니다.
`detail` 메시지는 **개발자를 위한 디버깅 정보**이지, 사용자에게 보여줄 용도로 작성된 것이 아니라는 결론이었습니다.
그리고 생각해보니 단순히 "보기 안 좋다"는 문제만이 아니었습니다.

- 사용자가 이해할 수 없는 기술 용어와 내부 스택 정보가 그대로 노출됩니다
- 내부 경로나 클래스명 같은 구현 정보가 드러날 수 있습니다
- 백엔드 메시지는 다국어 대응이 되지 않습니다

그래서 프론트엔드가 HTTP 상태 코드를 기준으로 사용자 친화적인 메시지를 직접 관리하기로 했습니다.
에러가 났을 때 사용자의 다음 행동을 유도할 수 있도록, 401이면 "로그인이 필요합니다", 403이면 "접근 권한이 없습니다"처럼요.

## 조건문 분기의 한계, i18n 키 매핑으로

가장 먼저 떠오르는 방법은 조건문 분기입니다.

```tsx
// 상태 코드가 늘어날 때마다 코드 수정 필요
if (error.status === 400) return '잘못된 요청입니다'; // [!code highlight]
if (error.status === 401) return '인증이 필요합니다'; // [!code highlight]
if (error.status === 403) return '접근 권한이 없습니다'; // [!code highlight]
```

프로젝트에 이미 i18next가 도입되어 있었기 때문에, 조건문 대신 **상태 코드를 i18n JSON의 키로 매핑**하는 구조를 선택했습니다.
조건문 분기와 비교했을 때 이점이 명확했습니다.

- 상태 코드 추가 시 컴포넌트 코드를 수정할 필요 없이 JSON에 키-값만 추가하면 됩니다
- 다국어 지원은 언어별 JSON 파일에 같은 키 구조를 복제하면 됩니다
- 메시지 수정 시 컴포넌트 코드를 건드리지 않아도 됩니다

```json
{
  "http-error": {
    "title": {
      "400": "잘못된 요청입니다",
      "401": "인증이 필요합니다",
      "404": "페이지를 찾을 수 없습니다"
    },
    "desc": {
      "400": "요청 형식이 올바르지 않습니다. 입력 내용을 확인한 후 다시 시도해주세요.",
      "401": "로그인이 필요한 서비스입니다. 로그인 후 다시 시도해주세요."
    }
  }
}
```

컴포넌트에서는 동적 키 조회 한 줄로 해결됩니다.

```tsx
const title = t(`http-error.title.${status}`); // [!code highlight]
```

![[스크린샷 2026-03-13 오후 5.24.54.png]]

## 모든 상태 코드를 정의하지 않는다, 그래서 fallback이 필요하다

HTTP 상태 코드는 수십 개가 존재하지만, 저는 전부 개별 메시지를 정의하지 않았습니다.
이 에러 화면의 역할은 디버깅 도구가 아니라, **사용자에게 에러를 인지시키고 다음 행동을 안내하는 것**이기 때문입니다.
사용자의 행동이 달라지는 주요 코드(401→로그인, 403→권한 문의, 404→URL 확인)만 개별 메시지를 정의하고, 나머지는 기본 메시지로 충분하다고 판단했습니다.

그런데 일부만 정의하면, 서버가 422나 418 같은 미정의 코드를 내려보낼 때 문제가 생깁니다.

사실 i18next의 `t()` 함수는 배열을 받으면 순서대로 키를 탐색하고, 첫 번째로 존재하는 키의 값을 반환합니다.
저는 이 동작을 활용했습니다.

```tsx
const title = t([`http-error.title.${status}`, 'http-error.title.default']); // [!code highlight]
const desc = t([`http-error.desc.${status}`, 'http-error.desc.default']); // [!code highlight]
```

JSON에는 `default` 키만 추가하면 됩니다.

```json
{
  "http-error": {
    "title": {
      "400": "잘못된 요청입니다",
      "401": "인증이 필요합니다",
      "default": "오류가 발생했습니다"
    },
    "desc": {
      "400": "요청 형식이 올바르지 않습니다. 입력 내용을 확인한 후 다시 시도해주세요.",
      "default": "요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요."
    }
  }
}
```

정의된 코드와 미정의 코드 모두 정상 동작하는지 검증한 결과입니다.

| 시나리오    | status | title 결과                      | desc 결과                          |
| ----------- | ------ | ------------------------------- | ---------------------------------- |
| 정의된 코드 | 400    | "잘못된 요청입니다"             | "요청 형식이 올바르지 않습니다..." |
| 정의된 코드 | 500    | "서버 오류가 발생했습니다"      | "요청을 처리하는 중 문제가..."     |
| 미정의 코드 | 422    | "오류가 발생했습니다" (default) | "요청 처리 중 문제가..." (default) |
| 미정의 코드 | 418    | "오류가 발생했습니다" (default) | "요청 처리 중 문제가..." (default) |

## 마무리

백엔드의 `detail` 메시지를 사용자에게 그대로 보여주는 대신, HTTP 상태 코드를 기준으로 프론트엔드가 메시지를 관리하는 구조로 전환했습니다.
i18n JSON에 상태 코드를 키로 매핑하고, 배열 키 fallback으로 미정의 코드까지 안전하게 처리했습니다.
새 상태 코드에 대응하려면 JSON에 키-값 한 쌍만 추가하면 되고, 컴포넌트 코드는 수정할 필요가 없습니다.

물론 이 구조에도 한계는 있습니다.
같은 400이라도 맥락에 따라 다른 메시지가 필요한 경우, 상태 코드만으로는 분기할 수 없습니다.
서버가 HTTP 상태 코드 외에 커스텀 에러 코드를 사용한다면 별도 매핑 체계가 필요합니다.
현재로서는 상태 코드 기반 분기로 충분하지만, 에러 케이스가 세분화되면 확장 방법을 고민하게 될 것 같습니다.

한편, 에러 메시지를 아무리 잘 만들어도 그걸 보여줄 ErrorBoundary가 없으면 소용이 없다는 걸 곧 알게 되었습니다. 다음 글에서 다루겠습니다.

> 참고: [i18next 공식 문서 - Fallback](https://www.i18next.com/principles/fallback)]]></content:encoded>
          <category>에러 다루기</category>
          <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Zustand scoped store #1. 글로벌 store로는 안 되는 것들</title>
          <link>https://blog.ssumi.space/blog/zustand-why-scoped-store</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/zustand-why-scoped-store</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>전역상태를 남용하지 말라는 이야기는 많이 들어봤지만, 정작 왜 그런지를 Zustand 맥락에서 구체적으로 짚은 글은 드뭅니다. 글로벌 store의 세 가지 한계에서 출발해 scoped store가 필요한 이유를 정리합니다.</description>
          <content:encoded><![CDATA[**"전역상태를 남용하지 마라."**

Zustand, Redux, MobX 같은 상태관리 라이브러리를 쓰다 보면 한 번쯤 듣게 되는 이야기입니다. 그런데 정작 **왜** 남용하면 안 되는지를 구체적으로 설명하는 글은 많지 않았습니다.

"Context를 쓰세요", "서버 상태는 React Query로 분리하세요" 같은 조언은 넘쳐나지만, Zustand의 `create()`로 만든 글로벌 store가 정확히 어떤 상황에서 문제를 일으키는지를 코드로 보여주는 글은 드물었습니다.

이 시리즈는 그 질문에서 출발합니다.

1편에서는 먼저 전역상태와 지역상태의 경계를 짚고,
Zustand의 `create()`가 구체적으로 어떤 상황에서 한계를 드러내는지 세 가지 시나리오를 살펴보겠습니다.

## 전역상태는 언제 써야 할까

본격적인 한계를 이야기하기 전에, 먼저 기준을 세워보겠습니다.

모든 상태를 글로벌로 올리는 것이 나쁜 건 아닙니다.

핵심은 ==상태의 생명주기==입니다.
이 상태가 **앱 전체와 같은 생명주기를 가져야 하는가**, 아니면 특정 페이지나 컴포넌트와 함께 생겨나고 사라져야 하는가. 글로벌 store가 적합한 경우는 전자에 해당합니다.

![[state-lifecycle-diagram (3).png]]

- **앱 전체에서 하나의 인스턴스만 필요한 상태.**
  인증 정보, 테마 설정, 로케일 같은 것들은 앱에 하나만 있으면 됩니다. 여러 인스턴스가 필요할 이유가 없습니다.
- **컴포넌트 트리 외부에서 접근해야 하는 상태.**
  Axios interceptor에서 토큰을 읽거나, 라우터 가드에서 인증 상태를 확인하는 경우입니다. React Context 안에 있으면 접근할 수 없지만, 글로벌 store는 어디서든 `getState()`로 접근할 수 있습니다.
- **React 생명주기와 무관한 상태.**
  WebSocket 연결 상태나 백그라운드 동기화 상태처럼, 컴포넌트의 마운트/언마운트와 무관하게 유지되어야 하는 상태도 있습니다.

```typescript

const useCounterStore = create

TkDodo도 같은 결론에 도달했습니다.

> "I've realized that more often than not, I've needed some state to be available globally to one component subtree rather than the whole application."
>
> 참고: [TkDodo - Zustand and React Context](https://tkdodo.eu/blog/zustand-and-react-context)

앱 전체가 아니라 특정 컴포넌트 서브트리에만 상태가 필요한 경우가 실제로는 더 많다는 이야기입니다.

Kent C. Dodds도 비슷한 맥락에서 "**상태는 필요한 곳에 최대한 가까이 두라**(Keep state as close to where it's needed as possible)"고 말합니다.
각 페이지가 자신에게 필요한 상태만 가지고, 그 페이지를 벗어나면 상태도 함께 정리되는 것이 자연스러운 구조입니다.

> 참고: [Kent C. Dodds - Application State Management with React](https://kentcdodds.com/blog/application-state-management-with-react)

이 기준으로 생각해 보면, 문제가 보이기 시작합니다.

이 근본적인 불일치가 구체적으로 세 가지 한계로 드러납니다.

## 한계 1. 같은 컴포넌트를 여러 개 렌더링하면 상태가 공유된다

가장 먼저 부딪히는 문제는 **다중 인스턴스**입니다.
글로벌 store를 사용하는 컴포넌트를 화면에 여러 개 렌더링하면, 모든 인스턴스가 같은 상태를 바라봅니다.

```tsx
function App() {
  return (
    <>
       
       
       
    </>
  );
}
```

Counter 하나를 클릭하면 **세 개가 동시에 바뀝니다.**
당연한 결과입니다. `useCounterStore`는 ==모듈 스코프에 단 하나만 존재==하니까요.

컴포넌트를 아무리 여러 번 마운트해도, 그 컴포넌트들이 바라보는 store는 하나뿐입니다.
![[singleton-store-diagram (2).png]]

Counter 정도면 "그냥 로컬 state 쓰면 되지"라고 넘어갈 수 있습니다.
하지만 실제 프로젝트에서는 상황이 다릅니다.

- 워크플로우 빌더에서 각 노드마다 독립적인 편집 상태가 필요할 때
- 대시보드에서 같은 차트 위젯을 여러 개 렌더링하되, 각각 다른 필터를 적용해야 할 때
- 멀티탭 에디터에서 탭마다 독립적인 편집/저장 상태를 유지해야 할 때

이런 경우에는 상태 로직 자체가 충분히 복잡해서 단순한 `useState`로는 관리하기 어렵습니다.
Zustand의 미들웨어(`devtools`, `persist` 등)도 활용하고 싶고, selector 기반 구독으로 리렌더를 최적화하고 싶습니다.

그런데 글로벌 store로는 **인스턴스별 독립 상태**를 만들 수 없습니다.

"그러면 store를 여러 개 만들면 되지 않을까?" 싶을 수도 있습니다. `createStore1`, `createStore2`를 따로 만드는 식으로요. 하지만 인스턴스 수가 동적으로 결정되는 상황(노드 개수, 탭 개수)에서는 이 접근도 통하지 않습니다.

이 문제를 고민하다 보면 자연스럽게 두 번째 한계에 도달합니다.

## 한계 2. 부모에서 받은 props로 store를 초기화할 수 없다

TkDodo가 이 문제를 정확히 짚었습니다.

> "Global stores are created outside of the React Component lifecycle, so we can't initialize the store with a value we get as a prop."
>
> 참고: [TkDodo - Zustand and React Context](https://tkdodo.eu/blog/zustand-and-react-context)

`create()`는 ==모듈이 평가되는 시점==에 호출됩니다.
이 시점에는 React 컴포넌트가 아직 렌더링되지 않았으므로, props에 접근할 방법이 없습니다.

서버에서 받아온 데이터로 에디터의 초기 상태를 세팅하거나, `userId`에 따라 대시보드를 초기화해야 하는 상황에서 난감해집니다.

이를 우회하는 가장 흔한 패턴은 `useEffect`로 props를 store에 밀어넣는 것입니다.

```tsx
function Editor({ initialContent }: { initialContent: string }) {
  const setContent = useEditorStore(s => s.setContent);

  useEffect(() => {
    setContent(initialContent);
  }, [initialContent, setContent]);

  return ;
}
```

동작은 합니다. 하지만 두 가지 문제가 따라옵니다.

- **첫 렌더에서 깜빡임이 발생합니다.**
  컴포넌트가 처음 마운트될 때 store에는 기본값(빈 문자열)이 들어 있고, `useEffect`가 실행된 뒤에야 `initialContent`로 바뀝니다.
  사용자 눈에는 빈 화면이 한 프레임 보였다가 내용이 채워지는 것으로 보입니다.

- **"초기화"가 아니라 "지속적 동기화"가 되어버립니다.**
  `useEffect`의 의존성 배열에 `initialContent`가 들어 있기 때문에, 이 prop이 바뀔 때마다 store가 덮어씌워집니다.
  사용자가 에디터에서 내용을 수정하는 도중에 부모 컴포넌트가 리렌더되면서 `initialContent`가 다시 전달되면, 수정 중이던 내용이 날아갈 수 있습니다.

의존성 배열에서 `initialContent`를 빼면 동기화 문제는 해결되지만, ESLint의 `exhaustive-deps` 규칙이 경고를 내고, 의도가 코드에서 드러나지 않습니다.

근본적으로 **store 생성 시점에 초기값을 주입하는 방법**이 필요하다는 생각이 들었습니다.

그리고 이 두 가지 한계가 세 번째 문제와 만나면, 글로벌 store의 구조적 한계가 더욱 분명해집니다.

## 한계 3. 테스트 간 store 상태가 누수된다

글로벌 store는 모듈 스코프에 존재하므로, 테스트 간에도 상태가 공유됩니다.

```typescript
// 테스트 A
it('adds item to cart', () => {
  useCartStore.getState().addItem({ id: 1, name: 'Widget' });
  expect(useCartStore.getState().items).toHaveLength(1);
});

// 테스트 B: 이전 테스트의 상태가 남아 있다
it('starts with empty cart', () => {
  // 실패! items에 이미 Widget이 들어 있음
  expect(useCartStore.getState().items).toHaveLength(0);
});
```

테스트 A에서 추가한 아이템이 테스트 B에서도 그대로 남아 있습니다.
==테스트 실행 순서에 따라 결과가 달라지는==, 가장 디버깅하기 어려운 종류의 버그입니다.

일반적인 우회 방법은 `beforeEach`에서 store를 수동으로 리셋하는 것입니다.

```typescript
beforeEach(() => {
  useCartStore.setState({ items: [], total: 0 });
  useUserStore.setState({ user: null, isAuthenticated: false });
  useSettingsStore.setState({ theme: 'light', locale: 'ko' });
  // store가 늘어날 때마다 여기에 추가해야 한다...
});
```

store가 두세 개일 때는 관리할 만합니다.

하지만 store가 10개, 20개로 늘어나면 어떻게 될까요.
새 store를 만들 때마다 모든 테스트 파일의 `beforeEach`를 업데이트해야 하고, 하나라도 빠뜨리면 테스트가 간헐적으로 실패합니다.

근본적으로 **store 인스턴스 자체가 테스트 단위로 격리**되어야 한다고 느꼈습니다.
각 테스트가 자신만의 store를 가지면 리셋을 신경 쓸 필요가 없으니까요.

여기까지 정리하면, 세 가지 한계 모두 같은 근본 원인을 가리킵니다.

> `create()`가 만드는 store는 **모듈 스코프의 싱글턴**입니다.

- 싱글턴이기 때문에 → **다중 인스턴스를 만들 수 없고**
- 모듈 평가 시점에 생성되기 때문에 → **props로 초기화할 수 없고**
- 테스트 간에 공유되기 때문에 → **상태가 누수된다**

그렇다면 이 문제에 대한 zustand의 공식 답은 무엇이었을까요.

## zustand/context의 역사, 있었는데 사라졌다

사실 zustand는 이 문제를 인식하고 있었고, 한때 공식 솔루션을 제공하기도 했습니다.

| 버전 | zustand/context 상태                                           |
| ---- | -------------------------------------------------------------- |
| v3   | 도입: React Context 기반 scoped store 패턴 제공                |
| v4   | deprecated: "직접 만들어 쓰라"는 방향으로 전환                 |
| v5   | **완전 제거**: 패키지 exports에서 `context` 모듈 자체가 사라짐 |

v5의 exports를 확인해 보면 `vanilla`, `react`, `middleware`, `shallow`, `traditional`만 존재하고 `context`는 없습니다.

zustand 메인테이너인 dai-shi는 GitHub Discussion에서 이 결정의 배경을 밝혔습니다.

> 참고: [Zustand GitHub Discussion #1276 - createContext deprecation](https://github.com/pmndrs/zustand/discussions/1276)

zustand 팀의 입장은 명확했습니다.
**라이브러리를 최소한으로 유지하고, Context 기반 패턴은 사용자가 직접 구현하도록 맡기겠다**는 것이었습니다.

커뮤니티 반응은 갈렸습니다. 일부는 core에 남겨야 한다고 주장했고, 일부는 별도 유틸리티로 충분하다고 동의했습니다.

> 참고: [Zustand 공식 문서 - Setup with Next.js](https://zustand.docs.pmnd.rs/learn/guides/nextjs) (Next.js 가이드에서 per-request store 패턴으로 이 보일러플레이트를 보여줍니다)

## 정리하며

글로벌 store가 적합한 경우와 그렇지 않은 경우를 고민하면서, 제가 찾은 판단 기준은 결국 **생명주기**였습니다.

> - 인증이나 테마처럼 **앱 전체와 생명주기를 함께하는 상태** → 글로벌 store가 맞습니다.
> - 특정 페이지나 컴포넌트에서만 쓰이고, **그 페이지를 떠나면 사라져야 하는 상태** → 글로벌에 올릴 이유가 없습니다.

후자를 글로벌에 넣으면 상태가 필요 이상으로 오래 살아남으면서 예기치 못한 문제를 만듭니다.
그리고 `create()`는 이 생명주기 불일치를 해결할 수 없습니다.

![[convergence-diagram (1).png]]

| 한계               | 근본 원인                   | 필요한 것                                |
| ------------------ | --------------------------- | ---------------------------------------- |
| 다중 인스턴스 불가 | 모듈 스코프 싱글턴          | 컴포넌트 트리 단위로 store 인스턴스 생성 |
| props 초기화 불가  | 모듈 평가 시점에 store 생성 | 렌더 시점에 props를 캡처하여 store 생성  |
| 테스트 격리 불가   | 테스트 간 store 공유        | 테스트 단위로 독립적인 store 인스턴스    |

세 가지 모두 **"store 인스턴스를 React 컴포넌트 생명주기에 맞춰 생성하고 관리하는 것"**으로 해결할 수 있어 보입니다.
그리고 그 메커니즘으로 가장 자연스러운 것이 React Context라고 생각했습니다.

다음 글에서는 `createStore()`와 React Context를 사용해 이 문제들을 실제로 해결하는 과정을 다룹니다.
`create()`와 `createStore()`의 차이부터 시작해서, Context에 store 인스턴스를 담는 패턴을 단계별로 구현해 보겠습니다.

하지만 미리 말해두자면, 이 패턴에는 반복되는 보일러플레이트가 따라옵니다.
그 문제는 시리즈 3편에서 다루겠습니다.

---

## 레퍼런스

- [TkDodo - Zustand and React Context](https://tkdodo.eu/blog/zustand-and-react-context)
- [Kent C. Dodds - Application State Management with React](https://kentcdodds.com/blog/application-state-management-with-react)
- [Zustand GitHub Discussion #1276 - createContext deprecation](https://github.com/pmndrs/zustand/discussions/1276)
- [Zustand 공식 문서 - Setup with Next.js](https://zustand.docs.pmnd.rs/learn/guides/nextjs)]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Zustand scoped store #2. React Context로 지역 store 만들기</title>
          <link>https://blog.ssumi.space/blog/zustand-context-scoped-store</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/zustand-context-scoped-store</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>create()와 createStore()의 차이를 이해하고, React Context와 조합해 컴포넌트 단위로 격리된 store를 직접 구현합니다.</description>
          <content:encoded><![CDATA[1편에서 글로벌 store의 세 가지 한계를 살펴봤습니다.

- 같은 컴포넌트를 여러 개 렌더링하면 상태가 공유되고
- 부모에서 받은 props로 store를 초기화할 수 없으며
- 테스트 간 store 상태가 누수되는 문제였습니다

이번 글에서는 이 문제들을 실제로 해결한 과정을 정리합니다.
React Context와 zustand의 `createStore()`를 조합해서 컴포넌트 단위로 격리된 store를 만들었습니다.

구현 자체는 어렵지 않았는데, 그 과정에서 `create()`와 `createStore()`의 차이, Context로 store를 공유할 때의 리렌더 동작 같은 개념을 먼저 정리할 필요가 있었습니다.

## create()와 createStore(), 왜 두 개가 있을까

zustand를 쓰면서 `create()`만 써본 분이 많을 겁니다. 저도 그랬습니다. 그런데 scoped store를 만들려면 `createStore()`라는 다른 함수를 써야 합니다.

처음 참고한 문서는 [zustand Next.js 가이드](https://zustand.docs.pmnd.rs/learn/guides/nextjs#initializing-the-store)였는데, 여기서 `createStore()`를 사용하는 걸 보고 `create()`와 뭐가 다른 건지 궁금해졌습니다. 그래서 이 둘의 차이를 먼저 살펴봤습니다.

```typescript
// create(): zustand에서 import
// 반환값이 React hook입니다

const useMyStore = create

![[context-zustand-architecture.png]]

TkDodo의 글에서 이 구분을 잘 설명하고 있었습니다.

> "The idea is to merely share the store instance via React Context - not the store values themselves."

> 참고: [TkDodo - Zustand and React Context](https://tkdodo.eu/blog/zustand-and-react-context)

이게 왜 중요한지 조금 더 풀어 보면 이렇습니다.

React Context는 value가 바뀔 때 모든 Consumer를 리렌더합니다.
그래서 Context에 자주 바뀌는 값을 넣으면 성능 문제가 생기죠.

하지만 여기서 넣는 건 store 인스턴스, 즉 `StoreApi` 객체 자체입니다.
이 객체의 참조는 Provider가 마운트된 이후 절대 바뀌지 않습니다.

그렇다면 store 안의 값이 바뀔 때 컴포넌트 리렌더는 누가 일으킬까요?

zustand의 내부 subscription 시스템이 처리합니다.
zustand는 내부적으로 [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)를 사용하여 store 값 변경을 감지하고, selector를 통해 구독한 값이 바뀌었을 때만 해당 컴포넌트를 리렌더합니다.

이 패턴은 사실 새로운 것이 아닙니다. React Query의 `QueryClientProvider`가 `QueryClient` 인스턴스를, Redux의 `Provider`가 store 인스턴스를 Context로 공유하는 것과 동일한 구조입니다.

정리하면 역할이 이렇게 나뉩니다.

- **Context의 역할**: "어떤 store 인스턴스를 사용할지" 컴포넌트 트리에 알려주는 것
- **zustand의 역할**: "store 값이 바뀌었을 때 어떤 컴포넌트를 리렌더할지" 결정하는 것

이 구분이 잡히고 나니, 구현 코드가 자연스럽게 읽히기 시작했습니다.

## 직접 만들어 보기

### Step 1. createStore로 팩토리 함수 만들기

store를 생성하는 함수를 먼저 만들었습니다.
이 함수가 호출될 때마다 ==새로운 store 인스턴스==가 만들어지는데, 이것이 글로벌 `create()`와의 핵심 차이입니다.

```typescript

type CounterState = {
  count: number;
  inc: () => void;
  dec: () => void;
};

const createCounterStore = (initialCount = 0) =>
  createStore

setter를 사용하지 않으므로 store 인스턴스는 Provider가 살아 있는 동안 절대 바뀌지 않습니다.

`initialCount` props를 받아서 `createCounterStore`에 넘기는 부분이 앞서 심어둔 복선의 회수입니다.
`useEffect` 없이, **첫 렌더부터 원하는 초기값으로 store가 동작합니다.**

한 가지 궁금할 수 있는 점이 있습니다. `useRef`로 store를 초기화하는 코드를 본 적이 있으실 겁니다.
`zustand/context`가 제거된 뒤 커뮤니티에서 논의된 대안 구현에서 이 패턴을 사용합니다.

```tsx
// 커뮤니티 구현의 useRef 패턴: 동작은 하지만 시맨틱 보장이 약함
const storeRef = useRef
  );
}
```

글로벌 `create()` 패턴과 비교하면, 컴포넌트 코드는 거의 동일합니다.
`useCounterStore(selector)` 호출 형태도 같고, selector로 필요한 값만 구독하는 것도 같습니다.

달라진 건 store가 Provider 단위로 격리된다는 점뿐입니다.

이 차이가 1편에서 다뤘던 세 가지 한계를 어떻게 해결하는지 확인해 봤습니다.

## 1편의 세 가지 한계, 해결 확인

### 다중 인스턴스, Provider를 여러 번 쓰면 된다

```tsx
<div style={{ display: 'flex', gap: '2rem' }}>
  
  
  
</div>
```

Provider를 세 번 렌더링하면, `useState`가 세 번 호출되고, `createCounterStore`도 세 번 호출됩니다.
각 Provider 안의 `CounterDisplay`는 **자기만의 store 인스턴스**를 바라봅니다.

첫 번째 카운터를 올려도 나머지 둘에는 아무 영향이 없습니다.

1편에서 글로벌 `create()`로는 이것이 불가능했습니다. 모듈 스코프에 store가 단 하나만 존재했으니까요.

![[global-vs-scoped-store (1).png]]

### Props 초기화, useEffect 없이 깜빡임 없이

```tsx
function EditorPage({ initialContent }: { initialContent: string }) {
  return (
    
  );
}
```

Provider의 `useState` lazy initializer가 `initialContent`를 캡처하여 store를 생성합니다.
컴포넌트가 마운트되는 순간부터 올바른 초기값으로 동작하기 때문에, `useEffect` 동기화에서 발생하던 **첫 렌더 깜빡임이 사라집니다.**

1편에서 이 문제를 "초기화와 동기화는 다르다"고 표현했는데, 이 패턴은 정확히 **"초기화"만** 수행합니다.

### 테스트 격리, 매 테스트마다 새 store

```tsx
function renderWithStore(ui: ReactElement, initialCount = 0) {
  return render(
    
  );
}

it('increments', () => {
  renderWithStore(, 0);
  fireEvent.click(screen.getByText('+'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

it('starts from initial', () => {
  renderWithStore(, 42);
  expect(screen.getByText('42')).toBeInTheDocument();
  // 이전 테스트의 상태와 완전히 격리됨
});
```

각 테스트가 `renderWithStore`를 호출할 때마다 새로운 `CounterProvider`가 마운트되고, 새로운 store 인스턴스가 생성됩니다.
`beforeEach`에서 수동으로 state를 초기화할 필요가 없습니다.

테스트가 끝나면 Provider가 언마운트되면서 store도 자연스럽게 사라집니다.

## 그런데, store를 하나 더 만들어야 한다면

그런데 문제가 하나 남아 있었습니다. Counter store를 만드는 데 필요했던 코드를 돌아보면 이렇습니다.

1. `createCounterStore` 팩토리 함수
2. `CounterStoreContext`: `createContext` 호출
3. `CounterProvider`: Context.Provider를 감싸는 컴포넌트
4. `useCounterStore`: `useContext` + `useStore`를 조합하는 커스텀 hook

프로젝트에 scoped store가 Counter 하나뿐이라면 이 정도는 괜찮습니다.

  </CompareSection>
</ComparePanel>

하지만 실제 프로젝트에서는 에디터 store, 폼 store, 대시보드 패널 store...
scoped store가 3개, 5개로 늘어나면 2~4번을 매번 복사해서 타입만 바꿔 붙여넣게 됩니다.

Context 생성, null 체크, 에러 메시지, Provider 컴포넌트. 이 보일러플레이트는 store마다 구조가 동일하고, ==달라지는 건 State 타입과 팩토리 함수뿐==입니다.

다음 글에서는 이 반복을 약 40줄짜리 유틸리티 하나로 제거한 과정을 다룹니다.
`useState` vs `useRef` 초기화 방식의 시맨틱 차이, selector 필수 여부, 에러 메시지 개선 같은 작지만 중요한 설계 결정들이 있었습니다.

---

## 레퍼런스

- [Zustand 공식 문서 - createStore](https://zustand.docs.pmnd.rs/apis/create-store)
- [Zustand 공식 문서 - useStore](https://zustand.docs.pmnd.rs/hooks/use-store)
- [Zustand 공식 문서 - Initialize state with props](https://zustand.docs.pmnd.rs/learn/guides/initialize-state-with-props)
- [Zustand GitHub Discussion #1276 - createContext deprecation](https://github.com/pmndrs/zustand/discussions/1276)
- [Zustand GitHub Discussion #1975 - create vs createStore for context](https://github.com/pmndrs/zustand/discussions/1975)
- [TkDodo - Zustand and React Context](https://tkdodo.eu/blog/zustand-and-react-context)
- [TkDodo - useState for one-time initializations](https://tkdodo.eu/blog/use-state-for-one-time-initializations)
- [React 공식 문서 - Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks)
- [React 공식 문서 - useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore)
- [React 공식 문서 - useState (lazy initializer)](https://react.dev/reference/react/useState#avoiding-recreating-the-initial-state)]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>Zustand scoped store #3. 유틸리티, 직접 만들어 쓰기</title>
          <link>https://blog.ssumi.space/blog/zustand-create-zustand-context</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/zustand-create-zustand-context</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>zustand/context가 사라진 v5에서, GitHub Discussion과 TkDodo 블로그를 참고해 ~40줄의 createZustandContext 유틸리티를 설계한 과정과 각 결정의 이유</description>
          <content:encoded><![CDATA[2편에서 `createStore()` + React Context 조합으로 세 가지 한계를 해결했습니다.
다중 인스턴스, props 초기화, 테스트 격리 모두 동작합니다.

그런데 프로젝트에 scoped store가 하나둘 늘어나면서 불편함이 생겼습니다.
store를 하나 추가할 때마다 Context 생성, Provider 컴포넌트 작성, custom hook 정의를 반복하고 있었습니다.

구조는 동일한데 타입과 이름만 다릅니다.

      
    </CompareGrid>
    
  </CompareSection>
  
      
    </CompareGrid>
    
  </CompareSection>
</ComparePanel>

```tsx
{
  /* CounterStore를 위한 Context, Provider, hook */
}
const CounterContext = createContext;
}

function useCounterStore

store 인스턴스가 두 번 생성되면 구독이 어긋나고, 디버깅하기 극도로 어려운 버그가 됩니다.
**"아마 괜찮을 것"보다 "React가 보장하는 것"**에 기대는 쪽을 택했습니다.

    
  </CompareSection>
  
    
    
  </CompareSection>
</ComparePanel>

### useStoreWithEqualityFn을 zustand/traditional에서 가져온 이유

zustand/react의 표준 `useStore`는 `Object.is`로만 리렌더 여부를 판단합니다.
객체를 반환하는 selector를 쓰면, 내부 값이 같더라도 참조가 다르므로 매번 리렌더가 발생합니다.

```typescript
// zustand/react의 useStore: equalityFn 없음
useStore(store, s => ({ count: s.count, name: s.name }));
// 매 상태 변경마다 새 객체 → Object.is는 항상 false → 항상 리렌더
```

`zustand/traditional`의 [`useStoreWithEqualityFn`](https://zustand.docs.pmnd.rs/hooks/use-store-with-equality-fn)은 내부적으로 `useSyncExternalStoreWithSelector`를 사용합니다.
[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)의 확장 버전으로, selector와 equalityFn을 추가로 받아 커스텀 비교 로직을 적용할 수 있게 해줍니다.

```typescript
// useStoreWithEqualityFn 내부의 핵심
useSyncExternalStoreWithSelector(
  api.subscribe,
  api.getState,
  api.getInitialState,
  selector,
  equalityFn // 이 인자가 핵심
);
```

이 덕분에 `useShallow`나 커스텀 비교 함수와 조합할 수 있습니다.

글로벌 store에서 `useShallow`를 쓰던 개발자가 scoped store로 전환했을 때, 같은 패턴이 그대로 동작해야 합니다.

`useStoreWithEqualityFn`을 쓰지 않으면 이 호환성이 깨집니다.

### selector를 필수로 만든 이유

커뮤니티 구현에서는 selector를 생략할 수 있습니다. selector 없이 호출하면 전체 state를 반환합니다.

```typescript
// 커뮤니티 구현: selector 생략 가능
const state = useStore();
// 전체 state 구독 → store의 어떤 필드가 바뀌어도 리렌더
```

편리해 보이지만, ==전체 state 구독은 거의 항상 실수==입니다.
컴포넌트가 `count`만 필요한데 `name`이 바뀌어도 리렌더가 발생합니다.

프로젝트 초기에는 문제없어 보이다가, state가 커지면서 성능 이슈로 돌아옵니다.

그래서 저는 selector를 필수로 강제하도록 했습니다.

```typescript
// 제 구현: selector 없이 호출하면 TypeScript 에러
const count = useStore(s => s.count); // OK
const state = useStore(); // TS Error
```

selector를 강제하면 컴포넌트가 **"어떤 상태에 의존하는지"를 명시적으로 선언**하게 됩니다.
코드 리뷰에서 불필요한 구독을 발견하기 쉬워지고, 리렌더 최적화가 기본값이 됩니다.

### name 옵션으로 에러 메시지가 달라진다

커뮤니티 구현의 에러 메시지는 범용적입니다.

```
"Seems like you have not used zustand provider as an ancestor."
```

scoped store가 하나일 때는 이것으로 충분합니다.

하지만 store가 여러 개인 프로젝트에서는 어떤 Provider가 빠져 있는지 알 수 없습니다.
저는 `name` 옵션을 추가해서 에러 메시지에 Provider 이름을 포함시켰습니다.

```
"useStore must be used within 

기능을 빼는 것은 부족해서가 아니라, 일관된 사용 패턴을 유지하기 위한 선택이었습니다.

### React 19 호환, Context.Provider 대신 Context 직접 사용

React 19부터 `;
```

이 변경으로 JSX가 더 직관적이 됩니다.
미래의 deprecation 경고도 미리 피할 수 있습니다.

## 최종 구현 (전체 ~40줄)

여섯 가지 설계 결정을 모두 반영한 최종 코드입니다.

```typescript

type CreateZustandContextOptions = {
  name?: string;
};

  options?: CreateZustandContextOptions
) {
  const StoreContext = createContext;
  }
  Provider.displayName = `${name}.Provider`;

  function useStore;
```

`devtools`, `persist`, `immer` 등 어떤 미들웨어든 `createStore` 내부에서 조합하면 됩니다.
유틸리티는 store의 내부 구조에 관여하지 않고, StoreApi 인터페이스만 요구합니다.

## 주의사항과 한계

이 유틸리티가 모든 상황을 커버하지는 않았습니다.
사용하면서 알게 된 제약들을 정리해 봅니다.

### Provider 마운트 후 store 교체 불가

`useState`로 초기화하므로, Provider가 마운트된 뒤에는 store 인스턴스를 교체할 수 없습니다.
이것은 의도한 동작입니다.

"초기화"는 한 번만 일어나야 하고, 이후 상태 변경은 store의 `setState`를 통해야 합니다.

만약 props 변경에 따라 store를 완전히 재생성해야 한다면, `key` prop으로 Provider를 리마운트하면 됩니다.

```tsx

```

`key`가 바뀌면 React가 기존 컴포넌트를 언마운트하고 새로 마운트하므로, **`useState`의 초기화가 다시 실행됩니다.**

### 중첩 Provider

같은 Context를 중첩하면 React의 기본 동작대로 가장 가까운 Provider의 store를 사용합니다.

```tsx

</CounterProvider>
```

### 글로벌 store가 더 적합한 경우

1편에서 다뤘지만 다시 짧게 정리해 봅니다.
판단 기준은 ==상태의 생명주기==입니다.

앱 전체와 같은 생명주기를 가져야 하는 상태라면 여전히 글로벌 `create()`가 맞습니다.

- **앱과 생명주기를 함께하는 상태.** 인증, 테마, 로케일처럼 앱이 시작할 때 생겨나고, 앱이 종료될 때 사라지는 상태입니다.
- **컴포넌트 트리 외부에서 접근해야 하는 상태.** Axios interceptor 등에서 접근이 필요하다면, React 생명주기 밖에서도 살아있어야 합니다.
- **React 생명주기와 무관하게 유지되어야 하는 상태.** WebSocket 연결이나 백그라운드 동기화처럼, 컴포넌트의 마운트/언마운트와 무관한 상태입니다.

반대로, 특정 페이지나 컴포넌트와 함께 생겨나고 사라져야 하는 상태라면 scoped store가 적합합니다.
scoped store는 글로벌의 "대체"가 아니라 "보완"이라는 점은, 시리즈 전체를 관통하는 전제라고 생각합니다.

## 시리즈를 돌아보며

1편에서 글로벌 store의 세 가지 한계를 확인했고, 2편에서 `createStore()` + React Context로 해결했으며, 이 글에서 반복되는 보일러플레이트를 **~40줄 유틸리티로 제거**했습니다.

시리즈를 진행하면서 세 가지 생각이 남았습니다.

- **검증된 커뮤니티 패턴의 인터페이스를 따르되, 프로젝트의 철학에 맞게 커스터마이징하는 것이 가장 실용적이라고 느꼈습니다.** Discussion에서 수렴한 `[Provider, useStore]` 인터페이스는 그대로 가져왔지만, 초기화 방식(`useState`), selector 정책(필수), 에러 메시지(`name` 옵션)는 프로젝트에 맞게 바꿨습니다.

- **"무엇을 제공하지 않을지"가 API 설계의 핵심이었습니다.** `useStoreApi`를 배제한 것은 기능이 부족해서가 아니라, selector를 통한 선언적 접근만 허용하겠다는 설계 철학입니다. 기능을 추가하는 것보다 빼는 결정이 더 오래 고민한 부분이었습니다.

- **레퍼런스가 여러 개일 때, 각각에서 "왜" 그렇게 했는지를 이해하면 자연스럽게 최선의 조합이 보였습니다.** 공식 docs에서 패턴의 정답을 확인하고, 커뮤니티 Discussion에서 인터페이스를 가져오고, TkDodo 블로그에서 초기화의 안전성을 배웠습니다. "왜"를 이해하지 않고 코드만 가져왔다면, `useRef`와 `useState`의 차이를 모른 채 동작하는 코드를 쓰고 있었을 것입니다.

---

## 레퍼런스

- [Zustand GitHub Discussion #1276 - createContext deprecation](https://github.com/pmndrs/zustand/discussions/1276)
- [TkDodo - Zustand and React Context](https://tkdodo.eu/blog/zustand-and-react-context)
- [TkDodo - useState for one-time initializations](https://tkdodo.eu/blog/use-state-for-one-time-initializations)
- [React 19 Release Notes - Context as provider](https://react.dev/blog/2024/12/05/react-19)
- [Zustand 공식 docs - Initialize state with props](https://zustand.docs.pmnd.rs/learn/guides/initialize-state-with-props)]]></content:encoded>
          <category>설계와 구조</category>
          <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>F12 누르면 비밀번호가 보인다 - 프론트엔드에서 SHA-256 해싱으로 로그인 평문 노출 막기</title>
          <link>https://blog.ssumi.space/blog/frontend-sha256-login-hashing</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/frontend-sha256-login-hashing</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>로그인 보안 태스크를 받고, SHA-256 해싱을 처음 접하며 하나씩 질문하고 답을 찾아간 과정을 공유합니다.</description>
          <content:encoded><![CDATA["비밀번호가 평문으로 노출되고 있으니 SHA-256으로 해싱하라."

처음 받아본 보안 태스크였습니다. 해싱이나 암호화에 대해서는 알고있었지만, SHA-256이 정확히 뭔지는 몰랐습니다.
검색하면 salt, PBKDF2, 레인보우 테이블 같은 용어가 쏟아졌고, "이거 프론트엔드가 할 일인가?" 싶기도 했습니다. HTTPS가 있으니 전송 구간은 안전한 거 아닌가, 라고 생각했거든요.

결국 하나씩 질문하면서 답을 찾아갔습니다.

- SHA-256이 대체 뭔가
- salt 없이 해싱하면 위험하지 않나
- 서버도 비밀번호를 모르나
- 기존에 저장된 비밀번호는 어떻게 되나

로그인을 구현할 때 보안을 신경쓰고 싶은 프론트엔드 개발자에게 도움이 될까 싶어, 그 과정을 정리해보았습니다.

---

## 먼저, 문제가 뭐였나

사내 서비스가 GS 인증(정부 및 공공기관에 납품하기 위해 필요한 소프트웨어 품질 인증)을 앞두고 있었습니다. 보안 점검 과정에서 프론트엔드 팀에 떨어진 태스크가 이것이었습니다.

> **[로그인] 로그인 시 해당 계정 비밀번호가 노출되고 있다**

F12를 열어 보면 상황이 바로 보입니다.

![[스크린샷 2026-03-12 오후 5.29.34.png]]

HTTPS는 네트워크 전송 구간의 암호화일 뿐, 브라우저 DevTools에서는 복호화된 요청 본문이 그대로 노출됩니다. 화면을 잠깐 들여다보거나 화면 공유 중이라면, 비밀번호가 그대로 보이는 것이죠. "전송 중 암호화"와 "요청 본문의 평문 노출"은 별개의 문제였습니다.

해결 방향은 태스크에 적혀 있었지만, 해싱이라는 개념이 익숙하지 않았기 때문에 구현보다 이해가 먼저였습니다.

---

## SHA-256이 대체 뭔가

가장 먼저 부딪힌 질문이었습니다. 태스크에는 "SHA-256으로 해싱하라"고만 적혀 있었는데, 해싱이 뭔지부터 알아야 했습니다.

SHA-256은 어떤 텍스트를 넣으면 항상 같은 길이(64자리 hex 문자열)의 "지문"을 만들어주는 **일방향 함수**입니다. 같은 입력이면 항상 같은 결과가 나오지만, 결과로부터 원본을 역추적하는 것은 수학적으로 불가능합니다.

> 참고: [SHA-2 - Wikipedia](https://en.wikipedia.org/wiki/SHA-2)

```
"qwer3344@" → "f7c3bc1d808e04732adf679965ccc34ca7ae3441..."
```

암호화라고 하면 보통 "암호화 ↔ 복호화"를 떠올리는데, **해싱은 복호화가 없는 암호화**입니다. 원본을 알면 해시를 구할 수 있지만, 해시만 보고 원본을 알아내는 건 불가능하죠. 그래서 네트워크 탭에 해시값이 찍히더라도 비밀번호 자체는 노출되지 않습니다.

그런데 **"같은 입력이면 항상 같은 결과"** 라는 성질이 오히려 걸렸습니다. 누군가 흔한 비밀번호들의 해시값을 미리 모아두면, 해시만 보고 원본을 찾아낼 수 있는 거 아닌가.

실제로 SHA-256 해싱을 조사하면서 "salt 없이 해싱하면 위험하다"는 경고를 자주 봤습니다.

---

## salt 없이 해싱하면 위험하지 않나

사실 이 질문 때문에 한동안 구현을 시작하지 못했습니다. "salt 없이 SHA-256만 쓰면 레인보우 테이블 공격에 취약하다"는 글을 읽고, 태스크의 방향 자체가 불완전한 건 아닌가 의심했거든요.

> 참고: [Password Storage Cheat Sheet - OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)

레인보우 테이블이란 흔한 비밀번호들을 미리 해싱해둔 "정답지" 같은 것입니다.

```
"password123" → "ef92b778..."
"qwer3344@"  → "a1b2c3d4..."
```

공격자가 이 테이블을 가지고 있으면, 해시값을 역으로 찾아볼 수 있습니다. salt(임의의 랜덤 문자열)를 비밀번호에 덧붙여 해싱하면, 같은 비밀번호라도 완전히 다른 해시가 나오기 때문에 테이블이 무력화됩니다.

그런데 이걸 조금 더 들여다보니, **프론트엔드에서는 이게 문제가 되지 않는다**는 걸 알게 되었습니다. 프론트엔드와 서버는 해싱의 목적 자체가 다르기 때문입니다.

- **프론트엔드 해싱의 목적**: 브라우저 네트워크 탭에서 평문 비밀번호가 그대로 보이는 것을 막는 것
- **서버 해싱의 목적**: DB가 유출되더라도 비밀번호 원본을 보호하는 것 (salt + PBKDF2)

레인보우 테이블 공격이 위험한 건 DB에 저장된 해시가 유출될 때이고, 그건 서버가 salt로 방어합니다. 프론트엔드는 네트워크 구간의 평문 노출만 막으면 되기 때문에, salt 없는 SHA-256으로 충분했습니다.

> 📌 프론트엔드는 "네트워크 구간에서 평문을 숨기는 것", 서버는 "DB 유출에 대비하는 것".
> 이 역할 분리를 이해하고 나니 salt 없는 SHA-256이 프론트엔드에서 왜 괜찮은지 납득이 되었고, 이후의 기술 선택과 배포 판단에서도 이 구분이 계속 기준이 되었습니다.

돌아보면 이 의문에 매달린 시간이 아까웠을 수 있지만, 이때 "프론트엔드와 서버가 각각 뭘 책임지는지"를 정리한 것이 결국 이후 구현, 배포, 마이그레이션까지 모든 판단의 출발점이 되었습니다.

그렇다면 서버는 이 해시값을 받아서 어떻게 처리하는 걸까. 궁금해서 서버 개발자에게 물어봤습니다.

---

## 서버도 비밀번호를 모르나

이전에 네이버 같은 사이트에서 비밀번호를 변경할 때, "기존 비밀번호는 저희도 알 수 없습니다"라는 안내를 본 적이 있었습니다.
그때는 "입력받는데 왜 모르지?" 싶었는데, 이번에 해싱을 이해하고 나니 그 이유를 알게 되었습니다.

결론부터 말하면, 맞습니다. 서버도 원본 비밀번호를 모릅니다.

서버가 받는 건 이미 SHA-256으로 해싱된 값이고, 그걸 다시 PBKDF2(salt + 반복 연산)로 해싱해서 DB에 저장합니다. 어느 단계에서도 원본 비밀번호는 존재하지 않습니다.

```
1. 사용자 입력              "qwer3344@"
2. 프론트엔드 SHA-256       "a1b2c3d4..."
3. 서버 PBKDF2            "x9y8z7..."
4. DB 저장값과 비교          "x9y8z7..." === "x9y8z7..." → 로그인 성공
```

로그인 검증은 "같은 입력은 같은 해시를 만든다"는 성질을 이용한 비교 방식입니다. 비밀번호 원본을 비교하는 게 아니라 해시값을 비교하는 것이죠. 사용자가 올바른 비밀번호를 입력하면 같은 해시 체인을 거쳐 DB에 저장된 값과 동일한 결과가 나오고, 틀리면 다른 결과가 나옵니다.

이 구조를 서버 개발자에게 직접 확인하고 나니 구현을 시작할 수 있었습니다. 하지만 한 가지 선택이 더 필요했습니다. 프론트엔드에서 쓸 해싱 알고리즘으로 SHA-256이 정말 맞는 건지, PBKDF2 같은 더 강력한 것을 써야 하는 건 아닌지 확인하고 싶었습니다.

---

## SHA-256을 선택한 이유와 적용

### 왜 SHA-256인가

해싱 알고리즘이 여러 가지 있다는 걸 알게 되면서, 왜 하필 SHA-256인지 궁금해졌습니다.
PBKDF2라는 것도 있는데, 이건 비밀번호 보호에 더 적합하다고들 합니다.

|                   | SHA-256                       | PBKDF2                           |
| ----------------- | ----------------------------- | -------------------------------- |
| 속도              | 빠름                          | 의도적으로 느림                  |
| 용도              | 데이터 무결성 검증, 지문 생성 | 비밀번호 저장 (brute-force 방어) |
| 프론트엔드 적합성 | 빠르기 때문에 UX에 영향 없음  | 느려서 로그인 시 딜레이 발생     |

**PBKDF2는 "의도적으로 느린" 해싱**입니다. 공격자가 수천만 개의 비밀번호를 시도하는 brute-force 공격을 어렵게 만들기 위해 연산을 반복합니다. 이건 DB 저장 시에 필요한 특성이지, 사용자가 로그인 버튼을 누를 때마다 겪어야 할 이유는 없습니다.

앞서 정리한 "프론트엔드는 평문 노출만 막으면 된다"는 역할 구분에 따르면, 빠른 SHA-256이 적합했습니다.
저희 팀에서는 이 판단이 맞다고 봤지만, 만약 프론트엔드 단에서도 brute-force 방어가 필요한 환경이라면 다른 선택이 나올 수도 있을 것 같습니다.

### Web Crypto API로 구현하기

별도 라이브러리 없이, 브라우저 내장 [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest)로 구현할 수 있었습니다.

```js
async function hashPassword(password) {
  const encoder = new TextEncoder();
  const data = encoder.encode(password);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
```

`TextEncoder`로 문자열을 바이트 배열로 변환하고, `crypto.subtle.digest`로 SHA-256 해시를 생성한 뒤, 결과를 16진수 문자열로 변환합니다. Web Crypto API는 모든 모던 브라우저에서 지원되기 때문에 별도의 polyfill도 필요 없었습니다.

기존 로그인 API 호출부에 이 함수를 끼워 넣으면 됩니다.

```js
// Before
const response = await api.post('/login', {
  user_id: email,
  password: rawPassword, // 평문
});

// After
const hashedPw = await hashPassword(rawPassword);
const response = await api.post('/login', {
  user_id: email,
  password: hashedPw, // SHA-256 해시값
});
```

수정해야 할 곳은 **비밀번호를 서버에 전송하는 모든 곳**이었습니다. 저희 서비스에서는 로그인과 회원가입 두 곳이었습니다.

### 서버와 배포 타이밍 협의

코드 자체는 간단했지만, 배포에서 한 가지 중요한 문제가 있었습니다. 이미 운영을 하고 있는 서비스이기 때문에, 프론트엔드만 먼저 해싱해서 보내면 서버가 평문을 기대하고 있어 로그인이 깨집니다.
반대로 서버가 먼저 해시값 처리를 배포하면, 아직 평문을 보내는 프론트엔드 때문에 역시 로그인이 깨집니다.

**서버가 해시값을 받을 준비가 된 후에 프론트엔드를 배포**해야 했습니다.
이 순서를 맞추기 위해 서버 개발자와 배포 일정을 사전에 맞췄습니다. 코드 몇 줄짜리 작업이었지만, 순서를 틀리면 전체 로그인이 멈추는 상황이었기 때문에 이 협의가 실제로 가장 긴장되는 부분이었습니다.

> 📌 이 배포 순서 문제는 다음 장에서 다루는 "기존 비밀번호 마이그레이션"과도 직접 연결됩니다.
> 서버가 해시값을 받을 준비를 한다는 건 단순히 입력 형식만 바꾸는 게 아니라, 기존 유저의 로그인까지 고려한 로직을 함께 배포한다는 뜻입니다.

배포 순서가 정해지고 나서야, 한 가지 더 궁금한 게 생겼습니다. 기존에 이미 가입한 유저들의 비밀번호는 어떻게 되는 걸까.

---

## 기존 비밀번호는 어떻게 되나

이건 프론트엔드보다는 서버 영역의 문제이지만, 프론트엔드 개발자도 이 흐름을 알고 있어야 배포 후 로그인 관련 이슈가 생겼을 때 원인을 빠르게 짚을 수 있습니다.
핵심은 입력값 자체가 달라진다는 점이었습니다.

|                        | 기존 방식            | 변경 후                        |
| ---------------------- | -------------------- | ------------------------------ |
| 프론트엔드가 보내는 값 | 평문 (`"qwer3344@"`) | SHA-256 해시 (`"a1b2c3d4..."`) |
| DB 저장 방식           | `PBKDF2(평문)`       | `PBKDF2(SHA-256(평문))`        |

아무 조치 없이 배포하면 기존 유저의 로그인이 전부 깨질 수 있습니다.
앞서 "서버가 해시값을 받을 준비를 한다"고 했는데, 그 준비에는 이 마이그레이션 로직이 포함되어 있었습니다. 일반적인 해결법은 **점진적 마이그레이션**입니다.

```
로그인 요청 시 서버의 처리 흐름

1) 새 방식 검증: PBKDF2(SHA-256(입력값)) vs DB값
2) 실패하면 → 구 방식 검증: PBKDF2(입력값) vs DB값
3) 구 방식으로 성공하면 → DB를 새 방식으로 업데이트
```

유저가 로그인하는 순간, 자연스럽게 새 방식으로 마이그레이션되는 구조입니다. 처음 로그인할 때는 구 방식으로 검증되지만, 그 즉시 DB가 새 방식으로 갱신되기 때문에 다음 로그인부터는 새 방식으로 처리됩니다. 강제 비밀번호 재설정 없이 유저 경험을 해치지 않는 방법이죠.

여기까지 준비가 끝나고, 서버와 순서를 맞춰 배포했습니다. 그 결과가 어땠는지 확인해 봤습니다.

---

## 적용 결과

적용 후 F12 네트워크 탭을 다시 열어보니, 요청 페이로드가 달라져 있었습니다.

![[스크린샷 2026-03-12 오후 5.36.04.png]]

비밀번호 대신 64자리 해시값이 표시됩니다. 화면을 들여다봐도, 화면 공유 중이더라도, 원본 비밀번호는 알 수 없습니다. 로그인과 회원가입 모두 정상적으로 동작했고, 잘못된 비밀번호 입력 시 로그인 실패도 정상 처리되었습니다.

---

## 마무리

돌아보면 코드는 10줄도 안 됐지만, "이 단계에서 뭘 보호하려는 건지"를 이해하는 데 시간이 더 걸렸습니다. 프론트엔드와 서버의 역할 구분을 잡고 나니 나머지 판단은 자연스럽게 따라왔고, 실제로 가장 긴장된 건 서버 개발자와 배포 순서를 맞추는 과정이었습니다.

프론트엔드 해싱만으로 모든 보안 문제가 해결되지는 않습니다.
레인보우 테이블이나 brute-force 방어는 여전히 서버의 영역이고, 보안 감사에서 추가 요구사항이 나올 수도 있습니다. 그래도 보안 태스크가 처음이라 어디서부터 시작할지 모르겠다면, "이 단계에서 뭘 보호하려는 건지"부터 질문해 보시길 권합니다.

---

## 용어 정리

- **해싱(Hashing)**: 임의 길이의 데이터를 고정 길이의 값으로 변환하는 것. 복호화가 불가능한 일방향 변환입니다.
- **SHA-256**: 해싱 알고리즘의 한 종류. 어떤 입력이든 256비트(64자리 hex 문자열) 길이의 해시값을 생성합니다.
- **salt**: 해싱 전에 비밀번호에 덧붙이는 임의의 랜덤 문자열. 같은 비밀번호라도 다른 해시값이 나오게 만들어, 레인보우 테이블 공격을 무력화합니다.
- **PBKDF2**: Password-Based Key Derivation Function 2. 해싱 연산을 수천~수만 번 반복하여 의도적으로 느리게 만든 알고리즘입니다. 공격자의 brute-force 시도를 어렵게 만드는 것이 목적이며, 주로 서버에서 DB 저장 시 사용합니다.
- **레인보우 테이블(Rainbow Table)**: 흔한 비밀번호들을 미리 해싱해둔 대조표. 해시값을 이 표에서 역으로 찾아 원본 비밀번호를 알아내는 공격 방식에 사용됩니다.
- **brute-force 공격**: 가능한 모든 비밀번호 조합을 하나씩 시도하여 맞는 것을 찾아내는 공격 방식.]]></content:encoded>
          <category>보안</category>
          <pubDate>Wed, 11 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>에러 타입을 지정하니 인터셉터의 빠진 경로 3개가 드러났다</title>
          <link>https://blog.ssumi.space/blog/interceptor-error-path-unification</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/interceptor-error-path-unification</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>Register로 에러 타입을 ApiError로 지정한 뒤 인터셉터를 다시 보니, 4개 에러 경로 중 3개가 AxiosError를 그대로 reject하고 있었다. 타입을 제대로 설계했기 때문에 보이게 된 구멍을 메운 과정.</description>
          <content:encoded><![CDATA[## 들어가며

> 이 글은 [매번 instanceof를 쓰고 있다면 — TanStack Query Register로 에러 타입 지정하기](/blog/tanstack-query-v5-register-default-error)의 후속편입니다.

이전 글에서 TanStack Query v5의 Register 인터페이스를 통해 프로젝트 전체의 에러 타입을 `ApiError`로 통일했습니다.

```typescript
declare module '@tanstack/react-query' {
  interface Register {
    defaultError: ApiError;
  }
}
```

이 선언 이후, 모든 `useQuery`/`useMutation`의 `error`가 `ApiError | null`로 추론됩니다.
컴포넌트에서 `error.detail`, `error.status`를 타입 가드 없이 바로 참조할 수 있게 되었습니다.

에러 타입이 명확해지니, 이전에는 신경 쓰지 않았던 부분이 눈에 들어오기 시작했습니다.
`error.detail`을 타입 가드 없이 바로 쓸 수 있게 됐는데, 그러면 런타임에서도 정말 항상 `ApiError`가 reject되고 있는 걸까?

인터셉터를 다시 보니, 4개 에러 경로 중 1개만 `ApiError`로 변환하고 있었습니다.

## 인터셉터의 에러 경로 분석

프로젝트의 axios 응답 인터셉터는 서버 에러를 `ApiError` 클래스로 변환하는 역할을 합니다.
`ApiError`에 대한 자세한 설명은 이전 글의 "프로젝트의 에러 처리 구조" 섹션을 참고해주세요.

개선 전 인터셉터의 전체 에러 경로를 살펴보겠습니다.

```typescript
// src/services/interceptor.ts — 개선 전

  const errorType = error.response?.data?.type;
  const errorStatus = error.response?.data?.status;

  // 경로 1: 401 토큰 만료
  if (errorType === 'AUTH_003') {
    handleAuthError('로그인이 만료되었습니다...', 'auth-expired');
    return Promise.reject(error); // ← AxiosError 그대로 // [!code highlight]
  }

  // 경로 2: 계정 비활성화 (419)
  if (errorStatus === 419) {
    handleAuthError(error.response.data.detail, 'account-disabled');
    return Promise.reject(error); // ← AxiosError 그대로 // [!code highlight]
  }

  // 경로 3: 서버가 detail 필드를 포함한 에러 응답
  if (error.response?.data?.detail) {
    return Promise.reject(new ApiError(error.response.data)); // ✅ 유일하게 변환 // [!code highlight]
  }

  // 경로 4: 네트워크 에러, 타임아웃 등
  console.error('API Error:', error);
  return Promise.reject(error); // ← AxiosError 그대로 // [!code highlight]
};
```

도식화하면 문제가 선명하게 보입니다.

```
handleResponseErrorInterceptor(error)
  │
  ├─ 경로 1: AUTH_003 (토큰 만료)    → reject(AxiosError)     ❌
  ├─ 경로 2: 419 (계정 비활성화)      → reject(AxiosError)     ❌
  ├─ 경로 3: detail 필드 있음         → reject(new ApiError)   ✅
  └─ 경로 4: 그 외 (네트워크 에러 등) → reject(AxiosError)     ❌
```

**4개 경로 중 3개가 `AxiosError`를 그대로 reject하고 있었습니다.**
Register로 에러 타입을 명확히 지정한 덕분에, 이전에는 눈에 띄지 않던 이 불일치가 드러난 겁니다.

## 경로별 위험도가 다르다

모든 경로가 같은 수준으로 위험한 건 아니었어요.

- **경로 1, 2 (인증 에러)** — `handleAuthError()`가 먼저 실행되어 로그아웃 + 로그인 페이지 리다이렉트를 수행합니다. reject된 에러가 컴포넌트의 `onError`까지 도달하더라도, 리다이렉트로 컴포넌트가 이미 언마운트된 상태이므로 실질적 위험은 낮습니다.
- **경로 4 (네트워크 에러)** — 이것이 **실제 위험 구간**입니다. 네트워크 단절, CORS 에러, 타임아웃, 서버가 `detail` 없이 응답하는 경우 등에서 `AxiosError`가 그대로 reject됩니다.

경로 4에서 문제가 발생하는 시나리오는 이렇습니다.

```typescript
const { error } = useQuery(someQuery);

// Register 선언으로 error는 ApiError | null로 추론됨
// 하지만 네트워크 에러 시 실제로는 AxiosError가 들어옴
toast.error(error?.detail); // → undefined. 사용자에게 빈 에러 메시지가 표시됨
```

타입은 `ApiError`라고 말하는데 런타임에서는 `AxiosError`가 들어오니, `error.detail`이 `undefined`가 됩니다.
에러 타입을 지정하지 않았을 때는 어차피 `instanceof`로 방어하고 있어서 드러나지 않던 문제였습니다.
타입을 정확히 지정한 뒤에야 비로소 보이게 된 구멍이었습니다.

## 두 가지 방향과 판단

해결 방향은 두 가지였습니다.

**방향 A: 인터셉터에서 모든 경로를 `ApiError`로 통일**

Register 선언의 전제("모든 에러가 `ApiError`")를 런타임에서도 보장합니다.
변경 지점이 인터셉터 한 곳에 집중됩니다.

**방향 B: 사용처에서 방어적 유틸리티로 처리**

```typescript
function getErrorMessage(error: ApiError): string {
  if (error instanceof ApiError) return error.detail;
  return (error as any)?.message ?? '알 수 없는 오류가 발생했습니다.';
}
```

인터셉터를 건드리지 않지만, `instanceof`를 유틸 안에 숨기는 것에 불과합니다.
Register를 등록한 의미가 퇴색됩니다.

> 방향 A를 선택했습니다. Register를 등록한 목적 자체가 "모든 에러가 `ApiError`"라는 전제를 선언한 것이므로, 런타임도 그 전제에 맞추는 것이 자연스럽습니다.

저는 아래 이유로 방향 A가 맞다고 판단했습니다.

- `error.detail`을 직접 참조하는 곳이 38곳 이상 — 모든 곳에 방어 코드를 추가하는 것보다 인터셉터 한 곳을 고치는 게 효율적
- 네트워크 에러에 대해서도 `status: 0`, `type: 'NETWORK_ERROR'`처럼 구조화된 정보를 제공하면, 사용처에서 에러 종류별 분기도 깔끔해짐
- 방향 B는 Register 선언과 런타임 사이의 불일치를 방치하면서 사용처에서 수습하는 구조 — 근본적 해결이 아님

## 개선된 인터셉터

모든 `return Promise.reject(...)` 지점에서 `ApiError`를 생성하도록 수정했습니다.

```typescript
// src/services/interceptor.ts — 개선 후

  const errorType = error.response?.data?.type;
  const errorStatus = error.response?.data?.status;

  /** 401 토큰 만료 */
  if (errorType === 'AUTH_003') {
    handleAuthError('로그인이 만료되었습니다...', 'auth-expired');
    return Promise.reject(
      new ApiError({
        // [!code highlight]
        type: 'AUTH_003',
        title: '인증 만료',
        status: 401,
        detail: '로그인이 만료되었습니다.',
      })
    );
  }

  /** 계정 비활성화 */
  if (errorStatus === 419) {
    handleAuthError(error.response.data.detail, 'account-disabled');
    return Promise.reject(new ApiError(error.response.data)); // [!code highlight]
  }

  /** 서버가 detail 필드를 포함한 에러 응답 */
  if (error.response?.data?.detail) {
    return Promise.reject(new ApiError(error.response.data)); // [!code highlight]
  }

  /** 네트워크 에러, 타임아웃 등 */
  console.error('API Error:', error);
  return Promise.reject(
    new ApiError({
      // [!code highlight]
      type: 'NETWORK_ERROR',
      title: '네트워크 오류',
      status: 0,
      detail: error.message || '서버에 연결할 수 없습니다.',
    })
  );
};
```

변경의 핵심은 간단합니다.
기존에 `return Promise.reject(error)`로 `AxiosError`를 그대로 넘기던 세 곳을 모두 `new ApiError(...)`로 감쌌습니다.

## 검증: 모든 경로가 `ApiError`를 반환하는가

개선 후 에러 경로를 다시 도식화하면 이렇습니다.

```
handleResponseErrorInterceptor(error)
  │
  ├─ AUTH_003 (토큰 만료)    → reject(new ApiError({type:'AUTH_003', status:401}))     ✅
  ├─ 419 (계정 비활성화)      → reject(new ApiError(error.response.data))               ✅
  ├─ detail 필드 있음         → reject(new ApiError(error.response.data))               ✅
  └─ 네트워크 에러 등         → reject(new ApiError({type:'NETWORK_ERROR', status:0}))  ✅
```

모든 경로에서 `ApiError`가 reject됩니다.
`pnpm type-check` 통과를 확인했습니다.

`pnpm type-check` 통과까지 확인하면서, 4개 경로가 모두 `ApiError`를 반환하는 구조가 완성됐습니다.

## 마치며

에러 타입을 제대로 지정하고 나니, 절반만 해결된 상태가 보였습니다.
타입 시스템은 "모든 에러가 `ApiError`"라고 알고 있는데, 인터셉터는 아직 그에 맞게 정비되지 않은 경로가 3개 있었습니다.

이번에 배운 것을 정리하면 이렇습니다.

- **타입 선언은 약속이다.** `declare module`로 타입을 오버라이드했다면, 런타임도 그 약속을 지켜야 합니다. 타입만 바꾸고 런타임을 방치하면 `undefined` 참조라는 더 은밀한 버그가 생깁니다.
- **에러 경로는 도식화해서 검증해야 한다.** 인터셉터의 분기를 머릿속으로만 따라가면 놓치기 쉽습니다. 모든 `return Promise.reject(...)` 지점을 나열하고, 각각 어떤 타입이 reject되는지 확인하는 것이 효과적이었습니다.
- **원본 에러 정보 유실이라는 트레이드오프가 있다.** 네트워크 에러를 `ApiError`로 감싸면 원본 `AxiosError`의 `config`, `request`, `response` 등 디버깅 정보가 유실됩니다. 필요시 `ApiError` 생성 시 `cause` 필드로 원본을 보존하는 확장을 고려할 수 있습니다.

에러 타입을 제대로 설계한 덕분에 인터셉터의 빈틈이 드러났고, 그 빈틈을 메우면서 타입 선언과 런타임이 일치하게 되었습니다.
이제 `error.detail`을 안심하고 쓸 수 있습니다. 그런데 이 `detail` 메시지를 사용자에게 그대로 보여줘도 괜찮을까요? 다음 글에서 다루겠습니다.

## 참고 자료

- [이전 글: 매번 instanceof를 쓰고 있다면 — TanStack Query Register로 에러 타입 지정하기](/blog/tanstack-query-v5-register-default-error)
- [TanStack Query v5 - Register Interface (공식 문서)](https://tanstack.com/query/v5/docs/framework/react/typescript#registering-a-global-error)
- [RFC 7807 - Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807)]]></content:encoded>
          <category>에러 다루기</category>
          <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>매번 instanceof를 쓰고 있다면 — TanStack Query Register로 에러 타입 지정하기</title>
          <link>https://blog.ssumi.space/blog/tanstack-query-v5-register-default-error</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/tanstack-query-v5-register-default-error</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>useQuery의 error가 항상 Error로 추론되어 매번 instanceof 타입 가드를 쓰고 있었다. TanStack Query v5의 Register 인터페이스로 프로젝트 전체의 에러 타입을 바꾼 과정.</description>
          <content:encoded><![CDATA[## 들어가며

TanStack Query에서 `useQuery`나 `useMutation`의 `error`는 기본적으로 `Error` 타입으로 추론됩니다.
`message` 하나만 가진 가장 기본적인 에러 객체입니다.

하지만 실제 프로젝트에서는 이것만으로 부족한 경우가 많습니다.
백엔드와 에러 응답 형식을 협의하고, 그에 맞는 커스텀 에러 클래스를 만들어 쓰는 팀이 적지 않습니다.

저희 프로젝트도 그랬습니다.
백엔드가 `status`, `type`, `detail` 같은 필드를 포함한 구조화된 에러를 반환하기로 합의했고, 프론트엔드에서는 이에 대응하는 `ApiError`라는 커스텀 에러 클래스를 만들어 사용하고 있었습니다.

그런데 문제가 있었습니다.
TanStack Query가 이 커스텀 타입을 모른다는 거였거든요.

```typescript
const { mutate } = useMutation({
  mutationFn: () => deleteService(serviceId),
  onError: (error: Error) => {
    // ← Error로 추론됨. ApiError가 아님
    if (error instanceof ApiError) {
      // [!code highlight]
      // ← 매번 타입 가드를 써야 함
      toast.error(error.detail);
    }
  },
});
```

`error.detail`이라는 단순한 속성 접근을 위해 매번 `instanceof` 타입 가드를 거쳐야 했습니다.
프로젝트 전체를 검색해보니 이 패턴이 **38곳**에 반복되고 있었습니다.

queryOptions 팩토리 패턴 덕분에 데이터 타입은 완벽하게 추론되는데, 에러 타입만 항상 `Error`로 고정되어 있는 게 이상했습니다.
왜 그런지 파보다가, TanStack Query가 에러 타입의 기본값을 결정하는 구조를 발견했습니다.
그리고 그 구조를 활용하면 코드 3줄로 프로젝트 전체의 에러 타입을 바꿀 수 있다는 걸 알게 되었습니다.

## 프로젝트의 에러 처리 구조

먼저 프로젝트의 에러 처리 흐름을 짚어야 합니다.
이 구조를 알아야 "왜 전역 에러 타입 설정이 가능한가"를 이해할 수 있습니다.

앞서 말한 대로, 백엔드와 협의해서 모든 API 에러 응답을 **RFC 7807 (Problem Details)** 형식으로 통일한 상태입니다.
RFC 7807은 HTTP API의 에러 응답을 `status`, `type`, `title`, `detail` 같은 필드로 구조화하는 표준입니다.
프론트엔드에서는 이 형식에 대응하는 `ApiError` 클래스를 다음과 같이 정의했습니다.

```typescript
// src/types/ApiException.ts

  status: number;
  type: string;
  title: string;
  detail: string;
  instance?: string;

  constructor(data: ErrorSchema) {
    super(data.detail);
    this.status = data.status;
    this.type = data.type;
    this.title = data.title;
    this.detail = data.detail;
    this.instance = data.instance;
  }
}
```

이 `ApiError`는 axios 응답 인터셉터에서 생성됩니다.
서버가 에러를 반환하면, 인터셉터가 AxiosError를 잡아 `ApiError`로 변환한 뒤 reject하는 구조입니다.

```typescript
// src/services/interceptor.ts (핵심 부분만 발췌)

  // 401 토큰 만료, 419 계정 비활성화 등 특수 케이스 처리 후...

  // 서버가 detail 필드를 포함한 경우 → ApiError로 변환
  if (error.response?.data?.detail) {
    return Promise.reject(new ApiError(error.response.data)); // ✅ ApiError로 변환 // [!code highlight]
  }

  return Promise.reject(error); // ← 일부 경로에서는 AxiosError 그대로
};
```

즉, **런타임에서 대부분의 API 에러는 `ApiError` 인스턴스로 reject됩니다.**
사용처에서는 `error.detail`이나 `error.status`를 참조해 사용자에게 에러 메시지를 보여주는 패턴이 반복되고 있었습니다.

다만 타입 시스템은 이 흐름을 모릅니다.

## 타입 시스템이 모르는 것

> [!WARNING]
> 인터셉터가 런타임에서 `ApiError`를 throw하든 말든, TanStack Query의 타입 시스템은 이를 알 수 없습니다. `error`는 항상 `Error | null`로 추론됩니다.

`useQuery`나 `useMutation`에서 반환하는 `error`의 타입은 `Error | null`입니다.

이 때문에 프로젝트 전체에 세 가지 보일러플레이트 패턴이 반복되고 있었습니다.

```typescript
// 패턴 1: onError 콜백에서 instanceof (20곳 이상)
onError: (error: Error) => {
  if (error instanceof ApiError) {
    toast.error(error.detail);
  }
},

// 패턴 2: 삼항 연산자로 분기 (6곳)
onError: error => {
  toast.error(
    error instanceof ApiError ? error.detail : t('networkError'),
  );
},

// 패턴 3: error 필드 직접 사용 시 타입 가드 필수
const { error } = useMutation({ ... });
// error: Error | null ← detail, status 접근 불가
if (error instanceof ApiError) {
  setErrorMessage(error.detail);
} else {
  setErrorMessage(error?.message ?? '');
}
```

한편 데이터 타입은 queryOptions 팩토리 덕분에 완벽하게 추론되고 있었습니다.

```typescript
// src/services/queries/agent.ts

  detail: (agentId: number) =>
    queryOptions({
      queryFn: () => getUserAgent().getAgentDetail(agentId),
      queryKey: AGENT_DETAIL_QUERY_KEY(agentId),
    }),
};

const { data, error } = useQuery(agentQueries.detail(agentId));
// data: AgentDetailRs ← 완벽하게 추론됨
// error: Error | null  ← ApiError가 아님
```

`error.detail`이라는 단순한 속성 접근을 위해 매번 `instanceof`를 쓰는 건 분명 불필요한 반복이었습니다.
그렇다면 TanStack Query는 왜 에러 타입을 `Error`로 고정하는 걸까요?

## TanStack Query의 DefaultError 결정 구조

TanStack Query v5의 소스를 들여다봤더니, 에러 타입의 기본값을 결정하는 구조가 세 단계로 되어 있었습니다.

```typescript
// @tanstack/query-core/src/types.ts 내부 구조

// 1단계. Register 인터페이스 — 기본값은 빈 객체

  // defaultError: Error  ← 주석 처리됨 (사용자가 오버라이드하도록 설계)
}

// 2단계. DefaultError — 조건부 타입으로 결정

  ? TError // Register에 defaultError가 있으면 → 그 타입 // [!code highlight]
  : Error; // 없으면 → Error (기본값) // [!code highlight]

// 3단계. useQuery/useMutation에서 DefaultError를 기본값으로 사용
function useQuery<
  TQueryFnData = unknown,
  TError = DefaultError, // ← 여기서 에러 타입 결정 // [!code highlight]
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
>(options): UseQueryResult

## 적용: 코드 3줄

적용 자체는 간단합니다.
queryClient를 설정하는 파일에 module augmentation을 추가하면 됩니다.

```typescript
// src/services/queryClient.ts

declare module '@tanstack/react-query' {
  interface Register {
    defaultError: ApiError; // [!code highlight]
  }
}
```

이것만으로 프로젝트 전체에 두 가지가 바뀝니다.

- `useQuery`/`useMutation`이 반환하는 `error`의 타입이 `Error | null` → `ApiError | null`
- `error.detail`에 바로 접근 가능. `instanceof` 타입 가드 불필요

한편, 기존 코드가 `error: Error`를 전제로 작성되어 있었기 때문에 충돌하는 곳이 있을 수 있었습니다.
`pnpm type-check`로 확인해봤습니다.

## 검증: 타입 체크가 잡아준 3개의 불일치

> [!SUCCESS]
> `pnpm type-check` 실행 결과 3개의 타입 에러가 발생했습니다. 기존 코드의 버그가 아니라, 타입이 정확해지면서 불필요한 분기가 드러난 것입니다.

기존 코드에서 `error`를 `Error`로 가정하고 작성한 부분들이 `ApiError`로 바뀌면서 드러난 불일치였습니다.

**1. AgentTestChat.tsx — AxiosError 캐스팅 실패**

```typescript
// Before: error가 Error → AxiosError 캐스팅이 호환됨
const responseData = (error as AxiosError<{ detail: string }>)?.response?.data;

// After: error가 ApiError → AxiosError와 호환되지 않음
// 수정: 중간 캐스팅 추가
const responseData = (error as unknown as AxiosError<{ detail: string }>)
  ?.response?.data;
```

`Error`에서 `AxiosError`로의 캐스팅은 타입 계층이 호환되어 허용됐지만, `ApiError`에서 `AxiosError`로는 구조가 달라 직접 캐스팅이 불가능했습니다.

**2. LoginPage.tsx — else 분기가 never로 추론**

```typescript
// Before: error: Error | null → else 분기에서 Error로 message 접근 가능
if (error instanceof ApiError) {
  setErrorMessage(error.detail);
} else {
  setErrorMessage(error?.message ?? ''); // error: Error
}

// After: error: ApiError | null → instanceof ApiError가 false이면 null뿐
// else 분기의 error가 never → message 접근 에러
// 수정: instanceof 불필요, 직접 접근으로 단순화
setErrorMessage(error?.detail ?? ''); // [!code highlight]
```

`ApiError`는 `Error`를 상속하므로, `error: ApiError | null`에서 `instanceof ApiError`가 false이면 타입상 `null`만 남습니다.
그래서 else 분기에서 `error`가 `never`가 되어 `message` 속성에 접근할 수 없었습니다.
오히려 코드가 단순해지는 방향이었습니다.

**3. QueryEditor.tsx** — 2번과 같은 패턴입니다. `error instanceof ApiError ? error.detail : error?.message`에서 삼항 연산자 전체가 `error?.detail`로 단순화되었습니다.

3개 모두 기존 코드가 틀렸다기보다, **타입이 정확해지면서 불필요한 분기가 드러난 경우**였습니다.
수정 후 `pnpm type-check`가 통과했습니다.

## 마치며

처음 문제는 단순했습니다.
`error.detail`에 접근하고 싶은데, 매번 `instanceof ApiError`를 써야 하는 게 번거로웠습니다.
프로젝트 전체에 38곳이나 반복되는 이 패턴을 보고, 제가 에러 타입 자체를 바꿀 수 있는 방법을 찾아보게 됐습니다.

정리하면 이렇습니다.

- TanStack Query v5는 `Register` 인터페이스를 통해 에러 타입의 기본값을 오버라이드할 수 있도록 설계되어 있다
- 다섯 가지 방법 중, 인터셉터에서 `ApiError`로 균일하게 변환하는 프로젝트 구조와 가장 잘 맞는 Register 방식을 선택했다
- 변경은 `queryClient.ts`에 module augmentation 3줄을 추가한 것이 전부였고, 타입 체크에서 발견된 3개의 불일치도 오히려 코드를 단순하게 만드는 방향이었다

타입 선언만으로는 절반의 해결이고, 런타임에서도 그 전제를 보장해야 완전한 해결이 됩니다.
이 gap을 메우는 인터셉터 개선은 다음 글에서 다루겠습니다.

## 참고 자료

- [TanStack Query v5 - Register Interface (공식 문서)](https://tanstack.com/query/v5/docs/framework/react/typescript#registering-a-global-error)
- [TanStack Query - query-core/src/types.ts (소스 코드)](https://github.com/TanStack/query/blob/main/packages/query-core/src/types.ts)
- [RFC 7807 - Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807)]]></content:encoded>
          <category>에러 다루기</category>
          <pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>런타임에 변하는 한글 - i18next에서 조사를 자동으로 선택하기</title>
          <link>https://blog.ssumi.space/blog/i18next-korean-postprocessor</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/i18next-korean-postprocessor</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>{{name}}에 어떤 단어가 올지 모르는데, 을/를은 어떻게 정하지? i18next post-processor로 번역 키 하나만으로 조사를 자동 선택한 과정.</description>
          <content:encoded><![CDATA[## 들어가며

i18next 기반 프로젝트에서 국제화 작업을 진행하고 있었습니다. 한국어 텍스트를 하나씩 번역 키로 옮기다 보니, 이런 게 눈에 들어왔습니다.

```json
"deleteModalDescription": "{{name}}을/를 삭제하시겠습니까?"
```

`{{name}}`에 들어올 단어에 따라 `을`이 맞을 수도, `를`이 맞을 수도 있습니다. 그러면 이걸 쓰는 곳마다 사용부에서 직접 분기해야 하나? 싶었습니다.

모든 페이지를 국제화해야 하는 상황에서 이렇게 DX가 안 좋을 리 없다고 생각했고, 이 문제를 좀 파보게 되었습니다. 한국어의 **조사** 문제였습니다.

---

## 왜 이게 문제가 되는가

한국어는 앞 글자의 받침(종성) 유무에 따라 조사가 달라집니다.

| 받침 있음 | 받침 없음 | 예시                      |
| :-------: | :-------: | :------------------------ |
|    을     |    를     | 모델**을** / 도구**를**   |
|    은     |    는     | 모델**은** / 도구**는**   |
|    이     |    가     | 모델**이** / 도구**가**   |
|    과     |    와     | 모델**과** / 도구**와**   |
|   으로    |    로     | 부산**으로** / 서울**로** |

보통은 문제가 되지 않습니다. 글을 쓰는 시점에 앞 단어를 알고 있으니까요.

그런데 i18n에서는 상황이 다릅니다. `{{name}}`에 "모델"이 올지 "도구"가 올지는 런타임에 결정됩니다. 번역 키를 작성하는 시점에는 어떤 조사가 맞는지 알 수가 없습니다.

영어에는 이 문제 자체가 없습니다. "Delete {{name}}"은 name에 뭐가 오든 문법이 동일하거든요. 한국어-영어 다국어 프로젝트에서 한국어 쪽만 이 짐을 안게 되는 셈이죠.

그렇다면 프로젝트에서는 이 문제를 어떻게 다루고 있었을까요?

---

## 프로젝트에서 발견한 세 가지 패턴

`ko.json`을 더 살펴보니, 이 문제를 나름대로 회피하려는 세 가지 패턴이 섞여 있었습니다.

**패턴 1. 슬래시 표기: "을/를"을 그대로 노출**

```json
"deleteModalDescription": "{{agentName}}을/를 삭제하시겠습니까?"
```

가장 흔한 패턴이었습니다. 양쪽 다 써놓으면 틀릴 일은 없으니까요. 하지만 "을/를"이 화면에 그대로 나옵니다. UI가 비전문적으로 느껴지는 게 가장 큰 문제였습니다.

**패턴 2. 하드코딩된 단일 조사: 특정 단어에서만 맞음**

```json
"fieldPlaceholder": "{{name}}를 입력하세요"
```

"를"로 고정해뒀는데, `name`에 "설명"이 들어오면 "설명를"이 됩니다. 받침 있는 단어에서 무조건 틀리죠. 아마 처음 작성할 때 테스트한 단어가 받침 없는 단어였을 겁니다.

**패턴 3. 키 2개로 분리: 유지보수 부담**

```json
"selectFieldPlaceholder": "{{name}}을 선택하세요",
"selectFieldPlaceholder2": "{{name}}를 선택하세요"
```

가장 정직한 접근이지만, 호출하는 쪽에서 받침 판단을 직접 해야 합니다. 컴포넌트마다 "이 단어는 받침이 있으니 1번 키, 저 단어는 없으니 2번 키"라는 분기 코드가 들어가고, 조사가 필요한 키가 늘수록 키도, 판단 로직도 같이 불어납니다.

세 패턴 모두 근본적인 해결은 아니었습니다. 제가 원한 건 **번역 키 하나로, 런타임에 올바른 조사를 자동 선택**하는 것이었습니다. 거기에 영어 번역에는 아무 영향을 주지 않아야 했고요.

---

## 세 가지 접근법을 비교하다

i18next 생태계에서 이 문제를 해결하는 방법은 크게 세 가지가 있었습니다.

### 접근법 1. Formatter 커스텀

i18next의 내장 format 함수를 오버라이드하는 방식입니다.

```json
"selectPlaceholder": "{{name, 을}} 선택하세요"
```

보간 구문 안에 조사 힌트를 넣고, format 함수에서 받침을 판별해 올바른 조사를 반환합니다. 조사를 하나만 쓰면 되니 구문은 간결합니다. 다만 i18next의 기존 formatter와 충돌할 수 있고, 날짜 포맷 같은 다른 format 기능과 조사 처리가 한 함수에 얽히게 되는 점이 걸렸습니다.

### 접근법 2. Post-Processor 직접 구현

i18next가 보간을 완료한 뒤, 후처리 단계에서 조사를 교체하는 방식입니다.

```json
"selectPlaceholder": "{{name}}(을/를) 선택하세요"
```

보간이 끝난 문자열을 정규식으로 순회하면서, `(을/를)` 앞 글자의 받침을 보고 올바른 쪽을 고릅니다. 보간 시스템과 완전히 분리되니 충돌 걱정은 없습니다. 대신 양쪽 조사를 다 써야 하고, 받침 판별 로직과 엣지 케이스(숫자, 영어, 괄호 등)를 직접 챙겨야 합니다.

### 접근법 3. 검증된 라이브러리 도입

찾아보니 `i18next-korean-postposition-processor`라는 npm 패키지가 이미 있었습니다. Post-Processor 방식인데, 직접 구현 대신 검증된 코드를 가져다 쓰는 겁니다.

```json
"selectPlaceholder": "{{name}}[[를]] 선택하세요"
```

조사를 하나만 쓰면 됩니다. `[[를]]`이라고 쓰면 앞 글자의 받침에 따라 "을" 또는 "를"로 자동 변환됩니다. 숫자나 괄호로 감싸진 텍스트 같은 엣지 케이스도 이미 처리되어 있었습니다. 이 라이브러리가 내부적으로 받침을 어떻게 판별하는지는 뒤에서 따로 살펴봅니다.

### 어떤 걸 선택했는가

|   비교 기준   |     Formatter      |   직접 구현    |      라이브러리      |
| :-----------: | :----------------: | :------------: | :------------------: |
|  구문 간결함  | `\{\{name, 을\}\}` | `(을/를)` 양쪽 |    `[[를]]` 하나     |
|   구현 비용   |        중간        |      높음      |      거의 없음       |
| 테스트 신뢰도 |     직접 작성      |   직접 작성    |     이미 검증됨      |
|  엣지 케이스  |     직접 처리      |   직접 처리    | 내장 (숫자, 괄호 등) |
|   유지보수    |        직접        |      직접      |       커뮤니티       |

최종적으로 **라이브러리를 도입하기로 했습니다.** 받침 판별이라는 문제는 이미 잘 정의되어 있고, 검증된 구현체가 있는데 굳이 직접 만들 이유가 없었습니다.

Formatter 방식도 구문이 간결해서 끌렸지만, 결정적으로 **처리 시점**이 달랐습니다. Formatter는 보간 값 자체를 변환하는 단계에서 동작합니다. `{{name, 을}}`에서 `name` 값을 받아 조사를 붙인 결과를 돌려주는 방식인데, 이러면 format 함수 하나가 날짜 포맷, 숫자 포맷, 조사 처리를 전부 분기해야 합니다.

반면 Post-Processor는 보간이 끝난 완성된 문자열을 받습니다. `"모델[[를]] 선택하세요"`처럼 이미 보간이 끝난 상태에서, `[[를]]` 앞 글자만 보고 조사를 결정하면 됩니다. 기존 format 로직에 영향을 주지 않고, 조사 처리는 어차피 "앞 글자가 확정된 뒤"에야 할 수 있는 작업이니까 Post-Processor가 더 자연스러운 시점이라고 판단했습니다.

---

## 구현 과정

바꿔야 할 파일은 4개(`package.json`, `pnpm-lock.yaml`, `i18n.ts`, `ko.json`), 세 단계로 끝났습니다.

### 1단계. 설치

```bash
pnpm add i18next-korean-postposition-processor
```

### 2단계. 플러그인 등록

기존 i18n 설정에 두 줄만 추가하면 됩니다.

```typescript
// src/lib/i18n/i18n.ts

i18n
  .use(initReactI18next)
  .use(koreanPostpositionProcessor) // 추가
  .init({
    // ... 기존 설정 유지
    postProcess: ['korean-postposition'], // 추가
  });
```

여기서 한 가지 주의할 게 있습니다. `postProcess` 배열에 넣는 이름 `'korean-postposition'`은 라이브러리 소스의 `get name()`에서 정의된 값입니다. 이 이름이 틀리면 아무 동작도 하지 않으니, 라이브러리의 실제 name 속성을 꼭 확인해야 합니다.

### 3단계. 번역 키 마이그레이션

`ko.json`에서 기존 조사 관련 키들을 `[[조사]]` 구문으로 바꿨습니다. 규칙은 단순합니다. 기존 조사 자리를 `[[조사]]`로 감싸면 됩니다.

```json
// 하드코딩 조사 → [[조사]]
"{{name}}를 입력하세요"       →  "{{name}}[[를]] 입력하세요"
"새로운 {{item}}가 생성되었습니다."  →  "새로운 {{item}}[[가]] 생성되었습니다."

// 슬래시 표기 → [[조사]]
"{{agentName}}을/를 삭제하시겠습니까?"  →  "{{agentName}}[[를]] 삭제하시겠습니까?"
```

`[[를]]`이든 `[[을]]`이든 상관없습니다. 앞 글자의 받침에 따라 알아서 올바른 형태로 바뀝니다.

이 작업을 하면서 하나 더 정리한 게 있습니다. 기존에는 "~를 입력하세요", "~을 선택하세요" 같은 placeholder가 컴포넌트마다 따로 있었는데, 공통 키 두 개로 통합했습니다.

```json
"selectPlaceholder": "{{name}}[[을]] 선택하세요",
"inputPlaceholder": "{{name}}[[을]] 입력하세요"
```

사용하는 쪽에서는 이렇게 쓰면 됩니다.

```tsx
commonT('selectPlaceholder', { name: t('model') });
// → "모델을 선택하세요"
```

`en.json`은 손대지 않았습니다. `[[]]` 패턴이 없는 문자열은 post-processor가 그냥 통과시키거든요.

---

## 라이브러리는 어떻게 동작하는가

앞에서 "엣지 케이스가 이미 처리되어 있다"고 했는데, 실제로 어떤 과정인지 궁금해서 라이브러리 내부를 들여다봤습니다.

1. i18next가 `{{name}}`을 보간해서 `"모델[[를]] 선택하세요"`라는 문자열을 만듭니다.
2. post-processor가 정규식으로 `[[를]]`을 찾습니다.
3. `[[를]]` 바로 앞 글자 `'델'`의 유니코드를 분석합니다.

한글 유니코드에서 받침을 판별하는 공식은 `(charCode - 0xAC00) % 28`입니다. 결과가 0이면 받침 없음, 0이 아니면 받침 있음. 생각보다 단순한 산술 연산 하나로 끝나는 게 인상적이었습니다.

`'델'`의 코드포인트는 `0xB378`이고, `(0xB378 - 0xAC00) % 28 = 8`이니까 받침이 있다고 판별됩니다. 받침이 있으니 `를` 대신 `을`이 선택되고, 최종 출력은 `"모델을 선택하세요"`가 됩니다.

숫자는 한국어 발음 규칙을 따릅니다. "3"은 "삼"으로 읽히니 받침이 있고, "2"는 "이"로 읽히니 받침이 없는 식이죠. `"모델"`처럼 따옴표나 괄호로 감싸진 텍스트는 괄호 안의 마지막 문자를 기준으로 판별합니다. 직접 구현했다면 이런 케이스를 하나씩 테스트하고 처리해야 했을 텐데, 라이브러리를 선택한 이유가 여기에 있었습니다.

---

## 마치며

처음에 `ko.json`에서 발견한 "을/를"이 화면에 그대로 찍히던 문제(슬래시 표기, 하드코딩 조사, 키 분리)는 결국 같은 원인이었습니다. 런타임에 결정되는 보간 값의 받침을 번역 키 작성 시점에 알 수 없다는 것이죠.

세 가지 접근법을 비교하고, 구현 비용과 검증도를 기준으로 라이브러리를 도입했습니다. 하드코딩 텍스트를 i18n으로 옮기는 작업과 함께 조사 처리까지 한 PR로 정리할 수 있었습니다. "사용부마다 조사를 직접 분기해야 하나?"라는 처음의 DX 고민은 번역 키에 `[[조사]]`를 쓰는 것만으로 해소되었고, 이제 "을/를"이 화면에 나오는 일도 없어졌습니다.

앞으로 새 번역 키를 작성할 때는 이렇게 쓰면 됩니다.

```json
// ko.json
"message": "{{name}}[[를]] 삭제하시겠습니까?"

// en.json - 변경 없음
"message": "Do you want to delete {{name}}?"
```

지원되는 조사는 `[[을]]` `[[를]]` `[[은]]` `[[는]]` `[[이]]` `[[가]]` `[[과]]` `[[와]]` `[[으로]]` `[[로]]` `[[이랑]]` `[[랑]]`이고, 어느 쪽을 써도 알아서 올바른 형태로 변환됩니다.

다만 이 방식은 i18next의 post-processor로 동작하기 때문에 i18next 없이는 쓸 수 없습니다. 순수 유틸리티 함수가 필요하다면 Toss의 [slash josa](https://www.slash.page/libraries/common/hangul/src/josa.i18n)나 [hangul-postposition](https://www.npmjs.com/package/hangul-postposition) 같은 독립 라이브러리도 있습니다.

그리고 `[[]]` 구문은 번역자에게 익숙하지 않을 수 있습니다. 팀 내에서 번역 키 작성 규칙으로 공유하고, 이 구문의 의미를 문서화해두는 게 좋겠다고 느꼈습니다.

---

## 참고 자료

- [i18next-korean-postposition-processor (GitHub)](https://github.com/Perlmint/i18next-korean-postposition-processor)
- [Toss slash josa 유틸리티](https://www.slash.page/libraries/common/hangul/src/josa.i18n)
- [hangul-postposition (npm)](https://www.npmjs.com/package/hangul-postposition)]]></content:encoded>
          <category>사용자 경험</category>
          <pubDate>Fri, 27 Feb 2026 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>TypeScript는 왜 내 코드를 의심할까</title>
          <link>https://blog.ssumi.space/blog/why-typescript-doubts-your-code</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/why-typescript-doubts-your-code</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>TypeScript는 왜 내 코드를 의심할까</description>
          <content:encoded><![CDATA[## 들어가며

```typescript
interface State {
  user?: { name: string };
}

function showGreeting(state: State) {
  if (state.user) {
    setTimeout(() => {
      console.log(`안녕하세요, ${state.user.name}님!`);
      // ❌ 'state.user' is possibly 'undefined'
    }, 100);
  }
}
```

> "분명 if문에서 체크했는데, 왜 아직도 undefined인가?"

TypeScript를 사용하다 보면 이런 상황을 만나게 됩니다. 존재 여부를 확인했는데, 바로 다음 줄에서 "undefined일 수도 있다"고 말하는 TypeScript. 처음에는 버그인 줄 알았습니다.

그런데 지역 변수에 담으면 에러가 사라집니다.

```typescript
function showGreeting(state: State) {
  const user = state.user;
  if (user) {
    setTimeout(() => {
      console.log(`안녕하세요, ${user.name}님!`); // ✅ 정상 동작
    }, 100);
  }
}
```

같은 값인데, 왜 결과가 다를까요?

이 글에서는 TypeScript가 왜 우리 코드를 "의심"하는지, 그 뒤에 숨은 설계 철학을 살펴보겠습니다.

---

## Type Narrowing 복습

> Type Narrowing이 처음이라면 [이전 글: if문 하나로 TypeScript가 똑똑해지는 이유](https://velog.io/@sumi-0011/type-narrowing)를 먼저 읽어보시기 바랍니다.

TypeScript는 코드의 흐름을 분석해서 타입을 좁혀나갑니다. 이를 Control Flow Analysis라고 부릅니다.

```typescript
function greet(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase()); // string으로 좁혀짐
  } else {
    console.log(value.toFixed(2)); // number로 좁혀짐
  }
}
```

if문을 통과하면 TypeScript가 타입을 좁혀줍니다. 덕분에 타입 단언 없이도 안전하게 코드를 작성할 수 있습니다.

그런데 이 기능이 어떤 상황에서는 왜 제대로 동작하지 않는 걸까요?

---

## TypeScript의 의심, 그 합리적인 이유

결론부터 말하면, TypeScript는 **"지금 체크한 값이 나중에 쓸 때도 같을까?"**를 의심합니다.

"체크하고 바로 쓰는데 뭐가 바뀌는가?"라고 생각할 수 있습니다. React 개발에서 자주 겪는 상황들을 살펴보겠습니다.

### 상황 1. props로 받은 객체의 프로퍼티

```typescript
interface ChatState {
  connection?: WebSocket;
  messages: Message[];
}

function ChatRoom({ state }: { state: ChatState }) {
  const sendMessage = (text: string) => {
    if (state.connection) {
      // 이 시점: connection이 있음

      setTimeout(() => {
        // ❌ 'state.connection' is possibly 'undefined'
        state.connection.send(text);
      }, 100);
    }
  };
}
```

`state.connection`을 체크했지만, `setTimeout` 콜백 안에서 다시 접근하면 에러가 발생합니다. 부모 컴포넌트에서 `state`를 업데이트하면 `connection`이 `undefined`가 될 수 있기 때문입니다.

### 상황 2. 외부 store나 전역 객체

```typescript
// Zustand나 전역 상태를 직접 참조하는 경우
const authStore = {
  user: null as User | null,
  logout() {
    this.user = null;
  },
};

function UserGreeting() {
  const handleClick = () => {
    if (authStore.user) {
      // 이 시점: user가 있음

      setTimeout(() => {
        // ❌ 'authStore.user' is possibly 'null'
        console.log(`안녕하세요, ${authStore.user.name}님!`);
      }, 100);
    }
  };
}
```

`authStore.user`를 체크했지만, `setTimeout`이 실행되기 전에 다른 곳에서 `logout()`이 호출될 수 있습니다. TypeScript는 이 가능성을 인식하고 에러를 냅니다.

### 상황 3. ref.current는 언제든 바뀔 수 있다

```typescript
function VideoPlayer() {
  const videoRef = useRef<HTMLVideoElement | null>(null);

  const handlePlay = () => {
    if (videoRef.current) {
      // 이 시점: video 엘리먼트가 있음

      someAsyncOperation().then(() => {
        // 🤔 이 사이에 조건부 렌더링으로 video가 사라졌다면?
        videoRef.current.play();
      });
    }
  };
  // 조건에 따라 video가 렌더링되거나 안 될 수 있음
  return showVideo ? <video ref={videoRef} /> : <div>영상 없음</div>;
}
```

`ref.current`는 DOM이 업데이트되면 언제든 바뀔 수 있습니다. `if`로 체크한 시점과 실제 사용 시점이 다르면 `null`일 수 있습니다.

### 왜 TypeScript는 이렇게 설계됐을까?

TypeScript 팀의 입장은 다음과 같습니다:

> "각 프로퍼티 접근이 다른 값을 반환할 수 있다고 가정하는 것이 유일하게 안전한 방법입니다."

React의 상태는 언제든 바뀔 수 있고, ref는 DOM과 함께 변하고, 비동기 작업은 "나중에" 실행됩니다. TypeScript는 이 모든 가능성을 고려해서 **런타임 에러보다는 컴파일 타임의 불편함**을 선택한 것입니다.

보수적으로 느껴질 수 있지만, 이것은 버그가 아니라 의도적인 설계 결정입니다.

---

## 언제 TypeScript가 의심할까?

모든 상황에서 의심하는 것은 아닙니다. 패턴을 알아두면 도움이 됩니다.

### 나중에 실행되는 코드를 의심한다

```typescript
if (state.user) {
  // ❌ setTimeout 콜백
  setTimeout(() => {
    console.log(state.user.name); // 에러
  }, 100);

  // ❌ 배열 메서드 콜백
  items.forEach(() => {
    console.log(state.user.name); // 에러
  });

  // ❌ 이벤트 핸들러
  button.addEventListener('click', () => {
    console.log(state.user.name); // 에러
  });

  // ❌ Promise 콜백
  fetchData().then(() => {
    console.log(state.user.name); // 에러
  });
}
```

이 코드들의 공통점은 모두 **"나중에 실행되는"** 콜백 함수라는 점입니다.

### 바로 실행되는 코드는 의심하지 않는다

```typescript
if (state.user) {
  // ✅ 바로 다음 줄
  console.log(state.user.name);

  // ✅ 함수 호출 후에도
  doSomething();
  console.log(state.user.name);

  // ✅ await 후에도 (같은 함수 본문이므로)
  await fetchData();
  console.log(state.user.name);
}
```

동기적으로 실행되는 코드에서는 TypeScript가 narrowing을 유지합니다.

---

## 지역 변수는 왜 안전한가?

```typescript
const user = state.user; // 이 시점의 값을 "스냅샷"으로 저장

if (user) {
  setTimeout(() => {
    console.log(user.name); // ✅ 안전
  }, 100);
}
```

지역 변수(특히 `const`)의 특성:

- 재할당이 불가능함
- 외부에서 값을 바꿀 방법이 없음
- TypeScript가 완전히 추적 가능

`state.user`가 나중에 바뀌더라도, `user` 변수에 담긴 값은 그대로입니다. TypeScript는 이를 알고 있어서 안심하고 narrowing을 유지합니다.

---

## 실무에서 자주 만나는 상황

### React에서 이벤트 핸들러

```typescript
function UserProfile() {
  const { data } = useQuery(['user'], fetchUser);

  // ❌ 콜백에서 직접 접근
  const handleSave = () => {
    if (data?.user) {
      saveUser(data.user).then(() => {
        toast(`${data.user.name}님 저장 완료`); // 에러
      });
    }
  };

  // ✅ 지역 변수로 해결
  const handleSave = () => {
    const user = data?.user;
    if (user) {
      saveUser(user).then(() => {
        toast(`${user.name}님 저장 완료`); // OK
      });
    }
  };
}
```

### 배열 순회에서

```typescript
function processUsers(state: State) {
  // ❌ forEach 콜백
  if (state.user) {
    items.forEach(item => {
      sendNotification(item, state.user.email); // 에러
    });
  }

  // ✅ 지역 변수로 해결
  const user = state.user;
  if (user) {
    items.forEach(item => {
      sendNotification(item, user.email); // OK
    });
  }
}
```

### 클래스 메서드에서

```typescript
class NotificationService {
  private user?: User;

  // ❌ this 프로퍼티 + 콜백
  notify() {
    if (this.user) {
      setTimeout(() => {
        this.send(this.user.email); // 에러
      }, 1000);
    }
  }

  // ✅ 지역 변수로 해결
  notify() {
    const user = this.user;
    if (user) {
      setTimeout(() => {
        this.send(user.email); // OK
      }, 1000);
    }
  }
}
```

---

## 더 나은 타입 설계

지역 변수 외에도 TypeScript가 잘 이해하는 패턴이 있습니다.

### Discriminated Union

```typescript
type ApiResult =
  | { status: 'success'; data: User }
  | { status: 'error'; message: string };

function handle(result: ApiResult) {
  if (result.status === 'success') {
    setTimeout(() => {
      console.log(result.data.name); // ✅ 콜백에서도 OK
    }, 100);
  }
}
```

`status` 프로퍼티가 전체 타입을 결정하는 "판별자" 역할을 합니다. TypeScript가 가장 잘 이해하는 패턴입니다. API 응답이나 상태 관리에서 이런 형태로 타입을 설계하면 좋습니다.

---

## 해결 방법 정리

| 방법                        | 예시                               |
| --------------------------- | ---------------------------------- |
| **지역 변수에 담기 (권장)** | `const user = state.user;`         |
| **구조 분해 할당**          | `const { user } = state;`          |
| **Early Return 패턴**       | `if (!user) return;`               |
| **Discriminated Union**     | `{ status: 'loaded'; user: User }` |

### 1. 지역 변수에 담기

```typescript
const user = state.user;
if (user) {
  setTimeout(() => console.log(user.name), 100); // ✅
}
```

### 2. 구조 분해 할당

```typescript
const { user } = state;
if (user) {
  items.forEach(() => console.log(user.name)); // ✅
}
```

### 3. Early Return 패턴

```typescript
function process(state: State) {
  const user = state.user;
  if (!user) return;

  // 이 아래 전체가 user가 있는 스코프
  setTimeout(() => console.log(user.name), 100); // ✅
}
```

### 4. Discriminated Union으로 타입 설계

```typescript
type State =
  | { status: 'idle' }
  | { status: 'loaded'; user: User }
  | { status: 'error'; message: string };
```

---

## 마치며

정리하면 다음과 같습니다.

- TypeScript가 "의심"하는 것은 **버그가 아니라 의도적 설계**입니다
- **"나중에 실행되는 코드"**에서 값이 바뀔 가능성을 고려하는 것입니다
- **지역 변수에 담으면** TypeScript가 안전하게 추적할 수 있습니다
- **Discriminated Union**을 활용하면 더 나은 타입 추론을 받을 수 있습니다

처음에는 TypeScript가 지나치게 의심이 많다고 느꼈습니다. "분명히 체크했는데"라고 생각했습니다.

하지만 생각해보면 맞는 말입니다. `setTimeout` 콜백이 실행되는 100ms 동안 상태가 바뀌고, 컴포넌트가 언마운트되고, 사용자가 로그아웃할 수도 있습니다.

지역 변수 하나를 더 만드는 작은 습관으로 잠재적인 런타임 에러를 방지할 수 있습니다.

---

## 참고 자료

- [TypeScript Handbook - Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)]]></content:encoded>
          <category>타입과 언어</category>
          <pubDate>Fri, 12 Apr 2024 00:00:00 GMT</pubDate>
        </item>
<item>
          <title>if문 하나로 TypeScript가 똑똑해지는 이유</title>
          <link>https://blog.ssumi.space/blog/typescript-type-narrowing</link>
          <guid isPermaLink="true">https://blog.ssumi.space/blog/typescript-type-narrowing</guid>
          <author>sumi@ssumi.space (SUMI)</author>
          <description>if문 하나로 TypeScript가 똑똑해지는 이유</description>
          <content:encoded><![CDATA[## 들어가며

```typescript
function process(value: string | number) {
  console.log(value.toUpperCase());
  // ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}
```

TypeScript를 처음 사용할 때 이 에러를 자주 만났습니다.

`value`가 `string`일 수도 있으니 `toUpperCase()`를 쓸 수 있어야 하는 것 아닌가? 하지만 TypeScript 입장에서는 `value`가 `number`일 가능성도 있습니다. `number`에는 `toUpperCase()`가 없으니 에러를 내는 거죠.

그런데 if문 하나를 추가하면 에러가 사라집니다.

```typescript
function process(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase()); // ✅ OK!
  }
}
```

TypeScript가 if문 안에서는 `value`가 `string`이라는 걸 인식합니다. 별도의 타입 단언(`as string`) 없이도요.

이게 바로 **Type Narrowing**입니다. 이 개념을 이해하고 나면 TypeScript 코드 작성이 훨씬 수월해집니다.

---

## Type Narrowing이란?

Type Narrowing은 **넓은 타입을 좁은 타입으로 좁혀가는 과정**입니다.

`string | number` → `string`처럼, 가능한 타입의 범위를 줄여나가는 것이죠.

### 왜 이게 중요할까요?

TypeScript의 핵심 가치는 **컴파일 타임에 에러를 잡는 것**입니다. 그런데 Union 타입을 사용하면 "이 값이 정확히 어떤 타입인지 모른다"는 상황이 생깁니다.

```typescript
function formatValue(value: string | number) {
  // value가 string인지 number인지 알 수 없으므로
  // 어떤 메서드도 안전하게 호출할 수 없습니다
}
```

Type Narrowing은 이 문제를 해결합니다. "이 시점에서 이 값은 확실히 string이다"라고 TypeScript에게 알려주는 것이죠.

### TypeScript는 어떻게 타입을 추론할까요?

TypeScript는 코드의 흐름을 분석해서 타입을 자동으로 좁혀줍니다. 이 분석을 **Control Flow Analysis(제어 흐름 분석)**라고 부릅니다. (TypeScript 2.0부터 도입된 기능입니다.)

```typescript
function greet(value: string | number) {
  // 여기서 value는 string | number

  if (typeof value === 'string') {
    // 이 블록에서 value는 string
    console.log(value.toUpperCase());
  } else {
    // 이 블록에서 value는 number
    console.log(value.toFixed(2));
  }
}
```

그리고 이런 분석을 가능하게 하는 조건문들을 **Type Guard**라고 합니다.

그럼 이제부터는 Type Guard를 실제 상황에서 어떻게 사용할 수 있을지 정리해보겠습니다.

---

## Type Guard 총정리

### 1. typeof

원시 타입을 구분하는 가장 기본적인 방법입니다. 개인적으로 가장 자주 쓰는 Type Guard이기도 합니다.

```typescript
function formatValue(value: string | number | boolean) {
  if (typeof value === 'string') {
    return value.trim(); // string
  }
  if (typeof value === 'number') {
    return value.toFixed(2); // number
  }
  return value ? 'Yes' : 'No'; // boolean
}
```

TypeScript는 `typeof` 연산자의 결과를 신뢰합니다. JavaScript 런타임에서 `typeof`가 반환하는 값은 확실하기 때문입니다. `typeof value === 'string'`이 `true`라면, 그 시점에서 `value`는 100% `string`입니다.

**typeof가 반환하는 값들.** `"string"`, `"number"`, `"boolean"`, `"undefined"`, `"object"`, `"function"`, `"symbol"`, `"bigint"`

API 응답을 처리할 때 특히 유용합니다. 서버에서 숫자가 문자열로 올 때도 있고, 숫자 그대로 올 때도 있거든요.

```typescript
function parseAmount(value: string | number): number {
  if (typeof value === 'string') {
    return parseFloat(value);
  }
  return value;
}
```

**주의할 점.** `typeof null`은 `"object"`를 반환합니다. JavaScript의 오래된 버그입니다. 따라서 `null` 체크는 `typeof`로 하면 안 됩니다.

### 2. Truthiness 체크

`null`이나 `undefined`를 걸러내는 가장 간단한 방법입니다. 코드도 짧고 직관적이라 자주 쓰게 됩니다.

```typescript
function greet(name: string | null | undefined) {
  if (name) {
    console.log(`Hello, ${name}!`); // string
  }
}
```

JavaScript에서 `null`과 `undefined`는 falsy 값입니다. 따라서 `if (value)`를 통과했다면, `value`는 `null`도 `undefined`도 아닌 것이 확실합니다. TypeScript는 이 JavaScript 동작을 알고 있어서 타입을 자동으로 좁혀줍니다.

아래는 React에서 Optional props를 처리할 때 자주 사용하는 패턴입니다.

```typescript
function UserCard({ user }: { user?: User }) {
  if (user) {
    return <div>{user.name}</div>;
  }
  return <div>Loading...</div>;
}
```

배열에도 동일하게 적용됩니다.

```typescript
function getLength(arr?: string[]) {
  if (arr) {
    return arr.length; // string[]
  }
  return 0;
}
```

그런데 여기서 함정이 하나 있습니다. `0`, `""`, `NaN`도 falsy 값이라서 의도치 않게 걸러질 수 있습니다.

```typescript
function goToPage(page: number | null) {
  if (page) {
    // ⚠️ page가 0이면 이 블록에 진입하지 않습니다
    navigate(`/list?page=${page}`);
  }

  // 명시적인 null 체크가 더 안전합니다
  if (page !== null) {
    // page가 0이어도 진입합니다
    navigate(`/list?page=${page}`);
  }
}
```

실제로 페이지네이션에서 첫 페이지(0)로 이동이 안 되는 버그를 만난 적이 있습니다. `if (page)` 조건 때문이었습니다. 그 이후로 숫자를 다룰 때는 `!== null`을 명시적으로 사용합니다.

### 3. 동등 비교 (===, !==)

Truthiness 체크는 "falsy가 아닌 모든 것"을 통과시킵니다. 반면 동등 비교는 **정확히 그 값인지** 확인합니다. `null`만 걸러내고 싶을 때, `0`이나 `""`는 살리고 싶을 때 동등 비교가 필요합니다.

```typescript
function handle(value: string | null) {
  if (value === null) {
    return 'No value';
  }
  return value.toUpperCase(); // string
}
```

리터럴 타입과 함께 사용하면 더 강력해집니다.

```typescript
type Status = 'loading' | 'success' | 'error';

function getMessage(status: Status) {
  if (status === 'loading') {
    return 'Loading...'; // 'loading'
  }
  if (status === 'success') {
    return 'Done!'; // 'success'
  }
  return 'Something went wrong'; // 'error'
}
```

API 호출 상태를 관리할 때 유용한 패턴입니다.

### 4. in 연산자

객체에 특정 프로퍼티가 있는지로 타입을 구분합니다.

```typescript
interface AdminUser {
  role: 'admin';
  permissions: string[];
}

interface GuestUser {
  role: 'guest';
  expiresAt: Date;
}

function getUserInfo(user: AdminUser | GuestUser) {
  if ('permissions' in user) {
    console.log(`권한: ${user.permissions.join(', ')}`); // AdminUser
  } else {
    console.log(`만료일: ${user.expiresAt}`); // GuestUser
  }
}
```

API 응답 처리에서 성공/실패 응답의 구조가 다를 때 유용합니다.

```typescript
interface SuccessResponse {
  data: User;
}
interface ErrorResponse {
  error: string;
}

function handleResponse(res: SuccessResponse | ErrorResponse) {
  if ('error' in res) {
    console.error(res.error); // ErrorResponse
    return;
  }
  console.log(res.data); // SuccessResponse
}
```

`res.error`로 바로 접근하면 안 되는 이유가 있습니다. `SuccessResponse`에는 `error` 프로퍼티가 정의되어 있지 않기 때문에 접근 자체가 타입 에러가 됩니다. `in` 연산자는 런타임에 프로퍼티 존재 여부를 체크하면서 TypeScript에게 타입 정보도 전달합니다.

### 5. instanceof

클래스로 만든 객체를 구분할 때 씁니다.

```typescript
class ApiError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

function handleError(error: Error) {
  if (error instanceof ApiError) {
    console.log(`Status: ${error.statusCode}`); // ApiError
  } else {
    console.log(error.message); // Error
  }
}
```

`instanceof`는 객체의 프로토타입 체인을 확인합니다. `error instanceof ApiError`가 `true`라면, `error`는 `ApiError` 클래스(또는 그 하위 클래스)의 인스턴스입니다. TypeScript는 이를 통해 해당 클래스의 프로퍼티와 메서드에 안전하게 접근할 수 있다고 판단합니다.

커스텀 에러 클래스를 만들어서 에러 종류별로 다르게 처리할 수 있습니다.

```typescript
class ValidationError extends Error {
  field: string;
  constructor(field: string, message: string) {
    super(message);
    this.field = field;
  }
}

class NetworkError extends Error {
  retryable: boolean;
  constructor(message: string, retryable: boolean) {
    super(message);
    this.retryable = retryable;
  }
}

function handleError(error: Error) {
  if (error instanceof ValidationError) {
    showFieldError(error.field, error.message);
  } else if (error instanceof NetworkError && error.retryable) {
    showRetryButton();
  } else {
    showGenericError(error.message);
  }
}
```

### 6. Array.isArray

`typeof []`는 `"object"`를 반환합니다. 그래서 배열인지 확인하려면 `Array.isArray`를 써야 합니다.

```typescript
function process(input: string | string[]) {
  if (Array.isArray(input)) {
    return input.join(', '); // string[]
  }
  return input; // string
}
```

API 응답이 단일 객체일 수도 있고 배열일 수도 있을 때 유용합니다.

```typescript
function normalizeResponse(data: User | User[]) {
  if (Array.isArray(data)) {
    return data; // User[]
  }
  return [data]; // User -> User[]
}
```

이렇게 정규화하면 이후 코드에서 항상 배열로 일관되게 처리할 수 있습니다.

### 7. Discriminated Union

복잡한 상태를 다룰 때 가장 권장하는 패턴입니다. 공통된 리터럴 프로퍼티(discriminant)로 타입을 구분합니다.

```typescript
type ApiState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };

function render(state: ApiState) {
  switch (state.status) {
    case 'idle':
      return <div>Ready</div>;
    case 'loading':
      return ;
    case 'success':
      return ; // data 접근 가능
    case 'error':
      return ; // error 접근 가능
  }
}
```

이 패턴의 핵심 장점은 **불가능한 상태 조합을 타입 레벨에서 방지한다**는 것입니다.

아래와 같은 설계를 비교해보면 차이가 명확합니다.

```typescript
// ❌ 문제가 있는 설계: 불가능한 상태 조합이 허용됨
interface State {
  isLoading: boolean;
  data: User | null;
  error: string | null;
}

// isLoading: true이면서 data와 error가 모두 존재하는 상태가 가능
// 이런 상태는 논리적으로 말이 안 됩니다
```

Discriminated Union을 사용하면 이런 논리적 오류를 컴파일 타임에 방지할 수 있습니다.

Redux나 Zustand의 액션 타입에서도 이 패턴이 널리 사용됩니다.

```typescript
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; payload: number };

function reducer(state: number, action: Action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'SET':
      return action.payload; // payload 접근 가능
  }
}
```

---

## 사용자 정의 Type Guard (is 키워드)

기본 Type Guard들로 해결이 안 되는 경우가 있습니다. 복잡한 조건이 필요하거나, 조건을 재사용하고 싶을 때입니다.

이럴 때 `is` 키워드로 직접 Type Guard를 정의할 수 있습니다.

### 기본 문법

```typescript
function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
  return 'permissions' in user;
}
```

반환 타입이 `user is AdminUser`인 것이 핵심입니다. "이 함수가 `true`를 반환하면 `user`는 `AdminUser` 타입이다"라고 TypeScript에게 알려주는 것이죠.

단순히 `boolean`을 반환하는 것과의 차이를 살펴보겠습니다.

```typescript
// ❌ boolean 반환: 타입이 좁혀지지 않음
function isAdminBoolean(user: AdminUser | GuestUser): boolean {
  return 'permissions' in user;
}

if (isAdminBoolean(user)) {
  console.log(user.permissions); // 에러: user는 여전히 AdminUser | GuestUser
}

// ✅ is 키워드 사용: 타입이 좁혀짐
function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
  return 'permissions' in user;
}

if (isAdmin(user)) {
  console.log(user.permissions); // OK: user는 AdminUser
}
```

### API 응답 체크 실무 예시

```typescript
interface SuccessResponse {
  success: true;
  data: User;
}

interface ErrorResponse {
  success: false;
  error: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

// 사용자 정의 Type Guard
function isSuccess(res: ApiResponse): res is SuccessResponse {
  return res.success === true;
}

// 사용
function handleResponse(res: ApiResponse) {
  if (isSuccess(res)) {
    console.log(res.data); // SuccessResponse
  } else {
    console.error(res.error); // ErrorResponse
  }
}
```

이렇게 정의하면 여러 곳에서 `isSuccess` 함수를 재사용할 수 있습니다.

### 배열 필터링에서의 활용

Type Guard는 `filter`와 함께 사용할 때 특히 유용합니다.

```typescript
const items: (string | null)[] = ['a', null, 'b', null, 'c'];

// ❌ 일반 filter: 타입이 (string | null)[]로 유지됨
const filtered1 = items.filter(item => item !== null);
// filtered1의 타입: (string | null)[]

// ✅ Type Guard 사용: 타입이 string[]로 좁혀짐
function isNotNull<T>(value: T | null): value is T {
  return value !== null;
}
const filtered2 = items.filter(isNotNull);
// filtered2의 타입: string[]
```

이 차이가 중요한 이유는, `filtered1`을 사용할 때마다 `null` 체크를 다시 해야 하기 때문입니다. `filtered2`는 그럴 필요가 없습니다.

실무에서는 이런 식으로 활용합니다.

```typescript
// 비어있지 않은 문자열만 필터링
function isNonEmptyString(value: string | null | undefined): value is string {
  return value !== null && value !== undefined && value.length > 0;
}

const tags = ['react', '', null, 'typescript', undefined];
const validTags = tags.filter(isNonEmptyString); // string[]
```

---

## 어떤 Type Guard를 선택할까?

상황별로 정리하면 다음과 같습니다.

| 상황                              | Type Guard                    |
| --------------------------------- | ----------------------------- |
| 원시 타입(string, number 등) 구분 | `typeof`                      |
| null/undefined 체크               | Truthiness 또는 `=== null`    |
| 숫자/문자열의 null 체크           | `!== null` (0, ""을 살리려면) |
| 특정 값 체크                      | `===` 동등 비교               |
| 객체의 프로퍼티로 구분            | `in` 연산자                   |
| 클래스 인스턴스 구분              | `instanceof`                  |
| 배열 여부 확인                    | `Array.isArray`               |
| 복잡한 상태 관리                  | Discriminated Union           |
| 재사용 가능한 조건                | 사용자 정의 Type Guard (`is`) |

---

## 마치며

정리하면 다음과 같습니다.

- **Type Narrowing**은 넓은 타입을 좁은 타입으로 좁혀가는 과정입니다
- TypeScript는 **Control Flow Analysis**로 이를 자동으로 수행합니다
- **Type Guard**는 이 분석을 가능하게 하는 조건문입니다
- 상황에 맞는 Type Guard를 선택하면 타입 단언(`as`) 없이도 안전한 코드를 작성할 수 있습니다

Type Narrowing을 제대로 활용하면서 `as` 키워드 사용이 크게 줄었습니다. 이전에는 "TypeScript가 왜 이걸 모르지?"라며 `as`를 자주 사용했는데, 이제는 "내가 TypeScript에게 충분한 정보를 제공하지 않았구나"라고 생각하게 되었습니다.

그런데 한 가지 의문이 남습니다.

```typescript
if (state.user) {
  setTimeout(() => {
    console.log(state.user.name); // ❌ 에러?!
  }, 100);
}
```

분명 if문에서 체크했는데, 왜 콜백 안에서는 에러가 발생할까요?

이 질문에 대한 답은 다음 글에서 다루겠습니다.

**다음 글.** [TypeScript는 왜 내 코드를 의심할까](https://velog.io/@sumi-0011/typescript-narrowing-2)

---

## 참고 자료

- [TypeScript Handbook - Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)]]></content:encoded>
          <category>타입과 언어</category>
          <pubDate>Tue, 09 Apr 2024 00:00:00 GMT</pubDate>
        </item>
    </channel>
  </rss>