<?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>Wed, 22 Jul 2026 07:31:21 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[안정적인 네트워크 요청을 만들기 위한 Fetch 설계]]></title>
            <link>https://velog.io/@dobby_/%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%9A%94%EC%B2%AD%EC%9D%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%9C%84%ED%95%9C-Fetch-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@dobby_/%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%9A%94%EC%B2%AD%EC%9D%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%9C%84%ED%95%9C-Fetch-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 22 Jul 2026 07:31:21 GMT</pubDate>
            <description><![CDATA[<h2 id="안정적인-네트워크-요청을-만들기-위한-fetch-설계">안정적인 네트워크 요청을 만들기 위한 Fetch 설계</h2>
<p>웹 서비스를 개발하다 보면, 네트워크 요청은 항상 성공한다고 가정할 수 없다.
서버가 일시적으로 응답하지 않을 수도 있고, 사용자의 네트워크 환경이 불안정할 수도 있고, 요청이 예상보다 오래 걸리는 경우도 있다.</p>
<p>처음에는 단순히 <code>fetch()</code> 만 사용하면 충분하다고 생각했지만, 공통 Fetch 유틸리티를 구현하면서 ‘실패하는 요청을 어떻게 처리할 것인가’가 서비스의 안정성과 사용자 경험에 큰 영향을 준다는 점을 알게 되었다.</p>
<p>이번 글에서는 안정적인 네트워크 요청을 만들기 위해 고려했던 Timeout, AbortController, Retry Strategy에 대해 정리해보려고 한다.</p>
<blockquote>
<p>프론트에서의 재시도 전략은 네트워크 일시 장애나 서버 과부하 시 사용자 경험을 해치지 않고 요청을 복구하는 패턴이다.</p>
</blockquote>
<h3 id="fetch-api에는-timeout이-없다">Fetch API에는 Timeout이 없다.</h3>
<blockquote>
<p>참고 자료</p>
</blockquote>
<ul>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/Fetch_API">MDN - Fetch API</a><blockquote>
</blockquote>
</li>
</ul>
<p><code>fetch()</code> 를 처음 사용할 때 놓치는 부분 중 하나가 바로 Timeout이다.</p>
<pre><code class="language-tsx">await fetch(&quot;/api/users&quot;);</code></pre>
<p>이 코드를 보면 일정 시간이 지나면 자동으로 요청이 종료될 것처럼 느껴질 수 있지만, 실제로는 그렇지 않다.</p>
<p>Fetch API는 자체적으로 Timeout 기능을 제공하지 않는다.
서버가 응답하지 않더라도 브라우저는 응답이 올 때까지 계속 기다린다.
즉, Promise는 <code>pending</code> 상태로 남아 있고, 사용자는 로딩 화면만 계속 보게 될 수도 있다.</p>
<p>서비스를 운영하는 입장에서는 이러한 상황을 그대로 둘 수 없다.
실제로 프로젝트 개발 중 공동 기록 작성 후 저장 API 요청을 보내는데 서버로부터 응답이 오지 않아 10초 이상 기다렸다 끊어버린 경험이 있다.</p>
<p>일정 시간이 지나도 응답이 오지 않는다면 요청을 종료하고, 사용자에게 적절한 안내를 제공하는 것이 더 나은 사용자 경험을 제공할 것이다.</p>
<p>그래서 Timeout 처리가 필요하다.</p>
<h3 id="abortcontroller를-이용한-timeout-구현">AbortController를 이용한 Timeout 구현</h3>
<blockquote>
<p>참고 자료</p>
</blockquote>
<ul>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">MDN - AbortController</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/AbortSignal">MDN - AbortSignal</a><blockquote>
</blockquote>
</li>
</ul>
<p>Fetch API에는 Timeout 옵션은 없지만, 요청을 직접 취소할 수 있는 기능은 제공한다.
이때 사용하는 것이 <code>AbortController</code> 이다.</p>
<p><code>AbortController</code> 는 비동기 작업을 중단하기 위한 Web API이다.
Fetch API는 이 객체가 제공하는 <code>signal</code> 을 전달받아 요청을 관리한다.</p>
<pre><code class="language-tsx">const controller = new AbortController();

fetch(&quot;/api/users&quot;, {
  signal: controller.signal,
});</code></pre>
<p>그리고 일정 시간이 지나면 <code>abort()</code> 를 호출해 요청을 종료할 수 있다.</p>
<pre><code class="language-tsx">setTimeout(() =&gt; {
  controller.abort();
}, 5000);</code></pre>
<p>즉, Timeout 기능을 직접 구현하는 방식이다.</p>
<h3 id="abortsignal은-어떤-역할을-할까">AbortSignal은 어떤 역할을 할까?</h3>
<p><code>AbortController</code> 와 함께 자주 등장하는 객체가 <code>AbortSignal</code> 이다.</p>
<p>처음에는 두 객체의 차이가 헷갈렸지만, 역할은 생각보다 단순하다.</p>
<ul>
<li><strong>AbortController</strong>: 요청을 취소하는 주체</li>
<li><strong>AbortSignal</strong>: 요청이 취소되었는지 알려주는 신호</li>
</ul>
<p>Fetch는 <code>signal</code> 을 계속 관찰하다가 <code>abort()</code> 가 호출되면 요청을 즉시 종료하고 <code>AbortError</code> 를 발생시킨다.</p>
<pre><code class="language-tsx">fetch 시작
      │
      ▼
AbortSignal 등록
      │
      ▼
controller.abort()
      │
      ▼
AbortSignal 상태 변경
      │
      ▼
fetch 종료 (AbortError 발생)</code></pre>
<p>따라서 <code>try...catch</code> 를 사용할 경우에는 일반적인 네트워크 에러뿐 아니라 <code>AbortError</code> 도 함께 처리하는 것이 좋다.</p>
<p>여기서 하나 더 생각해봐야 하는게 있다.
타임 아웃 시간을 선택하는 것이다.</p>
<p>시간 제한을 너무 높게 설정하면 클라이언트가 대기하는 시간 동안에도 리소스가 계속 사용되기 때문에 유용성이 떨어진다.
시간 제한을 너무 낮게 설정하면 두 가지 위험이 대두된다.</p>
<ul>
<li>요청의 재시도가 너무 많아져 백엔드의 트래픽과 대기 시간이 증가한다.</li>
<li>모든 요청의 재시도가 시작되기 때문에 소규모 백엔드 지연 시간 증가가 완전한 가동 중단으로 이어진다.</li>
</ul>
<h3 id="요청이-실패했다면-다시-요청하면-될까">요청이 실패했다면 다시 요청하면 될까?</h3>
<p>Timeout을 구현하고 나면 자연스럽게 드는 생각이 있다.</p>
<p>“실패했으니 다시 요청하면 되는 것 아닌가?”</p>
<p>Timeout뿐만 아니라, 의도치않게 실패된 것인지 의도된 실패인지 분별할 수 없을 때 요청을 재시도하는 것이다.</p>
<p>하지만 모든 요청을 무조건 retry 하는 것은 위험할 수 있다.</p>
<p>네트워크 오류나 일시적인 서버 장애처럼 잠깐의 문제라면 retry가 효과적일 수 있다.
반면, 사용자의 요청 자체가 이미 정상적으로 처리되었는데 응답만 받지 못한 상황이라면 이야기가 달라진다.</p>
<p>예를 들어 결제 API를 생각해보자</p>
<pre><code class="language-tsx">결제 요청
↓
서버는 결제 완료
↓
응답 전 네트워크 끊김
↓
클라이언트는 실패라고 판단
↓
Retry
↓
결제가 두 번 수행될 수도 있다.</code></pre>
<p>즉, retry는 “실패했으니 다시 보내자”가 아니라 왜 실패했는지를 먼저 판단해야 하는 전략이다.</p>
<h3 id="언제-retry-해야할까">언제 Retry 해야할까?</h3>
<p>일반적으로 retry는 일시적인 오류에 대해서만 수행하는 것이 좋다.</p>
<p>대표적인 예시는 다음과 같다.</p>
<ul>
<li>네트워크 연결이 잠시 끊어진 경우</li>
<li>서버가 일시적으로 과부하 상태인 경우</li>
<li>Timeout이 발생한 경우</li>
<li>5xx 같은 일반적인 서버 오류</li>
</ul>
<p>이러한 경우는 잠시 후 다시 요청하면 정상적으로 처리될 가능성이 높다.</p>
<p>반대로 인증 오류(401), 권한 오류(403), 잘못된 요청(400)처럼 클라이언트가 수정하지 않는 이상 결과가 바뀌지 않는 오류는 retry해도 의미가 없다.</p>
<h3 id="중요한-것은-http-method가-아니라-멱등성">중요한 것은 HTTP Method가 아니라 멱등성</h3>
<p>흔히 <code>GET</code> 은 retry가 가능하고 <code>POST</code> 는 위험하다고 이야기한다.
물론 일반적으로는 맞는 이야기지만, 실제로는 HTTP 메소드보다는 멱등성이 더 중요한 기준이 된다.</p>
<blockquote>
<p><strong>멱등성</strong>
동일한 요청을 여러 번 수행하더라도 결과가 동일하게 유지되는 성질</p>
</blockquote>
<p>예를 들어 <code>PUT /users/1</code> 요청은 여러 번 호출하더라도 최종 상태는 동일하다.
따라서 retry가 비교적 안전하다.</p>
<p>반면 <code>POST /payments</code> 처럼 새로운 데이터를 생성하는 요청은 호출할 때마다 새로운 결제가 발생할 수 있기 때문에 retry가 위험하다.</p>
<p>즉, retry 여부는 HTTP 메소드만 보고 결정하기보다 해당 API가 멱등성을 보장하는지를 먼저 확인해야 한다.</p>
<h3 id="retry도-전략이-필요하다">Retry도 전략이 필요하다</h3>
<blockquote>
<p>참고 자료</p>
</blockquote>
<ul>
<li><a href="https://aws.amazon.com/ko/builders-library/timeouts-retries-and-backoff-with-jitter/">AWS timeouts retries and backoff with jitter</a><blockquote>
</blockquote>
</li>
</ul>
<p>주요 재시도 전략은 다음과 같다.</p>
<p><strong>1. 즉시 재시도</strong></p>
<p>실패 즉시 딜레이 없이 재시도한다.
순간적인 네트워크 깜빡임이 원인일 때 유용하고, 사용자는 대기 시간 없이 원활한 이용이 가능하다.</p>
<p><strong>2. 고정 간격 재시도</strong></p>
<p>실패시 매번 정해진 고정 시간을 기다린 후 재시도한다.
단순한 간헐적 오류에 대응할 때 적합하지만, 서버 장애 시 짧은 시간에 요청이 몰릴 위험이 있다.</p>
<p><strong>3. 지수 백오프(Exponential Backoff) 및 지터(Jitter)</strong></p>
<p>재시도를 거듭할 수록 대기 시간이 지수 함수적으로 증가하며(2초, 4초, 8초..), 여기에 무작위 값(Jitter)을 추가해 요청이 동시에 서버로 쏠리는 현상을 방지한다.
서버 점검이나 트래픽 급증 시 시스템을 보호하는 가장 안정적인 전략이다.</p>
<p>고정 간격과 지수 백오프 모두 일정 시간을 두고 재시도를 한다는 점은 동일하다.
하지만 어차피 동시에 요청이 몰린다면 똑같은 시간 간격으로 모든 재시도가 동일하게 몰릴 것이기에 일정 시간 대기 재요청도 한계가 있다.</p>
<p>그래서 이를 개선하기 위한 방법이 Jitter를 도입하는 것이다.</p>
<blockquote>
<p><strong>Jitter</strong>
데이터 전송이나 신호 처리시, 시간 간격이 일정하지 않고 미세하게 흔들리거나 변동되는 현상을 말한다.
네트워크 분야에서는 패킷이 목적지에 도착하는 지연 시간(latency)이 불안정하게 요동치는 현상을 말한다.
이 개념을 retry에 적용하면, API를 요청하는 클라이언트 간의 동일한 재시도 시간 간격에 무작위성을 추가하여 서로 요청하는 시간대를 분산시킬 수 있다.</p>
</blockquote>
<p>1번과 같은 실패 직후 바로 다시 요청하는 것은 좋은 방법이 아니다.</p>
<p>예를 들어 서버 장애가 발생한 상황에서 수천 명의 사용자가 동시에 Retry를 수행한다면, 이미 과부하 상태인 서버에 더 많은 요청을 보내게 된다.</p>
<p>이러한 문제를 방지하기 위해 대부분의 서비스는 3번 지수 백오프와 지터를 함께 사용한다.</p>
<p>AWS에서도 retry 전략으로 지수 백오프와 지터를 함께 사용하는 방식을 권장하고 있다.</p>
<blockquote>
<p>아래는 AWS 문서에서 가져온 내용</p>
<ul>
<li><strong>재시도 시점 결정</strong>. 
일반적으로 우리는 부작용을 수반하는 API가 멱등성을 제공하지 않는 한 안전하지 않다고 여기고 있습니다. 
이것은 재시도 빈도와 상관없이 부작용이 한번만 발생하게 합니다.
읽기 전용 API는 대개 멱등적이지만 리소스 생성 API는 그렇지 않을 수 있습니다. 
중복된 부작용을 방지하기 위해서는 바람직한 API 설계와 함께 클라이언트 구현 시 여러 가지 사항을 주의해야 합니다.</li>
<li><strong>어떤 장애 발생 시 재시도할 가치가 있는지 파악하기</strong>. 
HTTP는 <em>클라이언트*와 *서버</em> 오류 간에 분명한 구분을 제공합니다.
클라이언트 오류는 나중에도 성공하지 못할 것이기 때문에 동일한 요청으로 재시도해서는 안 되지만, 서버 오류는 후속 시도에서 성공할 수 있습니다. 
불행히도 시스템의 최종 일관성은 이 경계를 모호하게 만듭니다. 
한 순간의 클라이언트 오류는 상태가 전파될 경우 다음 순간에 성공으로 바뀔 수 있습니다.</li>
</ul>
</blockquote>
<h2 id="프로젝트에-재시도-정책-적용">프로젝트에 재시도 정책 적용</h2>
<blockquote>
<p>참고 자료</p>
</blockquote>
<ul>
<li><a href="https://aws.amazon.com/ko/blogs/architecture/exponential-backoff-and-jitter/">AWS exponential backoff and jitter</a><blockquote>
</blockquote>
</li>
</ul>
<h3 id="재시도-대상-응답을-못-받은-경우만">재시도 대상: 응답을 못 받은 경우만</h3>
<p>재시도 대상은 ‘응답을 못 받은 경우’만 이다.
가장 먼저 정한 기준은 <code>fetch</code> 자체가 실패한 경우만 재시도 대상으로 삼는다는 것이다.</p>
<pre><code class="language-tsx">try {
  const response = await fetch(url, { ...fetchOptions, signal: controller?.signal });
  // ... 응답을 정상적으로 받았다면(4xx/5xx 포함) 그대로 반환, 재시도하지 않음
  return { ...data, headers: response.headers };
} catch (error) {
  // AbortError(타임아웃)는 재시도하지 않고 즉시 반환
  if (err.name === &#39;AbortError&#39;) {
    return { success: false, data: null, error: { code: &#39;TIMEOUT&#39;, ... } };
  }
  // JSON 파싱 에러도 재시도하지 않음 — 서버 응답 자체의 문제일 가능성
  if (err.message.includes(&#39;JSON&#39;) || ...) { ... }

  // 여기까지 걸러지고 남은 것 — 연결 실패, DNS 오류 등 순수 네트워크 예외만 재시도
  if (attempt &gt;= maxRetries) { return { ...NETWORK_ERROR }; }
  await delay(waitTime);
  return fetchWithRetry(url, fetchOptions, attempt + 1, ...);
}</code></pre>
<p>서버가 응답을 주는 순간 재시도 루프에서는 완전히 손을 뗀다.
처음엔 5xx도 재시도해야 하지 않나 고민했지만, 우리 서비스 규모에서는 fetch 자체가 실패하는 케이스(연결 끊김, DNS 실패)와 타임아웃만 우선 다루고, 서버가 명시적으로 에러를 반환한 경우는 재시도 없이 사용자에게 즉시 알리는 쪽을 택했다.</p>
<p>타임아웃(AbortError)을 재시도 대상에서 뺀 것도 같은 맥락이다.
이미 응답이 오래 걸리고 있다는 신호인데, 거기에 재시도까지 얹으면 사용자를 더 오래 기다리게 만들 뿐이다.</p>
<h3 id="멱등성-기준으로-재시도-여부-나누기">멱등성 기준으로 재시도 여부 나누기</h3>
<p>앞서 정리했듯 HTTP 메서드보다 멱등성이 기준이 되어야 한다.
이걸 실제 코드에 반영했다.</p>
<pre><code class="language-tsx">// 네트워크 예외 재시도는 HTTP 스펙상 멱등이 보장되는 메서드(GET/PUT/DELETE)에만 기본 적용한다.
// POST(생성)와 PATCH(부분 수정, 멱등 보장 안 됨)는 요청이 서버에
// 도달해 처리된 뒤 응답만 유실됐을 수 있어, 자동 재시도가 중복 생성/중복 처리로
// 이어질 수 있다 — 필요하면 호출부에서 options.maxRetries로 명시적으로 오버라이드한다.
const method = (fetchOptions.method ?? &#39;GET&#39;).toUpperCase();
const isIdempotentMethod = method === &#39;GET&#39; || method === &#39;PUT&#39; || method === &#39;DELETE&#39;;
const resolvedMaxRetries = maxRetries ?? (isIdempotentMethod ? 3 : 0);</code></pre>
<p><code>maxRetries</code> 를 호출부에서 명시적으로 넘기면 이 기본값을 오버라이드할 수 있게 열어뒀는데, 이건 특정 <code>POST</code> 엔드포인트가 실제로는 멱등하다는걸 확인했을 때를 위한 탈출구라고 보면 된다.</p>
<h3 id="지수-백오프--full-jitter">지수 백오프 + Full Jitter</h3>
<pre><code class="language-tsx">// 재시도 전 대기: 지수 백오프(최대 1 -&gt; 2 -&gt; 4초)에 Full Jitter 적용.
// 같은 장애로 여러 클라이언트가 동시에 재시도할 때 정확히 같은 타이밍에
// 몰려 서버 회복을 방해하지 않도록, 0~상한 사이에서 무작위로 대기 시간을 뽑는다.
const maxWaitTime = retryDelay * 2 ** attempt;
const waitTime = Math.random() * maxWaitTime;
await delay(waitTime);</code></pre>
<p><a href="https://aws.amazon.com/ko/blogs/architecture/exponential-backoff-and-jitter/">AWS 문서</a>에서 소개하는 여러 Jitter 방식 중 가장 단순하고 실측상 성능이 가장 좋다고 언급된 Full Jitter(0~상한 사이 균등 분포)를 골랐다.</p>
<p>상한 자체는 기존 지수 백오프 그대로 유지하고, 실제 대기 시간만 그 안에서 매번 랜덤하게 뽑는 구조다.</p>
<h3 id="인증401-재시도는-별도-취급">인증(401) 재시도는 별도 취급</h3>
<p>토큰 만료로 인한 401 처리는 위의 재시도 로직과 분리했다.</p>
<pre><code class="language-tsx">if (!data.success &amp;&amp; response.status === 401 &amp;&amp; isRefreshableAuthError(errorCode) &amp;&amp; ...) {
  const newToken = await refreshAccessToken();
  // ...
  return fetchWithRetry(
    url,
    { ...fetchOptions, headers: finalHeaders },
    attempt,       // ← 증가시키지 않는다
    maxRetries,
    ...
  );
}</code></pre>
<p>이 재시도는 <code>attempt</code> 를 그대로 넘겨서 네트워크 예외 재시도의 횟수를 소모하지 않고, 대기 시간도 거치지 않는다.</p>
<p>근거는 멱등성이 아니라 401을 받았다는건 서버가 아직 이 요청의 실제 처리를 시작조차 안 했다는 것이다.</p>
<p>인증 가드가 비즈니스 로직 실행 전에 요청을 막기 때문에, 메서드가 POST든 뭐든 토큰을 갱신한 뒤 같은 요청을 다시 보내도 중복 처리 위험이 없다.
즉, 이 재시도가 안전한 이유는 멱등성과는 다른 축의 이야기다.</p>
<h2 id="마무리">마무리</h2>
<p>AWS의 결론을 그대로 가져와봤다.</p>
<blockquote>
<p>분산 시스템에서 일시적인 장애 또는 원격 상호 작용의 지연은 불가피합니다.
시간 제한은 시스템이 과도하게 오래 중단되지 않게 하고, 재시도는 이러한 장애를 거를 수 있게 해 주며, 백오프 및 지터는 활용도를 향상시키고 시스템의 정체를 줄여 줍니다.</p>
<p>우리는 재시도를 조심스럽게 다루는 것이 중요하다는 것을 배웠습니다.
재시도는 종속 시스템의 부하를 증폭시킬 수 있습니다.
시스템에 대한 호출이 시간 제한을 초과하고 시스템에서 오버로드가 발생한 경우 재시도는 문제를 개선하는 것이 아니라 더욱 악화시킬 수 있습니다.
우리는 종속성이 양호한 경우에만 재시도를 수행하여 문제가 증폭되는 것을 방지하고 있습니다.
재시도는 가용성 향상을 지원하지 않기 때문에 우리는 재시도를 중단하고 있습니다.</p>
</blockquote>
<p>처음 재시도 정책을 도입한 이유는 유저 입장에서의 생각한 불편함 때문이었다.
버튼을 클릭하거나 폼을 제출하는 등의 요청을 보냈는데, 네트워크와 같은 문제로 인해 요청이 실패해버리면 직접 재요청해야 하는 불편함이 생긴다.</p>
<p>이를 어떻게 개선할 수 있을까 고민하다 알게된게 재시도 정책이었다.</p>
<p>그리고 프로젝트를 진행하면서 직접 겪었던 기록 저장 API의 무한 대기로 인해 AbortController로 Timeout까지 적용하게 되었다.</p>
<p>사용자 경험을 개선하기 위해 도입했던 것들이지만, 실제 시스템에 적용하면서 시스템의 안정성과 안전 장치의 중요성을 체감할 수 있었다.</p>
<p>이렇게 구현하고 나중에 어떤 기업의 과제 테스트를 진행했었는데, 거기서 &#39;<strong>에러 발생시 재시도 로직을 적절히 구현하라</strong>&#39;는 요구사항을 마주했다.
그걸 보고, 좋은 경험 했구나..! 생각이 들었다.</p>
<br />

<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/Fetch_API">MDN - Fetch API</a>
<a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">MDN - AbortController</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/AbortSignal">MDN - AbortSignal</a></li>
<li><a href="https://aws.amazon.com/ko/builders-library/timeouts-retries-and-backoff-with-jitter/">AWS timeouts retries and backoff with jitter</a></li>
<li><a href="https://aws.amazon.com/ko/blogs/architecture/exponential-backoff-and-jitter/">AWS exponential backoff and jitter</a></li>
<li><a href="https://jungseob86.tistory.com/12">https://jungseob86.tistory.com/12</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[다중 탭/기기에서의 토큰 갱신 경쟁 상태 다루기]]></title>
            <link>https://velog.io/@dobby_/%EB%8B%A4%EC%A4%91-%ED%83%AD%EA%B8%B0%EA%B8%B0%EC%97%90%EC%84%9C%EC%9D%98-%ED%86%A0%ED%81%B0-%EA%B0%B1%EC%8B%A0-%EA%B2%BD%EC%9F%81-%EC%83%81%ED%83%9C-%EB%8B%A4%EB%A3%A8%EA%B8%B0</link>
            <guid>https://velog.io/@dobby_/%EB%8B%A4%EC%A4%91-%ED%83%AD%EA%B8%B0%EA%B8%B0%EC%97%90%EC%84%9C%EC%9D%98-%ED%86%A0%ED%81%B0-%EA%B0%B1%EC%8B%A0-%EA%B2%BD%EC%9F%81-%EC%83%81%ED%83%9C-%EB%8B%A4%EB%A3%A8%EA%B8%B0</guid>
            <pubDate>Tue, 21 Jul 2026 13:26:40 GMT</pubDate>
            <description><![CDATA[<h2 id="mutex만으로는-해결되지-않는-문제---multi-device-환경에서의-refresh-token-경쟁-상태">Mutex만으로는 해결되지 않는 문제 - Multi Device 환경에서의 Refresh Token 경쟁 상태</h2>
<p>프로젝트에서 인증 구조를 개선하면서 Refresh Token의 동시 갱신 문제를 해결하기 위해 Mutex를 적용했다.</p>
<p>동일한 실행 환경에서는 Mutex 만으로도 중복 Refresh 요청을 방지할 수 있었지만, 설계를 검토하는 과정에서 한 가지 의문이 생겼다.</p>
<p>“다른 탭이나 다른 기기에서도 Mutex가 동작할까?”</p>
<p>결론부터 말하면, 동작하지 않는다.</p>
<p>이번 글에서는 그 이유와 함께, 왜 백엔드에서 Grace Period와 같은 정책이 필요한지 정리해보려고 한다.</p>
<h3 id="mutex는-어디까지-보호할-수-있을까">Mutex는 어디까지 보호할 수 있을까?</h3>
<p>Mutex는 여러 요청이 동시에 하나의 자원에 접근하지 못하도록 임계 구역을 보호하는 동기화 기법이다.</p>
<p>예를 들어 Access Token이 만료된 상황에서 여러 API 요청이 동시에 발생했다고 가정해보자.</p>
<pre><code class="language-tsx">API 요청 A
API 요청 B
API 요청 C
↓
모두 Access Token 만료
↓
모두 Refresh 요청 시도</code></pre>
<p>이 경우 Mutex를 적용하면 첫 번째 요청만 Refresh를 수행하고, 나머지 요청은 Refresh가 끝날 때까지 대기하게 된다.</p>
<pre><code class="language-tsx">Refresh Request A
↓
Lock 획득
↓
Refresh Token 사용
↓
새로운 Access Token 발급
↓
Unlock
↓
대기 중이던 요청들이 새로운 Access Token 사용</code></pre>
<p>덕분에 동일한 실행 환경에서는 Refresh 요청이 한 번만 발생하게 된다.</p>
<h3 id="mutex에도-한계가-있다">Mutex에도 한계가 있다.</h3>
<blockquote>
<p>참고 자료</p>
<ul>
<li><a href="https://github.com/better-auth/better-auth/issues/8512">better-auth issues</a></li>
</ul>
</blockquote>
<p>중요한 점은 Mutex는 현재 실행 중인 프로세스(메모리 공간) 안에서만 동작한다는 것이다.</p>
<p>예를 들어, 아래와 같은 상황을 생각해보면 된다.</p>
<pre><code class="language-tsx">Chrome 탭 1
└── Mutex A

Chrome 탭 2
└── Mutex B

Safari
└── Mutex C

모바일 앱
└── Mutex D</code></pre>
<blockquote>
<p>참고로 Chrome의 각 탭은 각각 다른 프로세스로 동작한다.</p>
</blockquote>
<p>각 실행 환경은 서로 다른 메모리를 사용한다.</p>
<p>즉,</p>
<ul>
<li>Chrome 탭 1의 Mutex는 Chrome 탭 2를 알지 못하고,</li>
<li>Safari도 Chrome의 Mutex를 공유하지 않으며,</li>
<li>다른 기기 역시 동일하다.</li>
</ul>
<p>따라서 각각의 환경에서 동시에 Refresh 요청이 발생할 수 있다.</p>
<p>다른 예시도 있다.</p>
<ul>
<li>백그라운드 작업과 포그라운드 작업 모두 Refresh 시도를 유발하는 모바일 앱</li>
<li>서버에서 첫 번째 Refresh는 성공했지만, 클라이언트가 응답을 받지 못하고 동일한 Refresh 토큰을 재시도하는 경우 네트워크 시간 초과/응답 손실 발생
이 경우는 이미 만료된 토큰을 사용하므로 재사용 공격으로 간주되어 전체 토큰이 취소되고 불필요하게 로그아웃된다.</li>
</ul>
<h3 id="multi-device-환경에서는-어떤-문제가-발생할까">Multi Device 환경에서는 어떤 문제가 발생할까?</h3>
<p>예를 들어 PC와 모바일이 동일한 계정으로 로그인되어 있다고 생각해보자.</p>
<pre><code class="language-tsx">PC
↓
Refresh Token RT1 사용
↓
새로운 RT2 발급</code></pre>
<p>거의 동시에 모바일에서도 Refresh 요청을 보낸다.</p>
<pre><code class="language-tsx">모바일
↓
아직 RT1을 가지고 있음
↓
RT1으로 Refresh 요청</code></pre>
<p>만약 서버가 Refresh Token Rotation(RTR)을 사용한다면 RT1은 이미 사용된 토큰이므로 무효화되어 있다.</p>
<p>따라서 서버는 이를 재사용 공격으로 판단하여 요청을 거부할 수 있다.</p>
<blockquote>
<p><strong>재사용 공격</strong>
공격자가 유효한 데이터 송수신(토큰이나 인증 정보 등)을 중간에서 탈취한 후, 이를 그대로 재전송하여 정상적인 요청인 것처럼 서버를 속이는 공격 기법을 한다.</p>
</blockquote>
<p>여기서 재사용 공격 검출에 대해서 간단하게 정리해보자.</p>
<ol>
<li>정상 흐름(RTR 적용시)<ul>
<li>클라이언트가 Refresh Token A를 서버로 보내 새로운 Access Token과 Refresh Token B를 받는다.</li>
<li>이때 서버는 Refresh Token A를 이미 사용됨(무효) 처리한다.</li>
</ul>
</li>
<li>재사용 공격 발생시<ul>
<li>만약 탈취범이 이미 무효화된 Refresh Token A를 다시 서버에 전송하면, 서버는 이를 재사용 공격으로 판단한다.</li>
<li>서버는 해당 계정의 보안이 뚫렸다고 가정하고, 즉시 모든 Refresh Token을 만료시키거나 해당 계정을 로그아웃처리하여 피해를 막는다.</li>
</ul>
</li>
</ol>
<p>그런데 이런 재사용 공격이 다중 기기나 다중 탭 환경에서의 오탐 문제로 이어질 수 있다는 것이다.</p>
<pre><code class="language-tsx">RT1 사용
↓
이미 사용된 Refresh Token
↓
401 Unauthorized</code></pre>
<p>문제는 이 요청이 공격이 아니라 정상적인 사용자의 요청이라는 점이다.
단지, 두 기기에서 거의 동시에 요청했을 뿐이다.</p>
<p>서버는 단순히 누군가 폐기된 Token A를 재사용하려는 공격으로 오해하게 되는 것이다.</p>
<h3 id="그래서-grace-period가-필요하다">그래서 Grace Period가 필요하다</h3>
<blockquote>
<p>참고 자료</p>
<ul>
<li><a href="https://lucid.co/techblog/2023/09/18/avoiding-false-positives-in-oauth-2-0-refresh-token-theft-detection">Lucid refresh token theft detection</a></li>
<li><a href="https://developer.okta.com/docs/guides/refresh-tokens/main/">okta developer refresh tokens guides</a></li>
</ul>
</blockquote>
<p>이러한 문제를 해결하기 위해 일부 서비스에서는 Grace Period를 사용한다.</p>
<p>Grace Period는 Refresh Token이 한 번 사용되더라도 아주 짧은 시간 동안은 이전 Refresh Token도 함께 허용하는 정책이다.</p>
<pre><code class="language-tsx">RT1 사용
↓
RT2 발급
↓
(Grace Period)
↓
RT1도 잠시 허용</code></pre>
<p>정상 사용자의 정당한 지연 요청이 재사용 공격으로 잘못 인식되어 로그아웃되는 것을 방지하기 위해, 서버에서 이전 토큰도 몇 초간은 예외적으로 허용해 주는 것이다.</p>
<p>덕분에 네트워크 지연이나 여러 기기에서 거의 동시에 발생한 Refresh 요청도 정상 요청으로 처리할 수 있다.</p>
<p>Grace Period가 끝나면 기존 Refresh Token은 완전히 만료된다.</p>
<p>물론, 유예 기간 동안 침해 탐지력이 약해지긴 하지만, 그 영향은 미미하다.
Lucid에 따르면,</p>
<blockquote>
<p>&quot;A malicious actor would have to guess when the true client is going to refresh the token. And even if they guess successfully, as long as you give both of them (the malicious actor and the true client) the same new tokens, then the malicious actor will have to make another lucky guess the next time the client refreshes its tokens.&quot;</p>
</blockquote>
<p>악의적인 공격자는 실제 클라이언트가 언제 토큰을 갱신할지 추측해야 합니다.
설령 예측에 성공하더라도, 악의적인 공격자와 실제 클라이언트 모두에게 동일한 새 토큰을 제공하는 한, 악의적인 공격자는 클라이언트가 다음에 토큰을 갱신할 때 다시 한번 운 좋게 예측해야 합니다.</p>
<blockquote>
</blockquote>
<p>라고 한다.
또한, 유예 기간은 60초를 넘지 않아야 한다고 전한다.
갱신 및 토큰 재발급 프로세스는 몇 초밖에 걸리지 않으므로, 1분이면 동시 요청이 안정될 수 있는 충분한 시간이라는 것이다.</p>
<h3 id="프론트엔드-측만으로는-해결할-수-없는-이유">프론트엔드 측만으로는 해결할 수 없는 이유</h3>
<p>위에 먼저 설명했던 다중 탭/기기 에서의 RTR 문제를 인지한 뒤에는 Mutex만으로는 해결할 수 없겠다는 생각이 들었다.</p>
<p>Mutex는 동일한 실행 환경에서만 동작하기 때문에, 여러 탭이나 여러 기기에서 발생하는 경쟁 상태까지 해결할 수는 없다.</p>
<p>결국 문제를 해결하기 위해서는 역할을 나누어야 했다.</p>
<ul>
<li>프론트엔드<ul>
<li>동일 실행 환경에서 Mutex를 사용해 중복 Refresh 요청 방지</li>
</ul>
</li>
<li>백엔드<ul>
<li>여러 실행 환경에서 발생하는 경쟁 상태를 Grace Period로 허용</li>
</ul>
</li>
</ul>
<p>즉, 하나의 기술만으로 해결되는 문제가 아니라, 클라이언트와 서버가 함께 고려해야 하는 인증 설계 문제였던 것이다.</p>
<h2 id="프로젝트에-적용한-인증-구조">프로젝트에 적용한 인증 구조</h2>
<h3 id="mutex로-refresh-token-갱신-로직-임계-구역으로-제한하기">Mutex로 Refresh Token 갱신 로직 임계 구역으로 제한하기</h3>
<p>클라이언트 사이드와 서버 사이드를 나눠서 정리해줬다.</p>
<pre><code class="language-tsx">// 클라이언트 사이드에서 사용되는 Mutex
let isRefreshing = false;
let refreshSubscribers: ((token: string | null) =&gt; void)[] = [];

function subscribeTokenRefresh(callback: (token: string | null) =&gt; void) {
  refreshSubscribers.push(callback);
}
function onTokenRefreshed(token: string | null) {
  refreshSubscribers.forEach((callback) =&gt; callback(token));
  refreshSubscribers = [];
}

export async function refreshAccessToken(): Promise&lt;string | null&gt; {
  if (isRefreshing) {
    // 이미 갱신 중이면 새로 요청을 보내지 않고 대기열에 합류
    return new Promise((resolve) =&gt; subscribeTokenRefresh(resolve));
  }

  isRefreshing = true;
  let tokenResult: string | null = null;
  try {
    // ...실제 갱신 로직...
    return tokenResult;
  } finally {
    // isRefreshing을 먼저 false로 되돌린 뒤 구독자들에게 알려야
    // 콜백 실행 중 도착한 새 401이 다시 큐에 쌓이지 않음
    isRefreshing = false;
    onTokenRefreshed(tokenResult);
  }
}</code></pre>
<p><code>refreshSubscribers</code>에 쌓이는건 요청이 아니라 <code>resolve</code> 함수 그 자체다.
즉, 각 호출자는 자기만의 <code>Promise</code>를 하나씩 만들어서 <code>resolve</code>를 큐에 넘겨두고 멈춰있고, 실제 네트워크 요청(백엔드 refresh)은 딱 한 번만 일어난다.</p>
<p><code>forEach</code>가 실행되는 시점에 이미 <code>tokenResult</code>가 확정된 상태라, N개의 대기자에게 같은 값을 동시에 나눠주는 것에 가깝다.
동기적으로 한 번에 쭉 돌면서 <code>resolve(token)</code>을 순서대로 호출만 하며, 각 콜백이 뭔가 무거운 작업을 완료할 때까지 기다렸다가 다음으로 넘어가는게 아니다.</p>
<p>엄밀히 말하면 먼저 대기열에 들어온 순서대로 후속 코드가 재개된다는 것은 맞지만, 요청을 순서대로 실행해서 각자 다른 결과를 받는다는 뜻은 전혀 아니다.
전원이 완전히 동일한 토큰을 받는 것이다.
순서는 그냥 &#39;누가 먼저 깨어나는가&#39;의 스케줄링 순서일 뿐, 실질적으로 경쟁이나 대기 시간 차이가 의미를 갖는 지점은 아니다.</p>
<p>비유하자면, 순차 실행 큐라기보다, 하나의 Promise 결과를 여러 구독자에게 멀티캐스트(broadcast)하는 수동 구현에 가깝다.</p>
<blockquote>
<p>이걸 면접에서 물어보면 뭐라고 답해야 하나... 고민을 했는데(실제로 질문으로 들어오기도 했음), 다음과 같이 정리할 수 있을 것 같다.
<strong>&quot;요청을 일렬로 나열해서 순차 실행하는 식인가요?&quot;</strong>
-&gt; &quot;요청을 순서대로 하나씩 재실행하는 큐는 아니에요. 실제 refresh 요청은 딱 한 번만 나가고, 그 사이 들어온 요청들은 <code>resolve</code> 함수만 배열에 쌓아뒀다가 결과가 나오면 한꺼번에 같은 토큰을 나눠줘요.
그래서 순서는 누가 먼저 깨어나느냐 정도지, 각자 다른 실행을 순차 처리하는건 아니에요&quot;
라고 답할 수 있을 것 같다.</p>
</blockquote>
<pre><code class="language-tsx">// 서버 사이드에서 사용되는 Mutex
// 서버 사이드 토큰 갱신 동시 요청 방지용 mutex
let serverRefreshPromise: Promise&lt;any&gt; | null = null;

export async function refreshServerAccessToken(token: any) {
  // 이미 갱신 중이면 진행 중인 요청의 결과를 재사용
  if (serverRefreshPromise) {
    const result = await serverRefreshPromise;
    return { ...token, ...result };
  }

  serverRefreshPromise = (async () =&gt; {
    try {
      const response = await fetch(`${getBackendApiBaseUrl()}/auth/refresh`, {
        method: &#39;POST&#39;,
        headers: { &#39;Content-Type&#39;: &#39;application/json&#39;, Cookie: `refreshToken=${token.refreshToken}` },
      });
      // ...새 토큰 파싱 후 반환...
    } finally {
      serverRefreshPromise = null; // 락 해제
    }
  })();

  return serverRefreshPromise;
}
</code></pre>
<p>클라이언트와 서버 사이드를 나눠서 관리하는 이유는 서로가 분리된 실행 컨텍스트/메모리 공간으로 관리되기 때문이다.</p>
<p><code>isRefreshing</code> 이든 <code>serverRefreshPromise</code> 든 결국 모듈 스코프의 변수 하나로 만드는 락인데, 이건 같은 JS 힙을 공유하는 범위 안에서만 유효하다.</p>
<br />

<p><strong>서버 사이드 mutex가 따로 필요한 이유</strong></p>
<p>클라이언트 mutex를 통과해서 실제로 나가는 요청이 <code>getSession()</code> 이고, 이게 호출되면 NextAuth의 <code>jwt</code> 콜백이 Next.js 서버에서 실행되면서 <code>refreshServerAccessToken</code> 이 백엔드 <code>/auth/refresh</code> 를 직접 호출한다.</p>
<p>즉, 실제 네트워크 요청이 나가는 지점은 브라우저가 아니라 서버이다.
그런데 이 서버 쪽 진입점은 클라이언트 mutex를 거치지 않고도 도달할 수 있는 경로가 여러 개 있다.</p>
<ul>
<li><strong>탭이 여러개</strong>: 각 탭은 독립된 JS 힙을 가지므로 <code>isRefreshing</code> 상태도 탭마다 따로이다. 두 탭이 거의 동시에 401을 맞으면 둘 다 자기 탭 기준으로는 처음 갱신하는 요청이라 클라이언트 mutex를 통과해버리고, 결국 같은 Next.js 서버 프로세스에 동시에 도달한다.</li>
<li><strong>서버 컴포넌트/SSR 경로</strong>: 애초에 클라이언트 코드를 거치지 않는다.
<code>getAccessToken()</code> 의 서버 분기는 <code>auth()</code> 를 직접 호출해서, 클라이언트 mutex와 무관하게 서버의 <code>jwt</code> 콜백으로 바로 들어간다.</li>
</ul>
<p>그래서 클라이언트 mutex만 있으면 탭 하나 안에서의 중복은 막아도, 여러 탭 + SSR이 동시에 같은 서버 프로세스를 두드리는 상황은 그대로 뚫리게 된다.</p>
<p>정리하면, 두 mutex는 계층이 다른 문제를 막는 셈이다.
클라이언트에서는 브라우저 탭 하나 안에서 불필요한 <code>getSession()</code> 재호출 방지,
서버는 그 여러 호출 경로가 모두 모이는 실제 백엔드 호출 지점에서의 중복 방지이다.</p>
<p>그리고 이 서버 mutex 조차 여러 서버 인스턴스/기기 단위로는 못 막기 때문에, 그 마지막 구멍을 메우는게 Grace Period인 셈이다.</p>
<h3 id="백엔드측-grace-period-설정하기">백엔드측 Grace Period 설정하기</h3>
<pre><code class="language-tsx">async refreshAccessToken(
  oldToken: string,
): Promise&lt;{ accessToken: string; refreshToken: string | null }&gt; {
  const saved = await this.refreshTokenRepo.findOne({ where: { token: oldToken } });
  if (!saved) {
    throw new AuthUnauthorizedException(AUTH_ERROR_CODES.REFRESH_TOKEN_NOT_FOUND, ...);
  }

  // revoked 체크: 슬라이딩으로 rotation이 발생했을 때 동시 요청 처리용 grace period
  if (saved.revoked) {
    const now = new Date();
    const gracePeriod = 30 * 1000; // 네트워크 지연 고려 (30초)

    const revokedAt = new Date(saved.updatedAt).getTime();
    const diff = now.getTime() - revokedAt;

    if (diff &lt; gracePeriod) {
      // 이미 폐기된 토큰이지만 grace period 안이면,
      // 그 사이 새로 발급된 최신 토큰을 찾아 대신 내려준다
      const latestToken = await this.refreshTokenRepo.findOne({
        where: { userId: saved.userId, revoked: false },
        order: { createdAt: &#39;DESC&#39; },
      });

      if (latestToken) {
        const accessToken = this.jwtService.sign({ sub: saved.userId }, { expiresIn: &#39;15m&#39; });
        return { accessToken, refreshToken: latestToken.token };
      }
    }
    throw new AuthUnauthorizedException(AUTH_ERROR_CODES.REFRESH_TOKEN_REUSE_DETECTED, ...);
  }
  // ...만료 체크, 슬라이딩 rotation(issueNewTokens)...
}
</code></pre>
<p>처음에는 10초로 Grace Period를 설정했다가, 네트워크 지연을 고려해 30초로 늘렸다.</p>
<p>토큰 rotation은 트랜잭션으로 묶어, 구 토큰의 <code>revoked</code> 갱신과 신규 토큰 발급이 원자적으로 일어나도록 했다.</p>
<pre><code class="language-tsx">private async issueNewTokens(userId: string, oldTokenId: string) {
  return await this.refreshTokenRepo.manager.transaction(async (em) =&gt; {
    // 기존 토큰 폐기 (Grace Period를 위해 updatedAt이 갱신됨)
    await em.update(RefreshToken, { id: oldTokenId }, { revoked: true });

    const { accessToken, refreshToken, expiresAt } = this.generateTokens(userId);
    await em.insert(RefreshToken, { userId, token: refreshToken, expiresAt });

    return { accessToken, refreshToken };
  });
}</code></pre>
<p>핵심 흐름은 다음과 같다.</p>
<ul>
<li>A 기기가 먼저 refresh 요청을 보내 토큰을 rotation한 직후, B 기기가 같은 구 토큰으로 거의 동시에 요청하면</li>
<li>프론트 mutex는 서로 다른 기기이므로 막지 못하지만, 백엔드가 <code>revoked=true</code> 인 토큰이라도 <code>updateAt</code> 기준 30초 이내면 “재사용 공격”으로 간주하지 않고 그 사이 발급된 최신 토큰을 대신 내려줘 B 기기도 정상적으로 갱신되게 한다.</li>
<li>30초가 지나면 진짜 탈취/재사용으로 간주해 <code>REFRESH_TOKEN_REUSE_DETECTED</code> 로 막는다.</li>
</ul>
<br />

<h2 id="마무리">마무리</h2>
<p>이번 경험을 통해 가장 크게 느낀 점은 동시성 문제는 프론트엔드만 바라봐서는 해결할 수 없다는 것이었다.</p>
<p>Mutex는 분명 효과적인 해결책이지만, 그 범위는 하나의 실행 환경으로 제한된다.
실제 서비스에서는 여러 탭, 여러 브라우저, 여러 기기에서 동시에 요청이 발생할 수 있으며, 이러한 상황까지 고려해야 안정적인 인증 구조를 설계할 수 있다.</p>
<p>처음에는 Mutex를 적용하면 끝! 이라고 생각했지만, 설계를 더 깊게 고민하면서 Mutex의 한계, Refresh Token Rotation, Grace Period가 서로 연결되어 있다는 점을 이해하게 되었고, 클라이언트와 서버가 함께 협력해야 하는 이유를 배울 수 있었다.</p>
<br />

<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://github.com/better-auth/better-auth/issues/8512">better-auth issues</a></li>
<li><a href="https://lucid.co/techblog/2023/09/18/avoiding-false-positives-in-oauth-2-0-refresh-token-theft-detection">Lucid refresh token theft detection</a></li>
<li><a href="https://developer.okta.com/docs/guides/refresh-tokens/main/">okta developer refresh tokens guides</a></li>
<li><a href="https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/?utm_source=chatgpt.com">auth0 refresh tokens what are to use them</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[브라우저 다중 탭 간 실시간 상태 공유 방법]]></title>
            <link>https://velog.io/@dobby_/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%8B%A4%EC%A4%91-%ED%83%AD-%EA%B0%84-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%83%81%ED%83%9C-%EA%B3%B5%EC%9C%A0-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@dobby_/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%8B%A4%EC%A4%91-%ED%83%AD-%EA%B0%84-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%83%81%ED%83%9C-%EA%B3%B5%EC%9C%A0-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Mon, 20 Jul 2026 13:53:42 GMT</pubDate>
            <description><![CDATA[<h2 id="멀티-탭-동기화">멀티 탭 동기화</h2>
<p>프론트엔드 멀티 탭 동기화는 동일한 웹사이트의 여러 브라우저 탭 간에 상태, 로그인 세션, 장바구니 데이터를 일치시키는 기술이다.</p>
<p>다음과 같은 상황에서 필요하다.</p>
<ul>
<li>여러 탭을 띄워둔 상태에서 한 탭에서 로그아웃하지만 다른 탭에서는 여전히 인증된 콘텐츠를 보고 있는 상황</li>
<li>테마를 다크 모드로 변경하지만 나머지 탭은 밝은 모드를 유지하는 상황</li>
<li>장바구니에 상품을 추가하고 다른 탭으로 이동하면 장바구니 금액이 0으로 표시되는 상황</li>
</ul>
<p>브라우저는 기본적으로 탭 간에 react 상태를 공유하지 않는다.
이는 탭별로 js 스크립트가 독립적으로 실행되고 관리되기 때문이다. (컴포넌트 트리, 상태 및 메모리를 가진 자체 JavaScript 컨텐스트를 실행한다.)</p>
<p>하지만 사용자 입장에서 위와 같은 상황은 기대하던 결과는 아니기에 한 탭에서 변경 사항이 발생하면 모든 탭에 즉시 반영되기를 바란다.</p>
<p>이를 위한 멀티 탭 동기화는 주로 <code>BroadcastChannel</code> API나 <code>localStorage</code> 의 <code>storage</code> 이벤트를 활용해 탭 간 통신을 구현한다.</p>
<h3 id="localstorage-및-storage-event">localStorage 및 Storage Event</h3>
<blockquote>
<p>참고 자료</p>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event">MDN-storage event</a></li>
<li><a href="https://medium.com/@vinaykumarbr07/cross-tab-state-synchronization-in-react-using-the-browser-storage-event-14b6f1a97ea6">React에서 브라우저 storage 이벤트를 이용한 탭 간 상태 동기화</a></li>
</ul>
</blockquote>
<p>한 탭에서 <code>localStorage</code> 의 값을 변경하면, 동일한 브라우저의 다른 탭에서 <code>window</code> 객체의 <code>storage</code> 이벤트를 감지하여 동기화한다.</p>
<p>구현이 매우 간단하고 직관적이며, 상태의 영속성도 함께 보장된다.</p>
<pre><code class="language-tsx">// 변경 감지
window.addEventListener(&#39;storage&#39;, (event) =&gt; {
  if (event.key === &#39;user-token&#39;) {
    const newToken = event.newValue;
    // 새 토큰으로 상태 업데이트
  }
});

// 값 변경 (다른 탭에서 감지됨)
localStorage.setItem(&#39;user-token&#39;, &#39;new_token_value&#39;);</code></pre>
<pre><code class="language-tsx">// Tab A writes
localStorage.setItem(&quot;theme&quot;, &quot;dark&quot;);

// Tab B listens
window.addEventListener(&quot;storage&quot;, (event) =&gt; {
  if (event.key === &quot;theme&quot;) {
    console.log(&quot;Theme changed to:&quot;, event.newValue); // &quot;dark&quot;
  }
});</code></pre>
<p>이 이벤트는 변경을 수행한 창에서는 발생하지 않는다.</p>
<ul>
<li>같은 origin의 모든 브라우저 컨텍스트에서 이벤트가 발생한다.</li>
<li>탭 간 반응성을 무료로 얻을 수 있지만, 문자열 직렬화 가능한 데이터에만 해당한다.</li>
</ul>
<p>이를 직접 구현하기 위해서는 다음과 같은 작업이 필요하다.</p>
<ol>
<li><code>localStorage</code> 에서 초기값을 읽어온다.</li>
<li>내용 전체가 <code>localStorage</code> 문자열이므로 파싱한다.</li>
<li>다른 탭의 변경 사항을 감지하도록 <code>storage</code> 이벤트 리스너를 설정한다.</li>
<li>로컬 탭의 값이 변경될 때 직렬화하여 다시 저장한다.</li>
<li>마운트 해제시 리스너를 정리한다.</li>
</ol>
<pre><code class="language-tsx">import { useState, useEffect, useCallback } from &quot;react&quot;;

function useCrossTabTheme() {
  const [theme, setThemeState] = useState&lt;&quot;light&quot; | &quot;dark&quot;&gt;(() =&gt; {
    try {
      const stored = localStorage.getItem(&quot;app-theme&quot;);
      return stored === &quot;dark&quot; ? &quot;dark&quot; : &quot;light&quot;;
    } catch {
      return &quot;light&quot;;
    }
  });

  // Listen for changes from other tabs
  useEffect(() =&gt; {
    const handler = (event: StorageEvent) =&gt; {
      if (event.key === &quot;app-theme&quot; &amp;&amp; event.newValue) {
        setThemeState(event.newValue as &quot;light&quot; | &quot;dark&quot;);
      }
    };
    window.addEventListener(&quot;storage&quot;, handler);
    return () =&gt; window.removeEventListener(&quot;storage&quot;, handler);
  }, []);

  // Write to localStorage when local state changes
  const setTheme = useCallback((value: &quot;light&quot; | &quot;dark&quot;) =&gt; {
    setThemeState(value);
    try {
      localStorage.setItem(&quot;app-theme&quot;, value);
    } catch {
      // storage full or unavailable
    }
  }, []);

  return [theme, setTheme] as const;
}</code></pre>
<p>이렇게 많은 코드가 필요한데, 인증 토큰, 사용지 기본 설정, 장바구니 상태, 알림 횟수 등에 대해 이와 같은 작업을 수행한다고 하면, 더 많은 코드가 필요하게 된다.</p>
<h3 id="broadcastchannel-api">BroadcastChannel API</h3>
<p>동일한 오리진(origin)을 공유하는 브라우저 탭 간에 이름이 지정된 채널을 통해 직접 메시지를 송수신한다.</p>
<p>실시간 양방향 통신에 매우 적합하며, 문자열뿐만 아니라 객체 데이터도 쉽게 주고받을 수 있다.</p>
<pre><code class="language-tsx">// 채널 생성
const channel = new BroadcastChannel(&#39;app_sync_channel&#39;);

// 메시지 보내기
channel.postMessage({ type: &#39;LOGOUT&#39;, timestamp: Date.now() });

// 메시지 받기
channel.onmessage = (event) =&gt; {
  if (event.data.type === &#39;LOGOUT&#39;) {
    // 로그아웃 처리 수행
  }
};

// 채널 연결 해제하기 -&gt; 가비지 컬렉션이 활성화된다.
channel.close();</code></pre>
<p><a href="https://developer.mozilla.org/ko/docs/Web/API/Broadcast_Channel_API">MDN-Broadcast Channel API</a></p>
<p>Broadcast Channel API는 브라우저 맥락들(예: 창, 탭, 프레임, iframe)과 동일한 origin에 있는 워커들 간의 기본적인 통신을 허용한다.</p>
<blockquote>
<p>broadcastChannel API는 Web Worker에서 사용할 수 있다.</p>
</blockquote>
<p>통신하려는 프레임이나 워커에 대한 참조를 유지할 필요가 없다.
동일한 이름의 BroadcastChannel을 만들어, 특정 채널을 구독하고 모든 채널 간에 양방향 통신을 할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/0d994e11-f9f5-42e1-926b-b67f17d98475/image.png" alt=""></p>
<p>채널로 전송되는 데이터는 structured clone 알고리즘을 통해 직렬화된다.
즉, 다양한 데이터 객체를 직접 직렬화하지 않고도 안전하게 전송할 수 있다.</p>
<p>단점은 메시지를 전송한 후에는 수신 여부를 알 수 없다는 것이다.
즉, 메시지가 전송될 때 탭이 열려 있지 않으면 메시지를 수신하지 못한다.</p>
<p>Broadcast Channel API의 독립적인 인터페이스를 통해 다중 맥락 간 통신이 가능하다.
이는 사용자가 로그인하거나, 로그아웃하는 등의 동일한 origin 내의 다른 탭에서 사용자의 행동을 감지하는 데 사용할 수 있다.</p>
<h3 id="nextauth에서-볼-수-있는-sessionprovider-탭-동기화">NextAuth에서 볼 수 있는 SessionProvider 탭 동기화</h3>
<p><code>&lt;SessionProvider&gt;</code> 를 설정할 때 다음과 같은 옵션들을 부여할 수 있다.</p>
<ul>
<li><p><code>refetchOnWindowFocus</code></p>
<ul>
<li><p>사용자가 다른 탭을 보다가 다시 이 앱이 켜진 탭으로 돌아왔을 때(포커스 되었을 때) 최신 세션 정보를 서버에서 다시 가져와 동기화한다.</p>
</li>
<li><p>기본적으로 켜져 있으므로, 탭 전환시 자동 동기화를 끄고 싶을 때만 <code>false</code> 로 설정한다.</p>
</li>
<li><p>이는 <code>visibilitychange</code> 포커스 이벤트를 사용해 세션을 새로고침한다.</p>
<pre><code class="language-tsx">  &lt;SessionProvider refetchOnWindowFocus={false}&gt;
    {children}
  &lt;/SessionProvider&gt;</code></pre>
<pre><code class="language-tsx">    React.useEffect(() =&gt; {
      const { refetchOnWindowFocus = true } = props
      // Listen for when the page is visible, if the user switches tabs
      // and makes our tab visible again, re-fetch the session, but only if
      // this feature is not disabled.
      const visibilityHandler = () =&gt; {
        if (refetchOnWindowFocus &amp;&amp; document.visibilityState === &quot;visible&quot;)
          __NEXTAUTH._getSession({ event: &quot;visibilitychange&quot; })
      }
      document.addEventListener(&quot;visibilitychange&quot;, visibilityHandler, false)
      return () =&gt;
        document.removeEventListener(&quot;visibilitychange&quot;, visibilityHandler, false)
    }, [props.refetchOnWindowFocus])</code></pre>
</li>
</ul>
</li>
<li><p><code>refetchInterval</code></p>
<ul>
<li>일정 시간(초 단위)마다 주기적으로 세션을 백그라운드에서 계속 불러와 갱신한다.</li>
<li>여러 탭이 열려 있을 때 이 값이 설정되어 있으면 각 탭이 백그라운드에서 세션을 동기화하는 데 도움을 준다.</li>
</ul>
</li>
</ul>
<p><a href="https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/react.tsx">nextauth 내부 코드</a></p>
<p>NextAuth의 내부 구현 코드를 보면, Broadcast Channel로 세션 동기화를 구현한걸 알 수 있다.</p>
<pre><code class="language-tsx">// 브로드캐스트 채널을 싱글톤 형태로 관리하기 위한 변수
let broadcastChannel: BroadcastChannel | null = null

// 브라우저 환경에서 &#39;next-auth&#39; 채널 이름으로 BroadcastChannel 생성
function getNewBroadcastChannel() {
  if (typeof BroadcastChannel === &quot;undefined&quot;) {
    // SSR 환경(서버 사이드)이나 지원하지 않는 브라우저를 위한 폴백(Fallback) 객체
    return {
      postMessage: () =&gt; {},
      addEventListener: () =&gt; {},
      removeEventListener: () =&gt; {},
      name: &quot;next-auth&quot;,
      onmessage: null,
      onmessageerror: null,
      close: () =&gt; {},
      dispatchEvent: () =&gt; false,
    } satisfies BroadcastChannel
  }
  return new BroadcastChannel(&quot;next-auth&quot;)
}

// 채널 인스턴스를 가져오는 래퍼 함수
function broadcast() {
  if (broadcastChannel === null) {
    broadcastChannel = getNewBroadcastChannel()
  }
  return broadcastChannel
}</code></pre>
<p>이 로직은 사용자가 하나의 탭에서 <code>signIn</code> 또는 <code>signOut</code> 을 하거나 세션이 갱신되는 <code>getSession</code> 등이 실행될 때, 내부적으로 이 <code>broadcast()</code> 채널을 통해 다른 탭에 메시지를 보낸다.</p>
<pre><code class="language-tsx">// 예시: 세션이 새로 고쳐졌을 때 다른 탭에 알림 전송
if (newSession) {
  setSession(newSession)
  broadcast().postMessage({
    event: &quot;session&quot;,
    data: { trigger: &quot;getSession&quot; },
  })
}

// signOut 호출시 다른 탭에 알림 전송
broadcast().postMessage({ event: &quot;session&quot;, data: { trigger: &quot;signout&quot; } })</code></pre>
<p>그리고 리액트 컨텍스트인 <code>&lt;SessionProvider&gt;</code> 내부에서는 이 채널의 <code>message</code> 이벤트를 듣고 있다가, 이벤트가 수신되면 자식 컴포넌트들의 세션 상태를 강제로 동기화하도록 설계되어 있다.</p>
<blockquote>
<p>구버전의 초기 코드는 브라우저 호환성을 위해 <code>BroadcastChannel</code> 대신 <code>window.addEventListener(&#39;storage&#39;)</code> 이벤트를 사용하여 LocalStorage 변경을 감지하는 방식으로 구현하기도 했다.
최신 코드에는 브라우저 표준인 <code>BroadcastChannel</code> 위주로 안전하게 처리한다.</p>
</blockquote>
<h3 id="reactuse의-탭-간-안전한-메시지-전송">ReactUse의 탭 간 안전한 메시지 전송</h3>
<blockquote>
<p>reactuse는 Meta 공식 라이브러리는 아니고, 써드파티 오픈소스 라이브러리이다.
커스텀 훅 모음집이라 생각하면 된다.</p>
</blockquote>
<blockquote>
<p>참고자료</p>
<ul>
<li><a href="https://dev.to/childrentime/real-time-react-syncing-state-across-browser-tabs-hn5">실시간 React: 브라우저 탭 간 상태 동기화</a></li>
</ul>
</blockquote>
<p><strong>useBroadcastChannel: 탭 간 안전한 메시지 전송</strong></p>
<p>ReactUse의 훅 <code>useBroadcastChannel</code> 은 BroadcastChannel API를 깔끔하고 선언적인 인터페이스로 감싸준다.</p>
<p>채널 생성, 메시지 수신, 마운트 해제 시 정리, SSR 안정성까지 모두 단일 호출로 처리한다.</p>
<pre><code class="language-tsx">import { useBroadcastChannel } from &quot;@reactuses/core&quot;;

function NotificationSync() {
  const { data, post, error } = useBroadcastChannel&lt;{
    type: string;
    payload?: unknown;
  }&gt;(&quot;my-app-notifications&quot;);

  // Send a message to all other tabs
  const broadcastLogout = () =&gt; {
    post({ type: &quot;LOGOUT&quot; });
  };

  // React to messages from other tabs
  useEffect(() =&gt; {
    if (data?.type === &quot;LOGOUT&quot;) {
      // Clear local auth state and redirect
      authStore.clear();
      window.location.href = &quot;/login&quot;;
    }
  }, [data]);

  return &lt;button onClick={broadcastLogout}&gt;Log out everywhere&lt;/button&gt;;
}</code></pre>
<p>수동 직렬화가 필요 없으며, 정리 코드도 필요 없다.
훅은 컴포넌트가 마운트 해제될 때 채널을 자동으로 닫는다.
또한 <code>error</code> 값을 사용하면 BroadcastChannel이 지원되지 않는 드문 경우를 처리할 수 있다.</p>
<p><strong>useLocalStorage: 자동 탭 간 동기화</strong></p>
<p>탭 간에 상태를 유지하고 동기화해야 하는 경우, <code>useLocalStorage</code> 도구가 적합하다.
<code>localStorage</code> 이벤트에 의해 보호되며, 모든 탭에서 자동으로 동기화된다.</p>
<pre><code class="language-tsx">import { useLocalStorage } from &quot;@reactuses/core&quot;;

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage&lt;&quot;light&quot; | &quot;dark&quot;&gt;(
    &quot;app-theme&quot;,
    &quot;light&quot;
  );

  return (
    &lt;button onClick={() =&gt; setTheme(theme === &quot;light&quot; ? &quot;dark&quot; : &quot;light&quot;)}&gt;
      Current: {theme}
    &lt;/button&gt;
  );
}</code></pre>
<p>한 탭에서 이 훅이 호출되면 동일한 키로 이 훅을 실행하는 다른 모든 탭이 자동으로 업데이트된다.</p>
<p>JSON 직렬화, 초기값 대체, SSR 보호 및 스토리지 이벤트 구독을 내부적으로 처리한다.</p>
<p>두 훅을 사용해 인증 상태 동기화를 구현한 패턴의 예시는 다음과 같다.</p>
<pre><code class="language-tsx">import { useBroadcastChannel, useLocalStorage } from &quot;@reactuses/core&quot;;

function useAuth() {
  const [token, setToken] = useLocalStorage&lt;string | null&gt;(&quot;auth-token&quot;, null);
  const { data, post } = useBroadcastChannel&lt;{ type: &quot;LOGOUT&quot; | &quot;LOGIN&quot; }&gt;(
    &quot;auth-channel&quot;
  );

  // Handle messages from other tabs
  useEffect(() =&gt; {
    if (data?.type === &quot;LOGOUT&quot;) {
      setToken(null);
      window.location.href = &quot;/login&quot;;
    }
  }, [data, setToken]);

  const login = (newToken: string) =&gt; {
    setToken(newToken);
    post({ type: &quot;LOGIN&quot; });
  };

  const logout = () =&gt; {
    setToken(null);
    post({ type: &quot;LOGOUT&quot; });
    window.location.href = &quot;/login&quot;;
  };

  return { token, login, logout, isAuthenticated: token !== null };
}</code></pre>
<p>그 외 탭 간 테마 동기화, 전자상거래에서의 장바구니 상태 등의 패턴은 위에 올린 <a href="https://dev.to/childrentime/real-time-react-syncing-state-across-browser-tabs-hn5">참고자료</a>를 확인하면 된다.</p>
<h3 id="sharedworker와--broadcastchannel의-결합">SharedWorker와  BroadcastChannel의 결합</h3>
<p>여러 탭에서 공유하는 백그라운드 스레드인 <code>SharedWorker</code> 를 두고, 각 탭이 <code>BroadcastChannel</code> 을 통해 <code>SharedWorker</code> 와 통신하여 중앙 상태를 유지한다.</p>
<p>복잡한 상태 관리나 웹소켓 연결을 탭마다 맺을 필요 없이 하나로 통합할 수 있어서 서버 및 네트워크 비용을 최적화할 수 있다.</p>
<blockquote>
<p><a href="https://www.reddit.com/r/reactjs/comments/1mv92yb/how_to_keep_data_in_sync_across_server_and/">raddit 코멘트</a>
”SharedWorker와 broadcastchannel을 사용하면 됩니다. 즉, 각 탭은 데이터/네트워크 요청을 위해 워커를 구독하고 수신된 메시지를 기반으로 상태를 업데이트합니다. 따라서 각 앱은 워커로부터 메시지를 처리하는 메시지 수신기가 필요하며, 워커는 메시지를 보내는 메서드를 제공해야 합니다.</p>
<p>웹소켓이나 사용하는 연결 프로토콜을 워커에서 실행하세요. 이렇게 하면 앱에서 연결 세부 사항을 추상화할 수 있습니다.”</p>
<p>”저는 프로덕션 앱에서 비슷한 요구 사항을 가지고 있는데, 원격 서버를 사용하고 여러 탭을 사용하는 수많은 사용자가 모두 동기화된 상태를 유지해야 합니다. 저희는 React Query를 사용하여 공유 워커에 모든 데이터를 캐싱하는 방식을 채택했습니다. 이렇게 하면 탭마다 별도의 캐시 인스턴스를 생성하지 않고, 캐시 무효화 발생 시 사용자별로 한 번의 API 호출만으로 데이터를 다시 가져올 수 있습니다. 각 사용자는 웹소켓 연결을 통해 변경 사항이 발생할 때마다 캐시 무효화 메시지를 주고받으며, 모든 데이터가 항상 동기화된 상태를 유지합니다.”</p>
</blockquote>
<blockquote>
<p>참고자료</p>
<ul>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/SharedWorker">MDN-SharedWorker</a></li>
</ul>
</blockquote>
<p>이는 여러 브라우저 탭간에 중복 네트워크 요청을 방지하고 상태를 동기화하기 위한 아키텍처이다.</p>
<p>핵심은 무거운 작업(웹소켓 연결, 데이터 캐싱, API 요청)은 백그라운드의 SharedWorker가 한 번만 수행하고, 각 탭은 Worker가 주는 데이터만 받는다는 점이다.</p>
<ul>
<li>SharedWorker: 일반 Web Worker는 탭 하나에 종속되지만, SharedWorker는 동일한 도메인(origin)을 공유하는 모든 탭이 하나의 스레드를 공유할 수 있게 해준다.
기존의 다른 종류의 워커들과 다른 전역 스코프를 갖는 인터페이스를 구현한다.</li>
<li>BroadcastChannel 또는 Port 통신: SharedWorker와 각 탭이 서로 대화하기 위한 통신 채널로 쓰인다.</li>
</ul>
<p>중앙 집중식 데이터 캐싱: 탭마다 데이터 요청(react query)를 보내는게 아니라, SharedWorker 내부에서 캐시를 두거나(or 워커가 네트워크 요청을 통제하여) 탭들이 동일한 데이터를 공유하게 만든다.</p>
<p>전체적인 데이터 흐름은 다음과 같다.</p>
<pre><code class="language-tsx">[ 브라우저 탭 1 ] &lt;───(Message)───&gt; [ SharedWorker ] &lt;───(WebSocket)───&gt; [ 원격 서버 ]
[ 브라우저 탭 2 ] &lt;───(Message)───&gt;       │ (중앙 캐시 / 상태 관리)
[ 브라우저 탭 3 ] &lt;───(Message)───&gt;       ┘</code></pre>
<p><strong>1단계: SharedWorker 내부에서 웹소켓 연결 관리</strong></p>
<p>먼저, SharedWorekr 스크립트(<code>worker.js</code>)내부에서 웹소켓 연결 코드를 작성한다.
이렇게 하면 탭이 아무리 많아도 서버와 연결된 웹소켓은 딱 1개만 유지되므로 서버 비용이 절감된다.</p>
<p><strong>2단계: 탭들과 연결 끊김 관리(Port 구독)</strong></p>
<p>SharedWorker는 새로운 탭이 열릴 때마다 <code>onconnect</code> 이벤트를 발생시킨다.
연결된 탭들의 포트를 배열 등으로 관리하여 메시지를 주고받을 준비를 한다.</p>
<p><strong>3단계: React Query와의 결합(캐시 공유 및 무효화)</strong></p>
<p>이는 두 가지 형태로 구현할 수 있다.</p>
<ol>
<li>Worker 내부에서 데이터를 들고 있는 방식:<ul>
<li>서버에서 웹소켓으로 데이터가 변경되었다는 알림을 보낸다.</li>
<li>SharedWorker가 이 메시지를 받고, 자신이 직접 서버에 API 요청을 날려 최신 데이터를 가져온다.</li>
<li>가져온 최신 데이터를 모든 탭에 <code>postMessage</code> 한다.</li>
</ul>
</li>
<li>Worker는 신호만 주고, React Query의 QueryClient를 동기화하는 방식:<ul>
<li>서버가 웹소켓으로 무효화 신호를 주면 SharedWorker가 모든 탭에 <code>&quot;REFETCH_QUERY&quot;</code> 라는 메시지를 뿌린다.(<code>postMessage</code>)</li>
<li>메시지를 받은 모든 탭의 React Query가 동시에 무효화된다. (<code>queryClient.invalidateQueries()</code>)가 되면서 데이터를 다시 가져온다. 단, 이때도 중복 요청을 막기 위해 첫 번째 탭의 요청 결과를 공유하게끔 처리가 필요하다.</li>
</ul>
</li>
</ol>
<p>간단한 예시는 다음과 같다.</p>
<pre><code class="language-tsx">// SharedWorker 코드: worker.js
// 연결된 모든 탭의 포트를 저장
const ports = [];

// 1. 웹소켓 연결 (SharedWorker 내부에서 단 한 번만 실행)
const ws = new WebSocket(&quot;wss://your-server.com/sync&quot;);

ws.onmessage = (event) =&gt; {
  const message = JSON.parse(event.data);

  // 서버에서 데이터 변경(무효화) 알림이 오면 모든 탭에 브로드캐스트
  if (message.type === &quot;INVALIDATE_CACHE&quot;) {
    ports.forEach(port =&gt; {
      port.postMessage({ type: &quot;SYNC_DATA&quot;, payload: message.data });
    });
  }
};

// 2. 새로운 탭이 열려 연결되었을 때
self.onconnect = (e) =&gt; {
  const port = e.ports[0];
  ports.push(port);

  // 탭으로부터 메시지를 받았을 때 처리 (예: 데이터 요청 등)
  port.onmessage = (event) =&gt; {
    if (event.data.type === &quot;REQUEST_FETCH&quot;) {
      // 필요 시 워커가 대신 API를 호출하거나 웹소켓으로 전송
    }
  };

  port.start();
};</code></pre>
<pre><code class="language-tsx">// React
import { useEffect } from &#39;react&#39;;
import { useQueryClient } from &#39;@tanstack/react-query&#39;;

function useTabSynchronization() {
  const queryClient = useQueryClient();

  useEffect(() =&gt; {
    // 1. SharedWorker 초기화
    const worker = new SharedWorker(new URL(&#39;./worker.js&#39;, import.meta.url));

    // 2. 워커로부터 메시지 수신 (메시지 수신기)
    worker.port.onmessage = (event) =&gt; {
      const { type, payload } = event.data;

      if (type === &quot;SYNC_DATA&quot;) {
        // React Query 캐시를 최신 데이터로 강제 업데이트 하거나 무효화
        queryClient.setQueryData([&#39;myCachedData&#39;], payload);
        // 또는 queryClient.invalidateQueries({ queryKey: [&#39;myCachedData&#39;] });
      }
    };

    worker.port.start();

    return () =&gt; {
      // 탭이 닫힐 때 포트 닫기 처리 등 필요
    };
  }, [queryClient]);
}</code></pre>
<p>장점이 많지만, 2026.07 기준 지원하지 않는 환경이 있기에 불안정하거나 제한적일 수 있다.</p>
<p>또한 새로고침/빌드 시 번들링하는 작업이 필요하다.
Next.js나 Vite 환경에서 Web Worker 파일을 올바르게 번들링하고 경로를 맞춰주는 설정이 필요하다.</p>
<p>추가적으로, <code>useSyncExternalStore</code> 와 함께 사용하여 외부 상태를 React의 렌더링 시스템과 완벽하게 유기적으로 연결할 수 있다.</p>
<p>SharedWorker는 브라우저 백그라운드 스레드에서 돌아가는 React 외부 데이터 소스이다.
해당 훅을 사용하면 다음과 같은 장점이 있다.</p>
<ul>
<li>동기화 보장: 워커로부터 데이터가 오는 시점과 React의 렌더링 타이밍이 어긋나면서 화면이 찢어지거나 이전 데이터가 보이는 현상을 구조적으로 방지한다.</li>
<li>클린업 자동화: 탭이 닫히거나 컴포넌트가 언마운트될 때, 워커 리스너를 해제하는 이벤트를 관리해준다.</li>
<li>선언적인 코드: 외부 상태를 마치 <code>useState</code> 처럼 자연스럽게 다룰 수 있게 해준다.</li>
</ul>
<p>React Query와 함께 쓰든, 아니면 독자적인 전역 상태로 쓰든 구조는 비슷하다.</p>
<ol>
<li>외부 스토어 매니저 생성
먼저 SharedWorker의 이벤트를 구독하고 현재 값을 반환하는 작은 Store 객체를 만든다.</li>
</ol>
<pre><code class="language-tsx">// sharedWorkerStore.js
const worker = new SharedWorker(new URL(&#39;./worker.js&#39;, import.meta.url));
worker.port.start();

let snapshot = null; // 워커로부터 받은 최신 데이터를 담을 변수
const listeners = new Set(); // 구독 중인 컴포넌트들의 리스너 콜백 모음

// 워커로부터 메시지가 오면 스냅샷을 업데이트하고, 모든 리스너를 실행시킵니다.
worker.port.onmessage = (event) =&gt; {
  if (event.data.type === &quot;SYNC_DATA&quot;) {
    snapshot = event.data.payload;
    listeners.forEach((listener) =&gt; listener());
  }
};

export const workerStore = {
  // 1. 구독 등록 함수 (useSyncExternalStore가 내부적으로 호출함)
  subscribe(callback) {
    listeners.add(callback);
    // 구독 취소(클린업) 함수 반환
    return () =&gt; listeners.delete(callback);
  },

  // 2. 현재 최신 값을 가져오는 함수
  getSnapshot() {
    return snapshot;
  },

  // (선택) 워커에 메시지를 보내는 메서드
  emit(message) {
    worker.port.postMessage(message);
  }
};</code></pre>
<ol>
<li>React 컴포넌트에서 사용하기
컴포넌트나 커스텀 훅에서 <code>useSyncExternalStore</code> 를 사용해 이 스토어를 붙여준다.</li>
</ol>
<pre><code class="language-tsx">import { useSyncExternalStore, useEffect } from &#39;react&#39;;
import { useQueryClient } from &#39;@tanstack/react-query&#39;;
import { workerStore } from &#39;./sharedWorkerStore&#39;;

function useSharedWorkerData() {
  const queryClient = useQueryClient();

  // useSyncExternalStore(구독함수, 스냅샷함수)
  // 워커에서 새로운 메시지가 올 때마다 이 훅이 알아서 트리거되어 최신 data를 반환합니다.
  const data = useSyncExternalStore(
    workerStore.subscribe, 
    workerStore.getSnapshot
  );

  // 만약 React Query 캐시와도 연동하고 싶다면?
  useEffect(() =&gt; {
    if (data) {
      // 워커가 준 따끈따끈한 최신 데이터로 React Query 캐시 동기화
      queryClient.setQueryData([&#39;myCachedData&#39;], data);
    }
  }, [data, queryClient]);

  return data;
}

// 실제 컴포넌트에서의 사용
export function MyComponent() {
  const sharedData = useSharedWorkerData();

  if (!sharedData) return &lt;div&gt;연결 중...&lt;/div&gt;;

  return &lt;div&gt;모든 탭이 동기화된 데이터: {sharedData.message}&lt;/div&gt;;
}</code></pre>
<p>정리하면,</p>
<ul>
<li>SharedWorekr: 여러 탭 간의 징검다리 및 하나의 웹소켓 통신 담당</li>
<li>useSyncExternalStore: 백그라운드에서 일어난 데이터 변화를 메인 스레드(React 컴포넌트)에 안전하게 렌더링 신호로 토스해주는 역할</li>
<li>React Query: 토스받은 데이터를 메모리에 캐싱하고, 화면 이곳저곳에서 <code>useQuery</code> 로 편하게 쓸 수 있도록 관리하는 역할</li>
</ul>
<br />

<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event">MDN-storage event</a></li>
<li><a href="https://medium.com/@vinaykumarbr07/cross-tab-state-synchronization-in-react-using-the-browser-storage-event-14b6f1a97ea6">React에서 브라우저 storage 이벤트를 이용한 탭 간 상태 동기화</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/Broadcast_Channel_API">MDN-Broadcast Channel API</a></li>
<li><a href="https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/react.tsx">nextauth 내부 코드</a></li>
<li><a href="https://dev.to/childrentime/real-time-react-syncing-state-across-browser-tabs-hn5">실시간 React: 브라우저 탭 간 상태 동기화</a></li>
<li><a href="https://www.reddit.com/r/reactjs/comments/1mv92yb/how_to_keep_data_in_sync_across_server_and/">raddit 코멘트</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/SharedWorker">MDN-SharedWorker</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[PWA + Android 앱에 푸시 알림 구현하기 (Firebase FCM)]]></title>
            <link>https://velog.io/@dobby_/PWA-Android-%EC%95%B1%EC%97%90-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Firebase-FCM</link>
            <guid>https://velog.io/@dobby_/PWA-Android-%EC%95%B1%EC%97%90-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Firebase-FCM</guid>
            <pubDate>Fri, 10 Jul 2026 05:30:39 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>사용자에게 새로운 활동을 알려주는 푸시 알림 기능을 추가하자는 피드백이 들어왔다.</p>
<p>기록 서비스, 특히 공동 기록 서비스 특성상 다른 사용자가 기록을 수정하거나 추가하는 등의 활동에 대해서는 알림으로 알리는게 필요하다고 생각했다.</p>
<p>이번 글은 Next.js app router 기반 PWA와 Capacitor로 패키징한 Android 앱에 Firebase FCM을 활용해 푸시 알림을 구현한 과정에 대해 정리하고자 한다.</p>
<p>구현 목표는 다음과 같았다.</p>
<ul>
<li>그룹 활동(멤버 합류, 기록 작성 등)발생 시 관련 사용자에게 알림 발송</li>
<li>알림 클릭 시 해당 기록 또는 그룹 페이지로 딥링크</li>
<li>백그라운드/포그라운드 모두에서 알림 수신</li>
</ul>
<hr>
<h2 id="푸시-알림-지원-환경과-이유">푸시 알림 지원 환경과 이유</h2>
<h3 id="지원-플랫폼">지원 플랫폼</h3>
<p>간단하게, 최종적으로 어떤 플랫폼에 대해 알림 기능을 지원하도록 했는지 표로 정리해봤다.</p>
<table>
<thead>
<tr>
<th>플랫폼</th>
<th>지원 여부</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Android Chrome / PWA</td>
<td>✅</td>
<td>Web Push API</td>
</tr>
<tr>
<td>Android 네이티브 앱(Capacitor)</td>
<td>✅</td>
<td>WebView는 미지원이나 네이티브 플러그인으로 우회</td>
</tr>
<tr>
<td>macOS Safari</td>
<td>✅</td>
<td>Web Push API (Apple이 표준 지원)</td>
</tr>
<tr>
<td>iOS Safari/홈 화면 PWA</td>
<td>❌</td>
<td>기술적으로는 가능하나, APNs 키 없음</td>
</tr>
<tr>
<td>iOS Capacitor 앱(WKWebView)</td>
<td>❌</td>
<td>APNs 키 없음</td>
</tr>
</tbody></table>
<h3 id="플랫폼별-지원-여부-상세">플랫폼별 지원 여부 상세</h3>
<h4 id="android-브라우저-네이티브-앱">Android: 브라우저, 네이티브 앱</h4>
<p>MDN 호환성 표를 보면 WebView Android는 Push API를 지원하지 않는다.
Capacitor Android 앱은 내부적으로 WebView를 사용하므로 Web Push API로는 푸시를 받을 수 없다.</p>
<p>하지만 Capacitor는 <code>@capacitor/push-notifications</code> 플러그인을 통해 네이티브 레이어에서 직접 FCM을 처리하므로 WebView의 제약을 우회할 수 있다.
Chrome Android는 Web Push API를 정상 지원한다.</p>
<h3 id="ios를-지원하지-않는-이유">iOS를 지원하지 않는 이유</h3>
<p>iOS 16.4부터 Safari 브라우저와 홈 화면에 설치된 PWA에서 Web Push를 지원한다.
하지만 iOS에서 푸시 알림을 보내려면 Apple의 APNs(Apple Push Notification service)를 거쳐야 한다.
APNs 인증 키 발급을 위해서는 연 $99의 Apple Developer Program 가입이 필요하다.</p>
<p>우리 팀은 아직 서비스가 완전하게 출시되지 않았기도 하고, 비용 부담의 이유로 apple은 지원하지 않는 것으로 됐다.</p>
<p>반면 macOS에서는 Web Push API를 통해 Apple ID만으로도 웹 푸시가 가능하다.
Apple이 macOS용 웹 푸시를 W3C 표준으로 개발했기 때문이다.</p>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers">Apple — Sending web push notifications in web apps and browsers</a></li>
<li><a href="https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server">Apple — APNs Overview</a></li>
<li><a href="https://developer.apple.com/programs/">Apple Developer Program</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility">MDN — Push API: Browser compatibility</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/PushEvent#browser_compatibility">MDN — PushEvent: Browser compatibility</a></li>
</ul>
</blockquote>
<hr>
<h2 id="firebase-fcm-선택-이유">Firebase FCM 선택 이유</h2>
<p>웹과 Android 모두를 단일 서버에서 처리하기 위해 FCM을 선택했다.
직접 Web Push(VAPID)를 구현하는 방법도 있겠지만, 이를 직접 검증해야 하며 개인이 검증하는 것이기에 안전하지 않다고 판단했다.</p>
<p>FCM을 사용하면</p>
<ul>
<li>웹(VAPID)와 Android(FCM) 토큰을 단일 Admin SDK API로 발급 가능</li>
<li>만료 토큰 감지, 재시도 등 안정적인 전송 인프라 제공</li>
<li>Firebase Console에서 발송 로그 모니터링 가능</li>
</ul>
<p>위처럼 개인이 직접 구현하는 것보다는 이미 잘 만들어져 관리되고 있는 서비스를 사용하는게 더 낫다고 생각했다.</p>
<h3 id="중개-서버를-거치지-않고-자체적으로-구현한다면">중개 서버를 거치지 않고 자체적으로 구현한다면?</h3>
<p>FCM이나 APNs 같은 중개 서버를 거치지 않고 100% 자체 인프라로 실시간 알림을 직접 구현하려면, 클라이언트와 자체 서버 간에 지속적인 연결을 유지하는 기술을 사용해야 한다.</p>
<p>대표적으로는 3가지 정도가 있다.</p>
<h4 id="1-websocket">1. WebSocket</h4>
<p>가장 보편적인 양방향 실시간 통신 방식이다.</p>
<ul>
<li>클라이언트와 서버가 한 번 연결되면, 계속 연결을 유지하며 데이터를 주고받는다.</li>
<li>실시간성이 매우 뛰어나고 데이터 전송 오버헤드가 적다.</li>
</ul>
<h4 id="2-sse-server-sent-event">2. SSE (Server-Sent Event)</h4>
<p>서버가 클라이언트에게 단방향으로 데이터를 밀어주는 방식이다.</p>
<ul>
<li>HTTP 프로토콜을 그대로 사용하며, 연결을 끊지 않고 서버가 이벤트를 스트리밍한다.</li>
<li>웹소켓보다 구현이 간단하고, 연결이 끊겼을 때 자동 재연결 기능이 내장되어 있다.</li>
<li>보통 뉴스 피드 업데이트, 알림 피드, 실시간 점수 판 등에 활용된다.</li>
</ul>
<h4 id="3-mqtt">3. MQTT</h4>
<p>모바일 및 사물인터넷 환경에 최적화된 가벼운 메시징 프로토콜이다.</p>
<ul>
<li>발행과 구독 패턴으로 동작하며, 가벼운 브로커를 두고 통신한다.</li>
<li>네트워크 전력 소비가 매우 적어서 모바일 배터리 절약에 유리하다.</li>
<li>IoT 기기 제어, 모바일 메신저 백그라운드 통신 등에 활용된다.</li>
</ul>
<p>포그라운드 상태에서는 위 방식으로 완벽하게 작동한다.
하지만 모바일 앱이 꺼져 있거나 백그라운드 상태에서는 OS 제약이 발생하게 된다.</p>
<hr>
<ul>
<li>iOS(Apple) 제한: iOS는 앱이 백그라운드로 가면 전력 절약을 위해 네트워크 연결을 강제로 귾어버린다. 따라서 FCM을 쓰지 않더라도, 결국 Apple이 공식 제공하는 APNs 서버로는 무조건 메시지를 보내야만 아이폰 화면에 push 알림을 띄울 수 있다.</li>
<li>Android 제한: 안드로이드 역시 백그라운드 배터리 최적화로 인해 백그라운드 소켓 연결을 유지하기 어렵다. 백그라운드에서 상시 연결을 유지하려면 &#39;포그라운드 서비스&#39;를 띄워 상단 바에 고정 알림을 상시 노출해야 하므로 사용자 경험에 좋지 않다.</li>
</ul>
<p>그렇기에 앱이 켜져 있을 때는 WebSockets/SSE로 직접 빠르게 알림을 처리하고, 앱이 꺼져 있을 때는 FCM/APNs를 백업으로 호출하는 하이브리드 방식을 채택하기도 한다.</p>
<p>하지만 두 방식을 모두 지원하기엔 소규모 팀에선 리소스 소모가 크다고 생각했다.
그래서 FCM만을 사용해 push 알림을 구현하고자 했다.</p>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://firebase.google.com/docs/cloud-messaging">Firebase Cloud Messaging 공식 문서</a></li>
</ul>
</blockquote>
<hr>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/dobby_/post/b1b55e58-af45-47d7-b24f-92a083237bf8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/b57beb32-239c-4621-826e-04e2b274bad3/image.png" alt=""></p>
<p>푸시 알람의 흐름은 다음과 같다.</p>
<p>앱 실행
    -&gt; 알림 권한 요청
    -&gt; FCM 토큰 발급
    -&gt; 백엔드에 식별자 저장</p>
<p>그룹 활동 발생(POST_CREATE, MEMBER_JOIN 등)
    -&gt; 그룹 활동에 대한 service 메소드 호출 (<code>GroupActivityService.recordActivity()</code>)
    -&gt; 신규: 그룹 멤버들의 FCM 식별자 조회
    -&gt; Firebase Admin SDK로 푸시 발송
    -&gt; 기기에 알림 표시</p>
<blockquote>
<p><strong>웹과 안드로이드의 식별자가 다른 이유</strong>
Firebase Android SDK에서 getToken(), deleteToken(), onNewToken()이 deprecated되고 웹과 동일하게 onRegistered() 기반 FID 방식으로 전환됐다. 단 AndroidManifest.xml에 opt-in 플래그를 추가해야 활성화된다.
하지만 Capacitor 공식 문서는 현재도 registration 이벤트가 Android에서 FCM Token을 반환한다고 명시한다.
Capacitor 플러그인이 내부적으로 구 API를 래핑하고 있고, 위 opt-in 플래그도 설정하지 않기 때문에 아직 FID가 아닌 토큰을 돌려준다.
<a href="https://capacitorjs.com/docs/apis/push-notifications">Capacitor 공식문서</a></p>
</blockquote>
<h3 id="firebase-admin-sdk">Firebase Admin SDK?</h3>
<p>서버 사이드 전용 SDK이다.
클라이언트가 아닌 신뢰할 수 있는 서버 환경에서 Firebase 서비스를 제어할 목적으로 사용한다.
우리 팀은 NestJS 백엔드에서 FCM 메시지를 발송하는 데 사용하고 있다.</p>
<pre><code class="language-javascript">// notification.service.ts
import { getMessaging } from &#39;firebase-admin/messaging&#39;;

await getMessaging(this.app).sendEach(messages); // ← 여기</code></pre>
<h3 id="클라이언트-sdk-vs-admin-sdk-차이">클라이언트 SDK vs Admin SDK 차이</h3>
<table>
<thead>
<tr>
<th>.</th>
<th>클라이언트 SDK(firebase)</th>
<th>Admin SDK(firebase-admin)</th>
</tr>
</thead>
<tbody><tr>
<td>실행 환경</td>
<td>브라우저 / 앱</td>
<td>서버 (node.js 등)</td>
</tr>
<tr>
<td>인증 방식</td>
<td>사용자 로그인 (google, 이메일 등)</td>
<td>서비스 계정 키 (JSON 파일)</td>
</tr>
<tr>
<td>권한</td>
<td>로그인한 사용자 권한 범위</td>
<td>관리자 권한 (모든 데이터 접근)</td>
</tr>
<tr>
<td>용도</td>
<td>사용자가 직접 조작</td>
<td>서버가 대신 처리</td>
</tr>
</tbody></table>
<p>Admin SDK가 주로 쓰이는 곳은 다음과 같다.</p>
<ul>
<li>FCM 발송: 특정 사용자에게 푸시 알림 보내기</li>
<li>Firestore/Realtime DB 관리: 보안 규칙을 우회해서 서버에서 직접 데이터 읽기/쓰기</li>
<li>Auth 관리: 사용자 계성 생성/삭제, 커스텀 토큰 발급</li>
<li>Storage 관리: 파일 업로드/삭제</li>
</ul>
<p>사용자 대신 서버가 Firebase를 직접 제어해야 할 때 쓰며, <strong>클라이언트에서 Admin SDK를 쓰면 서비스 계정 키가 노출되기 때문에 절대 사용하면 안된다.</strong></p>
<br />

<p>Firebase는 Topic 구독으로 Topic별로 알림을 수신할 수 있도록 하는 기능도 제공한다.
검색하면 나오는 <a href="https://soeun2537.tistory.com/111">관련 글</a>을 참고하면 좋을 것 같다.</p>
<p>우리 팀도 처음에는 Topic 구독으로 단위를 나눠 알림을 제공할 수 있었지만, 그룹 별로 알림 수신 여부를 다르게 설정할 수 있는 기능을 추가해서 Topic 단위로는 부족했다.
그래서 그룹 내에서도 &#39;특정 멤버&#39;에게만 보내기 편한 FID 직접 관리 방식으로 구현했다.</p>
<hr>
<h2 id="환경변수-발급받기">환경변수 발급받기</h2>
<p>필요한 환경변수는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>위치</th>
<th>필요한 값</th>
</tr>
</thead>
<tbody><tr>
<td>백엔드</td>
<td>Firebase Amdin SDK 서비스 계정 JSON</td>
</tr>
<tr>
<td>프론트</td>
<td>Firebase 웹앱 config(apiKey, projectId 등) + VAPID 키</td>
</tr>
</tbody></table>
<p>위와 값은 모두 Firebase 콘솔에서 발급할 수 있다.
혹시나 나 같이 이런 타 서비스 사용에 있어서 어려움을 겪는 사람을 위해 발급 과정을 하나하나 정리해봤다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/ff05b58f-1649-450f-90c4-70b5484251c8/image.png" alt=""></p>
<p>먼저, 설정의 일반 페이지로 넘어간다.
내리면 &#39;내 앱&#39; 세션에서 앱을 등록해주는데, 나는 <code>&lt;/&gt;</code> 표시의 웹으로 등록을 먼저 해주었다.
(웹으로 동작하기 때문에)</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/aa6e0a55-7944-402b-86af-0d4db9197d47/image.png" alt=""></p>
<p>sdk 설정은 npm으로 해주었다.
<code>&lt;script&gt;</code> 태그 방식은 CDN에서 직접 로드하는 방식인데, Next.js + pnpm 프로젝트에서는 번들러가 tree-shaking으로 사용하는 기능만 포함시켜 주니 npm으로 충분하다.</p>
<p>그리고 config 코드를 복사해서 코드에 붙여넣어준다.</p>
<pre><code class="language-javascript">// Import the functions you need from the SDKs you need
import { initializeApp } from &quot;firebase/app&quot;;
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app&#39;s Firebase configuration
const firebaseConfig = {
  apiKey: &quot;AI...&quot;,
  authDomain: &quot;fr...&quot;,
  projectId: &quot;fr...&quot;,
  storageBucket: &quot;fr...&quot;,
  messagingSenderId: &quot;51...&quot;,
  appId: &quot;1:51...&quot;
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);</code></pre>
<p>이제 VAPID를 발급하자</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/80ba85ec-1bd7-4192-8ee4-56de357067e1/image.png" alt=""></p>
<p>알림 페이지로 접근하면 아래 사진처럼 클라우드 메시징 탭이 있다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/712048e3-58e6-4f5a-b73c-fc2a6eb740e6/image.png" alt=""></p>
<p>여기서 아래로 스크롤하면 &#39;웹 구성&#39; 섹션이 보인다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/13d84763-5d6b-4bc7-bec1-c80bf83c2764/image.png" alt=""></p>
<p>여기서 Generate key pair 버튼을 클릭해 VAPID(공개 키)를 발급받는다.</p>
<pre><code>BHc7cP...</code></pre><p>이제 마지막으로 백엔드 설정을 위한 Firebase Admin SDK 서비스 계정 JSON을 발급받아야 한다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/5f57d769-759f-4482-a306-474a17ca26d7/image.png" alt=""></p>
<p>같은 페이지에서 &#39;서비스 계정&#39; 탭으로 이동한다.
여기서 백엔드 환경에 맞는 탭이 선택되어 있는지 확인한다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/b74f2fd9-bece-47bf-9598-09f2363510c5/image.png" alt=""></p>
<p>우리 팀은 node를 사용하고 있어서, Node.js를 선택해줬다.</p>
<p>선택한 뒤, &#39;새 비공개 키 생성&#39; 버튼을 클릭한다.
팝업창이 뜨면 &#39;키 생성&#39;을 누른다.</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/cb2a6a6d-ec36-4586-a939-5d0e5ef2bb83/image.png" alt=""></p>
<p>&#39;키 생성&#39; 버튼을 클릭하면 자동으로 브라우저를 통해 <code>.json</code> 파일이 다운로드된다.
이 파일은 git 등의 공유 저장소에 업로드되지 않도록 안전하게 보관해야 하는 서비스 계정 키 파일이다.</p>
<blockquote>
<p>서비스 계정 JSON 파일은 프로젝트의 모든 권한을 가진 마스터 키와 같기 때문에, 절대 GitHub 같은 공용 저장소에 업로드되지 않도록 <code>gitignore</code> 처리를 해줘야 한다.</p>
</blockquote>
<p>gitignore에 json 파일을 추가해주고,</p>
<p><code>rontend/.env</code> 에 <code>NEXT_PUBLIC_FIREBASE_*</code> 7개의 값을 추가해줬다.
<code>backend/.env</code> 에 <code>FIREBASE_SERVICE_ACCOUNT_PATH</code> 를 추가해줬다.</p>
<p>이제 capacitor android를 위한 앱을 추가한다.
기존의 웹 앱을 추가한 것처럼 진행하면 된다.
이번에는 android 아이콘을 선택하면 된다.</p>
<p>Firebase 콘솔 -&gt; 프로젝트 설정 -&gt; 앱 추가 -&gt; Android</p>
<hr>
<h2 id="작업-단계">작업 단계</h2>
<p>처리해야 하는 작업들은 단계별로 정리했을 때 다음과 같다.</p>
<h3 id="1-백엔드-fcm-식별자-저장-테이블-및-api-추가">1. 백엔드: FCM 식별자 저장 테이블 및 API 추가</h3>
<p><code>fcm_token</code> 엔티티를 생성한다. (userId, token, platform: <code>&#39;web&#39;|&#39;android&#39;</code> )</p>
<p><code>POST /api/fcm-tokens</code> 엔드포인트로 토큰을 등록하고 갱신시킨다.</p>
<h3 id="2-백엔드-푸시-발송-서비스-생성">2. 백엔드: 푸시 발송 서비스 생성</h3>
<p><code>firebase-admin</code> 설치 후 <code>PushNitficationService</code> 를 생성한다.</p>
<pre><code>pnpm --filter backend add firebase-admin</code></pre><p><code>recordActivity()</code> 끝에서 엑터를 제외한 그룹 멤버들에게 알림을 발송한다.</p>
<h3 id="3-프론트-pwaweb">3. 프론트: PWA/Web</h3>
<p><code>firebase</code> 패키지를 설치한다.</p>
<pre><code>pnpm --filter frontend add firebase</code></pre><p><code>firebase-message-sw.js</code> 서비스 워커를 추가한다. 이 서비스 워커는 백그라운드의 알림 수신용이다.</p>
<p>권한 요청 + FCM 토큰 발급 + 백엔드에 등록하는 훅을 작성해 관리하기 편하게 한다.</p>
<h3 id="4-프론트-androidcapacitor">4. 프론트: Android/Capacitor</h3>
<p>capacitor 환경은 지원하는 플러그인을 사용하는게 좋다.</p>
<p><code>@capacitor-firebase/messaging</code> 플러그인을 설치하고, 같은 훅 안에서 플랫폼별로 분기 처리를 한다.</p>
<hr>
<h2 id="백엔드-구현">백엔드 구현</h2>
<h3 id="1-fcm-식별자-엔티티">1. FCM 식별자 엔티티</h3>
<p>플랫폼별로 다른 전송 방식이 필요하므로 <code>platform</code> 컬럼을 함께 저장한다.
사용자당 플랫폼별 하나의 식별자만 유지한다.</p>
<pre><code class="language-javascript">@Entity(&#39;fcm_tokens&#39;)
@Unique([&#39;userId&#39;, &#39;platform&#39;])
export class FcmToken {
  @PrimaryGeneratedColumn(&#39;uuid&#39;)
  id: string;

  @Column()
  userId: string;

  @Column()
  token: string; // 웹: FID(Firebase Installation ID), Android: FCM 등록 토큰

  @Column()
  platform: &#39;web&#39; | &#39;android&#39;;
}</code></pre>
<h3 id="2-fcm-발송-서비스">2. FCM 발송 서비스</h3>
<p>플랫폼별로 메시지 형식이 달라야 한다.</p>
<ul>
<li>웹: FidMessage - <code>fid</code> 필드에 FID(Firebase Installation ID)를 사용, <code>data-only</code> 메시지</li>
<li>Android: TokenMessage - <code>token</code> 필드에 FCM 등록 토큰을 사용, <code>notification</code> 필드 포함</li>
</ul>
<pre><code class="language-javascript">async sendToUsers(
  userIds: string[],
  title: string,
  body: string,
  data?: Record&lt;string, string&gt;,
): Promise&lt;void&gt; {
  const tokens = await this.fcmTokenRepo.find({
    where: userIds.map((userId) =&gt; ({ userId })),
    select: [&#39;token&#39;, &#39;platform&#39;],
  });

  if (tokens.length === 0) return;

  // 만료 식별자 추적을 위해 순서 보존
  const identifiers = tokens.map(({ token }) =&gt; token);

  const messages = tokens.map(({ token, platform }) =&gt;
    platform === &#39;web&#39;
      ? {
          fid: token, // FidMessage — Admin SDK에서 웹 FID 전용 필드
          data: { title, body, ...(data ?? {}) },
          webpush: { headers: { Urgency: &#39;high&#39; } },
        }
      : {
          token, // TokenMessage — Android FCM 등록 토큰
          notification: { title, body },
          ...(data ? { data } : {}),
        },
  );

  const response = await getMessaging(this.app).sendEach(messages);

  // 만료 식별자 정리
  const staleIdentifiers = response.responses
    .map((r, i) =&gt; ({ ...r, identifier: identifiers[i] }))
    .filter(
      (r) =&gt;
        !r.success &amp;&amp;
        (r.error?.code === &#39;messaging/registration-token-not-registered&#39; ||
          r.error?.code === &#39;messaging/invalid-argument&#39;),
    )
    .map((r) =&gt; r.identifier);

  if (staleIdentifiers.length &gt; 0) {
    await this.fcmTokenRepo
      .createQueryBuilder()
      .delete()
      .where(&#39;token IN (:...tokens)&#39;, { tokens: staleIdentifiers })
      .execute();
  }
}</code></pre>
<blockquote>
<p>왜 웹은 data-only?
Firebase compat SDK는 <code>notification</code> 필드가 있는 메시지를 수신하면 자동으로 알림을 표시하면서 동시에 <code>onBackgroundMessage</code>도 호출한다. 이 경우:</p>
<ol>
<li>알림이 두 번 표시된다</li>
<li>Firebase가 자동 표시한 알림의 <code>evnet.notification.data</code>는 FCM이 래핑한 구조라, <code>postId</code>, <code>groupId</code> 등 커스텀 데이터를 바로 꺼내기 어렵다.</li>
</ol>
<p><code>data-only</code>로 보내면 Firebase의 자동 표시 없이 <code>onBackgroundMessage</code>에서만 알림을 제어할 수 있다.</p>
</blockquote>
<p>참고로, Firebase JS SDK v12부터 웹 푸시 등록 방식이 <code>getToken</code> -&gt; <code>register</code> + <code>onRegistered</code> 방식으로 변경되었다.
새 API에서 반환하는 식별자는 FCM 등록 토큰이 아니라 FID이다. Firebase Admin SDK에서 이에 대응하는 <code>FidMessage</code> 타입(fid 필드)가 추가됐고, 기존 <code>TokenMessage</code>는 웹에 대해서는 deprecated 처리됐다.</p>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.messaging.md#messagingsendreach">Firebase Admin SDK — sendEach</a></li>
<li><a href="https://firebase.google.com/docs/cloud-messaging/customize-messages/set-message-type?hl=ko">FCM 메시지 타입 — 알림 메시지 vs 데이터 메시지</a></li>
<li><a href="https://firebase.google.com/docs/cloud-messaging/js/receive#handle_messages_when_your_web_app_is_in_the_background">웹용 백그라운드 메시지 수신</a></li>
</ul>
</blockquote>
<hr>
<h2 id="프론트엔드-구현">프론트엔드 구현</h2>
<h3 id="1-service-worker-설정-firebase-messaging-swjs">1. Service Worker 설정 (<code>firebase-messaging-sw.js</code>)</h3>
<pre><code class="language-javascript">self.addEventListener(&#39;install&#39;, () =&gt; self.skipWaiting());
self.addEventListener(&#39;activate&#39;, (event) =&gt;
  event.waitUntil(self.clients.claim()),
);

// ⚠️ 반드시 firebase.messaging() 호출 전에 등록해야 합니다
self.addEventListener(&#39;notificationclick&#39;, (event) =&gt; {
  event.notification.close();
  const { groupId, postId } = event.notification.data ?? {};
  const url =
    postId &amp;&amp; groupId
      ? `/record/${postId}?scope=group&amp;groupId=${groupId}`
      : postId
        ? `/record/${postId}`
        : groupId
          ? `/group/${groupId}`
          : &#39;/&#39;;

  event.waitUntil(clients.openWindow(url));
});

// firebase.messaging() 이후에 등록하면 Firebase 내부 핸들러가 먼저 실행됩니다
importScripts(&#39;https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js&#39;);
importScripts(&#39;https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js&#39;);

firebase.initializeApp({ /* firebaseConfig */ });
const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) =&gt; {
  // data-only 메시지이므로 payload.data에서 읽어야 합니다
  const title = payload.data?.title ?? &#39;알림&#39;;
  const body = payload.data?.body ?? &#39;&#39;;
  self.registration.showNotification(title, {
    body,
    icon: &#39;/icon-192x192.png&#39;,
    data: payload.data ?? {},  // notificationclick에서 꺼낼 데이터
  });
});</code></pre>
<p>구현하면서 <code>notificationclick</code> 등록 순서가 중요했다.
Firebase compat SDK는 <code>firebase.messaging()</code> 호출 시점에 내부적으로 <code>notificationclick</code> 이벤트 리스너를 등록한다.
이벤트 리스너는 등록 순서대로 실행되는데, Firebase 리스너가 먼저 실행되면 user gesture context를 소비해버린다.
이후 우리 리스너의 <code>clients.openWindow()</code>는 user gesture 없이 호출되므로 브라우저가 창 열기를 차단하게 된다.</p>
<p>이 때문에 나는 첫 번째 알림 클릭은 반응이 없고, 두 번째 알림 클릭부터 정상 동작하는 현상이 발생했다.</p>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event">MDN — ServiceWorkerGlobalScope: notificationclick event</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow">MDN — Clients.openWindow()</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">MDN — Service Worker API</a></li>
<li><a href="https://web.dev/articles/push-notifications-overview">web.dev — Push notifications overview</a></li>
<li><a href="https://firebase.google.com/docs/cloud-messaging/js/client">Firebase — Set up a JavaScript Firebase Cloud Messaging client app</a></li>
</ul>
</blockquote>
<h3 id="2-fcm-등록-후">2. FCM 등록 후</h3>
<p>Firebase JS SDK에서 <code>getToken/deleteToken</code>이 deprecated되고
<code>register/unregister/onRegistered</code> API로 전환됐다.</p>
<ul>
<li><code>register</code>: Promise를 반환하지 않는 콜백 기반 API</li>
<li>`onRegistered(messaging, callback): FID를 전달받는 콜백 등록, register 호출 전에 먼저 등록해야 한다.</li>
<li><code>unregister</code>: 기존 FID 등록 정보 삭제, <code>swRegistration</code> 초기화 없이도 바로 호출 가능</li>
</ul>
<pre><code class="language-javascript">import { register as fcmRegister, unregister, onRegistered } from &#39;firebase/messaging&#39;;
import type { Messaging } from &#39;firebase/messaging&#39;;

// onRegistered 콜백을 await 가능한 형태로 변환
function registerAndGetFid(
  messaging: Messaging,
  options: { vapidKey: string | undefined; serviceWorkerRegistration: ServiceWorkerRegistration },
): Promise&lt;string | null&gt; {
  return new Promise((resolve) =&gt; {
    // onRegistered를 먼저 등록한 뒤 register() 호출
    const unsubOnRegistered = onRegistered(messaging, (fid) =&gt; {
      unsubOnRegistered();
      resolve(fid);
    });
    fcmRegister(messaging, options).catch(() =&gt; {
      unsubOnRegistered();
      resolve(null);
    });
  });
}

async function getAndRegisterWebFcmToken() {
  const messaging = await getFirebaseMessaging();
  if (!messaging) return;

  const swReg = await navigator.serviceWorker.register(
    &#39;/firebase-messaging-sw.js&#39;,
    { scope: &#39;/firebase-cloud-messaging-push-scope&#39; },
  );

  // SW가 활성화될 때까지 기다립니다 (race condition 방지)
  if (!swReg.active) {
    await new Promise&lt;void&gt;((resolve) =&gt; {
      const sw = swReg.installing ?? swReg.waiting;
      if (!sw) { resolve(); return; }
      const handler = () =&gt; {
        if (sw.state === &#39;activated&#39;) {
          sw.removeEventListener(&#39;statechange&#39;, handler);
          resolve();
        }
      };
      sw.addEventListener(&#39;statechange&#39;, handler);
      // 리스너 등록 후 상태를 다시 확인 (등록 전에 이미 activated된 경우 대비)
      if (sw.state === &#39;activated&#39; || swReg.active) {
        sw.removeEventListener(&#39;statechange&#39;, handler);
        resolve();
      }
    });
  }

  // SW를 unregister하면 push subscription이 소멸합니다
  // 기존 구독이 없으면 FID 등록 정보가 무효화된 것이므로 강제 갱신합니다
  const existingSub = await swReg.pushManager.getSubscription().catch(() =&gt; null);
  if (!existingSub) {
    await unregister(messaging).catch(() =&gt; {});
  }

  const fid = await registerAndGetFid(messaging, {
    vapidKey: VAPID_KEY,
    serviceWorkerRegistration: swReg,
  });

  if (fid) {
    await registerFcmToken(fid, &#39;web&#39;).catch(() =&gt; {});
  }
}</code></pre>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://firebase.google.com/docs/reference/js/messaging_">Firebase — register()</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/PushManager/getSubscription">MDN — PushManager.getSubscription()</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration">MDN — ServiceWorkerRegistration</a></li>
</ul>
</blockquote>
<h3 id="3-android-네이티브-capacitor">3. Android 네이티브 (Capacitor)</h3>
<pre><code class="language-javascript">export async function registerAndroidToken() {
  const { PushNotifications } = await import(&#39;@capacitor/push-notifications&#39;);

  const result = await PushNotifications.requestPermissions();
  if (result.receive !== &#39;granted&#39;) return;

  await new Promise&lt;void&gt;((resolve) =&gt; {
    PushNotifications.addListener(&#39;registration&#39;, async ({ value: token }) =&gt; {
      await registerFcmToken(token, &#39;android&#39;).catch(() =&gt; {});
      await PushNotifications.removeAllListeners();
      resolve();
    });
    PushNotifications.addListener(&#39;registrationError&#39;, async () =&gt; {
      await PushNotifications.removeAllListeners();
      resolve();
    });
    PushNotifications.register();
  });
}</code></pre>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://capacitorjs.com/docs/apis/push-notifications">Capacitor — Push Notifications Plugin</a></li>
<li><a href="https://capacitorjs.com/docs/guides/push-notifications-firebase">Capacitor — Using FCM with Capacitor</a></li>
</ul>
</blockquote>
<hr>
<h2 id="구현-중-마주한-버그와-해결">구현 중 마주한 버그와 해결</h2>
<h3 id="1-서비스워커-unregister-후-알림이-오지-않는-문제">1. 서비스워커 unregister 후 알림이 오지 않는 문제</h3>
<p>DevTools에서 서비스 워커를 unregister하고 새로고침하면 알림 토큰이 재등록되지 않아 알림이 오지 않는 문제가 발생했다.</p>
<p><code>usePushNotification</code>의 활성화 조건을 <code>status === &#39;authenticated</code>(nextauth)로 설정해뒀었는데, fast refresh나 서비스 워커 unregister 이후에 nextauth의 <code>SessionProvider</code> 상태가 리셋되어서 <code>status</code>가 일시적으로 <code>unauthenticated</code>가 된 것이다.</p>
<p><code>useAuthStore</code>(Zustand + localStorage persist)의 <code>userType !== null</code>로 조건을 변경해줬다.
localStorage 기반이라 fast refresh에 영향을 받지 않는다.</p>
<hr>
<h3 id="2-서비스워커-unregister-후-재등록시-fid가-갱신되지-않는-문제">2. 서비스워커 unregister 후 재등록시 FID가 갱신되지 않는 문제</h3>
<p>DevTools에서 1번과 똑같이 unregister하면 push subscription이 소멸하는데, 재등록시 <code>register()</code>가 기존 FID 등록 정보를 재사용해서 push subscription을 생성하지 않았다.</p>
<p>Firebase는 FID 등록 정보를 내부적으로 캐싱한다.
push subscription이 없어진 상태에서도 기존 FID가 남아 있으면 <code>register</code>가 push subscription 재생성 없이 캐시된 FID를 그대로 반환하기에 발생한 문제이다.</p>
<p>push subscription이 없을 때 (<code>pushManager.getSubscription()</code> -&gt; <code>null</code>) <code>unregister(messaging)</code>를 먼저 호출해 Firebase 내부의 FID 등록 정보를 초기화한다.
이렇게 하면 이후 <code>register</code>가 새 push subscription과 함께 FID를 재발급한다.</p>
<pre><code class="language-javascript">const existingSub = await swReg.pushManager.getSubscription().catch(() =&gt; null);
if (!existingSub) {
  // FID 등록 정보를 초기화해 register()가 새 push subscription으로 재등록하도록 강제
  await unregister(messaging).catch(() =&gt; {});
}
const fid = await registerAndGetFid(messaging, options);</code></pre>
<p>결과적으로는 서비스워커 unregister 후에도 새로고침 후 재등록 된 후에는 알림이 잘 온다.</p>
<blockquote>
<p>참고</p>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/PushManager/getSubscription">MDN — PushManager.getSubscription()</a></li>
</ul>
</blockquote>
<hr>
<h3 id="3-첫-번째-알림-클릭-시-아무-반응이-없던-문제">3. 첫 번째 알림 클릭 시 아무 반응이 없던 문제</h3>
<p>앱을 처음 실행하거나 서비스워커를 재등록한 직후에 첫 알림 클릭은 반응이 없고, 두 번째 알림 클릭부터 정상 동작하는 문제가 있었다.</p>
<p>Firebase compat SDK가 <code>firebase.messaging()</code> 호출 시 내부적으로 <code>notificationclick</code> 리스너를 등록한다.
우리 리스너가 Firebase 리스너 이후에 등록되면 Firebase가 먼저 실행되어 user gesture context를 소비하고, 이후 <code>clients.openWindow()</code>가 차단된다.</p>
<p>그래서 <code>notificationclick</code> 리스너를 <code>importScripts</code>/<code>firebase.messaging()</code> 이전에 등록해주었고, 잘 동작하는 것도 확인했다.</p>
<hr>
<h3 id="4-첫-번째-알림-클릭-시-잘못된-페이지로-이동하는-문제">4. 첫 번째 알림 클릭 시 잘못된 페이지로 이동하는 문제</h3>
<p>첫 알림 클릭 시 올바른 기록 페이지가 아닌 엉뚱한 페이지로 이동하는 문제가 발생했다.</p>
<p>백엔드에서 <code>notification</code> 필드를 포함한 메시지를 보내면 Firebase SW가 자동으로 알림을 표시하면서 <code>onBackgroundMessage</code>도 호출한다. 이로 인해서</p>
<ul>
<li>알림이 두 번 표시됨(Firebase 자동 + onBackgroundMessage에서 수동)</li>
<li>자동 표시된 알림의 data는 Firebase가 <code>FCM_MSG</code> 키로 래핑한 구조 -&gt; <code>postId</code>, <code>groupId</code>를 못 찾아 fallback URL로 이동</li>
</ul>
<p>실제로 첫 알림이 빠르게 두 번 호출되는걸 확인했었다.</p>
<p>백엔드 쪽에는 웹 토큰에는 <code>notification</code> 필드 없이 <code>data</code>만 전송하도록 수정했다. (data-only 메시지)
서비스워커 쪽에서는 <code>onBackgroundMessage</code>에서 <code>payload.data.title</code>, <code>payload.data.body</code>를 읽도록 하고
<code>showNotification</code>의 <code>data</code>에 커스텀 필드를 전달해 <code>notificationclick</code>에서 바로 사용이 가능하도록 했다.</p>
<hr>
<h2 id="fcm-토큰-관리">FCM 토큰 관리</h2>
<h3 id="기본-권장사항">기본 권장사항</h3>
<p>토큰은 비활성 등록 토큰과 활성 토큰으로 나뉘는데, 토큰이 비활성화되는 이유는 여러 가지가 있다.
토큰이 손실되거나, 파손되거나, 스토리지로 넘어가거나, 잊혀진 경우이다.</p>
<p>토큰은 결국 db에 저장되고 관리되기 때문에, 비활성화된 토큰은 정리할 필요가 있다.
그렇지 않으면 리소스를 계속 잡아먹게 되기 때문에 불필요한 비용이 추가되게 된다.</p>
<blockquote>
<p>Android의 경우 비활성 토큰이 270일 동안 활동이 없으면 FCM에서 만료된 것으로 간주한다.
토큰이 만료되면 FCM은 토큰을 유효하지 않은 것으로 표시하고 토큰으로의 전송을 거부한다.
iOS와 같은 다른 플랫폼의 경우 FCM은 기본 푸시 서비스(예: APNs)를 사용한다.
APNs는 270일 비활성 상태를 기준으로 한 토큰 만료가 적용되지 않기에, 토큰을 최신 상태로 유지하고 비활성 등록 토큰을 삭제하는 것이 좋다.</p>
</blockquote>
<h3 id="등록-토큰-검색-및-저장">등록 토큰 검색 및 저장</h3>
<p>그렇기에 앱을 처음 시작할 때 이 토큰을 검색해 타임스탬프와 함께 앱 서버에 저장하는 것이 좋다.
물론, 이 타임스탬프는 FCM SDK에서 제공하지 않기에, 서버에서 구현해야 한다.</p>
<p>또한 토큰이 변경될 때마다 타임스탬프를 업데이트하는 것이 중요하다.</p>
<ul>
<li>새 기기에서 앱 복원</li>
<li>사용자가 앱 제거 또는 재설치</li>
<li>사용자가 앱 데이터 소거</li>
<li>FCM에서 기존 토큰이 만료된 후 앱이 다시 활성화</li>
</ul>
<h3 id="토큰-최신-상태-유지-및-비활성-토큰-삭제">토큰 최신 상태 유지 및 비활성 토큰 삭제</h3>
<p>토큰이 최신 상태인지 비활성 상태인지를 판단해 토큰을 처리하는 것은 기준을 정해둬야 한다.</p>
<p>기본적으로 FCM은 앱 인스턴스가 한 달 동안 연결되지 않은 경우 토큰을 비활성 상태로 간주한다.
1개월이 지난 토큰은 비활성 기기일 가능성이 높으며 그 외의 활성 기기는 토큰을 갱신했을 것이기 떄문이다.</p>
<p>그렇기에 FCM에서 잘못된 토큰 응답을 감지하고 유효하지 않거나 만료된 등록 토큰을 시스템에서 삭제해 이에 대응하도록 관리해야 한다.</p>
<p>HTTP v1 API를 사용하면 다음과 같은 오류 메시지가 전송 요청이 잘못된 토큰이나 만료된 토큰을 타겟팅했음을 나타낸다.</p>
<ul>
<li><strong><code>UNREGISTERED</code></strong>(HTTP 404)</li>
<li><strong><code>INVALID_ARGUMENT</code></strong>(HTTP 400)</li>
</ul>
<p><code>INVALID_ARGUMENT</code>는 메시지 페이로드에 문제가 있는 경우에도 반환될 수 있기에 페이로드가 완전히 유효한 경우에만 잘못된 토큰 신호를 보낸다.</p>
<p>메시지 페이로드가 유효하다고 확신한 상태에서, 타겟팅 된 토큰에 대해서 위와 같은 응답이 오면, 해당 토큰은 더 이상 유효한 상태가 아니므로 해당 레코드를 삭제하는 것이 안전하다.</p>
<h3 id="정기적으로-토큰-업데이트">정기적으로 토큰 업데이트</h3>
<p>서버에서 모든 등록 토큰을 주기적으로 검색 및 업데이트하는 것이 좋다.</p>
<blockquote>
<p>참고
<a href="https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ko">Firebase FCM 등록 토큰 관리를 위한 권장사항  |  Firebase Cloud Messaging</a></p>
</blockquote>
<hr>
<p>웹 개발 모드에서의 테스트
mov -&gt; gif 변환으로 인한 느림 주의</p>
<p><img src="https://velog.velcdn.com/images/dobby_/post/3e2afd8a-a3b1-42e3-ba36-6c2b25d70574/image.gif" alt=""></p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://firebase.google.com/docs/admin/setup">Firebase Admin SDK 설정</a></li>
</ul>
]]></description>
        </item>
        <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>
    </channel>
</rss>