<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dobby_.log</title>
        <link>https://velog.io/</link>
        <description>성장통을 겪고 있습니다.</description>
        <lastBuildDate>Thu, 05 Mar 2026 05:47:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dobby_.log</title>
            <url>https://velog.velcdn.com/images/dobby_/profile/aea1ae41-c25a-4568-8562-b91ff701192e/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dobby_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dobby_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[next.js 라우팅 지연 유저 피드백 개선]]></title>
            <link>https://velog.io/@dobby_/next.js-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EC%A7%80%EC%97%B0-%EC%9C%A0%EC%A0%80-%ED%94%BC%EB%93%9C%EB%B0%B1-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@dobby_/next.js-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EC%A7%80%EC%97%B0-%EC%9C%A0%EC%A0%80-%ED%94%BC%EB%93%9C%EB%B0%B1-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Thu, 05 Mar 2026 05:47:58 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>개발 환경에서 진행한 성능 최적화로, 운영 환경보다 성능이 떨어질 수 있습니다.</p>
</blockquote>
<h2 id="페이지-라우팅navigation-지연">페이지 라우팅(navigation) 지연</h2>
<p>데모데이로 캠퍼들이 서비스를 사용하며 개선할 사항들에 대해 정리해줬는데, 그 중 하나가 페이지 라우팅이 오래 걸린다는 점이었다.</p>
<h3 id="현재-상황-파악">현재 상황 파악</h3>
<p>우리는 웹소켓 장애 대응을 위해 sentry를 적용한 상태다.
마침 sentry에서 성능에 관련된 대시보드를 제공하기 때문에, 얼마나 지연되는지 평균을 찾아볼 수 있었다.</p>
<p>그 중 한 페이지를 사진으로 찍어왔다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/82e9cd34-3484-4d65-bcb5-e7d9fc4c21e0/image.png" alt=""></p>
<p><code>OPERATION</code> 이라고 적힌 부분에서 어떤 타입에 대한 분석인지를 확인할 수 있다.
나는 라우팅과 관련된 사항을 개선하고자 하므로, <code>navigation</code> 이라고 적힌 부분을 보면 된다.</p>
<p>사진상에는 대부분이 <code>navgation</code> 이라고 적혀있으니 위 사진을 토대로 분석을 해보자.</p>
<ul>
<li><strong>Operation (navigation vs pageload):</strong><ul>
<li><strong>navigation:</strong> 사용자가 앱 내에서 링크를 클릭해 페이지를 이동할 때 걸린 시간(Client-side routing). 
유저 피드백인 &quot;페이지 이동 시 지연&quot;과 직접적으로 관련된 수치</li>
<li><strong>pageload:</strong> 브라우저에 URL을 직접 입력하거나 새로고침했을 때, 전체 리소스를 처음부터 읽어오는 시간</li>
</ul>
</li>
<li><strong>AVG DURATION:</strong> 해당 페이지로 이동할 때 걸린 <strong>평균 시간</strong></li>
<li><strong>P95 DURATION:</strong> 전체 사용자 중 상위 5%(가장 느린 케이스)가 경험한 시간</li>
</ul>
<br />

<ol>
<li><strong>가장 심각한 지점:</strong> <code>/group/:groupId/post/:draftId</code> (pageload)</li>
</ol>
<ul>
<li>평균 <strong>2.48s</strong>, P95는 <strong>4.21s</strong>나 걸리고 있다. 
공동 작성 드래프트 페이지 인데, 초기 로딩 시 무거운 스크립트나 대량의 데이터를 가져오고 있을 가능성이 높다.</li>
</ul>
<ol>
<li><strong>병목 현상이 의심되는 지점:</strong> <code>/group/:groupId</code> (navigation)<ul>
<li>단순 페이지 이동임에도 평균 <strong>810.54ms</strong>, P95는 <strong>2.09s</strong>이다. 
보통 부드러운 웹 경험을 위해 navigation은 <strong>200~300ms</strong> 이내를 지향하는데, 2초 가량 지연된다면 유저는 확실히 답답함을 느낄 가능성이 크다.</li>
</ul>
</li>
</ol>
<p>전체적으로 훑어봐도, navigation에서의 지연이 발생하고 있음을 파악할 수 있다.
이제 이에 대한 원인을 알아보자.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>일단 가장 많은 유저가 접하게 될 루트 경로인 <code>/</code> 을 확인해봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/c491480b-4ba5-4857-b9c3-0e868a37d499/image.png" alt=""></p>
<p>눈에 띄는 구간은 </p>
<ul>
<li>613.99ms이나 걸리는 <code>/</code> 경로 GET 요청</li>
<li>API 요청</li>
<li>next-auth 세션 관리</li>
</ul>
<p>이렇게 3개로 분류할 수 있으며, 불필요한 직렬 요청과 인증 과정이 중복되어 병목이 발생하고 있다.</p>
<p><strong>1. 무거운 인증 및 세션 확인 절차</strong></p>
<p>가장 큰 막대 그래프를 차지하는 부분이 인증 관련 로직이다.</p>
<ul>
<li><code>POST /api/auth/callback/credentials</code> 와 그 아래의 <code>POST /v1/auth/excahnge</code> 가 전체 로딩 시간의 상당 부분을 점유하고 있다.</li>
<li>페이지에 진입할 때마다 세션을 다시 확인하려는 과정이 서버 사이드에서 발생해서 라우팅 지연을 유발하고 있는 것으로 추측된다.</li>
</ul>
<br />

<p><strong>2. 미들웨어 및 서버 사이드 처리 지연</strong></p>
<ul>
<li><code>http.server - GET /</code> 내부에서 페이지 컴포넌트를 해석하고 빌드하는 과정이 약 613.99ms 정도 소요되고 있다.</li>
<li>서버 컴포넌트가 렌더링되기 전에, 필요한 데이터를 모두 가져올 때까지 브라우저가 응답을 받지 못하고 대기하고 있음을 의미한다.</li>
</ul>
<p>2번에서 새로 알게 된 사실이 있다.</p>
<h3 id="nextjs-app-라우터-구조와-서버-컴포넌트-라우팅-지연-관계">next.js app 라우터 구조와 서버 컴포넌트 라우팅 지연 관계</h3>
<p>서버 컴포넌트와 라우팅 지연이 <code>next.js app</code> 라우터 구조에서 깊은 연관이 있다는 것이다.</p>
<p>일반적인 React 프로젝트(CSR)에서는 클릭하면 즉시 페이지가 바뀌게 된다.
그 다음, 페이지 내부에서 API를 호출하게 되기 때문에 데이터 로드에 대한 유저 피드백을 고려하게 된다.</p>
<p>하지만 Next.js 서버 컴포넌트에서는 서버에서 데이터를 다 가져온 뒤에 완성된 결과물인 RSC payload를 브라우저로 보낸다.</p>
<p>즉, 서버 컴포넌트에서의 api 패칭 처리와 같은 데이터 로드가 끝날 때까지 서버는 브라우저에 응답을 주지 않는다.
이때 유저는 클릭했지만 아무 변화가 없으니, 라우팅 자체가 안되고 지연되고 있다고 느끼게 되는 것이다.</p>
<p>서버 컴포넌트에서의 API 처리가 왜 라우팅에까지 영향을 주는것인지 이해가 안됐었는데, 바로 고개를 끄덕이게 됐다.</p>
<p>사실, 모든 서버 컴포넌트 페이지에서 이런식으로 동작하도록 코드를 적어놔서 이 부분만 수정해도 많이 개선될 것 같다.</p>
<h3 id="개선-과정">개선 과정</h3>
<p>일단 각 문제를 어떻게 개선할 수 있을지를 정리해보자.</p>
<p><strong>1. 인증 로직 최적화</strong>
사실, 이미 인증 로직은 한 번 최적화 과정을 거쳤다.</p>
<p><code>next-auth</code>로 세션을 관리하고 api 호출시에 세션이 필요해 api 호출시마다 매번 세션을 조회하도록 로직을 작성했었다.</p>
<p>그랬더니, 모든 api 호출마다 네트워크 탭에서 세션을 조회하는 api가 호출되고 있는걸 확인했다.
그래서 이는 메모리로 캐싱처리해서, 5분 동안은 캐싱된 데이터를 사용하도록 로직을 수정해주었다.</p>
<p>그럼에도 부족한 것으로 파악이 되니, 캐싱 시간을 5분에서 10분으로 늘리는 것으로 타협을 보려고 한다.
우리 서비스는 15분이 백엔드 토큰 만료시간이기 때문에, 이보다는 적은 시간으로 캐시 만료 시간을 정해두는게 안정성 측면에서 좋을 것으로 생각했다.</p>
<p>추가로, 해당 메인 페이지(<code>/</code>)가 로그인 후 세션을 저장하는 로직이 포함되어 있는 페이지와 연관되어 있어서 인증 관련 시간이 많이 소요된 것으로 파악된다.</p>
<br />

<p><strong>2. <code>Suspense</code>를 통한 스트리밍 도입</strong>
서버 컴포넌트에서 hydration 처리만 해주고 <code>suspense</code>를 적용하지 않았다.
그래서 데이터 패칭이 완료될 때까지 페이지 이동 없이 지연되고 있던 문제이다.</p>
<p><code>Suspense</code> 를 사용해서 레이아웃을 먼저 보여주고, 데이터가 필요한 부분만 로딩 상태를 보여주는 방식으로 UX를 개선할 수 있을 것 같다.</p>
<p>또한, 서버 컴포넌트에서 데이터를 클라이언트로 내려주겠다고 <code>queryClient.setQueryData</code> 를 해주고 있다.
tanstack query의 장점을 살리려면 <code>prefetchQuery</code> 를 사용해서 서버에서 미리 캐싱하는게 정석적일 것 같다.</p>
<blockquote>
<p><strong>setQueryData vs prefetchQuery</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th></th>
<th>setQueryData</th>
<th>prefetchQuery</th>
</tr>
</thead>
<tbody><tr>
<td><code>dateUpdatedAt</code> 타임스탬프</td>
<td>없음</td>
<td>올바르게 설정</td>
</tr>
<tr>
<td><code>staleTime</code> 존중</td>
<td>무시</td>
<td>만료 여부 추적</td>
</tr>
<tr>
<td>클라이언트에서 즉시 재요청 가능성</td>
<td>높음</td>
<td>stale 판단 후 결정</td>
</tr>
<tr>
<td>에러 처리</td>
<td>직접 해야 함</td>
<td>내장</td>
</tr>
</tbody></table>
<blockquote>
</blockquote>
<p><code>setQueryData</code> 는 캐시에 데이터를 강제 주입하는거라 Tanstack Query가 이 데이터가 언제 패칭됐는지, 아직 유효한지 알 수 없다.</p>
<pre><code class="language-tsx">const queryClient = new QueryClient();

await queryClient.prefetchQuery({
  queryKey: [&#39;records&#39;, &#39;preview&#39;, selectedDate, &#39;personal&#39;],
  queryFn: () =&gt; getCachedRecordPreviewList(selectedDate),
});</code></pre>
<p>현재 코드는 <code>Page</code> 메인 함수 안에 들어있어서, 데이터가 다 오기 전까지 지금처럼 라우팅이 지연되는 문제가 발생한다.</p>
<p>데이터 패칭 로직을 메인 페이지 밖으로 옮겨서, 데이터가 없어도 일단 페이지 레이아웃부터 보여주도록 수정해야 한다.</p>
<pre><code class="language-tsx">// 현재
export default async function HomePage() {
  // 여기서 await를 해버리면, 데이터가 올 때까지 아래 리턴문(HTML)은 브라우저에 전송되지 않음
  const [data1, data2] = await Promise.all([fetch1, fetch2]); 

  return &lt;Layout&gt;{/* 데이터 사용 */}&lt;/Layout&gt;;
}</code></pre>
<pre><code class="language-tsx">// 수정 방법
export default function HomePage() {
  return (
    &lt;Layout&gt;
      &lt;WeekCalendar /&gt; {/* 얘는 데이터가 필요 없으니 브라우저에 즉시 뜸 */}

      &lt;Suspense fallback={&lt;HomeSkeleton /&gt;}&gt;
        {/* await는 이 안에서 수행됨 */}
        &lt;HomeContent /&gt; 
      &lt;/Suspense&gt;
    &lt;/Layout&gt;
  );
}

// 별도 파일 혹은 같은 파일 아래에 작성
async function HomeContent() {
  // 여기서 await를 하더라도 HomePage는 이미 브라우저에 전달된 상태
  const [data1, data2] = await Promise.all([fetch1, fetch2]); 
  return &lt;RecordList data={data1} /&gt;;
}</code></pre>
<p>이런식으로 서버 컴포넌트 로직을 모두 수정해주자.</p>
<h2 id="메인-페이지-라우팅-지연-성능-문제-performance로-파악">메인 페이지 라우팅 지연 성능 문제 performance로 파악</h2>
<p><img src="https://velog.velcdn.com/images/dobby_/post/ff71804e-dd69-495a-b976-3ec99f77843c/image.png" alt=""></p>
<p>performance 탭에서 녹화 기능을 사용해 라우팅에 얼마나 걸리는지, 혹은 오래 걸리는 작업은 없는지를 파악하고 있었는데
프로필 페이지에서 메인 페이지로 이동하니까 이동하기 직전에 저렇게 long task가 길게 잡혔다.</p>
<p>앞의 빨간 Long task는 프로파일링할 때의 오버헤드라서 제외했다.</p>
<p>Call tree를 분석해보면:</p>
<ul>
<li><code>Run microtasks</code> → <code>handleResult</code> → <code>dispatchSetState</code> → React 렌더링 작업</li>
<li>대부분이 <strong>React의 state 업데이트와 커밋 작업</strong>에 소비됨</li>
</ul>
<p>이건 <strong>메인 페이지로 라우팅할 때 React가 새 페이지를 렌더링하면서 메인 스레드를 블로킹</strong>하는 문제이다.</p>
<h3 id="주요-문제">주요 문제</h3>
<img width="40%" src="https://velog.velcdn.com/images/dobby_/post/7b059042-23f0-4e36-b6b3-d134ab2053ce/image.png"/>

<blockquote>
<p>아래 파일들은 모두 위 사진인 메인 페이지(<code>/</code>)에서 사용하는 컴포넌트들이다.</p>
</blockquote>
<h3 id="recordlist---블록-레이아웃-계산"><strong>RecordList - 블록 레이아웃 계산</strong></h3>
<p><code>RecordList.tsx</code>를 보면</p>
<pre><code class="language-tsx">{(() =&gt; {
  // 블록을 row별로 그룹화
  const rowMap = new Map&lt;number, Block[]&gt;();
  record.blocks.forEach((block) =&gt; { ... });

  const sortedRows = Array.from(rowMap.entries()).sort(...);

  return sortedRows.map(([rowNumber, blocks]) =&gt; {
    const sortedBlocks = blocks.sort(...);
    const hasFullWidth = sortedBlocks.some(...);
    // ...
  });
})()}</code></pre>
<p><strong>문제</strong>: 각 기록마다 <strong>매번 새로운 Map 생성 → 정렬 → 필터링</strong>을 반복한다.
기록이 10개면 이 작업이 10번 반복되고, <strong>메인 스레드를 블로킹</strong>하게 된다.</p>
<h3 id="weekcalendar---초기-렌더링-시-routerreplace"><strong>WeekCalendar - 초기 렌더링 시 router.replace</strong></h3>
<pre><code class="language-tsx">// WeekCalendar.tsx
useEffect(() =&gt; {
  if (!dateParam) {
    const today = formatDateISO();
    router.replace(`/?date=${today}`);
  }
}, [dateParam, router]);</code></pre>
<p><strong>문제</strong>: 페이지 로드 시 URL에 <code>date</code> 파라미터가 없으면 <code>router.replace</code>를 호출해서 <strong>추가 렌더링</strong>이 발생한다.</p>
<h3 id="framer-motion의-애니메이션"><strong>framer-motion의 애니메이션</strong></h3>
<p>주간 달력을 표현하는 컴포넌트에서의 문제인데,</p>
<p><code>AnimatePresence</code> + <code>motion.div</code> + <code>drag</code> 기능이 초기 렌더링 시 많은 이벤트 리스너를 등록한다.</p>
<h2 id="문제-사항-최적화-적용">문제 사항 최적화 적용</h2>
<h3 id="recordlist-블록-레이아웃-계산-최적화">RecordList 블록 레이아웃 계산 최적화</h3>
<p>블록 정렬 로직을 별도 함수로 분리하고 <code>useMemo</code>로 감싼다.</p>
<pre><code class="language-tsx">// 각 기록의 블록을 useMemo로 최적화
const sortedRowsMap = useMemo(() =&gt; {
  return records.map(record =&gt; {
    const rowMap = new Map&lt;number, Block[]&gt;();
    record.blocks.forEach((block) =&gt; {
      const row = block.layout.row;
      if (!rowMap.has(row)) rowMap.set(row, []);
      rowMap.get(row)!.push(block);
    });

    return Array.from(rowMap.entries()).sort(([a], [b]) =&gt; a - b);
  });
}, [records]);</code></pre>
<ul>
<li>각 기록을 <code>RecordItem</code> 컴포넌트로 분리</li>
<li>블록 정렬 로직을 <code>useMemo</code>로 감싸서 불필요한 재계산 방지</li>
<li><code>React.memo</code>로 컴포넌트 메모이제이션</li>
</ul>
<p><strong>효과</strong>: 이전에는 각 기록마다 매번 Map 생성 → 정렬을 반복했지만, 이제는 블록이 변경될 때만 계산</p>
<h3 id="서버에서-날짜-파라미터-기본값-설정-및-초기-날짜-파라미터-설정-제거">서버에서 날짜 파라미터 기본값 설정 및 초기 날짜 파라미터 설정 제거</h3>
<p>서버 컴포넌트가 이미 날짜를 설정하고 있으니, WeekCalendar의 <code>useEffect</code>를 제거한다.
또한, 초기엔 날짜 파라미터가 url 상에 없으니 굳이 억지로 추가해 서버 컴포넌트가 두 번 실행되지 않도록 한다.</p>
<ul>
<li>클라이언트 <code>useEffect</code>에서 <code>router.replace</code> 제거</li>
<li>서버 컴포넌트에서의 <code>redirect</code> 제거</li>
</ul>
<p><strong>효과</strong>: 초기 렌더링 시 불필요한 추가 렌더링 제거</p>
<h3 id="blockcontent를-reactmemo로-감싸기">BlockContent를 React.memo로 감싸기</h3>
<p><code>RecordList.tsx</code>의 <code>BlockContent</code>를 memo 처리하면 불필요한 리렌더링을 방지할 수 있다.</p>
<ul>
<li><code>BlockContent</code>와 <code>ImageBlock</code> 컴포넌트를 <code>React.memo</code>로 감싸기</li>
<li>props가 변경되지 않으면 리렌더링 스킵</li>
</ul>
<p><strong>효과</strong>: 블록 개수만큼 리렌더링 절감</p>
<h3 id="최적화-후-결과">최적화 후 결과</h3>
<p><img src="https://velog.velcdn.com/images/dobby_/post/ba658c3c-b6fe-4ceb-8283-1004862f3e63/image.png" alt=""></p>
<p>175.7ms → 134.9ms로 Run microtasks 활동에 대한 작업 시간을 줄였다.</p>
<h2 id="프로필-페이지-성능-최적화">프로필 페이지 성능 최적화</h2>
<p>메인 페이지에서 프로필 페이지로 이동할 때도 performance를 돌려봤다.</p>
<blockquote>
<p>아래 사진은 프로필 페이지인 마이페이지다.</p>
</blockquote>
<img width="40%" src="https://velog.velcdn.com/images/dobby_/post/980ab889-858d-42fd-b3b1-f26fba1326fa/image.png"/>

<p><img src="https://velog.velcdn.com/images/dobby_/post/f28f15f5-3503-4dbe-a408-9b4f8387fbcd/image.png" alt=""></p>
<p>Timer fired는 Effect 실행 시간이다.</p>
<p>프로필 페이지에선 통계 자료를 보여주도록 되어 있는데, 이에 대한 <code>useEffect</code> 실행에 의한 것이라 판단했다.</p>
<pre><code class="language-tsx">&lt;div className={cn(
  &#39;overflow-hidden transition-all duration-300 ease-in-out&#39;,
  isChartVisible ? &#39;max-h-300 opacity-100&#39; : &#39;max-h-0 opacity-0&#39;,
)}&gt;
  &lt;MonthlyUsageChart /&gt;  // 항상 렌더링됨
  &lt;PlaceDashboard /&gt;
  &lt;EmotionDashboard /&gt;
&lt;/div&gt;</code></pre>
<p><strong>문제</strong>: <code>display: none</code>이 아니라 <code>max-h-0</code>로 숨기고 있어서, <strong>차트가 항상 렌더링</strong>되고 있다.</p>
<p>즉, CSS로만 숨기고 있어서 차트가 <strong>항상 렌더링</strong>되고, <strong><code>useEffect</code> 등이 모두 실행되는 문제이다.</strong></p>
<p>이를 필요할 때만 렌더링되도록 해 불필요한 <code>useEffect</code> 실행을 막아주자</p>
<pre><code class="language-tsx">{isChartVisible &amp;&amp; (  // 필요할 때만 렌더링
  &lt;div className=&quot;...&quot;&gt;
    &lt;MonthlyUsageChart /&gt;
    &lt;PlaceDashboard /&gt;
    &lt;EmotionDashboard /&gt;
  &lt;/div&gt;
)}</code></pre>
<p>결과적으론, 해당 문제에 대한 long task가 사라졌다.</p>
<h2 id="그-외-최적화한-사항">그 외 최적화한 사항</h2>
<h3 id="함께-기록함에서-기록-추가-버튼-클릭-시-지연">함께 기록함에서 기록 추가 버튼 클릭 시 지연</h3>
<p>함께 기록함 페이지에서, 하단 네비게이션바의 <code>+</code> 버튼을 클릭하면 어떤 그룹에 기록을 추가할 것인지 묻는 drawer가 표시된다.</p>
<img width="40%" src="https://velog.velcdn.com/images/dobby_/post/c76ac581-be8d-43ec-a004-4a685663bb98/image.png" />

<p>그런데 이 drawer가 표시되는 데 시간이 꽤 걸리길래, performance로 측정해봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/06d4ebaf-8c82-4e51-9f02-20014dbd9212/image.png" alt=""></p>
<p>클릭 이벤트가 처리되는 데 시간이 꽤 걸리는 것으로 보인다.</p>
<p><code>/shared</code> 페이지에서 <code>+</code> 버튼 클릭 시 <code>GroupSelectDrawer</code>가 열리는데, 이때 groups 데이터를 <code>enabled: isGroupSelectOpen</code>으로 조건부로만 패치하고 있어서 지연이 발생한다.</p>
<p>해결 방법: <code>/shared</code> 페이지에서는 drawer가 열리기 전에 미리 데이터를 prefetch하도록 수정</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/72c8ecc3-1681-4787-b44b-6258d1b15bbb/image.png" alt=""></p>
<p>데이터 패치는 <code>/shared</code> 페이지에서 불러오는 데이터와 동일하기 때문에, 캐시된 데이터를 사용하게 된다.</p>
<p>그렇기에 빠르게 데이터를 불러올 수 있게 되에 위 사진처럼 <strong>265.6ms → 102.0ms</strong>로 이벤트 처리 시간을 줄일 수 있었다.</p>
<p>수정 전 사진과 조금 형태가 다른데, 캐시된 데이터를 사용하게 되어서 빨리 뜰까봐 페이지 이동과 동시에 바로 drawer가 뜨도록 인터렉션을 걸어봤다.</p>
<h3 id="라우팅-지연이-심했던-groupgroupid-경로-개선">라우팅 지연이 심했던 <code>/group/:groupId</code> 경로 개선</h3>
<p>sentry에서 확인했을 때, draft 페이지 다음으로 지연이 심했던 페이지가 특정 그룹 내부 페이지로 이동할 때였다.</p>
<p>그래서 함께 기록함에서 그룹 페이지로 이동할 때의 performance를 측정해봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/f46c1f4e-cebd-485e-ac9e-5c6a516c16d8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/971fb208-9106-451a-a397-db11f60c42c5/image.png" alt=""></p>
<p><code>Suspense</code> 를 적용하기 전보다는 라우팅 지연이 개선되긴 했지만, 그럼에도 지연되는 부분이 존재했다.</p>
<p>페이지를 이동한 뒤에는 <code>Timer fired</code> 문제가, 이동 전 함께 기록함에서는 <code>forced reflow</code> 문제가 발생하는 것으로 파악이 된다.</p>
<p>코드를 확인한 결과,</p>
<ol>
<li><strong>Forced reflow</strong>: <code>/shared</code> 페이지의 <code>RecordCard</code>들이 <code>memo</code> 없이 매번 재렌더링되며, 각 카드의 <code>AssetImage</code>가 layout recalculation을 유발</li>
<li><strong>Timer fired:</strong> 그룹 페이지의 <code>MonthRecords</code>도 동일한 문제</li>
</ol>
<p>여기서 Timer fired를 조금 더 자세히 적자면,</p>
<ol>
<li><strong>그룹 페이지로 라우팅 후</strong> React가 다음 작업을 예약<ul>
<li>새 컴포넌트 마운트</li>
<li><code>useEffect</code> 훅 실행</li>
<li>상태 업데이트 처리</li>
<li>렌더링</li>
</ul>
</li>
<li><strong>많은 컴포넌트들이 동시에 렌더링</strong>되면서<ul>
<li><code>MonthRecords</code> 컴포넌트의 여러 <code>RecordCard</code>들</li>
<li>각 카드의 <code>AssetImage</code> 로딩</li>
<li><code>GalleryDrawer</code> (항상 렌더링되고 있었음)</li>
</ul>
</li>
</ol>
<p>즉, 리렌더링 문제로 파악을 해서 주요 컴포넌트들을 <code>React.memo</code>로 최적화를 진행했다.</p>
<p>그리고 <code>memo</code>를 적용해줘도 props가 자꾸 바뀌면 memo가 작동하지 않기 때문에, 이 부분도 같이 확인해줬다. (props 인라인 함수)</p>
<p>그래서 불필요하게 바뀌는 props도 callback으로 최적화를 시켜줬다. </p>
<p>결과는 다음 사진들과 같다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/972155d3-7048-4d18-b121-89013c12d8cf/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/fc09bacf-82f8-4ee7-ad4d-a106f6cd898e/image.png" alt=""></p>
<p>Timer fired는 <strong>163.3ms → 65.8ms</strong>로, forced reflow와 연관된 작업은 <strong>375.7ms → 171.3ms</strong>로 개선됐다.</p>
<p>그리고 직접 라우팅을 할 때도 개선하기 전보다 이동이 빨라진게 느껴졌다.</p>
<h3 id="그룹-프로필-수정-페이지-지연">그룹 프로필 수정 페이지 지연</h3>
<p>그룹 프로필 수정 페이지에 개인 프로필을 설정할 수 있는 수정 페이지가 존재한다.
해당 페이지로 라우팅할 때, 지연이 발생하는 것을 확인해서 이 부분도 같이 측정해봤다.</p>
<p>Call tree 보단 Bottom-up이 원인을 파악하기에 더 수월한 것 같아서 사진을 바꿔봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/236bcc32-266b-4c3d-a11b-ad057d0ca456/image.png" alt=""></p>
<p>코드를 확인했을 때, 매번 인라인 함수로 멤버 리스트를 생성하고 있어서 리렌더링 문제가 발생한걸로 추측되었다.</p>
<p>그래서 이 인라인 함수들을 모두 분리해주는 등 최적화 작업을 진행해줬다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/87f8d81b-67d9-4ef7-8ef2-532b71b8da30/image.png" alt=""></p>
<p>이전/이후 전체 시간을 찍고 내부 시간들을 찍어야 하는데 까먹는다..</p>
<p>가장 눈에 띄었던 Layout 작업이 <strong>143.1ms → 101.6ms</strong>로 개선된 것을 확인할 수 있었다.</p>
<h3 id="작성-페이지-라우팅-지연">작성 페이지 라우팅 지연</h3>
<p>가장 지연이 심했던 작성 페이지를 개선해볼까 한다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/777a0f31-99b4-44b4-816a-2a38341dce2e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/4dcfeca7-2dfc-4be3-8db4-ad9560349947/image.png" alt=""></p>
<p>Layout과 Recalculate style에 지연이 발생하고 있음을 파악할 수 있다.
여기도 비슷하게 인라인 함수를 많이 사용하고 있는걸 코드상에서 파악을 해서 똑같이 수정해줬다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/ce6bc9a3-d4af-4bac-934a-fd0b356305da/image.png" alt=""></p>
<p>long task가 사라졌다…</p>
<p>인라인 함수 수정과 메모이제이션만 적용해줬는데 개선이 된걸 보고, 컴포넌트에 넘겨줄 때 인라인 함수가 얼마나 안좋은지 알게 된 것 같다.</p>
<p>똑같은 Layout은 <strong>173.9ms → 31.7ms</strong>, Recalculate style은 <strong>14.4ms → 7.7ms</strong>로 줄었다.</p>
<p>그 외 다른 것들도 조금씩 감소된걸 확인할 수 있다.</p>
<h2 id="마무리">마무리</h2>
<p>라우팅 지연 최적화 작업은 사실 2주? 정도 된 작업이다.
다른 최적화 작업을 진행하느라 정리를 제대로 못해서 한 번에 정리해서 적어봤다.</p>
<p>애초에 작업하면서 찍어놓고 정리한 것도 이해하면서 읽기 편한 상태가 아니었어서 정리를 한 뒤에도 조금 가독성이 떨어지는 것 같다.</p>
<p>이번 작업을 하면서 깨달은 것은, 인라인 함수는 리렌더링이 될 때 새로 생성되기 때문에 props로 해당 함수를 전달받는 컴포넌트가 매번 리렌더링 된다는 것이다.</p>
<p>그렇기에 인라인 함수로 자식 컴포넌트에게 전달하기 보단, <code>useCallback</code>으로 감싸거나 인라인이 아닌 함수화해서 해당 함수를 전달하는 방식을 선택해야 한다는 것이다.</p>
<p>그리고 컴포넌트를 렌더링하는 영역인 <code>return</code> 함수 안에는 계산 로직이 들어가지 않도록 하는게 마찬가지로 리렌더링 이슈 개선에 좋다는 것도 알게 된 것 같다.</p>
<p>이 최적화 작업에 대한 PR이 이미 머지된 상태인데, 아직 이전과 비교할만한 아직 데이터가 쌓이지 않아서 전후 비교를 하지 못하고 있다.</p>
<p>스토어 출시를 목표로 하고 있으니, 이후에 쌓인 데이터로 전후 비교를 할 수 있지 않을까 싶다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[API 중복 호출 최적화 및 fetch 타임아웃 처리]]></title>
            <link>https://velog.io/@dobby_/API-%EC%A4%91%EB%B3%B5-%ED%98%B8%EC%B6%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%8F-fetch-%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@dobby_/API-%EC%A4%91%EB%B3%B5-%ED%98%B8%EC%B6%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%8F-fetch-%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Wed, 04 Mar 2026 05:58:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>mov -&gt; gif를 했는데, 영상이 느리게 실행됩니다.</p>
