<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dam2.log</title>
        <link>https://velog.io/</link>
        <description>🌐 DOM 위에서 살아남기</description>
        <lastBuildDate>Mon, 11 May 2026 05:39:09 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dam2.log</title>
            <url>https://velog.velcdn.com/images/do_dam/profile/7e21cf51-6775-4e7b-b8b5-e01170e4fdaa/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dam2.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/do_dam" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[i18n과 moment.js 로케일 동기화]]></title>
            <link>https://velog.io/@do_dam/i18n%EA%B3%BC-moment.js-%EB%A1%9C%EC%BC%80%EC%9D%BC-%EB%8F%99%EA%B8%B0%ED%99%94</link>
            <guid>https://velog.io/@do_dam/i18n%EA%B3%BC-moment.js-%EB%A1%9C%EC%BC%80%EC%9D%BC-%EB%8F%99%EA%B8%B0%ED%99%94</guid>
            <pubDate>Mon, 11 May 2026 05:39:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>4개 언어를 지원하는 예약 사이트를 개발하던 중, 언어를 바꿔도 날짜의 요일 표기만 그대로 남는 이슈를 발견했다. 원인은 <code>i18n</code>과 <code>moment.js</code>가 서로 다른 로케일 체계를 사용한다는 점이었다. 두 라이브러리를 언어 변경 이벤트로 동기화하고, 중국어 weekday는 moment 기본 표기(<code>周X</code>)와 디자인 시안(<code>星期X</code>)이 달라 디자이너와 협의를 거쳐 정자형으로 통일한 과정을 정리한다.</p>