</blockquote>
<h2 id="api-중복-호출-문제">API 중복 호출 문제</h2>
<p><img src="https://velog.velcdn.com/images/dobby_/post/569cf594-dcee-4718-aa62-859e1692419f/image.gif" alt=""></p>
<p>영상처럼 버튼을 여러 번 연속해서 클릭할 때, 클릭한 횟수만큼 api 요청이 가는 문제가 있음을 발견했다.</p>
<p>그래서 중복되는 데이터가 생기게 되며, 이로 인해 유저에게 혼란을 줄 수 있다.</p>
<p>get 요청의 경우 react query key에 맞춰 캐시되도록 했기 때문에, get을 제외한 메소드에 대한 api 중복 호출 문제를 해결하기로 했다.</p>
<h3 id="해결-방법-탐색">해결 방법 탐색</h3>
<p>API 중복 호출 문제를 해결하기 위한 방법은 세 가지 정도가 있다.</p>
<ol>
<li>debounce</li>
<li>throttling</li>
<li>tanstack query isPending 활용</li>
</ol>
<p>처음에는 1, 2번 중에서 선택하려고 했다.
POST나 DELETE와 같은 mutate 요청의 경우 API 응답이 왔다고 해도 그 응답에 맞는 처리를 하기까지 시간이 소요될 수 있다.</p>
<p>예를 들어 정상 응답을 받은 후에 페이지 이동이 필요하다면 라우팅을 준비하는 시간이 소요될 수 있다.
그 시간동안은 기존의 페이지에 머물게 되는 것이다.</p>
<p>그러면 유저 입장에선 API를 재호출할 수 있는 환경이 마련되면서 중복된 요청이 발생하게 될 것이라고 생각했다.</p>
<p>하지만 무턱대고 나 혼자만의 생각으로 바로 적용하기 보단, 조금 더 찾아보고 나은 선택을 하는게 좋을 것 같았다.</p>
<p>그렇게 아래의 블로그를 발견했다.
<a href="https://happysisyphe.tistory.com/72">React에서 중복호출(aka. 따닥)을 막는 완벽한 방법</a></p>
<p>블로그에서 말하는 API 중복 호출 중 debounce에 대한 내용은 다음과 같다.</p>
<blockquote>
<p>아래는 블로그 내용</p>
</blockquote>
<p>debounce 를 활용한 방식은 해피 케이스에는 문제가 없을 것입니다. 
여기서 고민이 되는 부분은 waitMS 를 얼마로 설정할 것이냐 입니다. 
API 는 보통 1초 안에 끝나니까 1초로? 여유롭게 3초로? 이런 직관으로 정할 순 없겠죠. 
API latency는 서버, DB 상황에 따라서 언제나 달라질 수 있습니다. 
이를 고정한다는 것은 엄밀하지 않은 사고입니다.</p>
<p>debounce 를 사용하면, 2가지 케이스가 발생한다는 것을 알 수 있습니다.</p>
<ul>
<li>api latency &lt; debounce wait</li>
<li>api latency &gt; debounce wait</li>
</ul>
<p>1번 케이스부터 보겠습니다. api가 빠르게 응답이 온다면, 보통의 경우에 큰 문제가 없습니다. 문제가 없는 경우는, API 가 성공했을 때입니다. 
API 가 성공해서 다음 유저 플로우를 타게 된다면, button disabled 시간(debounce wait - api latency)이 존재해도 문제가 되지 않습니다.</p>
<p>API 가 실패하면 어떻게 될까요? 
일정 시간(debounce wait - api latency) 만큼, 사용자는 버튼을 다시 누르지 못 합니다. 그러므로 중복호출을 막기 위한 목적으로 함부로 debounce wait 를 길게 해서는 안 됩니다.</p>
<p>2번 케이스를 보겠습니다. 
api latency 가 더 길다면, 정말로 문제 입니다. 
debounce wait 가 끝난다면, 다시 clickable 한 상태가 되고, 서버에 중복호출을 할 수 있습니다. 
이때는 서버에서 중복호출을 막고 있길 기도해야겠지요.</p>
<p>정리하자면, <strong>api latency 과 debounce wait 가 차이가 있기 때문에, debounce로 완벽한 중복호출을 막는 것은 본질적으로 불가능하다는 것입니다.</strong> throttle 도 마찬가지 논리이므로 생략합니다.</p>
<p>그럼 debounce, throttle 의 목적은 무엇일까요? 이들은 <strong>“중복호출”을 막기 위함이 아니라, “과도한 호출” 을 막기 위함입니다.</strong> 검색, 광클이 가능한 버튼 (게임 아이템 주기 등), 스크롤 이벤트 제어 등에 쓰입니다. 이를 중복 호출 방지에 쓰는 것은 적절하지 않습니다.</p>
<hr>
<p>이 내용을 읽고 너무나도 맞는 말이라 설득을 당해버렸다.
<code>debounce</code>와 <code>throttle</code>은 정해둔 시간이 적절하지 않으면 오히려 UX에 악영향을 줄 수 있다.</p>
<p>그래서 다른 방법을 선택하려고 했는데, 블로그에서는 <code>useRef</code>를 활용한 <code>isLoading</code> 관리 방법으로 API 중복 호출을 막도록 했다.</p>
<p>하지만 나는 공통 유틸 함수를 사용하고 있고, 이를 각 API마다 하나 하나 적용하기란 불필요한 비용이 소모될 것이라고 생각했다.</p>
<p>그래서 비슷한 방법인 공통 유틸 함수(mutate)에서 <code>isPending</code>을 활용해 API 중복 호출을 막도록 하고자 했다.</p>
<h3 id="api-중복-호출-제거하기">API 중복 호출 제거하기</h3>
<p>이를 위한 방법은 간단하다.</p>
<p>tanstack query의 <code>mutate</code>를 사용하는 곳에서 <code>isPending</code> 상태일 경우에는 api를 호출하지 않도록 하는 것이다.</p>
<p>우리 팀은 코드 일관성과 편의를 위해 공통된 곳에서 <code>mutate</code>를 호출해 사용하고 있다.</p>
<pre><code class="language-tsx">// useApi.ts
/**
 * POST 요청을 위한 훅
 * @example
 * const { mutate, isPending } = useApiPost(&#39;/users&#39;, {
 *   onSuccess: (data) =&gt; console.log(data),
 * });
 * mutate({ name: &#39;John&#39; });
 */
export function useApiPost&lt;
  TData = unknown,
  TVariables = Record&lt;string, unknown&gt;,
&gt;(
  endpoint: string,
  options?: UseApiMutationOptions&lt;TData, TVariables&gt; &amp; {
    invalidateKeys?: QueryKey[];
  },
  sendCookie?: boolean,
  headers?: Record&lt;string, string&gt;,
) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (variables: TVariables) =&gt; {
      const response = await post&lt;TData&gt;(
        endpoint,
        variables as Record&lt;string, unknown&gt;,
        { headers },
        sendCookie,
      );

      // 에러 응답 처리 (토큰 재발급은 fetchApi에서 자동 처리됨)
      if (!response.success) {
        throw createApiError(response);
      }

      return response;
    },
    onSuccess: (data, variables, context, mutationContext) =&gt; {
      if (options?.invalidateKeys) {
        options.invalidateKeys.forEach((key) =&gt; {
          queryClient.invalidateQueries({ queryKey: key });
        });
      }
      options?.onSuccess?.(data, variables, context, mutationContext);
    },
    ...options,
  });
}</code></pre>
<p>위와 같이 각 HTTP 메소드별로 유틸함수를 만들어 이를 호출해 사용하고 있다.
그러니 이 공통 유틸 함수만 수정하면 다른 api 로직에 모두 적용이 된다.</p>
<p>여기에 <code>isPending</code> 동안 <code>mutate</code> 호출을 무시하는 가드를 추가하자.</p>
<h3 id="mutate-호출-무시-가드-추가하기">mutate 호출 무시 가드 추가하기</h3>
<pre><code class="language-tsx">// useApi.ts
/**
 * isPending 중 중복 호출을 방지하는 mutate 가드
 * 버튼 더블 클릭 등으로 인한 중복 API 요청 방지
 */
function withPendingGuard&lt;TData, TError, TVariables, TContext&gt;(
  mutation: UseMutationResult&lt;TData, TError, TVariables, TContext&gt;,
) {
  return {
    ...mutation,
    mutate: (...args: Parameters&lt;typeof mutation.mutate&gt;) =&gt; {
      if (mutation.isPending) return;
      mutation.mutate(...args);
    },
  };
}</code></pre>
<p>이렇게 <code>mutation.isPending</code> 이 <code>true</code> 일 경우 <code>return</code> 해주어 실행되지 않도록 해주었다.</p>
<p>그리고 이를 다음처럼 감싸주면 된다.</p>
<pre><code class="language-tsx">// useApi.ts
return withPendingGuard(
    useMutation({
            ...</code></pre>
<p>이렇게만 적용하고 다시 테스트해봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/1b2700d0-a681-4ab8-b3dd-fd3b9e14749d/image.gif" alt=""></p>
<p>api 요청의 응답은 하나만 전달되어 데이터가 하나만 생성된 것을 확인할 수 있다.
생성 요청 api인 <code>post</code> 도 하나만 전달되었다.</p>
<p>그런데 눈에 띄는 요청이 있었다.
바로 <code>add</code> 라는 이름의 post 요청인데, 이것도 중복 호출되는 것을 막고자 했다.</p>
<p>먼저, 왜 중복 호출되는지에 대해 알아봤다.</p>
<h3 id="스크립트-중복-요청">스크립트 중복 요청</h3>
<p>분석 결과, 두 개의 Server Action이 동시에 호출되고 있었다.</p>
<pre><code class="language-tsx">// useCreateRecord.ts
const invalidateQuery = async (groupId?: string) =&gt; {
  await Promise.all([refreshRecordData(), refreshHomeData()]); // ← 2개 동시 호출
  ...
};</code></pre>
<p>이는 next.js의 server action 동작 방식 때문이다.</p>
<p>server action은 현재 페이지 url로 post 요청을 보낸다.
그러니 <code>/add</code> 페이지에서 server action을 호출하면 네트워크에서 <code>POST /add</code> 로 표시된다.</p>
<p><code>Promise.all</code> 로 server action을 동시에 호출하면</p>
<pre><code class="language-tsx">POST /add  ← refreshRecordData() Server Action
POST /add  ← refreshHomeData()  Server Action</code></pre>
<p>두 개의 다른 server action이지만 url이 같아서 네트워크 탭에 <code>add</code> 요청이 2번 나타나게 되는 것이다.</p>
<p>실제로는 next action 헤더로 구분되는 서로 다른 요청이다.
문제는 아니지만, 불필요한 이중 요청이라고 생각했다.</p>
<p>위 두 server action은 페이지 단위로 캐시를 지우고자 사용했다.</p>
<pre><code class="language-tsx">// revalidate.ts
&#39;use server&#39;;

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

export async function refreshHomeData() {
  revalidatePath(&#39;/&#39;, &#39;page&#39;);
}

export async function refreshRecordData() {
  revalidatePath(&#39;/my&#39;, &#39;layout&#39;);
}

export async function refreshGroupData(groupId: string) {
  revalidatePath(`/group/${groupId}`, &#39;layout&#39;);
}

export async function refreshSharedData() {
  revalidatePath(&#39;/shared&#39;);
}
</code></pre>
<p>이렇게 서버 단에서 캐시를 지우게 할 수도 있기에, 나는 빠르면서 한 번에 캐시를 무효화하기 위한 방법으로 사용하고 있었다.</p>
<p>이를 호출하는 server action들을 하나로 합치면 POST 요청이 1번으로 줄어들게 된다.</p>
<pre><code class="language-tsx">export async function refreshRecordAndHomeData() {
  revalidatePath(&#39;/&#39;, &#39;page&#39;);
  revalidatePath(&#39;/my&#39;, &#39;layout&#39;);
}</code></pre>
<p>두 <code>revalidatePath</code> 를 호출하는 함수를 추가하고, 이를 사용하도록 수정해줬다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/e903c1d7-1f60-4632-b9ae-af9301889f24/image.gif" alt=""></p>
<p>이전의 <code>add POST</code> 중복 호출이 사라진 것을 볼 수 있다.</p>
<h2 id="fetch-timeout-걸기">fetch timeout 걸기</h2>
<p>네트워크 환경이 불안정한 유저의 경우 요청이 무한정 대기 상태에 빠져 UI가 프리징되는 현상이 발생할 수 있다.</p>
<p>따라서 API 호출 최적화와 네트워크 타임아웃 전략을 도입해 애플리케이션의 견고함을 높이는 작업을 추가로 해주고자 했다.</p>
<p>우리 팀은 HTTP 메소드에 대한 유틸 함수를 따로 둬서 사용하고 있다.
공통된 유틸 함수만을 사용해서 코드 일관성을 유지하기 위함이다.</p>
<p>그래서 해당 유틸 함수에 타임아웃에 대한 로직을 추가해줬다.</p>
<pre><code class="language-tsx">// api.ts
async function fetchWithRetry&lt;T&gt;(
  url: string,
  fetchOptions: RequestInit,
  attempt: number,
  maxRetries: number,
  retryDelay: number,
  skipAuth: boolean,
  timeout: number,
) {
  const controller = timeout &gt; 0 ? new AbortController() : undefined;
  const timeoutId = controller
    ? setTimeout(() =&gt; controller.abort(), timeout)
    : undefined;

  try {
    const response = await fetch(url, {
      ...fetchOptions,
      signal: controller?.signal,
    });

    clearTimeout(timeoutId);
    ...</code></pre>
<p>이렇게 타임아웃을 위해 <code>AbortController</code> 를 사용하도록 수정해줬다.</p>
<p>그리고 <code>error</code> 가 발생한다면 타임아웃에 대한 에러인지를 판단해주는 코드도 추가해준다.</p>
<pre><code class="language-tsx">  // api.ts
  ...
  } catch (error) {
    clearTimeout(timeoutId);

    const err = error instanceof Error ? error : new Error(&#39;unknown error&#39;);

    // 타임아웃(AbortError) - 재시도 없이 즉시 반환
    if (err.name === &#39;AbortError&#39;) {
      return {
        success: false,
        data: null,
        error: {
          code: &#39;TIMEOUT&#39;,
          message: &#39;요청 시간이 초과되었습니다.&#39;,
          details: {},
        },
      };
    }

    ...</code></pre>
<blockquote>
<p><a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">AbortController</a> :
하나 이상의 웹 요청을 취소할 수 있게 해주는 인터페이스
<code>AbortController.abort()</code>로 DOM 요청이 완료되기 전에 취소한다.
이를 통해 fetch 요청, 모든 응답 Body 소비, 스트림을 취소할 수 있다.</p>
</blockquote>
<p>fetch 요청을 시작할 때, 요청의 옵션 객체 내부에 <code>AbortSignal</code> 옵션(<code>{signal}</code>)을 전달한다.
신호와 컨트롤러를 fetch 요청과 관계짓고, <code>AbortController.abort()</code> 를 호출해 이를 취소할 수 있게 한다.</p>
<blockquote>
</blockquote>
<p><code>AbortController</code> 에 대한 공식문서 예제는 다음과 같다.</p>
<pre><code class="language-tsx">var controller = new AbortController();
var signal = controller.signal;

var downloadBtn = document.querySelector(&#39;.download&#39;);
var abortBtn = document.querySelector(&#39;.abort&#39;);

downloadBtn.addEventListener(&#39;click&#39;, fetchVideo);

abortBtn.addEventListener(&#39;click&#39;, function() {
  controller.abort();
  console.log(&#39;Download aborted&#39;);
});

function fetchVideo() {
  ...
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
    reports.textContent = &#39;Download error: &#39; + e.message;
  })
}</code></pre>
<p>모든 브라우저에서 지원하기에 호환성도 좋다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/b8bafd13-b2f1-46d1-b383-0e68ac370d34/image.png" alt=""></p>
<p>그리고 그 유틸 함수를 가지고 tanstack query를 사용하고 있기 때문에, tanstack query의 전역 에러 핸들러에서 이 타임아웃 작업을 진행해주면 될 것이라 생각했다.</p>
<pre><code class="language-tsx">// provider.tsx
export default function Providers({ children }: { children: React.ReactNode }) {
  // 렌더마다 새 QueryClient 생성 방지 (중요)
  const [queryClient] = useState(
    () =&gt;
      new QueryClient({
        queryCache: new QueryCache({
          onError: (error, query) =&gt; {
            if (query.meta?.silent) return;
            if ((error as ApiError)?.code === &#39;TIMEOUT&#39;) {
              toast.error(&#39;요청 시간이 초과되었습니다.&#39;, {
                description: &#39;네트워크 연결을 확인하고 다시 시도해 주세요.&#39;,
              });
              return;
            }
            const message = getErrorMessage(error);
            toast.error(message);
          },
        }),
        mutationCache: new MutationCache({
          onError: (error, variables, context, mutation) =&gt; {
            if (mutation.meta?.silent) return;
            if ((error as ApiError)?.code === &#39;TIMEOUT&#39;) {
              toast.error(&#39;요청 시간이 초과되었습니다.&#39;, {
                description: &#39;네트워크 연결을 확인하고 다시 시도해 주세요.&#39;,
              });
              return;
            }
            const message = getErrorMessage(error);
            toast.error(message);
          },
        }),
        defaultOptions: {</code></pre>
<p>현재는 10초로 타임아웃 시간을 지정해놨는데, 실제 fetch 요청 시간에 맞춰서 천천히 수정해야할 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[nextjs 이미지 로드 최적화]]></title>
            <link>https://velog.io/@dobby_/nextjs-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A1%9C%EB%93%9C-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@dobby_/nextjs-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A1%9C%EB%93%9C-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Tue, 03 Mar 2026 04:57:21 GMT</pubDate>
            <description><![CDATA[<h2 id="최적화를-진행하기-전-배경과-문제상황">최적화를 진행하기 전, 배경과 문제상황</h2>
<p>이미지를 불러올 때 다음의 과정을 통해 화면에 출력되게 된다.</p>
<ol>
<li>서버로부터 이미지 id 가져오기 (1 fetch)</li>
<li>이미지 id로 스토리지로부터 이미지 경로 가져오기 (1 fetch)</li>
<li>브라우저가 이미지 경로로 다운받기</li>
<li>화면에 출력</li>
</ol>
<p>이 과정이 각 이미지가 모두 실행되게 되어 이미지가 많은 경우 메인 스레드를 블로킹하게 되어 인터렉션이 막히는 문제가 발생했다.</p>
<p>그러니 화면에 표시될 이미지 로드를 최적화해 메인 스레드 블로킹 현상을 막고, 더욱 빠르게 유저에게 이미지를 보여줄 수 있도록 하는 작업이 필요하다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/1e5b1722-aca9-4e01-ae00-05c8be156e2e/image.png" alt=""></p>
<p>추가로, sentry로 에러를 확인하고 있는데, 이미지 때문에 N + 1 문제가 발생하고 있는걸 확인해 개선이 필요함이 더욱 확실해졌다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/fc035012-9c9d-402b-a176-6adae666d1bd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/41884fb6-7c41-40be-b790-ca74bf3543cb/image.png" alt=""></p>
<p>레이아웃과 커밋 단계에서 Long Task가 잡히는 것은 <strong>&quot;브라우저가 화면을 그리느라 너무 바빠서 클릭을 무시한다&quot;</strong>는 뜻</p>
<h2 id="1-nextjs-image-컴포넌트를-제대로-활용하지-못한-문제">1. Nextjs Image 컴포넌트를 제대로 활용하지 못한 문제</h2>
<p>우리는 서버로부터 이미지 관리를 분리시켰다.
스토리지에서 이미지 데이터를 관리하고, 이를 프론트에서 직접 받아와 화면에 표시한다.</p>
<h3 id="스토리지로부터-받아온-경로를-그대로-사용하면-에러가-발생">스토리지로부터 받아온 경로를 그대로 사용하면 에러가 발생</h3>
<p>스토리지로부터 받아온 이미지를 <code>Image</code> 컴포넌트를 사용해 출력하고자 했는데, 자꾸 에러가 발생했다.</p>
<p>에러가 발생하면서 이미지 자체가 로드되지 않았다.
그래서 다음의 속성을 추가해서 스토리지로부터 받아온 이미지 경로를 그대로 사용하도록 했다.</p>
<pre><code class="language-tsx">unoptimized={true}

&lt;Image
 src={imageSrc}
 unoptimized={true}
 alt={alt}
 className={className}
 {...props}
/&gt;</code></pre>
<p>이미지 로드시 에러가 발생한 이유에 대해 정리하고 넘어가자.</p>
<p>먼저, Next.js가 <code>Image</code> 컴포넌트를 통해 이미지를 로드하고 최적화하는 방식은 다음과 같다.</p>
<ol>
<li>Next.js 서버가 원본 이미지 주소로 접속한다.</li>
<li>그리고 서버가 원본을 다운받아 브라우저 크기에 맞게 리사이징 및 WebP 변환을 시도한다.</li>
<li>변환된 이미지를 사용자에게 전달한다.</li>
</ol>
<p>하지만 외부 스토리지 이미지는 보안상의 이유로 Next.js가 함부로 접근할 수 없다.</p>
<p>만약 <code>next.config.js</code> 에 해당 도메인을 허용해주지 않으면, Nextjs 서버는 신뢰할 수 없는 출처의 이미지를 내가 대신 최적화해줄 수 없다며 에러를 내뱉는다.</p>
<p>하지만 우리는 이러한 문제를
<code>unoptimized={true}</code> 속성을 붙여서 해결했다.
이는 위의 Next.js 서버가 이미지를 최적화하는 과정을 건너뛰고, 원본 스토리지 URL로 직접 연결하기 때문에 해결된 것이다.</p>
<p>즉, <code>unoptimized={true}</code> 속성이 포함되어 있으면 이미지 로드시 성능에 영향을 준다.</p>
<p>그래서 먼저 이 문제를 해결하고자 했다.
이미지 최적화 작업을 진행하도록 하는 것만으로도 성능에 큰 도움이 줄 것이라고 생각했다.</p>
<p><code>next.config.ts</code> 파일을 확인해보니, <code>remotePattern</code> 은 설정되어 있었다.</p>
<pre><code class="language-tsx">images: {
    remotePatterns: [
      ...imageDomains.map((host) =&gt; ({
        protocol: &#39;https&#39; as const,
        hostname: host,
      })),
      ...
      {
        protocol: &#39;https&#39;,
        hostname: &#39;kr.object.ncloudstorage.com&#39;,
        pathname: &#39;/**&#39;,
      },
    ],
  },</code></pre>
<p>그럼에도 이미지 로드시 에러가 나는 이유를 찾아보니, 다음의 경우에도 문제가 발생한다고 한다.</p>
<ul>
<li>버킷의 visibility가 public으로 되어있지 않으면, next.js 서버가 이미지를 가져와 리사이징하는 등의 작업이 불가능하다.</li>
</ul>
<p>그러 만약 외부 스토리지를 사용하는데, 이미지 로드 에러가 발생한다면 다음 두가지를 체크해보면 된다.</p>
<ul>
<li>버킷의 <code>visibility</code>가 public으로 되어있는가?</li>
<li>next.config.js에 <code>remotePatterns</code> 로 스토리지 경로가 추가되어 있는가?</li>
</ul>
<p>나의 경우는 ncp로 사용할 때는 <code>remotePatterns</code> 를 적용했음에도 이미지 로드시 에러가 발생했다.</p>
<p>그런데 OCI로 바꾸면서 이미지 로드 에러가 사라졌다.</p>
<p>아마 NCP로 동작할 때는 버킷의 <code>visibility</code>가 public이 아닌 값으로 설정되어 있던 것 같다는 추측을 해본다. </p>
<blockquote>
<p>(OCI로 바꾸면서 이전의 NCP 서버 및 스토리지는 바로 삭제해버려 확인이 불가능하다.)</p>
</blockquote>
<p>그래서 <code>unoptimized</code> 속성을 제거해줬는데, 그럼에도 webP가 아닌 <code>png</code> 와 같은 content-type 그대로 출력이 됐다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/0d46efc1-8e6f-4833-b070-c1cb760da17b/image.png" alt=""></p>
<p>스토리지로부터 받아오는 이미지가 아닌 경우는 webP로 잘 나오고 있는데, 스토리지로부터 받아오는 이미지만 최적화가 안되는 것 같았다.</p>
<h3 id="webp가-아닌-content-type-그대로-다운받아-출력되는-문제">webP가 아닌 content-type 그대로 다운받아 출력되는 문제</h3>
<p>이는 <strong>pre-signed URL의 TTL(300초)이 Next.js 이미지 최적화 캐시와 충돌</strong>하고 있어 발생한 문제였다.</p>
<p><strong>문제 흐름:</strong></p>
<ol>
<li>브라우저가 <code>/_next/image?url=&lt;presigned-url&gt;&amp;w=...</code> 요청</li>
<li>Next.js 서버가 presigned URL로 원본 이미지를 fetch</li>
<li>그런데 presigned URL이 만료(300초)됐거나, 이미 캐시된 <code>/_next/image</code> 응답이 없으면 → <strong>MinIO 403 반환 → 최적화 실패 → 원본 URL로 리다이렉트</strong></li>
<li>브라우저가 MinIO에서 직접 PNG를 받아옴</li>
</ol>
<blockquote>
<p><strong>Pre-signed URL</strong> = &quot;미리 서명된 URL&quot;</p>
<p>일반적으로 S3/MinIO 같은 오브젝트 스토리지는 <strong>비공개(private)</strong> 이다. 
아무나 접근하면 안 되니까.</p>
<p>그런데 특정 파일을 일시적으로 공개해야 할 때, 서버가 <strong>&quot;이 URL은 내가 허가한 거야&quot;</strong> 라고 서명(signature)을 URL에 담아서 발급해주는 방식이 pre-signed URL이다.</p>
</blockquote>
<p>이에 대한 <strong>해결 방법은 크게 두 가지이다.</strong></p>
<ul>
<li><strong>API proxy 라우트</strong>
Next.js에 <code>/api/media-image/[id]</code> 라우트를 만들어서 안정적인 URL을 <code>&lt;Image src&gt;</code>에 넘기는 방식. presigned URL 만료 문제 자체가 없어지고 Next.js 캐시도 제대로 작동한다.</li>
<li><strong>read용 TTL 늘리기</strong>
업로드 presigned URL은 300초가 맞지만, <strong>조회용 URL</strong>은 훨씬 길어도 된다.</li>
</ul>
<p>나는 이 두 방법 중에서 첫 번째 방법인 <code>API proxy 라우트</code> 방법을 채택했다.</p>
<p>가장 큰 이유는 캐시 효율이다.</p>
<p>pre-signed url 은 발급받을 때마다 쿼리 파라미터가 계속 바뀐다.
next.js 이미지 최적화 서버는 <code>src</code> 문자열 전체를 캐시 키로 사용하는데, url이 매번 바뀌면 서버는 이를 완전히 새로운 이미지로 인식하게 되어 매번 새로 다운받고 실행시키게 된다.</p>
<p><code>/api/media-image/[id]</code> 는 변하지 않는 정적 url이다.
이 방식을 쓰면 next.js 서버가 이미지를 한 번만 최적화하고 이후에는 캐시된 데이터를 즉시 반환할 수 있어서, 서버 부하가 줄어들게 될 것이다.</p>
<p>그리고 pre-signed url을 클라이언트에 그대로 노출시키기보단, <code>/api/media-image/[id]</code> 가 캡슐화 역할을 해서 안전하게 실행시키는게 나을 것이라고 생각했다.</p>
<p>그리고 TTL이 짧은 URL은 브라우저가 캐싱을 꺼리게 만들기 때문에, TTL을 손대기보단 라우트를 추가해 캐싱이 잘 동작하도록 하는게 더 효율적이라고 생각했다.</p>
<p>즉, pre-signed url의 휘발성 문제를 해결하면서도 next.js와 브라우저가 이미지를 오래 캐싱할 수 있는 환경을 만들기 위해서 API proxy 방식을 채택했다.</p>
<p>그래서 라우트 파일을 아래와 같이 추가해줬다.</p>
<pre><code class="language-tsx">// src/app/api/media-image/[id]/route.ts
import { auth } from &#39;@/auth&#39;;
import { NextResponse } from &#39;next/server&#39;;
import sharp from &#39;sharp&#39;;

const backendUrl =
  process.env.NODE_ENV === &#39;production&#39;
    ? process.env.NEXT_PUBLIC_PRODUCTION_API_URL
    : &#39;http://localhost:4000&#39;;

export async function GET(
  _request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; },
) {
  const { id } = await params;

  const session = await auth();
  if (!session?.accessToken) {
    return new NextResponse(null, { status: 401 });
  }

  // 백엔드에서 presigned URL 가져오기
  const urlRes = await fetch(`${backendUrl}/v1/media/${id}/url`, {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  });

  if (!urlRes.ok) {
    return new NextResponse(null, { status: urlRes.status });
  }

  const body = await urlRes.json();
  const presignedUrl: string | undefined = body?.data?.url;

  if (!presignedUrl) {
    return new NextResponse(null, { status: 404 });
  }

  // presigned URL에서 원본 이미지 가져오기
  const imageRes = await fetch(presignedUrl);
  if (!imageRes.ok) {
    return new NextResponse(null, { status: imageRes.status });
  }

  const originalBuffer = Buffer.from(await imageRes.arrayBuffer());

  // WebP로 변환
  const webpBuffer = await sharp(originalBuffer).webp({ quality: 85 }).toBuffer();

  return new NextResponse(new Uint8Array(webpBuffer), {
    headers: {
      &#39;Content-Type&#39;: &#39;image/webp&#39;,
      // assetId는 불변(새 파일 = 새 UUID)이므로 1년 캐시
      &#39;Cache-Control&#39;: &#39;public, max-age=31536000, immutable&#39;,
    },
  });
}
</code></pre>
<p>흐름은 다음과 같다.</p>
<ol>
<li>브라우저 요청</li>
<li>auth 인증 확인</li>
<li>백엔드에서 presigned url 가져오기</li>
<li>MinIO에서 원본 이미지 가져오기</li>
<li>sharp로 WebP로 변환</li>
<li><code>Cache-Control: immutable</code>, 1년 캐시</li>
</ol>
<blockquote>
<p><code>sharp</code> 라이브러리?
Next.js는 기본적으로 이미지를 최적화할 때 내부적으로 <code>squoosh</code> 라는 라이브러리를 사용한다.
하지만 이는 JS 기반이라 속도가 느리고 기능이 제한적이다.
Next.js는 <code>sharp</code>가 설치되어 있으면, 자동으로 이를 감지해 모든 이미지 최적화 작업(리사이징, webp 변환 등)을 <code>sharp</code> 에게 맡긴다.</p>
</blockquote>
<p><code>sharp</code> 는 C++로 작성된 <code>libvips</code> 라이브러리를 사용하기 때문에, 일반적인 이미지 처리 도구보다 빠르고 메모리 소모가 적다.</p>
<blockquote>
</blockquote>
<ul>
<li>포맷 변환(webp/avif): 사용자가 png나 jpg 원본을 요청해도, 서버에서 실시간으로 <code>sharp</code> 를 거쳐 용량이 훨씬 작은 webp로 바꿔서 내보낸다.</li>
<li>다이나믹 리사이징: 브라우저 크기에 맞춰서 이미지를 깎아준다.<blockquote>
</blockquote>
</li>
</ul>
<p>그리고 기존의 <code>AssetImage.tsx</code> 의 <code>imageSrc</code> 를 위의 라우트 경로로 수정해줬다.</p>
<pre><code class="language-tsx">// url prop이 있거나 로컬/외부 URL이면 그대로, 아니면 proxy 라우트 사용
  const imageSrc =
    url ||
    (isLocalPath || isAlreadyUrl ? assetId : `/api/media-image/${assetId}`);

  ...

  // proxy 라우트는 이미 WebP로 변환해서 반환하므로 _next/image 최적화 불필요
  const isProxyUrl = !!imageSrc?.startsWith(&#39;/api/media-image/&#39;);

  return (
    &lt;Image
      src={imageSrc}
      alt={alt}
      className={className}
      unoptimized={isProxyUrl}
      onError={() =&gt; setHasError(true)}
      {...props}
    /&gt;
  );</code></pre>
<ul>
<li><code>useMediaResolveSingle</code> 제거해줬다.<ul>
<li>기존처럼 클라이언트에서 URL을 미리 resolve할 필요가 없어졌다.</li>
</ul>
</li>
<li>proxy URL은 <code>unoptimized={true}</code> 하도록 해줬다.<ul>
<li>이미 WebP 타입이므로 <code>/_next/image</code> 재최적화 불필요하다.</li>
</ul>
</li>
</ul>
<p>전체 흐름은 다음과 같다.</p>
<ul>
<li>이전: 브라우저 → <code>/api/media/[id]/url</code> → presigned URL → MinIO (PNG/..) → <code>/_next/image</code> 최적화 실패</li>
<li>이후: 브라우저 → <code>/api/media-image/[id]</code> → 서버에서 변환 → WebP 응답 (1년 캐시)</li>
</ul>
<h3 id="개선-결과-및-효과">개선 결과 및 효과</h3>
<p>결과적으론 이미 불러온 이미지의 경우 다른 페이지에서 같은 이미지를 사용할 때 불필요한 네트워크 요청이 사라졌다.</p>
<p>또한, 초기에 이미지를 불러올 때도 content-type 그대로 다운받아 출력하는 것에서 webp로 변환해 최적화된 이미지를 출력하는 것으로 수정되었다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/05da70e2-63ab-41f4-b695-3e5d0bfffa0d/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>이미지 포맷</td>
<td>PNG/JPEG 원본</td>
<td>WebP (quality 85)</td>
</tr>
<tr>
<td>클라이언트 API 호출</td>
<td>이미지마다 presigned URL 발급 요청</td>
<td>없음 (proxy URL 직접 생성)</td>
</tr>
<tr>
<td>캐시</td>
<td>presigned URL마다 다름 (TTL 300s)</td>
<td>1년 (immutable)</td>
</tr>
<tr>
<td>인증 노출</td>
<td>Presigned URL이 브라우저에 노출</td>
<td>브라우저는 proxy URL만 인지</td>
</tr>
<tr>
<td>로딩 상태 관리</td>
<td>isLoading 처리 필요</td>
<td>불필요 (동기 URL 생성)</td>
</tr>
</tbody></table>
<br />

<h2 id="2-여러-이미지를-불러올-때의-n--1-문제">2. 여러 이미지를 불러올 때의 N + 1 문제</h2>
<p>위에서 next.js의 <code>Image</code> 컴포넌트를 활용하는 것으로 n + 1 문제는 부분적으로는 어느정도 개선했다고 볼 수 있다.</p>
<pre><code class="language-tsx">// 변경 전 n + 1 문제
[브라우저 JS 메인 스레드]
useMediaResolveMulti 실행
  ├─ fetch(&quot;/v1/media/id1/url&quot;) → presigned URL1 → 브라우저 이미지 다운로드
  ├─ fetch(&quot;/v1/media/id2/url&quot;) → presigned URL2 → 브라우저 이미지 다운로드
  └─ fetch(&quot;/v1/media/id3/url&quot;) → presigned URL3 → 브라우저 이미지 다운로드</code></pre>
<p>이때 js <code>fetch</code> 가 메인 스레드에서 n번 실행되면서 인터렉션을 차단한다.</p>
<pre><code class="language-tsx">// 변경 후
[브라우저 JS 메인 스레드]
proxyUrls = mediaIds.map(id =&gt; `/api/media-image/${id}`)  ← 동기, fetch 없음
&lt;Image src=&quot;/api/media-image/id1&quot; /&gt;  ← 브라우저 네트워크 스택 처리 (JS 아님)
&lt;Image src=&quot;/api/media-image/id2&quot; /&gt;
&lt;Image src=&quot;/api/media-image/id3&quot; /&gt;

[서버 (Next.js)]
/api/media-image/id1 → fetch presigned URL → fetch 원본 → WebP 반환
/api/media-image/id2 → fetch presigned URL → fetch 원본 → WebP 반환
/api/media-image/id3 → fetch presigned URL → fetch 원본 → WebP 반환
</code></pre>
<p>수정 후에는 <code>fetch</code> 가 메인 스레드에서 서버로 옮겨졌기 때문에, 메인 스레드 블로킹 현상은 어느정도 개선 되었다고 볼 수 있다.</p>
<p>또한 캐시처리도 되었기 때문에, 초기 로드시에만 문제가 발생하고 이후론 발생하지 않을 문제이다.</p>
<p>추가로 <code>Image</code> 컴포넌트는 기본적으로 lazy loading을 하기 때문에, 스크롤로 화면에 출력해야 할 때 로드되기 때문에 한 번에 다 요청하지는 않는다.</p>
<table>
<thead>
<tr>
<th></th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>JS 메인 스레드 블로킹</td>
<td>N번 fetch() 실행 → <strong>블로킹</strong></td>
<td>없음 → <strong>해결됨</strong></td>
</tr>
<tr>
<td>총 네트워크 요청 수</td>
<td>동일 (N번)</td>
<td>동일 (N번), 단 서버에서 실행</td>
</tr>
<tr>
<td>재방문 시</td>
<td>매번 N번 요청</td>
<td><strong>캐시 히트 (0번 요청)</strong></td>
</tr>
</tbody></table>
<p>하지만 서버 측에서의 이미지 요청 자체는 여전히 n번 발생하고 있기 때문에, 추가 개선이 필요하다.</p>
<h3 id="서버-n--1-요청-개선-배경-파악">서버 N + 1 요청 개선 배경 파악</h3>
<p>먼저, 현재 상황의 문제점을 파악해보자.
30개가 동시 요청되면 서버에서:</p>
<ul>
<li><code>auth()</code> × 30</li>
<li>백엔드 presigned URL 조회 × 30</li>
<li>스토리지 이미지 다운로드 × 30</li>
<li><code>sharp</code> WebP 변환 × 30 (CPU 집약적)</li>
</ul>
<p>이게 여러 사용자에게 동시에 발생하면 서버 부하가 발생할 수 있다.</p>
<p>근본적인 n번 요청을 줄이기 위해선 <code>mediaId</code> 배열 → <code>presigned Url</code> 배열을 반환하는 batch 엔드포인트가 있어야 proxy route에서 1번 요청으로 처리할 수 있다.</p>
<p>그리고 우리 백엔드 팀원이 해당 엔트포인트를 이미 만들어두신 상태이다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/7f47f098-6557-4d66-89cd-c68b20ffbfaf/image.png" alt=""></p>
<p>기존엔 단건 url 요청 엔드포인트를 사용하고 있었기 때문에, 이 여러 url 요청 엔드포인트로 수정하면 될 것이라고 판단했다.</p>
<p>그러니 서버 컴포넌트에서 사용해 <strong>SSR 시 presigned URL을 1번에 batch 조회</strong>하고 <code>resolvedUrls</code>로 내려주면, proxy route를 거치지 않고 직접 이미지를 서빙할 수 있을 것이다.</p>
<p>하지만 presigned url 만료(300초) 문제가 남아 있어서, hybrid 방식으로 개선하는게 안정적일 것이라고 생각했다.</p>
<ul>
<li>SSR 시: <code>getMediaUrlsServer</code>로 batch 조회 → <code>resolvedUrls</code>에 담아 전달 → 빠른 초기 렌더링</li>
<li>만료 후: proxy URL(<code>/api/media-image/${id}</code>)로 fallback → 1년 캐시</li>
</ul>
<h3 id="개선이-필요한-페이지-ssr-컴포넌트-수정하기">개선이 필요한 페이지 SSR 컴포넌트 수정하기</h3>
<p>이미지 조회 최적화가 필요한 페이지는 어떤게 있을지를 생각해봤다.</p>
<ul>
<li>기록 리스트 출력 페이지(<code>/my</code> 하위, <code>/group</code> 하위, <code>/shared</code>, <code>/</code>)</li>
</ul>
<p>이 외는 그리 많은 이미지를 출력하지 않기에, 부분적으로 개선해주면 될 것이라 판단했다.</p>
<p>서버 컴포넌트에서 데이터를 미리 <code>prefetch</code> 하도록 했기 때문에, 이를 활용하는 방법으로 가져가고자 했다.</p>
<p>그래서 <code>prefetch</code> 로 데이터를 먼저 로드한 뒤, 해당 데이터를 통해 이미지 url을 한 번의 api 요청으로 가져와 캐시를 갱신하는 방법을 떠올렸다.</p>
<pre><code class="language-tsx">prefetchQuery → 캐시에 레코드 데이터 저장
     ↓
getQueryData  → 캐시에서 데이터 읽기
     ↓
getMediaUrlsServer(allMediaIds)  → 백엔드 1번 호출로 URL 배치 조회
     ↓
setQueryData  → resolvedUrls 주입한 데이터로 캐시 갱신
     ↓
dehydrate → HydrationBoundary → 클라이언트 캐시 복원
     ↓
useSuspenseQuery/useQuery → 캐시 히트, resolvedUrls 있음 → proxy 호출 없이 바로 렌더링</code></pre>
<p>그래서 이 로직대로 block에서 이미지 경로를 추출하는 유틸 함수와 해당 이미지 경로로 한 번에 batch api 요청을 보내는 유틸 함수를 만들어 사용해봤다.</p>
<p>그런데, 이미지가 출력되는 곳에서 위에서 설정해줬던 <code>Image</code> 최적화 로직이 실행되지 않았다.
content-type 그대로 다운받아 출력되는 문제가 발생했다.</p>
<p>문제를 파악한 결과로는 방금 추가한 batch 조회 때문이었다.</p>
<p><code>getMediaUrlsServer</code>가 반환하는 건 <strong>스토리지의 presigned URL</strong> (원본 PNG/JPEG)이다.
<code>resolvedUrls</code>에 이걸 주입하면 브라우저가 proxy route를 우회해서 원본 파일을 직접 다운로드하게 된다.</p>
<pre><code class="language-tsx">resolvedUrls에 presigned URL 주입됨
     ↓
BlockContent: resolvedUrls.length &gt; 0 → presigned URL 사용
     ↓
브라우저가 스토리지 직접 접근 → 원본 PNG
(proxy route 호출 안 함 → WebP 변환 없음)</code></pre>
<p>그래서 기존의 로직도 실행되면서 batch 처리된 데이터도 활용할 수 있는 로직을 생각해봤는데, presigned url 특성상 캐시가 망가지게 되는 문제가 발생함을 파악했다.</p>
<pre><code class="language-tsx">방문 1: /_next/image?url=https://...?X-Amz-Date=20260302T120000Z&amp;X-Amz-Signature=abc...
          → 캐시 저장 (키 = URL1)

방문 2: /_next/image?url=https://...?X-Amz-Date=20260302T130000Z&amp;X-Amz-Signature=xyz...
          → 캐시 미스 (새 presigned URL → 다른 키)
          → 매번 새로 fetch + WebP 변환</code></pre>
<p>presigned url은 요청할 때마다 서명이 달라지므로 <code>/_next/image</code> 캐시가 항상 미스된다.
그렇게 되면 매번 새로운 이미지로 인식해 캐시를 생성하지 못하고 매번 변환 연산을 수행하게 된다.</p>
<p>한 번의 batch 처리로 이미지를 로드할 수는 있지만, 캐시 처리가 되지 않는다면 매번 새로운 이미지를 다운받아 출력하는 과정이 필요하게 되니 오히려 성능에 안좋아진다.</p>
<p>결론적으론 <code>presigned url</code> + webp를 동시에 구현해도 캐시 효율이 현재 현재 proxy 방식보다 낮아지게 되면서 성능에도 영향을 받게 된다.</p>
<h3 id="결과-및-정리">결과 및 정리</h3>
<p>그래서 나는 batch로 pre-signed url을 미리 가져오는 방식 대신, 이미지 고유 id를 기반으로 하는 API proxy 경로(<code>/api/media-image/[id]</code>)를 그대로 유지하는 방향을 선택했다.</p>
<ul>
<li><code>/api/media-image/[id]</code> 는 시간이 지나도 변하지 않는 정적 url이다.</li>
<li>next.js 캐시 시스템은 이 id르르 사용해 한 번 변환된 webp 이미지를 영구적으로 보관할 수 있다.</li>
<li>하지만 pre-signed url은 매번 캐시 미스를 유발해 최적화의 의미를 퇴색시킨다.</li>
</ul>
<p>처음 우려했던 1번의 id 조회 → 1번의 경로 조회 문제는 id 자체를 이미지 활용함으로써 해결된다.</p>
<p>즉, <code>src=&quot;/api/media-image/123&quot;</code> 처럼 id를 경로에 직접 포함하면, 별도의 경로 조회 api 호출 없이도 브라우저가 즉시 이미지를 요청할 수 있다.</p>
<p>매 요청마다 <code>sharp</code> 가 새롭게 webp를 생성하는 것보단, 한 번 생성된 캐시를 서빙하는게 성능 면에서 훨씬 유리하다고 판단했다.</p>
<p>결론적으론, 나는 네트워크 요청 횟수를 줄이기보단 요청당 효율에 집중하는 것으로 이미지 로드 최적화 작업을 마무리하기로 했다.</p>
<br />

<p>변경 전</p>
<p>[브라우저 JS 메인 스레드]</p>
<ol>
<li>서버 → <code>assetId</code> 수신</li>
<li><code>useMediaResolveMulti(assetId)</code> → GET <code>/v1/media/${assetId}/url</code> → presigned URL  (N번 fetch, 메인 스레드 블로킹)</li>
<li><code>&lt;Image src={presignedUrl} unoptimized /&gt;</code></li>
<li>브라우저 → 스토리지 직접 다운로드 → PNG 원본</li>
<li>화면 출력 (PNG)</li>
</ol>
<p>변경 후</p>
<p>[브라우저 JS 메인 스레드]</p>
<ol>
<li>서버 → <code>assetId</code> 수신</li>
<li><code>proxyUrls = assetIds.map(id =&gt; &#39;/api/media-image/${id}&#39;)</code>  ← 동기, fetch 없음</li>
<li><code>&lt;Image src=&quot;/api/media-image/${id}&quot; unoptimized /&gt;</code></li>
</ol>
<p>[브라우저 네트워크 스택 - JS 블로킹 없음]</p>
<ol start="4">
<li>GET <code>/api/media-image/${id}</code><pre><code>↓</code></pre>[Next.js Route Handler - 서버]</li>
<li><code>auth()</code> 확인</li>
<li>GET <code>/v1/media/${id}/url</code> → presigned URL  (서버-서버, 브라우저 모름)</li>
<li>presigned URL로 스토리지 이미지 fetch</li>
<li>sharp로 WebP 변환</li>
<li><code>Cache-Control: immutable, max-age=31536000</code> 헤더와 함께 반환</li>
</ol>
<p>[브라우저]
10. WebP 수신 → 1년간 HTTP 캐시 저장
11. 화면 출력 (WebP)</p>
<p>재방문 시:
4. GET <code>/api/media-image/${id}</code> → 브라우저 캐시 히트 → 요청 없음</p>
<br />

<p>이 상태에서 추가 성능 문제가 발생하면, 그때 다른 방법을 고려해보는게 좋을 것 같다.
아직 프론트 배포를 새로 하지 않은 상태라, 이후에 배포까지 진행하면 테스트를 다시 진행해볼 계획이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기본 안내창이 없는 브라우저 PWA 지원하기]]></title>
            <link>https://velog.io/@dobby_/%EA%B8%B0%EB%B3%B8-%EC%95%88%EB%82%B4%EC%B0%BD%EC%9D%B4-%EC%97%86%EB%8A%94-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-PWA-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/%EA%B8%B0%EB%B3%B8-%EC%95%88%EB%82%B4%EC%B0%BD%EC%9D%B4-%EC%97%86%EB%8A%94-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-PWA-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 06:25:14 GMT</pubDate>
            <description><![CDATA[<p>PWA로 웹앱을 설치할 수 있도록 지원했는데, 팀원으로부터 한 피드백을 받았다.
<a href="https://github.com/boostcampwm2025/web25-ittda/pull/61">PWA 웹앱 지원-#61</a></p>
<blockquote>
<p>제가 vivaldi라는 브라우저를 사용하고 있어서 주소창에 다운 아이콘이 뜨지 않더라고요. 그래서 어떻게 다운하는지 좀 헤맸는데, 다양한 브라우저에서 지원 가능한 상태인지 추후 검증해보면 좋을 것 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/040ab161-d466-4add-a555-a47105e0641d/image.png" alt=""></p>
<p>그리고 브라우저에 의존하지 않고 웹으로 접속하면 사이트 안에 앱 다운하기가 있으면 더 직관적이고 좋을 것 같습니다.</p>
</blockquote>
<p>브라우저마다 install 안내창이 다르게 뜬다는 것은 몰랐기 때문에, 수정이 필요해보였다.</p>
<p>다운하지 않은 유저에겐 웹 상단에 설치하기 버튼 혹은 설치 방법 안내 메시지를 두는게 좋을 것 같아서, 그대로 적용해보려고 한다.</p>
<h2 id="유저에게-설치-안내-배너-띄워주기">유저에게 설치 안내 배너 띄워주기</h2>
<p>먼저, 유저 입장에서 봤을 때 웹앱을 설치할 수 있는지조차 모르는 경우가 많을 것 같았다.
보통 주소창에 뜨는 아이콘들은 잘 보지 않기 때문이다.</p>
<p>그래서 다음을 고려해줬다.</p>
<ul>
<li>서비스 상단에 배너로 이 웹은 앱으로 설치가 가능함을 알리기</li>
<li>설치하고 싶지 않은 유저를 위해, 배너 닫기 기능 지원하기</li>
<li>배너를 클릭하면 설치 창이 뜨도록 하기</li>
</ul>
<p>첫 번째로 UI 적인 배너는 다음처럼 만들었다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/4177a6b7-a72f-4221-9245-40771ebae4ac/image.png" alt=""></p>
<p>배너를 굳이 닫지 않아도 서비스 이용에 불편함이 없도록, fix로 두지 않고 스크롤을 내렸을 때 헤더에 의해 가려지게 했다.</p>
<h2 id="설치-안내창을-지원하지-않는-브라우저-고려하기">설치 안내창을 지원하지 않는 브라우저 고려하기</h2>
<p>이 문제를 나만 고민하는 것은 아니라고 생각하고, 인터넷을 찾아봤다.</p>
<p>그러다 <code>beforeinstallprompt</code> 를 알게 되었고, 이를 사용하면 될 것 같았다.</p>
<h3 id="beforeinstallprompt">beforeinstallprompt</h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeinstallprompt_event">MDN 공식문서</a></p>
<p><code>beforeinstallprompt</code> 이벤트는 브라우저가 웹사이트를 프로그레시브 웹앱(PWA)로 설치할 수 있음을 감지했을 때 발생한다.</p>
<ul>
<li>이벤트가 발생하는 정확한 시점은 보장되지 않지만, 일반적으로 페이지 로드 시에 발생한다고 한다.</li>
<li>웹 앱이 브라우저에게 제공하는 일반적인 UI 대신, 자체 앱 내 UI를 통해 사용자에게 앱 설치를 유도하고자 할 때 사용한다.</li>
</ul>
<p>사용자가 앱 내의 설치 UI를 사용해서 앱을 설치할 때, 앱 내 설치 UI는 <code>prompt()</code> 로 <code>BeforeInstallPromptEvent</code> 를 호출해 설치 프롬프트를 표시한다.</p>
<pre><code class="language-tsx">addEventListener(&quot;beforeinstallprompt&quot;, (event) =&gt; { })

onbeforeinstallprompt = (event) =&gt; { }</code></pre>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/platforms"><code>BeforeInstallPromptEvent.platforms</code></a></li>
</ul>
<p>이벤트가 발생한 플랫폼을 나타내는 문자열 배열을 반환한다.</p>
<p>사용자에게 ‘웹’ 또는 ‘플레이’와 같은 버전 선택지를 제공해 사용자가 웹 버전 또는 안드로이드 버전 중에서 선택할 수 있도록 한다.</p>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/userChoice"><code>BeforeInstallPromptEvent.userChoice</code></a></li>
</ul>
<p>앱 설치를 요청받았을 때 사용자가 선택한 내용을 설명하는 객체를 반환한다.</p>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/prompt"><code>BeforeInstallPromptEvent.prompt()</code></a></li>
</ul>
<p>앱을 설치할지 사용자에게 묻는 메시지를 표시한다.</p>
<pre><code class="language-tsx">&lt;button id=&quot;install&quot; hidden&gt;Install&lt;/button&gt;

let installPrompt = null;
const installButton = document.querySelector(&quot;#install&quot;);

window.addEventListener(&quot;beforeinstallprompt&quot;, (event) =&gt; {
  event.preventDefault();
  installPrompt = event;
  installButton.removeAttribute(&quot;hidden&quot;);
});</code></pre>
<p>버튼을 클릭하면 앱의 설치 버튼이 나타난다.</p>
<p>하지만, 이 이벤트를 지원하지 않는 브라우저가 있기 때문에 지원하지 않는 브라우저도 함께 고려해줘야 한다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/065b2040-e4c1-4e7a-ace6-a48b8297eb2f/image.png" alt=""></p>
<p>그래서 두 경우로 나눠서 지원하기로 했다.</p>
<ul>
<li>이벤트를 지원하는 경우 브라우저 기본 설치 안내창 띄우기</li>
<li>이벤트를 지원하지 않는 경우 모달로 어떻게 설치하는지 방법 띄우기</li>
</ul>
<h3 id="배너-클릭하면-설치-안내창-혹은-모달로-방법-알려주기">배너 클릭하면 설치 안내창 혹은 모달로 방법 알려주기</h3>
<p>나는 웹의 상단에 표시되는 배너를 클릭하면 동작되게 하고자 했다.</p>
<p>그러기 위해선, root layout에 배너를 띄워줘야 한다.</p>
<pre><code class="language-tsx">// src/app/layout.tsx

return (
  ...
  &lt;ThemeColorSetter /&gt;
      &lt;div className=&quot;flex flex-col min-h-screen w-full mx-auto shadow-2xl max-w-4xl relative transition-colors duration-300 dark:bg-[#121212] dark:text-white bg-white text-itta-black&quot;&gt;
        &lt;PWAInstallBanner /&gt;
        &lt;ConditionalHeader /&gt;
        ...</code></pre>
<p>이렇게 하고 배너 컴포넌트를 만들어보자.</p>
<p>먼저, 이 서비스가 설치한 앱으로 표시되는건지, 웹으로 표시되고 있는 것인지를 판단해야 한다.</p>
<pre><code class="language-tsx">  // src/components/PWAInstallBanner.tsx
  useEffect(() =&gt; {
    // 이미 설치되었는지 확인
    if (window.matchMedia(&#39;(display-mode: standalone)&#39;).matches) {
      requestAnimationFrame(() =&gt; {
        setIsInstalled(true);
      });
      return;
    }

    // 배너를 닫은 적이 있는지 확인
    const dismissedUntil = localStorage.getItem(&#39;pwa-banner-dismissed-until&#39;);
    if (dismissedUntil &amp;&amp; Date.now() &lt; parseInt(dismissedUntil)) {
      return;
    }

    // beforeinstallprompt 이벤트 리스너
    const handler = (e: Event) =&gt; {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
      setShowBanner(true);
    };

    window.addEventListener(&#39;beforeinstallprompt&#39;, handler);

    // beforeinstallprompt를 지원하지 않는 브라우저
    // (Safari, Vivaldi 등) - 바로 배너 표시
    const timeout = setTimeout(() =&gt; {
      if (!deferredPrompt) {
        setShowBanner(true);
      }
    }, 1000);

    return () =&gt; {
      window.removeEventListener(&#39;beforeinstallprompt&#39;, handler);
      clearTimeout(timeout);
    };
  }, [deferredPrompt]);</code></pre>
<p>나는 pwa를 <code>standalone</code>으로 설정해줬기 때문에, <code>matchMedia</code> 를 <code>standalone</code> 으로 파악해줬다.</p>
<p>일치하는게 있다면 설치한 앱으로 실행시키고 있는 것이니, 배너를 보여줄 필요가 없다.
그렇지 않다면, 만약 유저가 배너를 닫았는지를 <code>localStorage</code> 로 판단해준다.</p>
<p>나는 하루를 기준으로 배너를 숨겨주기로 했다.
나머지는 <code>beforeinstallprompt</code> 이벤트를 등록하고 언마운트시에 해제해주는 로직이다.</p>
<pre><code class="language-tsx">const timeout = setTimeout(() =&gt; {
      if (!deferredPrompt) {
        setShowBanner(true);
      }
    }, 1000);</code></pre>
<p>이 부분은 safari나 vivaldi와 같은 브라우저는 <code>beforeinstallprompt</code>를 지원하지 않기에, 모달을 띄워주기 위한 상태 업데이트 로직이다.</p>
<pre><code class="language-tsx">  // src/components/PWAInstallBanner.tsx
  const handleInstallClick = async () =&gt; {
    // Chrome/Edge 등에서 기본 프롬프트 지원하는 경우
    if (deferredPrompt) {
      try {
        await deferredPrompt.prompt();
        const { outcome } = await deferredPrompt.userChoice;

        if (outcome === &#39;accepted&#39;) {
          setIsInstalled(true);
        }

        setShowBanner(false);
        setDeferredPrompt(null);
      } catch (error) {
        console.error(&#39;PWA 설치 실패:&#39;, error);
      }
    } else {
      // 프롬프트를 지원하지 않는 브라우저 (Safari, Vivaldi 등)
      // 커스텀 안내 모달 표시
      setShowInstructions(true);
    }
  };</code></pre>
<p>유저가 배너를 클릭하게 되면 프롬프트를 지원하는 브라우저에선 기본 설치 안내창을 띄워주도록 했다.</p>
<p>그렇지 않다면 설치 안내 모달을 띄워주도록 한다.</p>
<pre><code class="language-tsx">  // src/components/PWAInstallBanner.tsx
  const handleClose = () =&gt; {
    setShowBanner(false);
    // 1일 동안 배너 숨김
    const dismissedUntil = Date.now() + 1 * 24 * 60 * 60 * 1000;
    localStorage.setItem(
      &#39;pwa-banner-dismissed-until&#39;,
      dismissedUntil.toString(),
    );
  };

  if (isInstalled || !showBanner) {
    return null;
  }</code></pre>
<p>배너를 닫고자 할 때는 localStorage에 시간과 함께 저장해준다.</p>
<pre><code class="language-tsx">// src/components/PWAInstallBanner.tsx
// 브라우저 감지
  const userAgent = typeof navigator !== &#39;undefined&#39; ? navigator.userAgent : &#39;&#39;;
  const isIOS = /iPad|iPhone|iPod/.test(userAgent);
  const isSafari = /Safari/.test(userAgent) &amp;&amp; !/Chrome/.test(userAgent);
  const isMacOS = /Macintosh|MacIntel|MacPPC|Mac68K/.test(userAgent);</code></pre>
<p>브라우저마다 다르게 안내창을 띄워야 하므로, 브라우저를 알아내야 한다.</p>
<p><code>navigator</code> 에서 <code>userAgent</code> 로 파악이 가능하길래, 이를 활용해줬다.</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgent">MDN 공식문서 - navigator.userAgent</a></p>
<p>맥북과 같은 노트북/데스크탑은 아이폰과 설치 방법이 달라서 다르게 지원하기 위해 ios, safars, macos를 구분해줬다.</p>
<p>마지막으로 렌더링할 부분(컴포넌트)를 작성해주면 된다. 이는 프로젝트마다 다를 것이니 다루지 않겠다!</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/2266a4a3-0193-4490-a84f-fb39591367dc/image.png" alt=""></p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://velog.io/@votogether2023/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%A0%88%EC%8B%9C%EB%B8%8C-%EC%9B%B9-%EC%95%B1PWA%EC%9D%B4%EB%9E%80-%EC%9D%B8%EC%95%B1-%EC%84%A4%EC%B9%98%EB%A5%BC-%EB%AC%BB%EB%8A%94-%ED%99%94%EB%A9%B4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-feat.beforeinstallprompt">https://velog.io/@votogether2023/프로그레시브-웹-앱PWA이란-인앱-설치를-묻는-화면-구현하기-feat.beforeinstallprompt</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeinstallprompt_event">https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeinstallprompt_event</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgent">https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgent</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[nextjs에서 PWA로 웹앱 지원하기]]></title>
            <link>https://velog.io/@dobby_/nextjs%EC%97%90%EC%84%9C-PWA%EB%A1%9C-%EC%9B%B9%EC%95%B1-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/nextjs%EC%97%90%EC%84%9C-PWA%EB%A1%9C-%EC%9B%B9%EC%95%B1-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 06:18:06 GMT</pubDate>
            <description><![CDATA[<p>&#39;잇다-&#39;는 기록 서비스이다.
기록은 사실 데스크탑보단 모바일에서 빠르게 진행하는게 더 효율적이면서 편하다.</p>
<p>그렇기에 우리 팀은 웹 기반이면서 어플로도 변환할 수 있는 pwa를 사용해 웹앱을 지원하기로 했다.</p>
<p>pwa web-app을 지원하는 과정과 그 안에서 고민했던 점들을 정리해봤다.</p>
<h2 id="nextjs를-사용해-pwa-구축하기">Next.js를 사용해 PWA 구축하기</h2>
<blockquote>
<p>PWA: progressive web application</p>
</blockquote>
<p>PWA는 웹 애플리케이션의 접근성과 활용성을 갖추면서, 네이티브 모바일 앱의 기능과 사용자 경험을 제공한다.</p>
<p>다음과 같은 이점이 있다.</p>
<ul>
<li>앱 스토어 승인을 기다릴 필요 없이 즉시 업데이트 배포 가능</li>
<li>단일 코드베이스로 크로스 플랫폼 애플리케이션 개발</li>
<li>홈 화면 설치 및 푸시 알림과 같은 네이티브 앱과 유사한 기능 제공</li>
</ul>
<h3 id="1-웹-애플리케이션-manifest-viewportmetadata-생성">1. 웹 애플리케이션 manifest, viewport/metadata 생성</h3>
<p>Next.js는 App Router를 사용해 웹앱 매니페스트를 생성하는 기능을 기본적으로 제공한다.</p>
<p><code>app/manifest.ts</code> 또는 <code>app/manifest.json</code> 파일을 만들어서 웹앱 설정을 할 수 있다.</p>
<pre><code class="language-tsx">// app/manifest.ts
import type { MetadataRoute } from &#39;next&#39;

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: &#39;Next.js PWA&#39;,
    short_name: &#39;NextPWA&#39;,
    description: &#39;A Progressive Web App built with Next.js&#39;,
    start_url: &#39;/&#39;,
    display: &#39;standalone&#39;,
    background_color: &#39;#ffffff&#39;,
    theme_color: &#39;#000000&#39;,
    icons: [
      {
        src: &#39;/icon-192x192.png&#39;,
        sizes: &#39;192x192&#39;,
        type: &#39;image/png&#39;,
      },
      {
        src: &#39;/icon-512x512.png&#39;,
        sizes: &#39;512x512&#39;,
        type: &#39;image/png&#39;,
      },
    ],
  }
}</code></pre>
<ul>
<li><code>display</code>: 
웹 애플리케이션의 기본 표시 모드 지정. 표시 모드는 운영체제 컨텍스트 내에서 앱이 실행될 때 사용자에게 표시되는 브라우저 UI의 범위를 결정<ul>
<li><code>fullscreen</code>: 
브라우저 UI 요소를 숨기고 사용 가능한 전체 화면 영역을 활용해 앱을 연다. 브라우저 컨트롤어 보이지 않고, 전체 화면을 차지해 완벽한 몰입형 게임 환경을 제공하는 게임 앱에 사용할 수 있다.</li>
<li><code>standalone</code>: 
앱을 마치 독립형 네이티브 앱처럼 보이도록 연다. 앱이 별도의 창으로 표시되고 앱 실행기에 자체 아이콘이 포함된다. 브라우저는 URL 표시줄과 같은 UI 요소를 제외하지만 상태 표시줄과 같은 다른 UI 요소는 포함할 수 있다. (브라우저의 URL 표시줄은 없지만 기기의 배터리와 알림 상태 표시줄은 그대로 표시)</li>
<li><code>minimal-ui</code>: 
앱을 독립 실행형 앱처럼 열되, 탐색을 위한 최소한의 UI 요소만 표시. 일반적으로 뒤로, 앞으로, 새로고침과 같은 탐색 컨트롤과 앱 URL을 표시하는 기능이 포함된다.</li>
<li><code>brower</code>: 
앱을 플랫폼별 링크 열기 규칙에 따라 일반 브라우저 탭 또는 새 창에서 연다. <code>display</code> 모드가 지정되지 않은 경우 이 값이 기본값.</li>
</ul>
</li>
<li><code>background_color</code>: 앱의 창 배경색<ul>
<li>앱의 스타일시트가 로드되기 전에 애플리케이션 창에 표시되는 색상을 정의한다. 즉, 스타일시트가 로드되기 전에 나타나므로, 애플리케이션 스타일시트의 CSS 속성 색상 값과 동일하게 설정해야 시각적 전환이 원활해진다.</li>
</ul>
</li>
</ul>
<blockquote>
<p>Next.js 13버전부턴 Metadata API가 나오면서 <code>&lt;head&gt;</code>  태그를 건드리지 않아도 됐다.
<code>layout.tsx</code> 나 <code>page.tsx</code> 에서 <code>metadata</code> 객체만 내보내면 Next.js가 빌드 및 런타임 시점에 알아서 최적화된 <code>&lt;head&gt;</code> 태그를 구성해준다.</p>
</blockquote>
<p>Next.js 13버전 이전까지는 위처럼 meta 태그를 직접 넣어줘야 했는데, 13버전 이후부턴 meta 객체만 선언해주면 된다.</p>
<p>추가로 14버전부터는 <code>metadata</code> 객체 안에 있던 <code>themeColor</code>, <code>viewport</code> 같은 속성들이 별도의 <code>viewport</code> 객체로 분리되었다.</p>
<p>그래서 두 객체를 같이 작성해줘야 한다.</p>
<pre><code class="language-tsx">// src/app/layout.tsx
export const viewport: Viewport = {
  width: &#39;device-width&#39;,
  initialScale: 1,
  minimumScale: 1,
  viewportFit: &#39;cover&#39;,
};

export const metadata: Metadata = {
  title: {
    default: &#39;잇다-&#39;,
    template: &#39;%s - 잇다-&#39;,
  },
  description: &#39;기억과 맥락을 이어주는 기록 서비스&#39;,
  manifest: &#39;/manifest.webmanifest&#39;,
  icons: {
    icon: &#39;/web-app-icon-192x192.png&#39;,
    apple: &#39;/apple-icon.png&#39;,
  },
};

export default function RootLayout({
...</code></pre>
<h3 id="viewportmetadata-vs-manifestts">viewport/metadata vs manifest.ts</h3>
<p>여기서, metadata/viewport랑 manifest.ts랑 무슨 차이가 있는걸까?</p>
<p><code>metadata</code>/ <code>viewport</code> 는 브라우저(탭)을 위한 것이고, <code>manifest.ts</code> 는 설치될 앱을 위한 것이다.</p>
<ul>
<li><code>viewport</code>: 
‘이 화면을 모바일 크기에 맞춰서 그려줘’, ‘주소창 색은 이걸로 해줘&#39; 같은 실시간으로 브라우저 창에 명령을 내리기 위해 사용</li>
<li><code>metadata</code>: 
‘이 페이지의 제목은 XX야’, ‘검색 결과에 어떻게 나와야 헤’와 같은 SEO와 공유 시 미리보기(openGraph)를 담당</li>
</ul>
<p>위 두 사항은 페이지를 이동할 때마다 바뀔 수 있다.</p>
<ul>
<li>manifest.ts: 사용자가 ‘홈 화면에 추가’ 버튼을 눌러서 앱을 설치할 때만 주로 참조되는 파일</li>
</ul>
<p>‘설치된 후 앱 아이콘 모양은?’, ‘앱을 켰을 때 가로모드로 고정할까?’, ‘스플래시 화면 배경색은 뭐야?’와 같은 앱의 설정 내용을 결정한다.</p>
<p>한 번 설치되면 잘 바뀌지 않는 앱의 정체성을 규정하고, 브라우저 탭 안에서 돌아갈 때는 <code>manifest</code> 의 설정보다 HTML 헤더의 <code>metadata</code> 설정이 우선순위를 갖는 경우가 많다.</p>
<table>
<thead>
<tr>
<th></th>
<th>metadata/viewport</th>
<th>manifest.ts</th>
</tr>
</thead>
<tbody><tr>
<td>주요 대상</td>
<td>구글 검색 로봇, 브라우저 탭, 공유 링크</td>
<td>안드로이드/IOS 운영체제</td>
</tr>
<tr>
<td>적용 시점</td>
<td>웹사이트 접속 중 매 순간</td>
<td>앱 설치 시점 및 앱 실행(런타임)</td>
</tr>
<tr>
<td>핵심 설정</td>
<td>제목, 설명, 브라우저 주소창 색</td>
<td>앱 아이콘, 스플래시 화면, 앱 실행 방향</td>
</tr>
<tr>
<td>차이</td>
<td>웹으로서의 최적화</td>
<td>앱으로서의 최적화</td>
</tr>
</tbody></table>
<p>둘 중 하나만 있으면 사용자 경험이 어딘가 깨지게 되기에, 웹앱을 지원하기 위해선 둘 모두 설정하는 것이 중요하다.</p>
<p>유저의 입장에서 봤을 때, 다음의 시나리오를 생각하면 된다.</p>
<ol>
<li>카톡으로 링크 공유: 이때 드는 제목과 사진은 <code>metadata</code> 가 결정</li>
<li>브라우저로 접속 중: 주소창 색상이 바뀌는 건 <code>viewport</code> 의 <code>themeColor</code> 덕분</li>
<li>홈 화면에 추가: 이때 폰에 생기는 아이콘 모양과 이름은 <code>manifest</code> 에서 가져옴</li>
<li>설치된 앱 실행: 이때 뜨는 스플래시 화면과 앱 전송 UI는 <code>manifest</code> 설정 값</li>
</ol>
<p>여기까지 해주면 웹앱 설치와 설정은 끝났다.</p>
<p>하지만 테마를 지원한다면, 테마에 맞춰 앱의 주소창 배경색을 동적으로 수정시켜줘야 한다.</p>
<h3 id="테마에-맞춰-앱의-창-배경색-설정하기">테마에 맞춰 앱의 창 배경색 설정하기</h3>
<p>나는 다크모드/라이트모드를 지원하고 있으며, 유저가 지정한 테마에 맞춰 창 배경색을 지정하고 싶었다.</p>
<p><code>manifest.ts</code> 는 빌드 타임에 생성되기 때문에, 런타임에 유저가 버튼을 눌러 바꾸는 tailwind의 테마 상태를 실시간으로 반영하기는 어렵다.</p>
<p>브라우저와 OS 수준에서 지원하는 시스템 테마 설정을 활용하거나, <code>HTML Meta Tag</code>를 병용해서 다크모드/라이트모드에 대응하는 방법이 있다.</p>
<p><code>layout.tsx</code> 나 <code>index.html</code> 에 다음의 코드를 추가해준다.</p>
<pre><code class="language-tsx">&lt;head&gt;
  &lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot; media=&quot;(prefers-color-scheme: light)&quot;&gt;
  &lt;meta name=&quot;theme-color&quot; content=&quot;#0f172a&quot; media=&quot;(prefers-color-scheme: dark)&quot;&gt;
&lt;/head&gt;</code></pre>
<p>하지만 Next.js 13버전부턴 Metadata API가 나오면서 <code>&lt;head&gt;</code>  태그를 건드리지 않아도 됐다.
<code>layout.tsx</code> 나 <code>page.tsx</code> 에서 <code>metadata</code> 객체만 내보내면 Next.js가 빌드 및 런타임 시점에 알아서 최적화된 <code>&lt;head&gt;</code> 태그를 구성해준다.</p>
<p>즉, 위에서 설정한 부분은 그대로 가져가면 된다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/0a1e9859-2822-4395-9911-c5842456cf19/image.png" alt=""></p>
<p>우리는 <code>metadata</code> 를 통해서 head에 추가된 저 <code>meta</code> 태그를 동적으로 수정해주면 된다.</p>
<p>테마를 지원한다면, 테마 토글 버튼이 있을테니 그 부분은 넘어가고 어떻게 주소창 배경색을 바꾸는지만 정리하겠다.</p>
<p><code>metadata</code> 는 매 페이지마다 다르게 html에 들어가기 때문에, 전역적으로 테마를 알아내서 적용시켜줘야 한다.</p>
<p>그러니, <code>layout.tsx</code> 파일에서 테마를 설정하는 컴포넌트를 추가해주자.</p>
<pre><code class="language-tsx">// src/app/layout.tsx
import ThemeColorSetter from &#39;@/components/ThemeColorSetter&#39;;

export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  ...
  &lt;body
    className={`${notoSans.variable} antialiased relative`}
    suppressHydrationWarning
  &gt;
    &lt;Script
      src={`https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&amp;libraries=places`}
      strategy=&quot;beforeInteractive&quot;
    &gt;
    &lt;Providers&gt;
      &lt;MswLoader /&gt;
      &lt;ThemeProvider
        attribute=&quot;class&quot;
        enableSystem={true}
        defaultTheme=&quot;system&quot;
      &gt;
        &lt;ThemeColorSetter /&gt; &lt;- 여기!
        ...</code></pre>
<p>이제 <code>ThemeColorSetter</code> 컴포넌트를 만들어주자.</p>
<pre><code class="language-tsx">// src/components/ThemeColorSetter.tsx
&#39;use client&#39;;

import { useTheme } from &#39;next-themes&#39;;
import { useEffect, useState } from &#39;react&#39;;

export default function ThemeColorSetter() {
  const { resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() =&gt; {
    // React 19의 cascading renders 에러 방지를 위한 지연 처리
    const raId = requestAnimationFrame(() =&gt; {
      setMounted(true);
    });
    return () =&gt; cancelAnimationFrame(raId);
  }, []);

  useEffect(() =&gt; {
    if (!mounted) return;

    const color = resolvedTheme === &#39;dark&#39; ? &#39;#121212&#39; : &#39;#ffffff&#39;;
    const metaThemeColors = document.querySelectorAll(
      &#39;meta[name=&quot;theme-color&quot;]&#39;,
    );

    if (metaThemeColors.length &gt; 0) {
      metaThemeColors.forEach((meta) =&gt; {
        if (meta.getAttribute(&#39;content&#39;) !== color) {
          meta.setAttribute(&#39;content&#39;, color);
        }
      });
    } else {
      const meta = document.createElement(&#39;meta&#39;);
      meta.name = &#39;theme-color&#39;;
      meta.content = color;
      document.head.appendChild(meta);
    }
  }, [resolvedTheme, mounted]);

  return null;
}</code></pre>
<p>위처럼 동적으로 <code>meta</code> 태그의 속성을 바꿔줘야 한다.</p>
<p>마운트가 되기 전에는 <code>meta</code> 태그에 접근했을 때 <code>undefined</code> 가 뜰 수 있기 때문에, 안전하게 수정되도록 마운트 후에 수정되도록 해주었다.</p>
<p>이제 동적으로 주소창도 테마 색상을 바꾸는 것은 끝났다.
하지만 한 가지 더 고려해줘야 하는게 있다.</p>
<p>테마 색상은 <code>localStorage</code> 에서 테마 색상을 꺼내오는 로직이기 때문에, 앱 실행과 테마 값을 불러오는 그 사이 지연시간이 있다.</p>
<p>그 동안은 <code>manifest</code> 에서 설정해준 색상으로 주소창 색상이 결정된다.
유저는 설정한 테마 색상이 아닌 다른 테마 색상이 나왔다가 바뀌는 모습을 지켜보게 된다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/f0befbb0-30c4-424d-b71c-ecc2aa784868/image.gif" alt=""></p>
<p>마지막으로 이를 보완해보자.</p>
<h3 id="앱-로딩시에도-테마-색상이-적용되도록-수정">앱 로딩시에도 테마 색상이 적용되도록 수정</h3>
<p>현재 문제인건 <code>ThemeColorSetter</code> 는 클라이언트 컴포넌트이기 때문에, 브라우저가 HTML 파일을 내려받고 자바스크립트를 해석해서 실행하기 전까지는 주소창이 어떤 색이어야 할지 모르는 상태이다.</p>
<p>그래서 Next.js가 기본으로 렌더링한 초기값이 잠깐 보였다가 자바스크립트가 실행된 후에야 색이 바뀌는 것이다.</p>
<p>이를 해결하기 위해 자바스크립트가 로드되길 기다리지 않고, HTML이 읽히자마자 즉시 실행되는 인라인 스크립트를 삽입해야 한다.</p>
<p><code>layout.tsx</code> 의 <code>&lt;head&gt;</code> 안에 테마를 확인하고 즉시 메타 태그를 수정하는 스크립트를 넣으면 해결된다.</p>
<pre><code class="language-tsx">export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;ko&quot; suppressHydrationWarning&gt;
      &lt;head&gt;
        {/* 테마 깜빡임을 방지하기 위한 인라인 스크립트 */}
        &lt;script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  const theme = localStorage.getItem(&#39;theme&#39;);
                  const supportDarkMode = window.matchMedia(&#39;(prefers-color-scheme: dark)&#39;).matches;
                  const isDark = theme === &#39;dark&#39; || (theme === &#39;system&#39; &amp;&amp; supportDarkMode) || (!theme &amp;&amp; supportDarkMode);
                  const color = isDark ? &#39;#121212&#39; : &#39;#ffffff&#39;;

                  // 메타 태그 생성 또는 수정
                  let meta = document.querySelector(&#39;meta[name=&quot;theme-color&quot;]&#39;);
                  if (!meta) {
                    meta = document.createElement(&#39;meta&#39;);
                    meta.name = &#39;theme-color&#39;;
                    document.head.appendChild(meta);
                  }
                  meta.setAttribute(&#39;content&#39;, color);

                  // 시스템 배경색과 일치시키기 위해 &lt;html&gt; 클래스도 미리 제어
                  if (isDark) {
                    document.documentElement.classList.add(&#39;dark&#39;);
                  } else {
                    document.documentElement.classList.remove(&#39;dark&#39;);
                  }
                } catch (e) {}
              })();
            `,
          }}
        /&gt;
      &lt;/head&gt;
      &lt;body className=&quot;...&quot;&gt;
        {/* ... 기존 내용 */}
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p><code>body</code> 가 렌더링되기 전, 즉 HTML 헤더를 읽는 즈시 스크립트가 실행되기 때문에 컴포넌트가 마운트될 때가지 기다릴 필요가 없다.</p>
<p>성능적으론 짧은 순수 자바스크립트이기 때문에, 성능 저하 거의 없이 문제를 해결할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/61687726-3274-4c73-b8db-48e0ae2119d2/image.gif" alt=""></p>
<blockquote>
<p>pwa를 지원하면서 유저가 어떻게 이 웹을 다운받아 어플로 사용할 수 있는지 모를 수도 있다.
이를 위해서 브라우저별로 어떻게 다운받는지를 알려주기 위해 추가 작업을 진행했다.
이에 대해서는 다음 포스트에서 다룬다.</p>
</blockquote>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://nextjs.org/docs/app/guides/progressive-web-apps">Next.js 공식문서 ‘Next.js를 사용하여 프로그레시브 웹 애플리케이션(PWA)을 구축하는 방법’</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/display">MDN 공식문서</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/categories">https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/categories</a></li>
<li><a href="https://realfavicongenerator.net/your-favicon-is-ready">https://realfavicongenerator.net/your-favicon-is-ready</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[하이드레이션 불일치 문제(hydration mismatch) 해결 방법 정리]]></title>
            <link>https://velog.io/@dobby_/%ED%95%98%EC%9D%B4%EB%93%9C%EB%A0%88%EC%9D%B4%EC%85%98-%EB%B6%88%EC%9D%BC%EC%B9%98-%EB%AC%B8%EC%A0%9Chydration-mismatch-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@dobby_/%ED%95%98%EC%9D%B4%EB%93%9C%EB%A0%88%EC%9D%B4%EC%85%98-%EB%B6%88%EC%9D%BC%EC%B9%98-%EB%AC%B8%EC%A0%9Chydration-mismatch-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 16 Feb 2026 06:01:49 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>그룹 프로젝트를 진행하면서 하이드레이션 에러를 정말 많이 마주했는데, 어떤 문제이고 어떻게 해결하는지를 정리하면 나중에 보기 편할 것이라 생각해서 정리해봤다.</p>
</blockquote>
<h2 id="서버와-클라이언트의-렌더링-불일치">서버와 클라이언트의 렌더링 불일치</h2>
<p>서버 사이드에서 받은 HTML이 클라이언트 사이드에서 렌더링한 HTML이 다를 때 hydration 에러가 발생하게 된다.</p>
<h3 id="하이드레이션hydration">하이드레이션(hydration)</h3>
<blockquote>
<p><a href="https://nextjs.org/docs/messages/react-hydration-error">Next.js 공식문서</a>:
React가 서버에서 미리 렌더링된 HTML을 이벤트 핸들러를 연결하여 완전한 상호작용 애플리케이션으로 변환하는 단계입니다.</p>
</blockquote>
<ol>
<li>서버 사이드 렌더링: 서버에서 HTML 생성 후 클라이언트에 전송하는 과정</li>
<li>클라이언트 사이드 하이드레이션: 클라이언트 측에서 React가 이 HTML을 받아서 DOM을 분석하고 이를 기반으로 virtual DOM을 생성, 이벤트 핸들러 추가 등의 작업을 수행하는 과정</li>
</ol>
<p>하이드레이션은 SEO에 친화적이며 초기 로딩 속도를 줄여 사용자에게 빠르게 웹페이지를 제공할 수 있다는 장점이 있다.</p>
<h3 id="하이드레이션-에러의-일반적인-원인">하이드레이션 에러의 일반적인 원인</h3>
<ul>
<li>HTML 태그의 잘못된 중첩</li>
<li>렌더링 로직에서 <code>typeof window !== &#39;undefined</code> 사용</li>
<li>렌더링 로직에서 브라우저 전용 API (ex localStorage) 사용</li>
<li>렌더링 로직에서 <code>Date()</code> 생성자와 같은 시간 API 사용</li>
</ul>
<h3 id="hydration-mismatch-해결-방법">hydration mismatch 해결 방법</h3>
<ul>
<li><code>useEffect</code> 로 클라이언트에서만 실행하도록 설정</li>
</ul>
<p>보통은 하이드레이션 에러를 해결하기 위해 <code>useEffect</code> 를 사용한다.
마운트된 이후 작업을 하도록 렌더링을 지연시키는 것이다.</p>
<pre><code class="language-tsx">import { useState, useEffect } from &#39;react&#39;

export default function App() {
  const [isClient, setIsClient] = useState(false)

  useEffect(() =&gt; {
    setIsClient(true)
  }, [])

  return &lt;h1&gt;{isClient ? &#39;This is never prerendered&#39; : &#39;Prerendered&#39;}&lt;/h1&gt;
}</code></pre>
<p>하지만 <code>useEffect</code> 는 마운트 즉시 호출되는 훅이 아니기 때문에 DOM에 마운트 된 후 임의의 시간이 지나 호출된다.</p>
<p>그래서 <code>useEffect</code> 로 hydration mismatch를 해결하게 되면 일부 UI가 깜빡거리는 현상을 마주하게 될 수 있다.</p>
<ul>
<li>특정 컴포넌트에서 <code>SSR</code> 비활성화</li>
</ul>
<p>동적 import로 컴포넌트를 불러올 때, SSR을 비활성화해 하이드레이션 불일치 문제를 방지할 수 있다.</p>
<pre><code class="language-tsx">import dynamic from &#39;next/dynamic&#39;

const NoSSR = dynamic(() =&gt; import(&#39;../components/no-ssr&#39;), { ssr: false })

export default function Page() {
  return (
    &lt;div&gt;
      &lt;NoSSR /&gt;
    &lt;/div&gt;
  )
}</code></pre>
<ul>
<li><code>suppressHydrationWarning</code></li>
</ul>
<p>React에서 제공하는 플래그로, hydration mismatch에 따른 에러를 무시하게 해준다(억제 해준다.)</p>
<p>텍스트 콘텐츠로 인해 발생하게 된 하이드레이션 불일치 문제를 억제해준다.</p>
<pre><code class="language-tsx">&lt;html lang=&quot;en&quot; suppressHydrationWarning={true}&gt;</code></pre>
<p>Next.js 공식문서에서 너무 자주 사용하지 말라는 말이 있었기에, 불가피하게 서버와 클라이언트 간에 콘텐츠가 달라지는 경우만 사용해야한다.</p>
<ul>
<li>현재 시간 표시: 서버 시간과 클라이언트 사용자 시간이 다를 때</li>
<li>브라우저 고유 정보: <code>localStorage</code> 의 데이터를 기반으로 초기 UI가 결정될 때</li>
<li>랜덤 숫자: <code>Math.random()</code> 을 초기 렌더링에 사용할 때</li>
</ul>
<p>이유는 성능, UX, 잠재적 버그와 직결되기 때문이다.</p>
<p><code>suppressHydrationWarning</code> 은 근본적인 원인을 해결하는 것이 아니라 경고만 끄는 것이다.</p>
<p>만약 불일치가 복잡한 DOM 구조에서 발생하면, React가 실제 DOM을 다시 맞추는 과정에서 기존 HTML을 다 날리고 처음부터 다시 렌더링해야 할 수도 있다.</p>
<p>이 경우 경고는 안뜨지만, 실제로는 보이지 않는 성능 저하가 계속 발생하게 되는 셈이다.</p>
<p>또한 공식문서에 따르면 해당 요소 바로 아래 텍스트나 속성에만 적용되기 때문에 만약 깊은 자식 컴포넌트까지 불일치가 전파되고 있다면, 최상위 태그에 플래그를 달아도 하위 요소에서 발생하는 모든 문제를 완벽하게 방어해주지 못한다.</p>
<p>즉, 사용한다고 해서 모든 하이드레이션 불일치 문제가 해결되는 것은 아니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://velog.io/@page1597/%ED%95%98%EC%9D%B4%EB%93%9C%EB%A0%88%EC%9D%B4%EC%85%98-%EB%B6%88%EC%9D%BC%EC%B9%98-%EA%B2%BD%EA%B3%A0">https://velog.io/@page1597/하이드레이션-불일치-경고</a></li>
<li><a href="https://medium.com/@jiwoochoics/%EC%96%B4%EC%A9%94-%EC%88%98-%EC%97%86%EB%8A%94-hydration-mismatch%EB%A5%BC-useeffect%EC%97%86%EC%9D%B4-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-c984c9120f9b">https://medium.com/@jiwoochoics/어쩔-수-없는-hydration-mismatch를-useeffect없이-해결하기-c984c9120f9b</a></li>
<li><a href="https://nextjs.org/docs/messages/react-hydration-error">https://nextjs.org/docs/messages/react-hydration-error</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[next-themes 다크/라이트 모드 지원하기]]></title>
            <link>https://velog.io/@dobby_/next-themes-%EB%8B%A4%ED%81%AC%EB%9D%BC%EC%9D%B4%ED%8A%B8-%EB%AA%A8%EB%93%9C-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/next-themes-%EB%8B%A4%ED%81%AC%EB%9D%BC%EC%9D%B4%ED%8A%B8-%EB%AA%A8%EB%93%9C-%EC%A7%80%EC%9B%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 05:56:56 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>네부캠 그룹 프로젝트 마감 기한이 빡빡해서, 포스팅할 시간이 없었다.
지금부터 조금씩 정리했던 내용들을 포스팅하려고 한다.</p>
</blockquote>
<p>우리 팀은 &#39;잇다-&#39; 라는 서비스를 만들고 있다. <a href="https://github.com/boostcampwm2025/web25-ittda">github</a></p>
<blockquote>
<p>개인의 기록을 넘어, 함께 만드는 추억을 위한 기록 서비스</p>
</blockquote>
<p>기록 어플은 시중에 나와있는 서비스가 정말 많다.
나는 그 많은 서비스 중에서 유저의 유입을 고려한다면 디자인이 가장 중요하다고 생각했다.</p>
<p>나만 봐도, 디자인이 별로면 아무리 기능이 뛰어나도 바로 삭제한다.
그래서 디자인적으로 고민을 많이 했고, 다크 모드를 많이 사용하는 사람으로서 다크모드도 넣는게 좋을 것 같았다.</p>
<p>그래서 이번엔 다크모드를 지원하는 과정을 포스팅하려고 한다.</p>
<br />

<h2 id="배경">배경</h2>
<p>tailwind에서 다크/라이트 모드를 제공하는 방법은 간단하다.
<code>class</code> 에 <code>dark:*</code> 로 다크모드일 때의 스타일을 지정해주면 된다.</p>
<pre><code class="language-tsx">&lt;span className=&quot;dark:text-gray-200 text-itta-black&quot;&gt;
  다크 모드
&lt;/span&gt;</code></pre>
<p>그리고 다크 모드로 제공하고자 할 때는 <code>html</code> 태그의 <code>class</code> 에 <code>dark</code> 를 추가해주면 된다.</p>
<pre><code class="language-tsx">&lt;html lang=&quot;ko&quot; className=&quot;dark&quot;&gt;</code></pre>
<p><code>html</code> 태그에 해당 class가 존재하냐에 따라 테마를 다르게 지원할 수 있다.
물론 tailwind 버전에 맞게 class로 테마를 설정하는 코드를 작성해줘야 할 수도 있다.</p>
<pre><code class="language-tsx">@import &quot;tailwindcss&quot;;
@custom-variant dark (&amp;:where(.dark, .dark *));</code></pre>
<p>이 부분은 <a href="https://tailwindcss.com/docs/dark-mode">공식문서</a>를 참고하면 좋을 것 같다.</p>
<p>여기에서 고려해줘야 할 부분이 있다.
매번 <code>html</code> 태그에 <code>dark</code> 를 수동으로 추가해줄 순 없기 때문에, 스크립트를 작성해줘야 한다.</p>
<p>유저가 어떤 페이지에 접근하는지 알 수 없기 때문에, 모든 페이지를 고려해 테마 설정은 <code>Context API</code> 를 주로 사용한다.</p>
<p>또한 새로고침을 해도 테마가 유지되어야 하기에 <code>localStorage</code> 와 같은 스토리지에 테마 값을 저장한다.</p>
<p>테마가 적용되는 흐름은 다음과 같다. (next 기준)</p>
<ul>
<li>서버: HTML을 생성</li>
<li>브라우저: HTML 로드<ul>
<li>React hydration</li>
<li>Context에서 localStorage 읽음</li>
<li>테마 적용</li>
</ul>
</li>
</ul>
<p>이 과정에서 문제가 발생하게 된다.</p>
<p>HTML 로드와 테마 적용 사이에 시간차가 발생하면서 화면이 깜빡이게 된다.
네트워크 지연 혹은 성능이 좋지 않은 디바이스에선 더 두드러질 것이다.</p>
<p>이 문제를 해결하는 방법 중 하나는 <code>blocking script</code> 를 사용하는 것이다.</p>
<pre><code class="language-tsx">    &lt;html&gt;
      &lt;head&gt;
        &lt;script dangerouslySetInnerHTML={{
          __html: `
            (function() {
              try {
                const theme = localStorage.getItem(&#39;theme&#39;);
                if (theme === &#39;dark&#39;) {
                  document.documentElement.classList.add(&#39;dark&#39;);
                }
              } catch (e) {}
            })();
          `
        }} /&gt;
      &lt;/head&gt;
      &lt;body&gt;
        &lt;ThemeProvider&gt; {/* Context API */}
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;</code></pre>
<p><code>blocking script</code> 는 React가 로딩되기 전, HTML 파싱 중 즉시 실행되며 React hydration을 기다리지 않는다.</p>
<p>그렇기에 <code>&lt;html&gt;</code> 태그에 바로 <code>dark</code> 클래스를 추가할 수 있어 깜빡임 문제를 개선할 수 있다.</p>
<p>직접 구현하는 것도 충분히 가능하지만, <code>blocking script</code> 로직과 여러 예외 처리를 직접 작성해줘야 한다.</p>
<p>또한 <code>localStorage</code> 에서 값을 가져오고 저장하는 로직도 추가로 작성해줘야 한다.</p>
<p>이런 번거로움을 줄이고자 next 테마를 설정하도록 지원해주는 라이브러리가 있는데, 그게 <code>next-themes</code> 이다.
번들 사이즈도 약 1.5kB로 작기 때문에, 시간과 안정성 측면에서 활용하는게 좋을 것 같다고 판단했다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/bac42e8c-fe73-46cc-954e-b7d6015d9568/image.png" alt=""></p>
<p><a href="https://bundlephobia.com/package/next-themes@0.4.6">출처</a></p>
<br />

<h2 id="next-themes">next-themes</h2>
<blockquote>
<p>Next.js에서 다크모드를 비롯한 테마 변경을 쉽게 구현할 수 있도록 하는 라이브러리</p>
</blockquote>
<p>클라이언트 측에서 <code>localStorage</code> 또는 <code>class</code> 속성을 사용해 테마를 적용하고 유지하도록 지원한다.</p>
<h3 id="주요-기능-및-이점">주요 기능 및 이점</h3>
<ul>
<li><code>useTheme()</code> 훅을 사용해 테마를 간편하게 변경할 수 있음</li>
<li>사용자가 선택한 테마를 <code>localStorage</code>로 유지</li>
<li><code>class</code> 기반 테마를 지원해 tailwindCSS를 활용할 때 유용</li>
<li>system 옵션을 사용해 사용자의 OS 테마 설정에 따라 자동으로 테마가 적용되도록 할 수 있음</li>
</ul>
<br />

<h3 id="사용법">사용법</h3>
<p><a href="https://medium.com/@kevstrosky/theme-colors-with-tailwind-css-v4-0-and-next-themes-dark-light-custom-mode-36dca1e20419">참고자료</a></p>
<pre><code class="language-tsx">pnpm install next-themes</code></pre>
<p>이제 layout에서 <code>ThemeProvider</code> 를 사용할 수 있으며, 이를 통해서 서비스 전체의 테마 색상을 변경할 수 있다.</p>
<blockquote>
<p><code>ThemeProvider</code> 는 서버 컴포넌트가 아닌 클라이언트 컴포넌트이다.
하지만 Provider 하위의 모든 컴포넌트가 CSR로 바뀌는 것은 아니다.
서버에서 <code>ThemeProvider</code> 포함 전체 HTML을 SSR에서 생성하고, 브라우저에서 해당 HTML을 받아 hydration 과정을 거치게 된다.</p>
</blockquote>
<p><code>layout.tsx</code> 파일을 다음과 같이 수정해준다.</p>
<pre><code class="language-tsx">import { ThemeProvider } from  &quot;next-themes&quot; ; 

export  default  function  RootLayout ( { 
  children, 
}: Readonly&lt;{ 
  children: React.ReactNode; 
}&gt; ) { 
  return ( 
    &lt;html lang = &quot;en&quot; suppressHydrationWarning&gt; 
      &lt;body className = {`${ geistSans.variable} antialiased`}&gt; 
        &lt;ThemeProvider attribute=&quot;class&quot; enableSystem={true} defaultTheme=&quot;system&quot;&gt;
           {children} 
        &lt;/ThemeProvider &gt; 
      &lt;/body &gt; 
    &lt;/html &gt;
   ); 
}</code></pre>
<p><code>suppressHydrationWarning</code> 속성을 추가하지 않으면 <code>next-themes</code> element가 업데이트될 때 경고가 표시된다.</p>
<p>서버 컴포넌트 환경에서 클라이언트가 테마를 결정하며 속성을 바꿀 때 발생하는 불일치 경고를 해결하기 위함이다.</p>
<p>특히 다크 모드처럼 <strong>클라이언트에서만 확정되는 값이 HTML 속성(class)에 반영될 때</strong> 자주 발생</p>
<p>한 단계 깊이까지만 적용되기 때문에, 다른 element의 hydration 경고는 차단하지 않는다.</p>
<ul>
<li><code>enableSystem={true}</code><ul>
<li>사용자의 운영 체제를 자동으로 감지할 수 있다. 사용자가 시스템을 다크 모드 또는 라이트 모드로 설정한 경우, <code>next-themes</code> 가 테마를 시스템 설정에 맞게 조정한다.</li>
</ul>
</li>
<li><code>defaultTheme=&quot;system&quot;</code><ul>
<li>사용자 시스템에 설정된 테마가 기본 테마로 사용됨을 나타낸다. 다른 테마가 지정되지 않은 경우, 기본적으로 시스템 테마를 사용한다.</li>
</ul>
</li>
<li><code>attribute=&quot;class&quot;</code><ul>
<li>테마 정보를 HTML의 어떤 속성(<code>attribute</code>)에 기록할지 결정한다.</li>
<li><code>next-themes</code> 가 테마를 바꿀 때마다 <code>&lt;html&gt;</code> 태그에 <code>&lt;html class=&quot;dark&quot;&gt;</code> 또는 <code>&lt;html class=&quot;light&quot;&gt;</code> 를 자동으로 넣어준다.</li>
</ul>
</li>
</ul>
<p>테마에 맞는 적절한 색상을 지정하고 싶을 땐 <code>.global.css</code> 파일을 적절히 수정하면 된다.</p>
<p>나는 <code>class</code> 로 테마를 수정하므로 다음과 같이 설정해줬다. (기본)</p>
<pre><code class="language-tsx">@custom-variant dark (&amp;:is(.dark *));</code></pre>
<p>이제 테마를 변경하는 토글 버튼을 추가하자</p>
<pre><code class="language-tsx">import { useTheme } from &#39;next-themes&#39;;
import { cn } from &#39;@/lib/utils&#39;;
import { useState, useEffect } from &#39;react&#39;;

export default function Setting() {
  const router = useRouter();
  const { theme, setTheme, resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() =&gt; {
    // React 19의 cascading renders 에러 방지를 위한 지연 처리
    const raId = requestAnimationFrame(() =&gt; {
      setMounted(true);
    });
    return () =&gt; cancelAnimationFrame(raId);
  }, []);

  // Hydration 불일치를 방지하기 위해 마운트되기 전에는 아무것도 렌더링하지 않음
  if (!mounted) {
    return null;
  }

  const toggleDarkMode = () =&gt; {
    setTheme(theme === &#39;dark&#39; ? &#39;light&#39; : &#39;dark&#39;);
  };

  const currentTheme = resolvedTheme;

  return (
    &lt;div className=&quot;flex items-center justify-between&quot;&gt;
      &lt;div className=&quot;flex items-center gap-3&quot;&gt;
        &lt;div className=&quot;p-2 rounded-lg transition-colors dark:bg-purple-500/10 dark:text-purple-400 bg-yellow-50 text-yellow-500&quot;&gt;
          {currentTheme === &#39;dark&#39; ? (
            &lt;Moon className=&quot;w-4 h-4&quot; /&gt;
          ) : (
            &lt;Sun className=&quot;w-4 h-4&quot; /&gt;
          )}
        &lt;/div&gt;
        &lt;span className=&quot;text-sm font-bold dark:text-gray-200 text-itta-black&quot;&gt;
          다크 모드
        &lt;/span&gt;
      &lt;/div&gt;
      &lt;button
        onClick={toggleDarkMode}
        className={cn(
          &#39;cursor-pointer w-11 h-6 rounded-full relative transition-all duration-300&#39;,
          !mounted
            ? &#39;bg-gray-200&#39;
            : currentTheme === &#39;dark&#39;
              ? &#39;bg-purple-500&#39;
              : &#39;bg-gray-200&#39;,
        )}
      &gt;
        &lt;div
          className={cn(
            &#39;absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-300 ease-in-out&#39;,
            mounted &amp;&amp; currentTheme === &#39;dark&#39; &amp;&amp; &#39;translate-x-5&#39;,
          )}
        /&gt;
      &lt;/button&gt;
    &lt;/div&gt;
  )</code></pre>
<ul>
<li><code>useTheme</code><ul>
<li><code>next-themes</code> 에서 제공하는 훅을 통해서 현재 테마 상태(<code>theme</code> )를 읽고, 원하는 테마로 변경(<code>setTheme</code> )할 수 있다.</li>
</ul>
</li>
<li>mounted 상태 관리 (hydration 에러 방지)<ul>
<li>서버 렌더링 시의 테마와 클라이언트에서 실제로 적용되는 테마가 다를 수 있는데, 이로 인한 hydration 불일치를 방지하기 위해 클라이언트 마운트 이후에만 UI를 렌더링하도록 한다.</li>
</ul>
</li>
<li>react19 대응 및 무한 루프 방지<ul>
<li><code>useEffect</code> 안에서 동기적으로 <code>setState()</code> 를 호출하면, 
렌더링 → effect → setState → 리렌더링 되는 반복 루프 가능성 때문에 경고를 보여준다.</li>
<li>이를 방지하기 위해 <code>requestAnimationframe</code> 을 활용해 마운트 후 상태 업데이트 다음 repaint 직전으로 지연시켜주었다.</li>
</ul>
</li>
</ul>
<blockquote>
<p><a href="https://velog.io/@wejaan/setTimeout%EA%B3%BC-requestAnimationFrame">setTimeout과 requestAnimationframe</a></p>
</blockquote>
<br />

<h2 id="결과">결과</h2>
<p><img src="https://velog.velcdn.com/images/dobby_/post/9e9647e4-05d4-4332-b8ef-dfe7dc5bb44d/image.gif" alt=""></p>
<p>디자인을 수정하기 전에 찍었던 영상이라 현재 배포 버전과 레이아웃이 다른 부분이 있다.</p>
<blockquote>
<p>mov -&gt; gif 변환하면서 영상이 많이 느려졌다.</p>
</blockquote>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://velog.io/@maruru301/SpennyTIL-2-Next.js-%EB%8B%A4%ED%81%AC%EB%AA%A8%EB%93%9C-%EC%84%A4%EC%A0%95-next-themes">https://velog.io/@maruru301/SpennyTIL-2-Next.js-다크모드-설정-next-themes</a></li>
<li><a href="https://medium.com/@kevstrosky/theme-colors-with-tailwind-css-v4-0-and-next-themes-dark-light-custom-mode-36dca1e20419">https://medium.com/@kevstrosky/theme-colors-with-tailwind-css-v4-0-and-next-themes-dark-light-custom-mode-36dca1e20419</a></li>
<li><a href="https://bundlephobia.com/package/next-themes@0.4.6">https://bundlephobia.com/package/next-themes@0.4.6</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[profiler와 performance로 성능 개선하기]]></title>
            <link>https://velog.io/@dobby_/profiler%EC%99%80-performance%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/profiler%EC%99%80-performance%EB%A1%9C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 03 Dec 2025 09:53:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>다른 팀 프로젝트를 리팩토링하면서 한 성능개선 작업을 정리했습니다.</p>
</blockquote>
<p>성능 개선시 활용되는 브라우저 도구들은 다음과 같다.</p>
<ul>
<li>performance(성능): 웹 애플리케이션의 런타임 성능을 기록하고 분석할 수 있는 탭</li>
<li>profiler(프로파일러): React 개발자 도구의 일부로, React 컴포넌트의 렌더링 성능을 프로파일링한다.</li>
</ul>
<p>profiler를 통해 컴포넌트 렌더링 횟수와 성능을 측정하고, performance로 메인 스레드 사용량을 알 수 있다.</p>
<br />

<h2 id="performance-tab">Performance tab</h2>
<p>웹 페이지의 로딩 성능, 렌더링 성능, 스크립트 실행 성능 등을 측정하고 분석하는데 사용된다.</p>
<p>네트워크, CPU, FPS 등을 측정할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/67ea0f58-c946-4f7d-a6cb-adf9715b5afd/image.png" alt=""></p>
<p>녹화 버튼을 통해 특정 인터렉션 혹은 기능에 대한 성능을 측정해보면, 위의 사진처럼 표시된다.</p>
<p>위 사진은 <code>Main Timeline</code>  인데, 주로 웹사이트의 메인 스레드를 가리킨다.</p>
<p>이 메인 스레드에서 Javascript의 실행, 스타일 계산, 레이아웃 생성 등 웹사이트의 주요 동작들이 처리된다.</p>
<p>메인 스레드에서 얼마나 시간을 소요하는지를 확인할 수 있다.</p>
<p>Main 영역에서 소요되는 시간이 50ms를 초과하면 긴 작업으로 판단해 빨간색 태그를 붙여준다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/43af5ad0-7682-485a-aa1d-555ed2608351/image.png" alt=""></p>
<br />

<h2 id="profiler-tab">Profiler tab</h2>
<p>profiler 기능은 <code>react-dom 16.5</code> 이상의 개발자 모드에서 지원한다.
프로덕션 프로파일링도 지원하는데, 다른 방법을 사용해야 한다.</p>
<p>조금 지난 문서이긴 한데, <a href="https://legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html">공식문서</a>에서 설명을 해주고 있다.</p>
<p>기본적으로 React는 두 단계로 작동한다.</p>
<ol>
<li>렌더링 단계: DOM에 어떤 변경이 필요한지 결정하며, 호출한 <code>render</code> 후 결과를 이전 렌더링 결과와 비교한다.</li>
<li>커밋 단계: React가 변경사항을 적용하는 단계.(React DOM 노드 삽입, 업데이트, 제거) 여기서 <code>componentDidMount</code> , <code>componentDidUpdate</code> 와 같은 라이프사이클을 호출한다.</li>
</ol>
<br />

<h2 id="드래그-리렌더링-성능-개선">드래그 리렌더링 성능 개선</h2>
<p>profiler로 리렌더 상태를 확인하고자 했다.</p>
<p>리렌더 성능에 가장 큰 부분을 차지할 기능은 윈도우 drag &amp; drop이기 때문에, 이 부분을 살펴봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/a23c1259-133d-4d06-8173-0f82359b9a91/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/3b4ff82f-73c1-4307-bc8e-2a471c49d1ee/image.png" alt=""></p>
<p>4개의 윈도우를 띄우고 윈도우를 이동시켜봤다.
움직이는 윈도우 뿐만 아니라, 화면에 표시된 모든 윈도우들이 모두 함께 리렌더되고 있었다.</p>
<p>물론 바탕 화면(<code>WallPaper</code>)도 마찬가지였다.</p>
<p>여기서 바의 색상은 함수 실행의 시간을 의미한다.</p>
<ul>
<li><strong>초록색</strong>: 렌더링 시간이 짧고 최적 (성능에 큰 영향을 미치지 않는다.)</li>
<li><strong>주황색</strong>: 렌더링 시간이 상대적으로 길고 최적화가 필요하다. 성능 저하의 원인이 될 수 있다.</li>
<li><strong>노란색</strong>: 주황색보다는 덜 심각하지만 여전히 시간이 소요되는 구간</li>
<li><strong>회색</strong>: 리렌더링이 발생하지 않았다는 의미</li>
<li><strong>회색 빗금바</strong>: 다시 렌더링되지 않은 컴포넌트</li>
</ul>
<p>performance도 돌려봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/0e2da07f-6cca-432d-b38b-54f0693c6cb0/image.png" alt=""></p>
<p>가장 큰 부분을 차지하는건 <code>Event: mousedown</code> 처리였다.</p>
<p>이벤트 처리와 관련 작업에 소요된 총 시간이 103.2ms로 <code>Long Task</code> 로 분류되었다.</p>
<p>그리고 내부를 확인해 보면 <code>Function call</code> 이 많은 시간을 차지했는데, 이 <code>Function call</code> 은 주로 React의 업데이트(렌더링, 커밋 등) 또는 데이터 처리와 관련된 작업이다.</p>
<p><code>run</code> 작업도 보면, 함수 호출 내부에서 불필요하게 많은 컴포넌트가 리렌더링 되거나 복잡한 계산이 동기적으로 이루어졌을 가능성이 높다. 사실 console 출력도 동기적으로 이루어지기 때문에 콘솔 출력이 많아지면 성능에도 영향을 줄 수 있다.</p>
<p>위 performance와 profiler를 통해 마우스 이벤트에 대한 후속 작업에 리소스가 많이 쓰인다는걸 알았으니 이를 개선해보자.</p>
<p>현재 프로젝트는 마우스 이벤트는 리렌더에 직접적인 영향을 주고 있다. 그리고 리렌더 후 서버에 API 요청을 보내 처리한다.</p>
<p>가장 영향이 클 윈도우 리렌더링 이슈를 개선해볼까 한다.</p>
<br />

<h3 id="원인-분석">원인 분석</h3>
<p>profiler 사진의 오른쪽 사이드바를 보면, <code>What caused this update?</code> 를 볼 수 있다.
이 렌더링의 원인이 무엇인지를 알려주는데, <code>WallPaper</code> 때문이라고 한다.</p>
<p>그렇다면 <code>WallPaper</code> 컴포넌트를 살펴보자.</p>
<pre><code class="language-tsx">// wallpaper.tsx
const {
    windowList,
    handleFetchClose,
    handleFetchOpen,
    handleMove,
    handleResize,
    handleFocus,
    handleHide,
    handleShow,
  } = useWindowManager(isBoot);</code></pre>
<p>이렇게 <code>window</code> 와 관련된 함수와 데이터를 상위 컴포넌트인 <code>WallPaper</code> 컴포넌트에서 관리해 내려주고 있었다.</p>
<p>그래서 window의 상태(위치)가 바뀔 때마다 <code>windowList</code> 가 재생성되면서 wallpaper가 함께 리렌더 되던 것이다.</p>
<p>wallpaper가 리렌더되면서 자식 컴포넌트도 같이 리렌더링되는 문제였다.
이전 팀은 전역 상태를 거의 사용하지 않고 지역 상태 위주로 상태들을 관리해줬다.</p>
<p>그렇기에 상위에서 상태를 관리하고, 이를 자식으로 props drilling으로 전달해주고 있었다.</p>
<p>이로 인해 발생한 리렌더링 문제인데, 대부분이 custom hook으로 상태가 관리되고 있었기에 이를 어떻게 개선할지 고민이 됐다.</p>
<p>최대한 코드를 많이 안건들고 싶었는데, 윈도우에 대한 상태를 다른 컴포넌트들로 props drilling 없이 전달하기 위해선 전역 상태로 관리해주는 방법밖에 없었다.</p>
<p>싱글톤도 생각해봤는데, 상태가 불변성을 제공하지 않으면 리렌더가 필요한 곳이 렌더링이 안되는 문제가 발생하기 때문에 subscribe, publish를 만들어줘야 한다.</p>
<p>전역 상태가 그 역할을 대신 해주는데, 굳이 그래야 하나? 라는 생각이 들었다.
거기다 다음 할 일이 또 있었기 때문에 그리 많은 시간을 투자하지 못한다는 점도 컸다.</p>
<p>그래서 빠르게 작업할 수 있는 전역 상태를 활용해 <code>window</code>를 관리해주는걸로 결정했다.</p>
<p>마침 다른 팀원이 <code>zustand</code> 로 전역 상태를 관리하도록 해줘서, 그대로 zustand를 사용하기로 했다.</p>
<h3 id="window-상태들-추리기">window 상태들 추리기</h3>
<p>전역으로 윈도우 상태를 관리하기로 했으니, 윈도우 상태값들이 어떤게 있는지를 정리해야 한다.</p>
<pre><code class="language-tsx">export interface Window {
  id: number;
  title: string;
  x: number;
  y: number;
  z: number;
  width: number;
  height: number;
  isHidden: boolean;
}</code></pre>
<p>마침 type으로 정의된 윈도우가 있고, <code>windowList</code> 도 이 타입들로 관리되고 있어서 그대로 사용하면 될 것 같았다.</p>
<p>이 타입들을 가지고 <code>useWindowStore</code> 를 만들어주고, 상태를 사용하는 컴포넌트에서 호출해주도록 했다.</p>
<pre><code class="language-tsx">  // wallpaper.tsx
  const {
    windowList,
    handleFetchClose,
    handleFetchOpen,
    handleMove,
    handleResize,
    handleFocus,
    handleHide,
    handleShow,
  } = useWindowManager(isBoot);</code></pre>
<p><code>useWindowManager</code> 훅에서 상태 업데이트와 관련 핸들러를 모두 다루고 있어서 분리 실행하기 어려웠다.</p>
<p><code>useWindowManager</code> 는 내부 로직이 다음과 같이 나뉘어진다.</p>
<ul>
<li>sse 데이터 전달받기 및 window 업데이트</li>
<li>window 이벤트 핸들러</li>
</ul>
<p>이를 관련 비즈니스 로직별로 훅을 분리하기로 했다.</p>
<ul>
<li><code>useWindowState</code> : sse 데이터 전달 및 전달받은 데이터로 window 업데이트</li>
<li><code>useWindowActions</code> : window 관련 이벤트 핸들러 관리</li>
</ul>
<p>이후 해당 훅이 필요한 컴포넌트에서 직접 호출해주도록 하고, props drilling을 최소화해주었다.</p>
<pre><code class="language-tsx">// wallpaper.tsx
{isBoot &amp;&amp; (
  &lt;WindowManager
    isBoot={isBoot}
  /&gt;
)}
{isBoot &amp;&amp; (
  &lt;TaskBar
    onRestart={onRestart}
    onShutdown={onShutdown}
  /&gt;
)}</code></pre>
<p>마지막으로 윈도우 내부에서 출력되는 어플리케이션을 메모이제이션 시켜주었다.</p>
<p>리렌더링 확인해봤을 때, <code>Memo</code> 컴포넌트와 <code>FileList</code> 컴포넌트가 실행됐는데, 변경된 상태가 없음에도 리렌더링이 발생하고 있었다.</p>
<p>그리고 이를 살펴보니 사이드바에 적힌 원인은 부모가 리렌더되어 리렌더되었다는 것이었다.</p>
<p>그래서 윈도우 내부에서 실행되는 어플리케이션 컴포넌트에 모두 <code>React.memo</code> 를 적용시켜줬다.</p>
<h3 id="결과">결과</h3>
<p><img src="https://velog.velcdn.com/images/dobby_/post/a7fac378-a22d-4bdc-9390-31ff49e22e95/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/db15decb-d640-43b7-ba17-d7c326df367a/image.png" alt=""></p>
<p>before와 동일하게 가장 높게 측정된 부분을 가져왔다.</p>
<p>저 <code>IconButton</code> 굉장히 마음에 안드는데, memo를 적용했음에도 결과가 똑같아서 그냥 제거하고 그대로 뒀다.</p>
<p>profiler 기준 render time이 107.5ms로 측정되던 렌더링 시간을 9.2ms로 줄였다.
before, after 모두 측정 시 가장 높은 값을 가져온 것이며, <strong>약 91% 개선</strong>시켰다.</p>
<p>performance도 돌려봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/8d60b6c3-ee99-491b-b495-2c90c25187f5/image.png" alt=""></p>
<p>확실히 <code>Long Task</code>가 사라진걸 확인할 수 있다.</p>
<p>그리고 똑같이 position API가 요청될 때의 <code>mousedown</code> 이벤트의 내부 작업을 확인해보면, <code>Fucntion call</code> 이 97.7ms → 43.4ms로 <strong>약 55% 개선</strong>된 것을 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SVG 아이콘 vite 플러그인 vite-plugin-svgr으로 컴포넌트화]]></title>
            <link>https://velog.io/@dobby_/SVG-%EC%95%84%EC%9D%B4%EC%BD%98-vite-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-vite-plugin-svgr%EC%9C%BC%EB%A1%9C-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%ED%99%94</link>
            <guid>https://velog.io/@dobby_/SVG-%EC%95%84%EC%9D%B4%EC%BD%98-vite-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-vite-plugin-svgr%EC%9C%BC%EB%A1%9C-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%ED%99%94</guid>
            <pubDate>Tue, 25 Nov 2025 15:45:18 GMT</pubDate>
            <description><![CDATA[<p>다른 팀의 코드를 리팩토링 중인데, svg 아이콘 하나하나를 파일로 관리해서 불필요한 import가 늘어나고 icon 사용이 효율적이지 않았다.</p>
<p>그래서 이를 개선했던 과정을 담아봤다.</p>
<br />

<h2 id="svg-아이콘-통합">SVG 아이콘 통합</h2>
<p>svg sprite와 vite svg 컴포넌트 중 어떤걸 사용할까 고민하다, 일단 비교를 해봤다.</p>
<h3 id="1-svg-sprite">1. svg sprite</h3>
<p>HTTP 요청 수가 1개(sprite 파일)이며 이후엔 캐싱되기 때문에 네트워크 요청이 줄어든다는 점이 장점이다.</p>
<p>하지만 파일 크기가 커질 수록 느려지기 때문에 성능 이슈가 발생할 수 있다.
또한 스타일링이 <code>fill</code> 과 <code>stroke</code> 로 직접 svg로 제어하거나, <code>currentColor</code> 로 설정해두고 tailwind로 수정이 가능하다.</p>
<h3 id="2-vite-svg-컴포넌트화">2. vite svg 컴포넌트화</h3>
<p>svg 파일을 import 해서 컴포넌트처럼 사용할 수 있지만, 플러그인에 의존한다.</p>
<p>HTTP 요청 수는 0개(번들에 포함돼서)이며 코드 분할이 가능하다는 장점이 있다. 또한 초기 로드시 필요한 아이콘만 로드하기 때문에 빠르다.</p>
<p>컴포넌트처럼 사용되기 때문에, 스타일링도 props나 css-in-js 등으로 직접 제어할 수 있다.</p>
<br />

<h3 id="아이콘-통합-방법-결정">아이콘 통합 방법 결정</h3>
<p>비교는 해봤지만, 사실 아이콘이 18개 정도이고 더 추가될 가능성이 없기 때문에 성능상 두 방법은 차이가 없다고 봐도 된다.</p>
<p>스타일링도 코드를 보면 딱히 해주고 있는게 없고 <code>img</code> 태그로 넣어주기만 해서 굳이 필요 없다.</p>
<p>그래서 어떻게 아이콘들을 불러오는지를 확인해봤다.</p>
<pre><code class="language-tsx">// memo/utils/icons.ts
import bin from &quot;@/assets/bin.svg&quot;;
import post from &quot;@/assets/post.svg&quot;;
import search from &quot;@/assets/search.svg&quot;;
import bulb from &quot;@/assets/bulb.svg&quot;;
import onoff from &quot;@/assets/onoff.svg&quot;;

import bold from &quot;@/assets/bold.svg&quot;;
import italic from &quot;@/assets/italic.svg&quot;;
import strike from &quot;@/assets/strike.svg&quot;;
import code from &quot;@/assets/code.svg&quot;;
import h1 from &quot;@/assets/h1.svg&quot;;
import h2 from &quot;@/assets/h2.svg&quot;;
import h3 from &quot;@/assets/h3.svg&quot;;
import bulletlist from &quot;@/assets/bulletlist.svg&quot;;
import numberlist from &quot;@/assets/numberlist.svg&quot;;

export const icons = {
  bin,
  post,
  search,
  bulb,
  onoff,
};

export const mark = {
  bold,
  italic,
  strike,
  code,
  h1,
  h2,
  h3,
  bulletlist,
  numberlist,
};</code></pre>
<p>메모장에서 사용하는 아이콘들을 한 파일로 묶어 <code>export</code>해 사용하고 있었다.</p>
<p>이 방식이 vite svg 컴포넌트와 비슷해서 코드를 최대한 많이 안건들기 위해 vite 플러그인을 활용하기로 결정했다.</p>
<br />

<h3 id="vite-plugin-svgr-적용하기">vite-plugin-svgr 적용하기</h3>
<ol>
<li>vite 플러그인 사용을 위해 <code>vite-plugin-svgr</code> 을 install 해준다.</li>
<li><code>vite.config.ts</code> 파일에 plugins로 다음을 추가해준다.</li>
</ol>
<pre><code class="language-tsx">plugins: [
    svgr({
      svgrOptions: {
        icon: true,
      },
    }),
    ...</code></pre>
<ol start="3">
<li>타입스크립트가 <code>vite-plugin-svgr</code> 이 제공하는 타입을 인식하도록 다음을 추가해준다.</li>
</ol>
<pre><code class="language-tsx">// vite-env.d.ts
/// &lt;reference types=&quot;vite-plugin-svgr/client&quot; /&gt;</code></pre>
<blockquote>
<p><code>vite-plugin-svgr</code> 은 <code>import Logo from &#39;./logo.svg?react&#39;</code> 같은 식으로 svg를 react 컴포넌트로 로딩할 수 있게 해준다.
그런데 타입스크립트는 <code>*.svg?react</code> 형식을 기본적으로 모르기 때문에 컴파일 에러를 낸다.</p>
<p>그래서 <code>vite-plugin-svgr</code> 은 자기 타입 선언을 제공하고 있는데, 그게 바로 <code>vite-plugin-svgr/client</code> 다.</p>
<p>이 타입을 참조하면 타입스크립트가 <code>*.svg?react</code> 는 리액트 컴포넌트라는걸 알게 된다.</p>
</blockquote>
<p>먼저, 현재 svg 파일을 그대로 import 하고 있는데, vite svg 컴포넌트로 사용할 때 기존 코드처럼 <code>import</code>하면 <code>createElement(&quot;data:image/svg+xml,...&quot;)</code> 으로 컴포넌트를 생성하려고 해 에러를 내뿜는다.
경로 뒤에 <code>?react</code> 를 붙여줘야 한다.</p>
<blockquote>
<p>svg 파일을 그대로 import 하게 되면 문자열 경로로 import 된다.</p>
</blockquote>
<pre><code class="language-tsx">Uncaught InvalidCharacterError: Failed to execute &#39;createElement&#39; on &#39;Document&#39;: The tag name provided (&#39;data:image/svg+xml,%3c?</code></pre>
<p>그냥 붙이면 해당 경로(<code>?react</code>)는 존재하지 않다고 파일 내에서 에러를 내뿜기 때문에, 타입 선언을 해준다.</p>
<pre><code class="language-tsx">// svg.d.ts
declare module &quot;*.svg?react&quot; {
  import * as React from &quot;react&quot;;
  const ReactComponent: React.FC&lt;React.SVGProps&lt;SVGSVGElement&gt;&gt;;
  export default ReactComponent;
}</code></pre>
<p>그리고 타입스크립트가 이 타입을 해석할 수 있도록 <code>tsconfig</code> 파일도 수정해준다.</p>
<pre><code class="language-tsx">// tsconfig.app.json
&quot;include&quot;: [&quot;src&quot;, &quot;svg.d.ts&quot;]</code></pre>
<p>그리고 경로를 수정해 기존 방법과 똑같이 객체로 아이콘을 모아준다.</p>
<pre><code class="language-tsx">// components/common/icon/iconMap.ts
import BinIcon from &quot;@/assets/bin.svg?react&quot;;
...

export const IconMap = {
  leftLine: LeftLineIcon,
  power: PowerIcon,
  rightLine: RightLineIcon,
  setting: SettingIcon,
  system: SystemIcon,
  ...
}

export type IconName = keyof typeof IconMap;</code></pre>
<p>이 아이콘을 편하게 사용하기 위해 아이콘 컴포넌트를 만들어줬다.</p>
<pre><code class="language-tsx">// components/common/icon/icon.tsx
const Icon = ({ name, size = 24, className = &quot;&quot; }: IconProps) =&gt; {
  const IconComponent = IconMap[name];

  return (
    &lt;IconComponent
      width={size}
      height={size}
      className={cn(&quot;inline-block&quot;, className)}
    /&gt;
  );
};</code></pre>
<p>사용할 때는 아래처럼 사용해주면 된다.</p>
<pre><code class="language-tsx">&lt;Icon name=&quot;bin&quot; size={17} /&gt;</code></pre>
<br />

<h3 id="기존-코드와-비교">기존 코드와 비교</h3>
<pre><code class="language-typescript">// 수정 전
import { icons } from &quot;./utils/icons&quot;;

&lt;img src={icons.bin} alt=&quot;delete&quot; width={17} height={17} /&gt;</code></pre>
<img width="70%" src="https://velog.velcdn.com/images/dobby_/post/b65a27a4-66d3-477d-a02a-f6e39add3e75/image.png" />

<p><strong>수정 전: 자산(Asset)으로 처리 (다수 HTTP 요청)</strong></p>
<p><strong>작동 방식</strong>: 수정 전 코드는 <code>import bin from &quot;@/assets/bin.svg&quot;;</code>와 같이 SVG 파일을 불러왔을 때, 번들러가 이를 자산(Asset) 파일로 간주하고 해당 파일의 <strong>경로(URL 문자열)</strong>를 JavaScript 변수에 할당.</p>
<p><strong>네트워크 결과</strong>: 이 경로 문자열을 React 컴포넌트가 <code>&lt;img&gt;</code> 태그 등으로 사용하면, 브라우저는 해당 URL로 새로운 HTTP 요청을 보내 SVG 파일을 다운로드.
아이콘이 18개면 18개의 HTTP 요청이 네트워크 탭에 하나하나 찍혔던 것.</p>
<p>하나의 객체로 <code>export</code>하고 여러 파일에서 이 객체를 <code>import</code>하여 사용하기 때문에, 객체를 <code>import</code>하는 모든 파일에서 사용 여부와 관계없이 객체에 담긴 모든 아이콘이 함께 로드된다.</p>
<pre><code class="language-typescript">// 수정 후
import Icon from &quot;@/components/common/icon/icon&quot;;

&lt;Icon name=&quot;bin&quot; size={17} /&gt;</code></pre>
<img width="70%" src="https://velog.velcdn.com/images/dobby_/post/6420334b-003c-46d8-b0d5-4bf6b47e1c52/image.png" />

<p><strong>수정 후: 컴포넌트(Component)로 처리 (단일 JS 번들)</strong></p>
<p><strong>작동 방식</strong>: <code>vite-plugin-svgr</code> 플러그인과 <code>?react</code> 또는 <code>{ ReactComponent }</code> 임포트 구문을 사용하면서 모든 SVG 파일은 더 이상 단순한 자산이 아님.</p>
<p>플러그인은 SVG 파일의 XML 내용을 읽어 들임.</p>
<p>이 내용을 <code>&lt;svg&gt;...&lt;/svg&gt;</code>를 반환하는 React 컴포넌트 함수로 변환.</p>
<p>이 변환된 JavaScript 코드가 <strong>메인 애플리케이션의 JavaScript 번들 파일 안에 통째로 삽입(Inlining)</strong>됨.</p>
<p><strong>네트워크 결과</strong>: 브라우저는 여전히 JavaScript 번들 파일(예: index.js 또는 app.js)을 다운로드하지만, 그 안에 이미 18개 아이콘의 코드가 포함되어 있음.
따라서 별도로 SVG 파일을 다운로드하기 위한 HTTP 요청이 발생하지 않음.</p>
<p><code>IconMap.ts</code>에 모든 아이콘이 묶여 있지만, 이 맵은 오직 <code>Icon.tsx</code> 컴포넌트 한 곳에서만 참조된다. 따라서 <code>Icon.tsx</code> 파일을 사용하지 않는 다른 모듈이나 컴포넌트들은 번들링 시 아이콘 코드 전체가 포함되지 않는다.</p>
<br />

<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://velog.io/@mari/React-Vite-svgr-setup">https://velog.io/@mari/React-Vite-svgr-setup</a>
<a href="https://breathof.tistory.com/311">https://breathof.tistory.com/311</a>
<a href="https://www.npmjs.com/package/vite-plugin-svgr">https://www.npmjs.com/package/vite-plugin-svgr</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[unload 비동기 API 요청이 동작하지 않을 때 sendBeacon을 사용하자]]></title>
            <link>https://velog.io/@dobby_/unload-%EB%B9%84%EB%8F%99%EA%B8%B0-API-%EC%9A%94%EC%B2%AD%EC%9D%B4-%EB%8F%99%EC%9E%91%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%84-%EB%95%8C-sendBeacon%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90</link>
            <guid>https://velog.io/@dobby_/unload-%EB%B9%84%EB%8F%99%EA%B8%B0-API-%EC%9A%94%EC%B2%AD%EC%9D%B4-%EB%8F%99%EC%9E%91%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%84-%EB%95%8C-sendBeacon%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90</guid>
            <pubDate>Tue, 11 Nov 2025 17:51:51 GMT</pubDate>
            <description><![CDATA[<h2 id="navigator의-sendbeacon으로-새로고침시-비동기-로직-실행하기">navigator의 sendBeacon으로 새로고침시 비동기 로직 실행하기</h2>
<p>새로고침시 브라우저 이벤트가 발생하게 된다.
새로고침에 의한 이벤트는 <code>beforeunload</code> 와 <code>unload</code> 가 있는데, <code>beforeunload</code> 는 <code>unload</code> 하기 전, 사용자에게 새로고침을 할 것인지 경고 혹은 알리는데 주로 사용한다.</p>
<p><code>unload</code> 는 새로고침 되기 직전에 발생하는 이벤트이다.</p>
<p>사용자가 직접 새로고침한 것이기에 의도된 것이라고 판단하고 <code>beforeunload</code> 는 고려하지 않기로 했다.
그래서 <code>unload</code> 이벤트에 서버로 <code>shutdown</code> API를 요청해 클라이언트와 서버의 시스템 상태를 동기화시키도록 로직을 작성해줬다.</p>
<p>새로고침은 모든 Page 컴포넌트에서 발생할 수 있는거기에 <code>App.tsx</code> 파일에 이벤트를 등록해주었다.</p>
<pre><code class="language-tsx">  const { updateSystemState } = useSystemStateStore();
  const { mutate: shutdownMutate } = useApi&lt;ShutdownResponse&gt;(
    systemAPI.shutdown,
    {
      onSuccess: () =&gt; {
        updateSystemState(SYSTEM_MODE.SHUTDOWN);
      },
      onError: (res) =&gt; {
        updateSystemState(SYSTEM_MODE.ERROR);
      },
    },
  );

  useEffect(() =&gt; {
    const unloadFunc = async () =&gt; {
      await shutdownMutate()
    };

    window.addEventListener(&#39;unload&#39;, unloadFunc);
    return () =&gt; {
      window.removeEventListener(&#39;unload&#39;, unloadFunc);
    };
  }, []);</code></pre>
<p>하지만 <code>unloadFunc</code> 함수는 실행이 되지만 <code>shutdownMutate</code> 함수가 정상적으로 실행되지 않았고, console로 API 요청 후 출력까지 해봤지만 console도 실행되지 않았다.</p>
<br />

<h3 id="문제-발생-이유">문제 발생 이유</h3>
<p>브라우저 환경에서 <code>unload</code> 이벤트가 발생하면 브라우저는 현재 페이지를 즉시 <code>unload</code> 하고 새 페이지 로드를 시작한다.</p>
<p>여기서 <code>unload</code> 이벤트는 동기적이며, <code>unload</code> 핸들러가 실행되더라도 브라우저가 이 핸들러가 완전히 종료될때까지 기다려주지 않는다.</p>
<p>즉, 페이지 <code>unload</code> 작업이 매우 빠르게 진행된다.</p>
<p>그런데 핸들러 안에서 비동기적으로 시간이 소요되는 로직을 호출하고, 작업이 종료되기 전에 페이지가 <code>unload</code> 되면서 이후의 console이 출력되지도, 정상적으로 요청이 처리되지도 않은 것이다.</p>
<br />

<h3 id="문제-해결">문제 해결</h3>
<p>하지만 나는 브라우저가 페이지를 닫기 전에 서버에 데이터를 보내야 하고, 비동기 로직을 <code>await</code> 하는걸 기다려주지 않는다.</p>
<p>그렇기에 <code>unload</code> 이벤트에 핸들러를 등록하는 것 대신, 브라우저가 직접 요청을 처리할 수 있는 방법을 찾아야 한다.</p>
<p>이를 알아보다 <code>navigator</code> 에서 제공해주는 <code>sendBeacon()</code> 이라는 메소드를 알아냈다.</p>
<p><a href="https://developer.mozilla.org/ko/docs/Web/API/Navigator/sendBeacon">MDN</a></p>
<blockquote>
<p><strong><code>navigator.sendBeacon()</code></strong> 메서드는 적은 양의 데이터를 포함하는 <a href="https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Methods/POST">HTTP POST</a> 요청을 <a href="https://developer.mozilla.org/ko/docs/Glossary/Asynchronous">비동기적</a>으로 웹 서버에 보냅니다.</p>
</blockquote>
<p>딱 나한테 필요한 기능!</p>
<p>서버로부터 응답을 받아야 하면 <code>fetch</code> 의 <code>keepalive</code> 를 <code>true</code> 로 설정한 걸 사용하라고 한다.
나한텐 필요없다.</p>
<pre><code class="language-tsx">sendBeacon(url)
sendBeacon(url, data)</code></pre>
<ul>
<li><code>url</code> : data를 받을 서버의 URL, 상대 주소와 절대 주소 모두 가능하다</li>
<li><code>data</code> : <code>ArrayBuffer</code> , <code>TypedArray</code>, <code>DataView</code>, <code>Blob</code> , 문자열 또는 객체 리터럴, <code>FormData</code>, <code>URLSearchParams</code> 등 전송할 데이터를 담은 객체</li>
</ul>
<p>성공적으로 사용자 에이전트가 전송할 data를 대기열에 추가하면 <code>true</code> 를 반환하고, 아니라면 <code>false</code> 를 반환한다.</p>
<blockquote>
<p><strong>사용자 에이전트?</strong>
사용자를 대표하는 컴퓨터 프로그램으로, 웹 맥락에선 브라우저를 의미한다.</p>
</blockquote>
<p>설명을 보면 분석 정보나 진단 데이터를 서버에 보내기 위한 목적으로 만들었다고 한다.
하지만? 사용하기 편하고 적절한 대안이니  상황에 따라 사용 가능</p>
<p>원래는 <code>unload</code> 이벤트 발생 시 브라우저가 정상적으로 비동기 요청을 전송할 수 있도록 지원했다고 한다.</p>
<p>하지만 이는 다음 페이지로의 탐색 속도가 저하되기 때문에, 사용자는 새로운 페이지가 느리다고 느끼게 되는 것이다. 이 UX를 개선하기 위해 고안된 메소드라고 한다.</p>
<p>(사실 <code>unload</code> 이벤트에 비동기 로직을 작성하는건 굉장히 좋지 않다고 적혀있다.)</p>
<p>장점은 다음과 같다고 설명한다.</p>
<ul>
<li>데이터가 안정적으로 전송됨</li>
<li>비동기적임</li>
<li>다음 페이지에 영향을 끼치지 않음</li>
</ul>
<p>이제 어떤 목적으로 이 메소드가 만들어졌고, 어떤 이점이 있는지에 대해 알았으니 적용해보자.</p>
<pre><code class="language-tsx">  const unloadFunc = async () =&gt; {
    const shutdownPayload = JSON.stringify({ reason: &#39;window_unload&#39; });
    const blob = new Blob([shutdownPayload], { type: &#39;application/json&#39; });

    // sendBeacon을 사용하여 API 요청을 브라우저에 위임
    const success = navigator.sendBeacon(&#39;/api/system/shutdown&#39;, blob);

    if (success) {
      console.log(&#39;Shutdown request successfully initiated via sendBeacon.&#39;);
    } else {
      console.log(&#39;Failed to initiate sendBeacon request.&#39;);
    }

    // sendBeacon은 비차단이므로 다음 코드는 즉시 실행되지만,
    // API 응답을 기다리지 않으므로 로그아웃 성공/실패 여부를 알 수 없다.
    console.log(&#39;dhfh (sendBeacon started)&#39;);
  };

  useEffect(() =&gt; {
    window.addEventListener(&#39;unload&#39;, unloadFunc);
    return () =&gt; {
      window.removeEventListener(&#39;unload&#39;, unloadFunc);
    };
  }, []);</code></pre>
<p>전달할 수 있는 데이터 중 알고 있는 타입이 <code>Blob</code> 였기에 이를 사용했다.</p>
<p>이렇게 작성하고 실행해봤더니, 정상적으로 처리된걸 확인했다.</p>
<p>네트워크 탭으로 확인할 땐 Type ping으로 cancled 상태의 요청만 보였는데, 이건 실패했다는건 아니고 <code>sendBeacon</code> 은 성능에 영향을 주지 않도록 백그라운드에서 실행되기 때문에 <code>ping</code> 이나 <code>other</code> 등을 분류되는게 일반적이라고 한다.</p>
<p>그리고 <code>ping</code> 은 매우 낮은 우선순위로 처리되기 때문에 브라우저 개발자 도구가 이를 완전히 추적하지 못했다는 것으로 해석할 수 있다.</p>
<p>확실하게 처리됐는지 확인하고자 한다면, 서버 로그를 확인하는걸 추천한다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모노레포 pnpm으로 husky + lint-staged 적용하기]]></title>
            <link>https://velog.io/@dobby_/%EB%AA%A8%EB%85%B8%EB%A0%88%ED%8F%AC-pnpm%EC%9C%BC%EB%A1%9C-husky-lint-staged-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/%EB%AA%A8%EB%85%B8%EB%A0%88%ED%8F%AC-pnpm%EC%9C%BC%EB%A1%9C-husky-lint-staged-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 06 Nov 2025 05:35:37 GMT</pubDate>
            <description><![CDATA[<h2 id="husky--lint-staged-설정">husky + lint-staged 설정</h2>
<p>이전 프로젝트를 진행하면서, 팀원이 lint 에러를 수정하지 못하고 push를 해서 수정된 코드를 pull 받은 팀원이 해결해야 하는 문제가 있었습니다.</p>
<p>그리고 prettier가 버그로 인해 동작하지 않았을 때 (IDE or 특정 폴더에서는 동작하지 않는 등) push를 해서 다른 팀원이 pull로 코드를 받았을 때 파일의 모든 부분이 변경되는 문제점도 있었습니다.</p>
<p>이 문제들을 해결하기 위해 husky + lint-staged를 설정하기로 결정했습니다.
설정 후엔 위의 문제들이 해결되고, 깜빡하고 넘어갔던 lint 에러도 해결이 되어 배포시 발생했던 lint 오류도 훨씬 줄어들었습니다.</p>
<p>이번 그룹 스프린트에서도 협업이기에 비슷한 문제가 발생할 경우를 대비해 <code>husky + lint-staged</code> 를 설정하기로 했습니다.</p>
<p>먼저, 모노레포로 프로젝트가 설정되어 있기에 이를 고려해 진행했습니다.</p>
<pre><code class="language-json">- app/
   |- frontend/
   |- bacnend/</code></pre>
<br />

<h3 id="설치하기">설치하기</h3>
<p>husky는 깃의 특정 이벤트가 발생할 때 자동으로 원하는 스크립트를 실행할 수 있게 해주는 역할을 합니다.</p>
<p><a href="https://typicode.github.io/husky/get-started.html">공식 문서 설치가이드</a>를 통해 husky를 각 패키지 매니저로 설치할 수 있습니다.</p>
<p>저희는 <code>pnpm</code> 을 사용하고 있기에, 다음의 명령어를 입력해 설치 및 초기화했습니다.</p>
<pre><code class="language-json">pnpm install -D husky lint-staged -w</code></pre>
<p><code>-w</code> 는 pnpm의 모노레포 환경 보호 기능을 끄기 위해서 추가한 옵션입니다.
pnpm을 사용하면 루트 디렉토리는 워크스페이스의 중앙 관리 지점입니다.</p>
<p><code>pnpm install -D husky lint-staged</code> 를 루트에서 실행하면, pnpm은 이 의존성이 모든 서브 프로젝트에서 사용될지, 아니면 루트에서 관리되는 도구로만 사용될지 명확하지 않다고 판단합니다.</p>
<p>그렇기에 이 의존성을 워크스페이스 루트에 추가하는 것이 의도한 것이라면 <code>-w</code> 플래그를 사용해 명시적으로 알려달라고 요청합니다.</p>
<pre><code class="language-json"> ERR_PNPM_ADDING_TO_ROOT  Running this command will add the dependency to the workspace root, which might not be what you want - if you really meant it, make it explicit by running this command again with the -w flag (or --workspace-root). If you don&#39;t want to see this warning anymore, you may set the ignore-workspace-root-check setting to true.</code></pre>
<p>위의 문제를 해결하기 위해 <code>-w</code> 옵션으로 의도한 것이라는걸 알려주는 것입니다.</p>
<p>이제 초기화를 시키기 위해 다음의 명령어를 입력합니다.</p>
<pre><code class="language-json">pnpm exec husky install</code></pre>
<p>위의 명령어를 입력하면 루트 폴더에 <code>.husky</code> 폴더가 생기는 것을 볼 수 있습니다.</p>
<img src="https://velog.velcdn.com/images/dobby_/post/c19938d7-7eb3-451e-98a4-0c33c856e77a/image.png" width="60%"/>

<br />

<h3 id="설정하기">설정하기</h3>
<p>이제 커밋을 하게 될 때 자동으로 lint와 prettier가 작동되도록 설정해야 합니다.</p>
<p><code>husky</code> 가 버전이 업그레이드되면서 CLI로 <code>pre-commit</code> 훅을 설정하는 방식이 DEPRECATED 되었습니다.</p>
<p>그렇기에 직접 <code>pre-commit</code> 파일에 접근해서 코드를 작성해주어야 합니다.</p>
<p>다음과 같이 작성해주었습니다.</p>
<pre><code class="language-json">#!/usr/bin/env sh
. &quot;$(dirname -- &quot;$0&quot;)/_/husky.sh&quot;

pnpm exec lint-staged</code></pre>
<ul>
<li><code>#!/usr/bin/env sh</code> : 이 파일이 쉘 스크립트임을 선언하고, 시스템 환경 변수를 사용해 <code>sh</code> 인터프리터로 실행되도록 지정합니다.</li>
<li><code>. &quot;$(dirname -- &quot;$0&quot;)/_/husky.sh&quot;</code> : husky가 제공하는 실행 환경을 불러와 훅을 실행할 준비를 합니다. husky가 올바른 환경에서 실행되도록 보장하는 필수 초기화 코드입니다.</li>
<li><code>pnpm exec lint-staged</code> : 스테이징된 파일만 검사하고 수정하는 <code>lint-staged</code> 도구를 실행합니다.</li>
</ul>
<p>husky만 적용하면 변경된 파일뿐만 아니라 모든 파일에 대해 매번 수행을 하기 때문에 시간이 오래걸리며 불필요한 작업이 수행되게 됩니다.</p>
<p>lint-staged는 이 문제를 해결해줍니다.
커밋하기 전에 변경된 파일에 대해서만 린트와 포맷팅 작업을 수행하도록 트리거합니다.</p>
<p><code>lint-staged</code> 를 설정해주었으니, package.json에 <code>lint-staged</code> 가 실제로 어떤 파일을 검사하고 어떤 명령을 실행할지에 대한 규칙을 정의하는 블록을 작성해주어야 합니다.</p>
<pre><code class="language-javascript">  // package.json
  &quot;lint-staged&quot;: {
    &quot;app/frontend/**/*.{js,jsx,ts,tsx}&quot;: [
      &quot;pnpm exec eslint --fix --cwd app/frontend&quot;,
      &quot;pnpm exec prettier --write&quot;
    ],
    &quot;app/backend/**/*.{js,ts}&quot;: [
      &quot;pnpm exec eslint --fix --cwd app/backend&quot;,
      &quot;pnpm exec prettier --write&quot;
    ],
    &quot;*.{json,css,md,yaml,yml}&quot;: [
      &quot;pnpm exec prettier --write&quot;
    ]
  },</code></pre>
<p>위의 <code>&quot;lint-staged&quot;</code> 를 추가해줍니다.</p>
<p>저희는 <code>app/frontend</code> 와 <code>app/backend</code> 로 분리했기에 위처럼 작성해주었습니다. 각 팀의 폴더 구조에 맞게 작성해주시면 됩니다.</p>
<p>이제 커밋을 하게 될 때마다 린트와 포맷팅 작업을 수행하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/68eced19-7882-4fbe-af48-1a62c3d72b14/image.png" alt="">
(브랜치 이름은 오타가 맞습니다 ㅎㅎ..)</p>
<p>여기서 경고 메시지가 보입니다.</p>
<pre><code class="language-json">husky - DEPRECATED

Please remove the following two lines from .husky/pre-commit:

#!/usr/bin/env sh
. &quot;$(dirname -- &quot;$0&quot;)/_/husky.sh&quot;

They WILL FAIL in v10.0.0</code></pre>
<p>이는 husky가 v10에서 스크립트 실행 방식이 변경될 예정임을 미리 알려주는 단순 경고 메시지입니다.</p>
<p>husky 버전이 올라가면서 스크립트 실행 환경을 초기화하는 방식을 더 단순화하고 있기에 알려주는 경고 메시지입니다.</p>
<p>아직은 v9이기 때문에 안전하지만, v10으로 변경될 때 지원을 중단할 예정이며 문제가 발생할 수 있으니 미리 삭제하라고 권장합니다. (안바꿔도 됩니다! v10으로 업그레이드 할 계획이 없다면)</p>
<p>메시지는 <code>pre-commit</code> 에서 설정한 <code>. &quot;$(dirname -- &quot;$0&quot;)/_/husky.sh&quot;</code> 명령어를 없애라고 합니다.</p>
<pre><code class="language-json">#!/usr/bin/env sh

pnpm exec lint-staged</code></pre>
<p>위처럼 <code>pre-commit</code> 을 바꿔 다시 커밋을 날려보면 lint-staged가 동작하지 않습니다!</p>
<p>v10 버전부터 제거해야하는거고, 현재 <code>husky</code> 버전(v9.1.7)에서는 이 줄이 <strong>필수적인 초기화 코드</strong>이기 때문이다.</p>
<p>그러니, 다시 붙이자….</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[XML과 HTML과 JSON]]></title>
            <link>https://velog.io/@dobby_/XML%EA%B3%BC-HTML%EA%B3%BC-JSON</link>
            <guid>https://velog.io/@dobby_/XML%EA%B3%BC-HTML%EA%B3%BC-JSON</guid>
            <pubDate>Wed, 16 Jul 2025 14:20:58 GMT</pubDate>
            <description><![CDATA[<h2 id="xml이란">XML이란</h2>
<p><code>XML</code>은 데이터를 정의하는 규칙을 제공하는 마크업 언어이다.
다른 프로그래밍 언어와 달리 <code>XML</code>은 자체적으로 컴퓨팅 작업을 수행할 수 없다.</p>
<p>대신, 구조적 데이터 관리를 위해 모든 프로그래밍 언어 또는 소프트웨어를 구현할 수 있다.</p>
<p>주로 서로 다른 시스템 간에 데이터를 교환할 때 사용하며, 구조 표현용 마크업 언어이다.</p>
<p>자유롭게 태그를 정의할 수 있으며, 계층 구조를 가진다.</p>
<pre><code class="language-jsx">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;bookstore&gt;
  &lt;book category=&quot;cooking&quot;&gt;
    &lt;title lang=&quot;en&quot;&gt;Everyday Italian&lt;/title&gt;
    &lt;author&gt;Giada De Laurentiis&lt;/author&gt;
    &lt;year&gt;2005&lt;/year&gt;
    &lt;price&gt;30.00&lt;/price&gt;
  &lt;/book&gt;
  &lt;book category=&quot;children&quot;&gt;
    &lt;title lang=&quot;en&quot;&gt;Harry Potter&lt;/title&gt;
    &lt;author&gt;J K. Rowling&lt;/author&gt;
    &lt;year&gt;2005&lt;/year&gt;
    &lt;price&gt;29.99&lt;/price&gt;
  &lt;/book&gt;
  &lt;book category=&quot;web&quot;&gt;
    &lt;title lang=&quot;en&quot;&gt;Learning XML&lt;/title&gt;
    &lt;author&gt;Erik T. Ray&lt;/author&gt;
    &lt;year&gt;2003&lt;/year&gt;
    &lt;price&gt;39.95&lt;/price&gt;
  &lt;/book&gt;
&lt;/bookstore&gt;</code></pre>
<br />

<h3 id="xml과-html의-차이">XML과 HTML의 차이</h3>
<p>예시만 보면 <code>XML</code>과 <code>HTML</code>은 동일한 것 같다.</p>
<p><code>HTML</code> 과 <code>XML</code> 은 애플리케이션 개발과 웹 개발에서 널리 사용되는 두 가지 마크업 언어이다.</p>
<p>이름은 비슷하지만, 사용 사례는 다르다.</p>
<br />

<p><code>HTML</code> 은 주로 애플리케이션의 UI를 개발하는 데 사용된다.
웹 사이트 또는 애플리케이션에서 볼 수 있는 텍스트, 이미지, 버튼, 확인한 및 드롭다운 상자를 렌더링한다.</p>
<p>반면, <code>XML</code> 의 주요 목적은 데이터 교환 및 전송이다.
기계와 사람이 모두 읽을 수 있는 형식으로 데이터를 인코딩한다.</p>
<p>즉, <code>XML</code> 은 데이터가 무엇인지 설명하는 반면, <code>HTML</code> 은 데이터를 최종 사용자에게 표시하는 방법을 결정한다.</p>
<p>또한 <code>XML</code>은 사용자가 태그를 직접 정의할 수 있으며 자유롭게 만들 수 있다.
즉, 데이터의 의미와 구조가 중요하다.</p>
<p>반면 <code>HTML</code>은 미리 정의된 태그만 사용해야 하며, 데이터의 의미는 중요하지 않다.</p>
<br />

<h3 id="왜-html로는-안될까">왜 HTML로는 안될까?</h3>
<p>근본적인 차이로 인해 <code>HTML</code>로는 <code>XML</code>이 표현하고자 하는 의미 있는 데이터 표현이 불가하다.</p>
<p>아래와 같은 <code>XML</code> 데이터가 있다고 하자.</p>
<pre><code class="language-xml">&lt;order&gt;
  &lt;customer&gt;수연&lt;/customer&gt;
  &lt;item&gt;노트북&lt;/item&gt;
  &lt;price&gt;1200000&lt;/price&gt;
&lt;/order&gt;</code></pre>
<p>이 데이터는 <strong>주문 정보</strong>라는 의미를 정확히 담고 있다.</p>
<p>이를 HTML로 표현하면 다음과 같을 것이다.</p>
<pre><code class="language-html">&lt;div&gt;
  &lt;div&gt;수연&lt;/div&gt;
  &lt;div&gt;노트북&lt;/div&gt;
  &lt;div&gt;1200000&lt;/div&gt;
&lt;/div&gt;</code></pre>
<p>그저 박스(<code>div</code> )만 있을 뿐, 어떤 것을 의미하는지 알 수 없다.</p>
<p>이 외에도 <code>HTML</code> 은 사람이 화면으로 보는 용도이며 <code>XML</code> 은 앱 ↔ 서버, 회사 ↔ 회사 간의 데이터 교환 용도라는 차이로 데이터 교환에 적합하지 않다.</p>
<p>실제 예시론, 항공사 시스템 ↔ 여행사 시스템 ↔ 결제 시스템 사이에서의 데이터 교환을 들 수 있다.</p>
<p>항공편 정보, 승객 정보, 가격 등을 <code>XML</code> 을 통해 데이터를 교환한다.</p>
<p><code>XML</code> 로 데이터를 전송해 서로가 데이터의 의미를 정확히 알 수 있다.</p>
<br />

<h3 id="html과-xml을-사용해야-하는-경우">HTML과 XML을 사용해야 하는 경우</h3>
<p><code>HTML</code>은 프레젠테이션 언어로 알려진 일종의 마크업이다.
<code>HTML</code> 을 사용해 웹 페이지와 클라이언트 측 애플리케이션을 만들 수 있다.</p>
<p>일반적으로 스타일 지정을 위한 CSS와 동적 동작을 위한 JS와 결합된다.</p>
<p>반대로 두 애플리케이션 또는 시스템 간의 데이터 교환에는 <code>XML</code> 을 사용한다.
동일한 형식을 이해하기 위해 애플리케이션에서는 <code>XML</code>  파일의 내용을 정의하는 <code>XML 스키마</code> 를 공유했다.</p>
<p><code>XML</code> 은 여전히 널리 사용되고 있지만, 데이터 교환을 위한 또 다른 방법인 <code>JSON</code> 으로의 경량 데이터 포맷은 빠른 파싱으로 인해 더 많이 사용되고 있다.</p>
<table>
<thead>
<tr>
<th></th>
<th>HTML</th>
<th>XML</th>
</tr>
</thead>
<tbody><tr>
<td>what</td>
<td>주로 브라우저에서 구조화된 콘텐츠를 표시하는 데 사용되는 마크업 언어</td>
<td>주로 컴퓨터 시스템 간에 구조화된 데이터를 교환하는 데 사용되는 마크업 언어</td>
</tr>
<tr>
<td>목적</td>
<td>프레젠테이션 언어</td>
<td>데이터 교환 언어</td>
</tr>
<tr>
<td>사용</td>
<td>클라이언트측 웹 페이지 또는 웹 앱 구축</td>
<td>두 시스템 간에 데이터 교환</td>
</tr>
<tr>
<td>태그</td>
<td>사전 정의된 태그</td>
<td>확장 가능한 태그</td>
</tr>
<tr>
<td>타이핑</td>
<td>동적</td>
<td>XML 스키마를 사용할 때 수정됨</td>
</tr>
</tbody></table>
<br />

<h3 id="xml과-json">XML과 JSON</h3>
<p>데이터 교환을 위한 목적으로 사용한다면, <code>JSON</code> 도 있지 않나?</p>
<p>그렇다! 이전엔 <code>XML</code> 을 주로 사용했지만, <code>JSON</code> 이 구문이 더 간결하고  빠르며 쓰고 읽기가 쉬워 오늘날은 <code>JSON</code> 을 더 많이 쓰는 추세다.</p>
<pre><code class="language-xml">// xml 버전
&lt;user&gt;
  &lt;id&gt;1&lt;/id&gt;
  &lt;name&gt;Suyeon&lt;/name&gt;
  &lt;email&gt;suyeon@example.com&lt;/email&gt;
  &lt;skills&gt;
    &lt;skill&gt;JavaScript&lt;/skill&gt;
    &lt;skill&gt;React&lt;/skill&gt;
  &lt;/skills&gt;
&lt;/user&gt;</code></pre>
<pre><code class="language-json">// json 버전
{
  &quot;user&quot;: {
    &quot;id&quot;: 1,
    &quot;name&quot;: &quot;Suyeon&quot;,
    &quot;email&quot;: &quot;suyeon@example.com&quot;,
    &quot;skills&quot;: [&quot;JavaScript&quot;, &quot;React&quot;]
  }
}</code></pre>
<p><code>XML</code> 은 데이터를 기계가 읽을 수 있는 방식으로 저장하는 데 중점을 두므로, 복잡한 데이터의 오류를 검사하는 데 있어서 <code>JSON</code> 보다 더 효율적이다.</p>
<p>또한 더욱 발전된 도구와 라이브러리를 갖추고 있어서 레거시 시스템에서 더 잘 작동할 수 있다.</p>
<p>반면, <code>JSON</code> 은 데이터 교환을 목적으로 설계되었으며 더 간단하고 간결한 형식을 제공한다.
또한 성능과 통신 속도를 향상시킨다.</p>
<p><code>JSON</code> 은 일반적으로 API, 모바일 앱 및 데이터 스토리지에 더 적합하며, <code>XML</code> 은 데이터 교환이 필요한 복잡한 문서 구조에 더 적합하다.</p>
<table>
<thead>
<tr>
<th></th>
<th>JSON</th>
<th>XML</th>
</tr>
</thead>
<tbody><tr>
<td>의미</td>
<td>JavaScript Object Notation</td>
<td>Extensible Markup Languge</td>
</tr>
<tr>
<td>형식</td>
<td>키-값 페어가 있는 맵과 유사한 구조</td>
<td>다양한 데이터 범주에 대한 네임스페이스가 있는 트리 구조</td>
</tr>
<tr>
<td>구문 분석</td>
<td>표준 JavaScript 함수를 사용하여 JSON을 구문 분석</td>
<td>XML 구문 분석기를 사용하여 XML을 구문 분석</td>
</tr>
<tr>
<td>스키마 문서</td>
<td>간단하고 유연</td>
<td>복잡하고 유연성이 떨어짐</td>
</tr>
<tr>
<td>사용 편의성</td>
<td>파일 크기가 더 작고 데이터 전송 속도가 더 빠르다</td>
<td>쓰고 읽기가 더 복잡하고, 파일 용량을 더 크게 만든다</td>
</tr>
</tbody></table>
<p>한마디로, JSON의 장점은 파일 크기가 작아 네트워크 전송 속도가 빠르며 파싱 속도가 빠르며 메모리 효율적이다.</p>
<p>하지만 태그가 없어 데이터의 의미 표현에 약하며, 매우 복잡한 계층 구조나 혼합 콘텐츠 표현엔 부적합하다.</p>
<p>XML은 사용자 정의 태그가 가능해 산업별 표준에 적합하며, DTD, XSD를 통한 강력한 검증이 가능하다.</p>
<p>하지만 문법이 복잡하며 가독성이 떨어지며, 파싱 속도가 느려서 클라이언트 리소스가 부담된다.</p>
<p>JSON보다 파일 크기도 크기 때문에, 네트워크 전송 속도도 비교적 느리다.</p>
<br />

<h2 id="well-formed-xml">Well-formed XML</h2>
<p><code>Well-formed XML</code> 이란, XML 문서가 문법적으로 정확한 형태를 만족하는 것을 말한다.</p>
<p>즉, XML의 규칙을 모두 만족하는 것을 말한다.</p>
<p>XML의 규칙은 다음과 같다.</p>
<ol>
<li>모든 XML 요소는 닫는 태그를 가지고 있어야 한다.</li>
<li>태그는 열린 순서대로 닫혀야 한다.</li>
<li>반드시 하나의 루트 엘리먼트만 존재한다.</li>
<li>XML 태그는 대소문자를 구분한다.</li>
<li>모든 XML 요소는 적절하게 중첩되어야 한다.</li>
<li>모든 XML 문서는 루트 요소를 가지고 있어야 한다.</li>
<li>속성값은 항상 따옴표로 묶어야 한다.</li>
<li>빈 요소는 반드시 <code>/&gt;</code> 로 끝나야 한다.</li>
<li>모든 시작 태그는 하나의 마침태그를 가지고 있어야 한다.</li>
<li>주석은 <code>&lt;!--</code> 로 시작해서 <code>--&gt;</code> 로 끝난다.</li>
</ol>
<p>이 외에도 다양한 규칙이 존재한다.
이러한 XML 문법만 잘 지켜지면 <code>Well-formed XML</code>  이라고 할 수 있다.</p>
<p>이에 대한 검증 방식으론 파서(parser)를 이용한 검증과 온라인 XML 검증 도구를 사용하는 등의 방식이 있다.</p>
<p>XML 파서는 XML 문서를 문법에 맞게 작성했는지 검증하거나, XML 문서를 다른 애플리케이션에서 사용할 수 있는 문서로 변환하는 소프트웨어를 통틀어서 말한다.</p>
<br />

<h3 id="xml-파서">XML 파서</h3>
<img src="https://velog.velcdn.com/images/dobby_/post/1f1bf15c-8f52-4098-bf48-1e4b13f0cea0/image.png" width="60%" />

<p>파서의 종류는 크게 3가지로 나눈다.</p>
<ol>
<li>문법적인 오류만 검사하는 파서</li>
</ol>
<p>일반적으로 브라우저에 내장된 파서이다. XML 문서를 출력시 자동으로 문법적인 오류를 검사한다.</p>
<p>문법만 맞으면 통과된다.</p>
<p>익스플로러, 크롬, 사파리 같은 브라우저에도 XML 파서가 내장되어 있다.</p>
<br />

<ol start="2">
<li>유효한 문서인지 검사하는 파서</li>
</ol>
<p>문서가 DTD 또는 XSD 스키마를 준수하는지를 확인한다.</p>
<p>태그와 속성이 스키마 정의와 일치하는지, 속성의 값 타입이 올바른지, 요소의 순서와 반복 조건이 일치한지 등을 검사한다.</p>
<p>즉, 문법 검사(well-formed)는 기본이며, 이에 유효성 검사가 추가된다.</p>
<p>최근 대부분의 브라우저는 XML 파서 기능을 포함하고 있어 별도 설치가 필요없다.</p>
<br />

<ol start="3">
<li>XML 문서를 다른 형태의 문서로 변형해 주는 파서</li>
</ol>
<p>기존의 XML 문서를 WML, HTML 같은 형태의 문서 구조로 변형하고 출력해준다.</p>
<br />

<h3 id="dom-기반의-파서">DOM 기반의 파서</h3>
<p>DOM 기반의 파서는 DOM API를 사용한다.
문법 검사와 필요시 유효성 검사도 한다.</p>
<p>파싱 후 트리 구조를 생성하기 때문에, 자식, 부모, 형제 등 관계를 쉽게 탐색/수정할 수 있다.</p>
<p>DOM API를 사용하면 DOM 노드로 접근해 XML 문서 데이터를 변경할 수 있다.</p>
<p>브라우저의 내장 XMl 파서가 DOM 기반의 파서이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[가상 환경과 애플리케이션 실행 환경의 변화]]></title>
            <link>https://velog.io/@dobby_/%EA%B0%80%EC%83%81-%ED%99%98%EA%B2%BD%EA%B3%BC-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EC%8B%A4%ED%96%89-%ED%99%98%EA%B2%BD%EC%9D%98-%EB%B3%80%ED%99%94</link>
            <guid>https://velog.io/@dobby_/%EA%B0%80%EC%83%81-%ED%99%98%EA%B2%BD%EA%B3%BC-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EC%8B%A4%ED%96%89-%ED%99%98%EA%B2%BD%EC%9D%98-%EB%B3%80%ED%99%94</guid>
            <pubDate>Tue, 15 Jul 2025 13:03:59 GMT</pubDate>
            <description><![CDATA[<h2 id="가상-환경이란">가상 환경이란?</h2>
<p>가상 환경은 물리적으로 한 대의 컴퓨터 안에서 여러 개의 독립된 컴퓨터(시스템)을 가상으로 만들어 사용하는 기술이다.</p>
<p>가상 환경을 만드는 것은 시대가 변하면서 변화했다.</p>
<br />

<h3 id="애플리케이션-실행-환경의-변화">애플리케이션 실행 환경의 변화</h3>
<p>애플리케이션 실행 환경의 변화는 3단계로 나눠진다.</p>
<p>전통적인 환경에서의 배포(On-premise)에서 가상 환경에서의 배포(Virtual Machine)로, 또 컨테이너의 배포(Container)로 이어진다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/32feb6b7-6f9f-4a3d-9c4b-c120c4727be1/image.png" alt=""></p>
<br />

<h3 id="전통적인-환경에서의-배포on-premise">전통적인 환경에서의 배포(On-premise)</h3>
<p>초기의 개발 환경은 물리적 서버에서 애플리케이션을 실행하였다.</p>
<p>이 방법은 물리적 서버 애플리케이션의 변경 사항을 쉽게 적용할 수 없고, 물리적 서버를 유지 관리하는데도 비용이 많이 들었다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/c613a377-eed9-4f38-9e01-863cf72be437/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/0b88fe51-295c-4bde-9a92-cd80b44d8153/image.png" alt=""></p>
<br />

<h3 id="가상-환경에서의-배포virtual-machine">가상 환경에서의 배포(Virtual Machine)</h3>
<p>물리적 환경에 대한 솔루션으로 가상화가 도입되었다.</p>
<p>단일한 물리적 서버의 CPU에서 여러 대의 가상머신(VM)을 실행할 수 있게 되었다.</p>
<p>이러한 가상화를 사용하게 되면 애플리케이션 간의 격리를 할 수 있고, 상호 간의 보안 환경도 유지할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/7e22328b-904c-4605-ae64-d819726415b2/image.png" alt=""></p>
<ul>
<li>Host OS(호스트 OS): 실제 물리 하드웨어와 운영체제를 가진 PC나 서버</li>
<li>Gest OS(게스트 OS): 하이퍼바이저 위에서 동작하는 가상머신의 운영체제로, 물리적 자원을 간접적으로 할당받아 시스템처럼 동작</li>
</ul>
<p>하이퍼바이저란, 물리 하드웨어와 가상 머신 사이에서 자원을 중재하는 소프트웨어 레이어다.</p>
<p>서로 다른 OS를 동시에 구동할 수 있으며, 한 VM이 다른 VM에 직접적인 영향을 주지 않기 때문에 보안과 안정성이 높다는 장점이 있다.</p>
<p>하지만, Gest OS 전체를 구동하므로 무겁고, 부팅 시간이 길다.</p>
<p>또한 VM 이미지는 용량이 크며 메모리, CPU 자원을 많이 사용한다.</p>
<br />

<h3 id="컨테이너로의-배포container">컨테이너로의 배포(Container)</h3>
<p>컨테이너는 위의 가상머신과 유사하지만 컨테이너에는 자체 파일 시스템, CPU 공유, 메모리, 프로세스 공간 등이 있다.</p>
<p>기본 인프라에서 분리되기 때문에 클라우드 및 OS 배포 전반에 걸쳐 이식 가능하다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/7cfa4869-fe67-4926-b2ec-740cec27f51b/image.png" alt=""></p>
<p>VM처럼 운영체제를 통째로 띄우지 않고, 호스트 OS의 커널을 공유하여 애플리케이션만 격리하는 방식이다.</p>
<p>이로 인해 훨씬 가볍고 빠른 가상 환경을 제공한다.</p>
<p>이러한 이유로 현재는 컨테이너로 배포하는 환경이 주를 이루고 있다</p>
<p>실제로는 호스트 커널을 함께 쓰지만, 각 컨테이너마다 프로세스와 네트워크, 파일 시스템이 독립된 것처럼 보인다.</p>
<p>VM에 비해 훨씬 빠른 시작과 종료가 가능하며, 적은 자원으로도 다수의 애플리케이션 운영이 가능하다.</p>
<p>또한 애플리케이션별로 격리된 환경을 쉽게 만들 수 있다는 장점이 있다.</p>
<p>하지만 호스트 커널을 공유하기 때문에 보안 격리는 VM보다 취약할 수 있으며, 서로 다른 OS를 동시에 쓰기엔 커널 호환성 문제로 어렵다는 단점이 있다.</p>
<br />

<h3 id="docker">Docker</h3>
<p>컨테이너 기술을 쉽고 간편하게 쓸 수 있도록 만들어진 오픈소스 플랫폼으로 <code>Docker</code> 가 표준처럼 널리 사용된다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/5b65af67-206a-45bd-be7c-232111e7394a/image.png" alt=""></p>
<p>애플리케이션의 실행에 필요한 환경을 하나의 이미지로 모아두고, 그 이미지를 사용하여 다양한 환경에서 애플리케이션 실행 환경을 구축 및 운영하기 위한 오픈소스 가상화 플랫폼이다.</p>
<p>컨테이너는 프로세스 단위의 격리 환경을 조성하고 있어, 상호 간의 의존도를 낮추어 준다.</p>
<p>또한 VM에 비해 사이즈가 작아서 배포가 빠르고 성능 손실이 거의 없다.</p>
<br />

<h3 id="쿠버네티스kubernetes">쿠버네티스(Kubernetes)</h3>
<p><img src="https://velog.velcdn.com/images/dobby_/post/672bc96a-b629-456e-a4a1-1583fd3ddf06/image.png" alt=""></p>
<p>도커의 등장으로 컨테이너 기반 배포 방식이 보편화되고, 많은 서비스들이 도커라이징 되어 이미지로 관리되기 시작했다.</p>
<p>점점 이미지가 많아지면서, 관리해야할 컨테이너와 서버들 또한 많아지게 되었다.</p>
<p>이 말은, 엔지니어가 할 일이 많아졌다는 말이다.</p>
<p>컨테이너들의 관리를 자동화할 도구(<code>컨테이너 오케스트레이션 툴</code> )의 필요성이 대두되고, 비로소 컨테이너 오케스트레이션의 춘추전국시대가 열리게 된다.</p>
<p>많은 컨테이너 오케스트레이션 도구가 있음에도 불구하고, 현재는 쿠버네티스가 컨테이너 오케스트레이션들의 사실상 표준으로 자리매김하게 되었다.</p>
<ul>
<li>대규모 컨테이너를 관리했던 구글의 노하우와 강력한 확장성</li>
<li>마이크로소프트, RedHat, IBM 등 수많은 기업의 참여</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[History API와 SPA 라우팅: 새로고침 없이 페이지 전환하기]]></title>
            <link>https://velog.io/@dobby_/History-API%EC%99%80-SPA-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8-%EC%97%86%EC%9D%B4-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/History-API%EC%99%80-SPA-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8-%EC%97%86%EC%9D%B4-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 07 Jul 2025 09:41:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>당근 인턴 면접에서, 새로고침 없이 페이지를 이동하는 라우팅 구현 문제가 라이브 코딩에서 나왔다.
이때, <code>pushState</code>로 <code>url</code>을 넣어주면 새로고침 되지 않는다는 것만 기억나서, 라이브 코딩은 망하고 면접 보는 도중에 계속 상태…상태…상태…만 얘기했던 기억이 있다.
그래서 이번 기회에 history API와 SPA 클라이언트 라우팅을 공부해 정리해봤다.</p>
</blockquote>
<h2 id="history-api란">History API란</h2>
<p><code>history</code> 전역 객체를 통해 브라우저 세션 히스토리에 대한 접근을 제공한다.</p>
<p>사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택의 내용을 조작할 수 있다.</p>
<p>메인 스레드의 전역 객체인 <code>window</code> 를 사용할 수 있는 곳에서만 활용 가능한 API이기 때문에, 메인 스레드에서 동작하지 않는 <code>Worker</code> 와 같은 곳에서는 접근할 수 없다.</p>
<ul>
<li><code>pushState</code> : 세션 히스토리에 새로운 URL 상태를 쌓는다.</li>
<li><code>replaceState</code>: 세션 히스토리에 새로운 URL을 쌓지 않고, 현재 URL을 대체한다.</li>
</ul>
<p>실제로는 화면 이동이 일어나지 않지만, 히스토리에는 URL 스택이 쌓이고, 현재 URL을 바꿔줄 수 있다.</p>
<p>이를 활용해 실제론 페이지 이동(<strong>페이지 이동시 발생하는 새로고침 없이</strong>)을 하지 않고, URL에 따른 화면을 다시 그릴 수 있다.</p>
<p>즉, history API를 활용해 SPA를 구현하는 것이 가능하며, 우리가 흔히 사용하는 <code>router</code> 가 history API를 기반으로 한다.</p>
<br />

<h2 id="history-프로퍼티">history 프로퍼티</h2>
<p><img src="https://velog.velcdn.com/images/dobby_/post/93a04504-52d6-4087-8482-7df9f541ef15/image.png" alt=""></p>
<ul>
<li><p><code>length</code>: history 스택에 쌓인 페이지 수를 의미한다.
현재 3개의 페이지가 스택에 담겨있는 모습이다.</p>
</li>
<li><p><code>scrollRestoration</code>: 스크롤 복원에 대한 프로퍼티</p>
<ul>
<li><p><code>auto</code>: 사용자가 스크롤한 페이지의 위치가 복원된다.</p>
</li>
<li><p><code>manual</code>: 페이지의 위치가 복원되지 않는다. 사용자가 직접 스크롤하여 해당 위치로 이동해야 한다.</p>
<pre><code class="language-jsx">history.scrollrestoration = &quot;manual&quot;;

const scrollRestoration = history.scrollRestoration;
if(scrollRestoration === &#39;manual&#39;) {
console.log(&#39;scroll location is not restored&#39;);
}</code></pre>
</li>
</ul>
</li>
<li><p><code>state</code>: <code>pushState</code> 와 <code>replaceState</code> 함수의 첫 번째 인자로 전달할 값이 저장된다.</p>
<pre><code class="language-jsx">  history.pushSate(state, title, url);

  history.pushState({scrollY: window.scrollY}, &#39;&#39;, &#39;/new-page&#39;);

  // 이후 popstate 이벤트에서 사용 가능
  window.addEventListener(&#39;popstate&#39;, (event) =&gt; {
    console.log(event.state); // {scrollY: ...}
  });</code></pre>
<ul>
<li><p><code>state</code>: 개발자가 임의로 넣을 수 있는 객체 데이터
  초기값은 null이며, 추가 정보를 저장하고자 할 때 사용한다.
  <code>pushState</code>, <code>replaceState</code>를 써야만 값이 들어간다.</p>
</li>
<li><p><code>title</code>: 현재는 거의 무시되며, 대부분의 브라우저가 사용하지 않는다.</p>
</li>
<li><p><code>url</code>: 바뀔 주소</p>
</li>
</ul>
</li>
</ul>
<br />

<h3 id="spa에서의-scrollrestoration">SPA에서의 scrollRestoration</h3>
<p>여기서 velog로 실제로 스크롤 위치가 저장되는지 <code>scrollRestoration</code> 을 테스트해봤다.</p>
<p>하지만 다음 게시글로 넘어갔다 돌아와도 스크롤 위치가 고정되지 않고, 최상단으로 이동했다.</p>
<p>알아보니, SPA 특성상 기본 브라우저 동작이 비활성화 되어 있기 때문이라 한다.</p>
<p><strong>페이지 전환이 실제로는 URL만 바뀌고, 콘텐츠는 자바스크립트가 동적으로 교체하기 때문에 브라우저는 페이지 이동이라고 인식하지 못하고</strong>, 스크롤 위치 복원도 자동으로 동작하지 않는다고 한다.</p>
<p>MPA 기준으론 별도로 조작하지 않으면 기본적으로 이전 페이지의 스크롤 위치를 기억하고 복원한다.</p>
<p>하지만 여기서 이해되지 않는 부분이 있다.</p>
<blockquote>
<p>‘<code>history</code> 스택이 변하는게 결국은 페이지 이동을 했다는 것을 의미하니까, 결국 똑같은거 아닌가?’</p>
</blockquote>
<p>정확히 보면, 브라우저의 <code>history</code> 스택에 새로운 항목이 추가된다고 해서 브라우저가 ‘페이지 전환’으로 인식하는 것은 아니다.</p>
<p><code>pushState</code>, <code>replaceState</code> 로 history 스택에 새로운 entry가 추가된다.
이때 URL은 바뀌지만, 브라우저가 이를 새로운 페이지를 요청해 로딩했다고 인식하지는 않는다.</p>
<p>단순히 주소와 상태만 바뀌었다고 본다.
브라우저가 ‘페이지 전환’으로 인식하는 경우는 다음과 같다.</p>
<ul>
<li>새로운 문서를 요청해, 전체 페이지를 다시 그리는 경우
<code>&lt;a href=”/page2”&gt;</code>  클릭 시 서버에 새 HTML 요청</li>
</ul>
<p>이때만 기본적으로 이전 페이지의 스크롤 위치를 기억하고, 뒤로 가기 시 복원한다.</p>
<p>React Router, Next.js, Vue Router 같은 클라이언트 라우팅 라이브러리들은 <code>pushSate</code> 를 사용해 URL만 변경한다.</p>
<p>그렇기에 기본적으론 스크롤 복원이 자동으로 되지 않는 것이며, 개발자가 직접 처리해야 한다.</p>
<br />

<h2 id="history-관리-메소드">history 관리 메소드</h2>
<h3 id="historypushstatestate-title-url"><code>history.pushState(state, title, url)</code></h3>
<p>url은 세션 히스토리에 새로 push할 URL 값이다.</p>
<p>a 태그를 클릭하거나 <code>location.href</code> 로 URL을 변경하는 것과는 달리, 이 URL이 변경된다고 해서 화면이 리로드되지 않는다.</p>
<p>말 그대로, URL만 바뀌게 된다.</p>
<br />

<h3 id="historyreplacestatestate-title-url"><code>history.replaceState(state, title, url)</code></h3>
<p>기본적으로 pushState와 같다.</p>
<p>다른 점은, 히스토리에 새 URL 상태를 쌓지 않고, 현재 URL을 넣어준 url 값으로 대체한다.</p>
<br />

<h3 id="history-스택-이동-메소드">history 스택 이동 메소드</h3>
<pre><code class="language-jsx">history.back();
history.go(-1);</code></pre>
<p>위는 모두 방문 기록의 뒤로 이동하는 방법이다.
브라우저 도구 모음에서 [뒤로 가기] 버튼을 클릭한 것과 동일하다.</p>
<br />

<pre><code class="language-jsx">history.forward();
history.go(1);</code></pre>
<p>위는 모두 방문 기록의 앞으로 이동하는 방법이다.
브라우저 도구 모음에서 [앞으로 가기] 버튼을 클릭한 것과 동일하다.</p>
<br />

<pre><code class="language-jsx">history.go(2);
history.go(-2);</code></pre>
<p>방문 기록의 특정 지점으로 이동하고자 한다면, 위처럼 현재 위치에 대한 상대 위치로 식별되는 특정 페이지로 로드하면 된다.
현재 위치는 <code>0</code> 이다.</p>
<br />

<pre><code class="language-jsx">history.go(0);
history.go();</code></pre>
<p>위는 현재 페이지를 새로고침하는 방법이다.</p>
<br />

<pre><code class="language-jsx">history.length();</code></pre>
<p>스택의 사이즈를 통해 페이지 수를 확인할 수 있다.</p>
<br />

<h2 id="popstateevent">PopStateEvent</h2>
<p>브라우저에서 페이지를 이동하게 되면 <code>popstate</code> 라는 이벤트가 발생한다.</p>
<p><code>history.pushState()</code> 또는 <code>history.replaceState()</code> 를 호출하는 것만으로는 <code>popstate</code> 이벤트가 트리거되지 않는다.</p>
<p>이벤트는 <code>history.back()</code> , <code>history.forward()</code> 와 같은 뒤로, 또는 앞으로 가기 버튼을 클릭하는 것과 같은 브라우저 동작을 수행하거나 JavaScript에서 <code>popstate</code> 를 호출할 때 트리거된다.</p>
<br />

<h3 id="pushstate-replacestate에서-popstate-이벤트가-발생하지-않는-이유"><code>pushState</code>, <code>replaceState</code>에서 <code>popstate</code> 이벤트가 발생하지 않는 이유</h3>
<blockquote>
<p>history entry가 실제로 변경될 때 발생한다고 하는데, <code>pushState</code>, <code>replaceState</code>로 entry가 변경된 것인거 아닌가?</p>
</blockquote>
<p>라는 생각이 들었다.
이에 대한 답은 다음과 같다.</p>
<p>히스토리 스택 내에서 ‘<strong>기존 entry 간에 이동(스택 간 이동)</strong>’을 한 경우에만 <code>popstate</code> 이벤트가 발생한다고 한다.</p>
<ul>
<li><code>pushState</code>는 <code>url</code>을 변경하긴 하지만, 기존 entry 간의 이동이 아닌, 새 entry를 추가하고 그 entry가 활성화되는 것이기 때문에 이벤트가 발생하지 않는 것이다.</li>
</ul>
<p>이 과정은 새로운 페이지를 불러온 게 아니라, 자바스크립트가 페이지 상태를 바꾼 것일 뿐이기 때문에 이벤트는 발생하지 않는다.</p>
<ul>
<li><code>replaceState</code>는 현재 entry를 교체하는 것이므로, 히스토리 내에서 이동이 전혀 일어나지 않는다.</li>
</ul>
<p>현재 활성화된 history entry를 교체하는(현재 entry를 덮어쓰는) 동작만을 하기 때문에 이벤트가 발생하지 않는 것이다.</p>
<br />

<h2 id="history-api로-클라이언트-spa-라우팅-구현해보기">history API로 클라이언트 SPA 라우팅 구현해보기</h2>
<p>SPA는 페이지를 이동할 때마다 새로고침되지 않는다.</p>
<p>history의 <code>pushState</code> 를 사용해 URL만 바꾸고 해당 URL에 맞는 컴포넌트를 렌더링시키는 것이다.</p>
<p>주요 아이디어는 다음과 같다.</p>
<ul>
<li>URL이 변경될 때마다 컴포넌트 교체</li>
<li><code>history.pushState()</code> 를 사용해 URL만 바꾸고 페이지를 새로 고치지 않음</li>
<li><code>popstate</code> 이벤트를 이용해 앞/뒤로 가기 대응</li>
</ul>
<br />

<h3 id="1-react로-구현해보기">1. React로 구현해보기</h3>
<pre><code class="language-jsx">import React, { useEffect, useMemo, useState } from &#39;react&#39;;

const routes = [
  { path: /^\/$/, component: () =&gt; &lt;h2&gt;홈 페이지&lt;/h2&gt; },
  { path: /^\/about$/, component: () =&gt; &lt;h2&gt;소개 페이지&lt;/h2&gt; },
  { path: /^\/contact$/, component: () =&gt; &lt;h2&gt;연락처 페이지&lt;/h2&gt; },
  {
    path: /^\post\/(\d+)$/,
    component: (params) =&gt; &lt;h2&gt;포스트 ID: {params[1]} 페이지&lt;/h2&gt;,
  },
];

export default function App() {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() =&gt; {
    const onPopState = () =&gt; {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener(&#39;popstate&#39;, onPopState);
    return () =&gt; {
      window.removeEventListener(&#39;popstate&#39;, onPopState);
    };
  }, []);

  const navigate = (path) =&gt; {
    // state, title, url
    window.history.pushState({}, &#39;&#39;, path);
    setCurrentPath(path);
  };

  // 리렌더링을 고려하여 useMemo로 감싸줌
  const MatchedComponent = useMemo(() =&gt; {
    let component = () =&gt; &lt;h2&gt;404 페이지를 찾을 수 없습니다.&lt;/h2&gt;;

    routes.some((route) =&gt; {
      const match = currentPath.match(route.path);
      if (match) {
        component = () =&gt; route.component(match);
        return true;
      }
      return false;
    });

    return component;
  }, [currentPath]);

  return (
    &lt;div className=&quot;p-4 space-y-4&quot;&gt;
      &lt;nav className=&quot;space-x-4&quot;&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; navigate(&#39;/&#39;)}
          className=&quot;text-blue-500 underline&quot;
        &gt;
          홈
        &lt;/button&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; navigate(&#39;/about&#39;)}
          className=&quot;text-blue-500 underline&quot;
        &gt;
          소개
        &lt;/button&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; navigate(&#39;/contact&#39;)}
          className=&quot;text-blue-500 underline&quot;
        &gt;
          연락처
        &lt;/button&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; navigate(&#39;/post/123&#39;)}
          className=&quot;text-blue-500 underline&quot;
        &gt;
          포스트 123
        &lt;/button&gt;
        &lt;button
          type=&quot;button&quot;
          onClick={() =&gt; navigate(&#39;/post/456&#39;)}
          className=&quot;text-blue-500 underline&quot;
        &gt;
          포스트 456
        &lt;/button&gt;
      &lt;/nav&gt;
      &lt;hr /&gt;
      &lt;MatchedComponent /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<br />

<h3 id="2-순수-자바스크립트로-라우터-모듈-구현해보기">2. 순수 자바스크립트로 라우터 모듈 구현해보기</h3>
<pre><code class="language-jsx">const routes = [
  { path: /^\/$/, component: () =&gt; &#39;&lt;h2&gt;홈 페이지&lt;/h2&gt;&#39; },
  { path: /^\/about$/, component: () =&gt; &#39;&lt;h2&gt;소개 페이지&lt;/h2&gt;&#39; },
  { path: /^\/contact$/, component: () =&gt; &#39;&lt;h2&gt;연락처 페이지&lt;/h2&gt;&#39; },
  {
    path: /^\/post\/(\d+)$/,
    component: (params) =&gt; `&lt;h2&gt;포스트 ID: ${params[1]} 페이지&lt;/h2&gt;`,
  },
];

function matchRoute(pathname) {
  let matchedComponent = null;

  routes.some((route) =&gt; {
    const match = pathname.match(route.path);
    if (match) {
      matchedComponent = route.component(match);
      return true;
    }
    return false;
  });

  return matchedComponent || &#39;&lt;h2&gt;404 페이지를 찾을 수 없습니다.&lt;/h2&gt;&#39;;
}

function render(html) {
  document.getElementById(&#39;app&#39;).innerHTML = html;
}

function navigate(path) {
  window.history.pushState({}, &#39;&#39;, path);
  const html = matchRoute(path);
  render(html);
}

// popstate 이벤트 (뒤/앞으로 가기)
window.addEventListener(&#39;popstate&#39;, () =&gt; {
  const html = matchRoute(window.location.pathname);
  render(html);
});

// 초기 렌더링
const html = matchRoute(window.location.pathname);
render(html);
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript 개념 정리 - 타입스크립트 특징]]></title>
            <link>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%8A%B9%EC%A7%95</link>
            <guid>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%8A%B9%EC%A7%95</guid>
            <pubDate>Tue, 01 Jul 2025 06:38:06 GMT</pubDate>
            <description><![CDATA[<h2 id="타입스크립트의-특징">타입스크립트의 특징</h2>
<h3 id="컴파일-언어-정적-타입-언어">컴파일 언어, 정적 타입 언어</h3>
<p>자바스크립트는 동적 타입의 언어로 런타임에서 오류를 발견하고,
타입스크립트는 정적 타입의 컴파일 언어로 코드 작성 단계에서 타입을 체크해 오류를 확인할 수 있고, 미리 타입을 결정하기 때문에 실행속도가 빠르다.</p>
<p>하지만 코드 작성 시 매번 타입을 결정해야 하기 때문에 번거롭고 코드량이 증가하며 컴파일 시간이 오래 걸린다는 단점이 있다.</p>
<br />

<h3 id="자바스크립트-superset">자바스크립트 superset</h3>
<p>자바스크립트 기본 문법에 타입스크립트 문법을 추가한 언어이다.
따라서 유효한 자바스크립트로 작성한 코드는 확장자를 <code>.js</code> 에서 <code>.ts</code> 로 변경하고, 타입스크립트로 컴파일해 변환할 수 있다.</p>
<br />

<h3 id="객체지향-프로그래밍-지원">객체지향 프로그래밍 지원</h3>
<p>ES6을 포함하여 <code>class</code>, <code>instance</code>, <code>상속</code>, <code>모듈</code> 등과 같은 객체지향 프로그래밍 패턴을 제공한다.</p>
<br />

<h2 id="타입스크립트를-고려해야-하는-이유">타입스크립트를 고려해야 하는 이유</h2>
<h3 id="높은-수준의-코드-탐색과-디버깅">높은 수준의 코드 탐색과 디버깅</h3>
<p>타입스크립트는 코드에 목적을 명시하고, 목적에 맞지 않는 타입의 변수나 함수들에서 에러를 발생시켜 버그를 사전에 제거한다.
또한, 코드 자동완성이나 실행 전 피드백을 제공하여 작업과 동시에 디버깅이 가능해 생산성을 높일 수 있다.</p>
<br />

<h3 id="자바스크립트-호환">자바스크립트 호환</h3>
<p>타입스크립트는 자바스크립트와 100% 호환된다.
따라서 프론트엔드 또는 백엔드 어디든 자바스크립트를 사용할 수 있는 곳이라면 타입스크립트도 쓸 수 있다.
타입스크립트는 앱과 웹을 구현하는 자바스크립트와 동일한 용도로 사용가능하며 서버 단에서 개발이 이루어지는 복잡한 대형 프로젝트에서도 빛을 발한다.</p>
<br />

<h3 id="강력한-생태계">강력한 생태계</h3>
<p>타입스크립트는 그리 오래되지 않은 언어임에도 강력한 생태계를 가지고 있다.
대부분의 라이브러리들이 타입스크립트를 지원하며 마이크로소프트의 비주얼 스튜디오 코드를 비롯해 각종 에디터가 타입스크립트 관련 기능과 플러그인을 지원한다.</p>
<br />

<h3 id="점진적-전환-가능">점진적 전환 가능</h3>
<p>기존의 자바스크립트 프로젝트를 타입스크립트로 전환하는데 부담이 있다면, 추가 기능이나 특정 기능에만 타입스크립트를 도입함으로써 프로젝트를 점진적으로 전환할 수 있다.</p>
<p>자바스크립트에 주석을 추가하는 것에서부터 시작해 시간이 지남에 따라 코드베이스가 완전히 바뀌도록 준비 시간을 가질 수 있다.
프로젝트의 규모가 크고 복잡할 수록, 유지보수가 중요한 장기 프로젝트일 수록 타입스크립트의 이점이 부각된다.</p>
<br />

<h3 id="타입스크립트-장단점">타입스크립트 장/단점</h3>
<p>코드 작성시 매번 타입을 결정해야해서 번거롭고 코드량이 증가하며 컴파일 시간이 오래걸린다는 단점이 있다.
장점은 자바스크립트와 100% 호환되며, 코드에 목적을 명시하고 목적에 맞지않는 타입의 변수나 함수들에서 에러를 발생시켜 버그를 사전에 제거하고, 작업과 동시에 디버깅이 가능해 생산성을 높일 수 있다는 점이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript 개념 정리 - 유틸리티 타입]]></title>
            <link>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%EC%9C%A0%ED%8B%B8%EB%A6%AC%ED%8B%B0-%ED%83%80%EC%9E%85</link>
            <guid>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%EC%9C%A0%ED%8B%B8%EB%A6%AC%ED%8B%B0-%ED%83%80%EC%9E%85</guid>
            <pubDate>Mon, 30 Jun 2025 00:41:48 GMT</pubDate>
            <description><![CDATA[<h2 id="유틸리티-타입">유틸리티 타입</h2>
<p><code>제네릭</code>, <code>맵드 타입</code>,<code>조건부 타입</code> 등의 타입 조작 기능을 이용해 실무에서 자주 사용되는 타입을 만들어 놓은 것</p>
<img src="https://velog.velcdn.com/images/dobby_/post/742ba0c3-b063-49ce-b2f3-1162c16afdf4/image.png" width="70%" />

<br />

<h3 id="맵드-타입-기반의-유틸리티-타입">맵드 타입 기반의 유틸리티 타입</h3>
<blockquote>
<p><code>맵드 타입</code>
기존에 정의되어 있는 타입을 새로운 타입으로 변환해 주는 문법을 의미한다.
마치 <code>map()</code> API 함수를 타입에 적용한 것과 같은 효과를 가진다.</p>
</blockquote>
<br />

<h3 id="partial">Partial</h3>
<p>특정 객체 타입의 모든 프로퍼티를 선택적 프로퍼티로 바꿔주는 타입</p>
<pre><code class="language-typescript">interface Post {
  title: string;
  tags: strng[];
  content: string;
  thumbnailURL?: string;
}

type Partial&lt;T&gt; = {
  [key in keyof T]? T[key];
}

const draft: Partial&lt;Post&gt; = {
  title: &#39;&#39;,
  content: &#39;&#39;,
}</code></pre>
<br />

<h3 id="required">Required</h3>
<p>특정 객체 타입의 모든 프로퍼티를 필수 프로퍼티로 바꿔주는 타입</p>
<pre><code class="language-typescript">interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

type Required&lt;T&gt; = {
  [key in keyof T]-?: T[key];
}

const withThumbnailPost: Required&lt;Post&gt; = {
  title: &#39;&#39;,
  tags: &#39;&#39;,
  content: &#39;&#39;,
  thumbnailURL: &#39;&#39;,
}</code></pre>
<br />

<h3 id="readonly">Readonly</h3>
<p>특정 객체의 타입에서 모든 프로퍼티를 읽기 전용 프로퍼티로 만들어주는 타입</p>
<pre><code class="language-typescript">interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?:string;
}

type Readonly&lt;T&gt; = {
  readonly [key in keyof T]: T[key];
}

const readonlyPost: Readonly&lt;Post&gt; = {
  title: &#39;&#39;,
  tags: &#39;&#39;,
  content: &#39;&#39;,
}</code></pre>
<br />

<h3 id="pick">Pick</h3>
<p>Pick&lt;T, K&gt;
객체 타입으로부터 특정 프로퍼티만 딱 골라내는 타입</p>
<pre><code class="language-typescript">interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

type Pick&lt;T, K extends keyof T&gt; = {
  [key in K]: T[key];
}

const lagacyPost: Pick&lt;Post, &#39;title&#39; | &#39;content&#39;&gt; = {
  title: &#39;옛날 글&#39;,
  content: &#39;옛날 컨텐츠&#39;,
}</code></pre>
<br />

<h3 id="omit">Omit</h3>
<p>Omit&lt;T, K&gt;
객체 타입으로부터 특정 프로퍼티를 제거하는 타입</p>
<pre><code class="language-typescript">interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?: string;
}

type Omit&lt;T, K extends keyof T&gt; = Pick&lt;T, Exclude&lt;keyof T, K&gt;&gt;;

const noTitlePost: Omit&lt;Post, &#39;title&#39;&gt; = {
  content: &#39;&#39;,
  tags: [],
  thumbnailURL: &#39;&#39;,
}</code></pre>
<br />

<h3 id="record">Record</h3>
<p>Record&lt;K, V&gt;
객체 타입을 만들어주는 타입</p>
<pre><code class="language-typescript">interface Post {
  title: string;
  tags: string[];
  content: string;
  thumbnailURL?:string;
}

type Record&lt;K extends keyof any, V&gt; = {
  [key in K]: V;
}

type Thumnail = Record&lt;&#39;large&#39; | &#39;medium&#39; | &#39;small&#39;, {url: string}&gt;;
//type ThumnailRegacy = {
//    large: { url: string };
//    medium: { url: string };
//    small: { url: string };
//    watch: { url: string };
//}</code></pre>
<br />

<h2 id="조건부-타입-기반-유틸리티-타입">조건부 타입 기반 유틸리티 타입</h2>
<h3 id="exclude">Exclude</h3>
<p>Exclude&lt;T, U&gt;
T에서 U를 제거하는 타입</p>
<pre><code class="language-typescript">type Exclude&lt;T, U&gt; = T extends U ? never : T;

type A = Exclude&lt;string | boolean, boolean&gt;;
// 1. Exclude&lt;string, boolean&gt;
// 2, Exclude&lt;boolean, boolean&gt;
// 3. string | never
// 4. string</code></pre>
<br />

<h3 id="extract">Extract</h3>
<p>Extract&lt;T, U&gt;
T에서 U를 추출하는 타입</p>
<pre><code class="language-typescript">type Extract&lt;T, U&gt; = T extends U ? T : never;
type B = Extract&lt;string | boolean, boolean&gt;
// 1. Extract&lt;string, boolean&gt;
// 2. Extract&lt;boolean, boolean&gt;
// 3. boolean</code></pre>
<br />

<h3 id="returntype">ReturnType</h3>
<p>ReturnType&lt;T&gt;
함수의 반환값 타입을 추출하는 타입</p>
<pre><code class="language-typescript">type ReturnType&lt;T extends (...args: any) =&gt; any&gt; = T extends (...args: any) =&gt; infer R ? R : never;

function funcA() {
  return &#39;hello&#39;;
}

function funcB() {
  return 10;
}

type ReturnA = ReturnType&lt;typeof funcA&gt;
type ReturnB = ReturnType&lt;typeof funcB&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript 개념 정리 - keyof 타입 연산자]]></title>
            <link>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-keyof-%ED%83%80%EC%9E%85-%EC%97%B0%EC%82%B0%EC%9E%90</link>
            <guid>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-keyof-%ED%83%80%EC%9E%85-%EC%97%B0%EC%82%B0%EC%9E%90</guid>
            <pubDate>Fri, 27 Jun 2025 02:20:29 GMT</pubDate>
            <description><![CDATA[<h2 id="keyof-타입-연산자">keyof 타입 연산자</h2>
<p><code>keyof</code> 연산자는 객체 타입에서 객체의 키 값들을 숫자나 문자열 리터럴 유니언으로 생성한다.</p>
<p>아래의 타입 P는 <code>&#39;x&#39; | &#39;y&#39;</code>와 동일한 타입이다.</p>
<pre><code class="language-typescript">type Point = { x: number; y: numer;};
type P = keyof Point;</code></pre>
<p>요약하자면, 자바스크립트에서의 <code>typeof</code> 는 변수의 타입을 반환하고, <code>keyof</code> 는 객체 또는 인터페이스의 키 값을 타입으로 반환하는 것이다.</p>
<p>만약 타입이 <code>string</code>이나 <code>number</code> 인덱스 시그니처를 가지고 있다면, <code>keyof</code> 는 해당 타입을 리턴한다.</p>
<pre><code class="language-typescript">type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish; // number;

type Mapish = { [k: string]: boolean };
type M = keyof Mapish; // string | number</code></pre>
<p>여기서 주목할 점은 M의 타입이 <code>string | number</code> 라는 점이다.
Javascript 객체 키는 항상 문자열을 강제하기 때문에 <code>obj[0]</code>은 <code>obj[&#39;0&#39;]</code>과 동일하다
typescript에서는 number 키도 자동으로 string으로 변환되기 때문에 안전하게 <code>number | string</code> 으로 확장해서 보는 것이다.</p>
<p>그래서 <code>type Arrayish = { [n: number]: unknown };</code> 이긴 하지만 n에 <code>&#39;1&#39;</code>과 같은 문자열 숫자 키도 허용된다.</p>
<br />

<p><code>keyof</code> 타입은 매핑된 타입과 함께 사용할 때 특히 유용하다.
인터페이스를 정의하고 <code>keyof</code> 를 사용하여 해당 인터페이스의 모든 속성 이름을 가져올 수 있다.</p>
<pre><code class="language-typescript">interface Person {
 name: string;
 age: number;
 address: string;
}

type PersonKeys = keyof Person;</code></pre>
<p>PersonKeys는 <code>&quot;name&quot; | &quot;age&quot; | &quot;address&quot;</code> 라는 문자열 리터럴 유니온 타입이 된다.</p>
<p>이렇게 생성된 문자열 유니온 타입을 사용하여 객체의 속성 이름을 동적으로 참조하거나 검사할 수 있다.</p>
<br />

<h3 id="사용-예시">사용 예시</h3>
<pre><code class="language-typescript">type Filter = {
  size: string[];
  breed: string[];
}
const [selectedFilter, setSelectedFilter] = useState&lt;Filter&gt;({
  size: [],
  breed: [],
});

const handleFilterSelect = (filterKey: keyof Filter, filterName: string) =&gt; {
  const updatedFilter = { ...selectedFilter };

  // 이미 배열에 저장되어 있는 filter 값인 경우 배열에서 제거
  if(selectedFilter[filterKey].includes(filterName)) {
   updatedFilter[filterKey] = updatedFilter[filterKey].filter((name) =&gt; name !== filterName));

    selectedFilter(updatedFilter);
    return;
  }
  // 배열에 저장되어 있지 않은 filter 값인 경우 배열에 추가
  updatedFilter[filterKey].push(filterName);
  setSelectedFilter(updatedfilter);
}</code></pre>
<br />

<h2 id="keyof-typeof">keyof typeof</h2>
<pre><code class="language-typescript">const handlePrintOptionButtonClick = (groupIndex: number, buttonIndex: number) =&gt; {
 ...
 const selectedKey = ButtonTitleKey[printMod as keyof typeof ButtonTitleKey][groupIndex];
}</code></pre>
<p><code>keyof typeof</code> 를 사용해 객체의 키 값을 타입으로 가져왔다.
-&gt; <code>typeof ButtonTitleKey</code> 는 <code>ButtonTitleKey</code> 객체의 타입을 가져오고, <code>keyof</code> 를 사용해 해당 객체의 모든 키 값을 가져온다.</p>
<p>그렇다면 아래와 같은 상황에서는 어떻게 사용해야 할까?</p>
<pre><code class="language-typescript">const person = {
 name: &#39;jiwoo&#39;,
 age: 10,
}

console.log(typeof person); // &#39;object&#39;</code></pre>
<p><code>typeof</code> 만 사용했을 경우, 이런식으로 &#39;object&#39;라는 타입을 반환하게 되고, <code>keyof</code> 를 사용하면 해당 객체의 키 값을 유니온 타입으로 반환한다고 했는데 어쩔 때 같이 사용해야 할까?</p>
<br />

<h3 id="keyof-typeof를-같이-사용하는-경우">keyof typeof를 같이 사용하는 경우</h3>
<pre><code class="language-typescript">const bmw = { name: &#39;BMW&#39;, power: &#39;1000hp&#39; };

type CarLiteraltype = keyof typeof bmw;

let carPropertyLiteral: CarLiteraltype;
carPropertyLiteral = &#39;name&#39; // OK
carPropertyLiteral = &#39;power&#39; // OK
carPropertyLiteral = &#39;anyOther&#39; // Error...</code></pre>
<p>위 예시처럼, 미리 <code>interface</code> 등으로 타입 지정이 되어 있지 않고 값만 할당되어 있는 경우 object에서 바로 사용하고 싶은 경우에 typeof와 keyof를 같이 사용하여 해당 객체의 키 값으로 이루어진 유니온 타입을 얻을 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript 개념 정리 - 조건부 타입]]></title>
            <link>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%EC%A1%B0%EA%B1%B4%EB%B6%80-%ED%83%80%EC%9E%85</link>
            <guid>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%EC%A1%B0%EA%B1%B4%EB%B6%80-%ED%83%80%EC%9E%85</guid>
            <pubDate>Wed, 25 Jun 2025 00:51:23 GMT</pubDate>
            <description><![CDATA[<h2 id="조건부-타입">조건부 타입</h2>
<pre><code class="language-typescript">type A = number extends string ? sring : number; // number

type objA = {
  a: number;
}

type objB = {
 a: number;
 b: number;
}

type B = objB extends objA ? number : string; // number

// 제네릭과 조건부 타입
type StringNumberSwitch&lt;T&gt; = T extends number ? string : number;

let varA: StringNumberSwitch&lt;number&gt;; // string
let varB: StringNumberSwitch&lt;string&gt;; // number

function removeSpaces&lt;T&gt;(text: T):T extends string ? string : undefined;
function removeSpaces(text: any) {
 if(typeof text === &#39;string&#39;) {
   return text.replaceAll(&#39; &#39;, &#39;&#39;);
 }
 else {
   return undefined;
 }
}

let result = removeSpaces(&#39;hi im sooyeon&#39;);
result.toUpperCase();

let result2 = removeSpaces(undefined);</code></pre>
<br />

<h2 id="분산적인-조건부-타입">분산적인 조건부 타입</h2>
<pre><code class="language-typescript">// 제네릭과 조건부 타입
type StringNumberSwitch&lt;T&gt; = T extends number ? string : number;

let c: StringNumberSwitch&lt;number | string&gt;; // number | string
// 한 번은 StringNumberSwitch&lt;number&gt;만 전달이 되고
// 한 번은 StringNumberSwitch&lt;string&gt;만 전달이 된다.
// 두 개의 타입이 | 유니온으로 묶이게 되는 것이다.

let d: StringNumberSwitch&lt;boolean | numer | string&gt;;
// number | string | number
// number | string

type Exclude&lt;T, U&gt; = T extends U ? never : T;
type A = Exclude&lt;number | string | boolean, string&gt;;
// 1. Exclude&lt;number, string&gt; |
// 2. Exclude&lt;string, string&gt; |
// 3. Exclude&lt;boolean, string&gt;
// 4. number | never | boolean
// 5. number | boolean

type Extract&lt;T, U&gt; = T extends U ? T : never;
type B = Extract&lt;number | string | boolean, string&gt;;
// 1. Extract&lt;number, string&gt; |
// 2. Extract&lt;string, string&gt; |
// 3. Extract&lt;boolean, string&gt;
// 4. never | string | never
// 5. string</code></pre>
<br />

<h2 id="infer">infer</h2>
<p>inference(추론)의 줄임말이다.
타입을 추론해 대입해주며, 참이 되는 타입으로 추론된다.</p>
<pre><code class="language-typescript">type FuncA = () =&gt; string;
type FuncB = () =&gt; number;
type ReturnType&lt;T&gt; = T extends () =&gt; string ? string : never;

type A = ReturnType&lt;FuncA&gt;; // string
type B = ReturnType&lt;FuncB&gt;; // never

type ReturnType&lt;T&gt; = T extends () =&gt; infer R ? R : never;
type A = ReturnType&lt;FuncA&gt;; // string
type B = ReturnType&lt;FuncB&gt;; // number
// 조건식이 참이되게 하는 타입이 R로 된다.

type C = ReturnType&lt;number&gt;; // never
// R 타입이 뭐가 되도 참이 될 수 없음
// number가 () =&gt; R의 서브 타입이 되는 참이 없음

type PromiseUnpack&lt;T&gt; = T extends Promise&lt;infer R&gt; ? R : never;
type PromiseA = PromiseUnpack&lt;Promise&lt;number&gt;&gt;; // number
type PromiseB = PromiseUnpack&lt;Promise&lt;string&gt;&gt;; // string</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript 개념 정리 - 타입 조작하기]]></title>
            <link>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%ED%83%80%EC%9E%85-%EC%A1%B0%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%ED%83%80%EC%9E%85-%EC%A1%B0%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 24 Jun 2025 01:18:41 GMT</pubDate>
            <description><![CDATA[<h2 id="타입-조작하기">타입 조작하기</h2>
<h3 id="인덱스드-엑세스-타입">인덱스드 엑세스 타입</h3>
<p>인덱스라는 것을 이용해서 다른 타입 내의 특정 프로퍼티의 타입을 추출하는 타입</p>
<pre><code class="language-typescript">interface Post {
 title: string;
 content: string;
 author: {
  id: number;
  name: string;
  age: number;
 }
}

// 인덱스트 엑세스 타입
function printAuthorInfo(author: Post[&#39;author&#39;]) {
 console.log(`${author.name}-${author.id}`); 
}

const post: Post = {
 title: &#39;게시글 제목&#39;,
 content: &#39;&#39;,
 author: {
  id: 1,
  name: &#39;&#39;,
  age: 26,
 }
}</code></pre>
<pre><code class="language-typescript">type PostList = {
 title: string;
 content: string;
 author: {
  id: number;
  name: string;
  age: number
 }
}[];

// [name][author] 인덱스의 author 타입을 접근한다.
function printAuthorInfo(author: PostList[number][&#39;author&#39;]) {
 console.log(`${author.name}-${author.id}`); 
}

// 하나의 요소의 타입만 가져온다.
const post: PostList[number] = {
 title: &#39;게시글 제목&#39;,
 content: &#39;&#39;,
 author: {
   id: 1,
   name: &#39;&#39;,
   age: 26
 }
}</code></pre>
<pre><code class="language-typescript">type Tup = [number, string, boolean];
type Tup0 = Tup[0];
type Tup1 = Tup[1];
type Tup2 = Tup[2];

// 튜플안에 있는 모든 타입의 최적의 공통 타입을 뽑는다.
type TupNum = Tup[number]; // string | number | boolean </code></pre>
<br />

<h2 id="keyof-연산자">keyof 연산자</h2>
<p>특정 객체의 타입으로부터 프로퍼티의 키들을 string 유니온 타입으로 추출하는 연산자</p>
<pre><code class="language-typescript">interface Person {
 name: string;
 age: number;
}

// key: &quot;name&quot; | &quot;age&quot;
function getPropertyKey(person: Person, key: keyof Person) {
 return person[key];
}

const person: Person = {
 name: &#39;&#39;,
 age: 26,
}

getPropertyKey(person, &#39;name&#39;);</code></pre>
<pre><code class="language-typescript">type Person = typeof person;

function getPropertyKey(person: Person, key: keyof typeof person) {
  return person[key];
}

const person: Person = {
 ame: &#39;&#39;,
 age: 26
}

getPropertyKey(person, &#39;name&#39;);</code></pre>
<br />

<h2 id="mapped-type">mapped type</h2>
<p>mapped type은 interface에서는 쓸 수 없고, 타입 별칭에서만 쓸 수 있다.</p>
<pre><code class="language-typescript">interface User {
 id: number;
 name: string;
 age: number;
}

type PartialUser = {
 [key in &#39;id&#39; | &#39;name&#39; | &#39;age&#39;]?: User[key];
}

type BooleanUser = {
 [key in keyof User]: boolean; 
}

type ReadonlyUser = {
 readonly [key in keyof User]: User[key]; 
}

function fetchUser(): ReadonlyUser {
 // ...
  return {
   id: 1,
   name: &#39;&#39;,
   age: 26,
  }
}

function updateUser(user: User) {

}

updateUser({
  // id: 1,
  // name: &#39;&#39;,
  // age: 26
});</code></pre>
<br />

<h2 id="템플릿-리터럴-타입">템플릿 리터럴 타입</h2>
<pre><code class="language-typescript">type Color: &#39;red &#39; | &#39;black&#39; | &#39;reen&#39;;
type Animal = &#39;dog&#39; | &#39;cat&#39; | &#39;chicken&#39;;

// type ColoredAnimal = &#39;red-dog&#39; | &#39;red-cat&#39; | &#39;red-chicken&#39; | &#39;black-dog&#39; ...
type ColoredAnimal = `${Color}-${Animal}`;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript 개념 정리 - 제네릭]]></title>
            <link>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%EC%A0%9C%EB%84%A4%EB%A6%AD</link>
            <guid>https://velog.io/@dobby_/TypeScript-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC-%EC%A0%9C%EB%84%A4%EB%A6%AD</guid>
            <pubDate>Mon, 23 Jun 2025 00:43:01 GMT</pubDate>
            <description><![CDATA[<h2 id="제네릭">제네릭</h2>
<p>제네릭 함수: 모든 타입에 두루두루 사용할 수 있는 범용 함수</p>
<blockquote>
<p>제네릭은 <strong>선언 시점</strong>이 아니라 <strong>생성 시점</strong>에 타입을 명시하여 하나의 타입만이 아닌 다양한 타입을 사용할 수 있도록 하는 기법이다.</p>
</blockquote>
<p>함수를 호출할 때 매개변수에 따라 타입이 달라진다.</p>
<pre><code class="language-typescript">function func&lt;T&gt;(value: T): T {
 return value; 
}

let num = func(10); // number type
let bool = func(true); // boolean type
let str = func(&#39;str&#39;); // string type</code></pre>
<br />

<h2 id="타입-변수-응용">타입 변수 응용</h2>
<p><code>&lt;T&gt;</code>: 타입 변수</p>
<pre><code class="language-typescript">function swap&lt;T, U&gt;(a: T, b: U) {
 return [b, a]; 
}

const [a, b] = swap(&#39;1&#39;, 2);</code></pre>
<pre><code class="language-typescript">function returnFirstValue&lt;T&gt;(data: [T, ...unknown[]]) {
 return data[0]; 
}

let num = returnFirstValue([0, 1, 2]);
let str = returnFirstValue([1, &#39;hello&#39;, &#39;mynameis&#39;]);</code></pre>
<pre><code class="language-typescript">function getLength&lt;T extends {length: number}&gt;(data: T) {
 return data.length; 
}

let var1 = getLength([1,2,3]);
let var2 = getLength(&#39;12345&#39;);
let var4 = getLength({length: 10});</code></pre>
<br />

<h2 id="map-foreach-메서드-타입-정의하기">map, forEach 메서드 타입 정의하기</h2>
<pre><code class="language-typescript">// map
function map&lt;T, U&gt;(arr: T[], callback: (item: T) =&gt; U) {
 let result = [];
 for(let i = 0; i &lt; arr.length; i++) {
  result.push(callback(arr[i])); 
 }

 return result;
}

map(arr, (it) =&gt; it * 2);
map([&#39;hi&#39;, &#39;hello&#39;], (it) =&gt; parseInt(it));</code></pre>
<pre><code class="language-typescript">// forEach
function forEach&lt;T&gt;(arr: T[], callback: (item: T) =&gt; void) {
 for(let i = 0; i &lt; arr.length; i++) {
  callback(arr[i]); 
 }
}

forEach(arr2, (it) =&gt; {
  console.log(it.toFixed());
});

forEach([&#39;123&#39;, &#39;456&#39;], (it) =&gt; {
 it; 
})</code></pre>
<br />

<h2 id="제네릭-인터페이스--제네릭-타입-별칭">제네릭 인터페이스 &amp; 제네릭 타입 별칭</h2>
<h3 id="제네릭-인터페이스">제네릭 인터페이스</h3>
<pre><code class="language-typescript">interface KeyPair&lt;K, V&gt; {
 key: K;
 value: V;
}

let keyPair: KeyPair&lt;string, number&gt; = {
 key: &#39;key&#39;,
 value: 0,
}

let keyPair2: KeyPair&lt;boolean, string[]&gt; = {
 key: true,
 value: [&#39;1&#39;],
}</code></pre>
<br />

<h3 id="인덱스-시그니처">인덱스 시그니처</h3>
<pre><code class="language-typescript">interface NumberMap {
 [key: string]: number;
}

let numberMap1: NumberMap = {
 key: -1234,
 key2: 123123,
}

interface Map&lt;V&gt; {
 [key: string]: V; 
}

let stringMap: Map&lt;string&gt; = {
 key: &#39;string&#39;, 
}

let booleanMap: Map&lt;boolean&gt; = {
 key: true, 
}</code></pre>
<br />

<h3 id="제네릭-타입-별칭">제네릭 타입 별칭</h3>
<pre><code class="language-typescript">type Map2&lt;V&gt; = {
 [key: string]: V; 
}

let stringMap2: Map2&lt;string&gt; = {
 key: &#39;hello&#39;, 
}</code></pre>
<br />

<h3 id="제네릭-인터페이스-활용-예시">제네릭 인터페이스 활용 예시</h3>
<pre><code class="language-typescript">interface Student {
 type: &#39;student&#39;;
 school: string;
}

interface Developer {
 type: &#39;developer&#39;;
 skill: string;
}

interface User&lt;T&gt; {
 name: string;
 profile: T;
}

function goToSchool(user: User&lt;Student&gt;) {
 const school = user.profile.school;
 console.log(`${school}로 등교 완료`);
}

const developerUser: User&lt;Developer&gt; = {
 name: &#39;dobby&#39;,
 profile: {
  type: &#39;developer&#39;,
  skill: &#39;typescript&#39;,
 }
}

const studentUser: User&lt;Student&gt; = {
 name: &#39;sooyeon&#39;,
 profile: {
  type: &#39;student&#39;,
  school: &#39;전남대학교&#39;,
 }
}</code></pre>
<br />

<h2 id="제네릭-클래스">제네릭 클래스</h2>
<pre><code class="language-typescript">class List&lt;T&gt; {
 constructor(private list: T[]) {}

 push(data: T) {
  this.list.push(data); 
 }

 pop() {
  return this.list.pop(); 
 }

 print() {
  console.log(this.list); 
 }
}

const numberList = new List([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print();

const stringList = new List([&#39;1&#39;, &#39;2&#39;]);
stringList.push(&#39;hello&#39;);</code></pre>
<br />

<h2 id="프로미스promise">프로미스(Promise)</h2>
<p>자동으로 타입을 추론하는 기능을 가지고 있지 않다.
<code>resolve</code>의 타입은 정의할 수 있지만, <code>reject</code>에 대한 타입은 정의할 수 없다.</p>
<pre><code class="language-typescript">const promise = new Promise&lt;number&gt;((resolve, reject) =&gt; {
  setTimeout(() =&gt; {
    resolve(20); // number;
    reject(&#39;~~ 때문에 실패&#39;);
  }, 3000)
});

promise.then((response) =&gt; {
  console.log(response * 10); // number
})

promise.catch((err) =&gt; {
  if(typeof err === &#39;string&#39;) {
    console.log(err);
  }
});</code></pre>
<p>프로미스를 반환하는 함수의 타입 정의</p>
<pre><code class="language-typescript">interface Post {
 id: number;
 title: string;
 content: string;
}

function fetchPost(): Promise&lt;Post&gt; {
 return new Promise((resolve, reject) =&gt; {
   setTimeout(() =&gt; {
     resolve({id: 1, title: &#39;&#39;, content: &#39;&#39;});
   }, 3000);
 }); 
}

const postRequest = fetchPost();
postRequest.then((post) =&gt; {
  post.id;
})</code></pre>
]]></description>
        </item>
    </channel>
</rss>