</blockquote>
<hr>
<h2 id="1-문제-인식">1. 문제 인식</h2>
<p>사이트 상단의 언어 선택을 한국어에서 영어로 바꾸자 메뉴와 본문 텍스트는 영어로 잘 바뀌었지만, 예약 캘린더의 요일 표기만은 여전히 한국어(&quot;일 / 월 / 화 / 수 / 목 / 금 / 토&quot;)로 남아있었다.</p>
<p>처음에는 번역 키가 빠진 줄 알고 번역 파일을 확인했다. 그런데 해당 텍스트는 번역 키 자체가 아니었다. 코드를 따라가 보니 그 자리에는 <code>moment.js</code>로 포맷팅한 결과가 들어가고 있었다.</p>
<pre><code class="language-typescript">moment(date).format(&#39;dddd&#39;); 
// → &#39;월요일&#39; (언어를 영어로 바꿔도 계속 한국어)</code></pre>
<p>호출부에서는 어떤 언어 관련 인자도 전달하고 있지 않았다. 그렇다면 출력 결과를 결정하는 것은 <code>moment</code> 내부의 로케일 상태일 수밖에 없었다. 콘솔에서 <code>moment.locale()</code>을 확인해 보니, 언어를 영어로 바꿔도 값은 계속 <code>&#39;ko&#39;</code>로 남아 있었다. i18n 쪽 언어를 바꿔도 <code>moment</code>의 로케일은 그대로라는 의미였다. 그제야 <strong>두 라이브러리가 서로의 로케일을 공유하지 않는다</strong>는 점을 알게 됐다.</p>
<p>그렇다면 각 라이브러리가 왜 자기만의 로케일을 갖고 있는지부터 짚어볼 필요가 있었다.</p>
<hr>
<h2 id="2-원인-분석">2. 원인 분석</h2>
<p>대부분의 i18n 라이브러리(<code>i18next</code>, <code>react-intl</code> 등)는 번역 텍스트만 관리한다. <code>t(&#39;greeting&#39;)</code>이 <code>&#39;안녕하세요&#39;</code>로 매핑되는 키-값 구조가 핵심이다.</p>
<p>반면 <code>moment.js</code>(혹은 <code>date-fns</code>, <code>dayjs</code>)는 날짜와 시간 포맷팅을 위한 자체 로케일을 별도로 갖는다. 월 이름, 요일 이름, 시간 형식(&quot;오전 9시&quot; vs &quot;9 AM&quot;), 상대 시간 표현(&quot;3일 전&quot;) 같은 정보가 여기에 들어있다.</p>
<pre><code class="language-typescript">// i18n: 텍스트 키-값 매핑
i18n.t(&#39;greeting&#39;) // → &#39;안녕하세요&#39;

// moment: 날짜 포맷팅에 자체 로케일 사용
moment().format(&#39;LL&#39;) // → &#39;2025년 11월 11일&#39; (한국어 로케일일 때)</code></pre>
<p>즉, <code>i18n.changeLanguage(&#39;en&#39;)</code>을 호출해도 <code>moment</code>의 로케일은 바뀌지 않는다. 그래서 번역 텍스트는 전환되지만 날짜 표기는 한국어로 남는, 절반만 동작하는 상태가 된다.</p>
<p>같은 문제는 <code>moment</code>뿐 아니라 브라우저 내장 <code>Intl.NumberFormat</code>, <code>Intl.DateTimeFormat</code>, 통화·숫자 포맷팅 라이브러리 전반에 동일하게 적용된다. 각 라이브러리는 자체 로케일을 가지고 있고, 명시적으로 동기화하지 않으면 따로 움직인다.</p>
<p>결국 두 로케일을 명시적으로 묶어주는 작업이 필요했다.</p>
<hr>
<h2 id="3-1차-해결--언어-변경-이벤트로-두-로케일-연결">3. 1차 해결 — 언어 변경 이벤트로 두 로케일 연결</h2>
<p><code>i18next</code>는 언어가 바뀔 때마다 <code>languageChanged</code> 이벤트를 발화한다. 이 이벤트에 <code>moment.locale()</code> 호출을 연결하면 두 로케일이 함께 움직인다.</p>
<pre><code class="language-typescript">import i18n from &#39;i18next&#39;;
import moment from &#39;moment&#39;;

// 언어 변경 이벤트 → moment 로케일 동기화
i18n.on(&#39;languageChanged&#39;, (lng) =&gt; {
  moment.locale(lng);
});</code></pre>
<p>한 가지 주의할 점이 있다. <code>moment</code>는 기본적으로 영어 로케일만 들고 있다. 다른 언어를 사용하려면 명시적으로 import해서 등록해야 한다.</p>
<pre><code class="language-typescript">import &#39;moment/locale/ja&#39;;     // 일본어
import &#39;moment/locale/zh-cn&#39;;  // 중국어
// 한국어는 &#39;moment/locale/ko&#39;</code></pre>
<p><code>import</code>만 해두면 <code>moment</code>가 내부적으로 해당 로케일을 등록한다. 등록되지 않은 로케일을 <code>moment.locale(&#39;xx&#39;)</code>로 호출하면 오류 없이 영어로 폴백되기 때문에, 누락된 import는 런타임에 발견하기 어렵다.</p>
<p>이 설정까지 적용하면 한국어 / 영어 / 일본어 간 전환은 의도대로 동작했다. 다만 중국어 화면에서는 또 다른 이슈가 남아 있었다.</p>
<hr>
<h2 id="4-추가-이슈--중국어-weekday-표기-차이">4. 추가 이슈 — 중국어 weekday 표기 차이</h2>
<p>QA를 돌리던 중, 중국어 화면의 요일 표기가 디자인 시안과 다른 것을 발견했다.</p>
<p>확인해 보니 <code>moment</code>의 <code>zh-cn</code> 기본 로케일은 요일을 다음과 같이 출력하고 있었다.</p>
<pre><code>周日 周一 周二 周三 周四 周五 周六</code></pre><p>반면 디자인 시안은 정자형이었다.</p>
<pre><code>星期日 星期一 星期二 星期三 星期四 星期五 星期六</code></pre><p>두 표기 모두 문법적으로 올바른 중국어다. 차이는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>표기</th>
<th>특성</th>
<th>보통 쓰이는 곳</th>
</tr>
</thead>
<tbody><tr>
<td><code>周X</code></td>
<td>간결, 일상</td>
<td>모바일 UI / 캘린더 앱 / 일상 회화</td>
</tr>
<tr>
<td><code>星期X</code></td>
<td>격식, 정자</td>
<td>공식 문서 / 문어체 / 전통 콘텐츠</td>
</tr>
</tbody></table>
<p>라이브러리 기본값(<code>周X</code>)이 틀린 표기는 아니었기 때문에 처음에는 그대로 두는 것도 고려했다. 다만 디자이너가 정자형을 의도해서 선택했을 가능성이 있어 의도를 확인해 보았는데, 한옥 예약 서비스 특유의 전통적이고 격식 있는 톤을 살리기 위해 의도적으로 정자형을 선택한 것이었다.</p>
<hr>
<h2 id="5-2차-해결--momentupdatelocale로-weekday-덮어쓰기">5. 2차 해결 — <code>moment.updateLocale</code>로 weekday 덮어쓰기</h2>
<p>요일 표기만 바꾸기 위해 <code>moment.updateLocale</code>을 사용했다.</p>
<pre><code class="language-typescript">const updateMomentLocale = (lng: string) =&gt; {
  if (lng === &#39;zh&#39;) {
    // 중국어는 디자인 시안에 맞춰 weekday 커스텀
    moment.updateLocale(&#39;zh-cn&#39;, {
      weekdays: [&#39;星期日&#39;, &#39;星期一&#39;, &#39;星期二&#39;, &#39;星期三&#39;, &#39;星期四&#39;, &#39;星期五&#39;, &#39;星期六&#39;],
    });
  } else {
    moment.locale(lng);
  }
};</code></pre>
<p>여기서 <code>moment.locale</code>과 <code>moment.updateLocale</code>은 동작이 다르다.</p>
<ul>
<li><strong><code>moment.locale(&#39;zh-cn&#39;)</code></strong> — 어떤 로케일을 사용할지 선택한다. 기존 로케일을 그대로 사용한다.</li>
<li><strong><code>moment.updateLocale(&#39;zh-cn&#39;, { ... })</code></strong> — 기존 로케일의 일부 설정만 덮어쓴다. 명시한 필드(<code>weekdays</code>)만 바뀌고, 나머지 설정(월 이름, 시간 표기, 상대 시간 등)은 기본값을 유지한다.</li>
</ul>
<p><code>updateLocale</code>을 사용하면 <code>weekdays</code>만 정자형으로 바꾸고, 월 이름(<code>一月 / 二月 / ...</code>)이나 시간 표기는 기본 <code>zh-cn</code> 값을 그대로 사용할 수 있다.</p>
<hr>
<h2 id="6-최종-코드">6. 최종 코드</h2>
<p>앞서 만든 모든 처리를 합치면 다음과 같이 정리된다.</p>
<pre><code class="language-typescript">import i18n from &#39;i18next&#39;;
import moment from &#39;moment&#39;;
import &#39;moment/locale/ja&#39;;
import &#39;moment/locale/zh-cn&#39;;

// i18n 언어 → moment 로케일 동기화 함수
const updateMomentLocale = (lng: string) =&gt; {
  if (lng === &#39;zh&#39;) {
    // 중국어는 디자인 시안에 맞춰 weekday 커스텀
    moment.updateLocale(&#39;zh-cn&#39;, {
      weekdays: [&#39;星期日&#39;, &#39;星期一&#39;, &#39;星期二&#39;, &#39;星期三&#39;, &#39;星期四&#39;, &#39;星期五&#39;, &#39;星期六&#39;],
    });
  } else {
    moment.locale(lng);
  }
};

// 1) 페이지 진입 시 1회 동기화
updateMomentLocale(i18n.language);

// 2) 이후 언어 변경마다 동기화
i18n.on(&#39;languageChanged&#39;, updateMomentLocale);</code></pre>
<p>15줄 남짓의 코드지만, 절반만 동작하던 다국어를 완성된 상태로 만들어 준다.</p>
<hr>
<h2 id="7-정리">7. 정리</h2>
<p>기술적으로 어려운 작업은 아니었다. <code>moment</code>와 <code>i18next</code>의 문서를 차근차근 따라가다 보면 패턴이 어렵지 않게 보이는 수준이었다. 다만 이 과정에서 정리해 둘 만한 두 가지가 있었다.</p>
<h3 id="1-라이브러리마다-로케일은-별도다">1) 라이브러리마다 로케일은 별도다</h3>
<p>i18n 라이브러리가 번역 텍스트의 로케일을 관리한다고 해서, 날짜·통화·숫자 같은 다른 영역의 로케일까지 함께 챙겨주는 것은 아니다. 라이브러리마다 자체 로케일이 따로 있고, 이를 묶어주는 처리가 없으면 동기화되지 않는다.</p>
<p>다국어 사이트를 만들 때는 지금 이 데이터가 어느 로케일을 보고 있는지 항상 점검할 필요가 있다. <code>moment</code>뿐 아니라 날짜·시간·통화·숫자를 다루는 모든 영역이 잠재적인 동기화 포인트다.</p>
<h3 id="2-라이브러리-기본값과-디자인-의도는-다를-수-있다">2) 라이브러리 기본값과 디자인 의도는 다를 수 있다</h3>
<p><code>moment</code>의 <code>zh-cn</code> 기본 표기가 틀린 것은 아니었다. 단지 디자인 의도와 달랐을 뿐이다. 코드가 동작하는 것과 의도대로 동작하는 것은 다른 문제다. 그리고 그 의도는 코드만 들여다봐서는 알 수 없는 경우가 많다.</p>
<p>디자이너에게 의도를 확인한 덕분에 서비스 톤의 일관성을 가져갈 수 있었다.</p>
<hr>
<p>QA에서 마주친 작은 어색함을 지나치지 않은 것, 그리고 라이브러리 기본값을 그대로 받아들이지 않고 한 번 더 확인한 것 — 결국 이 두 가지가 서비스의 디테일을 만들어 준 과정이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js · Vite · Astro 6개 앱을 하나의 pnpm 모노레포로 통합하기]]></title>
            <link>https://velog.io/@do_dam/Next.js-Vite-Astro-6%EA%B0%9C-%EC%95%B1%EC%9D%84-%ED%95%98%EB%82%98%EC%9D%98-pnpm-%EB%AA%A8%EB%85%B8%EB%A0%88%ED%8F%AC%EB%A1%9C-%ED%86%B5%ED%95%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dam/Next.js-Vite-Astro-6%EA%B0%9C-%EC%95%B1%EC%9D%84-%ED%95%98%EB%82%98%EC%9D%98-pnpm-%EB%AA%A8%EB%85%B8%EB%A0%88%ED%8F%AC%EB%A1%9C-%ED%86%B5%ED%95%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 30 Apr 2026 01:48:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Next.js · Vite · Astro로 만들어진 6개 앱과 3개의 공유 패키지(UI 라이브러리, API 서비스, 다국어 리소스)를 하나의 pnpm 워크스페이스로 묶은 작업 기록이다.
<code>pnpm-workspace.yaml</code>을 추가하는 수준이 아니라, 다중 프레임워크가 공존할 때 생기는 빌드 / HMR / 배포 / 타입 동기화 이슈를 어떻게 풀었는지에 초점을 맞췄다.</p>
</blockquote>
<hr>
<h2 id="1-모노레포로-옮긴-이유">1. 모노레포로 옮긴 이유</h2>
<p>원래 구조는 폴리레포였다. 호스트 관리 페이지, 게스트 예약 포털, 디자인 스튜디오, 랜딩 페이지가 각각 다른 저장소에 있었다. 그러다 보니 다음 문제가 누적됐다.</p>
<ul>
<li><strong>코드 중복</strong>: 같은 모양의 버튼·인풋·다이얼로그가 4번씩 작성된다.</li>
<li><strong>타입 불일치</strong>: 백엔드 Swagger에서 자동 생성한 클라이언트가 앱마다 따로 존재해서 DTO가 미묘하게 어긋난다.</li>
<li><strong>다국어 동기화 누락</strong>: ko·en·ja·fr 4개 언어 키를 저장소마다 따로 추가해야 하고, 빠뜨리면 런타임에 키가 그대로 노출된다.</li>
<li><strong>PR 검토 비용</strong>: 한 기능이 호스트·게스트·스튜디오에 동시에 영향을 줘도 PR이 3개로 쪼개져서 리뷰 흐름이 깨진다.</li>
</ul>
<p>그래서 <code>pnpm workspaces</code> + <code>Turborepo</code> 조합으로 단일 저장소로 합쳤다. yarn / npm workspaces 대신 pnpm을 고른 이유는 크게 두 가지였다.</p>
<ol>
<li><strong>하드 링크 기반 설치라 디스크 사용량이 적다.</strong> 앱 6개가 각자 무거운 <code>node_modules</code>를 들고 있는 상황을 떠올려 보면, 같은 패키지를 6번 복사하지 않고 store에 한 번만 두는 pnpm 방식이 결정적인 차이를 만든다.</li>
<li><strong><code>workspace:*</code> 프로토콜로 내부 패키지 의존성을 명시적으로 표현할 수 있다.</strong> 버전 번호 대신 이 키워드 하나로 워크스페이스 안의 다른 패키지를 가리킬 수 있어서, 내부 패키지 변경이 곧장 모든 앱에 반영된다.</li>
</ol>
<hr>
<h2 id="2-최종-디렉토리-구조">2. 최종 디렉토리 구조</h2>
<pre><code>stay-monorepo/
├── packages/
│   ├── stay-front/             # Next.js 15  - 호스트 관리 (port 4100)
│   ├── stay-host/              # Vite + React - 신규 호스트 SPA (port 4100)
│   ├── studio/                  # Next.js 15  - 디자인 스튜디오 (port 4000)
│   ├── stay-guest-portal/      # Astro       - 게스트 예약 포털 (port 4200)
│   ├── stay-landing/           # Astro       - 마케팅 랜딩 (port 4300)
│   ├── stay-planner/           # Astro       - 플래너 툴
│   │
│   ├── stay-ui/                # 공유: 디자인 시스템 (Vite library build)
│   ├── stay-common-services/   # 공유: API 클라이언트 + 타입
│   ├── stay-shared-locales/    # 공유: 4개 언어 i18n 리소스
│   │
│   ├── stay-host-api/          # AWS Lambda API
│   ├── stay-lambda/            # AWS Lambda (온보딩/번역)
│   └── stay-embedding-lambda/  # AWS Lambda (AI 임베딩)
│
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json
├── package.json
└── deploy.sh</code></pre><p>루트 <code>package.json</code>은 패키지 매니저를 고정하고, 모든 패키지를 워크스페이스로 잡는다.</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;stay-monorepo&quot;,
  &quot;private&quot;: true,
  &quot;packageManager&quot;: &quot;pnpm@10.13.1&quot;,
  &quot;workspaces&quot;: [&quot;packages/**&quot;]
}</code></pre>
<pre><code class="language-yaml"># pnpm-workspace.yaml
packages:
  - &quot;packages/**&quot;
  - &quot;!packages/stay-front&quot;</code></pre>
<p><code>!packages/stay-front</code>처럼 워크스페이스에서 명시적으로 제외할 수 있는 게 중요하다. 마이그레이션 도중 일부 앱은 잠시 외부 의존성으로 두고 점진적으로 끌어올 수 있어서, 한 번에 모든 앱을 옮기지 않아도 된다.</p>
<hr>
<h2 id="3-공유-패키지-3종-설계">3. 공유 패키지 3종 설계</h2>
<p>모노레포의 핵심은 결국 &quot;공유 패키지를 어떻게 쪼개느냐&quot;다. 잘못 자르면 한 패키지를 고칠 때마다 모든 앱이 리빌드된다.</p>
<h3 id="31-stayui--디자인-시스템">3.1 <code>@stay/ui</code> — 디자인 시스템</h3>
<p>가장 무거운 공유 패키지다. SVG 아이콘 150개, GridBuilder(드래그 앤 드롭 페이지 빌더), 어메니티 카탈로그, MUI/Mantine 래퍼 등이 들어 있다.</p>
<p>전체를 단일 번들로 export하면 호스트 앱이 1MB짜리 chunk를 그대로 들고 가게 된다. 그래서 다중 엔트리(multi-entry) 라이브러리 빌드로 설계했다.</p>
<pre><code class="language-ts">// packages/stay-ui/vite.config.ts (요약)
const entries = {
  index: &quot;src/index.ts&quot;,
  inputs: &quot;src/components/inputs/index.ts&quot;,
  gridBuilder: &quot;src/components/gridBuilder/index.ts&quot;,
  layouts: &quot;src/components/layout/index.ts&quot;,
  utils: &quot;src/utils/index.ts&quot;,
  types: &quot;src/types/index.ts&quot;,
  themes: &quot;src/themes/index.ts&quot;,
  hooks: &quot;src/hooks/index.ts&quot;,
  i18n: &quot;src/i18n/index.ts&quot;,
  templateComponents: &quot;src/components/templateComponents/index.tsx&quot;,
  // ... 27개+
};

export default defineConfig({
  build: {
    lib: { entry: entries, formats: [&quot;es&quot;], fileName: (_, name) =&gt; `${name}.js` },
    rollupOptions: {
      external: [
        &quot;react&quot;, &quot;react-dom&quot;, &quot;styled-components&quot;,
        /^@mui\//, /^@mantine\//, /^@dnd-kit\//, /^@xyflow\//,
        &quot;framer-motion&quot;, &quot;zustand&quot;, &quot;axios&quot;, ...
      ],
    },
  },
});</code></pre>
<p>그리고 <code>package.json</code>의 <code>exports</code> 필드로 각 엔트리에 별도 경로를 부여한다.</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@stay/ui&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;:              { &quot;types&quot;: &quot;./dist/index.d.ts&quot;,            &quot;import&quot;: &quot;./dist/index.js&quot; },
    &quot;./inputs&quot;:       { &quot;types&quot;: &quot;./dist/components/inputs/index.d.ts&quot;,     &quot;import&quot;: &quot;./dist/inputs.js&quot; },
    &quot;./gridBuilder&quot;:  { &quot;types&quot;: &quot;./dist/components/gridBuilder/index.d.ts&quot;,&quot;import&quot;: &quot;./dist/gridBuilder.js&quot; },
    &quot;./hooks&quot;:        { &quot;types&quot;: &quot;./dist/hooks/index.d.ts&quot;,       &quot;import&quot;: &quot;./dist/hooks.js&quot; },
    &quot;./themes&quot;:       { &quot;types&quot;: &quot;./dist/themes/index.d.ts&quot;,      &quot;import&quot;: &quot;./dist/themes.js&quot; },
    &quot;./styles&quot;:       { &quot;import&quot;: &quot;./src/styles/index.css&quot; }
  }
}</code></pre>
<p>이렇게 해두면 앱 코드에서 필요한 부분만 import할 수 있고, 트리 셰이킹이 명확하게 동작한다.</p>
<pre><code class="language-ts">import { StayButton } from &quot;@stay/ui/inputs&quot;;
import { useGridBuilderStore } from &quot;@stay/ui/gridBuilder&quot;;
import { stayTheme } from &quot;@stay/ui/themes&quot;;</code></pre>
<h3 id="32-staycommon-services--api-클라이언트--공용-비즈니스-로직">3.2 <code>@stay/common-services</code> — API 클라이언트 + 공용 비즈니스 로직</h3>
<p>이 패키지에는 두 가지가 들어간다.</p>
<ul>
<li>백엔드 Swagger에서 자동 생성된 TypeScript Axios 클라이언트</li>
<li>DynamoDB 리포지토리, JWT 갱신 로직, Zustand 기반 <code>useAuthStore</code> 등 앱 간 공유 비즈니스 로직</li>
</ul>
<p>빌드 산출물(<code>dist</code>)을 쓸 수도 있지만, 개발 편의를 위해 <code>exports</code>에서 TS 소스를 그대로 노출하는 방식을 택했다.</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@stay/common-services&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;:                                    &quot;./src/index.ts&quot;,
    &quot;./services&quot;:                           &quot;./src/services/helpers/index.ts&quot;,
    &quot;./dynamodb/repositories&quot;:              &quot;./src/dynamodb/repositories/index.ts&quot;,
    &quot;./aiDefinitions/pageTypeSectionConfig&quot;:&quot;./src/aiDefinitions/pageTypeSectionConfig.ts&quot;,
    &quot;./*&quot;:                                  &quot;./src/*&quot;
  }
}</code></pre>
<p>장점은 두 가지다.</p>
<ul>
<li>빌드를 거치지 않으니 수정 → 즉시 앱에 반영된다 (HMR이 바로 잡힌다).</li>
<li>Swagger 클라이언트를 재생성해도 별도 publish 절차 없이 곧장 동기화된다.</li>
</ul>
<p>대신 각 앱의 번들러가 이 패키지를 트랜스파일 대상에 포함해야 한다. 외부 패키지로 인식하면 TS 소스를 JS로 변환하지 못해 빌드가 깨지기 때문이다. Next.js는 <code>transpilePackages</code>, Vite는 <code>optimizeDeps.include</code>에 명시하는 식으로 처리한다.</p>
<h3 id="33-stayshared-locales--4개-언어-다국어-리소스">3.3 <code>@stay/shared-locales</code> — 4개 언어 다국어 리소스</h3>
<p>ko·en·ja·fr 4개 언어 × 13개 namespace(<code>common</code>, <code>error</code>, <code>guestPortal</code>, <code>editor</code> 등)의 JSON 리소스를 단일 객체로 export한다.</p>
<pre><code class="language-ts">// packages/stay-shared-locales/src/locales/index.ts
import koCommon from &quot;./ko/common.json&quot;;
import enCommon from &quot;./en/common.json&quot;;
// ...

export const sharedLocales = {
  ko: { common: koCommon, error: koError, guestPortal: koGuestPortal, ... },
  en: { common: enCommon, error: enError, guestPortal: enGuestPortal, ... },
  ja: { ... },
  fr: { ... },
} as const;</code></pre>
<p><code>package.json</code>은 빌드 산출물 없이 TS 소스 자체를 main으로 잡는다.</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@stay/shared-locales&quot;,
  &quot;main&quot;: &quot;src/index.ts&quot;,
  &quot;types&quot;: &quot;src/index.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./src/index.ts&quot;,
      &quot;import&quot;: &quot;./src/index.ts&quot;
    }
  }
}</code></pre>
<blockquote>
<p>이 한 줄짜리 설정 덕분에 번역 키 한 개만 추가하면 6개 앱에 동시 반영된다. 
폴리레포 구조에서는 같은 키를 4개 저장소에 따로 추가해야 했는데, 그 작업이 통째로 사라졌다.</p>
</blockquote>
<p>각 앱은 이 객체를 i18next의 <code>resources</code>로 넘겨 쓴다.</p>
<pre><code class="language-ts">import { sharedLocales } from &quot;@stay/shared-locales&quot;;

i18n.use(initReactI18next).init({
  resources: sharedLocales,
  lng: &quot;ko&quot;,
  fallbackLng: &quot;ko&quot;,
  defaultNS: &quot;common&quot;,
});</code></pre>
<hr>
<h2 id="4-워크스페이스-의존성--workspace">4. 워크스페이스 의존성 — <code>workspace:*</code></h2>
<p>내부 패키지를 참조할 때는 버전 대신 <code>workspace:*</code>를 적는다. 그러면 pnpm이 워크스페이스 안에서 심볼릭 링크로 연결한다.</p>
<pre><code class="language-json">// packages/stay-host/package.json
{
  &quot;dependencies&quot;: {
    &quot;@stay/ui&quot;:              &quot;workspace:*&quot;,
    &quot;@stay/common-services&quot;: &quot;workspace:*&quot;,
    &quot;@stay/shared-locales&quot;:  &quot;workspace:*&quot;
  }
}</code></pre>
<p><code>workspace:*</code>의 효과는 다음과 같다.</p>
<ul>
<li><code>pnpm publish</code> 시 자동으로 실제 버전으로 치환된다 (배포 호환).</li>
<li>외부 패키지에 같은 이름이 있어도 항상 로컬 워크스페이스가 우선이다.</li>
<li><code>pnpm install</code> 후 <code>node_modules/@stay/ui</code>는 <code>../packages/stay-ui</code> 심볼릭 링크가 된다 → IDE의 &quot;Go to Definition&quot;이 곧장 소스로 점프한다.</li>
</ul>
<h3 id="pnpmoverrides--의존성-충돌-강제-해결"><code>pnpm.overrides</code> — 의존성 충돌 강제 해결</h3>
<p>여러 앱이 서로 다른 minor 버전의 라이브러리를 의존하면 React/Emotion이 두 번 로드되는 사고가 난다. 루트 <code>package.json</code>의 <code>overrides</code>로 단일 버전을 강제했다.</p>
<pre><code class="language-json">{
  &quot;pnpm&quot;: {
    &quot;overrides&quot;: {
      &quot;framer-motion&quot;:            &quot;^12.23.12&quot;,
      &quot;motion-dom&quot;:               &quot;^12.23.12&quot;,
      &quot;@aws-sdk/client-dynamodb&quot;: &quot;3.948.0&quot;,
      &quot;@aws-sdk/lib-dynamodb&quot;:    &quot;3.948.0&quot;,
      &quot;entities&quot;:                 &quot;4.5.0&quot;,
      &quot;parse5&gt;entities&quot;:          &quot;^6.0.1&quot;
    }
  }
}</code></pre>
<p>여기서 <code>parse5&gt;entities</code> 같은 표기는 <strong>&quot;parse5 패키지가 안쪽에서 의존하는 entities만 따로 버전을 박는다&quot;</strong>는 의미다. 이렇게 특정 패키지의 transitive dependency(간접 의존성)만 골라서 override할 수 있는 점이 유용했다. 라이브러리 전체 버전을 건드리지 않고도 충돌만 정확히 짚어 해결할 수 있다.</p>
<h3 id="npmrc--aws-sdk--smithy-호이스팅"><code>.npmrc</code> — AWS SDK / Smithy 호이스팅</h3>
<p>pnpm은 기본적으로 strict한 <code>node_modules</code> 구조를 만든다. 즉 <code>package.json</code>에 직접 적은 의존성만 import할 수 있고, transitive dependency를 우연히 import하는 사고(이른바 phantom dependency)를 차단해 준다. 그런데 AWS SDK는 내부 모듈을 동적으로 require하는 케이스가 있어서, 이 strict 구조에서는 모듈을 못 찾고 런타임 에러가 난다. 그래서 호이스팅(평탄화)을 따로 켜줘야 한다.</p>
<pre><code class="language-ini"># .npmrc
shamefully-hoist=true
public-hoist-pattern[]=@smithy/*
public-hoist-pattern[]=@aws-sdk/*</code></pre>
<p><code>shamefully-hoist=true</code>는 가능하면 안 켜는 게 좋다. 다만 AWS SDK + Smithy 조합처럼 호이스팅을 가정하고 작성된 라이브러리를 쓸 때는 어쩔 수 없다.</p>
<hr>
<h2 id="5-동시-실행-패턴">5. 동시 실행 패턴</h2>
<p>모노레포의 가장 큰 함정은 공유 패키지를 수정해도 앱에서 반영이 안 되는 상황이다. <code>@stay/ui</code> 컴포넌트를 고쳤는데 호스트 앱은 옛 코드를 쓰고 있다면 보통 둘 중 하나다.</p>
<ol>
<li><strong>UI 패키지가 빌드되지 않아 <code>dist/</code>가 갱신되지 않은 경우</strong>다. 앱은 빌드된 결과물을 보고 있는데 그 결과물 자체가 옛것이니 변화가 보일 리 없다.</li>
<li><strong>앱의 번들러가 <code>dist/</code> 변화를 감지하지 못한 경우</strong>다. UI는 새로 빌드됐지만 번들러가 그걸 모르고 옛 버전을 캐싱하고 있다.</li>
</ol>
<p>해결책은 <code>concurrently</code>로 UI watch 빌드와 앱 dev 서버를 동시에 실행하는 것이다.</p>
<pre><code class="language-json">// 루트 package.json (요약)
{
  &quot;scripts&quot;: {
    &quot;front&quot;:  &quot;rimraf packages/stay-front/.next &amp;&amp; concurrently -n ui,front -c blue,green   \&quot;pnpm --filter @stay/ui dev:watch\&quot; \&quot;pnpm --filter stay-front dev\&quot;&quot;,
    &quot;studio&quot;: &quot;rimraf packages/studio/.next        &amp;&amp; concurrently -n ui,studio -c blue,yellow \&quot;pnpm --filter @stay/ui dev:watch\&quot; \&quot;pnpm --filter @stay/studio dev\&quot;&quot;,
    &quot;host&quot;:   &quot;node scripts/kill-ports.mjs &amp;&amp; pnpm build:ui &amp;&amp; concurrently --kill-others -n ui,api,host -c blue,yellow,magenta \&quot;pnpm --filter @stay/ui dev:watch\&quot; \&quot;pnpm --filter stay-host-api dev\&quot; \&quot;pnpm --filter stay-host dev\&quot;&quot;
  }
}</code></pre>
<p>이 스크립트에서 눈여겨볼 만한 디테일이 세 가지 있다.</p>
<ul>
<li><strong><code>rimraf packages/stay-front/.next</code></strong> — Next.js의 stale 캐시가 워크스페이스 패키지 변경을 못 잡는 경우가 있어서, 시작 시 <code>.next</code>를 비우고 출발한다.</li>
<li><strong><code>kill-ports.mjs</code></strong> — <code>lsof -ti:4100</code> 또는 <code>netstat</code>으로 좀비 프로세스를 미리 정리해 둔다. 이전 dev 서버가 깔끔하게 죽지 않았을 때 발생하는 포트 충돌을 막기 위해서다.</li>
<li><strong><code>--kill-others</code></strong> — 한 프로세스가 죽으면 나머지도 같이 종료시킨다. UI watch가 죽었는데 앱이 살아 있으면 stale 빌드를 보게 되는 사고를 막을 수 있다.</li>
</ul>
<h3 id="vite-watch-→-nextjs-리빌드-트리거">Vite watch → Next.js 리빌드 트리거</h3>
<p><code>@stay/ui</code>는 Vite library mode로 빌드되고 결과물이 <code>dist/</code>에 떨어진다. 그런데 Next.js의 Webpack은 성능상의 이유로 <code>node_modules</code> 안의 변화를 기본적으로 watch하지 않는다. 즉 UI 라이브러리를 새로 빌드해도 Next.js dev 서버는 그 사실을 알아채지 못하고 옛 코드를 계속 쓴다. 그래서 <code>dev:watch</code> 전용 Vite 설정에 다음 플러그인을 끼웠다.</p>
<pre><code class="language-ts">// packages/stay-ui/vite.config.watch.ts
const triggerNextRebuild = () =&gt; ({
  name: &quot;trigger-next-rebuild&quot;,
  closeBundle() {
    // 빌드가 끝날 때마다 dummy 파일 timestamp를 갱신
    const triggerFile = path.resolve(__dirname, &quot;dist/.rebuild-trigger&quot;);
    fs.writeFileSync(triggerFile, Date.now().toString());
    console.log(&quot;[vite] Build complete, triggering Next.js rebuild...&quot;);
  },
});</code></pre>
<p>원리는 단순하다. Vite 빌드가 끝날 때마다 의미 없는 dummy 파일의 timestamp를 갱신한다. 그리고 Next.js 앱 코드 어딘가에서 이 dummy 파일을 import해두면, 파일 수정 시각이 바뀐 걸 Next.js가 감지하고 페이지를 재컴파일한다. 별것 아닌 트릭이지만 <strong>공유 UI 수정 → Next.js HMR</strong>까지의 체인이 자동화된다.</p>
<pre><code class="language-ts">// 앱 어딘가 (보통 i18n init 옆)
import &quot;@stay/ui/dist/.rebuild-trigger?raw&quot;;</code></pre>
<p>watch 모드에서는 <code>vite-plugin-dts</code>(타입 생성)와 <code>vite-plugin-checker</code>(TS 체크)를 꺼뒀다. 메모리 사용량이 두 배 가까이 줄어 dev 환경 안정성이 좋아진다.</p>
<pre><code class="language-ts">// vite.config.watch.ts — dts/checker 제외
build: {
  sourcemap: false,
  reportCompressedSize: false,
  emptyOutDir: false, // build:ui 결과물 보존
},</code></pre>
<hr>
<h2 id="6-turborepo--빌드-그래프와-캐싱">6. Turborepo — 빌드 그래프와 캐싱</h2>
<p><code>pnpm -r run build</code>만으로도 모든 패키지를 빌드할 수 있다. 다만 패키지 간 의존 순서(<code>@stay/ui</code>가 먼저 빌드돼야 <code>stay-host</code>가 빌드 가능)와 캐싱을 위해 Turborepo를 얹었다.</p>
<pre><code class="language-jsonc">// turbo.json
{
  &quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
  &quot;ui&quot;: &quot;tui&quot;,
  &quot;globalDependencies&quot;: [&quot;.env*&quot;],
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;outputs&quot;: [&quot;dist/**&quot;, &quot;.next/**&quot;, &quot;!.next/cache/**&quot;],
      &quot;cache&quot;: true
    },
    &quot;build:ui&quot;: {
      &quot;dependsOn&quot;: [],
      &quot;outputs&quot;: [&quot;dist/**&quot;],
      &quot;cache&quot;: true
    },
    &quot;dev&quot;: {
      &quot;dependsOn&quot;: [&quot;^build:ui&quot;],   // 앱 dev 시작 전에 UI lib 1회 빌드
      &quot;persistent&quot;: true,
      &quot;cache&quot;: false
    },
    &quot;type-check&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;cache&quot;: true
    }
  }
}</code></pre>
<ul>
<li><strong><code>^build</code></strong> — 캐럿 기호는 Turborepo 표기법으로 &quot;이 패키지가 의존하는 다른 패키지의 build를 먼저 실행하라&quot;는 뜻이다. 예를 들어 <code>stay-host</code>의 build를 돌리면 <code>@stay/ui</code>의 build가 먼저 자동 실행된다.</li>
<li><strong><code>outputs</code></strong> — 캐시 대상 디렉토리를 지정한다. <code>.next/cache/**</code>는 일부러 제외해서 캐시가 끝없이 부풀지 않도록 했다.</li>
<li><strong><code>globalDependencies: [&quot;.env*&quot;]</code></strong> — <code>.env</code> 파일이 바뀌면 모든 캐시를 무효화한다. 환경 변수에 따라 빌드 결과가 달라질 수 있기 때문이다.</li>
<li><strong><code>dev</code>의 <code>cache: false</code></strong> — dev 모드는 항상 새로 띄워야 하므로 캐싱하지 않는다.</li>
</ul>
<p><code>pnpm front:turbo</code>처럼 turbo를 통한 빠른 dev 모드도 별도로 노출했다. 캐시가 적중하면 <code>@stay/ui</code>를 다시 빌드하지 않아 콜드 스타트가 수십 초 단축된다.</p>
<hr>
<h2 id="7-typescript-설정--tsconfigbasejson-상속">7. TypeScript 설정 — <code>tsconfig.base.json</code> 상속</h2>
<p>모든 앱이 동일한 strict 옵션을 쓰도록 루트에 <code>tsconfig.base.json</code>을 두고, 각 앱·패키지의 <code>tsconfig.json</code>이 상속한다.</p>
<pre><code class="language-jsonc">// tsconfig.base.json
{
  &quot;compilerOptions&quot;: {
    &quot;target&quot;: &quot;ES2020&quot;,
    &quot;module&quot;: &quot;ESNext&quot;,
    &quot;moduleResolution&quot;: &quot;bundler&quot;,
    &quot;lib&quot;: [&quot;dom&quot;, &quot;dom.iterable&quot;, &quot;esnext&quot;],
    &quot;strict&quot;: true,
    &quot;esModuleInterop&quot;: true,
    &quot;skipLibCheck&quot;: true,
    &quot;forceConsistentCasingInFileNames&quot;: true,
    &quot;resolveJsonModule&quot;: true,
    &quot;allowSyntheticDefaultImports&quot;: true,
    &quot;incremental&quot;: true,
    &quot;jsx&quot;: &quot;preserve&quot;,
    &quot;noEmit&quot;: true
  },
  &quot;exclude&quot;: [&quot;node_modules&quot;, &quot;.next&quot;, &quot;dist&quot;, &quot;out&quot;, &quot;build&quot;, &quot;.turbo&quot;, &quot;coverage&quot;]
}</code></pre>
<pre><code class="language-jsonc">// packages/stay-host/tsconfig.json
{
  &quot;extends&quot;: &quot;../../tsconfig.base.json&quot;,
  &quot;compilerOptions&quot;: {
    &quot;baseUrl&quot;: &quot;./src&quot;,
    &quot;paths&quot;: { &quot;@/*&quot;: [&quot;./*&quot;] }
  },
  &quot;include&quot;: [&quot;src&quot;, &quot;vite.config.ts&quot;]
}</code></pre>
<p><code>moduleResolution: &quot;bundler&quot;</code>가 핵심이다. TypeScript의 모듈 해석 모드 중 <code>Node16</code>은 <code>package.json</code>의 <code>exports</code> 필드를 표준 그대로 까다롭게 검증해서, <code>@stay/ui/inputs</code> 같은 subpath import에 자주 빨간 줄을 띄운다. </p>
<p>반면 <code>bundler</code> 모드는 Vite / Next.js 같은 번들러의 실제 동작과 일치하도록 설계돼 있어서, IDE 에러 없이 subpath import가 그대로 통한다. 모노레포에서 IDE 빨간 줄을 없애려면 이 설정이 사실상 필수다.</p>
<hr>
<h2 id="8-배포--deploysh-단일-진입점">8. 배포 — <code>deploy.sh</code> 단일 진입점</h2>
<p>Vercel에 4개 프로젝트(front / landing / guest-portal / studio)가 등록돼 있다. vercel --prod를 직접 실행하면 의도와 다른 프로젝트로 배포될 수 있어, 모든 배포를 단일 스크립트로 묶어 처리하도록 했다.</p>
<pre><code class="language-bash"># deploy.sh (요약)
deploy_project() {
  local project=$1
  local prod_flag=$2
  local project_id=$(get_project_id &quot;$project&quot;)  # case문으로 ID 매핑

  # .vercel/project.json 을 매번 덮어써서 프로젝트 컨텍스트 확정
  echo &quot;{\&quot;projectId\&quot;:\&quot;$project_id\&quot;,\&quot;orgId\&quot;:\&quot;$ORG_ID\&quot;}&quot; &gt; .vercel/project.json

  # 로컬 빌드 → prebuilt 업로드
  vercel build ${prod_flag} --yes
  vercel deploy --prebuilt ${prod_flag} --yes

  # guest-portal은 와일드카드 alias 자동 설정
  if [ &quot;$project&quot; = &quot;guest-portal&quot; ] &amp;&amp; [ &quot;$prod_flag&quot; = &quot;--prod&quot; ]; then
    vercel alias &quot;$deployment_url&quot; &#39;*.stay.site&#39;
    vercel alias &quot;$deployment_url&quot; &#39;www.example.com&#39;
  fi
}</code></pre>
<p>이 스크립트의 핵심 디자인 포인트는 세 가지다.</p>
<ul>
<li><strong><code>vercel build</code> → <code>vercel deploy --prebuilt</code> 분리</strong>: 로컬에서 빌드하고 결과물만 업로드하는 방식이다. Vercel의 원격 빌드 환경이 매번 새로 셋업되는 부담을 줄일 수 있다.</li>
<li><strong><code>.vercel/project.json</code> 동적 생성</strong>: 프로젝트마다 ID가 다르므로 매 배포마다 덮어쓴다. 이게 없으면 마지막에 배포한 프로젝트로 잘못 푸시된다.</li>
<li><strong>alias 자동 설정</strong>: 게스트 포털은 <code>*.stay.site</code> 와일드카드 도메인을 받아야 하는데, prebuilt 배포는 alias가 자동 적용되지 않으므로 스크립트가 직접 호출한다.</li>
</ul>
<pre><code class="language-bash">pnpm deploy:front           # production
pnpm deploy:guest-portal    # production
pnpm deploy:all             # 한 번에 4개 모두
pnpm deploy:front:preview   # preview</code></pre>
<hr>
<h2 id="9-회고">9. 회고</h2>
<h3 id="좋았던-점">좋았던 점</h3>
<ol>
<li><strong>공유 패키지의 export 경로를 잘게 쪼갠 것</strong> — 처음엔 단일 entry였는데 트리 셰이킹이 전혀 안 돼서 호스트 앱 번들에 GridBuilder 전체가 들어왔다. 27개로 쪼개고 나서야 chunk 크기가 안정됐다.</li>
<li><strong><code>workspace:*</code> 강제</strong> — 내부 패키지를 npm 버전으로 참조하면 <code>pnpm publish</code>까지 가야 변경이 반영된다. 무조건 <code>workspace:*</code>로 통일하니 개발 흐름이 끊기지 않았다.</li>
<li><strong><code>shared-locales</code>의 TS-source export</strong> — JSON을 import한 객체를 그대로 노출했더니 번역 키 검색이 IDE에서 곧장 점프된다. 별도 빌드 단계가 없어 라이브러리 수준의 중복 문제도 사라졌다.</li>
</ol>
<h3 id="주의할-점">주의할 점</h3>
<ol>
<li><strong>Next.js는 <code>node_modules</code> 안 패키지의 변경을 watch하지 않는다</strong> — <code>triggerNextRebuild</code> 플러그인을 만들기 전엔 매번 dev 서버를 종료하고 다시 띄워야 했다.</li>
<li><strong><code>shamefully-hoist</code>는 좋은 수단이 아니다</strong> — AWS SDK 호이스팅이 필요해서 켰는데, 그 결과 앱이 의도치 않은 transitive 패키지에 의존하는 일이 생겼다. CI에서는 <code>--frozen-lockfile</code>로 잡고 있다.</li>
<li><strong><code>pnpm.overrides</code>는 silent하게 동작한다</strong> — 버전을 강제로 박았다는 사실을 모르면 디버깅이 미궁에 빠진다. CHANGELOG로 따로 문서화해 두는 편이 좋다.</li>
<li><strong>Turbo <code>dev</code>의 <code>dependsOn: [&quot;^build:ui&quot;]</code></strong> — 처음엔 <code>^build</code>였는데 dev 시작 시 모든 패키지가 풀 빌드되면서 30초씩 걸렸다. UI lib만 의존하도록 좁히는 게 정답이었다.</li>
</ol>
<hr>
<h2 id="10-마치며">10. 마치며</h2>
<p>폴리레포에서 모노레포로 옮기면서 가장 크게 달라진 부분은 두 가지였다. </p>
<ol>
<li><strong>공유 패키지를 수정하면 모든 앱에 즉시 반영된다.</strong></li>
<li><strong>하나의 의존성 그래프 안에서 일관된 버전이 유지된다</strong>. </li>
</ol>
<p>처음엔 &#39;한 PR로 여러 앱을 한꺼번에 바꿀 수 있다&#39;는 게 가장 큰 이점이라고 생각했는데, 실제로 일하면서 체감한 차이는 이 두 가지가 더 컸다.</p>
<p>Next.js · Vite · Astro가 한 저장소에 공존하는 게 처음엔 부담스러웠지만, 각 프레임워크의 책임 영역이 명확해지자 오히려 자연스러운 선택지가 됐다 (Next.js는 인증/관리, Vite는 SPA, Astro는 SSG/마케팅). pnpm workspaces는 이 구성을 가장 가볍게 떠받쳐 주는 도구였다.</p>
<p>이번 작업을 돌아보면, 처음부터 모든 도구를 한꺼번에 도입하지 말고 pnpm workspace로 최소 구성을 만든 뒤 필요할 때 Turborepo를 얹어가는 순서로 진행했다면 시행착오가 더 적었을 것 같다.</p>
<hr>
<h3 id="reference">Reference</h3>
<ul>
<li>pnpm workspaces — <a href="https://pnpm.io/workspaces">https://pnpm.io/workspaces</a></li>
<li>pnpm <code>workspace:</code> protocol — <a href="https://pnpm.io/workspaces#workspace-protocol-workspace">https://pnpm.io/workspaces#workspace-protocol-workspace</a></li>
<li>Turborepo <code>tasks</code> 설정 — <a href="https://turbo.build/repo/docs/reference/configuration">https://turbo.build/repo/docs/reference/configuration</a></li>
<li>Vite Library Mode — <a href="https://vitejs.dev/guide/build.html#library-mode">https://vitejs.dev/guide/build.html#library-mode</a></li>
<li>Node.js <code>package.json#exports</code> — <a href="https://nodejs.org/api/packages.html#subpath-exports">https://nodejs.org/api/packages.html#subpath-exports</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[사진 업로드 — 압축·AI 분석·청크 병렬 업로드 파이프라인 구현기]]></title>
            <link>https://velog.io/@do_dam/%EC%82%AC%EC%A7%84-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%95%95%EC%B6%95AI-%EB%B6%84%EC%84%9D%EC%B2%AD%ED%81%AC-%EB%B3%91%EB%A0%AC-%EC%97%85%EB%A1%9C%EB%93%9C-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%ED%98%84%EA%B8%B0</link>
            <guid>https://velog.io/@do_dam/%EC%82%AC%EC%A7%84-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%95%95%EC%B6%95AI-%EB%B6%84%EC%84%9D%EC%B2%AD%ED%81%AC-%EB%B3%91%EB%A0%AC-%EC%97%85%EB%A1%9C%EB%93%9C-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%ED%98%84%EA%B8%B0</guid>
            <pubDate>Thu, 30 Apr 2026 01:05:43 GMT</pubDate>
            <description><![CDATA[<p>숙소 등록 플로우를 개발하다가 직접 테스트해보니 사진 업로드가 너무 느렸다. DSLR로 찍은 파일 몇 장을 그대로 올렸더니 용량이 커서 업로드가 한참 걸렸고, 여러 장을 올릴 때는 하나가 끝나야 다음 업로드가 시작되어 장수가 늘수록 기다리는 시간도 선형으로 늘어났다. </p>
<p>그 사이에 진행 상황도 안 보이니 &quot;되고 있는 건가?&quot; 싶어서 버튼을 여러 번 더 누르기도 했다. 어차피 만들어야 하는 기능이라면 처음부터 제대로 구조를 잡자는 생각으로 <strong>압축 → AI 분류 → 병렬 업로드</strong> 파이프라인을 설계했다.</p>
<hr>
<h2 id="문제-인식">문제 인식</h2>
<p>기존 방식은 단순했다.</p>
<pre><code>파일 선택 → FormData → POST /api/upload → 완료</code></pre><p>세 가지가 문제였다.</p>
<ul>
<li><strong>원본 파일 그대로 전송</strong>: DSLR로 찍은 숙소 사진은 한 장에 5~15MB에 달한다. 이걸 압축 없이 그대로 올리면 업로드 시간이 길어지는 건 물론이고, 서버 스토리지와 네트워크 비용도 낭비다.</li>
<li><strong>진행 상황 없음</strong>: 업로드 중에 아무 피드백이 없으면 잘 되고 있는 건지 알 수가 없다. 기다리다 못해 버튼을 한 번 더 누르면 같은 파일이 중복으로 올라가고, 그걸 나중에 수동으로 정리해야 하는 상황이 생긴다.</li>
<li><strong>순차 처리</strong>: 파일을 하나씩 순서대로 처리하면 장수가 늘수록 대기 시간이 그대로 늘어난다. 10장이면 10배, 15장이면 15배다. 숙소 등록 시 사진을 한꺼번에 올리는 경우가 많은데, 매번 30초 넘게 기다리는 건 현실적으로 쓸 수 없는 흐름이었다.</li>
</ul>
<hr>
<h2 id="파이프라인-구조">파이프라인 구조</h2>
<pre><code>파일 선택 → 유효성 검사 → 청크 분할 (3개씩)
    ↓ Promise.all (청크 내 병렬)
  [Canvas 압축 (업로드용 1600px / Vision용 1200px) → Vision API → SEO 파일명]
    ↓
  S3 Presigned URL 발급 → XHR PUT (progress 추적)
    ↓
  메인 지정 → 순서 저장 → 삭제 처리</code></pre><hr>
<h2 id="1-canvas-기반-다단계-이미지-압축">1. Canvas 기반 다단계 이미지 압축</h2>
<p>서버 정책(2MB 이하, JPEG/PNG/WebP)을 맞추기 위해 Canvas로 직접 구현했다. 단순히 1600px 고정으로 리사이즈하면 고해상도 원본은 여전히 2MB를 넘는다. 목표 용량을 달성할 때까지 단계적으로 압축 강도를 올리는 방식을 썼다.</p>
<pre><code class="language-typescript">const passes = [
  { size: 1600, quality: 0.85 },
  { size: 1280, quality: 0.78 },
  { size: 1024, quality: 0.70 },
  { size: 800,  quality: 0.60 }, // 최후 수단
];

for (const { size, quality } of passes) {
  const compressed = await compressImage(file, size, quality);
  if (compressed.size &lt;= TARGET_MAX_BYTES) return compressed;
}</code></pre>
<p>같은 파일을 업로드용(1600px)과 Vision 분석용(1200px base64)으로 각각 압축해야 하는데, 두 작업을 <code>Promise.all</code>로 병렬 실행해 대기 시간을 줄였다.</p>
<pre><code class="language-typescript">const [uploadFile, visionBase64] = await Promise.all([
  compressImageForUpload(file),
  compressImageForVision(file),
]);</code></pre>
<hr>
<h2 id="2-vision-api로-이미지-자동-분류--seo-파일명-생성">2. Vision API로 이미지 자동 분류 + SEO 파일명 생성</h2>
<p>숙소 사진은 공간별로 분류되어야 하는데(침실, 욕실, 주방 등), 호스트가 일일이 지정하기엔 번거롭다. Vision API에 압축된 이미지를 넘기면 공간 카테고리를 자동으로 반환한다.</p>
<pre><code class="language-typescript">const visionResult = await callVisionApi(visionBase64, visionTimeout);
// { category: &#39;BEDROOM&#39;, confidence: 0.92 }</code></pre>
<p>Vision API가 실패하거나 타임아웃(기본 10초)이 나도 업로드는 계속 진행된다. 실패는 <code>warnings</code> 배열에 누적하고 fallback으로 <code>UNCERTAIN</code>을 사용한다.</p>
<p>Vision 결과는 SEO 파일명 생성에도 활용한다. <code>IMG_2048.JPG</code> 같은 파일명은 검색엔진에 기여하지 못한다.</p>
<pre><code class="language-typescript">// gyedong-hanok-bedroom-03.jpg
generateSeoFilename({
  accommodationSlug: &#39;gyedong-hanok&#39;,
  category: visionResult.category,
  sequence: 3,
  extension: &#39;jpg&#39;,
});</code></pre>
<p>Vision이 실패하면 <code>gyedong-hanok-photo-03.jpg</code>로 fallback한다.</p>
<hr>
<h2 id="3-s3-presigned-url--xhr-진행률-추적">3. S3 Presigned URL + XHR 진행률 추적</h2>
<p>파일을 서버를 거쳐 올리면 서버 메모리와 대역폭을 소모한다. Presigned URL을 쓰면 클라이언트가 S3에 직접 PUT한다.</p>
<pre><code>클라이언트 → POST /api/s3-upload (파일명, 크기, 타입)
서버        → Presigned URL 생성 후 반환
클라이언트 → PUT presignedUrl (직접 전송)</code></pre><p><code>fetch()</code>는 업로드 진행률을 추적할 수 없어 <code>XMLHttpRequest</code>를 사용했다.</p>
<pre><code class="language-typescript">xhr.upload.addEventListener(&#39;progress&#39;, (event) =&gt; {
  if (event.lengthComputable) {
    const progress = Math.round((event.loaded / event.total) * 100);
    onProgress?.(progress);
  }
});</code></pre>
<hr>
<h2 id="4-청크-병렬-처리">4. 청크 병렬 처리</h2>
<p>파일 1장에 약 2초, 10장 순차 처리면 20초다. 파일 배열을 <code>concurrency=3</code> 단위로 쪼개고 청크 내부에서 <code>Promise.all</code>로 병렬 실행한다.</p>
<pre><code class="language-typescript">const chunks: File[][] = [];
for (let i = 0; i &lt; files.length; i += concurrency) {
  chunks.push(files.slice(i, i + concurrency));
}

for (const chunk of chunks) {
  const results = await Promise.all(chunk.map(processFile));
}</code></pre>
<p>concurrency를 3으로 설정한 이유는 Vision API 동시 요청 한도와 브라우저 메모리(파일당 3<del>5MB × 3 ≈ 10</del>15MB) 사이의 균형점이기 때문이다.</p>
<hr>
<h2 id="5-업로드-오케스트레이션-imagechangesync">5. 업로드 오케스트레이션 (imageChangeSync)</h2>
<p>업로드 이후에도 메인 이미지 지정, 순서 저장, 삭제 처리가 남아있다. 이 흐름을 <code>processImages</code>가 담당한다.</p>
<pre><code class="language-typescript">// 1) 신규 파일 업로드
for (const item of newItems) {
  const imageType = await api.analyze?.(item.file);
  const uploaded = await api.upload(item.file, imageType);
}

// 2) 메인 이미지 지정
await api.setMain(mainId);

// 3) 순서 저장 (변경 없으면 스킵)
if (!isSameOrder(currentOrderIds, finalOrderIds)) {
  await api.reorder(finalOrderIds);
}

// 4) 삭제 처리
await api.deleteOne(id); // 또는 api.deleteAll()

return { warnings }; // 부분 실패는 throw 대신 누적</code></pre>
<p>두 가지 원칙을 지켰다. <strong>부분 실패 허용</strong> — 이미지는 숙소 저장의 필수값이 아니라서, 업로드 실패가 전체 저장 실패로 전파되면 안 된다. <strong>의존성 주입</strong> — <code>ImageApi</code> 인터페이스를 주입받는 구조라 룸, 조직 등 다른 엔터티에도 동일한 유틸을 재사용할 수 있다.</p>
<hr>
<h2 id="성능-추정">성능 추정</h2>
<p>파일 1장 처리 시간을 분해하면, 전체 약 2,240ms 중 86%가 네트워크 I/O다(Vision 1,200ms + S3 600ms + Presigned URL 140ms). <code>Promise.all</code>로 병렬화가 가장 효과적인 구간이다.</p>
<table>
<thead>
<tr>
<th>파일 수</th>
<th>순차 처리</th>
<th>청크 병렬 (×3)</th>
<th>단축률</th>
</tr>
</thead>
<tbody><tr>
<td>3장</td>
<td>6.7초</td>
<td>2.8초</td>
<td>57.7%</td>
</tr>
<tr>
<td>6장</td>
<td>13.4초</td>
<td>5.7초</td>
<td>57.7%</td>
</tr>
<tr>
<td>15장</td>
<td>33.6초</td>
<td>14.2초</td>
<td>57.7%</td>
</tr>
</tbody></table>
<blockquote>
<p>위 수치는 각 단계별 평균 소요 시간을 기반으로 한 이론적 추정치다. 실제 수치는 Vision API 응답 속도와 네트워크 환경에 따라 달라진다.</p>
</blockquote>
<p>단축률이 파일 수와 무관하게 일정한 이유는, <code>ceil(n / 3)</code> 청크 수에 비례해 시간이 줄어들기 때문이다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>지금 구조에서 아직 해결 못 한 게 있다면, 업로드 실패 시 재시도 로직이다. 현재는 실패를 <code>warnings</code>에 누적하고 끝이라서, 네트워크가 불안정한 환경에서는 일부 파일이 조용히 빠질 수 있다.</p>
<p>이 부분을 어떻게 개선할지 찾아보다가 <strong>지수 백오프(exponential backoff)</strong> 라는 패턴을 알게 됐다. 실패했을 때 즉시 재시도하면 서버가 과부하 상태일 경우 오히려 503이 반복되는데, 재시도 간격을 <code>1초 → 2초 → 4초</code>처럼 점점 늘려가면 서버가 회복할 시간을 줄 수 있다는 개념이다. 아직 적용하지는 못했지만, 다음 개선 포인트로 정해뒀다.</p>
<p><strong>Vision API 타임아웃</strong>도 마찬가지다. 현재 10초로 설정했는데, 이 값은 실측 없이 일단 넉넉하게 잡은 숫자다. 타임아웃을 어떻게 정해야 하는지 찾아보니, <strong>실제 응답 시간 분포를 측정해서 대부분의 요청이 완료되는 시점을 기준</strong>으로 잡는 게 맞다고 한다. 운영 데이터가 쌓이면 그때 다시 조정해볼 생각이다.</p>
<hr>
<!-- 
*핵심 파일*
- `comp-ui/utils/imageCompression.ts` — Canvas 다단계 압축
- `comp-host/utils/prepareImageForUpload.ts` — 압축 + Vision + SEO 파일명
- `comp-ui/hooks/useS3Upload.ts` — Presigned URL + XHR 진행률
- `comp-host/utils/imageChangeSync.ts` — 오케스트레이션
- `comp-host/components/common/PhotoContainer.tsx` — DnD + 드롭존 UI
-->]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 포트폴리오 성능 최적화 회고 — LCP·TBT·CLS 개선기]]></title>
            <link>https://velog.io/@do_dam/Next.js-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%9A%8C%EA%B3%A0-LCPTBTCLS-%EA%B0%9C%EC%84%A0%EA%B8%B0</link>
            <guid>https://velog.io/@do_dam/Next.js-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%9A%8C%EA%B3%A0-LCPTBTCLS-%EA%B0%9C%EC%84%A0%EA%B8%B0</guid>
            <pubDate>Tue, 28 Apr 2026 08:51:19 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>개인 포트폴리오 사이트(Next.js 15 / App Router)를 만들고 Lighthouse를 돌려보니
첫 페인트 시점, 메인 스레드 점유 시간, 폰트 로딩 시 레이아웃 시프트 등에서
개선할 만한 지점들이 보였다.</p>
<p>이번 글에서는 <strong>LCP / TBT / CLS</strong> 세 가지 지표를 중심으로,
지표별 원인과 해결 과정을 코드와 함께 남겨둔다.</p>
<blockquote>
<p>적용 스택: Next.js 15, React 18, App Router, Tailwind CSS, Framer Motion</p>
</blockquote>
<hr>
<h2 id="1-이미지-최적화--png-→-webpavif-전환">1. 이미지 최적화 — PNG → WebP/AVIF 전환</h2>
<p>이번 최적화 과정에서 가장 효과가 컸던 부분이다. 프로젝트 썸네일 이미지를 모두 PNG로 관리하고 있었는데,
용량이 큰 데다, 호버 시 두 번째 이미지까지 함께 로드되면서 네트워크 비용이 커지는 문제가 있었다.</p>
<h3 id="1-1-원본-이미지를-webp로-일괄-전환">1-1. 원본 이미지를 WebP로 일괄 전환</h3>
<pre><code class="language-diff">- defaultImage: &quot;/images/projects/panzy-main.png&quot;,
- hoverImage: &quot;/images/projects/panzy-hover.png&quot;,
+ defaultImage: &quot;/images/projects/panzy-main.webp&quot;,
+ hoverImage: &quot;/images/projects/panzy-hover.webp&quot;,</code></pre>
<p>PNG 대비 평균 <strong>60~70% 정도 용량이 감소했다</strong>. 화질 손실은 육안으로 거의 차이가 없는 수준이다.</p>
<h3 id="1-2-nextconfigts로-이미지-파이프라인-튜닝">1-2. <code>next.config.ts</code>로 이미지 파이프라인 튜닝</h3>
<pre><code class="language-ts">// next.config.ts
const nextConfig: NextConfig = {
    images: {
        // 브라우저가 지원하는 최적 포맷 자동 선택 (AVIF 우선)
        formats: [&quot;image/avif&quot;, &quot;image/webp&quot;],
        // 이미지 CDN 캐시 1년
        minimumCacheTTL: 31536000,
        // 뷰포트별 최적 크기
        deviceSizes: [640, 750, 828, 1080, 1200, 1920],
        // 176 포함: 프로필 이미지(176×176) 정확히 매칭 → 리사이즈 비용 절감
        imageSizes: [16, 32, 48, 64, 96, 128, 176, 256, 384],
    },
};</code></pre>
<p>포인트는 <code>imageSizes</code> 배열에 <strong>실제 사용하는 사이즈를 추가</strong>한 것.
프로필 이미지가 176×176인데 기본 imageSizes에는 176이 없어서
가장 가까운 256으로 리사이즈된 후 표시되고 있었다.
실제 사용하는 크기를 명시하면 Next.js의 이미지 최적화 단계에서 불필요한 리사이즈 과정을 줄일 수 있다.</p>
<h3 id="1-3-nextimage에-quality-sizes-명시">1-3. <code>next/image</code>에 <code>quality</code>, <code>sizes</code> 명시</h3>
<pre><code class="language-tsx">&lt;Image
    src={project.defaultImage}
    alt={`${project.name} 프로젝트 메인 이미지`}
    fill
    sizes=&quot;(max-width: 768px) 100vw, 50vw&quot;
    loading=&quot;lazy&quot;
    quality={85}        // 기본 75 → 85, 화질과 용량 사이 절충
    className=&quot;object-cover transition-all duration-500&quot;
/&gt;</code></pre>
<ul>
<li><code>sizes</code>를 정확히 지정해야 Next.js가 viewport에 맞는 이미지만 다운로드한다.</li>
<li>기본 <code>quality</code>는 75인데, 프로젝트 썸네일은 화질이 중요해 85로 약간 올렸다.
대신 메인 LCP 요소인 프로필 이미지는 90으로 유지했다.</li>
</ul>
<hr>
<h2 id="2-폰트-최적화--다운로드-크기-줄이고-cls-막기">2. 폰트 최적화 — 다운로드 크기 줄이고 CLS 막기</h2>
<p><code>next/font/google</code>를 쓰면 self-host로 자동 처리되긴 하지만,
<strong>굵기(weight)를 명시하지 않으면 사용하지도 않는 굵기까지 다 받아온다.</strong></p>
<h3 id="2-1-실제-사용하는-weight만-로드">2-1. 실제 사용하는 weight만 로드</h3>
<pre><code class="language-ts">// src/app/layout.tsx
const inter = Inter({
    subsets: [&quot;latin&quot;],
    variable: &quot;--font-inter&quot;,
    display: &quot;swap&quot;,
    adjustFontFallback: true,
    preload: true,
    // 실제 사용하는 굵기만 로드 → 폰트 다운로드 크기 절감
    weight: [&quot;400&quot;, &quot;500&quot;, &quot;600&quot;, &quot;700&quot;],
});</code></pre>
<p><code>weight</code>를 명시하지 않으면 200~900 범위의 variable font 전체가 함께 로드된다.
실제로 쓰는 4개 굵기만 지정하면 폰트 페이로드가 크게 줄어든다.</p>
<h3 id="2-2-보조-폰트는-display-optional로-cls-방지">2-2. 보조 폰트는 <code>display: &quot;optional&quot;</code>로 CLS 방지</h3>
<p>Fira Code는 Nav 로고 한 곳에만 쓰는 보조 폰트라 굳이 swap으로
시스템 폰트에서 웹폰트로 전환되는 과정을 사용자에게 보여줄 필요가 없었다.</p>
<pre><code class="language-ts">const firaCode = Fira_Code({
    subsets: [&quot;latin&quot;],
    weight: [&quot;600&quot;],
    variable: &quot;--font-fira-code&quot;,
    display: &quot;optional&quot;,  // swap → optional
});</code></pre>
<table>
<thead>
<tr>
<th>display 값</th>
<th>동작</th>
<th>CLS</th>
</tr>
</thead>
<tbody><tr>
<td><code>swap</code></td>
<td>시스템 폰트로 먼저 표시 → 웹폰트 로드되면 교체</td>
<td><strong>발생</strong> (폰트 메트릭 차이)</td>
</tr>
<tr>
<td><code>optional</code></td>
<td>100ms 안에 못 받으면 시스템 폰트 유지, 다음 방문에서 사용</td>
<td><strong>없음</strong></td>
</tr>
</tbody></table>
<blockquote>
<p>핵심 본문 폰트(Inter)는 시각적 일관성이 중요해서 swap을 유지하고,
사이드 폰트만 optional로 바꿨다.</p>
</blockquote>
<h3 id="2-3-adjustfontfallback-true">2-3. <code>adjustFontFallback: true</code></h3>
<p><code>adjustFontFallback</code> 옵션을 사용해 폴백 폰트의 메트릭(글자 크기와 행간)을 웹폰트와 유사하게 자동 보정했다.
이로 인해 폰트 교체 과정에서 발생하는 미세한 레이아웃 시프트를 줄이고,
Inter 적용 시에도 보다 안정적인 렌더링을 유지할 수 있었다.</p>
<hr>
<h2 id="3-lcp-개선--히어로-영역-프로필-이미지">3. LCP 개선 — 히어로 영역 프로필 이미지</h2>
<p>LCP 요소는 첫 화면의 <strong>프로필 이미지(176×176)</strong>.
원래 Framer Motion의 <code>scaleIn</code> 변형을 그대로 사용하고 있었는데,
이로 인해 LCP 측정 시점이 불필요하게 지연되고 있었다.</p>
<h3 id="3-1-lcp-요소에-opacity-0이-들어가면-안-된다">3-1. LCP 요소에 <code>opacity: 0</code>이 들어가면 안 된다</h3>
<pre><code class="language-tsx">// 기존: scaleIn — opacity 0 → 1 트랜지션 포함
// 문제: Lighthouse는 opacity:0인 동안 LCP가 &quot;보이지 않는다&quot;고 판단해
//       transition이 끝날 때까지 LCP 측정을 미룬다.</code></pre>
<p><code>scaleIn</code> 안에 <code>opacity: 0 → 1</code>이 포함되어 있어
이미지 자체는 빨리 다운로드되었지만 <strong>LCP 시간이 트랜지션 종료 시점까지 늦춰지는 현상</strong>이 발생했다.</p>
<h3 id="3-2-opacity-없이-scale만-사용하는-변형-추가">3-2. opacity 없이 scale만 사용하는 변형 추가</h3>
<pre><code class="language-tsx">// LCP 요소(프로필 이미지)는 opacity:0 으로 시작하면 LCP가 늦게 측정됨
// → opacity 없이 scale만 사용해 즉시 visible 상태 유지
const profileImageVariant: Variants = {
    hidden: { scale: 0.9 },
    visible: {
        scale: 1,
        transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
    },
};</code></pre>
<p>이미지는 처음부터 화면에 보이는 상태로 렌더링되고, 단지 scale만 변경되며 애니메이션이 적용된다.
이 경우 요소가 opacity: 0 상태를 거치지 않기 때문에, 
브라우저는 해당 이미지를 <strong>DOM에 페인트하는 시점에 바로 LCP를 측정</strong>한다.</p>
<h3 id="3-3-priority--정확한-sizes--적절한-quality">3-3. <code>priority</code> + 정확한 <code>sizes</code> + 적절한 <code>quality</code></h3>
<pre><code class="language-tsx">&lt;Image
    src={PROFILE_IMAGE_PATH}
    alt={`${personalInfo.name} 프로필 사진`}
    width={176}
    height={176}
    sizes=&quot;176px&quot;   // 고정 크기라 정확히 명시
    priority         // &lt;head&gt;에 preload 자동 삽입
    quality={90}     // 얼굴 사진이라 약간 높게
    className=&quot;...&quot;
/&gt;</code></pre>
<p><code>priority</code>를 주면 Next.js가 <code>&lt;link rel=&quot;preload&quot;&gt;</code>를 자동으로 넣어줘서
HTML 파싱 직후 이미지 다운로드가 시작된다.</p>
<hr>
<h2 id="4-tbt-개선--scroll-이벤트를-intersectionobserver로">4. TBT 개선 — <code>scroll</code> 이벤트를 IntersectionObserver로</h2>
<p>상단 Nav가 현재 활성 섹션을 하이라이트하기 위해 
<code>window.scroll</code> 이벤트로 위치를 계산하고 있었다.</p>
<h3 id="4-1-기존-코드의-문제">4-1. 기존 코드의 문제</h3>
<pre><code class="language-tsx">// Before: scroll 이벤트 + rAF 쓰로틀링
useEffect(() =&gt; {
    const sections = Array.from(document.querySelectorAll(&quot;section[id]&quot;));
    let ticking = false;

    const handleScroll = () =&gt; {
        if (ticking) return;
        ticking = true;
        requestAnimationFrame(() =&gt; {
            let current = &quot;&quot;;
            sections.forEach((el) =&gt; {
                if (window.scrollY &gt;= el.offsetTop - 100) {
                    current = el.getAttribute(&quot;id&quot;) ?? &quot;&quot;;
                }
            });
            setActiveSection(current);
            ticking = false;
        });
    };
    window.addEventListener(&quot;scroll&quot;, handleScroll);
    return () =&gt; window.removeEventListener(&quot;scroll&quot;, handleScroll);
}, []);</code></pre>
<p><code>requestAnimationFrame</code>으로 실행 횟수를 60fps로 제한했지만, 근본적인 문제는 그대로다.
<strong>스크롤하는 내내 매 프레임 메인 스레드에서 JS가 실행</strong>되고,
el.offsetTop 접근이 강제 레이아웃(forced layout)을 유발해 추가 비용까지 생긴다.
TBT(Total Blocking Time)는 메인 스레드가 50ms 이상 점유되는 구간을 집계하는 지표라,
스크롤 중 누적되는 JS 실행 시간이 점수에 직접적인 영향을 준다.</p>
<h3 id="4-2-intersectionobserver로-교체">4-2. IntersectionObserver로 교체</h3>
<pre><code class="language-tsx">useEffect(() =&gt; {
    /**
     * IntersectionObserver 기반 활성 섹션 감지
     *
     * - 스크롤마다 실행 ❌, 섹션이 진입/이탈할 때만 실행 ✅
     * - 브라우저 내부에서 처리 → 메인 스레드 부담 없음 → TBT 개선
     *
     * rootMargin: &quot;-40% 0px -55% 0px&quot;
     *  → 뷰포트 상단 40%, 하단 55% 제외한 중간 5% 영역에서만 감지
     *  → 스크롤 중간쯤 왔을 때 활성화돼 자연스러운 하이라이트
     */
    const observer = new IntersectionObserver(
        (entries) =&gt; {
            entries.forEach((entry) =&gt; {
                if (entry.isIntersecting) {
                    setActiveSection(entry.target.getAttribute(&quot;id&quot;) ?? &quot;&quot;);
                }
            });
        },
        { rootMargin: &quot;-40% 0px -55% 0px&quot; },
    );

    document.querySelectorAll(&quot;section[id]&quot;).forEach((s) =&gt; observer.observe(s));
    return () =&gt; observer.disconnect();
}, []);</code></pre>
<table>
<thead>
<tr>
<th>방식</th>
<th>실행 빈도</th>
<th>메인 스레드 부담</th>
</tr>
</thead>
<tbody><tr>
<td><code>scroll</code> + rAF</td>
<td>스크롤 중 매 프레임</td>
<td>있음</td>
</tr>
<tr>
<td>IntersectionObserver</td>
<td>진입/이탈 시 1회</td>
<td>거의 없음</td>
</tr>
</tbody></table>
<p><code>rootMargin</code>을 <code>-40% 0px -55% 0px</code>로 줘서 뷰포트의 정중앙(약 5% 영역)을
지날 때만 활성화되도록 했다. 섹션이 스크롤 중간쯤 왔을 때 부드럽게 바뀐다.</p>
<h3 id="4-3-불필요한-whileinview-제거">4-3. 불필요한 <code>whileInView</code> 제거</h3>
<p>Skills 섹션에서 그룹마다 stagger 애니메이션을 돌리고 있었는데,
<strong>그룹 안 뱃지마다 또다시 <code>whileInView</code>를 거는 불필요하게 중복된 구조</strong>였다.</p>
<pre><code class="language-tsx">// Before: 그룹과 뱃지 양쪽에서 IntersectionObserver 등록
&lt;m.div variants={staggerContainer(0.05)} initial=&quot;hidden&quot; whileInView=&quot;visible&quot;&gt;
    {group.skills.map((skill) =&gt; (
        &lt;m.span key={skill.id} variants={badgeVariant} ... /&gt;
    ))}
&lt;/m.div&gt;

// After: 부모 stagger를 자식이 상속받게 단순화
&lt;div className=&quot;flex flex-wrap gap-2&quot;&gt;
    {group.skills.map((skill) =&gt; (
        &lt;m.span key={skill.id} variants={badgeVariant} ... /&gt;
    ))}
&lt;/div&gt;</code></pre>
<p>Framer Motion은 부모의 <code>variants</code>를 자식이 자동으로 상속받는다.
따라서 중간 wrapper에 <code>whileInView</code>를 따로 걸 필요가 없다.
이를 제거하자 <strong>스크롤마다 동작하던 IntersectionObserver 인스턴스 수가 줄었고, 그만큼 TBT도 개선</strong>됐다.</p>
<hr>
<h2 id="5-css·번들-최적화">5. CSS·번들 최적화</h2>
<h3 id="5-1-critical-css-자동-인라인-optimizecss">5-1. Critical CSS 자동 인라인 (<code>optimizeCss</code>)</h3>
<pre><code class="language-ts">// next.config.ts
const nextConfig: NextConfig = {
    experimental: {
        // 첫 화면에 필요한 CSS만 추출해 &lt;head&gt;에 인라인
        optimizeCss: true,
    },
};</code></pre>
<p><code>critters</code> 의존성을 함께 추가하면 Next.js가 빌드 단계에서
<strong>첫 화면 렌더링에 필요한 CSS만 골라 <code>&lt;head&gt;</code>에 인라인</strong>해준다.
나머지는 비동기로 로드되므로 FCP가 빨라진다.</p>
<h3 id="5-2-tailwind-safelist로-동적-클래스-누락-방지">5-2. Tailwind safelist로 동적 클래스 누락 방지</h3>
<pre><code class="language-ts">// tailwind.config.ts
safelist: [
    &quot;object-contain&quot;,
    &quot;object-cover&quot;,
],</code></pre>
<p>JIT 모드는 사용하지 않는 클래스를 제거하는데,
데이터 파일에서 문자열로 조립되는 클래스는 빌드 시 감지되지 않아 누락될 수 있다.
런타임에서 동적으로 쓰는 클래스는 safelist에 명시해 둔다.</p>
<h3 id="5-3-모던-브라우저-타깃으로-polyfill-축소">5-3. 모던 브라우저 타깃으로 polyfill 축소</h3>
<pre><code class="language-json">// package.json
&quot;browserslist&quot;: {
    &quot;production&quot;: [
        &quot;chrome &gt;= 90&quot;,
        &quot;firefox &gt;= 90&quot;,
        &quot;safari &gt;= 14&quot;,
        &quot;edge &gt;= 90&quot;
    ]
}</code></pre>
<p>포트폴리오 사이트는 채용 담당자가 주로 보는 용도라, 방문자층이 좁고 구형 브라우저 지원이 불필요한 만큼,
모던 브라우저로 타깃을 좁히면 transpile 결과물이 가벼워지고 불필요한 polyfill도 줄어든다.</p>
<hr>
<h2 id="6-작은-디테일--willchange와-metadatabase">6. 작은 디테일 — <code>willChange</code>와 metadataBase</h2>
<h3 id="6-1-무한-애니메이션-요소에-willchange">6-1. 무한 애니메이션 요소에 <code>willChange</code></h3>
<pre><code class="language-tsx">&lt;m.div
    animate={{ y: [0, -18, 0] }}
    transition={{ duration: 7, repeat: Infinity, ease: &quot;easeInOut&quot; }}
    style={{ willChange: &quot;transform&quot; }}  // GPU 레이어 분리
/&gt;</code></pre>
<p>Hero 배경의 둥둥 떠다니는 그라디언트 원에 <code>will-change: transform</code>을 적용했다.
브라우저가 미리 GPU 레이어로 합성해서 애니메이션 중 리페인트가 발생하지 않는다.</p>
<blockquote>
<p>단, 모든 요소에 남발하면 메모리 사용량이 늘어나니
<strong>무한 반복되는 transform 애니메이션</strong>에만 선별 적용했다.</p>
</blockquote>
<h3 id="6-2-metadatabase로-og-이미지-절대-경로-자동-처리">6-2. <code>metadataBase</code>로 OG 이미지 절대 경로 자동 처리</h3>
<pre><code class="language-ts">metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? &quot;https://...&quot;),</code></pre>
<p>OG 이미지 경로를 상대 경로로 적어도 자동으로 절대 URL로 변환된다.
환경별 URL을 환경변수로 분기할 수 있어 Vercel preview/production 모두 대응할 수 있다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 작업을 돌아보면, 성능 문제의 대부분은 브라우저가 이미 잘 처리하는 일을 JS로 다시 구현하면서 생겼다.</p>
<blockquote>
<p>&quot;브라우저가 이미 잘하는 일은 브라우저에게 맡기고, JS는 꼭 필요한 곳에만 쓴다.&quot;</p>
</blockquote>
<p>이미지 포맷 변환은 Next.js가, 스크롤 위치 감지는 IntersectionObserver가, 폰트 폴백 보정은 adjustFontFallback이 처리하도록 역할을 넘겼다. 
각자가 맡은 일을 하도록 두었더니 지표도 함께 개선됐다.</p>
<p>그 중 <strong>LCP 요소에 <code>opacity: 0</code>을 넣었던 실수</strong>가 특히 기억에 남았다.
애니메이션을 넣고 싶었을 뿐인데, Lighthouse가 이를 &quot;보이지 않는 요소&quot;로 판단해 LCP 측정 대상에서 아예 제외해버린다는 걸 직접 겪고 나서야 알았다.</p>
<p>결국 브라우저가 어떻게 해석하는지를 먼저 이해하는 게 중요하다는 걸 다시 확인했다.</p>
<hr>
<h4 id="참고">참고</h4>
<ul>
<li><a href="https://nextjs.org/docs/app/building-your-application/optimizing/images">Next.js Image Optimization</a></li>
<li><a href="https://nextjs.org/docs/app/building-your-application/optimizing/fonts">next/font</a></li>
<li><a href="https://web.dev/articles/optimize-lcp">web.dev — Optimize LCP</a></li>
<li><a href="https://developer.mozilla.org/docs/Web/API/Intersection_Observer_API">MDN — IntersectionObserver</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[어메니티 아이콘 시스템 설계부터 개선까지]]></title>
            <link>https://velog.io/@do_dam/%EC%96%B4%EB%A9%94%EB%8B%88%ED%8B%B0-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EA%B0%9C%EC%84%A0%EA%B9%8C%EC%A7%80-hocn2q5c</link>
            <guid>https://velog.io/@do_dam/%EC%96%B4%EB%A9%94%EB%8B%88%ED%8B%B0-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EA%B0%9C%EC%84%A0%EA%B9%8C%EC%A7%80-hocn2q5c</guid>
            <pubDate>Tue, 21 Apr 2026 06:00:29 GMT</pubDate>
            <description><![CDATA[<p>숙박 플랫폼에서 어메니티 아이콘 시스템을 설계했습니다. 단순히 아이콘을 렌더링하는 것에서 끝나지 않고, 구현하면서 발견한 문제들을 직접 개선하는 과정까지 겪었습니다. SVG 변환 자동화, id 충돌 방지, 번들 최적화 등 그 과정을 정리한 글입니다.</p>
<hr>
<h2 id="배경">배경</h2>
<p>작업한 서비스는 세 개의 레이어로 나뉩니다.</p>
<pre><code>Studio (개발자)
  어메니티 항목 등록 — 아이콘 코드, 이름, 카테고리 등
        ↓
host (숙소 운영자)
  자기 숙소에서 제공하는 어메니티 선택·추가
        ↓
게스트 화면
  해당 숙소의 제공 어메니티 아이콘 노출</code></pre><p>Studio는 개발자가 어메니티 항목을 관리하는 어드민 페이지입니다. 여기서 새 어메니티를 등록하면, 숙소 운영자는 host 페이지에서 자기 숙소가 해당 어메니티를 제공한다고 설정할 수 있습니다. 그리고 게스트는 숙소 홈페이지에서 해당 숙소의 제공 어메니티를 아이콘으로 확인합니다.</p>
<p>여기서 핵심 조건이 하나 있습니다. <strong>Studio에서 새 어메니티를 추가할 때마다 개발자가 프론트엔드 코드를 수정하고 배포해야 하면 안 된다</strong>는 점입니다. 어메니티는 운영 중에도 수시로 추가되는데, 그때마다 배포가 필요하다면 현실적으로 운영이 어렵습니다.</p>
<hr>
<h2 id="정적-import-방식의-한계">정적 import 방식의 한계</h2>
<p>제일 먼저 떠올린 방식은 아이콘 컴포넌트를 직접 import하는 방법이었습니다.</p>
<pre><code class="language-tsx">import { BathTowelIcon } from &#39;panzy-ui&#39;
import { WifiIcon } from &#39;panzy-ui&#39;

&lt;BathTowelIcon /&gt;
&lt;WifiIcon /&gt;</code></pre>
<p>그런데 백엔드에서 내려오는 데이터는 이런 형태였습니다.</p>
<pre><code class="language-json">{
  &quot;amenities&quot;: [
    { &quot;name&quot;: &quot;수건&quot;, &quot;code&quot;: &quot;BATH_TOWEL&quot; },
    { &quot;name&quot;: &quot;WiFi&quot;, &quot;code&quot;: &quot;WIFI&quot; }
  ]
}</code></pre>
<p><code>code</code> 값이 문자열로 오기 때문에, 어떤 아이콘을 렌더링할지 런타임 전에는 알 수 없습니다. 결국 정적 import 방식으로는 가능한 모든 <code>code</code>에 대해 아래처럼 직접 매핑 코드를 작성해야 합니다.</p>
<pre><code class="language-tsx">import { BathTowelIcon } from &#39;panzy-ui&#39;
import { WifiIcon } from &#39;panzy-ui&#39;
import { ParkingIcon } from &#39;panzy-ui&#39;
// ...

const iconMap: Record&lt;string, React.FC&gt; = {
  BATH_TOWEL: BathTowelIcon,
  WIFI: WifiIcon,
  PARKING: ParkingIcon,
  // DB에 있는 모든 code를 여기에 직접 등록해야 함
}

const icon = iconMap[amenity.code]</code></pre>
<p>Studio에서 새 어메니티가 추가될 때마다 개발자가 <code>import</code> 한 줄, <code>iconMap</code> 항목 한 줄을 수동으로 추가하고 배포해야 합니다. 어메니티가 총 150개가 넘고 계속 추가될 수 있는 상황이므로 이 방식은 처음부터 한계가 있었습니다.</p>
<hr>
<h2 id="아이콘-표시-방식-검토">아이콘 표시 방식 검토</h2>
<p>정적 import의 한계를 확인하고 나서, 어떤 방식으로 아이콘을 관리하고 표시할지 몇 가지 방법을 비교했습니다.</p>
<p><strong>CDN에 SVG 파일 서빙</strong></p>
<p>SVG 파일을 S3 같은 스토리지에 올려두고 <code>code</code> 값으로 URL을 조합해 <code>&lt;img&gt;</code> 태그로 표시하는 방식입니다. 새 아이콘 추가 시 파일 업로드만 하면 되니 배포가 전혀 필요 없습니다. 다만 <code>&lt;img&gt;</code> 태그로 렌더링하면 CSS <code>color</code> 속성이 적용되지 않아 색상을 동적으로 제어할 수 없다는 단점이 있습니다.</p>
<p><strong>SVG 스프라이트</strong></p>
<p>모든 SVG를 하나의 <code>sprite.svg</code> 파일로 묶고 <code>&lt;use href=&quot;#icon-id&quot;&gt;</code>로 참조하는 방식입니다. 요청이 한 번으로 줄고 <code>currentColor</code>로 색상 제어도 됩니다. 하지만 아이콘이 추가될 때마다 스프라이트 파일을 다시 빌드해야 해서 완전한 무배포 운영은 어렵습니다.</p>
<p><strong>DB에 SVG 마크업 저장</strong></p>
<p>Studio에서 아이콘을 등록할 때 SVG 마크업 자체를 DB에 저장하고, 프론트에서 <code>dangerouslySetInnerHTML</code>로 렌더링하는 방식입니다. 프론트엔드 코드 변경이 전혀 필요 없고 색상 제어도 가능합니다. 다만 XSS 보안 처리가 필요하고, DB에 마크업을 직접 저장하는 구조가 관리 측면에서 부담이 됩니다.</p>
<p><strong>SVG → TSX 컴포넌트</strong></p>
<p>SVG를 빌드 타임에 React 컴포넌트로 변환해 패키지에서 관리하는 방식입니다. 색상 제어, 타입 안정성, 번들 최적화가 모두 가능합니다. 새 아이콘 추가 시 파일 변환과 배포가 필요하지만, <code>code</code> 값 기반 동적 매핑을 통해 Studio에서 어메니티를 추가할 때는 배포 없이 바로 연결되도록 구성할 수 있습니다.</p>
<table>
<thead>
<tr>
<th></th>
<th align="center">색상 제어</th>
<th align="center">배포 없이 추가</th>
<th align="center">타입 안정성</th>
</tr>
</thead>
<tbody><tr>
<td>CDN 서빙</td>
<td align="center">❌</td>
<td align="center">✅</td>
<td align="center">❌</td>
</tr>
<tr>
<td>SVG 스프라이트</td>
<td align="center">✅</td>
<td align="center">❌</td>
<td align="center">❌</td>
</tr>
<tr>
<td>DB 마크업 저장</td>
<td align="center">✅</td>
<td align="center">✅</td>
<td align="center">❌</td>
</tr>
<tr>
<td>SVG → TSX 컴포넌트</td>
<td align="center">✅</td>
<td align="center">△</td>
<td align="center">✅</td>
</tr>
</tbody></table>
<p>이 프로젝트에서는 <code>color</code> prop으로 색상을 자유롭게 제어해야 했고, TypeScript 기반 모노레포에서 타입 안정성도 중요했습니다. 아이콘 파일이 추가될 때 배포가 필요하다는 점은 <code>code</code> 기반 동적 렌더링으로 보완할 수 있었기 때문에, SVG → TSX 컴포넌트 방식을 선택했습니다.</p>
<hr>
<h2 id="amenityiconwrapper-동적-렌더링으로-전환">AmenityIconWrapper: 동적 렌더링으로 전환</h2>
<p><code>code</code> 값 자체를 활용하는 방향으로 전환했습니다. <code>BATH_TOWEL</code>이라는 문자열이 오면 이를 컴포넌트 이름으로 변환해 해당 컴포넌트를 찾아 렌더링하는 방식입니다.</p>
<pre><code class="language-tsx">&lt;AmenityIconWrapper iconKey=&quot;BATH_TOWEL&quot; color=&quot;#4A90E2&quot; size={32} /&gt;</code></pre>
<p>내부 변환 흐름은 다음과 같습니다.</p>
<pre><code>&quot;BATH_TOWEL&quot;
  → &quot;bath-towel&quot;     (snake_case → kebab-case)
  → &quot;BathTowelIcon&quot;  (PascalCase로 변환)
  → 해당 컴포넌트 렌더링</code></pre><p>이 방식을 쓰면 Studio에서 새 어메니티를 추가해도 프론트엔드 코드를 수정하거나 배포할 필요 없이 host와 게스트 화면에서 바로 아이콘이 연결됩니다.</p>
<p><code>code</code> 값이 컴포넌트 이름과 정확히 일치하지 않는 케이스를 대비해 키워드 기반 fallback도 추가했습니다. <code>coffee_maker_v2</code> 같은 값이 오면 <code>coffee</code> 키워드를 보고 <code>CoffeeMachineIcon</code>으로 연결되는 식입니다. 완벽하진 않지만 아이콘이 아예 렌더링되지 않는 것보다는 낫다고 판단했습니다.</p>
<hr>
<h2 id="아이콘-관리-구조">아이콘 관리 구조</h2>
<p>아이콘 컴포넌트는 <code>panzy-ui</code> 패키지에서 중앙 관리합니다. SVG 파일을 그대로 쓰는 게 아니라 TSX 컴포넌트로 변환해서 사용했고, 원본 SVG는 <code>amenitiesOriginal/</code> 폴더에 따로 보관했습니다.</p>
<p>카테고리별로 폴더를 나눴으며, 총 10개 카테고리 150개 이상입니다.</p>
<pre><code>packages/panzy-ui/src/components/icons/amenities/
├── amenitiesOriginal/
├── bath/       (32개)
├── bedding/    (16개)
├── facility/   (15개)
├── kitchen/    (25개)
├── laundry/    (8개)
├── outdoor/    (8개)
├── pet/        (7개)
├── room/       (24개)
├── safety/     (9개)
└── service/    (10개)</code></pre><p>모든 아이콘은 <code>24x24 viewBox</code>에 <code>currentColor</code>를 기본값으로 사용했습니다. <code>currentColor</code>를 쓰면 부모 요소의 CSS <code>color</code>를 상속받기 때문에, <code>color</code> prop 하나로 색상을 자유롭게 제어할 수 있습니다.</p>
<hr>
<h2 id="studio-관리-ui">Studio 관리 UI</h2>
<p>개발자가 어메니티 항목을 등록·관리하는 Studio 쪽 UI도 함께 작업했습니다.</p>
<pre><code>packages/studio/src/app/studio/management/amenity-categories/
├── AmenityForm.tsx
├── AmenityCategoryForm.tsx
├── AmenityTable.tsx
└── AmenityCategoryTable.tsx</code></pre><p>데이터 모델에서 신경 쓴 부분은 <code>scope</code> 필드입니다. 어메니티가 숙소 전체에 해당하는지, 객실별로 다른지를 구분해야 했습니다. 수영장·주차장은 숙소 공통이고, 침대 타입·욕실 비품은 객실마다 다를 수 있기 때문입니다.</p>
<ul>
<li><code>ACCOMMODATION</code> — 숙소 공통</li>
<li><code>ROOM</code> — 객실별</li>
<li><code>BOTH</code> — 둘 다</li>
</ul>
<p>다국어 이름·설명(<code>translations</code>)과 정렬 순서(<code>seq</code>)도 관리할 수 있도록 했습니다.</p>
<hr>
<h2 id="초기-구현-이후-발견한-문제와-개선">초기 구현 이후 발견한 문제와 개선</h2>
<p>초기 구현을 마치고 나서 세 가지 문제가 눈에 들어왔습니다.</p>
<hr>
<h3 id="1-svg-수동-변환-자동화">1. SVG 수동 변환 자동화</h3>
<p>아이콘이 추가될 때마다 SVG 파일을 직접 TSX 컴포넌트로 변환했는데, 150개가 넘다 보니 반복 작업의 비중이 꽤 컸습니다. 아이콘이 늘어날수록 더 심해질 게 분명해서 <code>@svgr/cli</code>와 <code>svgo</code>로 자동화했습니다.</p>
<p><code>package.json</code>에 스크립트를 추가했습니다.</p>
<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;generate:icons&quot;: &quot;svgr --config .svgrrc.json src/components/icons/amenities/amenitiesOriginal --out-dir src/components/icons/amenities/generated&quot;
  }
}</code></pre>
<p><code>.svgrrc.json</code> 설정은 아래와 같습니다.</p>
<pre><code class="language-json">{
  &quot;plugins&quot;: [&quot;@svgr/plugin-svgo&quot;, &quot;@svgr/plugin-jsx&quot;],
  &quot;svgoConfig&quot;: {
    &quot;plugins&quot;: [
      { &quot;name&quot;: &quot;preset-default&quot; },
      { &quot;name&quot;: &quot;prefixIds&quot; }
    ]
  },
  &quot;typescript&quot;: true,
  &quot;dimensions&quot;: false,
  &quot;replaceAttrValues&quot;: { &quot;#000&quot;: &quot;currentColor&quot;, &quot;#000000&quot;: &quot;currentColor&quot; }
}</code></pre>
<p>이제 <code>amenitiesOriginal/</code> 폴더에 SVG를 넣고 스크립트를 실행하면 TSX 컴포넌트가 자동으로 생성됩니다.</p>
<hr>
<h3 id="2-svg-id-충돌-방지">2. SVG id 충돌 방지</h3>
<p>SVG 내부에서 <code>mask</code>나 <code>linearGradient</code> 같은 요소에 <code>id</code>를 사용하는 경우, 같은 페이지에 동일한 아이콘이 여러 개 렌더링되면 <code>id</code>가 충돌해 렌더링이 깨질 수 있습니다.</p>
<p>예를 들어 같은 아이콘 두 개가 각각 <code>mask0</code>라는 id를 가지고 있으면, 두 번째 아이콘의 마스크가 첫 번째 것을 참조하게 되어 시각적으로 오동작합니다.</p>
<p>svgo의 <code>prefixIds</code> 플러그인을 자동화 파이프라인에 포함시켜서 해결했습니다. 변환 시 각 SVG 파일의 id를 파일명 기반 고유 prefix로 자동 대체해줍니다.</p>
<pre><code class="language-json">{
  &quot;svgoConfig&quot;: {
    &quot;plugins&quot;: [
      { &quot;name&quot;: &quot;prefixIds&quot; }
    ]
  }
}</code></pre>
<p>변환 전후를 비교하면 이렇습니다.</p>
<pre><code class="language-svg">&lt;!-- 변환 전 --&gt;
&lt;mask id=&quot;mask0&quot;&gt;...&lt;/mask&gt;
&lt;rect mask=&quot;url(#mask0)&quot; /&gt;

&lt;!-- 변환 후 (bath-towel.svg 기준) --&gt;
&lt;mask id=&quot;bath-towel-mask0&quot;&gt;...&lt;/mask&gt;
&lt;rect mask=&quot;url(#bath-towel-mask0)&quot; /&gt;</code></pre>
<p>자동화 파이프라인에 포함되어 있으니, 이후 추가되는 아이콘도 별도 처리 없이 id 충돌이 방지됩니다.</p>
<hr>
<h3 id="3-트리쉐이킹--동적-import로-전환">3. 트리쉐이킹 — 동적 import로 전환</h3>
<p><code>AmenityIconWrapper</code>가 내부에서 전체 아이콘 맵을 정적으로 들고 있는 구조였습니다.</p>
<pre><code class="language-tsx">import * as AmenityIcons from &#39;../icons/amenities&#39;

const iconMap = { ...AmenityIcons }</code></pre>
<p>이렇게 하면 실제로 사용하지 않는 아이콘도 전부 번들에 포함됩니다. 150개 이상의 SVG 컴포넌트가 모두 번들에 들어가는 셈입니다.</p>
<p>동적 import로 바꿔서 렌더링 시점에 필요한 아이콘만 로드하도록 했습니다.</p>
<pre><code class="language-tsx">import { lazy, Suspense } from &#39;react&#39;

function AmenityIconWrapper({ iconKey, size = 24, color = &#39;currentColor&#39; }: Props) {
  const componentName = toComponentName(iconKey) // &quot;BATH_TOWEL&quot; → &quot;BathTowelIcon&quot;

  const IconComponent = lazy(() =&gt;
    import(`../icons/amenities/generated/${toKebabCase(iconKey)}`).then((mod) =&gt; ({
      default: mod[componentName] ?? mod.default,
    }))
  )

  return (
    &lt;Suspense fallback={&lt;span style={{ width: size, height: size, display: &#39;inline-block&#39; }} /&gt;}&gt;
      &lt;IconComponent width={size} height={size} color={color} /&gt;
    &lt;/Suspense&gt;
  )
}</code></pre>
<p>번들 사이즈가 줄었고, 실제로 노출되는 아이콘만 네트워크 요청이 발생합니다. 다만 아이콘마다 별도 chunk가 생기기 때문에, 한 화면에 아이콘이 여러 개 노출된다면 그만큼 요청이 늘어납니다. 숙소 상세 페이지처럼 어메니티를 한꺼번에 여러 개 보여주는 화면에서는 문제가 될 수 있습니다.</p>
<p>이를 보완하기 위해 아이콘을 카테고리 단위로 묶어 하나의 chunk로 로드하는 방식을 적용했습니다. 각 카테고리별로 index 파일을 두고, 아이콘 코드에서 카테고리를 먼저 추출한 뒤 해당 카테고리 chunk를 동적으로 import합니다.</p>
<pre><code class="language-tsx">// 카테고리별 index (예: bath/index.ts)
export { BathTowelIcon } from &#39;./BathTowelIcon&#39;
export { ShampooIcon } from &#39;./ShampooIcon&#39;
export { SoapIcon } from &#39;./SoapIcon&#39;
// ...

// AmenityIconWrapper
const IconComponent = lazy(() =&gt;
  import(`../icons/amenities/${getCategory(iconKey)}/index`).then((mod) =&gt; ({
    default: mod[componentName],
  }))
)</code></pre>
<p>이렇게 하면 같은 카테고리의 아이콘들은 첫 번째 요청 이후 캐싱되어 추가 요청 없이 바로 렌더링됩니다. 숙소 상세 페이지처럼 bath 카테고리 아이콘이 여러 개 노출되는 경우, 요청이 아이콘 수만큼이 아니라 카테고리 수만큼으로 줄어듭니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>Studio에서 어메니티를 추가하면 host와 게스트 화면에 배포 없이 바로 반영되어야 한다는 조건 하나가 설계 방향 자체를 바꿨습니다. 단순히 아이콘을 렌더링하는 문제가 아니라, <code>code</code> 문자열을 컴포넌트로 동적 연결하는 구조를 설계해야 했고, 이를 위해 SVG → TSX 컴포넌트 방식을 선택하게 됐습니다.</p>
<p>초기 구현 이후에 보이기 시작한 문제들은 모두 &quot;처음부터 알았더라면&quot;이라는 생각이 드는 것들이었습니다. 수동 변환 작업은 svgr 파이프라인으로 없앴고, SVG id 충돌은 <code>prefixIds</code>를 파이프라인에 포함하면서 변환 시점에 원천 차단했습니다. 번들 최적화는 동적 import와 카테고리 단위 chunk 묶음을 조합해 해결했습니다. 세 가지 모두 기능 자체가 아니라 운영 맥락에서 비로소 보이는 문제였다는 점이 공통점입니다.</p>
<p>코드가 정상적으로 실행되는 것과, 실제 환경에서 지속적으로 사용될 수 있는 수준의 코드 사이에는 분명한 차이가 있었습니다. 이번 작업은 그 차이를 구성하는 요소들(유지보수성, 예외 처리, 코드 구조 등)을 구체적으로 고민해볼 수 있는 경험이었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[내 사이트가 검색에 안 잡히는 이유 — X-Robots-Tag: noindex]]></title>
            <link>https://velog.io/@do_dam/Vercel-%EB%B0%B0%ED%8F%AC-%ED%9B%84-Lighthouse-SEO%EA%B0%80-63%EC%A0%90%EC%9D%B8-%EC%9D%B4%EC%9C%A0-x-robots-tag-noindex</link>
            <guid>https://velog.io/@do_dam/Vercel-%EB%B0%B0%ED%8F%AC-%ED%9B%84-Lighthouse-SEO%EA%B0%80-63%EC%A0%90%EC%9D%B8-%EC%9D%B4%EC%9C%A0-x-robots-tag-noindex</guid>
            <pubDate>Thu, 16 Apr 2026 06:24:44 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>vercel.app 서브도메인으로 배포한 포트폴리오 사이트에서 Lighthouse SEO 점수가 100점에서 63점으로 급락했다. 코드를 아무리 뜯어봐도 문제가 없는데, 원인은 의외로 단순했다.</p>
</blockquote>
<hr>
<h2 id="문제-상황">문제 상황</h2>
<p>포트폴리오 사이트를 Vercel에 배포한 후 Lighthouse로 성능을 측정했다.</p>
<ul>
<li>성능: 99</li>
<li>접근성: 100</li>
<li>권장사항: 100</li>
<li><strong>검색엔진 최적화(SEO): 63</strong> 🚨</li>
</ul>
<p>SEO 항목을 클릭해서 펼쳐보니 이런 오류가 표시됐다.</p>
<pre><code>크롤링 및 색인 생성
▲ 페이지의 색인 생성이 차단됨

Blocking Directive Source
x-robots-tag: noindex</code></pre><p>meta 태그도 문제없고, sitemap도 있고, 코드 상에서 noindex를 설정한 적이 없는데 왜 이런 헤더가 붙는 걸까?</p>
<hr>
<h2 id="x-robots-tag가-뭔가요">x-robots-tag가 뭔가요?</h2>
<p><code>x-robots-tag</code>는 HTTP 응답 헤더로, 검색엔진 크롤러에게 해당 페이지를 어떻게 처리할지 지시하는 역할을 한다.</p>
<pre><code>x-robots-tag: noindex</code></pre><p>이 헤더가 응답에 포함되면 Google, Bing 등 검색엔진은 해당 페이지를 <strong>크롤링은 하지만 검색 결과에는 포함시키지 않는다.</strong></p>
<p>HTML에서 사용하는 아래 메타 태그와 동일한 효과를 HTTP 헤더 레벨에서 발휘한다.</p>
<pre><code class="language-html">&lt;meta name=&quot;robots&quot; content=&quot;noindex&quot; /&gt;</code></pre>
<p>차이점은 메타 태그는 HTML 파싱 후 적용되지만, HTTP 헤더는 페이지 로드 전에 이미 적용된다는 점이다.</p>
<hr>
<h2 id="왜-vercel이-자동으로-붙이는-걸까">왜 Vercel이 자동으로 붙이는 걸까?</h2>
<p>원인은 <strong>vercel.app 서브도메인 정책</strong> 때문이다.</p>
<p>Vercel은 무료 플랜에서 <code>*.vercel.app</code> 서브도메인으로 프로젝트를 배포한다.</p>
<pre><code>https://my-project.vercel.app</code></pre><p>문제는 이 <code>vercel.app</code> 도메인 아래에 수천, 수만 개의 프로젝트가 배포되어 있다는 점이다. 만약 모든 프로젝트가 검색엔진에 인덱싱된다면:</p>
<ul>
<li>테스트용, 임시 배포 프로젝트들이 검색 결과에 노출됨</li>
<li>스팸성 콘텐츠로 분류될 가능성이 높아짐</li>
<li><code>vercel.app</code> 도메인 자체의 검색 신뢰도가 낮아짐</li>
</ul>
<p>이런 이유로 Vercel은 <strong>커스텀 도메인이 연결되지 않은 <code>vercel.app</code> 서브도메인에는 자동으로 <code>x-robots-tag: noindex</code> 헤더를 삽입</strong>한다.</p>
<p>즉, 내 코드의 문제가 아니라 <strong>Vercel 인프라 레벨에서 강제로 삽입</strong>하는 헤더다.</p>
<hr>
<h2 id="직접-확인하는-방법">직접 확인하는 방법</h2>
<p>터미널에서 curl 명령어로 응답 헤더를 확인할 수 있다.</p>
<pre><code class="language-bash">curl -I https://my-project.vercel.app</code></pre>
<p>응답 헤더에서 아래와 같이 확인된다.</p>
<pre><code>HTTP/2 200
...
x-robots-tag: noindex
...</code></pre><p>커스텀 도메인이 연결된 경우에는 이 헤더가 없다.</p>
<hr>
<h2 id="코드로-우회할-수-없을까">코드로 우회할 수 없을까?</h2>
<p><code>next.config.js</code>에서 응답 헤더를 직접 설정해서 <code>index</code>로 덮어쓰려고 시도해봤다.</p>
<pre><code class="language-js">// next.config.js
const nextConfig = {
  async headers() {
    return [
      {
        source: &quot;/(.*)&quot;,
        headers: [
          { key: &quot;X-Robots-Tag&quot;, value: &quot;index&quot; },
        ],
      },
    ];
  },
};</code></pre>
<p>결과는 <strong>효과 없음</strong>이다.</p>
<p>Vercel 인프라 레벨에서 응답 헤더를 후처리하여 <code>noindex</code>를 덮어쓰기 때문에, 애플리케이션 코드에서 설정한 헤더는 무시된다. 이것이 핵심 포인트다.</p>
<blockquote>
<p><strong>코드 레벨에서는 해결이 불가능하다.</strong></p>
</blockquote>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="방법-1--커스텀-도메인-연결-가장-확실">방법 1 — 커스텀 도메인 연결 (가장 확실)</h3>
<p>커스텀 도메인을 연결하면 Vercel이 <code>noindex</code> 헤더를 자동으로 제거한다.</p>
<ol>
<li>도메인 구매 (가비아, Namecheap, Google Domains 등)</li>
<li>Vercel 프로젝트 → Settings → Domains</li>
<li>구매한 도메인 입력 후 DNS 설정</li>
<li>연결 완료 후 <code>x-robots-tag: noindex</code> 헤더 사라짐</li>
</ol>
<h3 id="방법-2--github-pages로-이전">방법 2 — GitHub Pages로 이전</h3>
<p>GitHub Pages는 무료 플랜에서도 <code>noindex</code> 헤더를 붙이지 않는다.</p>
<p>Next.js 프로젝트의 경우 <code>next.config.js</code>에서 정적 내보내기 설정이 필요하다.</p>
<pre><code class="language-js">// next.config.js
const nextConfig = {
  output: &quot;export&quot;,
};</code></pre>
<h3 id="방법-3--netlify로-이전">방법 3 — Netlify로 이전</h3>
<p>Netlify도 무료 플랜에서 <code>noindex</code> 없이 배포가 가능하다. 설정 없이 GitHub 연동 후 바로 배포할 수 있다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>배포 환경</th>
<th>SEO 인덱싱 여부</th>
</tr>
</thead>
<tbody><tr>
<td>vercel.app 서브도메인</td>
<td>❌ noindex 자동 삽입</td>
</tr>
<tr>
<td>Vercel + 커스텀 도메인</td>
<td>✅ 정상 인덱싱</td>
</tr>
<tr>
<td>GitHub Pages</td>
<td>✅ 정상 인덱싱</td>
</tr>
<tr>
<td>Netlify</td>
<td>✅ 정상 인덱싱</td>
</tr>
</tbody></table>
<hr>
<h2 id="번외--x-robots-tag-noindex-말고-뭐가-더-있을까">번외 — X-Robots-Tag, noindex 말고 뭐가 더 있을까?</h2>
<p>이번 문제를 겪으면서 <code>x-robots-tag</code>를 처음 접했다면, <code>noindex</code> 외에도 다양한 디렉티브가 있다는 걸 알아두면 나중에 유용하다.</p>
<h3 id="자주-쓰이는-디렉티브">자주 쓰이는 디렉티브</h3>
<table>
<thead>
<tr>
<th>디렉티브</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td><code>noindex</code></td>
<td>검색 결과에 노출하지 않음</td>
</tr>
<tr>
<td><code>nofollow</code></td>
<td>페이지 내 링크를 따라가지 않음</td>
</tr>
<tr>
<td><code>none</code></td>
<td><code>noindex, nofollow</code> 단축 표기</td>
</tr>
<tr>
<td><code>nosnippet</code></td>
<td>검색 결과에서 텍스트 스니펫 미표시</td>
</tr>
<tr>
<td><code>noimageindex</code></td>
<td>페이지 내 이미지를 색인화하지 않음</td>
</tr>
<tr>
<td><code>notranslate</code></td>
<td>검색 결과에서 자동 번역 옵션 미제공</td>
</tr>
</tbody></table>
<p>숫자로 세밀하게 제어하는 디렉티브도 있다.</p>
<pre><code class="language-http"># 스니펫 최대 150자
X-Robots-Tag: max-snippet: 150

# 이미지 미리보기 크기 제한 (none | standard | large)
X-Robots-Tag: max-image-preview: standard

# 특정 날짜 이후 검색 결과에서 제거
X-Robots-Tag: unavailable_after: Tue, 31 Dec 2025 23:59:59 GMT</code></pre>
<h3 id="특정-봇에만-적용하기">특정 봇에만 적용하기</h3>
<p>봇 이름을 앞에 붙이면 해당 크롤러에게만 규칙이 적용된다.</p>
<pre><code class="language-http">X-Robots-Tag: googlebot: nofollow
X-Robots-Tag: googlebot: nofollow, BadBot: noindex, nofollow</code></pre>
<h3 id="x-robots-tag-vs-robotstxt">X-Robots-Tag vs robots.txt</h3>
<p>헷갈리기 쉬운 둘의 차이를 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th></th>
<th>x-robots-tag</th>
<th>robots.txt</th>
</tr>
</thead>
<tbody><tr>
<td>적용 단위</td>
<td>개별 URL</td>
<td>디렉토리/경로 단위</td>
</tr>
<tr>
<td>색인화 제어</td>
<td>✅ <code>noindex</code> 등</td>
<td>❌ 접근 차단만 가능</td>
</tr>
<tr>
<td>크롤링 차단</td>
<td>❌</td>
<td>✅ <code>Disallow</code></td>
</tr>
<tr>
<td>비 HTML 리소스</td>
<td>✅ PDF, 이미지 등</td>
<td>✅</td>
</tr>
</tbody></table>
<p>한 가지 주의할 점이 있다. robots.txt로 접근이 차단된 URL은 크롤러가 <code>x-robots-tag</code>를 읽지 못한다. 색인 제거가 목적이라면 크롤링을 허용한 상태에서 <code>noindex</code>를 쓰는 것이 맞다.</p>
<h3 id="nextjs에서-직접-설정할-때">Next.js에서 직접 설정할 때</h3>
<p>커스텀 도메인이 연결된 환경에서 특정 경로만 색인화를 막고 싶다면 이렇게 쓴다.</p>
<pre><code class="language-js">// next.config.js
async headers() {
  return [
    {
      source: &#39;/admin/:path*&#39;,
      headers: [{ key: &#39;X-Robots-Tag&#39;, value: &#39;noindex, nofollow&#39; }],
    },
  ];
},</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>Lighthouse SEO 점수가 갑자기 낮아졌다고 해서 코드를 한참 뒤졌는데, 원인은 Vercel의 플랫폼 정책이었다.</p>
<p>포트폴리오 사이트처럼 실제 검색 노출이 중요한 경우라면 커스텀 도메인 연결을 고려하는 것이 좋다. 도메인 가격은 연간 1~2만원 수준으로 부담이 크지 않다.</p>
<p>반대로 개발 중인 프로젝트나 테스트 배포라면 <code>vercel.app</code> 서브도메인의 <code>noindex</code> 정책이 오히려 의도치 않은 검색 노출을 막아주는 장점이 되기도 한다.</p>
<hr>
<p><strong>참고</strong></p>
<ul>
<li><a href="https://vercel.com/docs/security/deployment-protection">Vercel 공식 문서 — Deployment Protection</a></li>
<li><a href="https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag">Google 검색 센터 — robots 메타 태그 및 X-Robots-Tag HTTP 헤더</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Robots-Tag">MDN Web Docs — X-Robots-Tag</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 SVG 아이콘 제대로 다루기 — CDN 제거부터 next/image 함정까지]]></title>
            <link>https://velog.io/@do_dam/Next.js%EC%97%90%EC%84%9C-SVG-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%8B%A4%EB%A3%A8%EA%B8%B0-CDN-%EC%A0%9C%EA%B1%B0%EB%B6%80%ED%84%B0-nextimage-%ED%95%A8%EC%A0%95%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@do_dam/Next.js%EC%97%90%EC%84%9C-SVG-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%8B%A4%EB%A3%A8%EA%B8%B0-CDN-%EC%A0%9C%EA%B1%B0%EB%B6%80%ED%84%B0-nextimage-%ED%95%A8%EC%A0%95%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 14 Apr 2026 01:57:08 GMT</pubDate>
            <description><![CDATA[<p>아이콘 36개를 어디서 불러오느냐로 Lighthouse 점수가 87점에서 75점까지 떨어졌습니다. 원인은 <code>loading=&quot;lazy&quot;</code> 한 줄이었습니다.</p>
<p>포트폴리오 사이트의 기술 스택 섹션을 개발하면서 아이콘 처리 방식이 성능에 이렇게까지 영향을 미칠 거라고는 생각하지 못했습니다.</p>
<p>처음엔 단순히 타입 안전성을 높이는 리팩토링이었는데, 
결국 외부 CDN 의존을 완전히 제거하는 방향으로 이어졌습니다. </p>
<p>이 글은 그 과정을 단계별로 정리한 기록입니다.</p>
<hr>
<h2 id="1단계--type-safe-구조로-skillid-union-type-도입">1단계 — Type-safe 구조로: <code>SkillId</code> union type 도입</h2>
<h3 id="문제">문제</h3>
<p>처음 코드는 스킬 이름(string)을 key로 아이콘 URL을 매핑하는 구조였습니다.</p>
<pre><code class="language-ts">// Skills.tsx
const ICON_URL: Record&lt;string, string&gt; = {
    &quot;HTML&quot;: `${DI}/html5/html5-original.svg`,
    &quot;React&quot;: `${DI}/react/react-original.svg`,
    // ...
};</code></pre>
<p>이 구조는 오타가 발생해도 TypeScript가 잡아주지 못했고, 존재하지 않는 키에 접근해도 컴파일 에러가 없었습니다.</p>
<h3 id="해결">해결</h3>
<p><code>SkillId</code> union type을 정의하고 <code>Record&lt;SkillId, string&gt;</code>으로 타입을 좁혔습니다.</p>
<pre><code class="language-ts">// types/index.ts
export type SkillId =
    | &quot;html&quot;
    | &quot;css&quot;
    | &quot;javascript&quot;
    | &quot;typescript&quot;
    | &quot;react&quot;
    | &quot;nextjs&quot;
    // ...

export interface Skill {
    id: SkillId;
    name: string;
    color: string;
}</code></pre>
<pre><code class="language-ts">// Skills.tsx
const ICON_URL: Record&lt;SkillId, string&gt; = {
    html: `${DI}/html5/html5-original.svg`,
    react: `${DI}/react/react-original.svg`,
    // ...
};</code></pre>
<p><strong>효과:</strong> 오타 시 즉시 컴파일 에러. 존재하지 않는 id 접근 차단.</p>
<hr>
<h2 id="2단계--데이터-구조-개선-아이콘을-데이터-안으로-이동">2단계 — 데이터 구조 개선: 아이콘을 데이터 안으로 이동</h2>
<h3 id="문제-1">문제</h3>
<p>스킬 데이터(<code>portfolio.ts</code>)와 아이콘 URL(<code>Skills.tsx</code>)이 분리되어 있어서, 스킬을 추가할 때 두 파일을 동시에 수정해야 했습니다.</p>
<pre><code>portfolio.ts  → 스킬 데이터 (name, color)
Skills.tsx    → 아이콘 매핑 (ICON_URL)</code></pre><p>유지보수할 때 둘 중 하나를 빠뜨리면 아이콘이 깨지는 문제가 있었습니다.</p>
<h3 id="해결-데이터-colocation-구조">해결: 데이터 colocation 구조</h3>
<p><code>Skill</code> 인터페이스에 <code>icon</code> 필드를 추가하고, 아이콘 URL을 데이터 안에 포함시켰습니다.</p>
<pre><code class="language-ts">// types/index.ts
export interface Skill {
    id: SkillId;
    name: string;
    color: string;
    icon?: string; // 추가
}</code></pre>
<pre><code class="language-ts">// portfolio.ts
export const skillGroups: SkillGroup[] = [
    {
        category: &quot;Language&quot;,
        skills: [
            { id: &quot;html&quot;, name: &quot;HTML&quot;, color: &quot;#e34c26&quot;, icon: `${DI}/html5/html5-original.svg` },
            { id: &quot;react&quot;, name: &quot;React&quot;, color: &quot;#61dafb&quot;, icon: `${DI}/react/react-original.svg` },
        ],
    },
];</code></pre>
<pre><code class="language-tsx">// Skills.tsx — ICON_URL 완전 제거
{skill.icon ? (
    &lt;img src={skill.icon} alt={skill.name} width={14} height={14} /&gt;
) : (
    &lt;span style={{ backgroundColor: skill.color }} /&gt;
)}</code></pre>
<p><strong>효과:</strong> 스킬을 추가할 때 <code>portfolio.ts</code> 한 곳만 수정하면 됩니다.
데이터와 아이콘 정보가 같은 곳에 있으니(data colocation) 누락이 생길 수 없습니다.</p>
<p>ICON_URL을 제거하고 <code>&lt;img&gt;</code> 태그를 직접 쓰게 되면서,
이미지 최적화에 대해 평소 알고 있던 내용을 적용해보고 싶었습니다.</p>
<blockquote>
<p>&quot;이미지 태그에는 loading=&quot;lazy&quot; 거는 게 성능에 좋다고 했으니까 추가해두자.&quot;</p>
</blockquote>
<pre><code class="language-tsx">&lt;img
    src={skill.icon}
    alt={skill.name}
    width={14}
    height={14}
    loading=&quot;lazy&quot; // 추가
/&gt;</code></pre>
<p>이미지 성능 최적화를 공부하다 보면 <code>loading=&quot;lazy&quot;</code>가 자주 등장합니다. 
스크롤 아래 이미지를 뷰포트에 가까워질 때까지 로드를 미뤄서 초기 로딩을 빠르게 만드는 기법인데, 문제는 이걸 14×14px짜리 아이콘에도 그대로 적용했다는 점입니다.</p>
<p><code>loading=&quot;lazy&quot;</code>가 효과적인 상황은 따로 있습니다.</p>
<ul>
<li>뷰포트 아래에 있는 크고 무거운 이미지</li>
<li>초기 로딩 시 굳이 가져올 필요 없는 이미지</li>
</ul>
<p>Skills 섹션의 아이콘은 달랐습니다. 크기가 14x14px라 용량 자체가 작고, Lighthouse가 모바일 에뮬레이션으로 측정할 때 측정이 끝나기 전에 로드가 완료되지 않아 미완성 상태로 점수에 반영됩니다. 결과는 87점 → 75점, 12점 하락이었습니다.</p>
<p><code>loading=&quot;lazy&quot;</code>를 제거하자 점수는 82점으로 회복됐지만, 초기 87점으로 돌아오지 않았습니다. lazy가 문제가 아니라 외부 CDN 요청 자체가 병목이었다는 걸 파악하게 됐습니다.</p>
<hr>
<h2 id="3단계--성능-개선-외부-cdn-→-로컬-svg">3단계 — 성능 개선: 외부 CDN → 로컬 SVG</h2>
<h3 id="문제-2">문제</h3>
<p>아이콘 URL이 두 개의 외부 CDN을 사용하고 있었습니다.</p>
<pre><code class="language-ts">const DI = &quot;https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons&quot;;
const SI = &quot;https://cdn.simpleicons.org&quot;;</code></pre>
<p>총 33개의 외부 이미지 요청이 발생하고 있었고, Lighthouse 성능 점수가 82점에서 더 오르지 않았습니다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>외부 CDN 요청의 문제는 단순히 파일을 가져오는 게 아닙니다. 각 도메인마다 아래 과정이 필요합니다.</p>
<pre><code>cdn.jsdelivr.net  : DNS 조회 → TCP 연결 → TLS 핸드셰이크 → 요청
cdn.simpleicons.org: DNS 조회 → TCP 연결 → TLS 핸드셰이크 → 요청</code></pre><p><code>preconnect</code>로 사전 연결을 맺어도, 33개 요청 자체가 네트워크 비용이었습니다. 특히 Lighthouse가 모바일 에뮬레이션으로 측정할 때 외부 네트워크 지연이 점수에 직접 반영됩니다.</p>
<h3 id="해결--아이콘-로컬-저장">해결 — 아이콘 로컬 저장</h3>
<p>모든 SVG 아이콘을 <code>/public/icons/</code>에 다운로드했습니다.</p>
<pre><code class="language-bash"># 터미널에서 일괄 다운로드
DI=&quot;https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons&quot;
SI=&quot;https://cdn.simpleicons.org&quot;
OUT=&quot;./public/icons&quot;

curl -s -o &quot;$OUT/html.svg&quot;       &quot;${DI}/html5/html5-original.svg&quot;
curl -s -o &quot;$OUT/react.svg&quot;      &quot;${DI}/react/react-original.svg&quot;
curl -s -o &quot;$OUT/mobx.svg&quot;       &quot;${SI}/mobx/ff7a00&quot;
# ... (36개 전체)</code></pre>
<p>이후 <code>portfolio.ts</code>의 icon 경로를 로컬로 전환했습니다.</p>
<pre><code class="language-ts">// Before
{ id: &quot;html&quot;, name: &quot;HTML&quot;, color: &quot;#e34c26&quot;, icon: `${DI}/html5/html5-original.svg` }

// After
{ id: &quot;html&quot;, name: &quot;HTML&quot;, color: &quot;#e34c26&quot;, icon: &quot;/icons/html.svg&quot; }</code></pre>
<p>CDN 상수와 <code>preconnect</code> 힌트도 모두 제거했습니다.</p>
<pre><code class="language-ts">// 제거
const DI = &quot;https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons&quot;;
const SI = &quot;https://cdn.simpleicons.org&quot;;</code></pre>
<pre><code class="language-tsx">// layout.tsx에서 제거
&lt;link rel=&quot;preconnect&quot; href=&quot;https://cdn.jsdelivr.net&quot; /&gt;
&lt;link rel=&quot;preconnect&quot; href=&quot;https://cdn.simpleicons.org&quot; /&gt;</code></pre>
<hr>
<h2 id="결과">결과</h2>
<p>단계별 변화를 정리하면 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>변경 내용</th>
<th align="center">Lighthouse</th>
</tr>
</thead>
<tbody><tr>
<td>초기</td>
<td>외부 CDN 아이콘, loose 타입</td>
<td align="center">87</td>
</tr>
<tr>
<td>lazy 추가 (실수)</td>
<td><code>loading=&quot;lazy&quot;</code> 추가 → 측정 시점에 미로드</td>
<td align="center">75</td>
</tr>
<tr>
<td>부분 수정</td>
<td>preconnect + fetchPriority</td>
<td align="center">82</td>
</tr>
<tr>
<td><strong>로컬 전환</strong></td>
<td><strong>SVG 로컬 저장, CDN 제거</strong></td>
<td align="center"><strong>87+</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="배운-점">배운 점</h2>
<p><strong><code>loading=&quot;lazy&quot;</code>는 크기와 위치를 먼저 따져야 한다</strong></p>
<p>14×14px 아이콘처럼 용량이 작고 페이지 중간에 위치한 이미지에는 오히려 역효과가 납니다. 뷰포트 아래 멀리 있는 크고 무거운 이미지에만 사용해야 합니다.</p>
<p><strong>외부 CDN은 파일 수와 배포 환경을 고려해야 한다</strong></p>
<p>아이콘 36개처럼 파일 수가 적고 Vercel처럼 자체 엣지 서빙이 되는 환경에서는 외부 CDN보다 로컬 파일이 더 안정적입니다.</p>
<p><strong><code>next/image</code>는 SVG에 최적화가 적용되지 않는다</strong></p>
<p>SVG는 WebP 변환이 불가능하고 보안상 기본 차단됩니다. 14px 아이콘에는 <code>width</code>/<code>height</code>를 명시한 <code>&lt;img&gt;</code>가 더 적합합니다.</p>
<hr>
<h2 id="🔍-번외--nextimage-vs-img-svg-아이콘에는-무엇이-맞을까">🔍 번외 — next/image vs <code>&lt;img&gt;</code>: SVG 아이콘에는 무엇이 맞을까?</h2>
<p>로컬 SVG로 전환하고 나서 한 가지 의문이 더 생겼습니다.
&quot;Next.js 프로젝트니까 이미지는 next/image로 통일하는 게 맞지 않을까?&quot;
검토해보니 이 경우엔 오히려 잘못된 선택이었습니다.</p>
<hr>
<h3 id="nextimage의-핵심-기능">next/image의 핵심 기능</h3>
<p>next/image가 제공하는 주요 최적화는 세 가지입니다.</p>
<ul>
<li>PNG/JPG → WebP/AVIF 자동 변환</li>
<li>뷰포트 기반 사이즈 리사이징</li>
<li>Lazy loading 및 CLS(Cumulative Layout Shift) 방지</li>
</ul>
<h3 id="svg에는-최적화가-적용되지-않음">SVG에는 최적화가 적용되지 않음</h3>
<p>SVG는 벡터 포맷이라 WebP 변환이나 사이즈 리사이징이 불가능합니다. next/image를 사용해도 SVG는 원본 파일 그대로 서빙됩니다.</p>
<pre><code>PNG/JPG → next/image → WebP 변환 ✅ 최적화됨
SVG     → next/image → SVG 그대로 ❌ 최적화 없음</code></pre><h3 id="nextimage는-svg를-기본적으로-차단">next/image는 SVG를 기본적으로 차단</h3>
<p>SVG 내부에 <code>&lt;script&gt;</code>가 포함될 수 있어 보안상 이유로, next/image는 SVG를 기본적으로 차단합니다. 제대로 사용하려면 <code>next.config.ts</code>에 별도 설정이 필요합니다.</p>
<pre><code class="language-ts">// next.config.ts
const nextConfig: NextConfig = {
    images: {
        dangerouslyAllowSVG: true, // 명시적으로 허용해야 함
    },
};</code></pre>
<p>이 설정 없이 SVG를 next/image에 넘기면 내부적으로 <code>unoptimized</code> 처리되거나 예상치 못한 동작이 발생할 수 있습니다.</p>
<h3 id="14×14px-아이콘에-nextimage는-오버엔지니어링">14×14px 아이콘에 next/image는 오버엔지니어링</h3>
<p>next/image가 진가를 발휘하는 건 히어로 이미지, 프로젝트 썸네일처럼 크고 무거운 이미지일 때입니다. 14px짜리 아이콘 36개에 적용하면 오히려 불필요한 JS 번들 비용만 추가됩니다.</p>
<table>
<thead>
<tr>
<th></th>
<th><code>next/image</code></th>
<th><code>&lt;img&gt;</code></th>
</tr>
</thead>
<tbody><tr>
<td>WebP 변환</td>
<td>SVG는 해당 없음</td>
<td>동일</td>
</tr>
<tr>
<td>사이즈 최적화</td>
<td>14px에서 의미 없음</td>
<td>동일</td>
</tr>
<tr>
<td>CLS 방지</td>
<td>width/height 명시하면 해결</td>
<td>width/height 명시하면 해결</td>
</tr>
<tr>
<td>JS 번들 비용</td>
<td>있음</td>
<td>없음</td>
</tr>
<tr>
<td>eslint-disable 필요</td>
<td>없음</td>
<td>필요</td>
</tr>
</tbody></table>
<p>CLS 방지는 <code>width</code>와 <code>height</code>를 명시하는 것만으로 충분합니다. next/image 없이도 동일한 효과를 얻을 수 있습니다.</p>
<h3 id="최종-코드">최종 코드</h3>
<pre><code class="language-tsx">// ✅ 로컬 SVG 아이콘 — 일반 &lt;img&gt; 사용
{skill.icon ? (
    // eslint-disable-next-line @next/next/no-img-element
    &lt;img
        src={skill.icon}
        alt={skill.name}
        width={14}
        height={14}
        style={{ width: 14, height: 14, flexShrink: 0 }}
    /&gt;
) : (
    &lt;span
        className=&quot;w-2 h-2 rounded-full flex-shrink-0&quot;
        style={{ backgroundColor: skill.color }}
    /&gt;
)}</code></pre>
<h3 id="이미지-종류별-권장-방법-정리">이미지 종류별 권장 방법 정리</h3>
<table>
<thead>
<tr>
<th>이미지 종류</th>
<th>권장</th>
</tr>
</thead>
<tbody><tr>
<td>프로젝트 썸네일 (JPG/PNG, 큰 이미지)</td>
<td><code>next/image</code></td>
</tr>
<tr>
<td>스킬 아이콘 (SVG, 14px)</td>
<td><code>&lt;img&gt;</code></td>
</tr>
</tbody></table>
<p>next/image는 강력한 도구지만, 모든 이미지에 무조건 적용하는 게 정답은 아닙니다. 이미지의 포맷과 크기, 역할에 따라 적절한 방법을 선택하는 것이 중요합니다.</p>
<h3 id="그래서-이-과정이-의미있었나">그래서 이 과정이 의미있었나?</h3>
<p>최종 점수는 처음과 같은 87점입니다. 솔직히 말하면 저도 이 질문을 스스로 했습니다.
하지만 CDN을 이용한 87점과 지금의 87점은 다릅니다.</p>
<p>CDN 87점은 외부 네트워크 상태에 따라 달라질 수 있지만, 로컬 SVG 87점은 외부 요인 없이 항상 같은 점수가 나옵니다.
그리고 Lighthouse 점수와 무관하게, <code>SkillId</code> union type과 data colodation은 코드를 더 안전하고 유지보수하기 쉽게 만들었습니다. 
스킬을 추가할 때 파일 두 곳을 동시에 수정하지 않아도 되고, 오타가 생기면 즉시 컴파일 에러로 잡힙니다.</p>
<p>점수는 같아도 코드는 달라졌습니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>아이콘 36개의 로딩 방식만으로도 Lighthouse 점수가 10점 이상 차이 날 수 있음을 확인했습니다. 
성능 최적화는 대규모 아키텍처 개선뿐 아니라, 이처럼 작은 리소스 처리 방식의 선택에서도 크게 좌우된다는 점을 다시 느꼈습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹폰트 로딩 최적화: FOUT 방지 전략 (font-display, preload, fallback)]]></title>
            <link>https://velog.io/@do_dam/%EC%9B%B9%ED%8F%B0%ED%8A%B8-%EB%A1%9C%EB%94%A9-%EC%B5%9C%EC%A0%81%ED%99%94-FOUT-%EB%B0%A9%EC%A7%80-%EC%A0%84%EB%9E%B5-font-display-preload-fallback</link>
            <guid>https://velog.io/@do_dam/%EC%9B%B9%ED%8F%B0%ED%8A%B8-%EB%A1%9C%EB%94%A9-%EC%B5%9C%EC%A0%81%ED%99%94-FOUT-%EB%B0%A9%EC%A7%80-%EC%A0%84%EB%9E%B5-font-display-preload-fallback</guid>
            <pubDate>Mon, 13 Apr 2026 05:35:46 GMT</pubDate>
            <description><![CDATA[<h1 id="fout-방지란-무엇인가-flash-of-unstyled-text">FOUT 방지란 무엇인가? (Flash of Unstyled Text)</h1>
<p>웹 개발을 하다 보면 페이지 로딩 시 폰트가 갑자기 바뀌는 경험을 한 적이 있을 것이다.<br>이 현상을 <strong>FOUT(Flash of Unstyled Text)</strong>라고 한다.</p>
<p>이번 글에서는 FOUT이 무엇인지, 왜 발생하는지, 그리고 실무에서 어떻게 방지하는지 정리해본다.</p>
<hr>
<h2 id="fout이란">FOUT이란?</h2>
<p><strong>FOUT (Flash of Unstyled Text)</strong><br>웹폰트가 로딩되기 전에 기본 폰트로 먼저 렌더링되었다가,<br>이후 웹폰트가 적용되면서 텍스트 스타일이 바뀌는 현상이다.</p>
<h3 id="예시-흐름">예시 흐름</h3>
<ol>
<li>페이지 로딩 시작</li>
<li>기본 시스템 폰트로 텍스트 표시</li>
<li>웹폰트 로딩 완료</li>
<li>텍스트가 다른 폰트로 변경됨</li>
</ol>
<p>→ 이때 폰트가 바뀌는 느낌이 바로 FOUT</p>
<hr>
<h2 id="왜-문제가-될까">왜 문제가 될까?</h2>
<ul>
<li>UI가 깜빡이는 것처럼 보임</li>
<li>디자인 완성도가 떨어져 보일 수 있음</li>
<li>콘텐츠 집중도를 방해</li>
<li>경우에 따라 CLS(Cumulative Layout Shift)에도 영향</li>
</ul>
<hr>
<h2 id="foit과의-차이">FOIT과의 차이</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>FOUT</th>
<th>FOIT</th>
</tr>
</thead>
<tbody><tr>
<td>의미</td>
<td>기본 폰트 → 웹폰트로 변경</td>
<td>텍스트가 안 보이다가 나타남</td>
</tr>
<tr>
<td>UX</td>
<td>깜빡임 있음</td>
<td>아예 안 보임</td>
</tr>
<tr>
<td>전략</td>
<td>빠르게 보여주기</td>
<td>스타일 유지</td>
</tr>
</tbody></table>
<p>→ 최근에는 FOIT보다 FOUT을 허용하고 최적화하는 방향이 일반적이다.</p>
<hr>
<h2 id="🛠️-fout-줄이는-방법">🛠️ FOUT 줄이는 방법</h2>
<h3 id="1-font-display-속성-활용">1. <code>font-display</code> 속성 활용</h3>
<pre><code>@font-face {
  font-family: &#39;MyFont&#39;;
  src: url(&#39;/fonts/myfont.woff2&#39;) format(&#39;woff2&#39;);
  font-display: swap;
}</code></pre><ul>
<li><code>swap</code>: 기본 폰트 → 웹폰트 교체 (가장 많이 사용)</li>
<li><code>block</code>: 텍스트 숨김 후 표시 (FOIT)</li>
<li><code>optional</code>: 네트워크 상황에 따라 웹폰트 생략</li>
</ul>
<p>→ 실무에서는 보통 <code>swap</code>을 기본으로 사용한다.</p>
<hr>
<h3 id="2-preload로-폰트-로딩-최적화">2. preload로 폰트 로딩 최적화</h3>
<pre><code>&lt;link 
  rel=&quot;preload&quot; 
  href=&quot;/fonts/myfont.woff2&quot; 
  as=&quot;font&quot; 
  type=&quot;font/woff2&quot; 
  crossorigin
/&gt;</code></pre><p>→ 폰트를 우선적으로 다운로드해서 FOUT 시간을 줄인다.</p>
<hr>
<h3 id="3-fallback-폰트-설정">3. fallback 폰트 설정</h3>
<pre><code>font-family: &#39;MyFont&#39;, &#39;Apple SD Gothic Neo&#39;, &#39;Noto Sans KR&#39;, sans-serif;</code></pre><p>→ 웹폰트와 비슷한 폰트를 설정하면 변경 시 이질감이 줄어든다.</p>
<hr>
<h3 id="4-폰트-파일-최적화">4. 폰트 파일 최적화</h3>
<ul>
<li>woff2 포맷 사용</li>
<li>필요한 글자만 포함 (subset)</li>
<li>폰트 용량 최소화</li>
</ul>
<p>→ 로딩 속도가 빨라질수록 FOUT도 줄어든다.</p>
<hr>
<h3 id="5-폰트-로딩-이후-처리-선택">5. 폰트 로딩 이후 처리 (선택)</h3>
<pre><code>document.fonts.ready.then(() =&gt; {
  document.body.classList.add(&#39;fonts-loaded&#39;);
});</code></pre><p>→ 폰트 로딩 시점을 기준으로 스타일 제어가 가능하다.</p>
<hr>
<h2 id="실무에서-사용하는-기본-조합">실무에서 사용하는 기본 조합</h2>
<pre><code>@font-face {
  font-family: &#39;MyFont&#39;;
  src: url(&#39;/fonts/myfont.woff2&#39;) format(&#39;woff2&#39;);
  font-display: swap;
}

body {
  font-family: &#39;MyFont&#39;, &#39;Apple SD Gothic Neo&#39;, sans-serif;
}

&lt;link rel=&quot;preload&quot; href=&quot;/fonts/myfont.woff2&quot; as=&quot;font&quot; type=&quot;font/woff2&quot; crossorigin&gt;</code></pre><p>→ 이 정도 설정으로 대부분의 FOUT 문제는 완화된다.</p>
<hr>
<h2 id="추가로-고려할-점">추가로 고려할 점</h2>
<h3 id="cls까지-함께-고려하기">CLS까지 함께 고려하기</h3>
<p>폰트 변경 시 레이아웃이 흔들릴 수 있다.</p>
<ul>
<li>fallback 폰트를 최대한 유사하게 선택</li>
<li><code>font-size-adjust</code> 사용 고려</li>
</ul>
<hr>
<h3 id="google-fonts-사용할-경우">Google Fonts 사용할 경우</h3>
<pre><code>&lt;link href=&quot;https://fonts.googleapis.com/css2?family=Roboto&amp;display=swap&quot; rel=&quot;stylesheet&quot;&gt;</code></pre><p>→ <code>display=swap</code> 옵션을 함께 사용하는 것이 좋다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>FOUT은 웹폰트 로딩 과정에서 발생하는 자연스러운 현상</li>
<li><code>font-display: swap</code>은 기본적으로 적용하는 것이 좋음</li>
<li>preload + fallback 폰트 조합으로 대부분의 문제는 완화 가능</li>
<li>완전히 제거하기보다, 사용자 입장에서 자연스럽게 보이도록 만드는 것이 더 중요</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>웹폰트는 단순히 디자인 요소라고만 생각했는데,<br>실제로는 성능과 UX에도 영향을 주는 부분이었다.</p>
<p>특히 fallback 폰트나 로딩 전략에 따라
사용자가 느끼는 화면의 안정감이 달라진다는 점이 인상적이었다.</p>
<p>앞으로는 폰트를 적용할 때도 단순히 디자인만 생각할 게 아니라,
로딩 과정과 사용자 경험까지 같이 고려해야겠다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 환경 변수 & 설정]]></title>
            <link>https://velog.io/@do_dam/Next.js-%ED%99%98%EA%B2%BD-%EB%B3%80%EC%88%98-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@do_dam/Next.js-%ED%99%98%EA%B2%BD-%EB%B3%80%EC%88%98-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Thu, 26 Mar 2026 09:51:34 GMT</pubDate>
            <description><![CDATA[<p>Next.js에서 환경 변수(Environment Variables)는<br>단순 설정값이 아니라 <strong>보안, 배포, 환경 분리를 담당하는 핵심 요소</strong>이다.</p>
<p>특히 Next.js는 Server와 Client가 함께 존재하기 때문에<br>환경 변수의 사용 범위를 명확히 구분해야 한다.</p>
<hr>
<h2 id="1-envlocal">1. <code>.env.local</code></h2>
<p>Next.js에서는 <code>.env.local</code> 파일을 통해 환경 변수를 관리한다.</p>
<pre><code class="language-bash">NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_APP_ENV=development

DATABASE_URL=postgres://user:password@localhost:5432/db
JWT_SECRET=super-secret-key</code></pre>
<h3 id="특징">특징</h3>
<ul>
<li>로컬 환경에서만 사용</li>
<li>Git에 포함되지 않음 (<code>.gitignore</code>)</li>
<li>민감 정보(API Key, DB 정보) 분리 가능</li>
</ul>
<hr>
<h3 id="환경-파일-종류">환경 파일 종류</h3>
<table>
<thead>
<tr>
<th>파일명</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>.env</code></td>
<td>기본 환경</td>
</tr>
<tr>
<td><code>.env.local</code></td>
<td>로컬 전용</td>
</tr>
<tr>
<td><code>.env.development</code></td>
<td>개발 환경</td>
</tr>
<tr>
<td><code>.env.production</code></td>
<td>배포 환경</td>
</tr>
</tbody></table>
<hr>
<h3 id="우선순위">우선순위</h3>
<pre><code>.env.local &gt; .env.development &gt; .env</code></pre><hr>
<h3 id="실무-기준">실무 기준</h3>
<ul>
<li>로컬: <code>.env.local</code> 사용</li>
<li>배포: 플랫폼(Vercel 등)에 직접 설정</li>
</ul>
<hr>
<h2 id="2-processenv-사용법">2. <code>process.env</code> 사용법</h2>
<p>환경 변수는 <code>process.env</code>로 접근한다.</p>
<pre><code class="language-js">const apiUrl = process.env.NEXT_PUBLIC_API_URL;</code></pre>
<hr>
<h3 id="핵심-규칙">핵심 규칙</h3>
<h4 id="1-클라이언트에서-사용하려면-next_public_-필요">1. 클라이언트에서 사용하려면 <code>NEXT_PUBLIC_</code> 필요</h4>
<pre><code class="language-bash">NEXT_PUBLIC_API_URL=...</code></pre>
<p>→ 브라우저에서 접근 가능</p>
<hr>
<h4 id="2-prefix-없는-변수는-서버-전용">2. prefix 없는 변수는 서버 전용</h4>
<pre><code class="language-bash">JWT_SECRET=...</code></pre>
<p>→ 서버에서만 사용 가능</p>
<hr>
<h3 id="왜-이런-구조일까">왜 이런 구조일까?</h3>
<p>Next.js는 클라이언트 코드를 브라우저로 번들링한다.</p>
<ul>
<li><code>NEXT_PUBLIC_</code> → 번들에 포함됨</li>
<li>일반 변수 → 서버에만 존재</li>
</ul>
<p>→ 잘못 사용하면 <strong>비밀 정보가 그대로 노출됨</strong></p>
<hr>
<h2 id="3-실행-환경-server-vs-client">3. 실행 환경 (Server vs Client)</h2>
<p>Next.js는 코드 실행 위치에 따라 동작이 달라진다.</p>
<hr>
<h3 id="server-side">Server Side</h3>
<ul>
<li>Node.js에서 실행</li>
<li>모든 환경 변수 접근 가능</li>
</ul>
<pre><code class="language-js">export async function getServerSideProps() {
  const db = process.env.DATABASE_URL;
}</code></pre>
<hr>
<h3 id="client-side">Client Side</h3>
<ul>
<li>브라우저에서 실행</li>
<li><code>NEXT_PUBLIC_</code> 변수만 접근 가능</li>
</ul>
<pre><code class="language-js">useEffect(() =&gt; {
  fetch(process.env.NEXT_PUBLIC_API_URL);
}, []);</code></pre>
<hr>
<h3 id="핵심">핵심</h3>
<p>→ 같은 코드라도 실행 위치에 따라 결과가 달라진다</p>
<hr>
<h2 id="4-환경-변수-로딩-시점">4. 환경 변수 로딩 시점</h2>
<p>환경 변수는 <strong>빌드 및 서버 시작 시점에 로딩된다</strong></p>
<hr>
<h3 id="특징-1">특징</h3>
<ul>
<li>실행 중 변경 불가</li>
<li>변경 후 서버 재시작 필요</li>
</ul>
<pre><code class="language-bash">npm run dev</code></pre>
<hr>
<h2 id="5-실무-패턴">5. 실무 패턴</h2>
<h3 id="api-주소-분리">API 주소 분리</h3>
<pre><code class="language-bash"># 개발
NEXT_PUBLIC_API_URL=http://localhost:3000

# 배포
NEXT_PUBLIC_API_URL=https://api.myapp.com</code></pre>
<hr>
<h3 id="환경-구분">환경 구분</h3>
<pre><code class="language-bash">NEXT_PUBLIC_APP_ENV=development</code></pre>
<pre><code class="language-js">if (process.env.NEXT_PUBLIC_APP_ENV === &quot;development&quot;) {
  console.log(&quot;dev mode&quot;);
}</code></pre>
<hr>
<h3 id="서버-전용-변수">서버 전용 변수</h3>
<pre><code class="language-bash">JWT_SECRET=...
DATABASE_URL=...</code></pre>
<p>→ <code>NEXT_PUBLIC_</code> 붙이지 않기</p>
<hr>
<h2 id="6-보안-원칙">6. 보안 원칙</h2>
<p>잘못된 예:</p>
<pre><code class="language-js">const secret = process.env.JWT_SECRET;</code></pre>
<p>→ 클라이언트 코드에서 사용 시 노출 위험</p>
<hr>
<p>올바른 방식:</p>
<ul>
<li>비밀 값 → 서버에서만 사용</li>
<li>클라이언트 → API 호출만 수행</li>
</ul>
<hr>
<h2 id="7-디버깅-체크리스트">7. 디버깅 체크리스트</h2>
<ul>
<li><code>NEXT_PUBLIC_</code> prefix 확인</li>
<li>서버 재시작 여부</li>
<li>변수명 오타</li>
<li><code>.env.local</code> 위치 확인</li>
</ul>
<hr>
<h2 id="8-배포-시-주의사항">8. 배포 시 주의사항</h2>
<ul>
<li><code>.env.local</code>은 배포 서버에 포함되지 않음</li>
<li>환경 변수는 배포 플랫폼에 직접 등록해야 함</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>환경 변수는 실행 환경에 따라 접근 범위가 다르다</li>
<li>클라이언트에서는 <code>NEXT_PUBLIC_</code> 필수</li>
<li>민감 정보는 서버에서만 처리</li>
<li>환경 변수는 빌드/실행 시점에 적용된다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 배포 (Deployment): Vercel, 환경변수, 빌드까지 한 번에]]></title>
            <link>https://velog.io/@do_dam/Next.js-%EB%B0%B0%ED%8F%AC-Deployment</link>
            <guid>https://velog.io/@do_dam/Next.js-%EB%B0%B0%ED%8F%AC-Deployment</guid>
            <pubDate>Thu, 26 Mar 2026 09:39:43 GMT</pubDate>
            <description><![CDATA[<p>Next.js 배포는 단순히 서버에 올리는 과정이 아니라<br>빌드, 최적화, 환경 분리, 실행까지 포함된 과정이다.</p>
<hr>
<h2 id="1-vercel-배포">1. Vercel 배포</h2>
<p>Next.js는 Vercel에서 가장 쉽게 배포할 수 있다.</p>
<h3 id="배포-흐름">배포 흐름</h3>
<ol>
<li>GitHub에 코드 push</li>
<li>Vercel에서 프로젝트 import</li>
<li>자동 빌드</li>
<li>배포 완료 (URL 생성)</li>
</ol>
<hr>
<h3 id="특징">특징</h3>
<ul>
<li>Next.js와 높은 호환성</li>
<li>SSR / SSG 자동 처리</li>
<li>서버리스 함수(<code>/api</code>) 지원</li>
<li>CDN 자동 적용</li>
<li>Git 기반 CI/CD</li>
</ul>
<hr>
<h3 id="추가-기능">추가 기능</h3>
<ul>
<li>Preview 배포 (브랜치별)</li>
<li>PR 미리보기 URL 생성</li>
<li>이전 배포로 롤백 가능</li>
</ul>
<hr>
<h2 id="2-빌드-과정-next-build">2. 빌드 과정 (<code>next build</code>)</h2>
<pre><code class="language-bash">npm run build</code></pre>
<p>빌드는 단순 번들링이 아니라<br>각 페이지의 렌더링 방식(SSR / SSG / ISR)을 결정하는 단계다.</p>
<hr>
<h3 id="빌드-시-수행-작업">빌드 시 수행 작업</h3>
<ul>
<li>코드 분할 (Code Splitting)</li>
<li>정적 페이지 생성 (SSG)</li>
<li>서버 코드 번들링 (SSR)</li>
<li>이미지 및 자산 최적화</li>
<li>Tree Shaking</li>
</ul>
<hr>
<h3 id="결과물">결과물</h3>
<pre><code>.next/</code></pre><ul>
<li>정적 HTML</li>
<li>JS 번들</li>
<li>서버 실행 코드</li>
</ul>
<p>이 폴더가 없으면 production 실행 불가</p>
<hr>
<h2 id="3-실행-next-start">3. 실행 (<code>next start</code>)</h2>
<pre><code class="language-bash">npm run start</code></pre>
<ul>
<li>production 환경에서 실행</li>
<li>SSR 요청 처리</li>
</ul>
<hr>
<h3 id="확인-방법">확인 방법</h3>
<pre><code class="language-bash">npm run build &amp;&amp; npm run start</code></pre>
<p>배포 전에 반드시 실행해서<br>production 환경에서 문제 없는지 확인해야 한다.</p>
<hr>
<h2 id="4-개발-vs-배포-환경-차이">4. 개발 vs 배포 환경 차이</h2>
<h3 id="개발-next-dev">개발 (<code>next dev</code>)</h3>
<ul>
<li>Hot Reload</li>
<li>디버깅 중심</li>
<li>최적화 없음</li>
</ul>
<hr>
<h3 id="배포-build--start">배포 (<code>build + start</code>)</h3>
<ul>
<li>코드 최적화</li>
<li>캐싱 및 압축 적용</li>
<li>에러 기준 엄격</li>
</ul>
<hr>
<h3 id="자주-발생하는-문제">자주 발생하는 문제</h3>
<ul>
<li>window / document 사용</li>
<li>환경 변수 누락</li>
<li>hydration 불일치</li>
</ul>
<hr>
<h3 id="예시">예시</h3>
<pre><code class="language-js">window.innerWidth</code></pre>
<p>→ 서버에서는 실행 불가</p>
<hr>
<h3 id="해결">해결</h3>
<pre><code class="language-js">if (typeof window !== &quot;undefined&quot;) {
  console.log(window.innerWidth);
}</code></pre>
<hr>
<h2 id="5-환경-변수-배포-시">5. 환경 변수 (배포 시)</h2>
<h3 id="문제">문제</h3>
<p><code>.env.local</code>은 로컬 전용이므로 배포 서버에 존재하지 않는다.</p>
<hr>
<h3 id="해결-1">해결</h3>
<p>Vercel에서 직접 설정</p>
<ul>
<li>Development</li>
<li>Preview</li>
<li>Production</li>
</ul>
<hr>
<h3 id="예시-1">예시</h3>
<pre><code>NEXT_PUBLIC_API_URL=https://api.myapp.com
JWT_SECRET=xxxx</code></pre><hr>
<h2 id="6-api-주소-문제">6. API 주소 문제</h2>
<h3 id="잘못된-방식">잘못된 방식</h3>
<pre><code class="language-js">fetch(&quot;http://localhost:3000/api&quot;);</code></pre>
<p>배포 환경에서는 동작하지 않는다.</p>
<hr>
<h3 id="올바른-방식">올바른 방식</h3>
<pre><code class="language-js">fetch(`${process.env.NEXT_PUBLIC_API_URL}/api`);</code></pre>
<p>또는</p>
<pre><code class="language-js">fetch(&quot;/api&quot;);</code></pre>
<hr>
<h2 id="7-빌드-에러-대응">7. 빌드 에러 대응</h2>
<h3 id="자주-발생하는-문제-1">자주 발생하는 문제</h3>
<h4 id="타입-에러">타입 에러</h4>
<p>빌드 시 타입 오류 발생 → 타입 명확히 정의 필요</p>
<hr>
<h4 id="window-is-not-defined">window is not defined</h4>
<p>서버에서 실행되기 때문</p>
<hr>
<h4 id="hydration-error">hydration error</h4>
<p>서버와 클라이언트 렌더링 결과 불일치</p>
<hr>
<h3 id="예시-2">예시</h3>
<pre><code class="language-js">const time = new Date();</code></pre>
<hr>
<h3 id="해결-2">해결</h3>
<pre><code class="language-js">useEffect(() =&gt; {
  setTime(new Date());
}, []);</code></pre>
<hr>
<h2 id="8-배포-체크리스트">8. 배포 체크리스트</h2>
<ul>
<li><code>npm run build</code> 성공</li>
<li><code>npm run start</code> 로컬 테스트</li>
<li>환경 변수 설정 완료</li>
<li>API 주소 분리</li>
<li>window 사용 여부 확인</li>
</ul>
<hr>
<h2 id="9-성능-최적화">9. 성능 최적화</h2>
<ul>
<li><code>next/image</code> 사용</li>
<li>dynamic import 적용</li>
<li>CSR 최소화 (가능하면 SSR / SSG 사용)</li>
</ul>
<hr>
<h2 id="10-배포-흐름">10. 배포 흐름</h2>
<pre><code class="language-bash">git push → build → deploy → production</code></pre>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>배포는 빌드 중심 과정이다.</li>
<li>개발과 배포 환경은 다르게 동작한다.</li>
<li>환경 변수는 별도로 관리해야 한다.</li>
<li>대부분의 문제는 환경 차이에서 발생한다.</li>
</ul>
<hr>
<p><strong>한 줄 요약</strong></p>
<p>Next.js 배포는 코드보다 
환경 변수, 렌더링 전략(SSR/SSG), 실행 환경 차이에 더 크게 영향을 받는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 메타데이터 & SEO 완벽 정리]]></title>
            <link>https://velog.io/@do_dam/Next.js-%EB%A9%94%ED%83%80%EB%8D%B0%EC%9D%B4%ED%84%B0-SEO-%EC%99%84%EB%B2%BD-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@do_dam/Next.js-%EB%A9%94%ED%83%80%EB%8D%B0%EC%9D%B4%ED%84%B0-SEO-%EC%99%84%EB%B2%BD-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 25 Mar 2026 02:37:13 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-개요">📌 개요</h3>
<p>웹 서비스에서 SEO(Search Engine Optimization)는<br>검색 엔진 노출과 유입에 직접적인 영향을 준다.</p>
<p>Next.js(App Router 기준)는 Metadata API를 통해<br>SEO 설정을 매우 간단하고 강력하게 관리할 수 있다.</p>
<p>이 글에서는 다음 3가지를 핵심으로 정리한다:</p>
<ul>
<li>Metadata 설정</li>
<li>동적 메타데이터</li>
<li>SEO 최적화 전략</li>
</ul>
<hr>
<h3 id="🏷-1-metadata-설정">🏷 1. Metadata 설정</h3>
<h4 id="✅-metadata란">✅ Metadata란?</h4>
<ul>
<li>페이지의 정보를 담는 메타 태그</li>
<li>검색 엔진 &amp; SNS 공유 시 사용됨</li>
</ul>
<p>대표적으로:</p>
<ul>
<li>title</li>
<li>description</li>
<li>og:image</li>
<li>keywords</li>
</ul>
<hr>
<h4 id="✅-기본-사용법">✅ 기본 사용법</h4>
<pre><code class="language-tsx">import type { Metadata } from &#39;next&#39;

export const metadata: Metadata = {
  title: &#39;My Page&#39;,
  description: &#39;This is my page&#39;,
}</code></pre>
<p>👉 layout.tsx 또는 page.tsx에서 선언 가능</p>
<hr>
<h4 id="✅-주요-옵션">✅ 주요 옵션</h4>
<pre><code class="language-tsx">export const metadata: Metadata = {
  title: &#39;페이지 제목&#39;,
  description: &#39;페이지 설명&#39;,
  keywords: [&#39;nextjs&#39;, &#39;seo&#39;],
  openGraph: {
    title: &#39;OG 제목&#39;,
    description: &#39;OG 설명&#39;,
    images: [&#39;/og-image.png&#39;],
  },
}</code></pre>
<hr>
<h4 id="✅-title-템플릿">✅ title 템플릿</h4>
<pre><code class="language-tsx">export const metadata = {
  title: {
    default: &#39;Panzy&#39;,
    template: &#39;%s | Panzy&#39;,
  },
}</code></pre>
<p>👉 페이지별 title 자동 구성 가능</p>
<hr>
<h3 id="⚡-2-동적-메타데이터">⚡ 2. 동적 메타데이터</h3>
<h4 id="✅-왜-필요한가">✅ 왜 필요한가?</h4>
<ul>
<li>상품 상세 페이지</li>
<li>블로그 글</li>
<li>숙소 상세 페이지</li>
</ul>
<p>👉 각각 다른 title/description 필요</p>
<hr>
<h4 id="✅-generatemetadata-사용">✅ generateMetadata 사용</h4>
<pre><code class="language-tsx">import type { Metadata } from &#39;next&#39;

export async function generateMetadata({ params }): Promise&lt;Metadata&gt; {
  const data = await fetch(`https://api.example.com/${params.id}`).then(res =&gt; res.json())

  return {
    title: data.title,
    description: data.description,
  }
}</code></pre>
<hr>
<h4 id="✅-예시-숙소-상세">✅ 예시 (숙소 상세)</h4>
<pre><code class="language-tsx">export async function generateMetadata({ params }) {
  const accommodation = await getAccommodation(params.id)

  return {
    title: accommodation.name,
    description: accommodation.description,
  }
}</code></pre>
<hr>
<h4 id="⚠️-주의사항">⚠️ 주의사항</h4>
<ul>
<li>서버 컴포넌트에서만 사용 가능</li>
<li>fetch는 캐싱 전략 고려 필요</li>
</ul>
<hr>
<h3 id="🔍-3-seo-최적화-전략">🔍 3. SEO 최적화 전략</h3>
<h4 id="✅-1-메타데이터-최적화">✅ 1. 메타데이터 최적화</h4>
<ul>
<li>title: 핵심 키워드 포함</li>
<li>description: 클릭 유도 문장</li>
<li>중복 제거 필수</li>
</ul>
<hr>
<h4 id="✅-2-open-graph-설정">✅ 2. Open Graph 설정</h4>
<p>SNS 공유 최적화</p>
<pre><code class="language-tsx">openGraph: {
  title: &#39;제목&#39;,
  description: &#39;설명&#39;,
  images: [&#39;/image.png&#39;],
}</code></pre>
<hr>
<h4 id="✅-3-구조화된-url">✅ 3. 구조화된 URL</h4>
<p>❌ 나쁜 예  </p>
<ul>
<li>/page?id=123</li>
</ul>
<p>✅ 좋은 예  </p>
<ul>
<li>/accommodation/123</li>
</ul>
<hr>
<h4 id="✅-4-ssr-활용">✅ 4. SSR 활용</h4>
<ul>
<li>검색 엔진은 JS 실행 제한적</li>
<li>서버 렌더링이 SEO에 유리</li>
</ul>
<hr>
<h4 id="✅-5-이미지-seo">✅ 5. 이미지 SEO</h4>
<ul>
<li>alt 속성 필수</li>
<li>next/image 사용 권장</li>
</ul>
<pre><code class="language-tsx">&lt;Image src=\&quot;/image.png\&quot; alt=\&quot;숙소 이미지\&quot; /&gt;</code></pre>
<hr>
<h4 id="✅-6-사이트맵--robotstxt">✅ 6. 사이트맵 &amp; robots.txt</h4>
<ul>
<li>sitemap.xml 생성</li>
<li>robots.txt 설정</li>
</ul>
<p>👉 검색 엔진 크롤링 최적화</p>
<hr>
<h4 id="✅-7-성능-최적화">✅ 7. 성능 최적화</h4>
<ul>
<li>Core Web Vitals 중요</li>
<li>LCP, CLS, TTI 개선 필요</li>
</ul>
<p>👉 SEO 랭킹에 직접 영향</p>
<hr>
<h3 id="📌-한눈에-정리">📌 한눈에 정리</h3>
<h4 id="metadata">Metadata</h4>
<ul>
<li>title, description 설정</li>
<li>Open Graph 포함</li>
</ul>
<h4 id="동적-메타데이터">동적 메타데이터</h4>
<ul>
<li>generateMetadata 사용</li>
<li>페이지별 SEO 대응</li>
</ul>
<h4 id="seo-전략">SEO 전략</h4>
<ul>
<li>키워드 최적화</li>
<li>SSR 활용</li>
<li>URL 구조 개선</li>
<li>성능 최적화</li>
</ul>
<hr>
<h3 id="🚀-결론">🚀 결론</h3>
<p>Next.js에서는 Metadata API를 활용하면<br>SEO 설정을 매우 효율적으로 관리할 수 있다.</p>
<p>👉 핵심 요약</p>
<ul>
<li>정적 페이지 → metadata 사용  </li>
<li>동적 페이지 → generateMetadata 사용  </li>
<li>전체 전략 → SEO + 성능 최적화  </li>
</ul>
<p>이 구조만 제대로 잡아도<br>검색 유입과 클릭률이 크게 개선된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 최적화 (next/image, next/font, 번들 최적화)]]></title>
            <link>https://velog.io/@do_dam/Next.js-%EC%B5%9C%EC%A0%81%ED%99%94-nextimage-nextfont-%EB%B2%88%EB%93%A4-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@do_dam/Next.js-%EC%B5%9C%EC%A0%81%ED%99%94-nextimage-nextfont-%EB%B2%88%EB%93%A4-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Wed, 25 Mar 2026 02:34:46 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-개요">📌 개요</h3>
<p>Next.js는 기본적으로 다양한 성능 최적화 기능을 내장하고 있다.<br>특히 이미지, 폰트, 번들 크기 최적화는 실제 서비스에서 로딩 속도와 UX에 큰 영향을 준다.</p>
<p>이 글에서는 다음 3가지를 핵심으로 정리한다:</p>
<ul>
<li>next/image</li>
<li>next/font</li>
<li>번들 최적화 개념</li>
</ul>
<h3 id="🖼-1-nextimage-이미지-최적화">🖼 1. next/image (이미지 최적화)</h3>
<h4 id="✅-왜-필요한가">✅ 왜 필요한가?</h4>
<p>일반 <code>&lt;img&gt;</code> 태그를 사용하면 다음과 같은 문제가 발생한다:</p>
<ul>
<li>이미지 크기가 크면 로딩 속도 저하</li>
<li>다양한 디바이스 대응 어려움</li>
<li>lazy loading 직접 구현 필요</li>
</ul>
<p>👉 next/image는 이 문제를 자동으로 해결해준다.</p>
<h4 id="✅-주요-기능">✅ 주요 기능</h4>
<h5 id="1-자동-사이즈-최적화">1. 자동 사이즈 최적화</h5>
<ul>
<li>디바이스 해상도에 맞는 이미지 제공</li>
<li>불필요하게 큰 이미지 다운로드 방지</li>
</ul>
<h5 id="2-lazy-loading-지연-로딩">2. Lazy Loading (지연 로딩)</h5>
<ul>
<li>화면에 보일 때만 이미지 로딩</li>
</ul>
<h5 id="3-webp--avif-자동-변환">3. WebP / AVIF 자동 변환</h5>
<ul>
<li>더 가벼운 이미지 포맷으로 자동 변환</li>
</ul>
<h5 id="4-cdn-기반-최적화">4. CDN 기반 최적화</h5>
<ul>
<li>요청 시 최적화된 이미지 제공</li>
</ul>
<h4 id="✅-기본-사용법">✅ 기본 사용법</h4>
<pre><code class="language-tsx">import Image from &#39;next/image&#39;

&lt;Image
  src=&quot;/example.png&quot;
  alt=&quot;example&quot;
  width={500}
  height={300}
/&gt;</code></pre>
<h4 id="✅-주요-옵션">✅ 주요 옵션</h4>
<h5 id="●-priority-lcp-최적화">● priority (LCP 최적화)</h5>
<pre><code class="language-tsx">&lt;Image src=&quot;/hero.png&quot; priority /&gt;</code></pre>
<p>👉 가장 먼저 보여야 하는 이미지에 사용</p>
<h5 id="●-fill-부모-요소-채우기">● fill (부모 요소 채우기)</h5>
<pre><code class="language-tsx">&lt;Image src=&quot;/image.png&quot; fill style={{ objectFit: &#39;cover&#39; }} /&gt;</code></pre>
<h5 id="●-sizes-반응형-최적화">● sizes (반응형 최적화)</h5>
<pre><code class="language-tsx">sizes=&quot;(max-width: 768px) 100vw, 50vw&quot;</code></pre>
<h4 id="⚠️-주의사항">⚠️ 주의사항</h4>
<ul>
<li>width/height 또는 fill 반드시 필요</li>
<li>외부 이미지 사용 시 설정 필요</li>
</ul>
<pre><code class="language-js">images: {
  domains: [&#39;example.com&#39;],
}</code></pre>
<h3 id="🔤-2-nextfont-폰트-최적화">🔤 2. next/font (폰트 최적화)</h3>
<h4 id="✅-왜-필요한가-1">✅ 왜 필요한가?</h4>
<p>기존 웹폰트 사용 시 문제:</p>
<ul>
<li>FOUT (Flash of Unstyled Text)</li>
<li>FOIT (Flash of Invisible Text)</li>
<li>렌더링 지연</li>
<li>외부 요청 증가</li>
</ul>
<p>👉 next/font는 이를 해결한다.</p>
<h4 id="✅-주요-특징">✅ 주요 특징</h4>
<h5 id="1-자동-서브셋">1. 자동 서브셋</h5>
<ul>
<li>필요한 문자만 다운로드</li>
</ul>
<h5 id="2-self-hosting">2. Self-hosting</h5>
<ul>
<li>외부 서버 요청 없이 내부에서 제공</li>
</ul>
<h5 id="3-layout-shift-방지">3. Layout Shift 방지</h5>
<ul>
<li>CLS 개선</li>
</ul>
<h4 id="✅-기본-사용법-google-font">✅ 기본 사용법 (Google Font)</h4>
<pre><code class="language-tsx">import { Inter } from &#39;next/font/google&#39;

const inter = Inter({
  subsets: [&#39;latin&#39;],
})

export default function RootLayout({ children }) {
  return (
    &lt;html className={inter.className}&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h4 id="✅-로컬-폰트-사용">✅ 로컬 폰트 사용</h4>
<pre><code class="language-tsx">import localFont from &#39;next/font/local&#39;

const myFont = localFont({
  src: &#39;./fonts/my-font.woff2&#39;,
})</code></pre>
<h4 id="⚠️-주의사항-1">⚠️ 주의사항</h4>
<ul>
<li>글로벌 layout에서 적용 권장</li>
<li>폰트 개수 최소화</li>
</ul>
<h3 id="📦-3-번들-최적화-개념">📦 3. 번들 최적화 개념</h3>
<h4 id="✅-번들이란">✅ 번들이란?</h4>
<ul>
<li>브라우저에 전달되는 JavaScript 파일 묶음</li>
<li>번들 크기가 클수록 로딩 속도 저하</li>
</ul>
<h4 id="✅-왜-중요한가">✅ 왜 중요한가?</h4>
<ul>
<li>초기 로딩 속도 개선</li>
<li>TTI (Time To Interactive) 향상</li>
<li>사용자 이탈 감소</li>
</ul>
<h4 id="✅-주요-전략">✅ 주요 전략</h4>
<h5 id="1-code-splitting">1. Code Splitting</h5>
<pre><code class="language-tsx">import dynamic from &#39;next/dynamic&#39;

const Component = dynamic(() =&gt; import(&#39;./Component&#39;))</code></pre>
<h5 id="2-dynamic-import">2. Dynamic Import</h5>
<pre><code class="language-tsx">const HeavyComponent = dynamic(() =&gt; import(&#39;./HeavyComponent&#39;), {
  ssr: false,
})</code></pre>
<h5 id="3-tree-shaking">3. Tree Shaking</h5>
<ul>
<li>사용하지 않는 코드 제거</li>
<li>ES Module 사용 시 자동 적용</li>
</ul>
<h5 id="4-라이브러리-최적화">4. 라이브러리 최적화</h5>
<p>❌ 잘못된 예</p>
<pre><code class="language-tsx">import _ from &#39;lodash&#39;</code></pre>
<p>✅ 좋은 예</p>
<pre><code class="language-tsx">import debounce from &#39;lodash/debounce&#39;</code></pre>
<h5 id="5-bundle-analyzer">5. Bundle Analyzer</h5>
<pre><code class="language-bash">npm install @next/bundle-analyzer</code></pre>
<pre><code class="language-js">const withBundleAnalyzer = require(&#39;@next/bundle-analyzer&#39;)({
  enabled: true,
})

module.exports = withBundleAnalyzer({})</code></pre>
<p>👉 번들 크기 시각적으로 분석 가능</p>
<h3 id="⚡-추가-최적화-포인트">⚡ 추가 최적화 포인트</h3>
<h4 id="1-ssr-vs-csr-선택">1. SSR vs CSR 선택</h4>
<ul>
<li>SSR: 초기 렌더링 빠름 (SEO 유리)</li>
<li>CSR: 인터랙션 빠름</li>
</ul>
<p>👉 상황에 맞게 선택</p>
<h4 id="2-캐싱-전략">2. 캐싱 전략</h4>
<ul>
<li>브라우저 캐시</li>
<li>CDN 캐시</li>
</ul>
<h4 id="3-api-요청-최적화">3. API 요청 최적화</h4>
<ul>
<li>batching</li>
<li>caching (SWR, React Query)</li>
</ul>
<h3 id="📌-한눈에-정리">📌 한눈에 정리</h3>
<h4 id="nextimage">next/image</h4>
<ul>
<li>자동 이미지 최적화</li>
<li>lazy loading</li>
<li>WebP 변환</li>
</ul>
<h4 id="nextfont">next/font</h4>
<ul>
<li>폰트 최적화</li>
<li>CLS 방지</li>
<li>self-hosting</li>
</ul>
<h4 id="번들-최적화">번들 최적화</h4>
<ul>
<li>code splitting</li>
<li>dynamic import</li>
<li>tree shaking</li>
</ul>
<h3 id="🚀-결론">🚀 결론</h3>
<p>Next.js는 강력한 최적화 기능을 기본 제공하지만,<br>제대로 활용하지 않으면 성능 차이가 크게 난다.</p>
<p>👉 핵심 요약</p>
<ul>
<li>이미지 → next/image  </li>
<li>폰트 → next/font  </li>
<li>JS → 번들 최적화  </li>
</ul>
<p>이 3가지만 적용해도 체감 성능은 확실히 개선된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 Edge Runtime은 언제 쓰는 걸까? (Node.js와 비교 정리)]]></title>
            <link>https://velog.io/@do_dam/Next.js%EC%97%90%EC%84%9C-Edge-Runtime%EC%9D%80-%EC%96%B8%EC%A0%9C-%EC%93%B0%EB%8A%94-%EA%B1%B8%EA%B9%8C-Node.js%EC%99%80-%EB%B9%84%EA%B5%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@do_dam/Next.js%EC%97%90%EC%84%9C-Edge-Runtime%EC%9D%80-%EC%96%B8%EC%A0%9C-%EC%93%B0%EB%8A%94-%EA%B1%B8%EA%B9%8C-Node.js%EC%99%80-%EB%B9%84%EA%B5%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 23 Mar 2026 12:32:25 GMT</pubDate>
            <description><![CDATA[<p>Next.js App Router를 사용하다 보면 다음과 같은 문장을 자주 보게 된다.</p>
<ul>
<li>&quot;Middleware는 Edge Runtime에서 실행된다&quot;</li>
</ul>
<p>여기서 자연스럽게 이런 의문이 생긴다.</p>
<p>👉 <strong>Edge Runtime은 정확히 무엇이고, 언제 사용해야 할까?</strong></p>
<p>이 글에서는 Edge Runtime의 개념과<br>Node.js Runtime과의 차이를 중심으로 정리한다.</p>
<hr>
<h2 id="1-edge-runtime이란">1. Edge Runtime이란?</h2>
<p>Edge Runtime은<br>👉 <strong>사용자와 가까운 위치(CDN)에서 코드를 실행하는 환경</strong>이다.</p>
<hr>
<h3 id="실행-구조-비교">실행 구조 비교</h3>
<pre><code class="language-text">[Node.js Runtime]
사용자 → 중앙 서버 → 응답

[Edge Runtime]
사용자 → 가까운 CDN 서버 → 응답</code></pre>
<p>👉 요청을 더 가까운 위치에서 처리하여 <strong>네트워크 지연(Latency)을 줄이는 구조</strong>이다.</p>
<hr>
<h2 id="2-왜-필요한가">2. 왜 필요한가?</h2>
<p>Edge Runtime의 목적은 <strong>응답 속도 개선</strong>이다.</p>
<h3 id="장점">장점</h3>
<ul>
<li>네트워크 지연 감소</li>
<li>초기 응답 속도 개선</li>
<li>글로벌 환경에서 일관된 성능 제공</li>
</ul>
<p>👉 특히 <strong>요청 초기에 실행되는 로직</strong>에서 효과가 크다.</p>
<hr>
<h2 id="3-nextjs에서-edge-runtime">3. Next.js에서 Edge Runtime</h2>
<p>Next.js에서는 일부 기능이 Edge Runtime에서 실행된다.</p>
<h3 id="대표적인-기능">대표적인 기능</h3>
<ul>
<li>Middleware</li>
<li>Route Handler (옵션 설정 시)</li>
<li>Edge API Routes</li>
</ul>
<p>👉 <strong>Middleware는 기본적으로 Edge에서 실행된다.</strong></p>
<hr>
<h2 id="4-nodejs-runtime과의-차이">4. Node.js Runtime과의 차이</h2>
<p>두 환경은 역할이 명확히 다르다.</p>
<h3 id="nodejs-runtime">Node.js Runtime</h3>
<ul>
<li>서버 환경</li>
<li>Node API 사용 가능 (<code>fs</code>, <code>net</code> 등)</li>
<li>DB 접근 가능</li>
<li>복잡한 로직 처리에 적합</li>
</ul>
<h3 id="edge-runtime">Edge Runtime</h3>
<ul>
<li>CDN 기반 실행 환경</li>
<li>일부 API 제한</li>
<li>빠른 실행에 최적화</li>
<li>가벼운 로직 처리에 적합</li>
</ul>
<p>👉 <strong>&quot;무거운 처리 vs 빠른 분기 처리&quot;</strong> 기준으로 구분하면 이해하기 쉽다.</p>
<hr>
<h2 id="5-edge-runtime의-제한">5. Edge Runtime의 제한</h2>
<p>Edge Runtime은 경량 환경이기 때문에 제약이 존재한다.</p>
<h3 id="사용-불가능">사용 불가능</h3>
<ul>
<li><code>fs</code> (파일 시스템)</li>
<li><code>net</code>, <code>http</code></li>
<li>일부 Node.js 전용 라이브러리</li>
</ul>
<p>👉 <strong>Node.js와 동일한 서버 환경이 아니다</strong>는 점이 핵심이다.</p>
<hr>
<h2 id="6-edge-runtime에서-가능한-것">6. Edge Runtime에서 가능한 것</h2>
<ul>
<li><code>fetch</code></li>
<li>쿠키 / 헤더 처리</li>
<li>인증 로직</li>
<li>리다이렉트</li>
<li>간단한 조건 분기</li>
</ul>
<p>👉 <strong>요청 초기에 빠르게 판단하는 로직에 최적화된 환경</strong>이다.</p>
<hr>
<h2 id="7-왜-middleware는-edge에서-실행될까">7. 왜 Middleware는 Edge에서 실행될까?</h2>
<p>Middleware는 요청이 들어오자마자 실행된다.</p>
<h3 id="특징">특징</h3>
<ul>
<li>모든 요청에서 실행됨</li>
<li>응답 지연 최소화가 중요</li>
<li>단순한 분기 로직 중심</li>
</ul>
<p>이 특성 때문에<br>👉 <strong>지연을 최소화할 수 있는 Edge Runtime이 적합하다.</strong></p>
<hr>
<h2 id="8-언제-edge-runtime을-사용해야-할까">8. 언제 Edge Runtime을 사용해야 할까?</h2>
<h3 id="적합한-경우">적합한 경우</h3>
<ul>
<li>인증 체크 (로그인 여부)</li>
<li>리다이렉트 처리</li>
<li>요청 필터링</li>
<li>로깅</li>
<li>A/B 테스트</li>
</ul>
<p>👉 공통점: <strong>빠르게 판단하고 넘기는 로직</strong></p>
<hr>
<h3 id="부적합한-경우">부적합한 경우</h3>
<ul>
<li>DB 접근</li>
<li>복잡한 비즈니스 로직</li>
<li>무거운 연산</li>
</ul>
<p>이런 경우는 <strong>Node.js Runtime이 더 적합하다.</strong></p>
<hr>
<h2 id="9-핵심-정리">9. 핵심 정리</h2>
<ul>
<li>Edge Runtime은 CDN 환경에서 실행되는 경량 실행 환경</li>
<li>빠른 응답을 위해 설계됨</li>
<li>Node.js보다 기능 제한이 존재</li>
<li>Middleware는 기본적으로 Edge에서 실행됨</li>
<li>빠른 분기 처리가 필요한 로직에 적합</li>
</ul>
<hr>
<h3 id="한-줄-정리">한 줄 정리</h3>
<blockquote>
<p>Edge Runtime은 사용자와 가까운 위치에서 실행되는 경량 환경으로,<br>요청 초기에 빠른 처리가 필요한 로직에 적합하다.</p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>Next.js는 하나의 실행 환경만 사용하는 구조가 아니다.</p>
<ul>
<li>Edge Runtime → 요청 초기에 빠르게 처리</li>
<li>Node.js Runtime → 복잡한 서버 로직 처리</li>
</ul>
<p>👉 <strong>실행 위치와 역할을 기준으로 구분하면</strong> 각 기능을 언제 사용해야 하는지 명확해진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ Middleware (Auth, Redirect, Edge Runtime)]]></title>
            <link>https://velog.io/@do_dam/Next.js-Middleware-%EC%A0%95%EB%A6%AC-Auth-Redirect-Edge-Runtime</link>
            <guid>https://velog.io/@do_dam/Next.js-Middleware-%EC%A0%95%EB%A6%AC-Auth-Redirect-Edge-Runtime</guid>
            <pubDate>Mon, 23 Mar 2026 11:57:19 GMT</pubDate>
            <description><![CDATA[<p>Next.js App Router를 사용하다 보면<br>사용자가 페이지에 진입하기 전에 공통 로직을 처리해야 하는 경우가 자주 발생한다.</p>
<p>예를 들어:</p>
<ul>
<li>로그인 여부 확인 (인증)</li>
<li>특정 페이지 접근 제한</li>
<li>요청 로깅</li>
<li>조건에 따른 리다이렉트</li>
</ul>
<p>이런 로직을 페이지나 API마다 반복 구현하면  코드가 분산되고 관리가 어려워진다.</p>
<p>이때 사용하는 기능이 <strong>Middleware</strong>이다.</p>
<hr>
<h2 id="1-middleware란">1. Middleware란?</h2>
<p>Middleware는 요청이 라우트에 도달하기 전에 실행되는 함수이다.</p>
<p>👉 요청을 가로채서 사전에 처리할 수 있다.</p>
<h3 id="실행-흐름">실행 흐름</h3>
<pre><code class="language-text">Request → Middleware → Route → Response</code></pre>
<h3 id="역할">역할</h3>
<ul>
<li>인증 처리</li>
<li>리다이렉트</li>
<li>요청 검사</li>
<li>로깅</li>
</ul>
<hr>
<h2 id="2-기본-구조">2. 기본 구조</h2>
<p>Middleware는 프로젝트 루트에 <code>middleware.ts</code>로 작성한다.</p>
<h3 id="예시">예시</h3>
<pre><code class="language-tsx">import { NextResponse } from &#39;next/server&#39;;
import type { NextRequest } from &#39;next/server&#39;;

export function middleware(request: NextRequest) {
  return NextResponse.next();
}</code></pre>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ul>
<li>모든 요청 전에 실행됨</li>
<li><code>NextRequest</code>, <code>NextResponse</code> 사용</li>
<li><code>next()</code> 호출 시 다음 단계로 전달</li>
</ul>
<hr>
<h2 id="3-실행-범위-matcher">3. 실행 범위 (matcher)</h2>
<p>기본적으로 모든 요청에 실행되므로<br>필요한 경로에만 적용하는 것이 중요하다.</p>
<h3 id="예시-1">예시</h3>
<pre><code class="language-tsx">export const config = {
  matcher: [&#39;/dashboard/:path*&#39;],
};</code></pre>
<p>👉 <code>/dashboard</code> 이하 경로에만 적용</p>
<hr>
<h2 id="4-인증-처리-auth">4. 인증 처리 (Auth)</h2>
<p>가장 많이 사용하는 패턴</p>
<h3 id="예시-2">예시</h3>
<pre><code class="language-tsx">export function middleware(request: NextRequest) {
  const token = request.cookies.get(&#39;token&#39;);

  if (!token) {
    return NextResponse.redirect(new URL(&#39;/login&#39;, request.url));
  }

  return NextResponse.next();
}</code></pre>
<h3 id="포인트">포인트</h3>
<ul>
<li>쿠키 / 헤더 기반 인증</li>
<li>접근 제어 로직 중앙 관리</li>
</ul>
<hr>
<h2 id="5-redirect-vs-rewrite">5. redirect vs rewrite</h2>
<h3 id="redirect">redirect</h3>
<ul>
<li>URL 변경됨</li>
</ul>
<pre><code class="language-tsx">return NextResponse.redirect(new URL(&#39;/login&#39;, request.url));</code></pre>
<h3 id="rewrite">rewrite</h3>
<ul>
<li>URL 유지, 내부 라우팅 변경</li>
</ul>
<pre><code class="language-tsx">return NextResponse.rewrite(new URL(&#39;/home&#39;, request.url));</code></pre>
<p>👉 redirect = 이동 / rewrite = 내부 매핑</p>
<hr>
<h2 id="6-요청-가공-및-공통-처리">6. 요청 가공 및 공통 처리</h2>
<p>전역적으로 적용되는 공통 로직 처리 가능</p>
<h3 id="예시-3">예시</h3>
<pre><code class="language-tsx">export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  response.headers.set(&#39;x-custom-header&#39;, &#39;middleware&#39;);

  return response;
}</code></pre>
<h3 id="활용">활용</h3>
<ul>
<li>공통 헤더</li>
<li>보안 설정</li>
<li>요청 추적</li>
<li>A/B 테스트</li>
</ul>
<hr>
<h2 id="7-edge-runtime">7. Edge Runtime</h2>
<p>Middleware는 Edge Runtime에서 실행된다.</p>
<blockquote>
<p>👉 Edge Runtime = 사용자와 가까운 서버(CDN 위치)에서 코드를 실행하는 환경</p>
</blockquote>
<h3 id="특징">특징</h3>
<ul>
<li>빠른 응답</li>
<li>사용자와 가까운 위치에서 실행</li>
<li>요청 초기에 처리</li>
</ul>
<h3 id="주의사항">주의사항</h3>
<ul>
<li>Node.js API 사용 불가</li>
<li>DB 직접 접근 비추천</li>
<li>무거운 로직 부적합</li>
</ul>
<p>👉 가벼운 분기 처리에 적합</p>
<hr>
<h2 id="8-실행-순서">8. 실행 순서</h2>
<p>Next.js의 요청 처리 흐름은 다음과 같다.</p>
<pre><code class="language-text">Middleware → Route Handler / Page → Response</code></pre>
<p>👉 Middleware는 항상 가장 먼저 실행된다.</p>
<p>즉, 실제 페이지나 API 로직이 실행되기 전에 <strong>요청을 선별하고 제어하는 역할</strong>을 담당한다.</p>
<hr>
<h2 id="9-언제-사용해야-할까">9. 언제 사용해야 할까?</h2>
<p>다음과 같은 &quot;요청 이전 단계에서 처리해야 하는 로직&quot;에 적합하다.</p>
<hr>
<h3 id="인증--권한-처리">인증 / 권한 처리</h3>
<ul>
<li>로그인 여부 확인</li>
<li>관리자 페이지 접근 제한</li>
</ul>
<hr>
<h3 id="리다이렉트">리다이렉트</h3>
<ul>
<li>로그인 상태에 따른 페이지 이동</li>
<li>특정 조건에 따른 경로 변경</li>
</ul>
<hr>
<h3 id="공통-로직-처리">공통 로직 처리</h3>
<ul>
<li>요청 로깅</li>
<li>A/B 테스트</li>
<li>트래픽 제어</li>
</ul>
<hr>
<p>👉 공통적으로 <strong>여러 페이지에서 반복되는 로직</strong>일수록 Middleware에 적합하다.</p>
<hr>
<h2 id="10-사용하면-안-되는-경우">10. 사용하면 안 되는 경우</h2>
<p>Middleware는 모든 요청마다 실행되기 때문에<br>👉 무거운 로직을 처리하기에는 적합하지 않다.</p>
<hr>
<h3 id="피해야-할-경우">피해야 할 경우</h3>
<ul>
<li>DB 접근</li>
<li>복잡한 비즈니스 로직</li>
<li>시간이 오래 걸리는 작업</li>
</ul>
<hr>
<p>👉 이런 로직은<br>Route Handler 또는 Server Action에서 처리하는 것이 적절하다.</p>
<hr>
<h2 id="11-주의사항">11. 주의사항</h2>
<p>정적 파일이나 내부 경로에도 Middleware가 실행될 수 있다.</p>
<p>예:</p>
<ul>
<li>/_next/*</li>
<li>/favicon.ico</li>
</ul>
<hr>
<h3 id="문제">문제</h3>
<p>불필요한 요청에도 Middleware가 실행되면서<br>👉 성능에 영향을 줄 수 있다.</p>
<hr>
<h3 id="해결">해결</h3>
<pre><code class="language-tsx">export const config = {
  matcher: [&#39;/((?!_next|favicon.ico).*)&#39;],
};</code></pre>
<p>👉 필요한 경로에만 적용하도록 제한하는 것이 중요하다.</p>
<hr>
<h2 id="12-핵심-정리">12. 핵심 정리</h2>
<ul>
<li>요청을 가로채는 사전 처리 레이어</li>
<li>인증, 리다이렉트, 로깅에 사용</li>
<li>Edge Runtime에서 실행</li>
<li>matcher로 범위 제어</li>
<li>redirect / rewrite 구분 필요</li>
</ul>
<hr>
<h3 id="한-줄-정리">한 줄 정리</h3>
<blockquote>
<p>Middleware는 요청 이전 단계에서 실행되어 인증, 리다이렉트 등 공통 로직을 처리하는 레이어이다.</p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>Middleware는 요청이 실제 라우트에 도달하기 전에 실행되며,<br>Next.js의 서버 처리 흐름에서 가장 앞단에 위치한다.</p>
<p>Next.js에서는 서버 로직을 처리하는 방식이 나뉜다.</p>
<ul>
<li>요청 이전 → Middleware  </li>
<li>API 처리 → Route Handlers  </li>
<li>UI 기반 변경 → Server Actions  </li>
</ul>
<p>👉 실행 위치를 기준으로 보면<br>각 기능의 역할과 사용 시점을 더 명확하게 구분할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Route Handlers 정리: API vs Server Actions 선택 기준]]></title>
            <link>https://velog.io/@do_dam/Next.js-Route-Handlers-%EC%A0%95%EB%A6%AC-API-vs-Server-Actions-%EC%84%A0%ED%83%9D-%EA%B8%B0%EC%A4%80</link>
            <guid>https://velog.io/@do_dam/Next.js-Route-Handlers-%EC%A0%95%EB%A6%AC-API-vs-Server-Actions-%EC%84%A0%ED%83%9D-%EA%B8%B0%EC%A4%80</guid>
            <pubDate>Sun, 22 Mar 2026 12:50:04 GMT</pubDate>
            <description><![CDATA[<p>Next.js App Router를 사용하면서 서버 로직을 처리하는 방법은 크게 두 가지가 있다.</p>
<ul>
<li>Route Handlers (API Routes)</li>
<li>Server Actions</li>
</ul>
<p>두 방식 모두 서버에서 실행되지만,<br>👉 <strong>사용 목적과 구조가 다르기 때문에 구분해서 이해하는 것이 중요하다.</strong></p>
<p>이 글에서는 Route Handlers를 중심으로<br>👉 <strong>언제 사용하고, 어떻게 구성하는지</strong>에 초점을 맞춰 정리하려고 한다.</p>
<hr>
<h2 id="1-route-handlers란">1. Route Handlers란?</h2>
<p>Route Handlers는 App Router에서 사용하는 API 서버 기능이다.</p>
<p>기존 <code>pages/api</code> 방식 대신<br>👉 <code>app/api/*/route.ts</code> 구조를 사용한다.</p>
<hr>
<h3 id="기본-구조">기본 구조</h3>
<pre><code class="language-tsx">// app/api/posts/route.ts
export async function GET() {
  return Response.json({ message: &#39;Hello World&#39; });
}</code></pre>
<hr>
<h2 id="2-지원-메서드">2. 지원 메서드</h2>
<p>Route Handlers는 HTTP 메서드별로 함수를 정의한다.</p>
<ul>
<li>GET</li>
<li>POST</li>
<li>PUT</li>
<li>PATCH</li>
<li>DELETE</li>
</ul>
<hr>
<h3 id="예시">예시</h3>
<pre><code class="language-tsx">export async function POST(request: Request) {
  const body = await request.json();

  return Response.json({
    message: &#39;Post created&#39;,
    data: body,
  });
}</code></pre>
<hr>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ul>
<li>함수 이름 = HTTP Method</li>
<li>Request 객체를 통해 데이터 접근</li>
<li>Response로 결과 반환</li>
</ul>
<hr>
<h2 id="3-동작-방식">3. 동작 방식</h2>
<p>Route Handler는 요청이 들어올 때마다 실행되는<br>👉 <strong>서버 함수</strong>이다.</p>
<hr>
<h3 id="특징">특징</h3>
<ul>
<li>기본적으로 서버에서 실행</li>
<li>클라이언트 / 서버 어디서든 호출 가능</li>
<li>REST API 형태로 사용 가능</li>
</ul>
<hr>
<h2 id="4-데이터-처리-흐름">4. 데이터 처리 흐름</h2>
<pre><code class="language-text">Client → API Route → DB → Response</code></pre>
<hr>
<h3 id="예시-1">예시</h3>
<pre><code class="language-tsx">export async function POST(request: Request) {
  const data = await request.json();

  await db.post.create({
    data,
  });

  return Response.json({ success: true });
}</code></pre>
<hr>
<h2 id="5-route-handlers-vs-server-actions">5. Route Handlers vs Server Actions</h2>
<p>두 기능은 역할이 겹쳐 보이지만, 실제로는 용도가 다르다.</p>
<hr>
<h3 id="비교">비교</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>Route Handlers</th>
<th>Server Actions</th>
</tr>
</thead>
<tbody><tr>
<td>호출 방식</td>
<td>HTTP 요청</td>
<td>함수 호출</td>
</tr>
<tr>
<td>사용 위치</td>
<td>API 서버</td>
<td>컴포넌트</td>
</tr>
<tr>
<td>용도</td>
<td>외부 API / 백엔드 로직</td>
<td>UI 기반 Mutation</td>
</tr>
<tr>
<td>구조</td>
<td>RESTful</td>
<td>함수 기반</td>
</tr>
</tbody></table>
<hr>
<h3 id="정리">정리</h3>
<ul>
<li>Route Handlers → <strong>API 중심</strong></li>
<li>Server Actions → <strong>UI 중심</strong></li>
</ul>
<hr>
<h2 id="6-언제-route-handlers를-사용해야-할까">6. 언제 Route Handlers를 사용해야 할까?</h2>
<hr>
<h3 id="외부에서-호출되는-api가-필요한-경우">외부에서 호출되는 API가 필요한 경우</h3>
<ul>
<li>모바일 앱</li>
<li>외부 서비스 연동</li>
<li>Webhook 처리</li>
</ul>
<hr>
<h3 id="rest-api-구조가-필요한-경우">REST API 구조가 필요한 경우</h3>
<ul>
<li>CRUD API</li>
<li>인증 처리</li>
<li>파일 업로드</li>
</ul>
<hr>
<h3 id="클라이언트와-서버를-분리해야-하는-경우">클라이언트와 서버를 분리해야 하는 경우</h3>
<ul>
<li>백엔드 로직을 독립적으로 관리할 때</li>
</ul>
<hr>
<h2 id="7-언제-server-actions를-사용하는-것이-더-좋은가">7. 언제 Server Actions를 사용하는 것이 더 좋은가?</h2>
<hr>
<h3 id="ui에서-직접-데이터-변경이-필요한-경우">UI에서 직접 데이터 변경이 필요한 경우</h3>
<pre><code class="language-tsx">&lt;form action={createPost}&gt;</code></pre>
<hr>
<h3 id="간단한-mutation">간단한 Mutation</h3>
<ul>
<li>게시글 생성</li>
<li>댓글 작성</li>
<li>좋아요</li>
</ul>
<hr>
<h3 id="핵심-차이">핵심 차이</h3>
<p>👉 Route Handler → API 중심<br>👉 Server Action → UI 중심</p>
<hr>
<h2 id="8-캐싱과의-관계">8. 캐싱과의 관계</h2>
<p>Route Handlers에서도 캐싱 전략을 적용할 수 있다.</p>
<hr>
<h3 id="예시-2">예시</h3>
<pre><code class="language-tsx">export async function GET() {
  const data = await fetch(&#39;https://api.example.com&#39;, {
    next: { revalidate: 60 },
  });

  return Response.json(data);
}</code></pre>
<hr>
<h3 id="핵심">핵심</h3>
<ul>
<li>fetch 캐싱 전략 그대로 적용됨</li>
<li><code>revalidate</code> 사용 가능</li>
</ul>
<hr>
<h3 id="추가-포인트">추가 포인트</h3>
<p>Route Handler 자체는 요청마다 실행되지만,<br>내부 <code>fetch</code>는 Next.js 캐싱 전략을 따른다.</p>
<p>필요한 경우 라우트 단위로 동적 처리도 가능하다.</p>
<pre><code class="language-tsx">export const dynamic = &#39;force-dynamic&#39;;</code></pre>
<p>👉 항상 최신 데이터를 기준으로 동작</p>
<hr>
<h2 id="9-nextrequest--nextresponse">9. NextRequest / NextResponse</h2>
<p>Route Handler에서는 기본 <code>Request</code>, <code>Response</code> 대신<br>Next.js에서 제공하는 확장 객체를 사용할 수 있다.</p>
<hr>
<h3 id="nextrequest">NextRequest</h3>
<pre><code class="language-tsx">import { NextRequest } from &#39;next/server&#39;;

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const id = searchParams.get(&#39;id&#39;);

  return Response.json({ id });
}</code></pre>
<hr>
<h3 id="nextresponse">NextResponse</h3>
<pre><code class="language-tsx">import { NextResponse } from &#39;next/server&#39;;

export async function GET() {
  return NextResponse.json({ message: &#39;Hello&#39; });
}</code></pre>
<hr>
<h3 id="핵심-포인트-1">핵심 포인트</h3>
<ul>
<li>URL, 쿠키, 헤더 접근이 편리함</li>
<li>Next.js 기능과 자연스럽게 연결됨</li>
</ul>
<hr>
<h2 id="10-핵심-정리">10. 핵심 정리</h2>
<ul>
<li>Route Handlers는 App Router의 API 서버 기능</li>
<li><code>app/api/*/route.ts</code> 구조 사용</li>
<li>HTTP Method 기반 함수 정의</li>
<li>REST API 형태로 활용 가능</li>
<li>NextRequest / NextResponse로 확장 기능 사용 가능</li>
<li>fetch 캐싱 및 revalidate 전략 적용 가능</li>
</ul>
<hr>
<h3 id="한-줄-정리">한 줄 정리</h3>
<blockquote>
<p>Route Handlers는 API 중심의 서버 로직을 구성하는 방식이며,<br>외부 요청 처리나 REST 구조가 필요한 경우에 적합하다.</p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<p>Next.js App Router에서는 서버 로직을 처리하는 방식이 하나로 고정되어 있지 않다.</p>
<p>Route Handlers와 Server Actions는 모두 서버에서 실행되지만,<br>👉 <strong>어떤 방식으로 연결되는지에 따라 역할이 달라진다.</strong></p>
<ul>
<li>외부 요청이나 API 구조가 필요한 경우 → Route Handlers  </li>
<li>UI와 직접 연결된 데이터 변경 → Server Actions  </li>
</ul>
<p>이 기준으로 구분하면<br>상황에 맞는 구조를 더 명확하게 선택할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 캐싱 전략 (Cache & Revalidation)]]></title>
            <link>https://velog.io/@do_dam/Next.js-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC-Cache-Revalidation</link>
            <guid>https://velog.io/@do_dam/Next.js-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC-Cache-Revalidation</guid>
            <pubDate>Thu, 19 Mar 2026 09:26:34 GMT</pubDate>
            <description><![CDATA[<p>Next.js App Router를 사용하다 보면 이런 상황을 자주 겪게 된다.</p>
<ul>
<li>&quot;데이터를 바꿨는데 왜 화면이 그대로지?&quot;</li>
<li>&quot;왜 어떤 요청은 다시 안 나가지?&quot;</li>
<li>&quot;revalidate를 했는데 왜 안 바뀌지?&quot;</li>
</ul>
<p>이런 문제의 대부분은<br>👉 <strong>Next.js의 캐싱 구조를 정확히 이해하지 못해서 발생한다.</strong></p>
<p>Next.js는 단순한 캐싱이 아니라,<br><strong>여러 레이어의 캐싱이 동시에 동작하는 구조</strong>를 가지고 있다.</p>
<hr>
<h2 id="전체-캐싱-구조">전체 캐싱 구조</h2>
<p>Next.js의 캐싱은 크게 3가지 레이어로 나뉜다.</p>
<ol>
<li>Request Memoization (요청 단위)</li>
<li>Data Cache (데이터 단위)</li>
<li>Full Route Cache (페이지 단위)</li>
</ol>
<hr>
<h3 id="동작-흐름">동작 흐름</h3>
<pre><code class="language-text">[Request]
   ↓
[Request Memoization]
   ↓
[Data Cache]
   ↓
[Full Route Cache]</code></pre>
<p>👉 이 구조를 이해하면 대부분의 캐싱 문제를 설명할 수 있다.</p>
<hr>
<h2 id="1-request-memoization">1. Request Memoization</h2>
<h3 id="개념">개념</h3>
<p>하나의 요청 사이클 내에서<br><strong>같은 fetch 요청을 여러 번 보내지 않도록 하는 메커니즘</strong></p>
<h3 id="특징">특징</h3>
<ul>
<li>서버 렌더링 중에만 적용</li>
<li>동일한 요청은 한 번만 실행됨</li>
<li>이후 요청은 결과 재사용</li>
</ul>
<h3 id="예시">예시</h3>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;);
await fetch(&#39;/api/data&#39;); // 실제로는 한 번만 요청됨</code></pre>
<hr>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ul>
<li>성능 최적화를 위한 내부 동작</li>
<li>개발자가 직접 제어하지 않음</li>
</ul>
<p>👉 &quot;왜 fetch를 두 번 했는데 한 번만 호출되지?&quot;의 원인</p>
<hr>
<h2 id="2-data-cache">2. Data Cache</h2>
<h3 id="개념-1">개념</h3>
<p>fetch 요청 결과를 캐싱하는 레이어</p>
<p>👉 Next.js에서는 <strong>기본적으로 fetch가 캐시된다</strong></p>
<hr>
<h3 id="기본-동작">기본 동작</h3>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;); // 기본적으로 캐싱됨</code></pre>
<hr>
<h3 id="캐시-제어">캐시 제어</h3>
<h4 id="1-revalidate-시간-기반">1. revalidate (시간 기반)</h4>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;, {
  next: { revalidate: 60 },
});</code></pre>
<ul>
<li>60초 동안 캐시 유지</li>
<li>이후 요청 시 재검증</li>
</ul>
<hr>
<h4 id="2-no-store-캐시-비활성화">2. no-store (캐시 비활성화)</h4>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;, {
  cache: &#39;no-store&#39;,
});</code></pre>
<ul>
<li>항상 최신 데이터 요청</li>
</ul>
<hr>
<h3 id="핵심-포인트-1">핵심 포인트</h3>
<ul>
<li>Data Cache는 <strong>데이터 단위 캐싱</strong></li>
<li>대부분의 캐싱 이슈는 여기서 발생</li>
</ul>
<p>👉 기본값이 &quot;캐시됨&quot;이라는 점이 중요하다</p>
<hr>
<h2 id="3-full-route-cache">3. Full Route Cache</h2>
<h3 id="개념-2">개념</h3>
<p>페이지 전체(HTML + 데이터)를 캐싱하는 레이어</p>
<p>👉 Static Rendering 시 자동 적용됨</p>
<hr>
<h3 id="특징-1">특징</h3>
<ul>
<li>페이지 단위 캐싱</li>
<li>빌드 시 또는 첫 요청 시 생성</li>
<li>이후 동일 요청은 캐시된 결과 반환</li>
</ul>
<hr>
<h3 id="라우트-옵션으로-제어">라우트 옵션으로 제어</h3>
<h4 id="1-dynamic">1. dynamic</h4>
<pre><code class="language-tsx">export const dynamic = &#39;force-dynamic&#39;;</code></pre>
<ul>
<li>항상 서버에서 렌더링</li>
<li>캐싱 비활성화</li>
</ul>
<hr>
<h4 id="2-revalidate">2. revalidate</h4>
<pre><code class="language-tsx">export const revalidate = 60;</code></pre>
<ul>
<li>60초마다 페이지 재생성</li>
</ul>
<hr>
<h3 id="핵심-포인트-2">핵심 포인트</h3>
<ul>
<li>Full Route Cache는 <strong>페이지 단위 캐싱</strong></li>
<li>Data Cache와 함께 동작함</li>
</ul>
<p>👉 &quot;페이지가 안 바뀌는 이유&quot;는 대부분 이 레이어 때문</p>
<hr>
<h2 id="4-revalidation-전략">4. Revalidation 전략</h2>
<p>데이터가 변경되었을 때<br>👉 캐시를 갱신하는 방법</p>
<hr>
<h3 id="1-revalidatepath">1. revalidatePath</h3>
<pre><code class="language-tsx">import { revalidatePath } from &#39;next/cache&#39;;

revalidatePath(&#39;/posts&#39;);</code></pre>
<ul>
<li>특정 페이지 캐시 무효화</li>
</ul>
<hr>
<h3 id="2-revalidatetag">2. revalidateTag</h3>
<pre><code class="language-tsx">import { revalidateTag } from &#39;next/cache&#39;;

revalidateTag(&#39;posts&#39;);</code></pre>
<ul>
<li>태그 기반 캐시 무효화</li>
</ul>
<hr>
<h3 id="언제-사용하나">언제 사용하나?</h3>
<p>👉 Server Action 이후</p>
<pre><code class="language-tsx">&#39;use server&#39;;

export async function createPost() {
  await db.post.create(...);

  revalidatePath(&#39;/posts&#39;);
}</code></pre>
<hr>
<h3 id="핵심-포인트-3">핵심 포인트</h3>
<ul>
<li>Mutation 이후 반드시 필요</li>
<li>안 하면 데이터는 바뀌었지만 UI는 그대로</li>
</ul>
<hr>
<h2 id="5-클라이언트-캐시와의-관계">5. 클라이언트 캐시와의 관계</h2>
<p>Next.js 캐시는 서버 중심이지만,<br>클라이언트에서도 별도의 캐시 전략이 존재한다.</p>
<hr>
<h3 id="예시-1">예시</h3>
<ul>
<li>React Query</li>
<li>SWR</li>
</ul>
<hr>
<h3 id="차이점">차이점</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>서버 캐시</th>
<th>클라이언트 캐시</th>
</tr>
</thead>
<tbody><tr>
<td>위치</td>
<td>서버</td>
<td>브라우저</td>
</tr>
<tr>
<td>목적</td>
<td>SSR 최적화</td>
<td>UX / 인터랙션</td>
</tr>
<tr>
<td>도구</td>
<td>fetch cache</td>
<td>React Query, SWR</td>
</tr>
</tbody></table>
<p>👉 두 캐시는 경쟁 관계가 아니라 <strong>보완 관계</strong></p>
<hr>
<h2 id="6-언제-어떤-캐싱-전략을-써야-할까">6. 언제 어떤 캐싱 전략을 써야 할까?</h2>
<p>캐싱 구조를 이해하는 것도 중요하지만,<br>실제로는 상황에 따라 어떤 전략을 선택할지가 더 중요하다.</p>
<p>Next.js에서는 데이터의 성격에 따라 캐싱 방식을 다르게 가져가는 것이 일반적이다.</p>
<hr>
<h3 id="항상-최신-데이터가-필요한-경우">항상 최신 데이터가 필요한 경우</h3>
<p>사용자 정보, 주문 상태처럼<br>요청마다 데이터가 달라질 수 있는 경우에는 캐싱을 사용하지 않는 것이 적절하다.</p>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;, {
  cache: &#39;no-store&#39;,
});</code></pre>
<p>또는</p>
<pre><code class="language-tsx">export const dynamic = &#39;force-dynamic&#39;;</code></pre>
<p>이렇게 설정하면 요청마다 새로운 데이터를 기준으로 렌더링된다.</p>
<hr>
<h3 id="데이터가-거의-변하지-않는-경우">데이터가 거의 변하지 않는 경우</h3>
<p>블로그 글, 정적 페이지처럼<br>데이터 변경이 거의 없는 경우에는 캐싱을 사용하는 것이 유리하다.</p>
<p>이 경우에는 별도의 설정 없이 기본 fetch를 사용해도<br>Next.js가 자동으로 캐싱을 적용한다.</p>
<hr>
<h3 id="일정-주기로-업데이트되는-경우">일정 주기로 업데이트되는 경우</h3>
<p>게시글 목록, 상품 리스트처럼<br>완전히 실시간일 필요는 없지만 일정 주기로 갱신되어야 하는 데이터는<br><code>revalidate</code>를 사용하는 방식이 적절하다.</p>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;, {
  next: { revalidate: 60 },
});</code></pre>
<p>이 방식은 캐시된 데이터를 먼저 제공하고,<br>이후 백그라운드에서 최신 데이터를 반영한다.</p>
<hr>
<h3 id="데이터-변경mutation이-발생하는-경우">데이터 변경(Mutation)이 발생하는 경우</h3>
<p>데이터를 변경한 이후에는 기존 캐시가 유지되기 때문에<br>명시적으로 캐시를 갱신해줘야 한다.</p>
<pre><code class="language-tsx">revalidatePath(&#39;/posts&#39;);</code></pre>
<p>또는</p>
<pre><code class="language-tsx">revalidateTag(&#39;posts&#39;);</code></pre>
<p>이 과정을 통해 변경된 데이터가 화면에 반영된다.</p>
<hr>
<h2 id="캐싱-전략-선택">캐싱 전략 선택</h2>
<p>** 언제 무엇을 쓸까 **</p>
<ul>
<li>항상 최신 데이터 → 캐시 사용하지 않음 (<code>no-store</code>)</li>
<li>거의 변하지 않는 데이터 → 캐싱 활용 (Static)</li>
<li>주기적으로 변하는 데이터 → <code>revalidate</code> 사용 (ISR)</li>
<li>데이터 변경 이후 → <code>revalidatePath</code> / <code>revalidateTag</code></li>
</ul>
<hr>
<h2 id="캐싱-구조-요약">캐싱 구조 요약</h2>
<p>** 무엇이 어떻게 동작할까 **</p>
<ul>
<li><code>Request Memoization</code>: 동일 요청을 한 번만 실행하여 중복 fetch 방지</li>
<li><code>Data Cache</code>: fetch 결과를 캐싱하여 동일 데이터 요청 시 재사용</li>
<li><code>Full Route Cache</code>: 페이지 전체를 캐싱하여 빠른 응답 제공</li>
<li><code>Revalidation</code>: 변경된 데이터가 반영되도록 캐시를 갱신</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>Next.js의 캐싱은 각 데이터의 특성과 업데이트 방식에 따라<br>적절한 전략을 선택하는 것이 더 중요하다.</p>
<p>특히 Next.js는 여러 레이어의 캐싱이 함께 동작하기 때문에,<br>문제가 발생했을 때 <strong>&quot;어느 레이어에서 캐시되고 있는지&quot;를 먼저 파악하는 것이 중요하다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[데이터 변경을 처리하는 방법: Server Actions 정리]]></title>
            <link>https://velog.io/@do_dam/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B3%80%EA%B2%BD%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-Server-Actions-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@do_dam/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B3%80%EA%B2%BD%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-Server-Actions-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 19 Mar 2026 03:09:25 GMT</pubDate>
            <description><![CDATA[<h1 id="server-actions-서버-액션">Server Actions (서버 액션)</h1>
<p>👉 Next.js 13+에서 추가된 핵심 기능으로,<br>👉 클라이언트 → 서버 데이터 변경(Mutation)을 단순하게 처리할 수 있다.</p>
<p>기존에는 데이터 변경을 위해 API Route를 따로 만들어야 했지만,<br>Server Actions를 사용하면 <strong>컴포넌트에서 직접 서버 로직을 실행</strong>할 수 있다.</p>
<hr>
<h2 id="개념">개념</h2>
<p>Server Action은 서버에서 실행되는 함수로,<br>주로 <strong>데이터 생성 / 수정 / 삭제 (Mutation)</strong> 작업을 담당한다.</p>
<p>정리하면 다음과 같이 역할이 나뉜다.</p>
<ul>
<li>데이터 조회 → Server Component / fetch</li>
<li>데이터 변경 → Server Action</li>
</ul>
<p>👉 즉, Server Action은 Next.js에서 <strong>Mutation을 담당하는 표준 방식</strong>이라고 볼 수 있다.</p>
<hr>
<h2 id="왜-중요한가">왜 중요한가?</h2>
<p>기존 방식:</p>
<ol>
<li>클라이언트에서 API 호출</li>
<li>API Route에서 로직 처리</li>
<li>응답을 받아 상태 업데이트</li>
</ol>
<p>Server Actions:</p>
<p>👉 <strong>API 없이 서버 함수 직접 실행</strong></p>
<p>이 구조 덕분에</p>
<ul>
<li>API Route를 따로 만들지 않아도 되어 코드가 줄어들고</li>
<li>클라이언트 → 서버 → 클라이언트로 이어지던 흐름이 단순해지며</li>
<li>데이터 변경 로직을 한 곳에서 관리할 수 있다</li>
</ul>
<p>👉 결과적으로, 구현해야 할 구조 자체가 단순해진다.</p>
<hr>
<h2 id="기본-사용법">기본 사용법</h2>
<h3 id="1-서버-액션-정의">1. 서버 액션 정의</h3>
<pre><code class="language-tsx">&#39;use server&#39;;

export async function createPost(formData: FormData) {
  const title = formData.get(&#39;title&#39;);

  await db.post.create({
    data: { title },
  });
}</code></pre>
<hr>
<h3 id="2-form에서-바로-사용">2. form에서 바로 사용</h3>
<pre><code class="language-tsx">import { createPost } from &#39;./actions&#39;;

export default function Page() {
  return (
    &lt;form action={createPost}&gt;
      &lt;input name=&quot;title&quot; /&gt;
      &lt;button type=&quot;submit&quot;&gt;Submit&lt;/button&gt;
    &lt;/form&gt;
  );
}</code></pre>
<hr>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ul>
<li>form의 <code>action</code>에 함수 직접 연결</li>
<li>별도의 API endpoint 불필요</li>
<li>자동으로 서버에서 실행됨</li>
</ul>
<p>👉 기존 fetch + API 구조보다 훨씬 직관적이다.</p>
<hr>
<h2 id="클라이언트-컴포넌트에서-사용">클라이언트 컴포넌트에서 사용</h2>
<p>Client Component에서도 Server Action을 사용할 수 있다.</p>
<pre><code class="language-tsx">&#39;use client&#39;;

import { createPost } from &#39;./actions&#39;;

export default function Button() {
  return (
    &lt;button onClick={() =&gt; createPost()}&gt;
      생성하기
    &lt;/button&gt;
  );
}</code></pre>
<hr>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li>브라우저 이벤트 → 서버 함수 호출 구조이기 때문에</li>
<li>네트워크 요청이 발생한다는 점을 고려해야 한다</li>
</ul>
<p>👉 단순 함수 호출처럼 보여도 실제로는 서버 통신이다.</p>
<hr>
<h2 id="revalidate로-데이터-갱신">revalidate로 데이터 갱신</h2>
<p>Mutation 이후에는 기존 데이터가 stale 상태가 되기 때문에<br><strong>캐시를 갱신해야 한다.</strong></p>
<pre><code class="language-tsx">&#39;use server&#39;;

import { revalidatePath } from &#39;next/cache&#39;;

export async function createPost(formData: FormData) {
  await db.post.create({
    data: { title: formData.get(&#39;title&#39;) },
  });

  revalidatePath(&#39;/posts&#39;);
}</code></pre>
<hr>
<h3 id="핵심-포인트-1">핵심 포인트</h3>
<ul>
<li>Server Action + revalidate 조합이 중요</li>
<li>데이터 변경 후 UI 최신 상태 유지</li>
</ul>
<p>👉 이걸 놓치면 &quot;데이터는 바뀌었는데 화면은 안 바뀌는&quot; 문제가 생긴다.</p>
<hr>
<h2 id="기존-방식-vs-server-actions">기존 방식 vs Server Actions</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기존 방식</th>
<th>Server Actions</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 변경</td>
<td>API Route 필요</td>
<td>필요 없음</td>
</tr>
<tr>
<td>코드 구조</td>
<td>클라이언트 + API 분리</td>
<td>한 곳에서 처리</td>
</tr>
<tr>
<td>복잡도</td>
<td>상대적으로 높음</td>
<td>단순함</td>
</tr>
</tbody></table>
<hr>
<h2 id="언제-사용하면-좋은가">언제 사용하면 좋은가?</h2>
<ul>
<li>form 기반 데이터 제출</li>
<li>CRUD 작업</li>
<li>간단한 서버 로직 처리</li>
<li>빠르게 기능을 구현해야 하는 경우</li>
</ul>
<hr>
<h2 id="한계-및-고려할-점">한계 및 고려할 점</h2>
<ul>
<li><p>복잡한 비즈니스 로직은 여전히 분리하는 것이 좋다  </p>
</li>
<li><p>서버 로직이 컴포넌트와 가까워지면서 구조가 섞일 수 있다<br>→ 규모가 커지면 레이어 분리가 필요하다</p>
</li>
<li><p>내부적으로는 네트워크 요청이 발생한다  </p>
</li>
</ul>
<p>👉 즉, Server Action은 편리하지만 모든 상황에서 사용하는 것이 아니라<br><strong>단순한 Mutation이나 빠른 구현이 필요한 경우에 더 적합하다.</strong></p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>Server Action은 <strong>데이터 변경(Mutation)을 담당</strong></li>
<li>API Route 없이 서버 로직 실행 가능</li>
<li>form과 자연스럽게 결합됨</li>
<li>revalidate를 통해 데이터 최신화 필요</li>
</ul>
<hr>
<h3 id="한-줄-정리">한 줄 정리</h3>
<blockquote>
<p>Server Action은 Next.js에서 데이터 변경(Mutation) 흐름을 단순화해주는 기능이며,<br>API 없이도 서버 로직을 직접 처리할 수 있게 해준다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[UX를 완성하는 Loading / Error 처리 방법]]></title>
            <link>https://velog.io/@do_dam/Next.js-App-Router%EC%97%90%EC%84%9C-UX%EB%A5%BC-%EC%99%84%EC%84%B1%ED%95%98%EB%8A%94-Loading-Error-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@do_dam/Next.js-App-Router%EC%97%90%EC%84%9C-UX%EB%A5%BC-%EC%99%84%EC%84%B1%ED%95%98%EB%8A%94-Loading-Error-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 19 Mar 2026 02:50:19 GMT</pubDate>
            <description><![CDATA[<p>사용자 경험(UX)을 크게 좌우하는 요소 중 하나는 <strong>로딩 상태와 에러 처리 UI</strong>이다.<br>특히 Next.js(App Router)에서는 이를 파일 단위로 구조화하여 매우 직관적으로 관리할 수 있다.</p>
<p>처음에는 단순한 보조 UI라고 생각했지만, 직접 적용해보면서<br><strong>Streaming만으로는 UX가 완성되지 않고, fallback UI 설계가 반드시 필요하다</strong>는 점을 체감하게 되었다.</p>
<p>이 글에서는 다음 내용을 정리한다.</p>
<ul>
<li><code>loading.tsx</code></li>
<li><code>error.tsx</code></li>
<li><code>not-found.tsx</code></li>
<li>Suspense 기반 로딩 처리</li>
<li>Streaming과 UI fallback의 관계</li>
</ul>
<hr>
<h2 id="1-loadingtsx">1. loading.tsx</h2>
<h3 id="개념">개념</h3>
<p><code>loading.tsx</code>는 해당 라우트(segment)가 <strong>로딩 중일 때 자동으로 보여지는 UI</strong>이다.</p>
<p>즉, 데이터 fetching 또는 서버 컴포넌트가 아직 준비되지 않았을 때<br>사용자에게 보여줄 fallback UI를 정의하는 역할을 한다.</p>
<h3 id="특징">특징</h3>
<ul>
<li>해당 폴더(라우트 segment)에 자동 적용</li>
<li>별도의 import 없이 Next.js가 자동 연결</li>
<li>내부적으로 Suspense fallback 역할 수행</li>
</ul>
<h3 id="예시">예시</h3>
<pre><code class="language-tsx">// app/posts/loading.tsx
export default function Loading() {
  return &lt;p&gt;Loading posts...&lt;/p&gt;;
}</code></pre>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ul>
<li>페이지 전환 시 깜빡임 없이 자연스러운 UX 제공</li>
<li>Skeleton UI와 함께 사용하는 것이 일반적</li>
</ul>
<p>👉 실제로 적용해보니 단순 텍스트보다 Skeleton UI가 훨씬 자연스럽게 느껴졌다.</p>
<hr>
<h2 id="2-errortsx">2. error.tsx</h2>
<h3 id="개념-1">개념</h3>
<p><code>error.tsx</code>는 해당 라우트에서 <strong>에러 발생 시 보여주는 UI</strong>이다.</p>
<p>React의 Error Boundary 기반으로 동작하며,<br>예상치 못한 에러 상황에서도 UI를 유지할 수 있게 해준다.</p>
<h3 id="특징-1">특징</h3>
<ul>
<li>반드시 클라이언트 컴포넌트 (<code>&#39;use client&#39;</code>)</li>
<li><code>reset()</code> 함수를 통해 에러 복구 가능</li>
</ul>
<h3 id="예시-1">예시</h3>
<pre><code class="language-tsx">&#39;use client&#39;;

export default function Error({ error, reset }: { error: Error; reset: () =&gt; void }) {
  return (
    &lt;div&gt;
      &lt;h2&gt;Something went wrong!&lt;/h2&gt;
      &lt;button onClick={() =&gt; reset()}&gt;Try again&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<h3 id="핵심-포인트-1">핵심 포인트</h3>
<ul>
<li>에러 발생 시에도 UI가 깨지지 않음</li>
<li>사용자에게 재시도 기회 제공</li>
</ul>
<p>👉 단순히 에러를 보여주는 것보다, 다시 시도할 수 있게 만드는 게 훨씬 중요하다고 느꼈다.</p>
<hr>
<h2 id="3-not-foundtsx">3. not-found.tsx</h2>
<h3 id="개념-2">개념</h3>
<p><code>not-found.tsx</code>는 존재하지 않는 경로이거나<br><code>notFound()</code>가 호출될 때 보여지는 UI이다.</p>
<h3 id="특징-2">특징</h3>
<ul>
<li>404 페이지를 라우트 단위로 정의 가능</li>
<li>서버 컴포넌트에서도 쉽게 트리거 가능</li>
</ul>
<h3 id="예시-2">예시</h3>
<pre><code class="language-tsx">// app/posts/not-found.tsx
export default function NotFound() {
  return &lt;h2&gt;Post Not Found&lt;/h2&gt;;
}</code></pre>
<pre><code class="language-tsx">// 사용 예시
import { notFound } from &#39;next/navigation&#39;;

export default async function Page({ params }) {
  const data = await fetchData(params.id);

  if (!data) {
    notFound();
  }

  return &lt;div&gt;{data.title}&lt;/div&gt;;
}</code></pre>
<h3 id="핵심-포인트-2">핵심 포인트</h3>
<ul>
<li>페이지 단위가 아닌 <strong>segment 단위 404 처리</strong></li>
<li>SEO 및 UX 모두에 유리</li>
</ul>
<p>👉 기존에는 전역 404만 생각했는데, 이렇게 나눌 수 있다는 점이 인상적이었다.</p>
<hr>
<h2 id="4-suspense-기반-로딩-처리">4. Suspense 기반 로딩 처리</h2>
<h3 id="개념-3">개념</h3>
<p>React의 <code>Suspense</code>를 활용하면<br><strong>컴포넌트 단위로 로딩 상태를 제어</strong>할 수 있다.</p>
<h3 id="예시-3">예시</h3>
<pre><code class="language-tsx">import { Suspense } from &#39;react&#39;;
import PostList from &#39;./PostList&#39;;

export default function Page() {
  return (
    &lt;Suspense fallback={&lt;p&gt;Loading posts...&lt;/p&gt;}&gt;
      &lt;PostList /&gt;
    &lt;/Suspense&gt;
  );
}</code></pre>
<h3 id="핵심-포인트-3">핵심 포인트</h3>
<ul>
<li>페이지 전체가 아니라 <strong>부분 단위 로딩 처리 가능</strong></li>
<li>느린 컴포넌트만 분리해서 UX 개선 가능</li>
</ul>
<p>👉 loading.tsx만으로는 부족하고, Suspense를 같이 써야 세밀한 제어가 가능했다.</p>
<hr>
<h2 id="5-streaming-vs-ui-fallback">5. Streaming vs UI Fallback</h2>
<h3 id="streaming이란">Streaming이란?</h3>
<p>서버에서 데이터를 모두 준비한 후 보내는 것이 아니라,<br><strong>준비된 UI부터 점진적으로 클라이언트에 전달하는 방식</strong>이다.</p>
<hr>
<h3 id="직접-적용하면서-느낀-점">직접 적용하면서 느낀 점</h3>
<p>Streaming을 적용하면 페이지가 빠르게 보이긴 했지만,<br>실제로는 UX가 완전히 좋아졌다고 느껴지지는 않았다.</p>
<p>특히 아래와 같은 상황이 있었다.</p>
<h3 id="문제점">문제점</h3>
<ul>
<li>어떤 부분이 아직 로딩 중인지 알기 어려움</li>
<li>UI가 중간중간 늦게 나타나면서 흐름이 끊기는 느낌</li>
<li>fallback 없이 콘텐츠가 갑자기 바뀌는 느낌</li>
</ul>
<hr>
<h3 id="결론">결론</h3>
<p>👉 Streaming만으로는 부족하고,<br>👉 반드시 fallback UI와 같이 설계해야 한다.</p>
<p>즉,</p>
<ul>
<li><code>loading.tsx</code></li>
<li><code>Suspense fallback</code></li>
</ul>
<p>이 둘이 함께 있어야 자연스러운 UX가 만들어진다.</p>
<hr>
<h3 id="동작-흐름">동작 흐름</h3>
<pre><code class="language-text">[사용자 요청]
   ↓
[Streaming 시작]
   ↓
[Fallback UI 먼저 렌더링]
   ↓
[데이터 준비 완료]
   ↓
[실제 UI로 점진적 교체]</code></pre>
<hr>
<h2 id="6-핵심-정리">6. 핵심 정리</h2>
<ul>
<li><code>loading.tsx</code> → 자동 fallback UI</li>
<li><code>error.tsx</code> → Error Boundary</li>
<li><code>not-found.tsx</code> → 라우트 단위 404 처리</li>
<li>Suspense → 컴포넌트 단위 로딩 제어</li>
<li>Streaming → 성능 개선, UX 완성은 fallback이 담당</li>
</ul>
<hr>
<h3 id="한-줄-정리">한 줄 정리</h3>
<blockquote>
<p>Streaming은 빠르게 보여주기 위한 것이고,<br>실제 사용자 경험은 fallback UI가 완성한다.</p>
</blockquote>
<hr>
<h2 id="7-실무에서-적용할-수-있는-포인트-정리">7. 실무에서 적용할 수 있는 포인트 정리</h2>
<ul>
<li>loading.tsx에는 Skeleton UI를 사용하는 것이 좋다</li>
<li>Suspense는 &quot;느린 컴포넌트 기준&quot;으로 분리하는 것이 효과적이다</li>
<li>error.tsx에는 retry UX를 반드시 포함하는 것이 좋다</li>
<li>not-found는 SEO까지 고려해서 설계해야 한다</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 내용을 정리하면서 느낀 점은,<br>로딩과 에러 처리는 단순히 &quot;예외 상황 처리&quot;가 아니라<br><strong>사용자 흐름을 끊지 않기 위한 설계 요소</strong>라는 것이다.</p>
<p>Next.js App Router는 이런 부분을 구조적으로 나눌 수 있게 해주기 때문에,<br>기능 구현뿐 아니라 UX까지 같이 고민하게 만드는 도구라고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Layout & Template 구조 (App Router)]]></title>
            <link>https://velog.io/@do_dam/Layout-Template-%EA%B5%AC%EC%A1%B0-App-Router</link>
            <guid>https://velog.io/@do_dam/Layout-Template-%EA%B5%AC%EC%A1%B0-App-Router</guid>
            <pubDate>Wed, 18 Mar 2026 02:54:23 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-개요">📌 개요</h2>
<p>Next.js 13부터 도입된 <strong>App Router</strong>에서는 기존 Page Router와 달리<br>애플리케이션의 UI를 <strong>페이지가 아닌 Layout 중심으로 설계</strong>한다.</p>
<p>이 글에서는 다음 내용을 정리한다.</p>
<ul>
<li><code>layout.tsx</code></li>
<li><code>template.tsx</code></li>
<li>Nested Layout 구조</li>
<li>공통 UI 관리 방식</li>
<li>실제 렌더링 흐름과 동작 원리</li>
</ul>
<hr>
<h2 id="1-layouttsx">1. layout.tsx</h2>
<h3 id="📌-개념">📌 개념</h3>
<p><code>layout.tsx</code>는 <strong>공통 UI를 정의하는 파일</strong>이다.<br>해당 경로 이하의 모든 페이지에서 <strong>공통으로 유지되는 UI</strong>를 담당한다.</p>
<p>예를 들어:</p>
<ul>
<li>Header</li>
<li>Footer</li>
<li>Sidebar</li>
<li>Navigation</li>
</ul>
<p>같은 요소들을 포함할 수 있다.</p>
<hr>
<h3 id="📌-기본-구조">📌 기본 구조</h3>
<pre><code class="language-tsx">export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &lt;html lang=&quot;ko&quot;&gt;
      &lt;body&gt;
        &lt;header&gt;Header&lt;/header&gt;
        {children}
        &lt;footer&gt;Footer&lt;/footer&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<hr>
<h3 id="📌-특징">📌 특징</h3>
<ul>
<li>페이지 이동 시 <strong>다시 렌더링되지 않음</strong></li>
<li>상태(state)가 유지됨</li>
<li>공통 레이아웃을 효율적으로 관리 가능</li>
</ul>
<hr>
<h3 id="📌-왜-다시-렌더링되지-않을까">📌 왜 다시 렌더링되지 않을까?</h3>
<p>Next.js App Router에서는 <strong>layout이 React Tree에서 유지되기 때문</strong>이다.</p>
<p>즉,</p>
<ul>
<li>layout은 유지되고</li>
<li>내부의 page만 교체된다</li>
</ul>
<p>👉 이 구조 덕분에:</p>
<ul>
<li>state 유지 가능</li>
<li>불필요한 렌더링 감소</li>
<li>성능 최적화</li>
</ul>
<hr>
<h2 id="2-templatetsx">2. template.tsx</h2>
<h3 id="📌-개념-1">📌 개념</h3>
<p><code>template.tsx</code>는 <code>layout.tsx</code>와 구조는 비슷하지만<br><strong>페이지 이동 시마다 새로 렌더링되는 레이아웃</strong>이다.</p>
<hr>
<h3 id="📌-왜-template가-필요할까">📌 왜 template가 필요할까?</h3>
<p>layout은 유지되기 때문에 다음과 같은 문제가 있다:</p>
<ul>
<li>애니메이션이 실행되지 않음</li>
<li>초기화 로직이 동작하지 않음</li>
</ul>
<p>👉 그래서 등장한 것이 <code>template.tsx</code></p>
<hr>
<h3 id="📌-차이점-layout-vs-template">📌 차이점 (layout vs template)</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>layout.tsx</th>
<th>template.tsx</th>
</tr>
</thead>
<tbody><tr>
<td>렌더링</td>
<td>유지됨</td>
<td>매번 새로 렌더링</td>
</tr>
<tr>
<td>상태 유지</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td>사용 목적</td>
<td>공통 UI</td>
<td>애니메이션, 초기화</td>
</tr>
</tbody></table>
<hr>
<h3 id="📌-사용-예시">📌 사용 예시</h3>
<ul>
<li>페이지 전환 애니메이션</li>
<li>진입 시마다 초기화가 필요한 UI</li>
<li>특정 페이지 진입 효과</li>
</ul>
<hr>
<h2 id="3-children-구조와-렌더링-흐름-핵심">3. children 구조와 렌더링 흐름 (핵심)</h2>
<p>App Router는 <strong>children을 통해 계층 구조를 구성한다.</strong></p>
<h3 id="📌-구조">📌 구조</h3>
<pre><code class="language-plaintext">layout
  → template
    → page</code></pre>
<p>또는 Nested 구조에서는:</p>
<pre><code class="language-plaintext">Root Layout
  └ Dashboard Layout
       └ Template
            └ Page</code></pre>
<hr>
<h3 id="📌-핵심-동작">📌 핵심 동작</h3>
<ul>
<li>layout은 children을 감싼다</li>
<li>하위 layout도 children으로 계속 중첩된다</li>
<li>결국 트리 구조로 UI가 구성됨</li>
</ul>
<hr>
<h2 id="4-nested-layout-구조">4. Nested Layout 구조</h2>
<h3 id="📌-개념-2">📌 개념</h3>
<p>Next.js는 폴더 구조를 기반으로<br><strong>레이아웃을 계층적으로 중첩(Nested)해서 적용</strong>할 수 있다.</p>
<hr>
<h3 id="📌-예시-구조">📌 예시 구조</h3>
<pre><code class="language-plaintext">app/
 ├─ layout.tsx
 ├─ page.tsx
 ├─ dashboard/
 │   ├─ layout.tsx
 │   └─ page.tsx</code></pre>
<hr>
<h3 id="📌-동작-방식">📌 동작 방식</h3>
<ul>
<li><code>app/layout.tsx</code> → 전체 공통 레이아웃</li>
<li><code>dashboard/layout.tsx</code> → dashboard 전용 레이아웃</li>
</ul>
<p>👉 <code>/dashboard</code> 접근 시:</p>
<pre><code class="language-plaintext">Root Layout
  └ Dashboard Layout
       └ Page</code></pre>
<hr>
<h3 id="📌-장점">📌 장점</h3>
<ul>
<li>기능별 UI 분리</li>
<li>유지보수 용이</li>
<li>복잡한 UI 구조 관리 가능</li>
</ul>
<hr>
<h2 id="5-실제-렌더링-흐름-중요">5. 실제 렌더링 흐름 (중요)</h2>
<h3 id="📌-페이지-이동-시-동작">📌 페이지 이동 시 동작</h3>
<p>예: <code>/home</code> → <code>/dashboard</code></p>
<pre><code class="language-plaintext">1. 기존 layout 유지
2. 새로운 page만 교체
3. template이 있다면 새로 렌더링</code></pre>
<hr>
<h3 id="📌-정리">📌 정리</h3>
<ul>
<li>layout → 유지됨</li>
<li>template → 새로 렌더링됨</li>
<li>page → 교체됨</li>
</ul>
<p>👉 이 구조가 Next.js 성능의 핵심</p>
<hr>
<h2 id="6-공통-ui-관리-방식">6. 공통 UI 관리 방식</h2>
<h3 id="📌-기존-방식-page-router">📌 기존 방식 (Page Router)</h3>
<ul>
<li><code>_app.tsx</code>에서 공통 UI 관리</li>
<li>모든 페이지에 동일 적용</li>
</ul>
<hr>
<h3 id="📌-app-router-방식">📌 App Router 방식</h3>
<ul>
<li><code>layout.tsx</code>를 활용하여<br><strong>경로별로 공통 UI를 분리 가능</strong></li>
</ul>
<hr>
<h3 id="📌-예시">📌 예시</h3>
<h4 id="전체-공통-ui">전체 공통 UI</h4>
<pre><code class="language-tsx">// app/layout.tsx
&lt;header&gt;Global Header&lt;/header&gt;</code></pre>
<h4 id="특정-영역-전용-ui">특정 영역 전용 UI</h4>
<pre><code class="language-tsx">// app/dashboard/layout.tsx
&lt;aside&gt;Dashboard Sidebar&lt;/aside&gt;</code></pre>
<hr>
<h3 id="📌-핵심-포인트">📌 핵심 포인트</h3>
<ul>
<li>UI를 &quot;페이지&quot;가 아닌 &quot;구조&quot; 기준으로 설계</li>
<li>필요한 곳에만 레이아웃 적용</li>
<li>불필요한 렌더링 감소</li>
</ul>
<hr>
<h2 id="7-layout-vs-template-언제-사용할까">7. layout vs template 언제 사용할까?</h2>
<h3 id="📌-layouttsx">📌 layout.tsx</h3>
<ul>
<li>Header / Footer</li>
<li>Sidebar</li>
<li>상태 유지가 필요한 UI</li>
<li>공통 구조</li>
</ul>
<hr>
<h3 id="📌-templatetsx">📌 template.tsx</h3>
<ul>
<li>페이지 전환 애니메이션</li>
<li>초기화가 필요한 UI</li>
<li>매번 새로 렌더링이 필요한 경우</li>
</ul>
<hr>
<h2 id="📌-최종-정리">📌 최종 정리</h2>
<ul>
<li><code>layout.tsx</code> → 공통 UI, 상태 유지, 재사용</li>
<li><code>template.tsx</code> → 매번 새로 렌더링, 초기화 목적</li>
<li>Nested Layout → 구조 기반 UI 설계</li>
<li>children → UI 트리 구성 핵심</li>
</ul>
<p>👉 App Router의 핵심은<br><strong>페이지가 아니라 Layout 중심으로 구조를 설계하는 것</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Webpack Dev Server란?]]></title>
            <link>https://velog.io/@do_dam/Webpack-Dev-Server%EB%9E%80</link>
            <guid>https://velog.io/@do_dam/Webpack-Dev-Server%EB%9E%80</guid>
            <pubDate>Wed, 11 Mar 2026 13:39:04 GMT</pubDate>
            <description><![CDATA[<h1 id="webpack-dev-server">Webpack Dev Server</h1>
<p>웹팩 데브 서버(Webpack Dev Server)는 개발 과정에서 사용하는 개발용 서버로,
빌드 대상 파일이 변경되면 매번 웹팩 명령어를 다시 실행하지 않아도
코드 변경 후 저장 시 자동으로 빌드하고 브라우저를 새로고침하여 변경 내용을 바로 확인할 수 있게 해준다.</p>
<hr>
<h2 id="웹팩-데브-서버-실행-명령어">웹팩 데브 서버 실행 명령어</h2>
<p><code>package.json</code>의 scripts에 아래와 같이 작성하여 실행할 수 있다.</p>
<pre><code>{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;webpack serve&quot;,
    &quot;build&quot;: &quot;webpack&quot;
  }
}</code></pre><ul>
<li><code>npm run dev</code>: 웹팩 데브 서버 실행</li>
<li><code>npm run build</code>: 일반 웹팩 빌드 실행</li>
</ul>
<hr>
<h2 id="웹팩-데브-서버의-특징">웹팩 데브 서버의 특징</h2>
<p>웹팩 데브 서버는 일반 웹팩 빌드와 달리 <strong>개발 환경에 최적화된 기능</strong>을 제공한다.</p>
<h3 id="1-빌드-결과물을-메모리에-저장">1. 빌드 결과물을 메모리에 저장</h3>
<ul>
<li>일반 웹팩 빌드 → <code>dist</code> 같은 폴더에 파일 생성</li>
<li>웹팩 데브 서브 → <strong>빌드 결과물을 메모리에 저장</strong></li>
<li>따라서 실제 프로젝트 폴더에서는 빌드 파일을 확인할 수 없다.</li>
</ul>
<h3 id="2-코드-변경-시-자동-빌드">2. 코드 변경 시 자동 빌드</h3>
<ul>
<li>소스 코드를 수정하고 저장하면 변경을 감지하여 <strong>자동으로 다시 빌드</strong>한다.</li>
<li>매번 웹팩 명령어를 실행할 필요가 없다.</li>
</ul>
<h3 id="3-브라우저-자동-새로고침">3. 브라우저 자동 새로고침</h3>
<ul>
<li>빌드가 완료되면 <strong>브라우저가 자동으로 새로고침</strong>되어 변경된 결과를 바로 확인할 수 있다.</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<p>웹팩 데브 서버는 <strong>개발 환경에서 사용하는 도구</strong>로 다음과 같은 특징이 있다.</p>
<ul>
<li>코드 변경 시 자동 빌드</li>
<li>브라우저 자동 새로고침</li>
<li>빌드 결과물을 메모리에 저장</li>
<li>개발 속도 향상</li>
</ul>
<p>따라서 <strong>개발 단계에서는 웹팩 데브 서버를 사용하고</strong>,
개발이 완료되면 <code>webpack</code> 명령어로 <strong>배포용 빌드 파일을 생성한다.</strong></p>
]]></description>
        </item>
    </channel>
</rss>