<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jina___.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Fri, 17 Jul 2026 10:21:19 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jina___.log</title>
            <url>https://velog.velcdn.com/images/jina___/profile/12a67536-e96f-425d-a3cc-cf544c9ecc1b/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jina___.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jina___" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[WebSocket을 연결했는데도 REST API가 필요한 이유]]></title>
            <link>https://velog.io/@jina___/WebSocket%EC%9D%84-%EC%97%B0%EA%B2%B0%ED%96%88%EB%8A%94%EB%8D%B0%EB%8F%84-REST-API%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@jina___/WebSocket%EC%9D%84-%EC%97%B0%EA%B2%B0%ED%96%88%EB%8A%94%EB%8D%B0%EB%8F%84-REST-API%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Fri, 17 Jul 2026 10:21:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>실시간 채팅에서 REST API와 WebSocket의 역할을 나누고 데이터를 동기화한 방법</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>이전 프로젝트에서 공동구매 서비스를 개발하며 실시간 채팅 구현을 시도한 경험이 있다.</p>
<p>처음에는 Long Polling 방식으로 채팅을 구현했고, 이후 WebSocket으로 전환하려 했다. 하지만 당시에는 WebSocket 연결을 안정적으로 구성하지 못했고, 배포 이후에도 원인을 명확히 파악하지 못한 오류로 실시간 채팅이 정상적으로 동작하지 않았다.</p>
<p>이 경험은 큰 아쉬움으로 남았다. SOPT에 지원할 때도 WebSocket 기반 실시간 채팅을 성공적으로 구현해 보고 싶다는 목표를 작성했다.</p>
<p>그래서 BUDDYS에서는 단순히 메시지가 한 번 실시간으로 전달되는 수준을 넘어 다음 요소까지 고려한 채팅을 구현하고자 했다.</p>
<ul>
<li>인증된 사용자의 WebSocket 연결</li>
<li>연결 및 구독 생명주기 관리</li>
<li>네트워크 단절 이후 자동 재연결</li>
<li>재연결 과정에서 놓친 데이터 복구</li>
<li>기존 메시지와 실시간 메시지의 중복 제거</li>
<li>과거 메시지 무한 스크롤</li>
<li>읽음 상태 실시간 반영</li>
<li>채팅방 목록의 실시간 정렬 및 캐시 갱신</li>
</ul>
<p>이 과정에서 가장 중요했던 질문은 다음과 같았다.</p>
<blockquote>
<p>WebSocket을 사용하면 채팅 데이터를 모두 WebSocket으로 관리해도 될까?</p>
</blockquote>
<p>결론부터 말하면 BUDDYS에서는 REST API와 WebSocket 중 하나를 선택하지 않았다. 두 통신 방식이 담당하는 역할을 구분하고 함께 사용했다.</p>
<hr>
<h2 id="1-websocket을-사용하면-rest-api는-필요-없을까">1. WebSocket을 사용하면 REST API는 필요 없을까?</h2>
<p>실시간 채팅을 구현한다고 하면 처음에는 다음과 같이 생각할 수 있다.</p>
<pre><code class="language-text">실시간 채팅
→ WebSocket으로 메시지를 모두 처리하면 되지 않을까?</code></pre>
<p>하지만 채팅방에 처음 들어온 사용자는 WebSocket 연결 이후 발생하는 메시지만 필요한 것이 아니다. 해당 채팅방에 이미 존재하는 이전 메시지도 확인해야 한다.</p>
<p>수개월 동안 쌓인 메시지를 WebSocket 연결 시점에 한꺼번에 전달하는 것도 적절하지 않다. 사용자가 실제로 확인하려는 범위만 페이지 단위로 조회할 수 있어야 한다.</p>
<p>BUDDYS에서는 다음과 같이 역할을 나눴다.</p>
<pre><code class="language-text">채팅방 최초 진입
→ REST API로 채팅방 정보와 최신 메시지 조회

과거 메시지 확인
→ REST API의 cursor pagination 사용

새 메시지 발생
→ WebSocket을 통해 실시간 전달

읽음 상태 변경
→ WebSocket 이벤트로 실시간 전달</code></pre>
<p>REST API는 클라이언트가 요청한 시점의 서버 상태를 조회하는 데 사용했다. WebSocket은 연결 이후 발생하는 변경 사항을 실시간 이벤트로 전달하는 데 사용했다.</p>
<p>이를 간단히 표현하면 다음과 같다.</p>
<blockquote>
<p>REST API는 현재 상태를 조회하고, WebSocket은 상태의 변화를 전달한다.</p>
</blockquote>
<p>여기서 REST 응답을 서버 전체 데이터의 완전한 스냅샷이라고 볼 수는 없다. 메시지 목록도 페이지 단위로 조회하기 때문이다.</p>
<p>따라서 더 정확하게는 REST API가 <strong>조회 시점에 서버가 보유한 상태의 일부를 확인하는 수단</strong>이고, WebSocket은 <strong>그 전후에 발생하는 변경을 빠르게 전달하는 수단</strong>이라고 할 수 있다.</p>
<hr>
<h2 id="2-상태-동기화-관점에서-본-rest와-websocket">2. 상태 동기화 관점에서 본 REST와 WebSocket</h2>
<p>REST와 WebSocket의 차이를 단순히 “단방향 통신과 양방향 통신”으로만 비교하면 실제 채팅 구현에서 어떤 역할을 맡겨야 하는지 판단하기 어렵다.</p>
<p>BUDDYS에서는 두 방식을 상태 동기화 관점에서 구분했다.</p>
<h3 id="rest-api">REST API</h3>
<p>REST API는 클라이언트가 필요한 시점에 서버로 요청을 보내고, 해당 시점의 상태를 응답받는 방식이다.</p>
<pre><code class="language-text">클라이언트: 현재 채팅 메시지를 주세요.
서버: 현재 조회할 수 있는 메시지는 이것입니다.</code></pre>
<p>다음 상황에 적합하다.</p>
<ul>
<li>채팅방 최초 데이터 조회</li>
<li>기존 메시지 목록 조회</li>
<li>과거 메시지 페이지네이션</li>
<li>재접속 이후 상태 복구</li>
<li>브라우저 포커스 복귀 후 데이터 검증</li>
<li>실시간 연결 중 놓친 데이터 재동기화</li>
</ul>
<p>요청과 응답의 범위가 명확하고, 원하는 시점에 서버 상태를 다시 확인할 수 있다는 장점이 있다.</p>
<h3 id="websocket">WebSocket</h3>
<p>WebSocket은 클라이언트와 서버가 연결을 유지하고, 연결된 동안 양방향으로 데이터를 전달하는 방식이다.</p>
<pre><code class="language-text">서버: 방금 새로운 메시지가 발생했습니다.
서버: 상대방이 이 메시지까지 읽었습니다.</code></pre>
<p>다음과 같이 즉각성이 필요한 상황에 적합하다.</p>
<ul>
<li>새로운 메시지 송수신</li>
<li>상대방의 읽음 상태 반영</li>
<li>채팅방의 마지막 메시지 변경</li>
<li>읽지 않은 메시지 수 변경</li>
<li>채팅방 목록의 실시간 정렬</li>
</ul>
<p>새로운 상태를 확인하기 위한 반복적인 polling 없이 서버의 변경 사항을 즉시 전달받을 수 있다는 장점이 있다.</p>
<p>하지만 WebSocket 이벤트만으로 현재 서버 상태를 항상 완전하게 알 수 있는 것은 아니다.</p>
<p>클라이언트가 연결되지 않은 동안 발생한 이벤트를 현재 구조에서 다시 재생받는 것은 아니기 때문이다. 네트워크가 끊기거나 브라우저가 백그라운드에 있는 동안 이벤트를 놓칠 가능성도 고려해야 한다.</p>
<p>따라서 WebSocket의 자동 재연결만으로는 충분하지 않았다. 다시 연결된 이후 REST API를 통해 서버 상태를 재확인하는 과정이 필요했다.</p>
<hr>
<h2 id="3-websocket과-stomp는-어떤-역할을-했을까">3. WebSocket과 STOMP는 어떤 역할을 했을까?</h2>
<p>BUDDYS에서는 Native WebSocket 연결 위에서 STOMP 프로토콜을 사용했다.</p>
<p>WebSocket은 양방향 통신을 위한 연결을 제공하지만, 메시지를 어떤 경로로 발행하고 구독할지에 대한 애플리케이션 수준의 규칙까지 정의하지는 않는다.</p>
<p>STOMP를 사용하면 destination을 기준으로 메시지를 발행하고 구독할 수 있다.</p>
<pre><code class="language-text">WebSocket
→ 클라이언트와 서버 사이의 양방향 통신 연결

STOMP
→ destination 기반의 subscribe·publish 규칙</code></pre>
<p>서버가 Native WebSocket 연결을 지원했기 때문에 WebSocket fallback을 제공하는 SockJS는 사용하지 않았다. <code>@stomp/stompjs</code>의 <code>brokerURL</code>을 통해 WebSocket에 직접 연결했다.</p>
<pre><code class="language-ts">return new Client({
  brokerURL: WEBSOCKET_URL,
  reconnectDelay: 5000,

  beforeConnect: async (client) =&gt; {
    const accessToken = getAccessToken();

    if (!accessToken) {
      await client.deactivate();
      onMissingAccessToken(
        new Error(&#39;액세스 토큰을 찾을 수 없습니다.&#39;),
      );
      return;
    }

    client.connectHeaders = {
      Authorization: `Bearer ${accessToken}`,
    };
  },
});</code></pre>
<p>연결 전에 최신 Access Token을 읽어 STOMP 연결 헤더에 설정했고, 연결이 끊어지면 5초 후 자동으로 재연결을 시도하도록 구성했다.</p>
<hr>
<h2 id="4-연결과-채팅-도메인의-책임-분리">4. 연결과 채팅 도메인의 책임 분리</h2>
<p>채팅방 컴포넌트마다 WebSocket 연결을 직접 생성하면 화면 이동이나 리렌더링 과정에서 연결이 중복될 수 있다. 인증, 오류 처리, 재연결 로직도 여러 곳에 반복될 가능성이 있다.</p>
<p>이를 방지하기 위해 앱의 Provider 계층에서 하나의 STOMP 연결을 생성하고 공유했다.</p>
<pre><code class="language-text">Providers
└─ AuthSessionProvider
   └─ AuthenticatedStompProvider
      └─ StompProvider
         └─ Chat Domain</code></pre>
<p><code>StompProvider</code>는 다음 책임을 담당한다.</p>
<ul>
<li>STOMP 클라이언트 생성</li>
<li>인증된 사용자에게만 연결 활성화</li>
<li>연결 상태 관리</li>
<li>destination 구독 인터페이스 제공</li>
<li>메시지 발행 인터페이스 제공</li>
<li>연결 오류 상태 처리</li>
<li>Provider 해제 시 클라이언트 비활성화</li>
</ul>
<p>연결 상태는 다음 네 가지로 관리했다.</p>
<pre><code class="language-ts">type StompConnectionStatus =
  | &#39;disconnected&#39;
  | &#39;connecting&#39;
  | &#39;connected&#39;
  | &#39;error&#39;;</code></pre>
<p>반면 채팅 도메인은 STOMP 클라이언트의 생성 방법을 직접 알지 않는다. 공통 Provider가 제공하는 <code>subscribe</code>와 <code>publish</code>를 사용해 채팅에 필요한 destination만 관리한다.</p>
<pre><code class="language-text">useChatRoomStomp
→ 특정 채팅방 메시지 구독
→ 메시지 전송
→ 읽음 이벤트 송수신

useChatRoomListStomp
→ 채팅방 목록 변경 이벤트 구독</code></pre>
<p>화면이 언마운트되거나 연결 상태가 변경되면 Effect의 cleanup을 통해 기존 구독도 해제한다.</p>
<p>이를 통해 공통 연결 인프라와 채팅 도메인의 이벤트 처리 책임을 분리할 수 있었다.</p>
<hr>
<h2 id="5-채팅에서-rest와-websocket의-역할을-어떻게-나눴을까">5. 채팅에서 REST와 WebSocket의 역할을 어떻게 나눴을까?</h2>
<p>BUDDYS에서 사용한 역할을 표로 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>사용한 방식</th>
</tr>
</thead>
<tbody><tr>
<td>채팅방 상세 정보 조회</td>
<td>REST API</td>
</tr>
<tr>
<td>채팅방 최초 메시지 조회</td>
<td>REST API</td>
</tr>
<tr>
<td>과거 메시지 무한 스크롤</td>
<td>REST API</td>
</tr>
<tr>
<td>새 메시지 전송</td>
<td>STOMP WebSocket</td>
</tr>
<tr>
<td>새 메시지 수신</td>
<td>STOMP WebSocket</td>
</tr>
<tr>
<td>읽음 이벤트 전송 및 수신</td>
<td>STOMP WebSocket</td>
</tr>
<tr>
<td>채팅방 목록 최신 정보 갱신</td>
<td>STOMP WebSocket</td>
</tr>
<tr>
<td>채팅방 목록 페이지네이션</td>
<td>REST API</td>
</tr>
<tr>
<td>채팅방 재진입 후 상태 확인</td>
<td>REST API</td>
</tr>
<tr>
<td>브라우저 포커스 복귀 후 검증</td>
<td>REST API</td>
</tr>
<tr>
<td>네트워크 복구 후 동기화</td>
<td>REST API</td>
</tr>
<tr>
<td>WebSocket 재구독 이후 동기화</td>
<td>REST API</td>
</tr>
</tbody></table>
<p>핵심은 실시간 이벤트와 REST 재조회의 목적을 구분하는 것이었다.</p>
<pre><code class="language-text">실시간 이벤트
→ 현재 실행 중인 화면에 변경 사항을 즉시 반영

REST 재조회
→ 놓친 데이터가 없는지 서버 상태와 다시 동기화</code></pre>
<p>WebSocket은 실시간성을 담당하고, REST API는 현재 상태를 조회하고 복구하는 수단으로 사용했다.</p>
<hr>
<h2 id="6-과거-메시지와-실시간-메시지는-어떻게-합쳤을까">6. 과거 메시지와 실시간 메시지는 어떻게 합쳤을까?</h2>
<p>기존 메시지는 TanStack Query의 <code>useInfiniteQuery</code>를 사용해 cursor 기반으로 조회했다.</p>
<pre><code class="language-text">첫 요청
→ 최신 메시지 페이지 조회

상단 스크롤
→ cursor를 이용해 이전 메시지 페이지 조회</code></pre>
<p>WebSocket으로 수신한 신규 메시지는 <code>realtimeMessages</code>라는 로컬 상태에 추가했다.</p>
<pre><code class="language-ts">setRealtimeMessages((previousMessages) =&gt; {
  const isDuplicated = previousMessages.some(
    (message) =&gt;
      message.messageId === receivedMessage.messageId,
  );

  if (isDuplicated) {
    return previousMessages;
  }

  return [...previousMessages, receivedMessage];
});</code></pre>
<p>화면에 표시할 때는 REST로 조회한 메시지와 WebSocket으로 수신한 메시지를 합쳤다.</p>
<pre><code class="language-ts">const fetchedMessages = [
  ...messagePages.pages.flatMap(
    (page) =&gt; page.messages,
  ),
].reverse();

const combinedMessages = [
  ...fetchedMessages,
  ...realtimeMessages,
];</code></pre>
<p>REST 재조회 결과에는 이미 WebSocket으로 수신한 메시지가 포함될 수 있다. 이 경우 같은 메시지가 두 번 표시되지 않도록 <code>messageId</code>를 기준으로 중복을 제거했다.</p>
<pre><code class="language-ts">const seenMessageIds = new Set&lt;number&gt;();
const uniqueMessages = combinedMessages.filter(
  ({ messageId }) =&gt; {
    if (seenMessageIds.has(messageId)) {
      return false;
    }

    seenMessageIds.add(messageId);
    return true;
  },
);</code></pre>
<p>처음에는 <code>filter</code> 내부에서 <code>findIndex</code>를 호출했지만, 메시지 수가 늘어날수록 중복 확인 비용이 O(n²)까지 증가할 수 있었다. 이를 <code>Set</code>에 이미 확인한 <code>messageId</code>를 저장하면서 한 번만 순회하는 방식으로 변경해 중복 제거를 O(n)에 처리했다.</p>
<p>현재 구현의 데이터 흐름은 다음과 같다.</p>
<pre><code class="language-text">REST 메시지
→ TanStack Query infinite query cache
                            ┐
                            ├→ 병합 → messageId 중복 제거 → 화면
                            │
WebSocket 메시지           │
→ realtimeMessages state ──┘</code></pre>
<p>이 구조는 기존 REST 페이지를 유지하면서 실시간 메시지를 빠르게 반영하기에 비교적 단순했다.</p>
<p>다만 REST 데이터와 실시간 데이터라는 두 개의 데이터 소스를 사용하므로 중복 제거와 정렬 규칙을 별도로 관리해야 한다는 부담도 생겼다.</p>
<hr>
<h2 id="7-실시간-이벤트마다-rest를-다시-조회하면-안-될까">7. 실시간 이벤트마다 REST를 다시 조회하면 안 될까?</h2>
<p>초기 구현에서는 메시지 전송에 성공하면 넓은 범위의 채팅 쿼리 키를 무효화했다.</p>
<pre><code class="language-ts">const handleSubmit = () =&gt; {
  const isSent = sendMessage(message);

  if (isSent) {
    setMessage(&#39;&#39;);

    void queryClient.invalidateQueries({
      queryKey: CHAT_ROOM_QUERY_KEY.ALL,
    });
  }
};</code></pre>
<p>이 구조에서는 하나의 메시지를 전송했을 때 다음 흐름이 발생할 수 있었다.</p>
<pre><code class="language-text">STOMP로 메시지 전송
→ 서버가 메시지 이벤트 전달
→ realtimeMessages에 메시지 추가

동시에

채팅 쿼리 invalidate
→ REST 메시지 목록 재조회
→ infinite query 데이터 변경</code></pre>
<p>하나의 사용자 동작으로 WebSocket 상태와 REST 캐시가 연속해서 변경되는 구조였다.</p>
<p>REST 응답과 WebSocket 이벤트에 동일한 메시지가 포함될 수 있으므로 중복 제거가 필요했고, infinite query 데이터가 갱신되면서 메시지 배열과 렌더링 시점도 여러 번 변경될 수 있었다.</p>
<p>메시지 목록에서는 최신 메시지 ID가 변경될 때 최하단으로 자동 스크롤하고 있었기 때문에, 불필요한 재조회와 데이터 변경은 스크롤 동작에도 영향을 줄 수 있었다.</p>
<pre><code class="language-ts">useEffect(() =&gt; {
  bottomRef.current?.scrollIntoView();
}, [latestMessageId]);</code></pre>
<p>따라서 메시지 전송 직후 전체 채팅 쿼리를 무효화하는 로직을 제거했다.</p>
<p>새 메시지는 서버가 STOMP destination을 통해 전달한 결과를 실시간 상태에 반영하고, REST 재조회는 누락 데이터 복구가 필요한 시점에 수행하도록 역할을 조정했다.</p>
<p>이 과정에서 얻은 기준은 다음과 같다.</p>
<blockquote>
<p>실시간 이벤트로 변경된 일부 상태를 반영하기 위해 전체 목록을 매번 다시 조회하지 않는다.</p>
</blockquote>
<hr>
<h2 id="8-읽음-이벤트도-전체-메시지를-다시-조회해야-할까">8. 읽음 이벤트도 전체 메시지를 다시 조회해야 할까?</h2>
<p>상대방이 메시지를 읽었을 때도 전체 메시지 목록을 다시 조회할 수 있다.</p>
<p>하지만 읽음 이벤트에는 상대방이 마지막으로 읽은 메시지 ID가 포함되어 있었다. 이를 이용하면 전체 메시지를 다시 요청하지 않고 현재 상태에서 필요한 필드만 변경할 수 있다.</p>
<p>먼저 상대방이 읽은 가장 큰 메시지 ID를 저장했다.</p>
<pre><code class="language-ts">setLastReadByParticipantMessageId(
  (previousMessageId) =&gt;
    Math.max(
      previousMessageId ?? 0,
      response.lastReadMessageId,
    ),
);</code></pre>
<p>이후 내가 보낸 메시지 중 해당 ID 이하의 메시지만 읽음 상태로 변경했다.</p>
<pre><code class="language-ts">return uniqueMessages.map((message) =&gt; {
  if (
    !message.mine ||
    message.isRead ||
    message.messageId &gt;
      lastReadByParticipantMessageId
  ) {
    return message;
  }

  return {
    ...message,
    isRead: true,
  };
});</code></pre>
<p>데이터 흐름은 다음과 같다.</p>
<pre><code class="language-text">WebSocket READ 이벤트 수신
→ 상대방이 읽은 마지막 messageId 확인
→ 내가 보낸 메시지 중 해당 ID 이하의 isRead 변경</code></pre>
<p>읽음 상태 하나를 갱신하기 위해 전체 메시지 목록을 다시 요청하지 않으므로 즉각적인 화면 반영이 가능하고, 기존 페이지 구성도 유지할 수 있었다.</p>
<p>읽음 이벤트를 발행할 때도 마지막으로 발행한 메시지 ID를 <code>ref</code>에 저장했다. 이미 같은 범위까지 읽음 처리를 전송했다면 중복해서 발행하지 않았다.</p>
<pre><code class="language-ts">if (
  lastPublishedReadMessageId !== null &amp;&amp;
  latestMessage.messageId &lt;=
    lastPublishedReadMessageId
) {
  return;
}</code></pre>
<hr>
<h2 id="9-그렇다면-rest-재조회는-언제-해야-할까">9. 그렇다면 REST 재조회는 언제 해야 할까?</h2>
<p>실시간 이벤트마다 REST를 재조회하지 않는다고 해서 REST refetch 자체가 불필요한 것은 아니다.</p>
<p>두 방식은 목적이 다르다.</p>
<h3 id="websocket-이벤트">WebSocket 이벤트</h3>
<p>현재 연결된 화면에서 발생한 변경을 빠르게 반영한다.</p>
<pre><code class="language-text">MESSAGE 이벤트
→ 신규 메시지 즉시 추가

READ 이벤트
→ 기존 메시지의 isRead 변경

채팅방 목록 이벤트
→ 해당 채팅방을 최신 정보로 교체하고 최상단 이동</code></pre>
<h3 id="rest-재조회">REST 재조회</h3>
<p>클라이언트가 놓친 데이터가 없는지 서버 상태와 다시 동기화한다.</p>
<p>BUDDYS에서는 메시지와 채팅방 목록 infinite query의 특성에 따라 서로 다른 재조회 정책을 사용했다.</p>
<p>채팅방 목록은 최신 메시지와 읽지 않은 메시지 수, 정렬 순서가 계속 바뀌므로 조회 직후부터 stale 상태로 판단하고 화면 재진입 시 항상 서버 상태를 다시 확인했다.</p>
<pre><code class="language-ts">{
  staleTime: 0,
  refetchOnMount: &#39;always&#39;,
  refetchOnWindowFocus: true,
  refetchOnReconnect: true,
}</code></pre>
<p>반면 메시지 목록은 채팅방에 진입할 때 상세 정보 조회와 메시지 조회가 연이어 suspend되면서 같은 요청이 불필요하게 반복되지 않도록 5초의 짧은 fresh 구간을 두었다.</p>
<pre><code class="language-ts">{
  staleTime: 5 * 1000,
  refetchOnMount: true,
  refetchOnWindowFocus: true,
  refetchOnReconnect: true,
}</code></pre>
<p>메시지 데이터가 5초 동안 fresh하더라도 STOMP 구독 완료 시에는 <code>refetchMessages</code>를 직접 호출한다. 따라서 짧은 캐시 유지 시간으로 중복 요청을 줄이면서도 최초 REST 조회와 WebSocket 구독 사이의 누락 가능성은 별도로 보완한다.</p>
<p>각 옵션의 목적은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>목적</th>
</tr>
</thead>
<tbody><tr>
<td>채팅방 목록의 <code>staleTime: 0</code></td>
<td>목록 데이터를 즉시 stale 상태로 판단</td>
</tr>
<tr>
<td>메시지 목록의 <code>staleTime: 5 * 1000</code></td>
<td>짧은 시간 동안 동일한 메시지 요청이 중복되는 것을 방지</td>
</tr>
<tr>
<td>채팅방 목록의 <code>refetchOnMount: &#39;always&#39;</code></td>
<td>화면 재진입 시 항상 서버 상태 재확인</td>
</tr>
<tr>
<td>메시지 목록의 <code>refetchOnMount: true</code></td>
<td>stale 상태로 재진입했을 때 서버 상태 재확인</td>
</tr>
<tr>
<td><code>refetchOnWindowFocus: true</code></td>
<td>브라우저 포커스 복귀 시 재검증</td>
</tr>
<tr>
<td><code>refetchOnReconnect: true</code></td>
<td>네트워크 복구 시 재조회</td>
</tr>
</tbody></table>
<p>또한 STOMP 구독이 완료되면 REST 데이터를 다시 조회했다.</p>
<pre><code class="language-ts">const handleSubscribed = useCallback(() =&gt; {
  void refetchMessages();
}, [refetchMessages]);</code></pre>
<p>채팅방 목록에서는 목록 destination을 구독한 뒤 관련 infinite query를 무효화했다.</p>
<pre><code class="language-ts">const handleSubscribed = useCallback(() =&gt; {
  void queryClient.invalidateQueries({
    queryKey:
      CHAT_ROOM_QUERY_KEY.INFINITE_LIST_ALL(),
  });
}, [queryClient]);</code></pre>
<p>최초 REST 조회와 WebSocket 구독 사이에는 짧은 시간 차이가 존재한다. 네트워크 단절 후 재연결되는 동안에도 이벤트를 놓칠 수 있다.</p>
<p>구독 완료 후 REST 상태를 다시 확인하면 해당 구간에서 누락됐을 가능성이 있는 데이터를 보완할 수 있다.</p>
<p>즉, TanStack Query의 재조회 정책은 실시간 이벤트를 대체하는 것이 아니라 누락 복구를 위한 안전망으로 사용했다.</p>
<pre><code class="language-text">WebSocket
→ 빠른 변경 전달

REST refetch
→ 서버 상태와의 재동기화</code></pre>
<hr>
<h2 id="10-채팅방-목록의-실시간-데이터는-어떻게-갱신했을까">10. 채팅방 목록의 실시간 데이터는 어떻게 갱신했을까?</h2>
<p>메시지 목록뿐 아니라 채팅방 목록도 실시간으로 변경된다.</p>
<p>새 메시지가 발생하면 해당 채팅방에서 다음 정보가 바뀐다.</p>
<ul>
<li>마지막 메시지</li>
<li>마지막 메시지 전송 시간</li>
<li>읽지 않은 메시지 수</li>
<li>최신 메시지 기준 정렬 순서</li>
</ul>
<p>이벤트를 받을 때마다 채팅방 목록 전체를 다시 조회하지 않고, TanStack Query 캐시에서 해당 채팅방만 갱신했다.</p>
<pre><code class="language-text">채팅방 목록 이벤트 수신
→ 같은 chatRoomId의 기존 항목 제거
→ 최신 채팅방을 목록 최상단에 추가
→ 기존 페이지 크기에 맞게 캐시 재구성</code></pre>
<p>채팅방 목록은 무한 스크롤로 구성되어 있었기 때문에 배열의 맨 앞에 채팅방을 추가하는 것만으로는 충분하지 않았다.</p>
<p>아직 불러오지 않은 다음 페이지의 채팅방이 실시간 이벤트로 현재 캐시에 들어오면 현재 페이지의 수용 범위가 초과되고, 이후 다음 페이지 조회에서 중복이 발생할 수 있었다.</p>
<p>이를 해결하기 위해 현재까지 조회한 모든 페이지를 하나의 배열로 합친 뒤 <code>chatRoomId</code>를 기준으로 중복을 제거하고, 각 페이지의 <code>size</code>에 맞게 목록을 다시 나눴다.</p>
<p>마지막 캐시 페이지의 범위를 초과한 항목이 있다면 다음 페이지 조회가 막히지 않도록 <code>hasNext</code>도 함께 보정했다.</p>
<p>이 경험을 통해 실시간 이벤트는 현재 항목 하나만 변경하는 것처럼 보여도, 정렬된 페이지 목록에서는 전체 페이지 경계에 영향을 줄 수 있다는 점을 알게 되었다.</p>
<hr>
<h2 id="11-메시지-상태를-하나의-캐시에서-관리할-수는-없을까">11. 메시지 상태를 하나의 캐시에서 관리할 수는 없을까?</h2>
<p>현재 메시지 화면은 REST 메시지와 WebSocket 메시지를 서로 다른 위치에서 관리한다.</p>
<pre><code class="language-text">REST 메시지
→ TanStack Query cache

WebSocket 메시지
→ realtimeMessages local state

화면
→ 두 배열 병합 및 중복 제거</code></pre>
<p>구현이 단순하고 기존 infinite query 페이지를 직접 변경하지 않아도 된다는 장점이 있지만 다음과 같은 한계도 있다.</p>
<ul>
<li>두 데이터 소스 사이의 중복 제거가 필요하다.</li>
<li>정렬과 병합 규칙을 컴포넌트가 관리한다.</li>
<li>REST refetch 전후 어떤 메시지를 유지할지 판단해야 한다.</li>
<li>컴포넌트가 재마운트되면 로컬 실시간 메시지 상태가 사라진다.</li>
<li>메시지 상태 관리 책임이 TanStack Query와 컴포넌트에 나뉜다.</li>
</ul>
<p>개선 방향으로는 WebSocket 메시지도 <code>queryClient.setQueryData</code>를 통해 infinite query 캐시에 반영하는 구조를 고려할 수 있다.</p>
<pre><code class="language-text">REST 메시지 ───────┐
                   ├→ TanStack Query cache → 화면
WebSocket 메시지 ──┘</code></pre>
<p>이 구조에서는 메시지 상태를 하나의 데이터 소스에서 관리할 수 있다. 컴포넌트가 REST 메시지와 실시간 메시지를 직접 병합하는 책임도 줄어든다.</p>
<p>다만 WebSocket 메시지를 배열 끝에 추가하는 것만으로 끝나지는 않는다.</p>
<p>메시지 infinite query의 다음 요소를 함께 유지해야 한다.</p>
<ul>
<li>페이지 저장 순서</li>
<li>화면에 표시할 메시지 정렬 순서</li>
<li>각 페이지의 cursor</li>
<li>페이지별 메타데이터</li>
<li>REST refetch 결과와의 중복 제거</li>
<li>과거 메시지 추가 조회 시 페이지 경계</li>
</ul>
<p>따라서 단일 캐시 구조가 항상 더 단순하다고 단정할 수는 없다. 캐시 갱신 규칙을 명확히 정의하고 단위 테스트를 마련한 뒤 적용해야 한다.</p>
<p>현재 프로젝트에서는 로컬 실시간 상태와 REST 페이지를 병합하는 방식을 사용했고, 단일 캐시 구조는 이후 개선 방향으로 남겨두었다.</p>
<hr>
<h2 id="12-현재-구현의-전체-데이터-흐름">12. 현재 구현의 전체 데이터 흐름</h2>
<p>BUDDYS의 현재 메시지 흐름은 다음과 같다.</p>
<h3 id="최초-진입">최초 진입</h3>
<pre><code class="language-text">채팅방 상세 REST 조회
+
최신 메시지 REST 조회
→ TanStack Query 캐시에 저장</code></pre>
<h3 id="stomp-구독">STOMP 구독</h3>
<pre><code class="language-text">채팅방 destination 구독
→ 구독 완료
→ 메시지 REST refetch
→ 최초 조회와 구독 사이의 누락 가능성 보완</code></pre>
<h3 id="새-메시지">새 메시지</h3>
<pre><code class="language-text">STOMP MESSAGE 이벤트 수신
→ realtimeMessages에 신규 메시지 추가
→ REST 메시지와 병합
→ messageId 기준 중복 제거
→ 화면 반영</code></pre>
<h3 id="메시지-전송">메시지 전송</h3>
<pre><code class="language-text">사용자 입력
→ STOMP SEND destination으로 발행
→ 서버가 MESSAGE 이벤트 전달
→ 수신한 메시지를 realtimeMessages에 반영</code></pre>
<p>전송 직후 화면에 임의의 메시지를 추가하는 optimistic update가 아니라, 서버가 전달한 MESSAGE 이벤트를 기준으로 화면에 반영한다.</p>
<h3 id="읽음-상태">읽음 상태</h3>
<pre><code class="language-text">가장 최신 메시지 확인
→ STOMP READ destination으로 마지막 messageId 발행

상대방의 READ 이벤트 수신
→ 내가 보낸 메시지 중 해당 messageId 이하의 isRead 변경</code></pre>
<h3 id="과거-메시지-조회">과거 메시지 조회</h3>
<pre><code class="language-text">사용자가 화면 상단에 도달
→ REST cursor pagination 요청
→ 이전 메시지 페이지 추가
→ 늘어난 높이만큼 스크롤 위치 보정</code></pre>
<h3 id="연결-복구">연결 복구</h3>
<pre><code class="language-text">네트워크 복구 또는 STOMP 재연결
→ destination 재구독
→ REST refetch
→ 놓친 데이터가 없는지 서버 상태와 재동기화</code></pre>
<h3 id="채팅방-목록">채팅방 목록</h3>
<pre><code class="language-text">채팅방 목록 REST 조회
→ infinite query cache 저장

채팅방 목록 STOMP 이벤트 수신
→ 해당 채팅방 최신 정보 반영
→ 목록 최상단으로 이동
→ 페이지 크기와 hasNext 보정</code></pre>
<hr>
<h2 id="13-구현하면서-남은-개선점">13. 구현하면서 남은 개선점</h2>
<h3 id="websocket-이벤트-런타임-검증">WebSocket 이벤트 런타임 검증</h3>
<p>채팅방 목록 이벤트는 파싱 과정에서 응답 구조를 검증하고 있지만, 개별 채팅방의 MESSAGE와 READ 이벤트는 현재 <code>JSON.parse</code> 결과를 TypeScript 타입으로 단언해 사용한다.</p>
<pre><code class="language-ts">const response =
  JSON.parse(body) as ReceiveChatRoomResponse;</code></pre>
<p>TypeScript 타입은 컴파일 시점에만 존재하므로 서버가 잘못된 형식의 데이터를 보내더라도 런타임 안전성을 보장하지 않는다.</p>
<p>외부에서 들어오는 데이터인 만큼 이벤트 타입과 필수 필드를 런타임에 검증하는 계층을 추가하면 잘못된 이벤트로 인한 오류를 더 안전하게 차단할 수 있다.</p>
<h3 id="자동-스크롤-조건-세분화">자동 스크롤 조건 세분화</h3>
<p>현재는 가장 최신 메시지 ID가 바뀌면 최하단으로 이동한다.</p>
<pre><code class="language-ts">useEffect(() =&gt; {
  bottomRef.current?.scrollIntoView();
}, [latestMessageId]);</code></pre>
<p>사용자가 과거 메시지를 읽는 중에 새 메시지가 도착하면 현재 위치를 잃을 수 있다.</p>
<p>다음과 같이 상황을 구분하는 것이 더 자연스러운 UX라고 생각한다.</p>
<ul>
<li>사용자가 직접 메시지를 전송하면 최하단으로 이동</li>
<li>이미 최하단 근처라면 새 메시지 수신 시 자동 스크롤</li>
<li>과거 메시지를 읽는 중이라면 현재 위치 유지</li>
<li>자동 스크롤하지 않은 경우 새 메시지 알림 제공</li>
</ul>
<h3 id="캐시-동기화-테스트">캐시 동기화 테스트</h3>
<p>실시간 데이터와 페이지네이션을 함께 사용하면 일반적인 조회보다 고려할 상태가 많아진다.</p>
<p>다음 시나리오를 중심으로 단위 테스트를 추가할 수 있다.</p>
<ul>
<li>REST와 WebSocket에 동일한 메시지가 포함된 경우</li>
<li>WebSocket 재연결 중 여러 메시지가 발생한 경우</li>
<li>과거 메시지를 조회하는 동안 새 메시지가 도착한 경우</li>
<li>이미 읽은 범위보다 작은 READ 이벤트가 도착한 경우</li>
<li>캐시에 없는 채팅방이 실시간 목록 이벤트로 들어온 경우</li>
<li>여러 채팅방의 정렬이 연속으로 변경되는 경우</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>실시간 채팅에서 REST API와 WebSocket은 서로 대체하는 관계가 아니었다.</p>
<p>REST API는 클라이언트가 필요한 시점에 서버 상태를 조회하고, 페이지네이션과 재동기화를 수행하는 데 적합했다. WebSocket은 메시지와 읽음 상태처럼 연결 이후 발생한 변경을 즉시 전달하는 데 적합했다.</p>
<p>BUDDYS에서는 두 방식의 역할을 다음과 같이 구분했다.</p>
<pre><code class="language-text">REST API
→ 현재 서버 상태 조회
→ 기존 및 과거 데이터 조회
→ 재진입·포커스 복귀·재연결 이후 동기화

WebSocket
→ 새 메시지 송수신
→ 읽음 상태 전달
→ 채팅방 목록 변경 실시간 반영</code></pre>
<p>WebSocket을 연결하는 것만으로 안정적인 실시간 채팅이 완성되지는 않았다.</p>
<p>연결이 끊기는 상황, 구독 전후의 이벤트 누락 가능성, REST 응답과 실시간 이벤트의 중복, 무한 스크롤 페이지 경계와 사용자 스크롤 위치까지 함께 고려해야 했다.</p>
<p>이번 구현을 통해 가장 크게 배운 점은 다음과 같다.</p>
<blockquote>
<p>실시간 통신에서 어려운 것은 이벤트를 빠르게 받는 것만이 아니라, 이벤트와 기존 서버 상태를 일관되게 동기화하는 것이다.</p>
</blockquote>
<p>REST API와 WebSocket의 책임을 명확히 나누고, 실시간 이벤트는 변경된 범위를 즉시 반영하며, REST 재조회는 누락 복구와 상태 재검증에 사용했다.</p>
<p>이러한 역할 분리가 실시간성과 데이터 일관성을 함께 확보하는 핵심이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[E2E 테스트와 시각적 회귀 테스트]]></title>
            <link>https://velog.io/@jina___/E2E-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%EC%8B%9C%EA%B0%81%EC%A0%81-%ED%9A%8C%EA%B7%80-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@jina___/E2E-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-%EC%8B%9C%EA%B0%81%EC%A0%81-%ED%9A%8C%EA%B7%80-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 22 Jun 2026 07:29:25 GMT</pubDate>
            <description><![CDATA[<p>이번 주차는 <strong>E2E 테스트</strong>와 <strong>시각적 회귀 테스트</strong>에 대해 공부했다.</p>
<p>큰 흐름은 이렇게 볼 수 있다.</p>
<pre><code class="language-txt">E2E 테스트:
사용자가 실제로 서비스를 쓰는 흐름이 끝까지 잘 동작하는지 확인

시각적 회귀 테스트:
화면 UI가 의도치 않게 깨지거나 바뀌지 않았는지 확인</code></pre>
<p>관련 도구들은 역할이 조금씩 다르다.</p>
<pre><code class="language-txt">Cypress / Playwright → E2E 테스트 도구
Storybook → 컴포넌트를 독립적으로 보여주는 도구
Chromatic → Storybook 화면을 캡처해서 이전 화면과 비교하는 도구</code></pre>
<hr>
<h2 id="1-e2e-테스트란">1. E2E 테스트란?</h2>
<p>E2E는 <strong>End-to-End</strong>의 줄임말이다.</p>
<p>말 그대로 사용자가 서비스를 사용하는 흐름을 처음부터 끝까지 테스트하는 것이다.</p>
<p>예를 들어 로그인 기능이라면 이런 흐름을 테스트한다.</p>
<pre><code class="language-txt">로그인 페이지 접속
이메일 입력
비밀번호 입력
로그인 버튼 클릭
홈 화면으로 이동했는지 확인</code></pre>
<p>즉, E2E 테스트의 핵심은 이거다.</p>
<blockquote>
<p>사용자가 이 기능을 실제로 끝까지 사용할 수 있는가?</p>
</blockquote>
<p>Unit Test가 작은 함수나 컴포넌트 단위를 본다면, E2E 테스트는 실제 브라우저에서 사용자가 하는 행동을 기준으로 본다.</p>
<p>예를 들어 공동구매 서비스라면 이런 흐름이 E2E 테스트 대상이 될 수 있다.</p>
<pre><code class="language-txt">로그인 → 상품 목록 → 상품 상세 → 참여하기 → 수량 선택 → 참여 완료 → 마이페이지 확인</code></pre>
<p>이렇게 핵심 흐름을 자동화해두면 배포 전에 중요한 기능이 깨졌는지 확인할 수 있다.</p>
<hr>
<h2 id="2-e2e-테스트의-장단점">2. E2E 테스트의 장단점</h2>
<p>장점은 실제 사용자 관점에 가장 가깝다는 것이다.</p>
<p>그래서 이런 문제를 잡을 수 있다.</p>
<pre><code class="language-txt">버튼이 실제로 클릭되는지
로그인 후 페이지 이동이 되는지
토큰 저장 후 인증 상태가 유지되는지
폼 제출 후 결과 화면이 잘 뜨는지</code></pre>
<p>하지만 단점도 있다.</p>
<p>브라우저를 실제로 띄우고 페이지 이동, 클릭, 입력을 수행하기 때문에 상대적으로 느리고 무겁다.</p>
<p>또 요소를 너무 구현 세부사항에 의존해서 찾으면 테스트가 쉽게 깨질 수 있다.</p>
<p>예를 들어 className으로 찾는 경우:</p>
<pre><code class="language-ts">cy.get(&#39;.login-button&#39;).click();</code></pre>
<p>디자인 수정으로 className이 바뀌면 기능은 멀쩡한데 테스트만 실패할 수 있다.</p>
<p>그래서 E2E 테스트는 모든 기능에 다 작성하기보다는, 서비스에서 중요한 핵심 흐름 위주로 작성하는 것이 좋다.</p>
<hr>
<h2 id="3-cypress">3. Cypress</h2>
<p>Cypress는 대표적인 E2E 테스트 도구다.</p>
<p>사용자가 브라우저에서 하는 행동을 코드로 작성할 수 있다.</p>
<pre><code class="language-ts">describe(&#39;로그인&#39;, () =&gt; {
  it(&#39;이메일과 비밀번호를 입력하면 로그인된다&#39;, () =&gt; {
    cy.visit(&#39;/login&#39;);

    cy.get(&#39;[data-cy=email-input]&#39;).type(&#39;test@example.com&#39;);
    cy.get(&#39;[data-cy=password-input]&#39;).type(&#39;password123&#39;);
    cy.get(&#39;[data-cy=login-button]&#39;).click();

    cy.url().should(&#39;include&#39;, &#39;/home&#39;);
    cy.contains(&#39;환영합니다&#39;).should(&#39;be.visible&#39;);
  });
});</code></pre>
<p>처음에는 Cypress가 className이나 id로 요소를 찾는 도구처럼 느껴졌는데, 실제로는 className보다 테스트 전용 속성을 쓰는 것이 더 안정적이다.</p>
<pre><code class="language-tsx">&lt;input data-cy=&quot;email-input&quot; /&gt;
&lt;button data-cy=&quot;login-button&quot;&gt;로그인&lt;/button&gt;</code></pre>
<pre><code class="language-ts">cy.get(&#39;[data-cy=email-input]&#39;).type(&#39;test@example.com&#39;);
cy.get(&#39;[data-cy=login-button]&#39;).click();</code></pre>
<p>className은 스타일 변경 때문에 자주 바뀔 수 있지만, <code>data-cy</code>는 테스트를 위한 이름이라 상대적으로 안정적이다.</p>
<hr>
<h2 id="4-playwright">4. Playwright</h2>
<p>Playwright도 E2E 테스트 도구다.</p>
<p>Cypress처럼 실제 브라우저에서 페이지에 접속하고, 클릭하고, 입력하고, 결과를 확인할 수 있다.</p>
<pre><code class="language-ts">import { test, expect } from &#39;@playwright/test&#39;;

test(&#39;로그인할 수 있다&#39;, async ({ page }) =&gt; {
  await page.goto(&#39;/login&#39;);

  await page.getByLabel(&#39;이메일&#39;).fill(&#39;test@example.com&#39;);
  await page.getByLabel(&#39;비밀번호&#39;).fill(&#39;password123&#39;);
  await page.getByRole(&#39;button&#39;, { name: &#39;로그인&#39; }).click();

  await expect(page).toHaveURL(/home/);
  await expect(page.getByText(&#39;환영합니다&#39;)).toBeVisible();
});</code></pre>
<p>Playwright는 <code>getByRole</code>, <code>getByLabel</code>, <code>getByText</code>처럼 사용자가 실제로 인식하는 방식으로 요소를 찾는 API가 잘 제공된다.</p>
<p>그래서 React Testing Library와 문법 느낌이 비슷하게 느껴졌다.</p>
<hr>
<h2 id="5-내가-헷갈렸던-부분">5. 내가 헷갈렸던 부분</h2>
<h3 id="playwright와-react-testing-library는-관련-있는-도구일까">Playwright와 React Testing Library는 관련 있는 도구일까?</h3>
<p>처음에는 Playwright 코드가 React Testing Library와 비슷해서 둘이 관련 있는 도구인가 싶었다.</p>
<p>React Testing Library:</p>
<pre><code class="language-tsx">screen.getByRole(&#39;button&#39;, { name: &#39;로그인&#39; });
screen.getByLabelText(&#39;이메일&#39;);
screen.getByText(&#39;환영합니다&#39;);</code></pre>
<p>Playwright:</p>
<pre><code class="language-ts">await page.getByRole(&#39;button&#39;, { name: &#39;로그인&#39; }).click();
await page.getByLabel(&#39;이메일&#39;).fill(&#39;test@example.com&#39;);
await expect(page.getByText(&#39;환영합니다&#39;)).toBeVisible();</code></pre>
<p>정리하면, 둘이 직접 연결된 도구라서 비슷한 것은 아니다.</p>
<p>둘 다 <strong>사용자 관점으로 테스트하자</strong>는 철학을 가지고 있어서 비슷한 API가 나온 것이다.</p>
<pre><code class="language-txt">Vitest → 테스트 실행기
React Testing Library → React 컴포넌트 테스트 도구
Playwright → 실제 브라우저 기반 E2E 테스트 도구</code></pre>
<p>즉, Playwright가 Vitest와 비슷하다기보다는, Playwright의 요소 찾는 방식이 React Testing Library와 비슷하다고 보는 게 맞다.</p>
<hr>
<h3 id="react-testing-library로-주소창-테스트도-할-수-있을까">React Testing Library로 주소창 테스트도 할 수 있을까?</h3>
<p>React Testing Library는 실제 브라우저를 띄우는 도구가 아니다.</p>
<p>보통 jsdom이라는 가짜 DOM 환경에서 컴포넌트를 테스트한다.</p>
<p>그래서 input 입력, 버튼 클릭, 텍스트 확인은 가능하다.</p>
<pre><code class="language-tsx">render(&lt;LoginForm /&gt;);

await user.type(screen.getByLabelText(&#39;이메일&#39;), &#39;test@example.com&#39;);
await user.click(screen.getByRole(&#39;button&#39;, { name: &#39;로그인&#39; }));

expect(screen.getByText(&#39;로그인 성공&#39;)).toBeInTheDocument();</code></pre>
<p>하지만 실제 주소창 이동, 새로고침, 뒤로가기 같은 브라우저 동작을 테스트하는 도구는 아니다.</p>
<p>React Router의 <code>MemoryRouter</code>를 쓰면 라우팅을 흉내낼 수는 있다.</p>
<pre><code class="language-tsx">render(
  &lt;MemoryRouter initialEntries={[&#39;/login&#39;]}&gt;
    &lt;App /&gt;
  &lt;/MemoryRouter&gt;
);</code></pre>
<p>하지만 이것은 실제 주소창이 바뀌는 것이 아니라, 메모리상에서 라우팅 상태를 흉내내는 것이다.</p>
<p>실제 URL 이동을 확인하려면 Playwright나 Cypress를 써야 한다.</p>
<pre><code class="language-ts">await expect(page).toHaveURL(/home/);</code></pre>
<hr>
<h2 id="6-cypress와-playwright-비교">6. Cypress와 Playwright 비교</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Cypress</th>
<th>Playwright</th>
</tr>
</thead>
<tbody><tr>
<td>역할</td>
<td>E2E 테스트 도구</td>
<td>E2E 테스트 도구</td>
</tr>
<tr>
<td>코드 느낌</td>
<td><code>cy.visit</code>, <code>cy.get</code>, <code>cy.contains</code></td>
<td><code>page.goto</code>, <code>page.getByRole</code>, <code>expect</code></td>
</tr>
<tr>
<td>요소 찾기</td>
<td><code>data-cy</code> 같은 selector를 많이 사용</td>
<td>role, label, text 기반 locator를 많이 사용</td>
</tr>
<tr>
<td>장점</td>
<td>실행 과정을 보기 쉽고 디버깅이 직관적</td>
<td>사용자 관점 locator, 여러 브라우저 테스트에 강함</td>
</tr>
</tbody></table>
<p>둘 중 뭐가 무조건 더 좋다기보다는 팀 상황과 테스트 목적에 따라 선택하면 될 것 같다.</p>
<p>처음 이해할 때는 이렇게 잡았다.</p>
<pre><code class="language-txt">Cypress:
테스트용 이름(data-cy)으로 요소를 안정적으로 찾는 느낌

Playwright:
사용자가 보는 이름(role, label, text)으로 요소를 찾는 느낌</code></pre>
<p>물론 Cypress도 Testing Library를 추가하면 사용자 관점 query를 사용할 수 있다.</p>
<hr>
<h2 id="7-시각적-회귀-테스트란">7. 시각적 회귀 테스트란?</h2>
<p>시각적 회귀 테스트는 UI의 생김새가 의도치 않게 바뀌지 않았는지 확인하는 테스트다.</p>
<p>여기서 회귀는 기존에 잘 되던 것이 다시 망가지는 것을 의미한다.</p>
<p>예를 들어 버튼 스타일을 조금 수정했는데, 그 버튼이 여러 페이지에서 쓰이고 있었다면 예상 못한 화면 깨짐이 생길 수 있다.</p>
<pre><code class="language-txt">버튼 크기가 커짐
카드 레이아웃이 밀림
텍스트가 두 줄로 밀림
모바일 화면에서 요소가 잘림
모달 위치가 어긋남</code></pre>
<p>이런 문제는 일반 테스트 코드로 하나하나 확인하기 어렵다.</p>
<p>그래서 시각적 회귀 테스트는 화면을 스크린샷으로 비교한다.</p>
<pre><code class="language-txt">이전 정상 화면 캡처
현재 화면 캡처
두 화면 비교
차이가 있으면 알려줌</code></pre>
<p>의도한 변경이면 승인하고, 의도하지 않은 변경이면 수정하면 된다.</p>
<hr>
<h2 id="8-storybook">8. Storybook</h2>
<p>Storybook은 컴포넌트를 앱에서 분리해서 독립적으로 볼 수 있게 해주는 도구다.</p>
<p>예를 들어 버튼 컴포넌트를 앱의 특정 페이지까지 이동하지 않고도 따로 확인할 수 있다.</p>
<pre><code class="language-tsx">&lt;Button variant=&quot;primary&quot;&gt;확인&lt;/Button&gt;
&lt;Button variant=&quot;secondary&quot;&gt;취소&lt;/Button&gt;
&lt;Button disabled&gt;비활성화&lt;/Button&gt;</code></pre>
<p>Storybook에서는 컴포넌트의 상태 하나하나를 <code>story</code>로 만든다.</p>
<pre><code class="language-tsx">export const Default = {
  args: {
    children: &#39;확인&#39;,
  },
};

export const Disabled = {
  args: {
    children: &#39;비활성화&#39;,
    disabled: true,
  },
};</code></pre>
<p>이렇게 해두면 버튼의 기본 상태, 비활성화 상태 등을 독립적으로 확인할 수 있다.</p>
<p>Storybook 자체는 테스트 도구라기보다는 UI 컴포넌트를 개발하고 문서화하는 도구에 가깝다.</p>
<p>하지만 컴포넌트 상태를 story로 정리해두면, 이후 시각적 회귀 테스트의 기준으로 활용할 수 있다.</p>
<hr>
<h2 id="9-chromatic">9. Chromatic</h2>
<p>Chromatic은 Storybook과 함께 사용하는 시각적 회귀 테스트 도구다.</p>
<p>Storybook에 등록된 story들을 캡처하고, 이전 화면과 현재 화면을 비교한다.</p>
<pre><code class="language-txt">Storybook → 컴포넌트 상태를 story로 정리
Chromatic → story 화면을 캡처해서 이전 화면과 비교</code></pre>
<p>예를 들어 Button story가 있다고 하면:</p>
<pre><code class="language-txt">Button / Default
Button / Disabled
Button / Loading</code></pre>
<p>Chromatic은 각각의 화면을 캡처해두고, PR에서 UI가 바뀌면 이전 화면과 비교해준다.</p>
<p>의도한 변경이면 승인하고, 의도하지 않은 변경이면 수정하면 된다.</p>
<p>비유하면 이렇게 이해했다.</p>
<pre><code class="language-txt">Storybook = 컴포넌트 전시장
Chromatic = 전시장 사진을 찍고 이전 사진과 비교하는 도구</code></pre>
<hr>
<h2 id="10-e2e-테스트와-시각적-회귀-테스트의-차이">10. E2E 테스트와 시각적 회귀 테스트의 차이</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>E2E 테스트</th>
<th>시각적 회귀 테스트</th>
</tr>
</thead>
<tbody><tr>
<td>관심사</td>
<td>기능 흐름</td>
<td>화면 생김새</td>
</tr>
<tr>
<td>질문</td>
<td>사용자가 기능을 끝까지 사용할 수 있는가?</td>
<td>UI가 의도치 않게 바뀌지 않았는가?</td>
</tr>
<tr>
<td>대표 도구</td>
<td>Cypress, Playwright</td>
<td>Storybook, Chromatic</td>
</tr>
<tr>
<td>검증 방식</td>
<td>클릭, 입력, 이동, 결과 확인</td>
<td>스크린샷 비교</td>
</tr>
</tbody></table>
<p>예를 들어 로그인 버튼을 기준으로 보면:</p>
<pre><code class="language-txt">E2E 테스트:
로그인 버튼을 누르면 실제로 로그인이 되는가?

시각적 회귀 테스트:
로그인 버튼의 크기, 색상, 위치가 의도치 않게 바뀌지 않았는가?</code></pre>
<p>둘은 대체 관계가 아니라 보완 관계다.</p>
<hr>
<h2 id="11-전체-정리">11. 전체 정리</h2>
<p>이번 주차 내용을 정리하면 이렇게 볼 수 있다.</p>
<pre><code class="language-txt">Vitest + React Testing Library:
컴포넌트가 사용자 행동에 잘 반응하는지 테스트

Cypress / Playwright:
실제 브라우저에서 사용자의 핵심 흐름을 테스트

Storybook:
컴포넌트를 독립적으로 보여주고 상태별로 정리

Chromatic:
Storybook 화면을 이전 화면과 비교해서 UI 변경 감지</code></pre>
<p>테스트마다 보는 관점이 다르다.</p>
<pre><code class="language-txt">Unit Test:
작은 코드가 맞는지

Integration Test:
여러 조각이 함께 잘 동작하는지

E2E Test:
사용자가 실제로 기능을 끝까지 쓸 수 있는지

Visual Regression Test:
사용자가 보는 화면이 깨지지 않았는지</code></pre>
<p>결국 이번 주차의 핵심은 이거다.</p>
<blockquote>
<p>E2E 테스트는 기능 흐름을 확인하고,
시각적 회귀 테스트는 화면의 생김새를 확인한다.</p>
</blockquote>
<p>둘 다 사용자가 실제로 마주하는 경험을 안정적으로 만들기 위한 테스트라고 이해하면 될 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Unit Test와 통합 테스트, 그리고 Vitest / React Testing Library / MSW]]></title>
            <link>https://velog.io/@jina___/Unit-Test%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Vitest-React-Testing-Library-MSW</link>
            <guid>https://velog.io/@jina___/Unit-Test%EC%99%80-%ED%86%B5%ED%95%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Vitest-React-Testing-Library-MSW</guid>
            <pubDate>Mon, 15 Jun 2026 05:51:16 GMT</pubDate>
            <description><![CDATA[<p>성능 최적화가 <strong>사용자 경험을 더 빠르고 부드럽게 만드는 것</strong>이라면, 테스트 코드는 <strong>내가 만든 기능이 의도대로 동작하는지 확인하고, 이후에 코드가 망가지는 것을 막는 것</strong>에 가깝다.</p>
<p>테스트 코드의 핵심은 이것이다.</p>
<blockquote>
<p>사용자가 어떤 행동을 했을 때, 화면이나 결과가 기대한 대로 나오는지 자동으로 확인하는 것</p>
</blockquote>
<hr>
<h2 id="테스트-코드란">테스트 코드란?</h2>
<p>테스트 코드는 사람이 직접 브라우저에서 하나하나 눌러보지 않아도, 코드로 기능 동작을 검증하는 것이다.</p>
<p>예를 들면 이런 것들을 확인할 수 있다.</p>
<pre><code class="language-tsx">로그인 버튼을 누르면 로그인 요청이 가는가?
검색어를 입력하고 Enter를 누르면 검색 결과가 보이는가?
API 요청이 실패하면 에러 메시지가 뜨는가?
좋아요 버튼을 누르면 좋아요 수가 증가하는가?</code></pre>
<p>아주 단순하게 보면 테스트 코드는 이런 구조를 가진다.</p>
<pre><code class="language-ts">expect(실제 결과).toBe(기대한 결과);</code></pre>
<p>예를 들어 두 숫자를 더하는 함수가 있다고 해보자.</p>
<pre><code class="language-ts">function add(a: number, b: number) {
  return a + b;
}</code></pre>
<p>이 함수가 제대로 동작하는지 테스트하면 이렇게 작성할 수 있다.</p>
<pre><code class="language-ts">import { expect, test } from &#39;vitest&#39;;

test(&#39;두 숫자를 더한다&#39;, () =&gt; {
  expect(add(1, 2)).toBe(3);
});</code></pre>
<p><code>add(1, 2)</code>의 결과가 <code>3</code>이면 테스트가 통과하고, 아니면 실패한다.</p>
<hr>
<h2 id="unit-test">Unit Test</h2>
<p>Unit Test는 <strong>단위 테스트</strong>라고 부른다.</p>
<p>말 그대로 가장 작은 단위의 기능을 테스트하는 것이다.</p>
<p>프론트엔드에서는 보통 이런 것들이 단위 테스트 대상이 된다.</p>
<pre><code class="language-tsx">유틸 함수
커스텀 훅
작은 컴포넌트
상태 변경 로직
계산 로직
조건부 렌더링</code></pre>
<p>예를 들어 가격을 원화 형식으로 바꿔주는 함수가 있다고 해보자.</p>
<pre><code class="language-ts">export const formatPrice = (price: number) =&gt; {
  return `${price.toLocaleString()}원`;
};</code></pre>
<p>이 함수는 다음과 같이 테스트할 수 있다.</p>
<pre><code class="language-ts">import { describe, expect, test } from &#39;vitest&#39;;
import { formatPrice } from &#39;./formatPrice&#39;;

describe(&#39;formatPrice&#39;, () =&gt; {
  test(&#39;숫자를 원화 형식으로 변환한다&#39;, () =&gt; {
    expect(formatPrice(12000)).toBe(&#39;12,000원&#39;);
  });
});</code></pre>
<p>여기서 중요한 점은 <strong>작은 기능 하나만 고립해서 확인한다</strong>는 것이다.</p>
<p>이 테스트가 실패하면 거의 바로 알 수 있다.</p>
<pre><code class="language-tsx">가격 포맷팅 함수에 문제가 있구나.</code></pre>
<p>그래서 단위 테스트는 빠르고, 실패 원인을 찾기 쉽다.</p>
<p>하지만 단위 테스트만으로는 실제 기능 전체가 잘 동작하는지 알기 어렵다.
함수 하나, 컴포넌트 하나는 잘 동작해도 실제 페이지에서 API 연결이나 사용자 흐름이 깨질 수 있기 때문이다.</p>
<p>그래서 통합 테스트가 필요하다.</p>
<hr>
<h2 id="통합-테스트">통합 테스트</h2>
<p>통합 테스트는 여러 단위가 함께 연결되었을 때 제대로 동작하는지 확인하는 테스트다.</p>
<p>프론트엔드에서는 보통 다음과 같은 흐름을 테스트한다.</p>
<pre><code class="language-tsx">컴포넌트 렌더링
사용자 입력
버튼 클릭
API 요청
응답에 따른 UI 변경</code></pre>
<p>예를 들어 검색 기능이 있다고 해보자.</p>
<p>단위 테스트에서는 이런 것들을 볼 수 있다.</p>
<pre><code class="language-tsx">검색어 상태가 잘 바뀌는가?
검색 버튼 컴포넌트가 잘 렌더링되는가?</code></pre>
<p>하지만 통합 테스트에서는 실제 사용자 흐름에 더 가깝게 본다.</p>
<pre><code class="language-tsx">사용자가 검색어를 입력한다.
검색 버튼을 누른다.
검색 API 요청이 발생한다.
검색 결과가 화면에 나타난다.</code></pre>
<p>즉, 통합 테스트는 이런 질문에 답하는 테스트다.</p>
<blockquote>
<p>이 기능이 실제 사용자 입장에서 제대로 동작하는가?</p>
</blockquote>
<p>프론트엔드 테스트에서는 단순히 함수의 반환값만 확인하는 것이 아니라, 사용자가 보는 화면과 행동 흐름을 기준으로 테스트하는 경우가 많다.</p>
<hr>
<h2 id="vitest">Vitest</h2>
<p>Vitest는 테스트를 실행해주는 도구다.</p>
<p>쉽게 말하면 테스트를 돌리는 엔진 같은 역할을 한다.</p>
<p>Vitest가 해주는 일은 다음과 같다.</p>
<pre><code class="language-tsx">테스트 파일 찾기
테스트 실행하기
성공 / 실패 결과 알려주기
test, describe, expect 같은 문법 제공하기
mock 함수 제공하기</code></pre>
<p>React + Vite 프로젝트에서는 Jest 대신 Vitest를 사용하는 경우가 많다.
Vite 기반 프로젝트와 잘 맞고, 설정도 비교적 간단하기 때문이다.</p>
<p>Vitest의 기본 문법은 다음과 같다.</p>
<pre><code class="language-ts">import { describe, expect, test } from &#39;vitest&#39;;

describe(&#39;계산 테스트&#39;, () =&gt; {
  test(&#39;1 + 2는 3이다&#39;, () =&gt; {
    expect(1 + 2).toBe(3);
  });
});</code></pre>
<p>각각의 의미는 다음과 같다.</p>
<pre><code class="language-tsx">describe: 관련 테스트들을 묶는 그룹
test 또는 it: 실제 테스트 하나
expect: 결과를 검증하는 함수
toBe, toEqual, toHaveTextContent: 기대값과 비교하는 matcher</code></pre>
<p>Vitest는 테스트의 종류라기보다는, 테스트를 실행하는 기반 도구라고 이해하면 된다.</p>
<p>즉, 유틸 함수 테스트, 컴포넌트 테스트, 통합 테스트 모두 Vitest 위에서 실행될 수 있다.</p>
<hr>
<h2 id="react-testing-library">React Testing Library</h2>
<p>React Testing Library는 React 컴포넌트를 테스트할 때 사용하는 도구다.</p>
<p>Vitest가 테스트를 실행하는 도구라면, React Testing Library는 컴포넌트를 화면에 렌더링하고, 사용자의 행동처럼 테스트할 수 있게 도와주는 도구다.</p>
<p>예를 들어 버튼 컴포넌트가 있다고 해보자.</p>
<pre><code class="language-tsx">interface ButtonProps {
  onClick: () =&gt; void;
}

const Button = ({ onClick }: ButtonProps) =&gt; {
  return &lt;button onClick={onClick}&gt;저장&lt;/button&gt;;
};

export default Button;</code></pre>
<p>이 버튼은 <code>onClick</code> 함수를 props로 받아서, 사용자가 버튼을 클릭했을 때 실행한다.</p>
<p>이 버튼을 테스트하면 다음과 같이 작성할 수 있다.</p>
<pre><code class="language-tsx">import { render, screen } from &#39;@testing-library/react&#39;;
import userEvent from &#39;@testing-library/user-event&#39;;
import { expect, test, vi } from &#39;vitest&#39;;
import Button from &#39;./Button&#39;;

test(&#39;저장 버튼을 클릭하면 onClick이 실행된다&#39;, async () =&gt; {
  // 호출 여부를 기록할 수 있는 가짜 함수
  const handleClick = vi.fn();

  // Button 컴포넌트를 테스트 화면에 렌더링한다.
  // 이때 onClick props로 handleClick을 넘긴다.
  render(&lt;Button onClick={handleClick} /&gt;);

  // 화면에서 사용자가 보는 기준으로 &quot;저장&quot; 버튼을 찾는다.
  const button = screen.getByRole(&#39;button&#39;, { name: &#39;저장&#39; });

  // 사용자가 버튼을 클릭한 것처럼 동작시킨다.
  await userEvent.click(button);

  // 버튼 클릭 후 handleClick 함수가 호출되었는지 확인한다.
  expect(handleClick).toHaveBeenCalled();
});</code></pre>
<p>즉, 이 테스트는 <strong>“저장 버튼을 클릭하면 <code>onClick</code>으로 넘긴 함수가 실행되는가?”</strong>를 확인하는 테스트다.</p>
<p>여기서 중요한 점은 <code>className</code>이나 내부 state 이름을 검사하지 않는다는 것이다.</p>
<p>예를 들어 이런 방식은 좋지 않은 테스트에 가깝다.</p>
<pre><code class="language-tsx">expect(container.querySelector(&#39;.save-button&#39;)).toBeTruthy();</code></pre>
<p>왜냐하면 사용자는 <code>.save-button</code>이라는 클래스 이름을 보지 않기 때문이다.</p>
<p>더 좋은 방식은 사용자가 실제로 보는 요소를 기준으로 찾는 것이다.</p>
<pre><code class="language-tsx">screen.getByRole(&#39;button&#39;, { name: &#39;저장&#39; });</code></pre>
<p>사용자는 “저장”이라고 적힌 버튼을 본다.</p>
<p>그래서 React Testing Library는 내부 구현보다 사용자에게 보이는 결과를 중심으로 테스트하게 도와준다.</p>
<hr>
<h2 id="msw">MSW</h2>
<p>MSW는 Mock Service Worker의 줄임말이다.</p>
<p>프론트엔드 테스트에서 API 요청을 실제 서버로 보내면 여러 문제가 생길 수 있다.</p>
<pre><code class="language-tsx">서버가 꺼져 있으면 테스트가 실패한다.
네트워크 상태에 따라 테스트 속도가 달라진다.
DB 데이터 상태에 따라 결과가 달라질 수 있다.
백엔드 API가 아직 완성되지 않으면 테스트하기 어렵다.</code></pre>
<p>그래서 테스트에서는 실제 서버에 요청하지 않고, MSW를 사용해 API 응답을 가짜로 만들어둔다.</p>
<p>예를 들어 게시글 목록 API가 있다고 해보자.</p>
<pre><code class="language-ts">GET /posts</code></pre>
<p>MSW로는 다음과 같이 가짜 응답을 만들 수 있다.</p>
<pre><code class="language-ts">import { http, HttpResponse } from &#39;msw&#39;;

export const handlers = [
  http.get(&#39;/posts&#39;, () =&gt; {
    return HttpResponse.json([
      { id: 1, title: &#39;첫 번째 게시글&#39; },
      { id: 2, title: &#39;두 번째 게시글&#39; },
    ]);
  }),
];</code></pre>
<p>이렇게 설정해두면 테스트 중에 <code>/posts</code> 요청이 발생했을 때 실제 서버로 가지 않고, MSW가 요청을 가로채서 위 데이터를 응답해준다.</p>
<p>즉, MSW를 사용하면 API가 있는 기능도 안정적으로 테스트할 수 있다.</p>
<hr>
<h2 id="vitest-react-testing-library-msw의-관계">Vitest, React Testing Library, MSW의 관계</h2>
<p>처음에는 이 세 가지가 각각 따로 느껴졌는데, 역할을 나눠보면 훨씬 이해하기 쉽다.</p>
<pre><code class="language-tsx">Vitest
→ 테스트를 실행하는 도구

React Testing Library
→ React 컴포넌트를 사용자 관점으로 테스트하는 도구

MSW
→ API 요청을 실제 서버 없이 테스트할 수 있게 가짜 응답을 제공하는 도구</code></pre>
<p>예를 들어 게시글 목록 페이지를 테스트한다고 하면 흐름은 다음과 같다.</p>
<pre><code class="language-tsx">Vitest가 테스트를 실행한다.
React Testing Library가 게시글 목록 페이지를 렌더링한다.
컴포넌트 내부에서 GET /posts 요청이 발생한다.
MSW가 요청을 가로채서 가짜 게시글 데이터를 응답한다.
응답 데이터가 화면에 렌더링된다.
React Testing Library로 화면에 게시글 제목이 보이는지 확인한다.</code></pre>
<p>정리하면 이런 흐름이다.</p>
<pre><code class="language-tsx">테스트 시작
↓
컴포넌트 렌더링
↓
API 요청 발생
↓
MSW가 가짜 응답 반환
↓
응답 데이터가 화면에 렌더링
↓
화면에 기대한 내용이 있는지 확인</code></pre>
<hr>
<h2 id="통합-테스트-예시">통합 테스트 예시</h2>
<p>게시글 목록 컴포넌트가 있다고 해보자.</p>
<pre><code class="language-tsx">import { useEffect, useState } from &#39;react&#39;;

interface Post {
  id: number;
  title: string;
}

const PostList = () =&gt; {
  const [posts, setPosts] = useState&lt;Post[]&gt;([]);

  useEffect(() =&gt; {
    fetch(&#39;/posts&#39;)
      .then((res) =&gt; res.json())
      .then((data) =&gt; setPosts(data));
  }, []);

  return (
    &lt;ul&gt;
      {posts.map((post) =&gt; (
        &lt;li key={post.id}&gt;{post.title}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
};

export default PostList;</code></pre>
<p>이 컴포넌트는 렌더링되자마자 <code>/posts</code> API를 호출하고, 응답받은 게시글을 화면에 보여준다.</p>
<p>테스트는 다음과 같이 작성할 수 있다.</p>
<pre><code class="language-tsx">import { render, screen } from &#39;@testing-library/react&#39;;
import { expect, test } from &#39;vitest&#39;;
import PostList from &#39;./PostList&#39;;

test(&#39;게시글 목록을 불러와 화면에 보여준다&#39;, async () =&gt; {
  render(&lt;PostList /&gt;);

  expect(await screen.findByText(&#39;첫 번째 게시글&#39;)).toBeInTheDocument();
  expect(await screen.findByText(&#39;두 번째 게시글&#39;)).toBeInTheDocument();
});</code></pre>
<p>여기서 <code>findByText</code>를 사용하는 이유는 게시글 데이터가 API 응답 이후에 화면에 나타나기 때문이다.</p>
<p>즉, 처음 렌더링되는 순간에는 게시글이 없고, 비동기 요청이 끝난 뒤 화면에 나타난다.
그래서 <code>await screen.findByText()</code>로 요소가 나타날 때까지 기다린다.</p>
<hr>
<h2 id="getby-findby-queryby-차이">getBy, findBy, queryBy 차이</h2>
<p>React Testing Library를 공부할 때 <code>getBy</code>, <code>findBy</code>, <code>queryBy</code>의 차이를 아는 것이 중요하다.</p>
<h3 id="getby">getBy</h3>
<p><code>getBy</code>는 지금 당장 화면에 있어야 하는 요소를 찾을 때 사용한다.</p>
<pre><code class="language-tsx">screen.getByText(&#39;로그인&#39;);</code></pre>
<p>요소가 없으면 바로 에러가 발생한다.</p>
<p>그래서 처음부터 화면에 보여야 하는 요소를 확인할 때 적합하다.</p>
<pre><code class="language-tsx">로그인 버튼이 처음부터 보여야 한다.
제목이 바로 렌더링되어야 한다.</code></pre>
<hr>
<h3 id="findby">findBy</h3>
<p><code>findBy</code>는 비동기적으로 나중에 나타나는 요소를 찾을 때 사용한다.</p>
<pre><code class="language-tsx">await screen.findByText(&#39;검색 결과&#39;);</code></pre>
<p>API 요청 후 화면에 나타나는 데이터나, 특정 사용자 행동 이후 렌더링되는 요소를 확인할 때 적합하다.</p>
<pre><code class="language-tsx">로딩 후 게시글이 나타난다.
검색 후 결과가 나타난다.
로그인 성공 후 사용자 이름이 나타난다.</code></pre>
<hr>
<h3 id="queryby">queryBy</h3>
<p><code>queryBy</code>는 요소가 없어야 하는지 확인할 때 사용한다.</p>
<pre><code class="language-tsx">expect(screen.queryByText(&#39;에러&#39;)).not.toBeInTheDocument();</code></pre>
<p><code>getByText</code>는 요소가 없으면 바로 에러를 던지기 때문에, “없는지 확인”할 때는 <code>queryBy</code>를 사용하는 것이 좋다.</p>
<hr>
<h2 id="좋은-테스트는-무엇을-확인할까">좋은 테스트는 무엇을 확인할까?</h2>
<p>테스트는 내부 구현보다 결과를 확인하는 것이 좋다.</p>
<p>예를 들어 이런 테스트는 구현 방식에 너무 의존한다.</p>
<pre><code class="language-tsx">state가 true로 바뀌었는가?
setIsOpen이 호출되었는가?
특정 className이 붙었는가?</code></pre>
<p>물론 경우에 따라 필요할 수도 있지만, 프론트엔드 테스트에서는 보통 사용자가 보는 결과를 기준으로 작성하는 것이 더 좋다.</p>
<p>예를 들어 다음과 같은 테스트가 더 자연스럽다.</p>
<pre><code class="language-tsx">버튼을 클릭하면 모달이 열린다.
입력창에 값을 입력하면 화면에 반영된다.
API 요청 성공 시 게시글 목록이 보인다.
API 요청 실패 시 에러 메시지가 보인다.</code></pre>
<p>즉, 테스트를 작성할 때는 이렇게 생각하면 된다.</p>
<blockquote>
<p>사용자가 어떤 행동을 했을 때, 화면이 어떻게 바뀌어야 하는가?</p>
</blockquote>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-tsx">❌ isOpen state가 true인지 확인한다.
⭕ 버튼을 클릭했을 때 모달 텍스트가 화면에 보이는지 확인한다.

❌ 내부 함수가 호출되었는지만 확인한다.
⭕ 제출 버튼을 눌렀을 때 성공 메시지가 보이는지 확인한다.

❌ 특정 className이 있는지만 확인한다.
⭕ 사용자가 볼 수 있는 텍스트나 역할을 기준으로 확인한다.</code></pre>
<hr>
<h2 id="이번-주차-핵심-정리">이번 주차 핵심 정리</h2>
<p>이번 주차에서 공부할 내용은 크게 다음과 같이 정리할 수 있다.</p>
<pre><code class="language-tsx">Unit Test
→ 작은 함수나 컴포넌트 하나가 잘 동작하는지 확인하는 테스트

통합 테스트
→ 여러 코드가 연결된 실제 기능 흐름이 잘 동작하는지 확인하는 테스트

Vitest
→ 테스트를 실행하는 도구

React Testing Library
→ React 컴포넌트를 사용자 관점에서 테스트하는 도구

MSW
→ API 요청을 실제 서버 없이 가짜 응답으로 테스트하게 해주는 도구</code></pre>
<p>결국 프론트엔드 테스트의 목적은 단순히 코드 줄을 많이 검사하는 것이 아니다.</p>
<p>내가 만든 기능이 실제 사용자 입장에서 의도대로 동작하는지 확인하고, 이후에 코드를 수정하더라도 기존 기능이 깨지지 않았다는 확신을 얻기 위한 것이다.</p>
<p>처음에는 테스트 코드가 낯설고 어렵게 느껴지지만, 기준을 이렇게 잡으면 조금 더 이해하기 쉬워진다.</p>
<blockquote>
<p>내부 구현을 테스트하기보다, 사용자가 보는 화면과 행동 흐름을 테스트한다.</p>
</blockquote>
<p>이 관점으로 보면 Unit Test, 통합 테스트, Vitest, React Testing Library, MSW가 각각 왜 필요한지 조금씩 연결된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[성능 최적화, 그래서 도대체 어디서부터 시작해야 할까?]]></title>
            <link>https://velog.io/@jina___/%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jina___/%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 12 Jun 2026 10:29:13 GMT</pubDate>
            <description><![CDATA[<p>프론트엔드 성능 최적화에 대해 공부하다 보면 자주 등장하는 말들이 있다.</p>
<ul>
<li>이미지 용량을 줄이자.</li>
<li>폰트를 최적화하자.</li>
<li>코드 스플리팅을 적용하자.</li>
<li>불필요한 리렌더링을 줄이자.</li>
<li><code>requestAnimationFrame</code>을 활용하자.</li>
<li>Lighthouse 점수를 개선하자.</li>
</ul>
<p>분명 다 중요한 이야기다. 그런데 막상 실제 프로젝트에 적용하려고 하면 갑자기 막막해진다.</p>
<blockquote>
<p>이미지가 문제인지, JavaScript가 문제인지, 렌더링이 문제인지 어떻게 알지?
Lighthouse에서 LCP가 느리다고 하는데, 그래서 어느 코드를 수정해야 하지?
Performance 탭을 열기는 했는데, 복잡한 그래프 중에서 무엇을 봐야 하지?</p>
</blockquote>
<p>나 역시 합동세미나에서 직접 성능 최적화를 적용해보려고 하면서 같은 문제를 겪었다.
네트워크 탭은 그나마 익숙했다. 어떤 이미지가 큰지, 어떤 요청이 늦는지는 비교적 쉽게 확인할 수 있었다. 하지만 그것만으로는 부족했다. 화면에 리소스가 늦게 도착하는 문제는 찾을 수 있어도, 리소스가 이미 도착했는데 왜 화면이 늦게 그려지는지는 알기 어려웠다.</p>
<p>그래서 이번에는 최적화 기법을 하나씩 정리하기보다, <strong>개발이 끝난 서비스에서 실제로 병목을 찾고 수정 방향을 정하는 흐름</strong>을 정리해보려고 한다.</p>
<hr>
<h1 id="최적화는-코드-수정이-아니라-디버깅이다">최적화는 코드 수정이 아니라 디버깅이다</h1>
<p>처음에는 성능 최적화를 “좋다고 알려진 기법을 프로젝트에 적용하는 일”이라고 생각했다.</p>
<p>이미지를 WebP로 바꾸고, <code>React.memo</code>를 붙이고, lazy loading을 적용하면 성능이 좋아질 것 같았다. 물론 실제로 도움이 될 수도 있다. 하지만 측정 없이 적용하면 무엇이 효과가 있었는지 알 수 없고, 심지어 현재 문제와 전혀 상관없는 작업을 하고 있을 수도 있다.</p>
<p>성능 최적화는 결국 다음 과정을 반복하는 일에 가깝다.</p>
<pre><code class="language-text">느린 순간을 정한다
→ 동일한 조건에서 측정한다
→ 병목이 발생한 구간을 찾는다
→ 원인을 추측한다
→ 하나를 수정한다
→ 같은 조건에서 다시 측정한다</code></pre>
<p>중요한 것은 무작정 많이 고치는 것이 아니다.
<strong>어떤 문제를 해결하기 위해 무엇을 수정했고, 실제로 어떤 변화가 있었는지 확인하는 것</strong>이다.</p>
<hr>
<h1 id="1-가장-먼저-할-일-언제-느린가를-정하기">1. 가장 먼저 할 일: “언제 느린가?”를 정하기</h1>
<p>Performance 탭을 열자마자 복잡한 그래프를 읽으려고 하면 어렵다.
그 전에 먼저 사용자가 느끼는 문제를 구체적으로 정해야 한다.</p>
<p>성능 문제는 크게 두 종류로 나눌 수 있다.</p>
<h2 id="첫-화면이-늦게-뜨는-경우">첫 화면이 늦게 뜨는 경우</h2>
<p>페이지에 처음 들어갔을 때 주요 콘텐츠가 늦게 보이는 문제다.</p>
<p>예를 들면 다음과 같다.</p>
<ul>
<li>흰 화면이 오래 유지된다.</li>
<li>제목은 보이는데 대표 이미지가 한참 뒤에 나타난다.</li>
<li>로딩 화면이 오래 유지된다.</li>
<li>화면의 가장 중요한 콘텐츠가 늦게 등장한다.</li>
</ul>
<p>이때는 주로 다음을 확인한다.</p>
<ul>
<li>FCP</li>
<li>LCP</li>
<li>Network waterfall</li>
<li>초기 렌더링을 막는 작업</li>
</ul>
<h2 id="화면은-떴지만-사용-중에-버벅이는-경우">화면은 떴지만 사용 중에 버벅이는 경우</h2>
<p>페이지 로딩은 끝났는데 클릭, 검색, 필터링, 스크롤, 애니메이션에서 문제가 발생하는 경우다.</p>
<p>예를 들면 다음과 같다.</p>
<ul>
<li>검색어를 입력할 때마다 입력이 끊긴다.</li>
<li>필터를 누르면 화면이 늦게 바뀐다.</li>
<li>모달을 열 때 버벅인다.</li>
<li>스크롤이 끊긴다.</li>
<li>애니메이션이 부드럽지 않다.</li>
</ul>
<p>이때는 주로 다음을 확인한다.</p>
<ul>
<li>INP</li>
<li>Long Task</li>
<li>Main Thread</li>
<li>Scripting / Rendering / Painting</li>
<li>React Profiler</li>
<li>Frames</li>
</ul>
<p>처음부터 모든 문제를 한 번에 해결하려고 하면 안 된다.
<strong>느린 장면 하나를 정하고, 그 장면만 기록하는 것</strong>이 중요하다.</p>
<hr>
<h1 id="2-측정-조건을-먼저-고정하기">2. 측정 조건을 먼저 고정하기</h1>
<p>성능을 비교하려면 수정 전과 수정 후의 조건이 최대한 같아야 한다.</p>
<p>예를 들어 초기 로딩을 확인하고 싶다면 다음과 같은 시나리오를 고정할 수 있다.</p>
<pre><code class="language-text">1. 배포된 페이지를 연다.
2. DevTools를 켠다.
3. 캐시를 비운다.
4. 새로고침과 함께 Performance 기록을 시작한다.
5. 첫 화면의 주요 콘텐츠가 나타나면 기록을 종료한다.</code></pre>
<p>사용자 인터랙션을 확인하고 싶다면 다음처럼 기록한다.</p>
<pre><code class="language-text">1. 페이지를 먼저 로딩한다.
2. Performance 기록을 시작한다.
3. 느리다고 느낀 행동을 한 번만 수행한다.
4. 바로 기록을 종료한다.</code></pre>
<p>여기서 중요한 점은 행동을 너무 많이 하지 않는 것이다.</p>
<p>검색도 하고, 스크롤도 하고, 필터도 누르고, 모달도 열면 Performance 탭에 너무 많은 정보가 쌓인다. 그러면 어느 구간이 문제인지 판단하기가 더 어려워진다.</p>
<p>처음에는 정말 단순하게 기록하는 편이 좋다.</p>
<pre><code class="language-text">검색어 한 글자 입력하기
필터 버튼 한 번 누르기
스크롤 한 번 내리기
페이지 한 번 새로고침하기</code></pre>
<p>이 정도면 충분하다.</p>
<hr>
<h1 id="3-lighthouse는-출발점으로만-사용하기">3. Lighthouse는 출발점으로만 사용하기</h1>
<p>성능 최적화를 시작할 때 가장 먼저 Lighthouse를 돌리는 경우가 많다.</p>
<p>Lighthouse는 현재 페이지의 성능 상태를 빠르게 확인하기 좋다.
FCP, LCP, CLS 등의 지표를 보여주고, 개선이 필요해 보이는 항목도 알려준다.</p>
<p>하지만 Lighthouse만 보고 바로 코드를 수정하기는 어렵다.</p>
<p>예를 들어 다음과 같은 결과를 받았다고 해보자.</p>
<pre><code class="language-text">LCP가 느립니다.</code></pre>
<p>이것만으로는 아직 아무것도 알 수 없다.</p>
<ul>
<li>대표 이미지가 너무 큰 것인지</li>
<li>이미지 요청이 늦게 시작된 것인지</li>
<li>서버 응답이 늦은 것인지</li>
<li>JavaScript 실행이 끝나지 않아서 화면에 늦게 그려진 것인지</li>
</ul>
<p>모두 가능하다.</p>
<p>그래서 Lighthouse는 정답을 알려주는 도구라기보다, <strong>어느 방향을 먼저 조사해야 하는지 알려주는 출발점</strong>에 가깝다.</p>
<p>나는 이렇게 생각하면 가장 이해가 쉬웠다.</p>
<blockquote>
<p>Lighthouse는 범인을 잡아주는 도구가 아니라,
어느 사건부터 수사해야 하는지 알려주는 도구다.</p>
</blockquote>
<hr>
<h1 id="4-첫-화면이-느리다면-lcp부터-추적하기">4. 첫 화면이 느리다면 LCP부터 추적하기</h1>
<p>초기 로딩 문제를 살펴볼 때 가장 먼저 확인하기 좋은 지표는 LCP다.</p>
<p>LCP는 화면 안에서 가장 큰 콘텐츠가 실제로 표시되는 시점을 의미한다.
사용자가 페이지에 들어왔을 때 가장 중요하게 인식하는 콘텐츠가 언제 보였는지를 확인할 수 있다.</p>
<p>예를 들면 다음과 같은 요소가 LCP가 될 수 있다.</p>
<ul>
<li>메인 배너 이미지</li>
<li>게시글 대표 이미지</li>
<li>큰 제목 텍스트</li>
<li>첫 화면의 큰 카드</li>
<li>히어로 영역 이미지</li>
</ul>
<h2 id="performance-탭에서-확인하는-순서">Performance 탭에서 확인하는 순서</h2>
<pre><code class="language-text">1. Performance 탭을 연다.
2. 새로고침과 함께 기록한다.
3. 기록 결과에서 LCP 시점을 찾는다.
4. LCP 항목을 클릭한다.
5. 어떤 요소가 LCP element인지 확인한다.</code></pre>
<p>가장 먼저 해야 하는 일은 단순하다.</p>
<blockquote>
<p>무엇이 늦게 보였는지 확인하기</p>
</blockquote>
<p>이미지가 문제인지, 텍스트가 문제인지도 모르는 상태에서 무작정 이미지 최적화를 시작하면 안 된다.</p>
<hr>
<h1 id="5-lcp가-느린-이유를-네-구간으로-나누기">5. LCP가 느린 이유를 네 구간으로 나누기</h1>
<p>LCP element를 찾았다면, 이제 LCP가 늦어진 이유를 조금 더 구체적으로 나눠볼 수 있다.</p>
<p>LCP는 크게 다음 네 구간으로 나누어 생각할 수 있다.</p>
<table>
<thead>
<tr>
<th>구간</th>
<th>의미</th>
<th>확인할 방향</th>
</tr>
</thead>
<tbody><tr>
<td>TTFB</td>
<td>HTML의 첫 응답을 받기까지 걸린 시간</td>
<td>서버 응답, 캐시, CDN</td>
</tr>
<tr>
<td>Resource load delay</td>
<td>HTML을 받은 뒤 LCP 리소스 요청이 시작되기까지 걸린 시간</td>
<td>요청 발견 시점, 우선순위</td>
</tr>
<tr>
<td>Resource load duration</td>
<td>리소스를 실제로 다운로드하는 시간</td>
<td>이미지 크기, 포맷, 네트워크</td>
</tr>
<tr>
<td>Element render delay</td>
<td>리소스를 다 받았지만 화면에 그려지기까지 기다린 시간</td>
<td>JavaScript, CSS, 렌더링 작업</td>
</tr>
</tbody></table>
<p>이렇게 나누면 막연했던 LCP 문제가 조금씩 구체적으로 보이기 시작한다.</p>
<hr>
<h2 id="경우-1-리소스-다운로드-자체가-오래-걸린다">경우 1. 리소스 다운로드 자체가 오래 걸린다</h2>
<p>LCP element가 큰 이미지라고 해보자.</p>
<p>Network 탭에서 해당 이미지를 찾아보니 파일 크기가 크고 다운로드 시간도 길다.
이 경우에는 리소스 크기 자체를 줄이는 방향을 생각할 수 있다.</p>
<p>예를 들면 다음과 같은 최적화가 후보가 된다.</p>
<ul>
<li>이미지 크기 줄이기</li>
<li>이미지 포맷 변경하기</li>
<li>CDN 활용하기</li>
<li>캐시 정책 확인하기</li>
<li>실제 화면 크기에 맞는 이미지 제공하기</li>
</ul>
<p>중요한 것은 “이미지 최적화는 무조건 해야 한다”가 아니다.</p>
<blockquote>
<p>LCP 리소스의 다운로드 시간이 길다는 근거를 확인했기 때문에
이미지 최적화를 선택하는 것이다.</p>
</blockquote>
<hr>
<h2 id="경우-2-리소스-요청-자체가-늦게-시작된다">경우 2. 리소스 요청 자체가 늦게 시작된다</h2>
<p>이번에는 이미지 파일이 그렇게 크지 않은데도 LCP가 늦다고 해보자.</p>
<p>Network 탭의 waterfall을 확인해보니, 대표 이미지 요청이 HTML을 받은 직후가 아니라 한참 뒤에 시작됐다.</p>
<p>이 경우에는 다운로드 속도보다 <strong>요청 시작 시점</strong>이 문제다.</p>
<p>다음과 같은 상황을 의심할 수 있다.</p>
<ul>
<li>중요한 이미지에 lazy loading이 적용되어 있다.</li>
<li>JavaScript 실행 이후에 이미지가 DOM에 추가된다.</li>
<li>API 응답을 받은 뒤에야 이미지 URL을 알 수 있다.</li>
<li>CSS의 background image로만 이미지를 불러오고 있다.</li>
<li>브라우저가 해당 리소스의 중요도를 늦게 판단하고 있다.</li>
</ul>
<p>이때는 다음과 같은 방향을 검토할 수 있다.</p>
<ul>
<li>중요한 이미지의 로딩 우선순위 조정</li>
<li>preload 적용 여부 확인</li>
<li>초기 HTML에서 리소스를 더 빨리 발견할 수 있게 변경</li>
<li>불필요한 lazy loading 제거</li>
</ul>
<p>여기서 Network 탭은 굉장히 유용하다.</p>
<pre><code class="language-text">수정 전: JavaScript 실행 이후 이미지 요청 시작
수정 후: HTML을 받은 직후 이미지 요청 시작</code></pre>
<p>이런 식으로 waterfall을 비교하면 수정 효과를 눈으로 확인할 수 있다.</p>
<hr>
<h2 id="경우-3-리소스를-다-받았는데도-화면에-늦게-그려진다">경우 3. 리소스를 다 받았는데도 화면에 늦게 그려진다</h2>
<p>이 경우부터는 Network 탭만으로 부족하다.</p>
<p>이미지는 이미 다운로드가 끝났는데, 실제 LCP 시점은 훨씬 뒤에 있다.
즉, 리소스는 도착했지만 화면에 나타나지 못하고 기다린 것이다.</p>
<p>이때 확인해야 하는 것이 <code>Element render delay</code>다.</p>
<p>가능한 원인은 다음과 같다.</p>
<ul>
<li>큰 JavaScript 번들을 파싱하고 실행하느라 Main Thread가 바쁘다.</li>
<li>초기 렌더링 전에 불필요한 계산을 많이 하고 있다.</li>
<li>너무 많은 컴포넌트를 한 번에 렌더링하고 있다.</li>
<li>로딩 상태 때문에 주요 콘텐츠를 늦게 DOM에 추가하고 있다.</li>
<li>초기 화면과 상관없는 스크립트가 먼저 실행되고 있다.</li>
<li>CSS 처리나 레이아웃 계산이 오래 걸리고 있다.</li>
</ul>
<p>이 경우에는 Performance 탭의 Main Thread를 봐야 한다.</p>
<blockquote>
<p>Network 탭은 리소스가 언제 도착했는지 알려준다.
Performance 탭은 리소스가 도착했는데도 왜 화면에 나오지 못했는지 알려준다.</p>
</blockquote>
<p>이 차이를 이해하고 나니 Performance 탭을 왜 봐야 하는지 조금 감이 왔다.</p>
<hr>
<h1 id="6-fcp가-느리다면-첫-렌더링을-막는-요소-확인하기">6. FCP가 느리다면 첫 렌더링을 막는 요소 확인하기</h1>
<p>LCP가 주요 콘텐츠가 나타나는 시점이라면, FCP는 화면에 처음으로 어떤 콘텐츠라도 나타나는 시점이다.</p>
<p>LCP만 늦다면 대표 이미지나 큰 콘텐츠의 문제일 가능성이 있다.
그런데 FCP부터 늦다면 페이지의 첫 렌더링 자체가 막히고 있을 수 있다.</p>
<p>이때는 다음을 확인해볼 수 있다.</p>
<table>
<thead>
<tr>
<th>확인할 항목</th>
<th>어떤 문제가 있을 수 있는가</th>
</tr>
</thead>
<tbody><tr>
<td>HTML 응답</td>
<td>서버 응답 자체가 느릴 수 있다</td>
</tr>
<tr>
<td>CSS</td>
<td>초기 렌더링을 막는 CSS가 클 수 있다</td>
</tr>
<tr>
<td>JavaScript</td>
<td>초기 렌더링 전에 너무 많은 스크립트가 실행될 수 있다</td>
</tr>
<tr>
<td>폰트</td>
<td>텍스트 렌더링이 폰트를 기다리고 있을 수 있다</td>
</tr>
<tr>
<td>초기 데이터 요청</td>
<td>데이터를 모두 받을 때까지 화면을 숨기고 있을 수 있다</td>
</tr>
</tbody></table>
<p>여기서도 바로 특정 기법을 적용하는 것이 아니라, 어떤 구간이 오래 걸리는지 먼저 확인해야 한다.</p>
<hr>
<h1 id="7-화면은-떴는데-버벅인다면-main-thread-보기">7. 화면은 떴는데 버벅인다면 Main Thread 보기</h1>
<p>첫 화면은 잘 떴는데 클릭이나 스크롤이 느리다면 이제는 런타임 성능을 봐야 한다.</p>
<p>예를 들어 다음과 같은 상황이다.</p>
<ul>
<li>필터 버튼을 누르면 화면 전환이 늦다.</li>
<li>검색창에 글자를 입력할 때 입력이 끊긴다.</li>
<li>긴 리스트를 스크롤할 때 버벅인다.</li>
<li>모달을 열 때 순간적으로 멈춘다.</li>
<li>애니메이션이 뚝뚝 끊긴다.</li>
</ul>
<p>이때는 페이지를 새로고침하며 기록하는 것이 아니라, 문제가 발생하는 행동만 기록한다.</p>
<pre><code class="language-text">1. 페이지를 먼저 로딩한다.
2. Performance 기록을 시작한다.
3. 느린 행동을 한 번 수행한다.
4. 바로 기록을 종료한다.
5. Main Thread에서 오래 걸린 작업을 찾는다.</code></pre>
<hr>
<h1 id="8-main-thread에서는-긴-작업부터-찾기">8. Main Thread에서는 긴 작업부터 찾기</h1>
<p>Performance 탭을 처음 보면 Flame Chart가 굉장히 복잡하게 느껴진다.</p>
<p>수많은 함수와 색깔이 한 번에 보이기 때문이다.
하지만 처음부터 모든 함수를 이해하려고 할 필요는 없다.</p>
<p>가장 먼저 해야 하는 일은 이것이다.</p>
<blockquote>
<p>느린 순간에 가로로 길게 늘어진 작업을 찾기</p>
</blockquote>
<p>가로 폭은 실행 시간을 의미한다.
길게 이어진 작업이 있다면 그동안 Main Thread가 바빴다는 뜻이다.</p>
<p>처음에는 다음 정도만 구분해도 충분하다.</p>
<table>
<thead>
<tr>
<th>영역</th>
<th>의미</th>
<th>의심할 방향</th>
</tr>
</thead>
<tbody><tr>
<td>Scripting</td>
<td>JavaScript 실행</td>
<td>이벤트 핸들러, 반복 계산, 데이터 가공</td>
</tr>
<tr>
<td>Rendering</td>
<td>스타일 계산, Layout</td>
<td>DOM 크기, 레이아웃 계산</td>
</tr>
<tr>
<td>Painting</td>
<td>실제 픽셀 그리기</td>
<td>넓은 repaint, 무거운 시각 효과</td>
</tr>
<tr>
<td>Loading</td>
<td>리소스 로딩 처리</td>
<td>Network 요청과 연결하여 확인</td>
</tr>
</tbody></table>
<p>그다음 긴 작업을 클릭하고, Summary와 Bottom-Up을 확인한다.</p>
<pre><code class="language-text">1. 긴 작업을 클릭한다.
2. Summary에서 작업 종류와 소요 시간을 확인한다.
3. Bottom-Up에서 시간이 많이 걸린 함수를 확인한다.
4. Call Tree에서 어떤 흐름으로 호출됐는지 확인한다.
5. 실제 코드로 이동한다.</code></pre>
<p>Performance 탭에서 가장 중요한 것은 모든 정보를 읽는 것이 아니다.</p>
<blockquote>
<p>느린 순간에 가장 많은 시간을 사용한 작업 하나를 찾는 것</p>
</blockquote>
<p>이렇게 접근하면 Flame Chart가 조금 덜 무섭다.</p>
<hr>
<h1 id="9-scripting이-길다면-javascript-실행-흐름-확인하기">9. Scripting이 길다면 JavaScript 실행 흐름 확인하기</h1>
<p>필터 버튼을 누르거나 검색어를 입력했을 때 Scripting 구간이 길게 나타난다면, JavaScript 실행 비용이 큰 상황일 수 있다.</p>
<p>예를 들어 다음과 같은 코드가 있다고 해보자.</p>
<pre><code class="language-tsx">const filteredMovies = movies
  .filter((movie) =&gt; movie.title.includes(keyword))
  .sort((a, b) =&gt; b.popularity - a.popularity);</code></pre>
<p>데이터가 적으면 문제가 없을 수 있다.
하지만 데이터가 많고, 입력할 때마다 반복해서 실행되고, 그 결과로 많은 컴포넌트가 다시 렌더링된다면 버벅임이 생길 수 있다.</p>
<p>이 경우에는 다음과 같은 흐름으로 판단한다.</p>
<pre><code class="language-text">검색어 입력
→ Performance 기록
→ 입력 직후 Scripting 구간이 길게 나타남
→ Bottom-Up에서 반복 계산 함수 확인
→ 불필요한 계산 여부 확인
→ 최적화 적용
→ 동일한 조건으로 재측정</code></pre>
<p>수정 방향은 상황에 따라 달라질 수 있다.</p>
<ul>
<li>계산 횟수 줄이기</li>
<li>메모이제이션 검토</li>
<li>debounce 적용</li>
<li>이벤트 처리 분리</li>
<li>큰 작업 나누기</li>
<li>필요하다면 Web Worker 검토</li>
</ul>
<p>중요한 것은 무작정 <code>useMemo</code>를 붙이는 것이 아니다.</p>
<blockquote>
<p>실제로 반복 계산 비용이 크다는 근거를 확인한 뒤
메모이제이션을 선택하는 것</p>
</blockquote>
<hr>
<h1 id="10-react-렌더링이-의심된다면-react-profiler로-이동하기">10. React 렌더링이 의심된다면 React Profiler로 이동하기</h1>
<p>Performance 탭에서 JavaScript 실행이나 렌더링 비용이 크다는 사실을 확인했다면, 그다음에는 React Profiler를 활용할 수 있다.</p>
<p>브라우저 Performance 탭과 React Profiler는 역할이 조금 다르다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>확인하기 좋은 것</th>
</tr>
</thead>
<tbody><tr>
<td>Performance 탭</td>
<td>브라우저 전체 흐름에서 어떤 작업이 오래 걸렸는지</td>
</tr>
<tr>
<td>React Profiler</td>
<td>어떤 컴포넌트가 왜 다시 렌더링됐는지</td>
</tr>
</tbody></table>
<p>따라서 다음과 같은 순서가 자연스럽다.</p>
<pre><code class="language-text">Performance 탭에서 느린 순간 찾기
→ JavaScript 또는 Rendering 비용이 크다는 근거 확인
→ React Profiler에서 컴포넌트 렌더링 흐름 확인
→ 불필요한 렌더링 원인 찾기
→ 상태 구조 또는 props 전달 방식 수정
→ 다시 측정</code></pre>
<p>이때 검토할 수 있는 것은 다음과 같다.</p>
<ul>
<li>상태가 너무 상위에 위치해 있는지</li>
<li>작은 상태 변경으로 너무 많은 컴포넌트가 다시 렌더링되는지</li>
<li>매번 새로운 객체나 배열이 전달되고 있는지</li>
<li>리스트가 너무 많은 컴포넌트를 한 번에 렌더링하고 있는지</li>
<li><code>memo</code>, <code>useMemo</code>, <code>useCallback</code>이 실제로 필요한 상황인지</li>
</ul>
<p>React 최적화도 결국 측정 이후에 적용해야 한다.</p>
<hr>
<h1 id="11-rendering이-길다면-layout을-의심하기">11. Rendering이 길다면 Layout을 의심하기</h1>
<p>Main Thread에서 Rendering 작업이 길게 나타난다면 레이아웃 계산을 확인해야 한다.</p>
<p>브라우저는 DOM과 CSS를 바탕으로 요소의 위치와 크기를 계산한다.
이 과정이 Layout이다.</p>
<p>특히 JavaScript에서 DOM 스타일을 변경한 직후 크기나 위치를 다시 읽으면, 브라우저가 미뤄두었던 레이아웃 계산을 즉시 수행해야 할 수 있다.</p>
<pre><code class="language-ts">element.style.width = &quot;200px&quot;;
const width = element.offsetWidth;</code></pre>
<p>이런 작업이 반복되면 Layout Thrashing이 발생할 수 있다.</p>
<p>조금 더 극단적인 예시는 다음과 같다.</p>
<pre><code class="language-ts">elements.forEach((element) =&gt; {
  element.style.width = &quot;200px&quot;;
  console.log(element.offsetWidth);
});</code></pre>
<p>스타일을 변경하고 크기를 읽는 작업이 계속 반복된다.</p>
<p>이런 경우에는 읽기와 쓰기를 나누는 방향을 생각할 수 있다.</p>
<pre><code class="language-ts">const widths = elements.map((element) =&gt; element.offsetWidth);

elements.forEach((element, index) =&gt; {
  element.style.width = `${widths[index] + 10}px`;
});</code></pre>
<p>Performance 탭에서는 <code>Recalculate Style</code>, <code>Layout</code> 등의 작업이 반복되거나 길게 나타나는지 확인하면 된다.</p>
<hr>
<h1 id="12-painting이-길다면-화면에-다시-그리는-범위-확인하기">12. Painting이 길다면 화면에 다시 그리는 범위 확인하기</h1>
<p>Painting은 브라우저가 실제 픽셀을 그리는 과정이다.</p>
<p>Main Thread에서 Painting 작업이 많이 발생한다면 다음과 같은 것을 의심할 수 있다.</p>
<ul>
<li>넓은 영역이 자주 다시 그려지고 있다.</li>
<li>큰 그림자나 blur 효과를 자주 변경하고 있다.</li>
<li>스크롤 중 많은 요소의 스타일이 계속 바뀌고 있다.</li>
<li>애니메이션이 Layout과 Paint를 반복적으로 유발하고 있다.</li>
</ul>
<p>이 경우에는 어떤 스타일 변경이 repaint를 발생시키는지 확인하고, 가능한 경우 다시 그리는 범위를 줄이는 방향을 생각할 수 있다.</p>
<p>여기서도 핵심은 같다.</p>
<blockquote>
<p>시각 효과가 많으니 전부 제거하는 것이 아니라,
실제로 Paint 비용이 크다는 근거를 확인한 뒤 수정한다.</p>
</blockquote>
<hr>
<h1 id="13-dom이-너무-크다면-렌더링-비용도-커진다">13. DOM이 너무 크다면 렌더링 비용도 커진다</h1>
<p>카드 리스트나 테이블처럼 반복되는 요소가 많은 화면에서는 DOM 크기도 확인해야 한다.</p>
<p>DOM이 너무 커지면 초기 렌더링뿐 아니라 이후의 스타일 계산, Layout, Paint 비용도 커질 수 있다.</p>
<p>예를 들어 게시글 카드 수백 개를 한 번에 렌더링하고 있다면 다음을 검토할 수 있다.</p>
<ul>
<li>pagination</li>
<li>무한 스크롤</li>
<li>virtualization</li>
<li>불필요한 wrapper 제거</li>
<li>화면 밖 영역 지연 렌더링</li>
</ul>
<p>이 역시 무조건 적용할 필요는 없다.</p>
<p>Performance 탭에서 Rendering 시간이 길고, DOM이 지나치게 크다는 근거가 있을 때 적용하면 된다.</p>
<hr>
<h1 id="14-애니메이션과-스크롤이-끊긴다면-frames-확인하기">14. 애니메이션과 스크롤이 끊긴다면 Frames 확인하기</h1>
<p>스크롤이나 애니메이션이 부드럽지 않은 경우에는 Frames 영역을 확인한다.</p>
<p>브라우저는 여러 프레임을 연속으로 그리면서 움직임을 표현한다.
한 프레임을 처리하는 데 너무 오래 걸리면 화면이 끊겨 보인다.</p>
<p>확인 흐름은 다음과 같다.</p>
<pre><code class="language-text">1. Performance 기록을 시작한다.
2. 스크롤하거나 애니메이션을 실행한다.
3. 기록을 종료한다.
4. Frames 영역에서 유난히 오래 걸린 프레임을 찾는다.
5. 해당 프레임의 Main Thread 작업을 확인한다.
6. Scripting, Rendering, Painting 중 어디에서 시간이 많이 들었는지 확인한다.</code></pre>
<p>이때 검토할 수 있는 방향은 다음과 같다.</p>
<ul>
<li>스크롤 이벤트가 너무 자주 실행되고 있는지</li>
<li>DOM 측정과 스타일 변경이 반복되는지</li>
<li>애니메이션에 <code>top</code>, <code>left</code>, <code>width</code> 변경이 많이 사용되는지</li>
<li><code>transform</code>, <code>opacity</code> 중심으로 바꿀 수 있는지</li>
<li><code>requestAnimationFrame</code>이 필요한 상황인지</li>
<li>repaint 범위가 지나치게 넓은지</li>
</ul>
<hr>
<h1 id="15-수정은-하나씩-하고-반드시-다시-측정하기">15. 수정은 하나씩 하고, 반드시 다시 측정하기</h1>
<p>최적화를 하다 보면 여러 가지를 한 번에 고치고 싶어진다.</p>
<p>이미지도 줄이고, 폰트도 바꾸고, lazy loading도 추가하고, 컴포넌트 구조도 바꾸고 싶다.
하지만 여러 작업을 한 번에 하면 무엇이 실제로 효과가 있었는지 알 수 없다.</p>
<p>그래서 한 번에 하나씩 수정하고 다시 측정하는 편이 좋다.</p>
<p>예를 들어 다음과 같이 기록할 수 있다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th align="right">수정 전</th>
<th>수정 내용</th>
<th align="right">수정 후</th>
</tr>
</thead>
<tbody><tr>
<td>LCP</td>
<td align="right">4.1초</td>
<td>대표 이미지 요청 우선순위 조정</td>
<td align="right">2.8초</td>
</tr>
<tr>
<td>대표 이미지 요청 시작</td>
<td align="right">1.5초</td>
<td>초기 HTML에서 더 빨리 발견되도록 변경</td>
<td align="right">0.4초</td>
</tr>
<tr>
<td>필터 클릭 후 Long Task</td>
<td align="right">170ms</td>
<td>반복 계산 줄이기</td>
<td align="right">55ms</td>
</tr>
<tr>
<td>초기 DOM node 수</td>
<td align="right">2,100개</td>
<td>페이지네이션 적용</td>
<td align="right">480개</td>
</tr>
</tbody></table>
<p>숫자가 크게 개선되지 않을 수도 있다.
하지만 그것도 의미가 있다.</p>
<pre><code class="language-text">이 수정은 현재 병목과 큰 관련이 없었다.
→ 다른 구간을 다시 확인한다.</code></pre>
<p>이 과정을 반복하다 보면 성능 최적화가 막연한 작업이 아니라 디버깅처럼 느껴지기 시작한다.</p>
<hr>
<h1 id="16-실제로-따라-할-수-있는-최적화-흐름">16. 실제로 따라 할 수 있는 최적화 흐름</h1>
<p>마지막으로 전체 흐름을 한 번에 정리해보면 다음과 같다.</p>
<pre><code class="language-text">1. 느린 순간을 구체적으로 정한다.

2. 같은 조건에서 기록한다.
   - 초기 로딩 문제라면 새로고침과 함께 기록
   - 인터랙션 문제라면 느린 행동 하나만 기록

3. Lighthouse로 어떤 방향을 먼저 볼지 확인한다.

4. 첫 화면이 느리다면 LCP element를 찾는다.
   - TTFB가 긴가?
   - 리소스 요청 시작이 늦은가?
   - 다운로드가 오래 걸리는가?
   - 다운로드 후 렌더링이 늦는가?

5. 사용 중에 버벅인다면 Main Thread를 본다.
   - Scripting이 긴가?
   - Rendering이 긴가?
   - Painting이 긴가?
   - Long Task가 있는가?

6. React 렌더링이 의심된다면 React Profiler로 좁힌다.

7. 원인과 연결되는 수정 하나를 적용한다.

8. 같은 조건으로 다시 측정한다.

9. 수치와 그래프를 비교한다.

10. 다음 병목으로 이동한다.</code></pre>
<p>조금 더 짧게 정리하면 다음과 같다.</p>
<pre><code class="language-text">느린 순간 정의
→ 측정
→ 병목 구간 확인
→ 원인 추측
→ 하나 수정
→ 재측정</code></pre>
<hr>
<h1 id="마무리">마무리</h1>
<p>성능 최적화를 공부할 때는 보통 다양한 기법을 먼저 접하게 된다.</p>
<p>이미지 최적화, 폰트 최적화, JavaScript 최적화, 렌더링 최적화, 코드 스플리팅, 캐싱, 메모이제이션 등 알아야 할 것은 정말 많다.</p>
<p>하지만 실제 프로젝트에서는 기법을 많이 아는 것보다, <strong>현재 문제에 맞는 기법을 선택하는 능력</strong>이 더 중요하다는 생각이 들었다.</p>
<p>무작정 이미지를 줄이고, 무작정 <code>memo</code>를 붙이고, 무작정 lazy loading을 적용하는 것이 아니라 먼저 질문해야 한다.</p>
<blockquote>
<p>지금 사용자가 느끼는 느린 순간은 언제인가?
그 순간 브라우저는 무엇을 하고 있었는가?
가장 오래 걸린 작업은 무엇인가?
이 수정은 정말 현재 병목을 줄였는가?</p>
</blockquote>
<p>Performance 탭은 처음 보면 복잡하다.
하지만 모든 그래프를 완벽하게 읽으려고 하지 않아도 된다.</p>
<p>처음에는 단 하나만 찾으면 된다.</p>
<blockquote>
<p>내가 느리다고 생각한 순간에, 가장 오래 걸린 작업은 무엇이었는가?</p>
</blockquote>
<p>그 질문에서부터 실제 성능 최적화가 시작된다.
::: </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[런타임 성능 최적화: FPS, requestAnimationFrame, React Profiler, Performance Timeline API]]></title>
            <link>https://velog.io/@jina___/%EB%9F%B0%ED%83%80%EC%9E%84-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@jina___/%EB%9F%B0%ED%83%80%EC%9E%84-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Mon, 08 Jun 2026 08:09:29 GMT</pubDate>
            <description><![CDATA[<p>페이지가 빠르게 로딩되는 것만으로 충분하지는 않다.<br>화면이 처음 나타난 이후 사용자가 스크롤하거나 버튼을 클릭하고, 모달을 열거나 애니메이션을 볼 때도 화면이 부드럽게 반응해야 한다.</p>
<p>이번 글에서는 런타임 성능 최적화와 관련된 핵심 개념을 정리해보겠다.</p>
<ul>
<li>FPS와 Frame rendering stats</li>
<li><code>requestAnimationFrame</code></li>
<li>React Profiler</li>
<li>Performance Timeline API</li>
</ul>
<hr>
<h2 id="로딩-성능과-런타임-성능">로딩 성능과 런타임 성능</h2>
<p>웹 성능은 크게 로딩 성능과 런타임 성능으로 나누어 생각할 수 있다.</p>
<h3 id="로딩-성능">로딩 성능</h3>
<p>사용자가 페이지에 처음 진입했을 때 콘텐츠가 얼마나 빠르게 나타나는지 확인한다.</p>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-text">FCP
LCP
CLS
이미지 로딩 시간
폰트 로딩 시간
JavaScript 번들 크기
네트워크 요청 지연</code></pre>
<p>이미지 크기를 줄이거나, 폰트를 최적화하고, 코드 스플리팅을 적용하는 작업은 주로 로딩 성능과 관련이 있다.</p>
<h3 id="런타임-성능">런타임 성능</h3>
<p>페이지가 이미 로딩된 이후 사용자 동작에 얼마나 빠르고 부드럽게 반응하는지 확인한다.</p>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-text">스크롤할 때 화면이 버벅이는가?
애니메이션이 끊겨 보이는가?
검색어를 입력할 때 타이핑이 밀리는가?
모달을 열 때 화면이 순간적으로 멈추는가?
상태 변경 시 불필요한 컴포넌트까지 다시 렌더링되는가?</code></pre>
<p>런타임 성능 최적화는 화면이 실제로 그려지는 흐름과 밀접하게 연결되어 있다.</p>
<hr>
<h2 id="브라우저가-화면을-그리는-과정">브라우저가 화면을 그리는 과정</h2>
<p>사용자가 버튼을 클릭하거나 스크롤하면 JavaScript가 실행되고, 상태나 DOM이 변경될 수 있다.</p>
<p>그 이후 브라우저는 화면을 다시 그리기 위해 다음 과정을 거친다.</p>
<pre><code class="language-text">JavaScript 실행
      ↓
Style Calculation
      ↓
Layout
      ↓
Paint
      ↓
Composite</code></pre>
<hr>
<h2 id="fps와-frame-rendering-stats">FPS와 Frame rendering stats</h2>
<h3 id="frame이란">Frame이란?</h3>
<p>브라우저는 움직이는 화면을 한 번에 만드는 것이 아니다.<br>짧은 간격으로 정지 화면을 계속 새로 만들어 보여준다.</p>
<p>이때 화면 한 장을 <strong>Frame</strong>이라고 한다.</p>
<pre><code class="language-text">1번째 Frame: 요소가 x = 0에 위치
2번째 Frame: 요소가 x = 5에 위치
3번째 Frame: 요소가 x = 10에 위치
4번째 Frame: 요소가 x = 15에 위치</code></pre>
<p>이 정지 화면들이 빠르게 교체되면 사용자는 요소가 자연스럽게 움직이는 것처럼 느낀다.</p>
<h3 id="fps란">FPS란?</h3>
<p><strong>FPS(Frames Per Second)</strong>는 1초 동안 표시되는 Frame의 수다.</p>
<p>일반적인 60Hz 모니터를 기준으로 브라우저는 1초 동안 약 60개의 Frame을 보여줄 수 있다.</p>
<pre><code class="language-text">1000ms ÷ 60 ≈ 16.67ms</code></pre>
<p>즉, 브라우저는 약 <code>16.67ms</code> 안에 하나의 Frame을 준비해야 한다.</p>
<h3 id="frame-예산을-초과하면">Frame 예산을 초과하면?</h3>
<p>한 Frame 안에서 처리해야 하는 작업이 너무 많다고 가정해 보자.</p>
<pre><code class="language-text">JavaScript 실행       8ms
Style Calculation     3ms
Layout                6ms
Paint                 5ms
-------------------------
총합                 22ms</code></pre>
<p>총 작업 시간이 <code>16.67ms</code>를 초과했다.</p>
<p>이 경우 브라우저는 다음 Frame을 제시간에 보여주지 못한다. 일부 Frame이 누락되면서 화면이 끊겨 보일 수 있다.</p>
<p>이를 흔히 <strong>Frame Drop</strong> 또는 <strong>Jank</strong>라고 한다.</p>
<h3 id="long-task와-frame-drop의-관계">Long Task와 Frame Drop의 관계</h3>
<p>Chrome Performance 패널에서 다음과 같은 작업을 확인했다고 가정해 보자.</p>
<pre><code class="language-text">긴 작업 소요 시간: 62.93ms</code></pre>
<p>60FPS 기준으로 한 Frame 예산은 약 <code>16.67ms</code>다.</p>
<pre><code class="language-text">62.93ms ÷ 16.67ms ≈ 3.8</code></pre>
<p>하나의 긴 작업 때문에 약 3~4개의 Frame을 제시간에 처리하지 못할 가능성이 있다.</p>
<p>따라서 Performance 패널에서 긴 작업을 분석할 때는 다음 질문을 함께 생각해야 한다.</p>
<pre><code class="language-text">이 작업 때문에 Frame 예산을 초과하고 있지는 않은가?</code></pre>
<h3 id="frame-rendering-stats란-무엇인가">Frame rendering stats란 무엇인가?</h3>
<p>Frame rendering stats는 현재 페이지의 FPS와 Frame 상태를 실시간으로 확인할 수 있는 도구다.</p>
<p>Chrome에서 다음 순서로 확인할 수 있다.</p>
<pre><code class="language-text">개발자 도구 열기
→ Ctrl + Shift + P
→ Rendering 검색
→ Show Rendering 선택
→ Frame rendering stats 체크</code></pre>
<p>정리하면 다음과 같다.</p>
<pre><code class="language-text">FPS
→ 1초 동안 표시되는 Frame 수

Frame rendering stats
→ Chrome DevTools에서 FPS 상태를 확인하는 도구
</code></pre>
<hr>
<h2 id="requestanimationframe">requestAnimationFrame</h2>
<h3 id="requestanimationframe이란">requestAnimationFrame이란?</h3>
<p><code>requestAnimationFrame()</code>은 브라우저에게 다음과 같이 요청하는 API다.</p>
<pre><code class="language-text">다음 화면을 다시 그리기 직전에 이 함수를 실행해 줘.</code></pre>
<p>일반적으로 줄여서 <code>rAF</code>라고 부른다.</p>
<pre><code class="language-ts">requestAnimationFrame(() =&gt; {
  console.log(&quot;다음 Frame 직전에 한 번 실행&quot;);
});</code></pre>
<p>이 코드는 콜백을 한 번만 실행한다.</p>
<h3 id="반복-실행하기">반복 실행하기</h3>
<p>Frame마다 반복해서 실행하려면 콜백 내부에서 다시 <code>requestAnimationFrame()</code>을 호출해야 한다.</p>
<pre><code class="language-ts">const animate = () =&gt; {
  console.log(&quot;Frame마다 실행&quot;);

  requestAnimationFrame(animate);
};

requestAnimationFrame(animate);</code></pre>
<p>흐름은 다음과 같다.</p>
<pre><code class="language-text">첫 번째 Frame 직전
→ animate 실행
→ 다음 animate 실행 예약

두 번째 Frame 직전
→ animate 실행
→ 다음 animate 실행 예약

세 번째 Frame 직전
→ animate 실행
→ 다음 animate 실행 예약</code></pre>
<p>60Hz 모니터라면 보통 약 <code>16.67ms</code> 간격으로 실행된다.</p>
<p>하지만 정확히 항상 같은 간격으로 실행되는 것은 아니다.</p>
<pre><code class="language-text">60Hz 모니터   → 약 16.67ms 간격
120Hz 모니터  → 약 8.33ms 간격
144Hz 모니터  → 약 6.94ms 간격</code></pre>
<p>메인 스레드가 바쁘거나 브라우저 탭이 백그라운드에 있다면 실행 간격이 달라질 수도 있다.</p>
<h3 id="실제-실행-간격-확인하기">실제 실행 간격 확인하기</h3>
<p><code>requestAnimationFrame()</code>은 콜백에 현재 시간을 전달한다.</p>
<pre><code class="language-ts">let previousTime: number | null = null;

const animate = (currentTime: number) =&gt; {
  if (previousTime !== null) {
    console.log(currentTime - previousTime);
  }

  previousTime = currentTime;

  requestAnimationFrame(animate);
};

requestAnimationFrame(animate);</code></pre>
<p>60Hz 환경에서는 대략 다음과 같은 값이 출력된다.</p>
<pre><code class="language-text">16.67
16.66
16.72
16.64</code></pre>
<h3 id="setinterval과의-차이">setInterval과의 차이</h3>
<p>애니메이션을 다음처럼 작성할 수도 있다.</p>
<pre><code class="language-ts">setInterval(() =&gt; {
  position += 5;
  box.style.transform = `translateX(${position}px)`;
}, 16);</code></pre>
<p>하지만 <code>setInterval()</code>은 브라우저가 실제로 화면을 그리는 시점을 고려하지 않는다.</p>
<p>반면 <code>requestAnimationFrame()</code>은 다음 repaint 직전에 실행되기 때문에 화면 갱신 타이밍에 맞춰 작업을 처리하기 쉽다.</p>
<pre><code class="language-text">setInterval
→ 개발자가 지정한 시간마다 실행을 시도
→ 화면 갱신 시점과 어긋날 수 있음

requestAnimationFrame
→ 다음 repaint 직전에 실행
→ 애니메이션 타이밍을 맞추기 쉬움</code></pre>
<h3 id="raf가-무조건-성능을-개선하는-것은-아니다">rAF가 무조건 성능을 개선하는 것은 아니다</h3>
<p><code>requestAnimationFrame()</code>은 무거운 코드를 자동으로 가볍게 만들어 주는 기능이 아니다.</p>
<pre><code class="language-ts">const animate = () =&gt; {
  for (let index = 0; index &lt; 100_000_000; index += 1) {
    // 무거운 연산
  }

  requestAnimationFrame(animate);
};</code></pre>
<p>한 Frame마다 무거운 작업을 실행하면 여전히 화면은 끊긴다.</p>
<p>핵심은 다음과 같다.</p>
<pre><code class="language-text">requestAnimationFrame
→ 무거운 작업을 자동으로 최적화하는 기능 X
→ 화면을 그리는 타이밍에 맞춰 작업을 예약하는 기능 O</code></pre>
<h3 id="이동-거리는-시간-기준으로-계산하기">이동 거리는 시간 기준으로 계산하기</h3>
<p>다음 코드처럼 Frame마다 고정된 값을 더하면 모니터 주사율에 따라 이동 속도가 달라질 수 있다.</p>
<pre><code class="language-ts">position += 5;</code></pre>
<p>60Hz 환경에서는 1초에 약 <code>300px</code> 이동한다.</p>
<pre><code class="language-text">5px × 60 = 300px</code></pre>
<p>120Hz 환경에서는 1초에 약 <code>600px</code> 이동한다.</p>
<pre><code class="language-text">5px × 120 = 600px</code></pre>
<p>따라서 지난 Frame 이후 흐른 시간을 기준으로 이동 거리를 계산하는 편이 좋다.</p>
<pre><code class="language-ts">let previousTime: number | null = null;
let position = 0;

const animate = (currentTime: number) =&gt; {
  if (previousTime !== null) {
    const deltaTime = currentTime - previousTime;

    position += 0.3 * deltaTime;

    box.style.transform = `translateX(${position}px)`;
  }

  previousTime = currentTime;

  requestAnimationFrame(animate);
};

requestAnimationFrame(animate);</code></pre>
<p><code>deltaTime</code>은 지난 Frame 이후 흐른 시간이다.</p>
<p>이 방식을 사용하면 모니터 주사율이 달라도 이동 속도를 일정하게 유지하기 쉽다.</p>
<h3 id="react에서-항상-raf를-사용해야-할까">React에서 항상 rAF를 사용해야 할까?</h3>
<p>그렇지 않다.</p>
<p>버튼 클릭, 좋아요 상태 변경, 모달 열기처럼 일반적인 UI 상태 변경은 React에 맡기면 된다.</p>
<pre><code class="language-tsx">const [isOpen, setIsOpen] = useState(false);

&lt;button onClick={() =&gt; setIsOpen(true)}&gt;
  모달 열기
&lt;/button&gt;</code></pre>
<p><code>requestAnimationFrame()</code>은 주로 화면이 지속적으로 바뀌는 작업에 사용한다.</p>
<pre><code class="language-text">직접 구현한 애니메이션
드래그 앤 드롭
스크롤 위치 기반 UI
Canvas 애니메이션
게임 화면
실시간 그래프
마우스 이동을 따라가는 요소</code></pre>
<hr>
<h2 id="react-profiler">React Profiler</h2>
<h3 id="react-profiler란">React Profiler란?</h3>
<p>React Profiler는 React 컴포넌트의 렌더링 성능을 측정하는 도구다.</p>
<p>다음과 같은 질문에 답하기 위해 사용한다.</p>
<pre><code class="language-text">버튼을 하나 눌렀는데 왜 화면이 버벅이는가?
검색창에 글자를 입력했는데 왜 카드 목록 전체가 다시 렌더링되는가?
어떤 컴포넌트의 렌더링 비용이 가장 큰가?
memo, useMemo, useCallback을 적용한 효과가 실제로 있는가?</code></pre>
<h3 id="react-렌더링과-브라우저-렌더링">React 렌더링과 브라우저 렌더링</h3>
<p>React 렌더링과 브라우저 렌더링은 다르다.</p>
<h4 id="react-렌더링">React 렌더링</h4>
<p>React가 컴포넌트 함수를 실행하여 어떤 UI가 필요한지 계산하는 과정이다.</p>
<pre><code class="language-text">state 또는 props 변경
        ↓
컴포넌트 함수 다시 실행
        ↓
이전 JSX와 새로운 JSX 비교
        ↓
필요한 변경 사항만 실제 DOM에 반영</code></pre>
<h4 id="브라우저-렌더링">브라우저 렌더링</h4>
<p>DOM과 CSS를 기반으로 화면을 실제로 그리는 과정이다.</p>
<pre><code class="language-text">Style Calculation
→ Layout
→ Paint
→ Composite</code></pre>
<p>React 컴포넌트가 다시 실행되었다고 해서 실제 DOM이 반드시 변경되는 것은 아니다.</p>
<p>하지만 컴포넌트 함수가 불필요하게 많이 실행되고 내부 연산까지 반복된다면 React 자체의 계산 비용이 커질 수 있다.</p>
<h3 id="react-devtools-profiler-사용-방법">React DevTools Profiler 사용 방법</h3>
<p>React Developer Tools를 설치하면 Chrome 개발자 도구에서 Profiler 탭을 사용할 수 있다.</p>
<pre><code class="language-text">개발자 도구 열기
→ Profiler 탭
→ Record 버튼 클릭
→ 분석할 동작 하나 수행
→ 기록 종료</code></pre>
<p>분석할 동작은 하나씩 나누어 확인하는 것이 좋다.</p>
<pre><code class="language-text">검색창에 글자 한 개 입력
좋아요 버튼 클릭
수량 증가 버튼 클릭
모달 열기
페이지 이동</code></pre>
<h3 id="commit">Commit</h3>
<p>Profiler 화면 상단에는 여러 개의 막대가 표시된다.</p>
<p>각 막대는 하나의 <strong>Commit</strong>을 의미한다.</p>
<pre><code class="language-text">Render Phase
→ 무엇이 바뀌어야 하는지 계산

Commit Phase
→ 계산된 변경 사항을 실제 DOM에 반영</code></pre>
<p>React가 계산한 결과를 실제 화면에 반영한 한 번의 작업이 하나의 Commit이다.</p>
<p>막대가 높을수록 렌더링 비용이 큰 Commit일 가능성이 있다.</p>
<h3 id="flamegraph">Flamegraph</h3>
<p>특정 Commit을 클릭하면 컴포넌트 구조를 확인할 수 있다.</p>
<pre><code class="language-text">App
└── PostListPage
    ├── SearchInput
    ├── PostItem
    ├── PostItem
    └── PostItem</code></pre>
<p>검색어를 한 글자 입력했는데 모든 <code>PostItem</code>이 다시 렌더링되었다면 다음 내용을 확인해야 한다.</p>
<pre><code class="language-text">모든 카드가 다시 렌더링되어야 하는가?
카드에 전달되는 props가 실제로 변경되었는가?
카드 내부에 무거운 계산이 있는가?</code></pre>
<h3 id="ranked-chart">Ranked Chart</h3>
<p>Ranked Chart에서는 렌더링 비용이 큰 컴포넌트를 순서대로 확인할 수 있다.</p>
<pre><code class="language-text">1. PostListPage     8.3ms
2. PostItem         4.1ms
3. SearchInput      0.8ms
4. Header           0.3ms</code></pre>
<p>우선 시간이 오래 걸리는 컴포넌트부터 확인한다.</p>
<h3 id="다시-렌더링된-이유-확인하기">다시 렌더링된 이유 확인하기</h3>
<p>Profiler 설정에서 다음 옵션을 활성화하면 각 컴포넌트가 렌더링된 원인을 확인할 수 있다.</p>
<pre><code class="language-text">Record why each component rendered while profiling</code></pre>
<p>대표적인 원인은 다음과 같다.</p>
<pre><code class="language-text">props 변경
state 변경
Hook 변경
부모 컴포넌트 렌더링</code></pre>
<h3 id="불필요한-리렌더링-예시">불필요한 리렌더링 예시</h3>
<pre><code class="language-tsx">const Parent = () =&gt; {
  const [count, setCount] = useState(0);

  const handleClick = () =&gt; {
    console.log(&quot;카드 클릭&quot;);
  };

  return (
    &lt;&gt;
      &lt;button onClick={() =&gt; setCount((prev) =&gt; prev + 1)}&gt;
        {count}
      &lt;/button&gt;

      &lt;PostItem onClick={handleClick} /&gt;
    &lt;/&gt;
  );
};</code></pre>
<p><code>count</code>가 바뀔 때마다 <code>Parent</code>가 다시 실행된다.</p>
<p>그 과정에서 <code>handleClick</code> 함수도 다시 만들어진다.</p>
<pre><code class="language-text">이전 렌더링의 handleClick
!==
다음 렌더링의 handleClick</code></pre>
<p><code>PostItem</code>을 <code>memo()</code>로 감싸더라도 함수 props의 참조가 달라졌기 때문에 다시 렌더링될 수 있다.</p>
<pre><code class="language-tsx">const PostItem = memo(({ onClick }: PostItemProps) =&gt; {
  return &lt;button onClick={onClick}&gt;게시글&lt;/button&gt;;
});</code></pre>
<p>필요하다면 <code>useCallback()</code>을 고려할 수 있다.</p>
<pre><code class="language-tsx">const handleClick = useCallback(() =&gt; {
  console.log(&quot;카드 클릭&quot;);
}, []);</code></pre>
<p>하지만 모든 코드에 무조건 <code>useCallback()</code>을 추가하면 안 된다.</p>
<pre><code class="language-text">Profiler로 불필요한 렌더링 확인
→ 원인 확인
→ 필요한 경우에만 최적화 적용
→ Profiler로 개선 효과 검증</code></pre>
<h3 id="자주-확인하는-문제">자주 확인하는 문제</h3>
<h4 id="부모-상태-변경으로-자식-전체가-렌더링되는-경우">부모 상태 변경으로 자식 전체가 렌더링되는 경우</h4>
<pre><code class="language-text">모달 상태 변경
→ Page 렌더링
→ PostList 렌더링
→ PostItem 수백 개 렌더링</code></pre>
<h4 id="매번-새로운-객체를-props로-전달하는-경우">매번 새로운 객체를 props로 전달하는 경우</h4>
<pre><code class="language-tsx">&lt;PostItem style={{ marginTop: 12 }} /&gt;</code></pre>
<p>객체는 내용이 같아도 렌더링할 때마다 새로 생성된다.</p>
<pre><code class="language-ts">{ marginTop: 12 } !== { marginTop: 12 }</code></pre>
<h4 id="무거운-계산을-반복하는-경우">무거운 계산을 반복하는 경우</h4>
<pre><code class="language-tsx">const filteredProducts = products
  .filter((product) =&gt; product.name.includes(keyword))
  .sort((a, b) =&gt; b.price - a.price);</code></pre>
<p>데이터가 많고 계산 비용이 실제로 크다면 <code>useMemo()</code>를 고려할 수 있다.</p>
<pre><code class="language-tsx">const filteredProducts = useMemo(() =&gt; {
  return products
    .filter((product) =&gt; product.name.includes(keyword))
    .sort((a, b) =&gt; b.price - a.price);
}, [products, keyword]);</code></pre>
<h4 id="context나-전역-상태를-너무-넓게-구독하는-경우">Context나 전역 상태를 너무 넓게 구독하는 경우</h4>
<p>Zustand 같은 전역 상태 관리 도구를 사용할 때는 필요한 상태만 선택하여 구독하는 것이 좋다.</p>
<pre><code class="language-tsx">const isModalOpen = useModalStore(
  (state) =&gt; state.isModalOpen,
);</code></pre>
<h3 id="코드에서-profiler-사용하기">코드에서 Profiler 사용하기</h3>
<p>React의 <code>&lt;Profiler&gt;</code> 컴포넌트를 직접 사용할 수도 있다.</p>
<pre><code class="language-tsx">import {
  Profiler,
  type ProfilerOnRenderCallback,
} from &quot;react&quot;;

const handleRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
) =&gt; {
  console.table({
    영역: id,
    렌더링종류: phase,
    실제렌더링시간: `${actualDuration.toFixed(2)}ms`,
    기준렌더링시간: `${baseDuration.toFixed(2)}ms`,
  });
};

const PostPage = () =&gt; {
  return (
    &lt;Profiler id=&quot;PostList&quot; onRender={handleRender}&gt;
      &lt;PostList /&gt;
    &lt;/Profiler&gt;
  );
};</code></pre>
<p>주요 값은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>id</code></td>
<td>측정 영역 이름</td>
</tr>
<tr>
<td><code>phase</code></td>
<td>최초 렌더링인지 업데이트인지 구분</td>
</tr>
<tr>
<td><code>actualDuration</code></td>
<td>이번 렌더링에서 실제로 걸린 시간</td>
</tr>
<tr>
<td><code>baseDuration</code></td>
<td>최적화가 없다고 가정했을 때 예상되는 기준 비용</td>
</tr>
</tbody></table>
<h3 id="react-profiler-사용-원칙">React Profiler 사용 원칙</h3>
<p>렌더링이 발생했다고 해서 무조건 문제가 있는 것은 아니다.</p>
<p>React에서 상태가 변경되면 다시 렌더링되는 것이 자연스럽다.</p>
<p>다음과 같은 경우에 최적화를 고민하면 된다.</p>
<pre><code class="language-text">사용자가 체감할 정도로 느림
렌더링 시간이 실제로 큼
불필요한 컴포넌트가 대량으로 반복 렌더링됨
동일한 무거운 계산이 자주 반복됨</code></pre>
<p><code>memo</code>, <code>useMemo</code>, <code>useCallback</code>도 공짜가 아니다.</p>
<p>값을 비교하고 메모리에 저장하는 비용이 추가되므로, Profiler로 실제 병목을 확인한 뒤 필요한 경우에만 적용하는 것이 좋다.</p>
<hr>
<h2 id="performance-timeline-api">Performance Timeline API</h2>
<h3 id="performance-timeline-api란">Performance Timeline API란?</h3>
<p>Performance Timeline API는 브라우저가 수집한 성능 기록을 시간순으로 확인할 수 있게 해 주는 API 체계다.</p>
<p>브라우저 내부에 성능 기록용 타임라인이 존재한다고 생각하면 쉽다.</p>
<pre><code class="language-text">페이지 진입
  ↓
HTML 문서 요청
  ↓
CSS 파일 다운로드
  ↓
이미지 다운로드
  ↓
첫 콘텐츠 표시
  ↓
사용자 정의 작업 시작
  ↓
사용자 정의 작업 종료</code></pre>
<p>이 타임라인에 여러 종류의 기록이 쌓이고, JavaScript 코드에서 기록을 조회하거나 관찰할 수 있다.</p>
<h3 id="devtools-performance-탭과의-차이">DevTools Performance 탭과의 차이</h3>
<p>Chrome DevTools Performance 탭은 개발자가 직접 화면을 열고 녹화하여 분석한다.</p>
<pre><code class="language-text">개발자 도구 열기
→ Performance 탭
→ Record
→ 동작 수행
→ 기록 종료
→ 결과 분석</code></pre>
<p>Performance Timeline API는 코드에서 성능 데이터를 조회하거나 수집한다.</p>
<pre><code class="language-ts">const entries = performance.getEntries();

console.log(entries);</code></pre>
<table>
<thead>
<tr>
<th>방식</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Chrome Performance 패널</td>
<td>사람이 직접 상세 흐름을 시각적으로 분석</td>
</tr>
<tr>
<td>Performance Timeline API</td>
<td>코드에서 성능 기록을 조회하거나 수집</td>
</tr>
<tr>
<td>React Profiler</td>
<td>React 컴포넌트 렌더링 비용에 집중</td>
</tr>
</tbody></table>
<h3 id="performanceentry">PerformanceEntry</h3>
<p>타임라인에 저장되는 성능 기록 하나를 <code>PerformanceEntry</code>라고 한다.</p>
<pre><code class="language-ts">{
  name: &quot;https://example.com/image.webp&quot;,
  entryType: &quot;resource&quot;,
  startTime: 324.5,
  duration: 81.2
}</code></pre>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>name</code></td>
<td>기록 이름</td>
</tr>
<tr>
<td><code>entryType</code></td>
<td>기록 종류</td>
</tr>
<tr>
<td><code>startTime</code></td>
<td>측정 시작 시점</td>
</tr>
<tr>
<td><code>duration</code></td>
<td>걸린 시간</td>
</tr>
</tbody></table>
<h3 id="기록-조회하기">기록 조회하기</h3>
<p>모든 기록을 조회할 수 있다.</p>
<pre><code class="language-ts">const entries = performance.getEntries();

console.log(entries);</code></pre>
<p>특정 종류의 기록만 조회할 수도 있다.</p>
<pre><code class="language-ts">const resources =
  performance.getEntriesByType(&quot;resource&quot;);

console.log(resources);</code></pre>
<p>특정 이름을 가진 기록만 조회할 수도 있다.</p>
<pre><code class="language-ts">const results =
  performance.getEntriesByName(&quot;filter-posts&quot;);

console.log(results);</code></pre>
<h3 id="대표적인-entrytype">대표적인 entryType</h3>
<table>
<thead>
<tr>
<th><code>entryType</code></th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>&quot;navigation&quot;</code></td>
<td>페이지 진입과 HTML 문서 로딩</td>
</tr>
<tr>
<td><code>&quot;resource&quot;</code></td>
<td>이미지, CSS, JavaScript 등의 리소스 로딩</td>
</tr>
<tr>
<td><code>&quot;mark&quot;</code></td>
<td>개발자가 직접 표시한 특정 시점</td>
</tr>
<tr>
<td><code>&quot;measure&quot;</code></td>
<td>개발자가 직접 측정한 구간</td>
</tr>
<tr>
<td><code>&quot;paint&quot;</code></td>
<td>콘텐츠가 화면에 그려진 시점</td>
</tr>
<tr>
<td><code>&quot;longtask&quot;</code></td>
<td>메인 스레드를 오래 점유한 작업</td>
</tr>
</tbody></table>
<h3 id="performancemark">performance.mark()</h3>
<p><code>performance.mark()</code>는 타임라인에 특정 시점을 기록한다.</p>
<pre><code class="language-ts">performance.mark(&quot;filter-start&quot;);</code></pre>
<p>스톱워치의 시작 지점을 표시하는 것과 비슷하다.</p>
<h3 id="performancemeasure">performance.measure()</h3>
<p><code>performance.measure()</code>는 두 시점 사이의 시간을 계산하여 타임라인에 기록한다.</p>
<pre><code class="language-ts">performance.mark(&quot;filter-start&quot;);

// 측정할 작업

performance.mark(&quot;filter-end&quot;);

performance.measure(
  &quot;filter-posts&quot;,
  &quot;filter-start&quot;,
  &quot;filter-end&quot;,
);</code></pre>
<h3 id="게시글-필터링-시간-측정하기">게시글 필터링 시간 측정하기</h3>
<pre><code class="language-ts">performance.mark(&quot;filter-start&quot;);

const filteredPosts = postList.filter((post) =&gt;
  post.title.includes(keyword),
);

performance.mark(&quot;filter-end&quot;);

performance.measure(
  &quot;filter-posts&quot;,
  &quot;filter-start&quot;,
  &quot;filter-end&quot;,
);

const results =
  performance.getEntriesByName(&quot;filter-posts&quot;);

console.log(results);</code></pre>
<p>결과는 대략 다음과 같다.</p>
<pre><code class="language-ts">[
  {
    name: &quot;filter-posts&quot;,
    entryType: &quot;measure&quot;,
    startTime: 824.4,
    duration: 1.72,
  },
]</code></pre>
<p><code>duration: 1.72</code>라면 필터링에 약 <code>1.72ms</code>가 걸렸다는 의미다.</p>
<h3 id="performancenow와의-차이">performance.now()와의 차이</h3>
<p>간단한 측정에는 <code>performance.now()</code>를 사용할 수도 있다.</p>
<pre><code class="language-ts">const startTime = performance.now();

const filteredPosts = postList.filter((post) =&gt;
  post.title.includes(keyword),
);

const endTime = performance.now();

console.log(endTime - startTime);</code></pre>
<p>차이는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><code>performance.now()</code></td>
<td>간단하게 시간 차이를 확인하기 좋음</td>
</tr>
<tr>
<td><code>performance.mark()</code> + <code>performance.measure()</code></td>
<td>의미 있는 이름을 붙여 타임라인에 기록하기 좋음</td>
</tr>
</tbody></table>
<h3 id="기록-초기화하기">기록 초기화하기</h3>
<p>같은 이름으로 측정하면 결과가 계속 누적된다.</p>
<pre><code class="language-ts">performance.clearMarks();
performance.clearMeasures();</code></pre>
<p>특정 이름의 기록만 지울 수도 있다.</p>
<pre><code class="language-ts">performance.clearMarks(&quot;filter-start&quot;);
performance.clearMarks(&quot;filter-end&quot;);
performance.clearMeasures(&quot;filter-posts&quot;);</code></pre>
<h3 id="performanceobserver">PerformanceObserver</h3>
<p>새로운 성능 기록이 생길 때마다 자동으로 감지하려면 <code>PerformanceObserver</code>를 사용할 수 있다.</p>
<pre><code class="language-ts">const observer = new PerformanceObserver((list) =&gt; {
  const entries = list.getEntries();

  entries.forEach((entry) =&gt; {
    console.log(entry.name, entry.duration);
  });
});

observer.observe({
  entryTypes: [&quot;measure&quot;],
});</code></pre>
<p>이후 새로운 <code>measure</code> 기록을 만들면 Observer가 자동으로 감지한다.</p>
<pre><code class="language-ts">performance.mark(&quot;filter-start&quot;);

// 측정할 작업

performance.mark(&quot;filter-end&quot;);

performance.measure(
  &quot;filter-posts&quot;,
  &quot;filter-start&quot;,
  &quot;filter-end&quot;,
);</code></pre>
<h3 id="긴-작업-감지하기">긴 작업 감지하기</h3>
<p>일부 환경에서는 <code>&quot;longtask&quot;</code> 타입을 관찰하여 메인 스레드를 오래 막은 작업을 감지할 수 있다.</p>
<pre><code class="language-ts">const observer = new PerformanceObserver((list) =&gt; {
  list.getEntries().forEach((entry) =&gt; {
    console.log(&quot;긴 작업 감지:&quot;, entry.duration);
  });
});

observer.observe({
  entryTypes: [&quot;longtask&quot;],
});</code></pre>
<p>Long Task는 메인 스레드를 <code>50ms</code> 이상 점유한 작업이다.</p>
<pre><code class="language-text">긴 작업 감지: 72.4</code></pre>
<p>이 경우 메인 스레드가 약 <code>72.4ms</code> 동안 막혀 있었다는 의미다.</p>
<p>60FPS 기준 한 Frame 예산은 약 <code>16.67ms</code>이므로 화면이 순간적으로 끊길 가능성이 있다.</p>
<hr>
<h2 id="도구별-역할-비교">도구별 역할 비교</h2>
<p>각 도구는 서로 대체하는 관계가 아니다.<br>문제 원인을 좁히기 위해 함께 사용한다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>중심 관점</th>
<th>확인할 내용</th>
</tr>
</thead>
<tbody><tr>
<td>Frame rendering stats</td>
<td>사용자 체감</td>
<td>FPS가 실제로 떨어지는가?</td>
</tr>
<tr>
<td><code>requestAnimationFrame()</code></td>
<td>렌더링 타이밍</td>
<td>화면 갱신 타이밍에 맞춰 작업하고 있는가?</td>
</tr>
<tr>
<td>React Profiler</td>
<td>React 컴포넌트</td>
<td>불필요한 리렌더링이 발생하는가?</td>
</tr>
<tr>
<td>Performance Timeline API</td>
<td>코드 기반 측정</td>
<td>특정 로직에 몇 ms가 걸리는가?</td>
</tr>
<tr>
<td>Chrome Performance 패널</td>
<td>브라우저 전체 흐름</td>
<td>JS, Layout, Paint 중 무엇이 병목인가?</td>
</tr>
</tbody></table>
<hr>
<h2 id="런타임-성능-최적화-흐름">런타임 성능 최적화 흐름</h2>
<p>실제 화면에서 버벅임을 발견했다면 다음 순서로 접근할 수 있다.</p>
<pre><code class="language-text">사용자가 화면 버벅임을 느낌
        ↓
Frame rendering stats로 FPS 저하 확인
        ↓
React Profiler로 불필요한 리렌더링 확인
        ↓
Performance Timeline API로 특정 로직 시간 측정
        ↓
Chrome Performance 패널로
JavaScript, Layout, Paint 흐름 분석
        ↓
원인에 맞춰 개선
        ↓
같은 조건에서 다시 측정</code></pre>
<p>예를 들어 검색 기능이 느리다면 다음과 같이 원인을 구분할 수 있다.</p>
<pre><code class="language-text">검색 요청 시간이 긴가?
데이터 가공 시간이 긴가?
React 렌더링 시간이 긴가?
Layout 또는 Paint 비용이 큰가?</code></pre>
<p>막연히 화면이 느리다고 판단하는 것이 아니라, 구간별로 시간을 측정하여 실제 병목을 찾아야 한다.</p>
<hr>
<h2 id="정리">정리</h2>
<pre><code class="language-text">FPS
→ 1초 동안 표시되는 Frame 수

Frame rendering stats
→ Chrome DevTools에서 FPS 상태를 확인하는 도구

requestAnimationFrame
→ 다음 repaint 직전에 작업을 실행하도록 예약하는 API

React Profiler
→ React 컴포넌트의 렌더링 비용과 불필요한 리렌더링을 확인하는 도구

Performance Timeline API
→ 브라우저 성능 기록을 조회하고 특정 작업 시간을 코드로 측정하는 API 체계</code></pre>
<p>런타임 성능 최적화의 핵심도 로딩 성능 최적화와 마찬가지로</p>
<pre><code class="language-text">측정
→ 병목 확인
→ 필요한 부분만 개선
→ 다시 측정</code></pre>
<p>이 과정을 반복하여 실제 사용자 경험을 개선해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JavaScript 실행시간 최적화 ]]></title>
            <link>https://velog.io/@jina___/JavaScript-%EC%8B%A4%ED%96%89%EC%8B%9C%EA%B0%84-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@jina___/JavaScript-%EC%8B%A4%ED%96%89%EC%8B%9C%EA%B0%84-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Mon, 25 May 2026 04:58:46 GMT</pubDate>
            <description><![CDATA[<p>이번 주에는 웹 성능 최적화 중에서도 <strong>JavaScript 실행시간 최적화</strong>를 중심으로 공부했다.</p>
<p>주요 키워드는 다음과 같다.</p>
<ul>
<li>Code Splitting</li>
<li>Lazy Loading</li>
<li>번들 분석 도구</li>
<li>CSS 최적화</li>
<li>React.memo</li>
<li>useMemo</li>
<li>useCallback</li>
</ul>
<hr>
<h2 id="javascript-실행시간이-중요한-이유">JavaScript 실행시간이 중요한 이유</h2>
<p>웹 페이지를 그릴 때 브라우저는 HTML, CSS, JavaScript를 각각 처리한다.</p>
<pre><code class="language-txt">HTML → 파싱 → DOM 생성
CSS → 파싱 → CSSOM 생성
JavaScript → 파싱 → 컴파일 → 실행</code></pre>
<p>HTML은 문서 구조를 만들고, CSS는 스타일 규칙을 만든다.</p>
<p>반면 JavaScript는 실제 동작을 수행한다. 예를 들어 버튼을 찾고, 이벤트를 등록하고, 상태를 변경하고, DOM을 업데이트한다.</p>
<p>즉 JavaScript는 단순히 읽히는 것이 아니라 <strong>실행되는 코드</strong>이기 때문에 브라우저 입장에서 더 큰 비용이 든다.</p>
<p>React 앱에서는 JavaScript가 더 중요해진다. React 컴포넌트를 실행하고, 상태 변경에 따라 렌더링하고, 이벤트를 처리하는 대부분의 작업이 JavaScript를 통해 이루어지기 때문이다.</p>
<p>그래서 JavaScript 파일이 너무 크거나, 초기 실행 코드가 많거나, 불필요한 리렌더링이 많으면 사용자는 페이지가 느리다고 느낄 수 있다.</p>
<hr>
<h2 id="code-splitting">Code Splitting</h2>
<p>Code Splitting은 JavaScript 번들을 여러 조각으로 나누는 기법이다.</p>
<p>처음부터 모든 코드를 한 번에 불러오는 대신, 필요한 코드만 먼저 불러오고 나머지는 나중에 불러올 수 있다.</p>
<p>예를 들어 모든 페이지를 정적으로 import하면 초기 번들에 많은 코드가 포함될 수 있다.</p>
<pre><code class="language-tsx">import HomePage from &#39;./pages/HomePage&#39;;
import DetailPage from &#39;./pages/DetailPage&#39;;
import MyPage from &#39;./pages/MyPage&#39;;</code></pre>
<p>이 방식은 페이지 이동은 빠를 수 있지만, 처음 로딩이 무거워질 수 있다.</p>
<p>반면 동적 import를 사용하면 특정 페이지 코드를 별도 chunk로 분리할 수 있다.</p>
<pre><code class="language-tsx">const DetailPage = lazy(() =&gt; import(&#39;./pages/DetailPage&#39;));</code></pre>
<p>이렇게 하면 <code>DetailPage</code> 코드는 처음부터 불러오지 않고, 실제로 해당 페이지가 필요해지는 순간에 로드된다.</p>
<hr>
<h2 id="code-splitting과-lazy-loading의-차이">Code Splitting과 Lazy Loading의 차이</h2>
<p>처음에는 Code Splitting과 Lazy Loading이 같은 개념처럼 느껴질 수 있다.</p>
<p>하지만 정확히는 역할이 다르다.</p>
<pre><code class="language-txt">Code Splitting
→ 코드를 여러 chunk로 나누는 것

Lazy Loading
→ 필요한 시점까지 로딩을 미루는 것</code></pre>
<p>React에서 자주 사용하는 코드는 다음과 같다.</p>
<pre><code class="language-tsx">const DetailPage = lazy(() =&gt; import(&#39;./pages/DetailPage&#39;));</code></pre>
<p>여기서 역할을 나누면 다음과 같다.</p>
<pre><code class="language-txt">import(&#39;./pages/DetailPage&#39;)
→ 번들러가 코드를 별도 chunk로 나눌 수 있게 함
→ Code Splitting

React.lazy(...)
→ 해당 컴포넌트를 필요한 시점에 렌더링하도록 함
→ Lazy Loading</code></pre>
<p>즉, Code Splitting은 “나누는 것”이고 Lazy Loading은 “나중에 불러오는 것”이다.</p>
<hr>
<h2 id="code-splitting이-항상-좋은-것은-아니다">Code Splitting이 항상 좋은 것은 아니다</h2>
<p>Code Splitting을 하면 초기 로딩은 가벼워질 수 있다.</p>
<p>하지만 처음 방문하는 페이지에서는 해당 chunk를 새로 다운로드해야 하므로 로딩 화면이 보일 수 있다.</p>
<p>따라서 무조건 많이 나누는 것이 좋은 것은 아니다.</p>
<p>기준은 다음과 같이 잡을 수 있다.</p>
<pre><code class="language-txt">초기 화면에 꼭 필요한 코드
→ 처음부터 로드

자주 사용하는 핵심 페이지
→ 상황에 따라 처음부터 로드

가끔 쓰는 무거운 페이지나 기능
→ Code Splitting 적용

사용자가 곧 접근할 가능성이 높은 페이지
→ Prefetching 고려</code></pre>
<p>예를 들어 다음과 같은 기능은 Code Splitting 대상으로 적합하다.</p>
<pre><code class="language-txt">차트 페이지
마크다운 에디터
지도
이미지 압축 기능
관리자 페이지
채팅방
PDF 생성 기능</code></pre>
<p>이런 기능은 모든 사용자가 처음부터 사용하는 것이 아니기 때문에, 필요할 때만 불러오는 것이 더 효율적일 수 있다.</p>
<hr>
<h2 id="reactlazy-없이도-code-splitting은-가능하다">React.lazy 없이도 Code Splitting은 가능하다</h2>
<p>Code Splitting은 꼭 페이지 컴포넌트에만 적용하는 것이 아니다.</p>
<p>사용자 액션이 발생했을 때 무거운 라이브러리를 동적으로 불러올 수도 있다.</p>
<p>예를 들어 이미지 압축 라이브러리를 처음부터 불러오지 않고, 사용자가 이미지를 업로드할 때만 불러올 수 있다.</p>
<pre><code class="language-tsx">const handleImageChange = async (file: File) =&gt; {
  const { default: imageCompression } = await import(
    &#39;browser-image-compression&#39;
  );

  const compressedFile = await imageCompression(file, {
    maxSizeMB: 1,
    maxWidthOrHeight: 1024,
  });

  console.log(compressedFile);
};</code></pre>
<p>이 경우 <code>React.lazy</code>는 사용하지 않았지만 <code>import()</code>를 사용했기 때문에 번들러가 해당 라이브러리를 별도 chunk로 분리할 수 있다.</p>
<p>즉, 컴포넌트 Lazy Loading이 아니더라도 특정 기능이나 라이브러리를 필요할 때만 불러오는 방식으로 Code Splitting을 활용할 수 있다.</p>
<hr>
<h2 id="무거운-코드는-어떻게-찾을까">무거운 코드는 어떻게 찾을까?</h2>
<p>최적화는 감으로 하는 것이 아니라 측정이 먼저다.</p>
<p>무거운 코드를 찾을 때는 보통 다음 도구들을 함께 사용한다.</p>
<pre><code class="language-txt">Chrome DevTools Network 탭
→ 어떤 JS 파일이 얼마나 다운로드되는지 확인

Chrome DevTools Performance 탭
→ JS 실행, 렌더링, 메인 스레드 부담 확인

번들 분석 도구
→ 번들 안에서 어떤 라이브러리가 큰 비중을 차지하는지 확인</code></pre>
<p>Network 탭에서는 JS 파일의 크기와 다운로드 시간을 볼 수 있다.</p>
<p>하지만 <code>index-abc123.js</code> 같은 파일명만 보고는 그 안에 어떤 라이브러리가 들어 있는지 알기 어렵다.</p>
<p>그래서 번들 분석 도구가 필요하다.</p>
<hr>
<h2 id="번들-분석-도구">번들 분석 도구</h2>
<p>Webpack 환경에서는 <code>webpack-bundle-analyzer</code>를 사용할 수 있다.</p>
<p>이 도구는 번들 안에 어떤 모듈이 얼마나 큰 비중을 차지하는지 시각적으로 보여준다.</p>
<p>하지만 Vite 프로젝트에서는 보통 <code>rollup-plugin-visualizer</code>를 사용한다.</p>
<p>Vite는 프로덕션 빌드에서 Rollup 기반으로 번들링되기 때문이다.</p>
<pre><code class="language-bash">pnpm add -D rollup-plugin-visualizer</code></pre>
<pre><code class="language-ts">// vite.config.ts
import { defineConfig } from &#39;vite&#39;;
import react from &#39;@vitejs/plugin-react&#39;;
import { visualizer } from &#39;rollup-plugin-visualizer&#39;;

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: &#39;dist/stats.html&#39;,
      open: true,
      gzipSize: true,
      brotliSize: true,
    }),
  ],
});</code></pre>
<p>이후 빌드하면 분석 리포트를 확인할 수 있다.</p>
<pre><code class="language-bash">pnpm build</code></pre>
<p>번들 분석 도구를 사용하면 다음과 같은 판단이 가능하다.</p>
<pre><code class="language-txt">어떤 라이브러리가 번들을 크게 만드는지
중복으로 포함된 패키지가 있는지
Code Splitting이 잘 적용되었는지
특정 페이지 chunk가 너무 큰지</code></pre>
<p>예를 들어 차트 라이브러리가 특정 페이지에서만 사용되는데 초기 번들에 포함되어 있다면, 해당 페이지를 Code Splitting 대상으로 고려할 수 있다.</p>
<hr>
<h2 id="css-최적화와-javascript-실행시간">CSS 최적화와 JavaScript 실행시간</h2>
<p>CSS 최적화는 JavaScript 실행시간과 별개처럼 보일 수 있다.</p>
<p>하지만 실제 렌더링 과정에서는 JS, CSS, DOM이 서로 연결되어 있다.</p>
<p>브라우저는 화면을 그릴 때 다음 과정을 거친다.</p>
<pre><code class="language-txt">HTML 파싱
CSS 파싱
DOM 생성
CSSOM 생성
렌더 트리 생성
레이아웃 계산
페인트
합성</code></pre>
<p>JavaScript가 DOM을 변경하면 브라우저는 다시 스타일을 계산하거나 레이아웃을 계산해야 할 수 있다.</p>
<p>이때 CSS가 복잡하거나 불필요한 스타일이 많으면 화면 업데이트 비용이 커진다.</p>
<p>CSS 최적화에서 신경 쓸 수 있는 부분은 다음과 같다.</p>
<pre><code class="language-txt">사용하지 않는 CSS 제거
복잡한 선택자 줄이기
불필요한 레이아웃 재계산 줄이기
애니메이션 성능 개선
초기 화면에 필요한 CSS 우선 로드</code></pre>
<p>특히 레이아웃을 자주 바꾸는 속성은 주의해야 한다.</p>
<pre><code class="language-txt">width
height
top
left
margin
padding</code></pre>
<p>이런 속성은 요소의 크기나 위치를 바꾸기 때문에 레이아웃 계산을 다시 발생시킬 수 있다.</p>
<p>반면 애니메이션에서는 보통 다음 속성이 더 유리하다.</p>
<pre><code class="language-txt">transform
opacity</code></pre>
<p>예를 들어 모달이나 버튼 애니메이션을 구현할 때 <code>top</code>이나 <code>height</code>를 계속 바꾸는 것보다 <code>transform</code>을 사용하는 편이 성능상 유리할 수 있다.</p>
<hr>
<h2 id="reactmemo">React.memo</h2>
<p><code>React.memo</code>는 컴포넌트의 불필요한 리렌더링을 줄이기 위한 기능이다.</p>
<p>부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 다시 렌더링될 수 있다.</p>
<p>하지만 자식 컴포넌트가 받는 props가 바뀌지 않았다면 다시 렌더링할 필요가 없을 수 있다.</p>
<p>이때 <code>React.memo</code>를 사용할 수 있다.</p>
<pre><code class="language-tsx">import { memo } from &#39;react&#39;;

interface PostItemProps {
  title: string;
  content: string;
}

const PostItem = ({ title, content }: PostItemProps) =&gt; {
  return (
    &lt;li&gt;
      &lt;h3&gt;{title}&lt;/h3&gt;
      &lt;p&gt;{content}&lt;/p&gt;
    &lt;/li&gt;
  );
};

export default memo(PostItem);</code></pre>
<p><code>React.memo</code>는 이런 경우에 유용하다.</p>
<pre><code class="language-txt">리스트 아이템 컴포넌트
렌더링 비용이 큰 컴포넌트
부모가 자주 리렌더링되는 경우
props가 자주 바뀌지 않는 경우</code></pre>
<p>하지만 모든 컴포넌트에 무조건 붙이는 것은 좋지 않다.</p>
<p><code>React.memo</code>도 이전 props와 다음 props를 비교하는 비용이 있기 때문이다.</p>
<p>컴포넌트가 아주 단순하거나 props가 매번 바뀐다면 효과가 거의 없을 수 있다.</p>
<hr>
<h2 id="usememo">useMemo</h2>
<p><code>useMemo</code>는 계산 결과를 메모이제이션하는 Hook이다.</p>
<p>즉, 값을 매번 다시 계산하지 않고 의존성이 바뀔 때만 다시 계산하게 한다.</p>
<pre><code class="language-tsx">const filteredPosts = useMemo(() =&gt; {
  return postList.filter((post) =&gt; post.title.includes(keyword));
}, [postList, keyword]);</code></pre>
<p><code>useMemo</code>는 다음과 같은 경우에 유용하다.</p>
<pre><code class="language-txt">계산 비용이 큰 작업
큰 배열에 대한 filter, sort, reduce
객체나 배열 참조를 안정적으로 유지해야 하는 경우
memo 컴포넌트에 객체나 배열 props를 넘기는 경우</code></pre>
<p>하지만 간단한 계산에는 굳이 사용할 필요가 없다.</p>
<pre><code class="language-tsx">const fullName = firstName + lastName;</code></pre>
<p>이런 정도의 계산은 그냥 매번 계산해도 부담이 거의 없다.</p>
<p>또한 의존성이 자주 바뀌는 경우에는 <code>useMemo</code>가 계산을 막아주지 못한다.</p>
<p>예를 들어 <code>keyword</code>가 입력될 때마다 바뀐다면, <code>filteredPosts</code>는 결국 매번 다시 계산된다.</p>
<p>따라서 <code>useMemo</code>는 “모든 재계산을 막는 도구”가 아니라, <strong>의존성이 바뀌지 않았을 때 이전 계산 결과를 재사용하는 도구</strong>로 이해해야 한다.</p>
<hr>
<h2 id="usecallback">useCallback</h2>
<p><code>useCallback</code>은 함수를 메모이제이션하는 Hook이다.</p>
<p>React 컴포넌트 안에서 함수를 선언하면 렌더링될 때마다 새로운 함수가 생성된다.</p>
<pre><code class="language-tsx">const handleClick = () =&gt; {
  console.log(&#39;click&#39;);
};</code></pre>
<p>이 함수가 memo된 자식 컴포넌트에 props로 전달되면, 자식 입장에서는 props가 매번 바뀐 것으로 판단될 수 있다.</p>
<p>이때 <code>useCallback</code>을 사용할 수 있다.</p>
<pre><code class="language-tsx">const handlePostClick = useCallback((postId: number) =&gt; {
  console.log(`${postId}번 게시글 클릭`);
}, []);</code></pre>
<p><code>useCallback</code>은 다음과 같은 경우에 유용하다.</p>
<pre><code class="language-txt">memo된 자식 컴포넌트에 함수를 props로 넘길 때
함수가 useEffect 의존성으로 들어갈 때
커스텀 훅에서 함수를 반환할 때</code></pre>
<p>하지만 모든 함수에 <code>useCallback</code>을 붙일 필요는 없다.</p>
<p>단순한 이벤트 핸들러에 무조건 적용하면 코드만 복잡해질 수 있다.</p>
<hr>
<h2 id="reactmemo-usememo-usecallback-비교">React.memo, useMemo, useCallback 비교</h2>
<p>세 가지는 모두 메모이제이션과 관련이 있지만 최적화 대상이 다르다.</p>
<table>
<thead>
<tr>
<th>개념</th>
<th>최적화 대상</th>
<th>사용 목적</th>
</tr>
</thead>
<tbody><tr>
<td>React.memo</td>
<td>컴포넌트</td>
<td>props가 바뀌지 않으면 리렌더링 방지</td>
</tr>
<tr>
<td>useMemo</td>
<td>값</td>
<td>계산 결과 재사용</td>
</tr>
<tr>
<td>useCallback</td>
<td>함수</td>
<td>함수 참조 재사용</td>
</tr>
</tbody></table>
<p>간단히 정리하면 다음과 같다.</p>
<pre><code class="language-txt">React.memo
→ 컴포넌트 렌더링 결과를 재사용

useMemo
→ 계산된 값을 재사용

useCallback
→ 함수를 재사용</code></pre>
<hr>
<h2 id="최적화는-언제-해야-할까">최적화는 언제 해야 할까?</h2>
<p>이번 내용을 공부하면서 가장 중요하게 느낀 점은, 최적화는 무작정 적용하는 것이 아니라는 점이다.</p>
<p>성능 최적화는 다음 흐름으로 접근하는 것이 좋다.</p>
<pre><code class="language-txt">1. 실제로 느린지 확인한다
2. DevTools로 병목 지점을 찾는다
3. 번들 크기와 실행 시간을 확인한다
4. 문제가 되는 코드나 라이브러리를 찾는다
5. 필요한 곳에만 최적화를 적용한다</code></pre>
<p>무조건 <code>React.memo</code>, <code>useMemo</code>, <code>useCallback</code>을 붙인다고 성능이 좋아지는 것은 아니다.</p>
<p>오히려 코드가 복잡해지고 유지보수가 어려워질 수 있다.</p>
<p>중요한 것은 다음 질문을 먼저 던지는 것이다.</p>
<pre><code class="language-txt">이 컴포넌트가 정말 자주 리렌더링되는가?
이 계산이 정말 비싼가?
이 함수 참조가 자식 리렌더링에 영향을 주는가?
이 라이브러리가 초기 번들에 꼭 필요한가?</code></pre>
<p>측정 없이 최적화하기보다, 병목을 확인한 뒤 필요한 부분에 적용하는 것이 더 실무적인 접근이라고 느꼈다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>이번 주에는 JavaScript 실행시간을 줄이기 위한 여러 방법을 공부했다.</p>
<p>Code Splitting은 초기 번들 크기를 줄이는 데 도움이 되고, 번들 분석 도구는 어떤 코드가 번들을 무겁게 만드는지 확인하는 데 유용하다.</p>
<p>CSS 최적화 역시 렌더링 과정과 연결되어 있기 때문에 JavaScript 실행시간 최적화와 함께 고려해야 한다.</p>
<p>또한 <code>React.memo</code>, <code>useMemo</code>, <code>useCallback</code>은 불필요한 리렌더링이나 재계산을 줄일 수 있지만, 무조건 사용하는 도구는 아니다.</p>
<p>결국 성능 최적화의 핵심은 단순히 기술을 많이 적용하는 것이 아니라, <strong>측정하고, 원인을 찾고, 필요한 곳에 적절한 최적화를 적용하는 것</strong>이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[사용자가 기다리기 전에 준비하기: 지연시간 최적화]]></title>
            <link>https://velog.io/@jina___/%EC%A7%80%EC%97%B0%EC%8B%9C%EA%B0%84-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@jina___/%EC%A7%80%EC%97%B0%EC%8B%9C%EA%B0%84-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Mon, 11 May 2026 09:26:37 GMT</pubDate>
            <description><![CDATA[<p><strong>사용자가 기다리는 시간을 어떻게 줄일 수 있을까?</strong></p>
<p>이전에 정리했던 <strong>웹 성능 최적화</strong>, <strong>Lazy Loading</strong>, <strong>Code Splitting</strong>, <strong>Tree Shaking</strong>, <strong>이미지 최적화</strong>가 주로 불필요한 리소스를 줄이거나 늦게 불러오는 것에 가까웠다면, </p>
<p><strong>지연시간 최적화</strong>는 한 단계 더 나아가서 <strong>필요한 데이터를 더 빠르게 가져오게 만드는 전략</strong>이라고 볼 수 있다.</p>
<hr>
<h2 id="지연시간-최적화">지연시간 최적화</h2>
<blockquote>
<p><strong>지연시간 최적화(Latency Optimization)</strong><br>사용자가 요청을 보낸 뒤 응답을 받기까지 걸리는 시간을 줄여, 페이지나 기능이 더 빠르게 반응하도록 만드는 최적화 방식</p>
</blockquote>
<p>예를 들어 사용자가 버튼을 눌렀을 때, 다음과 같은 과정이 일어난다.</p>
<pre><code class="language-text">사용자 클릭 → 서버 요청 → 서버 처리 → 응답 도착 → 화면 렌더링</code></pre>
<p>이 과정에서 기다리는 시간이 길어지면 사용자는 서비스를 “느리다”고 느끼게 된다.</p>
<p>그래서 지연시간 최적화의 핵심은 다음과 같다.</p>
<blockquote>
<p><strong>사용자가 요청하기 전에 미리 준비하거나, 가까운 곳에서 빠르게 가져오거나, 이미 받은 것은 다시 요청하지 않도록 하는 것</strong></p>
</blockquote>
<p>이번 주제의 핵심 태그는 <strong>Caching, Prefetching, CDN</strong>이다.</p>
<hr>
<h2 id="caching">Caching</h2>
<h3 id="caching의-개념">Caching의 개념</h3>
<blockquote>
<p><strong>Caching</strong>은 한 번 받아온 데이터나 리소스를 저장해두고, 다음에 필요할 때 다시 서버에 요청하지 않고 저장된 것을 재사용하는 방식이다.</p>
</blockquote>
<p>예를 들어 로고 이미지, 폰트, CSS, JavaScript 파일처럼 자주 바뀌지 않는 리소스는 매번 새로 받을 필요가 없다.</p>
<pre><code class="language-text">첫 방문:
서버에서 이미지 다운로드 → 브라우저에 저장

두 번째 방문:
서버 요청 없이 브라우저 캐시에서 바로 사용</code></pre>
<p>이렇게 하면 네트워크 요청 자체가 줄어들기 때문에 훨씬 빠르게 느껴질 수 있다.</p>
<hr>
<h3 id="브라우저-캐시">브라우저 캐시</h3>
<p>이전에 Network 탭에서 이미지 크기 대신 <strong>(disk cache)</strong> 라고 표시된 것을 본 적이 있다.</p>
<p>이것이 바로 브라우저 캐시다.</p>
<pre><code class="language-text">(disk cache)
→ 브라우저가 이전에 저장해둔 파일을 디스크에서 다시 가져온 것

(memory cache)
→ 메모리에 저장된 파일을 바로 가져온 것</code></pre>
<p>즉, 이미 받아온 이미지를 또 다운로드하지 않고 재사용했기 때문에 네트워크 비용이 줄어든 것이다.</p>
<hr>
<h3 id="프론트엔드에서의-캐싱">프론트엔드에서의 캐싱</h3>
<p>프론트엔드에서는 크게 두 가지 캐싱을 생각할 수 있다.</p>
<h4 id="정적-리소스-캐싱">정적 리소스 캐싱</h4>
<p>이미지, CSS, JavaScript, 폰트 같은 파일을 캐싱하는 것이다.</p>
<pre><code class="language-text">logo.png
main.css
bundle.js
Pretendard.woff2</code></pre>
<p>이런 리소스들은 브라우저 캐시나 CDN 캐시를 활용할 수 있다.</p>
<h4 id="서버-데이터-캐싱">서버 데이터 캐싱</h4>
<p>API로 받아온 데이터를 일정 시간 동안 재사용하는 것이다.</p>
<p>예를 들어 React Query를 사용하면 다음과 같이 캐싱 전략을 줄 수 있다.</p>
<pre><code class="language-tsx">useQuery({
  queryKey: [&#39;posts&#39;],
  queryFn: getPosts,
  staleTime: 1000 * 60 * 5,
});</code></pre>
<p>여기서 <code>staleTime</code>은 “이 시간 동안은 데이터가 신선하다고 보고 다시 요청하지 않겠다”는 의미다.</p>
<p>즉, 사용자가 페이지를 왔다 갔다 해도 매번 API를 다시 호출하지 않기 때문에 응답 대기시간이 줄어들 수 있다.</p>
<hr>
<h2 id="prefetching">Prefetching</h2>
<h3 id="prefetching의-개념">Prefetching의 개념</h3>
<blockquote>
<p><strong>Prefetching</strong>은 사용자가 곧 필요로 할 가능성이 높은 리소스나 데이터를 미리 가져와두는 방식이다.</p>
</blockquote>
<p>쉽게 말하면 <strong>사용자가 누르기 전에 미리 준비해두기</strong>다.</p>
<p>예를 들어 사용자가 게시글 목록 페이지에 있다고 가정해보자.</p>
<pre><code class="language-text">게시글 목록 페이지
→ 사용자가 특정 게시글을 클릭할 가능성이 높음
→ 상세 페이지 데이터나 코드를 미리 불러와두기</code></pre>
<p>그러면 사용자가 실제로 클릭했을 때 이미 준비되어 있기 때문에 페이지 전환이 훨씬 빠르게 느껴질 수 있다.</p>
<hr>
<h3 id="lazy-loading과-prefetching">Lazy Loading과 Prefetching</h3>
<p>Lazy Loading과 Prefetching은 어느 정도 반대 방향의 전략처럼 볼 수 있다.</p>
<pre><code class="language-text">Lazy Loading
→ 당장 필요 없는 것은 나중에 불러오기

Prefetching
→ 곧 필요할 것 같은 것은 미리 불러오기</code></pre>
<p>하지만 둘의 목적은 같다.</p>
<blockquote>
<p><strong>사용자가 느끼는 대기시간을 줄이는 것</strong></p>
</blockquote>
<p>다만 접근 방식이 다르다.</p>
<p>Lazy Loading은 초기 로딩 부담을 줄이는 전략이고, Prefetching은 이후 행동을 빠르게 만드는 전략이다.</p>
<hr>
<h3 id="react-query와-prefetching">React Query와 Prefetching</h3>
<p>React Query를 사용하면 특정 데이터를 미리 가져올 수 있다.</p>
<p>예를 들어 채용 공고 목록에서 사용자가 카드에 마우스를 올렸을 때 상세 데이터를 미리 가져올 수 있다.</p>
<pre><code class="language-tsx">const queryClient = useQueryClient();

const handlePrefetchRecruitDetail = (id: number) =&gt; {
  queryClient.prefetchQuery({
    queryKey: [&#39;recruitDetail&#39;, id],
    queryFn: () =&gt; getRecruitDetail(id),
  });
};</code></pre>
<p>사용 예시는 다음과 같다.</p>
<pre><code class="language-tsx">&lt;RecruitCard
  onMouseEnter={() =&gt; handlePrefetchRecruitDetail(recruit.id)}
  onClick={() =&gt; navigate(`/recruit/${recruit.id}`)}
/&gt;</code></pre>
<p>그러면 사용자가 클릭하기 전에 상세 데이터를 미리 요청해둘 수 있다.</p>
<pre><code class="language-text">마우스 올림 → 상세 데이터 미리 요청
클릭 → 이미 캐시된 데이터 사용</code></pre>
<p>결과적으로 상세 페이지가 더 빠르게 뜨는 것처럼 느껴질 수 있다.</p>
<hr>
<h3 id="code-splitting과-prefetching">Code Splitting과 Prefetching</h3>
<p>이전에 배웠던 <code>React.lazy()</code>도 Prefetching과 연결해서 생각할 수 있다.</p>
<p>Code Splitting은 페이지별 JavaScript 코드를 나누는 것이었다.</p>
<pre><code class="language-tsx">const RecruitDetailPage = lazy(() =&gt; import(&#39;./RecruitDetailPage&#39;));</code></pre>
<p>이렇게 하면 상세 페이지 코드는 처음부터 받지 않고, 상세 페이지에 들어갈 때 받아오게 된다.</p>
<p>그런데 사용자가 곧 상세 페이지에 들어갈 것 같다면, 그 JavaScript 파일을 미리 가져오는 전략도 가능하다.</p>
<pre><code class="language-text">Code Splitting
→ 필요한 페이지 코드를 분리함

Prefetching
→ 곧 필요할 분리된 코드를 미리 가져옴</code></pre>
<p>즉, Code Splitting과 Prefetching은 함께 사용하면 좋은 전략이다.</p>
<hr>
<h2 id="cdn">CDN</h2>
<h3 id="cdn의-개념">CDN의 개념</h3>
<blockquote>
<p><strong>CDN(Content Delivery Network)</strong>은 전 세계 여러 지역에 서버를 분산해두고, 사용자와 가까운 서버에서 리소스를 전달해주는 방식이다.</p>
</blockquote>
<p>원래 서버가 미국에 있다고 가정해보자.</p>
<p>한국 사용자가 이미지를 요청하면 미국 서버까지 요청이 가야 한다.</p>
<pre><code class="language-text">한국 사용자 → 미국 서버 → 한국 사용자</code></pre>
<p>거리가 멀수록 응답이 느려질 수 있다.</p>
<p>그런데 CDN을 사용하면 한국이나 일본처럼 더 가까운 CDN 서버에서 리소스를 받을 수 있다.</p>
<pre><code class="language-text">한국 사용자 → 가까운 CDN 서버 → 한국 사용자</code></pre>
<p>그래서 이미지, JavaScript, CSS, 폰트 같은 정적 파일을 훨씬 빠르게 받을 수 있다.</p>
<hr>
<h3 id="cdn과-caching">CDN과 Caching</h3>
<p>CDN은 단순히 가까운 서버 역할만 하는 것이 아니라, 리소스를 CDN 서버에 저장해두는 <strong>캐시 서버</strong> 역할도 한다.</p>
<pre><code class="language-text">첫 번째 사용자:
원본 서버에서 이미지 가져옴 → CDN에 저장

다음 사용자:
원본 서버까지 가지 않고 CDN에서 이미지 받음</code></pre>
<p>즉, CDN은 이렇게 이해할 수 있다.</p>
<blockquote>
<p><strong>사용자 가까이에 있는 캐시 저장소</strong></p>
</blockquote>
<p>그래서 CDN은 Caching과 아주 강하게 연결되어 있다.</p>
<hr>
<h2 id="핵심-개념-비교">핵심 개념 비교</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>핵심</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Caching</td>
<td>이미 받은 것을 저장해두고 재사용</td>
<td>이미지, JS, API 데이터 재사용</td>
</tr>
<tr>
<td>Prefetching</td>
<td>곧 필요할 것을 미리 가져오기</td>
<td>상세 페이지 데이터 미리 요청</td>
</tr>
<tr>
<td>CDN</td>
<td>가까운 서버에서 리소스 제공</td>
<td>이미지, CSS, JS를 CDN에서 전달</td>
</tr>
</tbody></table>
<p>조금 더 쉽게 말하면 다음과 같다.</p>
<pre><code class="language-text">Caching
→ 다시 안 받기

Prefetching
→ 미리 받아두기

CDN
→ 가까운 곳에서 받기</code></pre>
<hr>
<h2 id="실제-서비스-적용-흐름">실제 서비스 적용 흐름</h2>
<p>예를 들어 채용 공고 서비스를 만든다고 가정해보자.</p>
<p>사용자가 홈 화면에 들어오면 다음과 같은 흐름이 일어난다.</p>
<pre><code class="language-text">1. 홈 페이지 접속
2. 공고 목록 API 요청
3. 공고 카드 이미지 로딩
4. 사용자가 공고 상세 페이지 클릭
5. 상세 페이지 데이터 요청
6. 상세 페이지 화면 렌더링</code></pre>
<p>여기서 최적화 전략을 적용하면 다음과 같다.</p>
<pre><code class="language-text">공고 카드 이미지
→ CDN에서 빠르게 가져오기

한 번 받아온 공고 목록 데이터
→ React Query 캐시에 저장하기

사용자가 상세 카드에 관심을 보임
→ 상세 데이터 Prefetching 하기

로고, 폰트, JS 파일
→ 브라우저 캐싱 활용하기</code></pre>
<p>그러면 사용자는 페이지를 이동할 때마다 매번 기다리지 않아도 된다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>사용자가 같은 리소스를 반복해서 요청하지 않도록 <strong>Caching</strong>을 활용하고, 곧 필요할 가능성이 높은 데이터를 <strong>Prefetching</strong>으로 미리 준비하며, 정적 리소스는 사용자와 가까운 <strong>CDN</strong>을 통해 전달할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Router 파헤치기]]></title>
            <link>https://velog.io/@jina___/React-Router-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</link>
            <guid>https://velog.io/@jina___/React-Router-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0</guid>
            <pubDate>Fri, 08 May 2026 14:54:33 GMT</pubDate>
            <description><![CDATA[<p>지금까지 React 프로젝트에서 라우팅을 구현할 때는 자연스럽게 <code>BrowserRouter</code>를 사용했다.</p>
<p>보통 <code>main.tsx</code>에서 앱 전체를 <code>BrowserRouter</code>로 감싸고, <code>App.tsx</code> 안에서 <code>Routes</code>와 <code>Route</code>를 사용해 페이지를 연결하는 방식이었다.</p>
<pre><code class="language-tsx">import { BrowserRouter } from &quot;react-router&quot;;

createRoot(document.getElementById(&quot;root&quot;)!).render(
  &lt;BrowserRouter&gt;
    &lt;App /&gt;
  &lt;/BrowserRouter&gt;,
);</code></pre>
<pre><code class="language-tsx">import { Routes, Route } from &quot;react-router&quot;;

const App = () =&gt; {
  return (
    &lt;Routes&gt;
      &lt;Route path=&quot;/&quot; element={&lt;Home /&gt;} /&gt;
      &lt;Route path=&quot;/login&quot; element={&lt;Login /&gt;} /&gt;
      &lt;Route path=&quot;/members&quot; element={&lt;Members /&gt;} /&gt;
    &lt;/Routes&gt;
  );
};

export default App;</code></pre>
<p>이 방식은 처음 React Router를 배울 때 이해하기 쉽다.</p>
<p><code>BrowserRouter</code>가 라우팅이 가능한 환경을 만들어주고, <code>Routes</code> 안에 <code>Route</code>를 나열하면서 URL과 컴포넌트를 연결하면 되기 때문이다.</p>
<p>그런데 이번에 React Router를 다시 사용하면서 <code>createBrowserRouter</code>와 <code>RouterProvider</code>를 사용하는 방식을 보게 되었다.</p>
<p>처음에는 단순히 문법만 다른 줄 알았는데, 알고 보니 라우팅을 바라보는 구조 자체가 조금 달랐다.</p>
<p>이번 글에서는 <code>createBrowserRouter</code> 방식이 무엇인지 정리해보려고 한다.</p>
<br />

<h2 id="react-router는-왜-필요할까">React Router는 왜 필요할까?</h2>
<p>React는 SPA, 즉 Single Page Application을 만들 때 자주 사용된다.</p>
<p>SPA는 페이지를 이동할 때마다 HTML 문서를 새로 받아오는 방식이 아니라, 하나의 페이지 안에서 필요한 컴포넌트만 바꿔 렌더링하는 방식이다.</p>
<p>예를 들어 다음과 같은 URL이 있다고 해보자.</p>
<pre><code class="language-text">/          → Home
/login     → Login
/members   → MemberList
/members/1 → MemberDetail</code></pre>
<p>사용자는 서로 다른 페이지로 이동한다고 느끼지만, 실제로는 React 앱 내부에서 URL에 맞는 컴포넌트를 렌더링하고 있는 것이다.</p>
<p>이때 URL과 컴포넌트를 연결해주는 라이브러리가 <code>React Router</code>다.</p>
<p>즉, React Router는 단순히 페이지를 이동시키는 도구라기보다는 <strong>현재 URL에 따라 어떤 UI를 보여줄지 결정하는 라우팅 도구</strong>라고 볼 수 있다.</p>
<br />

<h2 id="기존에-사용하던-browserrouter-방식">기존에 사용하던 BrowserRouter 방식</h2>
<p>내가 기존에 익숙했던 방식은 다음과 같다.</p>
<pre><code class="language-tsx">import { BrowserRouter } from &quot;react-router&quot;;
import App from &quot;./App&quot;;

createRoot(document.getElementById(&quot;root&quot;)!).render(
  &lt;BrowserRouter&gt;
    &lt;App /&gt;
  &lt;/BrowserRouter&gt;,
);</code></pre>
<p>그리고 <code>App.tsx</code>에서 라우트를 선언한다.</p>
<pre><code class="language-tsx">import { Routes, Route } from &quot;react-router&quot;;

const App = () =&gt; {
  return (
    &lt;Routes&gt;
      &lt;Route path=&quot;/&quot; element={&lt;Home /&gt;} /&gt;
      &lt;Route path=&quot;/login&quot; element={&lt;Login /&gt;} /&gt;
      &lt;Route path=&quot;/signup&quot; element={&lt;Signup /&gt;} /&gt;
      &lt;Route path=&quot;/members&quot; element={&lt;Members /&gt;} /&gt;
      &lt;Route path=&quot;/members/:id&quot; element={&lt;MemberDetail /&gt;} /&gt;
    &lt;/Routes&gt;
  );
};

export default App;</code></pre>
<p>이 구조에서 핵심은 다음과 같다.</p>
<ul>
<li><code>BrowserRouter</code>가 앱 전체에 라우팅 기능을 제공한다.</li>
<li><code>Routes</code>는 여러 개의 <code>Route</code> 중 현재 URL과 맞는 것을 찾아준다.</li>
<li><code>Route</code>는 특정 path와 렌더링할 컴포넌트를 연결한다.</li>
</ul>
<p>이 방식은 JSX로 라우트를 직접 작성하기 때문에 처음 보기에는 더 직관적이다.</p>
<p>하지만 페이지가 많아지고, 레이아웃이 중첩되고, 로그인 여부에 따라 접근을 막아야 하는 페이지가 생기면 <code>App.tsx</code> 안의 라우트 구조가 점점 복잡해질 수 있다.</p>
<br />

<h2 id="createbrowserrouter-방식">createBrowserRouter 방식</h2>
<p>이번에 새로 사용한 방식은 <code>createBrowserRouter</code>와 <code>RouterProvider</code>를 사용하는 방식이다.</p>
<pre><code class="language-tsx">import { createBrowserRouter, RouterProvider } from &quot;react-router&quot;;
import RootLayout from &quot;./layouts/RootLayout&quot;;
import Home from &quot;./pages/Home&quot;;
import Login from &quot;./pages/Login&quot;;
import Members from &quot;./pages/Members&quot;;
import MemberDetail from &quot;./pages/MemberDetail&quot;;

const router = createBrowserRouter([
  {
    path: &quot;/&quot;,
    Component: RootLayout,
    children: [
      {
        index: true,
        Component: Home,
      },
      {
        path: &quot;login&quot;,
        Component: Login,
      },
      {
        path: &quot;members&quot;,
        Component: Members,
      },
      {
        path: &quot;members/:id&quot;,
        Component: MemberDetail,
      },
    ],
  },
]);

const App = () =&gt; {
  return &lt;RouterProvider router={router} /&gt;;
};

export default App;</code></pre>
<p>처음 봤을 때 가장 낯설었던 점은 <code>Route</code>를 JSX로 작성하지 않는다는 점이었다.</p>
<p>기존 방식에서는 다음처럼 썼다.</p>
<pre><code class="language-tsx">&lt;Route path=&quot;/login&quot; element={&lt;Login /&gt;} /&gt;</code></pre>
<p>하지만 <code>createBrowserRouter</code> 방식에서는 라우팅 정보를 객체 배열로 선언한다.</p>
<pre><code class="language-tsx">{
  path: &quot;login&quot;,
  Component: Login,
}</code></pre>
<p>즉, 라우팅 구조를 JSX가 아니라 <strong>객체 형태의 설정 정보</strong>로 관리하는 방식이다.</p>
<br />

<h2 id="element-대신-component">element 대신 Component<img src="https://velog.velcdn.com/images/jina___/post/068e0800-f735-43f2-bd38-bc02ae29ba77/image.png" alt=""></h2>
<p>React Router 예시를 찾아보면 보통 이런 코드가 많이 나온다.</p>
<pre><code class="language-tsx">{
  path: &quot;login&quot;,
  element: &lt;Login /&gt;,
}</code></pre>
<p>하지만 내가 사용한 방식은 다음과 같다.</p>
<pre><code class="language-tsx">{
  path: &quot;login&quot;,
  Component: Login,
}</code></pre>
<p>처음에는 <code>element</code>와 <code>Component</code>가 완전히 다른 기능인가 싶었는데, 둘 다 해당 경로에서 렌더링할 컴포넌트를 지정한다는 점은 비슷하다.</p>
<p>다만 작성 방식이 다르다.</p>
<pre><code class="language-tsx">// element 방식
{
  path: &quot;login&quot;,
  element: &lt;Login /&gt;,
}

// Component 방식
{
  path: &quot;login&quot;,
  Component: Login,
}</code></pre>
<p><code>element</code> 방식은 JSX 요소를 직접 넘긴다.</p>
<p>반면 <code>Component</code> 방식은 컴포넌트 자체를 넘긴다.</p>
<p>그래서 <code>Component</code>를 사용할 때는 <code>&lt;Login /&gt;</code>처럼 작성하지 않고 <code>Login</code>이라고 작성해야 한다.</p>
<pre><code class="language-tsx">// 올바른 방식
{
  path: &quot;login&quot;,
  Component: Login,
}

// 잘못된 방식
{
  path: &quot;login&quot;,
  Component: &lt;Login /&gt;,
}</code></pre>
<p>이 부분이 헷갈릴 수 있다.</p>
<p><code>Component</code>는 말 그대로 컴포넌트 함수를 받는 속성이기 때문에 JSX 태그 형태가 아니라 컴포넌트 이름을 그대로 전달해야 한다.</p>
<br />

<h2 id="browserrouter-방식과-createbrowserrouter-방식-비교">BrowserRouter 방식과 createBrowserRouter 방식 비교</h2>
<p>두 방식의 차이를 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>BrowserRouter 방식</th>
<th>createBrowserRouter 방식</th>
</tr>
</thead>
<tbody><tr>
<td>라우트 선언 방식</td>
<td>JSX로 선언</td>
<td>객체 배열로 선언</td>
</tr>
<tr>
<td>주로 사용하는 API</td>
<td><code>BrowserRouter</code>, <code>Routes</code>, <code>Route</code></td>
<td><code>createBrowserRouter</code>, <code>RouterProvider</code></td>
</tr>
<tr>
<td>렌더링 컴포넌트 지정</td>
<td><code>element={&lt;Page /&gt;}</code></td>
<td><code>Component: Page</code> 또는 <code>element: &lt;Page /&gt;</code></td>
</tr>
<tr>
<td>중첩 라우팅</td>
<td><code>Route</code> 안에 <code>Route</code> 작성</td>
<td><code>children</code> 배열 사용</td>
</tr>
<tr>
<td>라우터 구조 분리</td>
<td><code>App.tsx</code> 안에 섞이기 쉬움</td>
<td><code>router.tsx</code> 같은 파일로 분리하기 좋음</td>
</tr>
<tr>
<td>확장성</td>
<td>단순 라우팅에 적합</td>
<td>loader, action 같은 Data API까지 확장 가능</td>
</tr>
</tbody></table>
<p>내가 느낀 가장 큰 차이는 <strong>라우팅을 컴포넌트 안에 작성하느냐, 라우팅 설정 객체로 분리하느냐</strong>였다.</p>
<p><code>BrowserRouter</code> 방식은 처음 배우기 쉽고 직관적이다.</p>
<p>반면 <code>createBrowserRouter</code> 방식은 처음에는 낯설지만, 라우트 구조가 커질수록 전체 라우팅 트리를 한눈에 보기 좋다.</p>
<br />

<h2 id="중첩-라우팅-이해하기">중첩 라우팅 이해하기</h2>
<p>React Router를 사용하다 보면 단순히 <code>/login</code>, <code>/signup</code>처럼 독립적인 페이지도 있지만, 특정 레이아웃 안에서 여러 페이지가 바뀌는 경우도 많다.</p>
<p>예를 들어 마이페이지가 있다고 해보자.</p>
<pre><code class="language-text">/mypage
/mypage/members
/mypage/members/:id</code></pre>
<p>이 페이지들은 모두 공통적으로 마이페이지 레이아웃을 사용할 수 있다.</p>
<p>이럴 때 중첩 라우팅을 사용할 수 있다.</p>
<pre><code class="language-tsx">const router = createBrowserRouter([
  {
    path: &quot;/mypage&quot;,
    Component: MyPageLayout,
    children: [
      {
        index: true,
        Component: MyPage,
      },
      {
        path: &quot;members&quot;,
        Component: Members,
      },
      {
        path: &quot;members/:id&quot;,
        Component: MemberDetail,
      },
    ],
  },
]);</code></pre>
<p>여기서 중요한 점은 자식 route의 path에 <code>/mypage/members</code>처럼 전체 경로를 쓰지 않는다는 것이다.</p>
<p>부모 route의 path가 <code>/mypage</code>이기 때문에, 자식 route에서는 그 뒤에 붙을 경로만 작성하면 된다.</p>
<pre><code class="language-tsx">{
  path: &quot;members&quot;,
  Component: Members,
}</code></pre>
<p>이렇게 작성하면 실제 경로는 다음과 같이 만들어진다.</p>
<pre><code class="language-text">/mypage/members</code></pre>
<p>만약 자식 route에 절대 경로처럼 <code>/members</code>를 작성하면, 부모 경로와 맞지 않아 에러가 날 수 있다.</p>
<p>중첩 라우팅에서는 자식 경로를 보통 상대 경로로 작성한다는 점을 기억해야 한다.</p>
<br />

<h2 id="outlet은-자식-route가-들어갈-자리다">Outlet은 자식 route가 들어갈 자리다</h2>
<p>중첩 라우팅을 사용할 때 꼭 같이 등장하는 개념이 <code>Outlet</code>이다.</p>
<pre><code class="language-tsx">import { Outlet } from &quot;react-router&quot;;
import Header from &quot;../components/Header&quot;;

const MyPageLayout = () =&gt; {
  return (
    &lt;&gt;
      &lt;Header /&gt;
      &lt;Outlet /&gt;
    &lt;/&gt;
  );
};

export default MyPageLayout;</code></pre>
<p>처음에는 <code>Outlet</code>이 왜 필요한지 헷갈릴 수 있다.</p>
<p>부모 route에 <code>children</code>을 작성했다고 해서 자식 컴포넌트가 아무 위치에나 자동으로 렌더링되는 것은 아니다.</p>
<p>부모 컴포넌트 안에서 자식 route가 렌더링될 위치를 지정해야 하는데, 그 역할을 하는 것이 <code>Outlet</code>이다.</p>
<p>예를 들어 다음과 같은 라우트 구조가 있다고 해보자.</p>
<pre><code class="language-tsx">{
  path: &quot;/mypage&quot;,
  Component: MyPageLayout,
  children: [
    {
      index: true,
      Component: MyPage,
    },
    {
      path: &quot;members&quot;,
      Component: Members,
    },
  ],
}</code></pre>
<p>사용자가 <code>/mypage/members</code>로 이동하면 먼저 부모인 <code>MyPageLayout</code>이 렌더링된다.</p>
<p>그리고 <code>MyPageLayout</code> 안의 <code>Outlet</code> 위치에 <code>Members</code> 컴포넌트가 렌더링된다.</p>
<pre><code class="language-tsx">const MyPageLayout = () =&gt; {
  return (
    &lt;&gt;
      &lt;Header /&gt;
      {/* 이 위치에 MyPage 또는 Members가 들어온다 */}
      &lt;Outlet /&gt;
    &lt;/&gt;
  );
};</code></pre>
<p>즉, <code>Outlet</code>은 <strong>중첩 라우팅에서 자식 페이지가 렌더링되는 자리</strong>라고 이해하면 된다.</p>
<br />

<h2 id="index-true는-기본-자식-페이지다">index: true는 기본 자식 페이지다</h2>
<p>중첩 라우팅을 사용하다 보면 <code>index: true</code>라는 속성도 자주 보인다.</p>
<pre><code class="language-tsx">{
  path: &quot;/members&quot;,
  Component: MembersLayout,
  children: [
    {
      index: true,
      Component: MemberList,
    },
    {
      path: &quot;:id&quot;,
      Component: MemberDetail,
    },
  ],
}</code></pre>
<p>이 구조에서 실제 URL은 다음과 같이 매칭된다.</p>
<pre><code class="language-text">/members    → MemberList
/members/1  → MemberDetail</code></pre>
<p><code>index: true</code>는 부모 경로에 정확히 들어왔을 때 보여줄 기본 자식 route를 의미한다.</p>
<p>즉, <code>/members</code>로 들어왔을 때 <code>MembersLayout</code>의 <code>Outlet</code> 자리에 기본으로 렌더링되는 컴포넌트가 <code>MemberList</code>다.</p>
<p>만약 <code>index: true</code>가 없다면 <code>/members</code>에 들어왔을 때 부모 레이아웃만 보이고, <code>Outlet</code>에는 아무것도 렌더링되지 않을 수 있다.</p>
<p>그래서 중첩 라우팅에서 부모 경로 자체에 보여줄 기본 화면이 필요하다면 <code>index: true</code>를 사용하면 된다.</p>
<br />

<h2 id="동적-라우팅-id">동적 라우팅: :id</h2>
<p>상세 페이지를 만들 때는 보통 URL에 따라 다른 데이터를 보여줘야 한다.</p>
<p>예를 들어 멤버 상세 페이지가 있다면 다음과 같은 URL이 만들어질 수 있다.</p>
<pre><code class="language-text">/members/1
/members/2
/members/3</code></pre>
<p>각각 다른 멤버의 상세 정보지만, 페이지 구조 자체는 동일하다.</p>
<p>이럴 때 사용하는 것이 동적 라우팅이다.</p>
<pre><code class="language-tsx">{
  path: &quot;members/:id&quot;,
  Component: MemberDetail,
}</code></pre>
<p>여기서 <code>:id</code>는 고정된 문자열이 아니라 URL에 따라 달라지는 값이다.</p>
<p>컴포넌트 안에서는 <code>useParams</code>를 사용해 이 값을 꺼낼 수 있다.</p>
<pre><code class="language-tsx">import { useParams } from &quot;react-router&quot;;

const MemberDetail = () =&gt; {
  const { id } = useParams();

  return &lt;div&gt;{id}번 멤버 상세 페이지&lt;/div&gt;;
};

export default MemberDetail;</code></pre>
<p>예를 들어 <code>/members/3</code>으로 이동하면 <code>id</code> 값은 <code>&quot;3&quot;</code>이 된다.</p>
<p>이렇게 하면 하나의 <code>MemberDetail</code> 컴포넌트를 여러 상세 페이지에서 재사용할 수 있다.</p>
<br />

<h2 id="link와-navigate는-언제-사용할까">Link와 navigate는 언제 사용할까?</h2>
<p>라우팅을 공부하다 보면 <code>Link</code>와 <code>useNavigate</code>도 같이 헷갈린다.</p>
<p>둘 다 페이지 이동을 할 수 있지만, 사용하는 상황이 조금 다르다.</p>
<h3 id="link">Link</h3>
<p><code>Link</code>는 사용자가 클릭해서 이동하는 UI를 만들 때 사용한다.</p>
<pre><code class="language-tsx">import { Link } from &quot;react-router&quot;;

const MemberCard = ({ id, name }: { id: number; name: string }) =&gt; {
  return (
    &lt;Link to={`/members/${id}`}&gt;
      &lt;article&gt;
        &lt;h3&gt;{name}&lt;/h3&gt;
      &lt;/article&gt;
    &lt;/Link&gt;
  );
};

export default MemberCard;</code></pre>
<p>카드 전체를 클릭해서 상세 페이지로 이동해야 한다면 <code>div</code>에 <code>onClick</code>을 붙일 수도 있지만, 실제로 페이지 이동 목적이라면 <code>Link</code>를 사용하는 것이 더 자연스럽다.</p>
<p>왜냐하면 <code>Link</code>는 HTML의 링크 의미를 살릴 수 있고, 사용자가 새 탭으로 열기 같은 기본 브라우저 동작도 사용할 수 있기 때문이다.</p>
<h3 id="usenavigate">useNavigate</h3>
<p>반면 <code>useNavigate</code>는 특정 로직이 끝난 뒤 코드로 이동시켜야 할 때 사용한다.</p>
<pre><code class="language-tsx">import { useNavigate } from &quot;react-router&quot;;

const Login = () =&gt; {
  const navigate = useNavigate();

  const handleLogin = () =&gt; {
    // 로그인 요청 성공 후
    navigate(&quot;/mypage&quot;);
  };

  return &lt;button onClick={handleLogin}&gt;로그인&lt;/button&gt;;
};

export default Login;</code></pre>
<p>정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 방식</th>
</tr>
</thead>
<tbody><tr>
<td>사용자가 클릭해서 페이지 이동</td>
<td><code>Link</code></td>
</tr>
<tr>
<td>로그인 성공 후 이동</td>
<td><code>useNavigate</code></td>
</tr>
<tr>
<td>폼 제출 성공 후 이동</td>
<td><code>useNavigate</code></td>
</tr>
<tr>
<td>단순 메뉴, 카드, 버튼형 링크</td>
<td>가능하면 <code>Link</code></td>
</tr>
</tbody></table>
<br />

<h2 id="상대-경로도-헷갈렸다">상대 경로도 헷갈렸다</h2>
<p>중첩 라우팅을 사용하면 <code>to=&quot;..&quot;</code>, <code>to=&quot;../members&quot;</code> 같은 상대 경로도 자주 보인다.</p>
<p>예를 들어 현재 URL이 다음과 같다고 해보자.</p>
<pre><code class="language-text">/mypage/members/1</code></pre>
<p>여기서 <code>Link</code>를 이렇게 작성할 수 있다.</p>
<pre><code class="language-tsx">&lt;Link to=&quot;..&quot;&gt;뒤로가기&lt;/Link&gt;</code></pre>
<p>이 경우 현재 route 기준으로 한 단계 위로 이동한다.</p>
<p>즉, 보통 다음 경로로 이동한다.</p>
<pre><code class="language-text">/mypage/members</code></pre>
<p>반면 다음처럼 작성하면:</p>
<pre><code class="language-tsx">&lt;Link to=&quot;../members&quot;&gt;멤버 목록&lt;/Link&gt;</code></pre>
<p>현재 route 구조에 따라 예상과 다르게 <code>/members</code>가 중복되거나 이상한 경로가 될 수 있다.</p>
<p>그래서 중첩 라우팅에서는 현재 route 기준으로 상대 경로가 계산된다는 점을 이해해야 한다.</p>
<p>명확하게 특정 경로로 이동시키고 싶다면 절대 경로를 사용할 수도 있다.</p>
<pre><code class="language-tsx">&lt;Link to=&quot;/mypage/members&quot;&gt;멤버 목록&lt;/Link&gt;</code></pre>
<p>정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>작성 방식</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>to=&quot;..&quot;</code></td>
<td>현재 route 기준 한 단계 위</td>
</tr>
<tr>
<td><code>to=&quot;members&quot;</code></td>
<td>현재 route 아래의 members</td>
</tr>
<tr>
<td><code>to=&quot;../members&quot;</code></td>
<td>한 단계 위로 간 뒤 members</td>
</tr>
<tr>
<td><code>to=&quot;/members&quot;</code></td>
<td>루트 기준 members</td>
</tr>
</tbody></table>
<p>처음에는 상대 경로가 편해 보이지만, 중첩 구조가 복잡할 때는 오히려 헷갈릴 수 있다.</p>
<p>따라서 현재 위치와 route 구조를 정확히 알고 있을 때 상대 경로를 쓰고, 명확한 이동이 필요하면 절대 경로를 쓰는 것도 좋은 방법이라고 느꼈다.</p>
<br />

<h2 id="createbrowserrouter-방식의-장점">createBrowserRouter 방식의 장점</h2>
<p>내가 느낀 <code>createBrowserRouter</code> 방식의 장점은 다음과 같다.</p>
<h3 id="1-라우팅-구조를-한눈에-보기-좋다">1. 라우팅 구조를 한눈에 보기 좋다</h3>
<p>라우트가 객체 배열로 정리되기 때문에 전체 페이지 구조를 보기 좋다.</p>
<pre><code class="language-tsx">const router = createBrowserRouter([
  {
    path: &quot;/&quot;,
    Component: RootLayout,
    children: [
      { index: true, Component: Home },
      { path: &quot;login&quot;, Component: Login },
      { path: &quot;signup&quot;, Component: Signup },
      { path: &quot;members&quot;, Component: Members },
      { path: &quot;members/:id&quot;, Component: MemberDetail },
    ],
  },
]);</code></pre>
<p>페이지가 많아졌을 때 <code>router.tsx</code> 같은 파일로 분리해두면 앱의 URL 구조를 한 곳에서 관리할 수 있다.</p>
<br />

<h3 id="2-중첩-레이아웃을-표현하기-좋다">2. 중첩 레이아웃을 표현하기 좋다</h3>
<p>공통 레이아웃을 부모 route로 두고, 그 안에서 바뀌는 페이지를 children으로 표현할 수 있다.</p>
<pre><code class="language-tsx">{
  path: &quot;/mypage&quot;,
  Component: MyPageLayout,
  children: [
    { index: true, Component: MyPage },
    { path: &quot;members&quot;, Component: Members },
    { path: &quot;members/:id&quot;, Component: MemberDetail },
  ],
}</code></pre>
<p>이렇게 하면 <code>Header</code>, <code>Sidebar</code>처럼 여러 페이지에서 공유하는 UI를 레이아웃 컴포넌트에 두고, 실제 페이지 내용만 <code>Outlet</code>에서 바꿀 수 있다.</p>
<br />

<h3 id="3-data-api까지-확장할-수-있다">3. Data API까지 확장할 수 있다</h3>
<p><code>createBrowserRouter</code>는 단순히 URL에 따라 컴포넌트를 보여주는 것뿐만 아니라, <code>loader</code>, <code>action</code> 같은 Data API와 함께 사용할 수 있다.</p>
<p>예를 들어 페이지가 렌더링되기 전에 필요한 데이터를 미리 가져오는 구조를 만들 수도 있다.</p>
<pre><code class="language-tsx">{
  path: &quot;members/:id&quot;,
  Component: MemberDetail,
  loader: async ({ params }) =&gt; {
    const response = await fetch(`/api/members/${params.id}`);
    return response.json();
  },
}</code></pre>
<p>이번 글에서는 Data API까지 깊게 다루지는 않지만, <code>createBrowserRouter</code>가 단순 라우팅보다 더 확장된 구조를 지원한다는 점은 기억해둘 만하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[데이터 / 이미지 / 코드 로딩 최적화 (대역폭 최적화)]]></title>
            <link>https://velog.io/@jina___/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%BD%94%EB%93%9C-%EB%A1%9C%EB%94%A9-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%8C%80%EC%97%AD%ED%8F%AD-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@jina___/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%BD%94%EB%93%9C-%EB%A1%9C%EB%94%A9-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%8C%80%EC%97%AD%ED%8F%AD-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Sun, 03 May 2026 13:25:47 GMT</pubDate>
            <description><![CDATA[<p>지난 주에 정리했던 웹 성능 최적화는 이렇게 말할 수 있다.</p>
<blockquote>
<p>사용자가 페이지를 더 빠르게 보고, 더 빠르게 조작하고, 덜 답답하게 느끼도록 만드는 것</p>
</blockquote>
<p>이 중에서도 사용자 경험에 가장 큰 영향을 주는 요소 중 하나는 <strong>초기 로딩 속도</strong>다.</p>
<p>웹 페이지가 느려지는 이유는 다양하지만, 대표적으로는 다음과 같다.</p>
<ul>
<li>데이터가 너무 많다</li>
<li>이미지가 너무 많거나 크다</li>
<li>JavaScript 코드가 너무 많다</li>
<li>실제로 사용하지 않는 코드까지 포함된다</li>
</ul>
<p>결국 문제의 본질은, <strong>처음부터 너무 많은 것을 한 번에 불러온다</strong>는 것이다.</p>
<p>그래서 오늘은 이 문제를 해결하기 위한 <strong>대역폭 최적화</strong>에 대해 알아보려고 한다.</p>
<hr>
<h2 id="대역폭-최적화란">대역폭 최적화란?</h2>
<p>대역폭 최적화는 단순히 인터넷 속도를 빠르게 만드는 것이 아니라, 
사용자가 당장 필요로 하지 않는 데이터, 이미지, 코드를 처음부터 전부 가져오지 않도록 하는 것이다.</p>
<blockquote>
<p>처음부터 전부 불러오지 말고, 필요한 순간에 필요한 만큼만 불러오자.</p>
</blockquote>
<p>관련 개념은 크게 네 가지다.</p>
<ul>
<li>데이터 리스트 가상화</li>
<li>Lazy Loading</li>
<li>Code Splitting</li>
<li>Tree Shaking</li>
</ul>
<p>각 개념은 최적화하는 대상이 다르다.</p>
<table>
<thead>
<tr>
<th>개념</th>
<th>줄이는 대상</th>
<th>핵심 아이디어</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 리스트 가상화</td>
<td>DOM 렌더링 비용</td>
<td>보이는 데이터만 렌더링</td>
</tr>
<tr>
<td>Lazy Loading</td>
<td>이미지 / 리소스 로딩 비용</td>
<td>필요할 때 나중에 불러오기</td>
</tr>
<tr>
<td>Code Splitting</td>
<td>초기 JavaScript 번들 크기</td>
<td>코드를 나눠서 불러오기</td>
</tr>
<tr>
<td>Tree Shaking</td>
<td>불필요한 JavaScript 코드</td>
<td>안 쓰는 코드 제거하기</td>
</tr>
</tbody></table>
<blockquote>
<p>데이터는 필요한 만큼만 보여주고, 이미지는 보일 때 불러오고, 코드는 나눠서 받고, 안 쓰는 코드는 제거한다.</p>
</blockquote>
<hr>
<h2 id="데이터-리스트-가상화">데이터 리스트 가상화</h2>
<p><strong>: 현재 화면에 보이는 데이터만 렌더링하는 최적화 기법</strong></p>
<p>예를 들어 게시글이 10,000개 있다고 해보자.</p>
<p>일반적인 방식이라면 브라우저는 10,000개의 게시글을 모두 DOM으로 만든다.</p>
<blockquote>
<p>게시글 10,000개<br>→ DOM 10,000개 생성<br>→ 브라우저가 전부 계산<br>→ 렌더링 느려짐<br>→ 스크롤 버벅임</p>
</blockquote>
<p>하지만 가상화를 적용하면 실제 DOM에는 현재 화면에 보이는 일부 항목만 존재한다.</p>
<blockquote>
<p>전체 데이터는 10,000개<br>→ 화면에 보이는 10~20개만 렌더링<br>→ 나머지는 DOM에 만들지 않음<br>→ 스크롤할 때 필요한 항목만 교체</p>
</blockquote>
<p>사용자 입장에서는 전체 리스트가 모두 있는 것처럼 보이지만,<br>브라우저 입장에서는 일부 항목만 처리하면 된다.</p>
<h3 id="왜-성능이-좋아질까">왜 성능이 좋아질까?</h3>
<p>브라우저는 화면을 그릴 때 DOM을 기준으로 계산한다.</p>
<p>DOM이 많아지면 다음과 같은 작업이 많아진다.</p>
<ul>
<li>렌더링 계산</li>
<li>레이아웃 계산</li>
<li>페인트 작업</li>
<li>스크롤 처리</li>
</ul>
<p>그래서 리스트 데이터가 많을 때는, 데이터 개수 자체보다 
<strong>실제로 화면에 렌더링되는 DOM 개수</strong>를 줄이는 것이 중요하다.</p>
<p>예를 들어 전체 데이터가 10,000개여도<br>실제로 DOM에 존재하는 항목이 20개 정도라면 브라우저가 처리해야 할 부담이 훨씬 줄어든다.</p>
<h3 id="예시-상황">예시 상황</h3>
<p>전체 데이터가 10,000개 있다고 가정해보자.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>개수</th>
</tr>
</thead>
<tbody><tr>
<td>전체 데이터</td>
<td>10,000개</td>
</tr>
<tr>
<td>화면에 보이는 아이템</td>
<td>약 15개</td>
</tr>
<tr>
<td>여유로 미리 렌더링하는 아이템</td>
<td>약 5개</td>
</tr>
<tr>
<td>실제 DOM에 존재하는 아이템</td>
<td>약 20개</td>
</tr>
</tbody></table>
<p>이렇게 하면 데이터는 많아도 화면은 훨씬 부드럽게 동작한다.</p>
<h3 id="자주-사용하는-라이브러리">자주 사용하는 라이브러리</h3>
<p>React에서는 보통 다음과 같은 라이브러리를 사용한다.</p>
<ul>
<li>react-window</li>
<li>react-virtualized</li>
<li>@tanstack/react-virtual</li>
</ul>
<p>이런 라이브러리들은 스크롤 위치를 계산해서 
현재 화면에 보여야 하는 항목만 렌더링하도록 도와준다.</p>
<h3 id="간단한-예시">간단한 예시</h3>
<p>react-window를 사용하여 데이터 리스트 가상화를 적용한 간단한 예시이다.</p>
<pre><code class="language-jsx">import { FixedSizeList as List } from &quot;react-window&quot;;

const items = Array.from({ length: 10000 }, (_, index) =&gt; `아이템 ${index + 1}`);

function VirtualList() {
  return (
    &lt;List
      height={400} // 리스트 영역의 높이 
      itemCount={items.length} // 전체 데이터 개수
      itemSize={50} // 각 아이템의 높이
      width={300} // 리스트 영역의 너비
    &gt;
      {({ index, style /* react-window가 계산한 위치값 */ }) =&gt; ( 
        &lt;div style={style}&gt;
          {items[index]}
        &lt;/div&gt;
      )}
    &lt;/List&gt;
  );
}

export default VirtualList;
</code></pre>
<p>여기서 <code>style</code>은 List의 props에 의해 계산되기 때문에 
<code>height</code>, <code>itemCount</code>, <code>itemSize</code>, <code>width</code>는 필수 요소이며, 
각 아이템의 최상위 요소에 <code>style={style}</code>을 꼭 적용해야 리스트가 정상적으로 배치된다.</p>
<hr>
<h2 id="lazy-loading-지연-로딩">Lazy Loading (지연 로딩)</h2>
<p><strong>: 리소스가 필요해지는 순간에 나중에 불러오는 방식</strong></p>
<p>예를 들어 이미지가 100장 있는 페이지가 있다고 해보자.</p>
<p>일반적인 방식은 페이지에 접속하자마자 이미지 100장을 모두 요청한다.</p>
<blockquote>
<p>일반 로딩<br>→ 페이지 접속<br>→ 이미지 100장 모두 요청<br>→ 초기 로딩 느려짐</p>
</blockquote>
<p>하지만 Lazy Loading을 적용하면 처음에는 화면에 보이는 이미지만 불러온다.</p>
<blockquote>
<p>Lazy Loading<br>→ 처음 화면에 보이는 이미지 5장만 요청<br>→ 스크롤해서 이미지가 화면 근처에 오면 나머지 이미지 요청</p>
</blockquote>
<p>즉, 사용자가 아직 보지 않는 이미지는 처음부터 불러오지 않는다.</p>
<h3 id="왜-대역폭-최적화일까">왜 대역폭 최적화일까?</h3>
<p>대역폭은 쉽게 말하면, <strong>네트워크로 주고받는 데이터 양</strong>이라고 생각하면 된다.</p>
<p>사용자가 페이지 아래까지 스크롤하지 않을 수도 있는데,<br>처음부터 아래쪽 이미지까지 전부 불러오면 불필요한 네트워크 사용이 발생한다.</p>
<blockquote>
<p>사용자가 보지 않는 이미지<br>= 불필요한 네트워크 요청<br>= 대역폭 낭비<br>= 초기 로딩 속도 저하</p>
</blockquote>
<p>Lazy Loading은 이런 낭비를 줄여준다.</p>
<h3 id="이미지-lazy-loading-예시">이미지 Lazy Loading 예시</h3>
<p>HTML에서는 이미지 태그에 <code>loading=&quot;lazy&quot;</code> 속성을 추가하면 된다.</p>
<p><code>&lt;img src=&quot;image.jpg&quot; alt=&quot;상품 이미지&quot; loading=&quot;lazy&quot; /&gt;</code></p>
<p>이렇게 작성하면 브라우저가 이미지를 바로 불러오지 않고,<br>이미지가 화면 근처에 왔을 때 로딩한다.</p>
<h3 id="react에서의-lazy-loading">React에서의 Lazy Loading</h3>
<p>React에서는 이미지뿐만 아니라 컴포넌트도 Lazy Loading 할 수 있다.</p>
<p>예를 들어 상세 페이지 컴포넌트를 처음부터 불러오지 않고,<br>실제로 상세 페이지가 필요할 때 불러오도록 만들 수 있다.</p>
<p>React에서는 보통 <code>lazy</code>와 <code>Suspense</code>를 함께 사용한다.</p>
<ul>
<li><code>lazy</code> : 컴포넌트를 나중에 불러오도록 설정</li>
<li><code>Suspense</code> : 컴포넌트가 로딩되는 동안 보여줄 UI 설정</li>
</ul>
<p>이 방식은 <strong>Code Splitting</strong>과도 연결되며, 자세한 사용 예시는 뒤에서 설명하겠다.</p>
<hr>
<h2 id="code-splitting">Code Splitting</h2>
<p><strong>: JavaScript 코드를 여러 조각으로 나누어 필요한 시점에 불러오는 방식</strong></p>
<p>React 앱을 빌드하면 여러 컴포넌트와 페이지 코드가 JavaScript 파일로 묶인다.</p>
<p>예를 들어 앱에 다음과 같은 페이지가 있다고 해보자.</p>
<ul>
<li>메인 페이지</li>
<li>상세 페이지</li>
<li>마이 페이지</li>
<li>관리자 페이지</li>
</ul>
<p>Code Splitting을 하지 않으면 사용자가 메인 페이지만 보고 있어도<br>상세 페이지, 마이 페이지, 관리자 페이지 코드까지 처음부터 받을 수 있다.</p>
<blockquote>
<p>모든 페이지 코드<br>→ 하나의 큰 JavaScript 파일<br>→ 사용하지 않는 페이지 코드까지 다운로드<br>→ 초기 로딩 속도 저하</p>
</blockquote>
<p>Code Splitting을 적용하면 코드를 여러 파일로 나눌 수 있다.</p>
<blockquote>
<p>main.js<br>detail-page.js<br>mypage.js<br>admin-page.js</p>
</blockquote>
<p>이렇게 나누면 사용자가 상세 페이지에 들어갈 때만<br>상세 페이지에 필요한 코드를 불러올 수 있다.</p>
<h3 id="왜-필요할까">왜 필요할까?</h3>
<p>초기 JavaScript 파일이 크면 다음과 같은 문제가 생긴다.</p>
<ul>
<li>다운로드 시간이 길어진다</li>
<li>JavaScript 파싱 시간이 길어진다</li>
<li>실행 시간이 길어진다</li>
<li>초기 화면 표시가 늦어진다</li>
</ul>
<p>특히 React 앱은 JavaScript가 실행되어야 화면이 동작하기 때문에,<br>초기 번들이 크면 사용자가 체감하는 속도가 느려질 수 있다.</p>
<p>따라서 처음부터 모든 코드를 받기보다는<br>사용자가 실제로 필요한 시점에 필요한 코드만 불러오는 것이 좋다.</p>
<h3 id="보통-어디에-적용할까">보통 어디에 적용할까?</h3>
<p>Code Splitting은 보통 <strong>라우트 단위</strong>로 많이 적용한다.</p>
<p>예를 들어 다음과 같이 나눌 수 있다.</p>
<table>
<thead>
<tr>
<th>페이지</th>
<th>로딩 시점</th>
</tr>
</thead>
<tbody><tr>
<td>메인 페이지</td>
<td>처음 접속 시 로딩</td>
</tr>
<tr>
<td>상세 페이지</td>
<td>상세 페이지로 이동할 때 로딩</td>
</tr>
<tr>
<td>마이 페이지</td>
<td>마이 페이지로 이동할 때 로딩</td>
</tr>
<tr>
<td>관리자 페이지</td>
<td>관리자 페이지로 이동할 때 로딩</td>
</tr>
</tbody></table>
<p>이렇게 하면 사용자가 처음 접속했을 때
당장 필요하지 않은 페이지 코드를 받지 않아도 된다.</p>
<h2 id="react에서-lazy-loading과-code-splitting">React에서 Lazy Loading과 Code Splitting</h2>
<p>React에서는 컴포넌트를 Lazy Loading 할 때 보통 <code>lazy</code>와 <code>Suspense</code>를 함께 사용한다.</p>
<p>이때 <code>lazy</code> 안에서 사용하는 <code>import()</code>는 일반적인 import와 다르게<br>해당 코드가 필요해지는 시점에 파일을 불러오는 방식이다.</p>
<p>즉, 컴포넌트를 나중에 불러오는 Lazy Loading이면서,<br>동시에 JavaScript 코드를 별도의 파일로 나누는 Code Splitting이 된다.</p>
<pre><code class="language-jsx">import { lazy, Suspense } from &quot;react&quot;;

const DetailPage = lazy(() =&gt; import(&quot;./pages/DetailPage&quot;));

function App() {
  return (
    &lt;Suspense fallback={&lt;div&gt;페이지 로딩 중...&lt;/div&gt;}&gt;
      &lt;DetailPage /&gt;
    &lt;/Suspense&gt;
  );
}

export default App;</code></pre>
<p>여기서 중요한 부분은 다음 코드다.</p>
<pre><code class="language-jsx">const DetailPage = lazy(() =&gt; import(&quot;./pages/DetailPage&quot;));</code></pre>
<ul>
<li><p><code>lazy</code><br>→ 컴포넌트를 처음부터 불러오지 않고, 필요할 때 불러오도록 만든다.</p>
</li>
<li><p><code>import(&quot;./pages/DetailPage&quot;)</code><br>→ 번들러가 <code>DetailPage</code> 코드를 별도의 JavaScript 파일로 분리할 수 있게 한다.</p>
</li>
<li><p><code>Suspense</code><br>→ 컴포넌트가 로딩되는 동안 보여줄 UI를 설정한다.</p>
</li>
<li><p><code>fallback</code><br>→ 아직 컴포넌트가 로딩 중일 때 대신 보여줄 화면이다.</p>
</li>
</ul>
<p>다시 정리하면,</p>
<blockquote>
<p>Lazy Loading은 “필요할 때 불러오는 방식”이고,<br>Code Splitting은 “코드를 나누는 방식”이다.</p>
</blockquote>
<p>React에서는 <code>lazy(() =&gt; import(...))</code>를 사용하여 두 개념을 함께 적용할 수 있다.</p>
<hr>
<h2 id="tree-shaking">Tree Shaking</h2>
<p><strong>: 번들링 과정에서 실제로 사용하지 않는 코드를 제거하는 최적화 기법</strong></p>
<p>예를 들어 유틸 함수 파일에 다음과 같은 함수들이 있다고 해보자.</p>
<ul>
<li>formatDate</li>
<li>formatPrice</li>
<li>calculateTotal</li>
</ul>
<p>그런데 실제 코드에서는 <code>formatDate</code>만 사용한다고 가정해보자.</p>
<p>이때 Tree Shaking이 잘 적용되면<br>사용하지 않는 <code>formatPrice</code>, <code>calculateTotal</code>은 최종 번들에서 제거될 수 있다.</p>
<h3 id="왜-필요할까-1">왜 필요할까?</h3>
<p>개발할 때는 여러 함수나 라이브러리를 만들어두고,<br>그중 일부만 사용하는 경우가 많다.</p>
<p>하지만 사용하지 않는 코드까지 최종 JavaScript 파일에 포함되면<br>파일 크기가 커지고 초기 로딩 속도에도 영향을 줄 수 있다.</p>
<blockquote>
<p>사용하지 않는 코드 포함<br>→ 번들 크기 증가<br>→ 다운로드 시간 증가<br>→ 파싱 / 실행 시간 증가<br>→ 초기 로딩 속도 저하</p>
</blockquote>
<p>Tree Shaking은 이런 불필요한 코드를 제거해서 최종 번들을 더 가볍게 만들어준다.</p>
<h3 id="왜-이름이-tree-shaking일까">왜 이름이 Tree Shaking일까?</h3>
<blockquote>
<p>전체 코드 = 나무<br>사용하는 코드 = 살아있는 가지<br>사용하지 않는 코드 = 죽은 가지</p>
</blockquote>
<p>Tree Shaking은 나무를 흔들어서  필요 없는 죽은 가지를 떨어뜨리는 것과 비슷하다.</p>
<p>즉, 최종 결과물에는 실제로 사용하는 코드만 남기는 것이다.</p>
<h3 id="tree-shaking이-잘-되려면">Tree Shaking이 잘 되려면?</h3>
<p>Tree Shaking은 보통 ES Module 방식에서 잘 동작한다.</p>
<p>ES Module 방식은 다음과 같은 형태다.</p>
<ul>
<li>export</li>
<li>import</li>
</ul>
<p>예를 들어 필요한 함수만 가져오는 방식이다.</p>
<p><code>import { formatDate } from &quot;./utils&quot;;</code></p>
<p>이렇게 작성하면 번들러가 어떤 코드를 실제로 사용하는지 파악하기 쉽다.</p>
<p>반대로 CommonJS 방식처럼 <code>module.exports</code>를 사용하거나,<br>모든 코드를 하나의 객체로 묶어서 내보내면 Tree Shaking이 잘 되지 않을 수 있다.</p>
<p>그래서 현대 프론트엔드에서는 <code>import/export</code> 기반의 ES Module 방식이 중요하다.</p>
<hr>
<h2 id="상황에-따른-적용-방식">상황에 따른 적용 방식</h2>
<blockquote>
<p>데이터가 너무 많다 → 데이터 리스트 가상화
이미지가 너무 많거나 크다 → Lazy Loading
JavaScript 코드가 너무 크다 → Code Splitting
사용하지 않는 코드가 포함된다 → Tree Shaking</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Emotion 정리: @emotion/react 제대로 이해하기]]></title>
            <link>https://velog.io/@jina___/Emotion-%EC%A0%95%EB%A6%AC-emotionreact-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jina___/Emotion-%EC%A0%95%EB%A6%AC-emotionreact-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 01 May 2026 06:46:03 GMT</pubDate>
            <description><![CDATA[<h2 id="스타일링-방식에는-어떤-것들이-있을까">스타일링 방식에는 어떤 것들이 있을까?</h2>
<p>React에서 스타일을 적용하는 방식은 여러 가지가 있다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>CSS 파일</td>
<td>가장 기본적인 방식. 별도의 <code>.css</code> 파일에서 스타일을 관리한다.</td>
</tr>
<tr>
<td>CSS Module</td>
<td>클래스명이 자동으로 고유해져서 스타일 충돌을 줄일 수 있다.</td>
</tr>
<tr>
<td>Sass/SCSS</td>
<td>변수, 중첩, mixin 등을 사용할 수 있는 CSS 확장 문법이다.</td>
</tr>
<tr>
<td>styled-components</td>
<td>컴포넌트 단위로 스타일을 작성할 수 있는 CSS-in-JS 라이브러리다.</td>
</tr>
<tr>
<td>Emotion</td>
<td>styled 방식과 css prop 방식을 모두 지원하는 CSS-in-JS 라이브러리다.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>미리 정의된 유틸리티 클래스를 조합해서 스타일을 작성한다.</td>
</tr>
</tbody></table>
<p>이 중에서 내가 Emotion을 선택한 이유는 다음과 같다.</p>
<ol>
<li>컴포넌트 단위로 스타일을 관리할 수 있다.</li>
<li>props를 이용한 동적 스타일링이 쉽다.</li>
<li><code>styled</code> 방식과 <code>css prop</code> 방식을 모두 지원한다.</li>
<li><code>Global</code>, <code>ThemeProvider</code> 같은 React 스타일링 기능을 통해 전역 스타일과 테마 관리가 가능하다.</li>
<li>styled-components와 문법이 비슷해서 진입 장벽이 낮다.</li>
</ol>
<p>기존에는 styled-components를 주로 사용했지만, 최근 maintenance mode로 전환되면서 새 프로젝트에서는 사용을 지양하고 있다.
이에 익숙한 사용 방식은 유지하면서도 더 유연한 스타일링이 가능한 Emotion을 선택하게 되었고, 특히 헷갈렸던 <code>@emotion/react</code> 부분을 중심으로 정리해보려고 한다.</p>
<h2 id="emotion이란">Emotion이란?</h2>
<p>Emotion은 CSS 파일을 따로 분리하지 않고, JavaScript 안에서 스타일을 작성할 수 있게 해주는 CSS-in-JS 라이브러리다.</p>
<p>일반 CSS 방식에서는 보통 이렇게 작성한다.</p>
<pre><code class="language-jsx">&lt;button className=&quot;button&quot;&gt;버튼&lt;/button&gt;</code></pre>
<pre><code class="language-css">.button {
  padding: 10px 16px;
  background-color: black;
  color: white;
}</code></pre>
<p>Emotion을 사용하면 스타일을 JavaScript 코드 안에서 작성할 수 있다.</p>
<pre><code class="language-jsx">import styled from &quot;@emotion/styled&quot;;

const Button = styled.button`
  padding: 10px 16px;
  background-color: black;
  color: white;
`;

function App() {
  return &lt;Button&gt;버튼&lt;/Button&gt;;
}</code></pre>
<p>즉, Emotion은 컴포넌트와 스타일을 가까운 위치에서 관리할 수 있게 해준다.</p>
<p>이러한 방식은 styled-components를 사용해봤다면 익숙하게 느껴질 것이다.</p>
<h2 id="emotion-설치하기">Emotion 설치하기</h2>
<p>Emotion을 사용하려면 보통 아래 두 패키지를 함께 설치한다.</p>
<pre><code class="language-bash">npm install @emotion/react @emotion/styled</code></pre>
<p>pnpm을 사용한다면 이렇게 설치할 수 있다.</p>
<pre><code class="language-bash">pnpm add @emotion/react @emotion/styled</code></pre>
<p><code>@emotion/styled</code>와 <code>@emotion/react</code>는 무슨 차이가 있을까?</p>
<h2 id="emotionstyled와-emotionreact">@emotion/styled와 @emotion/react</h2>
<p>Emotion에는 대표적으로 두 패키지가 있다.</p>
<pre><code class="language-jsx">import styled from &quot;@emotion/styled&quot;;</code></pre>
<pre><code class="language-jsx">import { css } from &quot;@emotion/react&quot;;</code></pre>
<p>둘 다 Emotion이지만 역할이 조금 다르다.</p>
<table>
<thead>
<tr>
<th>패키지</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>@emotion/styled</code></td>
<td>styled-components처럼 <code>styled.div</code>, <code>styled.button</code> 형태로 스타일 컴포넌트를 만들 때 사용한다.</td>
</tr>
<tr>
<td><code>@emotion/react</code></td>
<td><code>css prop</code>, <code>Global</code>, <code>ThemeProvider</code>, <code>keyframes</code> 같은 React 전용 기능을 제공한다.</td>
</tr>
</tbody></table>
<p>쉽게 말하면 다음과 같다.</p>
<blockquote>
<p><code>@emotion/styled</code>는 styled-components와 거의 비슷하게 사용하는 패키지다.<br><code>@emotion/react</code>는 Emotion의 React 전용 핵심 기능을 제공하는 패키지다.</p>
</blockquote>
<p><code>styled</code> 방식은 styled-components와 매우 유사하기 때문에, 이번 글에서는 따로 설명하지 않겠다.</p>
<h2 id="emotionreact에서-제공하는-기능들">@emotion/react에서 제공하는 기능들</h2>
<p><code>@emotion/react</code>는 Emotion의 React 전용 기능을 제공한다.</p>
<p>대표적으로 다음 기능들이 있다.</p>
<ol>
<li><code>css</code></li>
<li><code>Global</code></li>
<li><code>ThemeProvider</code></li>
<li><code>keyframes</code></li>
</ol>
<h2 id="1-css">1. css</h2>
<p><code>css</code>는 Emotion 스타일을 만들 때 사용하는 함수다.</p>
<pre><code class="language-jsx">import { css } from &quot;@emotion/react&quot;;

const containerStyle = css`
  padding: 20px;
  background-color: #f5f5f5;
`;</code></pre>
<p>이렇게 만든 스타일은 JSX의 <code>css</code> prop에 넣어서 사용할 수 있다.</p>
<pre><code class="language-jsx">function App() {
  return &lt;div css={containerStyle}&gt;내용&lt;/div&gt;;
}</code></pre>
<p>객체 형태로도 작성할 수 있다.</p>
<pre><code class="language-jsx">import { css } from &quot;@emotion/react&quot;;

const containerStyle = css({
  padding: &quot;20px&quot;,
  backgroundColor: &quot;#f5f5f5&quot;,
});</code></pre>
<p>문자열 방식은 일반 CSS를 쓰는 느낌이고, 객체 방식은 JavaScript 객체로 스타일을 작성하는 느낌이다.</p>
<h2 id="2-global">2. Global</h2>
<p><code>Global</code>은 전역 스타일을 지정할 때 사용한다.</p>
<p>컴포넌트 스타일은 보통 특정 컴포넌트에만 적용된다.</p>
<p>하지만 <code>body</code>, <code>html</code>, <code>*</code> 같은 전역 스타일은 특정 컴포넌트에만 묶기 어렵다.</p>
<p>이럴 때 <code>Global</code>을 사용할 수 있다.</p>
<pre><code class="language-jsx">import { Global, css } from &quot;@emotion/react&quot;;

function App() {
  return (
    &lt;&gt;
      &lt;Global
        styles={css`
          * {
            box-sizing: border-box;
          }

          body {
            margin: 0;
            font-family: sans-serif;
          }

          button {
            font-family: inherit;
          }
        `}
      /&gt;

      &lt;div&gt;Emotion Global 스타일&lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
<p><code>Global</code>은 보통 다음과 같은 경우에 사용한다.</p>
<ul>
<li>reset CSS</li>
<li><code>body</code> 기본 스타일</li>
<li><code>font-family</code> 기본값</li>
<li>전체 <code>box-sizing</code></li>
<li>기본 버튼, input 스타일 초기화</li>
</ul>
<p>예를 들어 프로젝트에서 공통 reset 스타일을 관리하고 싶다면 이런 식으로 분리할 수 있다.</p>
<pre><code class="language-jsx">// GlobalStyle.jsx
import { Global, css } from &quot;@emotion/react&quot;;

const globalStyle = css`
  * {
    box-sizing: border-box;
  }

  body {
    margin: 0;
    font-family: sans-serif;
    background-color: #ffffff;
  }

  button {
    border: none;
    background: none;
    cursor: pointer;
    font-family: inherit;
  }
`;

function GlobalStyle() {
  return &lt;Global styles={globalStyle} /&gt;;
}

export default GlobalStyle;</code></pre>
<p>그리고 App에서 불러와 사용한다.</p>
<pre><code class="language-jsx">import GlobalStyle from &quot;./GlobalStyle&quot;;

function App() {
  return (
    &lt;&gt;
      &lt;GlobalStyle /&gt;
      &lt;div&gt;앱 화면&lt;/div&gt;
    &lt;/&gt;
  );
}

export default App;</code></pre>
<h2 id="3-themeprovider">3. ThemeProvider</h2>
<p><code>ThemeProvider</code>는 공통 디자인 값을 관리할 때 사용한다.</p>
<p>프로젝트를 하다 보면 색상, 폰트 크기, 간격 같은 값을 여러 곳에서 반복해서 사용하게 된다.</p>
<pre><code class="language-css">color: #333333;
background-color: #f5f5f5;</code></pre>
<p>이런 값을 컴포넌트마다 직접 작성하면 나중에 수정하기 어렵다.</p>
<p>예를 들어 메인 컬러가 바뀌면 모든 파일을 찾아다니며 수정해야 한다.</p>
<p>이럴 때 theme을 만들어 공통 값을 관리할 수 있다.</p>
<pre><code class="language-jsx">const theme = {
  colors: {
    primary: &quot;#2f80ed&quot;,
    text: &quot;#333333&quot;,
    background: &quot;#f5f5f5&quot;,
  },
  fontSize: {
    small: &quot;12px&quot;,
    medium: &quot;16px&quot;,
    large: &quot;24px&quot;,
  },
};</code></pre>
<p>그리고 <code>ThemeProvider</code>로 감싸준다.</p>
<pre><code class="language-jsx">import { ThemeProvider } from &quot;@emotion/react&quot;;
import styled from &quot;@emotion/styled&quot;;

const theme = {
  colors: {
    primary: &quot;#2f80ed&quot;,
    text: &quot;#333333&quot;,
  },
};

const Title = styled.h1`
  color: ${({ theme }) =&gt; theme.colors.primary};
`;

function App() {
  return (
    &lt;ThemeProvider theme={theme}&gt;
      &lt;Title&gt;Emotion ThemeProvider&lt;/Title&gt;
    &lt;/ThemeProvider&gt;
  );
}

export default App;</code></pre>
<p>이렇게 하면 하위 컴포넌트에서 <code>theme</code> 값을 사용할 수 있다.</p>
<p><code>css prop</code> 방식에서도 사용할 수 있다.</p>
<pre><code class="language-jsx">/** @jsxImportSource @emotion/react */
import { css, ThemeProvider } from &quot;@emotion/react&quot;;

const theme = {
  colors: {
    primary: &quot;#2f80ed&quot;,
  },
};

const titleStyle = (theme) =&gt; css`
  color: ${theme.colors.primary};
`;

function Title() {
  return &lt;h1 css={titleStyle}&gt;Emotion&lt;/h1&gt;;
}

function App() {
  return (
    &lt;ThemeProvider theme={theme}&gt;
      &lt;Title /&gt;
    &lt;/ThemeProvider&gt;
  );
}</code></pre>
<p>ThemeProvider를 사용하면 디자인 값을 한 곳에서 관리할 수 있다는 장점이 있다.</p>
<h2 id="4-keyframes">4. keyframes</h2>
<p><code>keyframes</code>는 애니메이션을 만들 때 사용한다.</p>
<p>CSS에서 <code>@keyframes</code>를 작성하는 것처럼, Emotion에서도 애니메이션을 정의할 수 있다.</p>
<pre><code class="language-jsx">import { keyframes, css } from &quot;@emotion/react&quot;;

const fadeIn = keyframes`
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
`;

const boxStyle = css`
  width: 100px;
  height: 100px;
  background-color: black;
  animation: ${fadeIn} 1s ease-in-out;
`;

function App() {
  return &lt;div css={boxStyle} /&gt;;
}</code></pre>
<p>이렇게 하면 컴포넌트 안에서 애니메이션까지 함께 관리할 수 있다.</p>
<h2 id="주의할-점">주의할 점</h2>
<p>Emotion을 사용할 때는 <code>styled</code> 방식과 <code>css prop</code> 방식을 상황에 맞게 구분해서 사용하는 것이 중요하다.</p>
<p><code>css prop</code>은 JSX 요소에 바로 스타일을 작성할 수 있어서 간단한 스타일을 적용할 때 편리하다. 하지만 모든 스타일을 <code>css prop</code>으로 작성하면 JSX 안에 스타일 코드가 많아져 오히려 구조를 파악하기 어려워질 수 있다.</p>
<p>따라서 재사용되는 UI나 스타일 코드가 길어지는 경우에는 <code>styled</code> 방식으로 분리하고, 한 번만 사용하는 간단한 스타일에는 <code>css prop</code>을 사용하는 것이 좋다.</p>
<p>정리하면 다음과 같이 사용할 수 있다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 방식</th>
</tr>
</thead>
<tbody><tr>
<td>여러 곳에서 재사용되는 버튼, 카드, 모달</td>
<td><code>styled</code></td>
</tr>
<tr>
<td>한 번만 사용하는 간단한 스타일</td>
<td><code>css prop</code></td>
</tr>
<tr>
<td>reset CSS, body 기본 스타일</td>
<td><code>Global</code></td>
</tr>
<tr>
<td>공통 색상, 폰트, 간격 관리</td>
<td><code>ThemeProvider</code></td>
</tr>
</tbody></table>
<h2 id="글을-정리하며">글을 정리하며</h2>
<p>처음 Emotion을 접했을 때는 <code>@emotion/styled</code>와 <code>@emotion/react</code>가 나뉘어 있어서 구조 자체가 조금 낯설고 헷갈렸다. 특히 <code>css prop</code> 방식은 기존에 사용하던 방식과 달라서 어떻게 활용해야 할지 감이 잘 잡히지 않았다.</p>
<p>이번에 개념을 정리하면서 각각의 역할을 구분해보니, 생각보다 단순한 구조라는 것을 이해할 수 있었다. 상황에 따라 <code>styled</code>와 <code>css prop</code>을 선택해서 사용할 수 있다는 점도 Emotion의 큰 장점이라고 느껴졌다.</p>
<p>앞으로는 단순히 사용하는 것을 넘어서, 상황에 맞게 스타일링 방식을 구분하면서 Emotion을 더 적극적으로 활용해볼 수 있을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹 성능 최적화 입문: DevTools와 이미지/폰트 최적화]]></title>
            <link>https://velog.io/@jina___/%EC%9B%B9-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%9E%85%EB%AC%B8-DevTools%EC%99%80-%EC%9D%B4%EB%AF%B8%EC%A7%80%ED%8F%B0%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@jina___/%EC%9B%B9-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%9E%85%EB%AC%B8-DevTools%EC%99%80-%EC%9D%B4%EB%AF%B8%EC%A7%80%ED%8F%B0%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Sun, 26 Apr 2026 14:14:36 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jina___/post/31c62885-eb82-4bf0-a03d-293ef4764848/image.png" alt=""></p>
<h2 id="웹-성능-최적화란">웹 성능 최적화란?</h2>
<blockquote>
<p><strong>웹 성능 최적화(Web Performance Optimization) :</strong><br>웹 페이지의 로딩 속도, 응답성, 렌더링 성능을 개선해 사용자가 콘텐츠를 더 빠르고 쾌적하게 경험할 수 있도록 만드는 과정</p>
</blockquote>
<p>성능 최적화는 단순히 페이지를 빠르게 만드는 작업만을 의미하지 않는다. </p>
<p>사용자가 콘텐츠를 빠르게 확인하고, 버튼 클릭이나 스크롤 같은 동작에 지연 없이 반응하며, 화면이 안정적으로 유지되도록 만드는 전반적인 개선 과정에 가깝다.</p>
<p>*<em>결국 웹 성능은 사용자 경험과 직접적으로 연결된다. *</em> 
페이지가 느리면 사용자는 쉽게 이탈할 수 있고, 빠르고 안정적인 페이지는 서비스에 대한 신뢰감, SEO, 전환율 향상에도 긍정적인 영향을 줄 수 있다.</p>
<p>웹페이지가 느려지는 이유는 여러 가지가 있다.</p>
<ul>
<li>이미지나 폰트 파일이 너무 클 수 있다.</li>
<li>JavaScript 실행 시간이 길 수 있다.</li>
<li>필요한 리소스가 너무 늦게 다운로드될 수 있다.</li>
<li>화면이 로딩되는 중간에 레이아웃이 흔들릴 수 있다.</li>
<li>버튼을 눌렀는데 반응이 늦을 수 있다.</li>
</ul>
<p>우리는 <strong>Chrome DevTools</strong> 같은 도구를 사용해서 실제로 어떤 부분이 느린지 확인하고 개선할 수 있다.</p>
<p>이번에는 DevTools의 <strong><code>Network</code>, <code>Performance</code>, <code>Lighthouse</code> 패널</strong>과 <strong>이미지 최적화</strong>, <strong>폰트 최적화</strong>에 대해 정리해보려고 한다.</p>
<hr>
<h2 id="1-network-패널">1. Network 패널</h2>
<p><strong>웹 페이지가 필요한 파일들을 어떤 순서로, 얼마나 오래 걸려서 받아오는지 보여주는 패널</strong>이다.
<img src="https://velog.velcdn.com/images/jina___/post/466a724c-6e23-4332-9fc3-4e802ae2ae9b/image.png" alt=""></p>
<p>Network 패널은 페이지가 로딩될 때 어떤 리소스들이 요청되고,<br>각각 얼마나 걸렸는지 확인할 수 있는 곳이다.</p>
<p>예를 들어 페이지 하나를 띄울 때 브라우저는 HTML만 가져오는 게 아니라, 
CSS, JavaScript, 이미지, 폰트, API 데이터 등 여러 리소스를 함께 가져온다.</p>
<p>Network 패널에서는 이런 것들을 확인할 수 있다.</p>
<ul>
<li>어떤 파일이 요청되었는지</li>
<li>요청이 성공했는지 실패했는지</li>
<li>파일 크기가 얼마나 되는지</li>
<li>다운로드에 시간이 얼마나 걸렸는지</li>
<li>어떤 순서로 리소스가 불러와졌는지</li>
<li>캐시에서 가져왔는지, 서버에서 새로 가져왔는지</li>
</ul>
<p>특히 성능을 볼 때는 <code>Size</code>, <code>Time</code>, <code>Waterfall</code>을 많이 보게 된다.</p>
<h3 id="size">Size</h3>
<p><strong>: 리소스의 크기</strong></p>
<p>파일이 클수록 다운로드에 오래 걸릴 가능성이 높다.</p>
<p>예를 들어, 화면에서는 작게 보이는 이미지인데 실제 파일 크기가 너무 크다면
사용자는 작은 이미지를 보기 위해 불필요하게 큰 파일을 다운로드하게 된다.</p>
<p>이런 경우 이미지 최적화가 필요할 수 있다.</p>
<h3 id="time">Time</h3>
<p><strong>: 요청이 완료되기까지 걸린 시간</strong></p>
<p>특정 파일 하나가 유독 오래 걸린다면, 
그 파일이 너무 크거나 서버 응답이 느린 것일 수 있다.</p>
<h3 id="waterfall">Waterfall</h3>
<p><strong>: 각 요청이 언제 시작되고 언제 끝났는지를 보여주는 막대 그래프</strong></p>
<p>이걸 보면 중요한 리소스가 너무 늦게 로딩되고 있지는 않은지 확인할 수 있다.</p>
<p>예를 들어 첫 화면에 바로 보여야 하는 메인 이미지가 너무 늦게 다운로드된다면, 사용자는 페이지가 늦게 뜬다고 느낄 수 있다.</p>
<h3 id="network-패널에서-확인해볼-것">Network 패널에서 확인해볼 것</h3>
<p>Network 패널에서는 아래 항목들을 중심으로 보면 좋다.</p>
<ul>
<li>이미지 파일이 너무 크지 않은가?</li>
<li>폰트 파일이 여러 개 불러와지고 있지는 않은가?</li>
<li>JavaScript 파일 크기가 너무 크지 않은가?</li>
<li>실패한 요청은 없는가?</li>
<li>중요한 리소스가 너무 늦게 요청되고 있지는 않은가?</li>
</ul>
<p>Network 패널은 &quot;페이지가 왜 느릴까?&quot;를 보기 위한 첫 번째 단서가 될 수 있다.</p>
<hr>
<h2 id="2-performance-패널">2. Performance 패널</h2>
<p><strong>브라우저가 페이지를 그리는 과정 전체를 볼 수 있는 패널</strong>이다.
<img src="https://velog.velcdn.com/images/jina___/post/2f0b4a86-72e4-447c-961f-3db7a6555db8/image.png" alt=""></p>
<p>Network 패널이 &quot;파일을 얼마나 빨리 가져왔는지&quot;를 보는 곳이라면,<br>Performance 패널은 &quot;브라우저가 그 파일들을 가지고 실제 화면을 어떻게 만들었는지&quot;를 보는 곳이다.</p>
<p>여기서는 JavaScript 실행, 렌더링, 레이아웃 계산, 페인트 작업 등을 확인할 수 있다.</p>
<p>처음 Performance 패널을 열면 보이는 LCP, CLS, INP 지표에 대해 알아보자.</p>
<h3 id="lcp-largest-contentful-paint">LCP (Largest Contentful Paint)</h3>
<p>*<em>: 화면에서 가장 큰 콘텐츠가 사용자에게 보이기까지 걸린 시간 *</em></p>
<p>보통 메인 이미지, 큰 제목, 큰 텍스트 블록 등이 LCP 요소가 될 수 있다.
예를 들어 쇼핑몰 상세 페이지라면 상품 대표 이미지가 LCP일 가능성이 높다.</p>
<p>LCP가 느리면 사용자는 &quot;페이지가 늦게 뜬다&quot;고 느끼기 때문에,
이미지 최적화, 폰트 최적화, 서버 응답 속도, CSS/JS 최적화를 통해 이를 개선한다. </p>
<h3 id="cls-cumulative-layout-shift">CLS (Cumulative Layout Shift)</h3>
<p><strong>: 페이지가 로딩되는 동안 화면이 얼마나 흔들렸는지를 나타내는 지표</strong></p>
<p>예를 들어 글을 읽으려고 했는데 이미지가 늦게 로딩되면서 버튼이나 텍스트가 아래로 밀리는 경우가 있으며, 이런 현상이 많으면 CLS가 나빠진다.</p>
<p>이미지에 <code>width</code>, <code>height</code>를 지정하지 않았거나,<br>폰트가 늦게 적용되면서 글자 크기가 바뀌는 경우에도 레이아웃 이동이 생길 수 있다.</p>
<h3 id="inp-interaction-to-next-paint">INP (Interaction to Next Paint)</h3>
<p><strong>: 사용자가 클릭, 입력, 터치 같은 상호작용을 했을 때, 화면이 얼마나 빠르게 반응하는지를 보는 지표</strong></p>
<p>버튼을 눌렀는데 반응이 늦거나, 입력할 때 버벅이면 INP가 나빠질 수 있다.</p>
<p>JavaScript 작업이 너무 오래 걸려서 브라우저의 메인 스레드가 바쁘면<br>사용자 입력에 빠르게 반응하지 못하게 된다.</p>
<h3 id="performance-패널에서-더-자세히-보려면">Performance 패널에서 더 자세히 보려면</h3>
<p>Performance 패널에서 다음 과정을 통해 더 상세한 분석을 할 수 있다.</p>
<ol>
<li>Performance 패널을 연다.</li>
<li>기록 버튼을 누른다.</li>
<li>페이지를 새로고침하거나 원하는 동작을 한다.</li>
<li>기록을 중지한다.</li>
<li>타임라인에서 어떤 작업이 오래 걸렸는지 확인한다.</li>
</ol>
<p>기록을 하면 <code>Main</code>, <code>Network</code>, <code>Timings</code>, <code>Interactions</code> 같은 영역을 볼 수 있다.
<img src="https://velog.velcdn.com/images/jina___/post/6f38bb72-0e66-45f4-8ec8-43a1f0c2fa3e/image.png" alt=""></p>
<p>여기서 Main Thread에 긴 작업이 있는지, LCP가 언제 발생했는지,<br>레이아웃 이동이 있었는지 등을 확인할 수 있다.</p>
<p>Performance 패널의 핵심은 &quot;브라우저가 어디서 시간을 많이 쓰고 있는지&quot;를 찾는 것이라고 볼 수 있다. </p>
<hr>
<h2 id="3-lighthouse-패널">3. Lighthouse 패널</h2>
<p><strong>페이지를 자동으로 검사해서 성능 점수와 개선할 부분을 알려주는 패널</strong>이다.
<img src="https://velog.velcdn.com/images/jina___/post/4a790cc8-ba26-4988-9be6-723e11545beb/image.png" alt=""></p>
<p>Lighthouse는 웹페이지 품질을 개선하기 위한 오픈소스 자동화 도구이고, 성능, 접근성, SEO 등 여러 항목을 검사할 수 있다.</p>
<p>Network나 Performance 패널은 직접 보고 해석해야 하는 느낌이 강한데,
Lighthouse는 리포트 형태로 문제를 정리해서 보여준다.</p>
<p>Lighthouse를 실행하면 LCP, CLS 같은 성능 지표뿐 아니라, 구체적으로 어떤 부분을 개선하면 좋을지 아래와 같은 메세지로 보여준다.</p>
<pre><code>이미지 크기를 줄이세요.
사용하지 않는 JavaScript를 줄이세요.
렌더링 차단 리소스를 제거하세요.
이미지에 명시적인 width/height를 지정하세요.
폰트 표시 방식을 개선하세요.
LCP 요소를 최적화하세요.</code></pre><p>Lighthouse를 활용하면 성능을 처음 공부하는 사람도 어떤 문제가 있는지 비교적 쉽게 파악할 수 있다는 큰 장점이 있다.</p>
<p><strong>하지만 Lighthouse 점수만 보고 성능을 판단하는 것은 위험할 수 있다.</strong></p>
<p>Lighthouse는 특정 환경에서 측정한 결과이기 때문에, 실제 사용자 환경과 완전히 같지는 않을 수 있다.</p>
<p>따라서 Lighthouse를 &quot;개선 방향을 잡는 용도&quot;로 보고, 
Performance 패널이나 Network 패널에서 원인을 더 자세히 분석하고 개선하며, 
개선 전후로 Lighthouse를 돌려보면서 내가 적용한 최적화가 실제로 어떤 영향을 줬는지 비교해보는 것이 좋다.</p>
<hr>
<h2 id="4-이미지-최적화">4. 이미지 최적화</h2>
<p>이미지는 웹페이지에서 용량을 많이 차지하는 대표적인 리소스이기 때문에,
이미지 최적화만 잘해도 페이지 로딩 속도가 꽤 좋아질 수 있다.</p>
<p>이미지 최적화는 크게 세 가지 관점에서 볼 수 있다.</p>
<ul>
<li>적절한 사이즈의 이미지 사용하기</li>
<li>필요할 때 이미지를 불러오기</li>
<li>중요한 이미지는 미리 불러오기</li>
</ul>
<h3 id="이미지-사이즈-최적화">이미지 사이즈 최적화</h3>
<p>가장 기본은 <strong>화면에 필요한 크기만큼의 이미지를 사용하는 것</strong>이다.</p>
<p>예를 들어 화면에서는 300px 정도로 보이는 이미지인데, 실제 파일은 3000px짜리라면 불필요하게 큰 이미지를 다운로드하느라 로딩이 느려질 수 있다.</p>
<p>이미지 사이즈를 최적화할 때는 아래 내용을 고려할 수 있다.</p>
<ul>
<li>실제 표시 크기에 맞는 이미지 사용하기</li>
<li>WebP, AVIF 같은 효율적인 포맷 고려하기</li>
<li>썸네일과 원본 이미지를 구분해서 사용하기</li>
<li>반응형 이미지에는 <code>srcset</code> 사용하기</li>
<li>이미지에 <code>width</code>, <code>height</code> 지정하기</li>
</ul>
<p>특히 <code>width</code>, <code>height</code>를 지정하면 이미지가 로딩되기 전에 브라우저가 이미지가 들어갈 공간을 미리 확보할 수 있어서 CLS 개선에도 도움이 된다. </p>
<h3 id="이미지-지연-로딩-lazy-loading">이미지 지연 로딩 (Lazy Loading)</h3>
<p>*<em>: 페이지가 처음 로딩될 때 모든 이미지를 한 번에 불러오는 것이 아니라,
사용자가 스크롤해서 해당 이미지 근처에 왔을 때 불러오는 방식
*</em></p>
<p>예를 들어 긴 게시글 페이지에 이미지가 30개 있다고 해보자.<br>사용자가 첫 화면만 보고 나갈 수도 있는데, 처음부터 30개 이미지를 모두 다운로드하면 불필요한 네트워크 비용이 발생한다.</p>
<p>이럴 때 아래처럼 <code>loading=&quot;lazy&quot;</code>를 사용할 수 있다.</p>
<pre><code>&lt;img src=&quot;/images/review.webp&quot; alt=&quot;리뷰 이미지&quot; loading=&quot;lazy&quot; /&gt;</code></pre><p>다만 모든 이미지에 lazy loading을 적용하면 안 된다.</p>
<p>첫 화면에 바로 보여야 하는 메인 이미지나 LCP에 해당하는 이미지는
오히려 lazy loading 때문에 늦게 로딩될 수 있다.</p>
<p>그래서 lazy loading은</p>
<ul>
<li>첫 화면에 바로 보이는 중요한 이미지에는 사용하지 않고, </li>
<li>스크롤해야 보이는 아래쪽 이미지에 사용하는 것이 좋다.</li>
</ul>
<h3 id="이미지-사전-로딩-preload">이미지 사전 로딩 (Preload)</h3>
<p><strong>: 중요한 이미지를 브라우저가 더 빨리 발견하고 다운로드하도록 미리 알려주는 방식</strong></p>
<p>예를 들어 첫 화면의 메인 이미지가 CSS의 background-image로 들어가 있거나, JavaScript 실행 이후에야 화면에 추가되는 이미지라면 브라우저가 이미지를 늦게 발견할 수 있다.</p>
<p>그런데 그 이미지가 사용자에게 가장 먼저 보여야 하는 중요한 이미지라면, 미리 알려주는 것이 도움이 될 수 있다.</p>
<p>다음과 같이 preload를 사용할 수 있다. </p>
<pre><code>&lt;link
  rel=&quot;preload&quot;
  as=&quot;image&quot;
  href=&quot;/images/hero.webp&quot;
/&gt;</code></pre><p>preload도 무조건 많이 쓰면 좋은 것은 아니다.</p>
<p>중요하지 않은 이미지까지 전부 preload하면 브라우저가 초반에 처리해야 할 리소스가 많아져서 오히려 중요한 CSS나 JavaScript 로딩에 방해가 될 수 있다.</p>
<p>그래서 preload는 정말 중요한 이미지, 
특히 첫 화면의 핵심 이미지에만 신중하게 사용하는 게 좋다.</p>
<hr>
<h2 id="5-폰트-최적화">5. 폰트 최적화</h2>
<p>웹폰트도 성능에 꽤 큰 영향을 준다.</p>
<p>브라우저는 HTML과 CSS를 읽은 뒤, CSS 안에서 사용된 폰트 파일을 발견하고 다운로드한다. 즉, 웹폰트는 기본적으로 별도의 네트워크 요청이 필요하다.</p>
<p>폰트 파일이 크거나 개수가 많으면 텍스트가 늦게 보이거나, 글꼴이 바뀌면서 화면이 흔들릴 수 있다.</p>
<p>그럼 폰트 때문에 생기는 문제들에 대해 살펴보자.</p>
<h3 id="폰트-때문에-생기는-문제">폰트 때문에 생기는 문제</h3>
<h4 id="1-foit-flash-of-invisible-text">1. FOIT (Flash of Invisible Text)</h4>
<p><strong>: 웹폰트가 로딩될 때까지 텍스트가 보이지 않는 현상</strong></p>
<p>사용자 입장에서는 페이지가 뜬 것 같은데 글자가 비어 있는 것처럼 느껴질 수 있다.</p>
<h4 id="2-fout-flash-of-unstyled-text">2. FOUT (Flash of Unstyled Text)</h4>
<p><strong>: 처음에는 기본 폰트로 텍스트가 보이다가, 웹폰트가 로딩되면 폰트가 바뀌는 현상</strong></p>
<p>FOUT는 텍스트가 아예 안 보이는 것보다는 낫지만, 폰트가 바뀌는 순간 글자 크기나 줄 간격이 달라지면 화면이 살짝 움직일 수 있다.</p>
<p>그럼 이러한 문제를 개선하기 위해서는 어떻게 해야할까?</p>
<h3 id="font-display-사용하기">font-display 사용하기</h3>
<p>폰트 최적화에서 자주 사용하는 속성이 <code>font-display</code>다.</p>
<pre><code>@font-face {
  font-family: &quot;Pretendard&quot;;
  src: url(&quot;/fonts/Pretendard.woff2&quot;) format(&quot;woff2&quot;);
  font-display: swap;
}</code></pre><p><code>font-display: swap</code>을 사용하면 웹폰트가 아직 로딩되지 않았을 때도 일단 기본 폰트로 텍스트를 보여준다.
그리고 웹폰트가 준비되면 그때 폰트를 교체한다.</p>
<p>이 방식은 텍스트가 보이지 않는 시간을 줄이는 데 도움이 된다.</p>
<h3 id="필요한-굵기만-사용하기">필요한 굵기만 사용하기</h3>
<p>폰트를 사용할 때 모든 굵기를 다 불러오면 파일이 많아질 수 있다.</p>
<p>예를 들어 실제로는 400, 700만 사용하는데
100부터 900까지 모든 weight를 가져온다면 불필요한 요청이 생긴다.</p>
<p>그래서 프로젝트에서 실제로 사용하는 굵기만 가져오는 것이 좋다.</p>
<h3 id="woff2-사용하기">WOFF2 사용하기</h3>
<p>웹폰트는 가능하면 <code>woff2</code> 형식을 사용하는 것이 좋다.</p>
<p><code>ttf</code>, <code>otf</code> 같은 원본 폰트 파일은 웹에서 사용하기에는 무거울 수 있다.
반면 <code>woff2</code>는 웹 전송에 적합하게 압축된 포맷이다.</p>
<p>그래서 웹에서 폰트를 제공할 때는 보통 <code>woff2</code>를 우선적으로 고려한다.</p>
<h3 id="폰트-preload">폰트 preload</h3>
<p>중요한 폰트는 <code>preload</code>를 사용해서 더 빨리 다운로드하게 할 수도 있다.</p>
<pre><code>&lt;link
  rel=&quot;preload&quot;
  href=&quot;/fonts/Pretendard.woff2&quot;
  as=&quot;font&quot;
  type=&quot;font/woff2&quot;
  crossorigin
/&gt;</code></pre><p><code>preload</code>를 사용하면 브라우저가 스타일시트 다운로드와 파싱이 끝나기를 기다리지 않고 웹폰트를 더 일찍 발견하고 다운로드할 수 있다.</p>
<p>여기서 <code>crossorigin</code>이 자주 필요한 이유는 폰트 요청이 CORS 정책 영향을 받기 때문이다. 같은 도메인에서도 브라우저가 폰트 preload와 실제 font 요청을 동일하게 처리하기 위해 <code>crossorigin</code>을 요구하는 경우가 있다.</p>
<p>다만 폰트 preload도 이미지 preload와 마찬가지로 무조건 많이 쓰는 게 좋은 것은 아니기 때문에, 정말 중요한 대표 폰트에만 사용하는 것이 좋다.</p>
<h3 id="서브셋-폰트">서브셋 폰트</h3>
<p>한글은 글자 수가 많기 때문에 한글 폰트는 특히 무거울 수 있다. </p>
<p>서비스에서 모든 글자를 다 쓰지 않는다면, 
필요한 글자만 포함한 서브셋 폰트를 사용할 수 있다.</p>
<p>다만 사용자 입력이 많은 서비스에서 너무 과하게 서브셋하면 없는 글자가 생길 수 있어 주의해야 한다.</p>
<hr>
<h2 id="6-정리">6. 정리</h2>
<p>이번에 공부한 내용을 정리하면, 성능 최적화는 단순히 “빠르게 만들기”가 아니라 사용자가 페이지를 더 편하게 느끼도록 만드는 작업에 가깝다.</p>
<p>그리고 성능 문제는 감으로 찾는 것이 아니라 DevTools를 통해 확인해야 한다.</p>
<p>Network 패널에서는 어떤 리소스가 얼마나 오래 걸려 다운로드되는지 볼 수 있다.<br>Performance 패널에서는 브라우저가 화면을 그리는 과정에서 어디에 시간이 쓰이는지 볼 수 있다.<br>Lighthouse는 성능 문제를 자동으로 검사하고 개선 방향을 알려준다.</p>
<p>이미지는 웹페이지에서 큰 용량을 차지하는 경우가 많기 때문에 적절한 크기, 포맷, lazy loading, preload를 고려해야 한다.</p>
<p>폰트는 텍스트 표시 속도와 화면 흔들림에 영향을 줄 수 있기 때문에 font-display, WOFF2, 필요한 weight만 사용하기, preload, 서브셋 폰트 등을 고려해야 한다.</p>
<p>결국 성능 최적화의 핵심은 이 문장으로 정리할 수 있을 것 같다.</p>
<blockquote>
<p>중요한 리소스는 더 빨리 불러오고,<br>당장 필요하지 않은 리소스는 나중에 불러오고,<br>불필요한 리소스는 줄인다.</p>
</blockquote>
<p>처음에는 DevTools 화면이 복잡해 보이지만, 각 패널이 어떤 역할을 하는지만 알아도 성능 문제를 훨씬 더 구체적으로 볼 수 있게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이벤트 위임(Event Delegation), 왜 필요한 걸까?]]></title>
            <link>https://velog.io/@jina___/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%9C%84%EC%9E%84Event-Delegation-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C-%EA%B1%B8%EA%B9%8C</link>
            <guid>https://velog.io/@jina___/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%9C%84%EC%9E%84Event-Delegation-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C-%EA%B1%B8%EA%B9%8C</guid>
            <pubDate>Wed, 22 Apr 2026 10:39:32 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jina___/post/4521b817-7832-4087-9a81-26b2a49cb55a/image.png" alt="">
JavaScript로 버튼 클릭 기능을 만들다 보면 보통 이렇게 시작한다.</p>
<pre><code class="language-js">const button = document.querySelector(&quot;button&quot;);

button.addEventListener(&quot;click&quot;, () =&gt; {
  console.log(&quot;클릭됨&quot;);
});</code></pre>
<p>버튼이 하나일 때는 전혀 문제 없다.<br>그런데 목록이 생기고, 삭제 버튼이 여러 개 생기고, 나중에 새로운 항목이 추가되기 시작하면 점점 코드가 번거로워진다.</p>
<p>이럴 때 자주 등장하는 개념이 <strong>이벤트 위임(Event Delegation)</strong> 이다.<br>그런데 이벤트 위임을 제대로 이해하려면, 먼저 <strong>이벤트가 브라우저에서 어떻게 전달되는지</strong>부터 알아야 한다.</p>
<hr>
<h2 id="이벤트는-어떻게-전달될까">이벤트는 어떻게 전달될까?</h2>
<p>어떤 요소를 클릭했다고 해서 이벤트가 그 요소에서만 끝나는 건 아니다.<br>브라우저에서는 이벤트가 특정한 흐름을 따라 전달된다.</p>
<p>이때 자주 같이 나오는 개념이 3가지 있다.</p>
<ul>
<li><strong>캡처링(Capturing)</strong></li>
<li><strong>타깃(Target)</strong></li>
<li><strong>버블링(Bubbling)</strong></li>
</ul>
<p>이 세 단계를 거쳐 이벤트가 전달된다고 보면 된다.</p>
<hr>
<h2 id="1-캡처링capturing">1. 캡처링(Capturing)</h2>
<p>캡처링은 이벤트가 바깥쪽 조상 요소에서 시작해서, 점점 안쪽 자식 요소로 내려가는 과정이다.</p>
<p>예를 들어 구조가 이렇게 있다고 해보자.</p>
<pre><code class="language-html">&lt;div class=&quot;outer&quot;&gt;
  &lt;div class=&quot;inner&quot;&gt;
    &lt;button&gt;클릭&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
<p>버튼을 클릭했을 때 이벤트는 먼저 가장 바깥쪽에서부터 내려온다.</p>
<p>대충 이런 느낌이다.</p>
<ul>
<li><code>document</code></li>
<li><code>html</code></li>
<li><code>body</code></li>
<li><code>.outer</code></li>
<li><code>.inner</code></li>
<li><code>button</code></li>
</ul>
<p>이 내려오는 단계가 <strong>캡처링</strong>이다.</p>
<p>다만 우리가 평소에 <code>addEventListener()</code>를 사용할 때는 대부분 이 캡처링을 직접 의식하지 않는다.<br>기본값이 보통 버블링 단계에서 동작하도록 되어 있기 때문이다.</p>
<hr>
<h2 id="2-타깃target">2. 타깃(Target)</h2>
<p>이벤트가 실제로 발생한 요소에 도착한 순간이 <strong>타깃 단계</strong>다.</p>
<p>위 예시에서는 사용자가 실제로 클릭한 요소가 <code>button</code>이므로,<br>이 이벤트의 타깃은 <code>button</code>이다.</p>
<p>여기서 중요한 것이 바로 <code>event.target</code>이다.</p>
<pre><code class="language-js">button.addEventListener(&quot;click&quot;, (event) =&gt; {
  console.log(event.target);
});</code></pre>
<p><code>event.target</code>은 <strong>실제로 이벤트가 발생한 요소</strong>를 의미한다.</p>
<p>즉,</p>
<ul>
<li>버튼을 클릭하면 <code>event.target</code>은 버튼</li>
<li>버튼 안에 <code>span</code>이 있다면 그 <code>span</code>을 클릭했을 때는 <code>span</code></li>
</ul>
<p>이 될 수 있다.</p>
<p>이 값은 이벤트 위임에서 정말 중요하게 쓰인다.</p>
<hr>
<h2 id="3-버블링bubbling">3. 버블링(Bubbling)</h2>
<p>타깃에 도착한 이벤트는 거기서 끝나지 않고 다시 바깥쪽 부모로 올라간다.<br>이 과정을 <strong>버블링</strong>이라고 한다.</p>
<p>즉 버튼을 클릭하면 이벤트는 다시 이렇게 올라간다.</p>
<ul>
<li><code>button</code></li>
<li><code>.inner</code></li>
<li><code>.outer</code></li>
<li><code>body</code></li>
<li><code>html</code></li>
<li><code>document</code></li>
</ul>
<p>이 “위로 올라가는” 성질 덕분에, 부모 요소도 자식 요소의 클릭을 감지할 수 있다.</p>
<p>그리고 바로 이 버블링을 이용한 방식이 <strong>이벤트 위임</strong>이다.</p>
<hr>
<h2 id="이벤트-위임이란">이벤트 위임이란?</h2>
<p>이벤트 위임은 <strong>자식 요소마다 이벤트를 붙이지 않고, 부모 요소 하나에 이벤트를 등록해서 처리하는 방식</strong>이다.</p>
<p>예를 들어 삭제 버튼이 여러 개 있는 목록이 있다고 해보자.</p>
<pre><code class="language-html">&lt;ul id=&quot;todo-list&quot;&gt;
  &lt;li&gt;
    공부하기
    &lt;button class=&quot;delete-button&quot;&gt;삭제&lt;/button&gt;
  &lt;/li&gt;
  &lt;li&gt;
    산책하기
    &lt;button class=&quot;delete-button&quot;&gt;삭제&lt;/button&gt;
  &lt;/li&gt;
&lt;/ul&gt;</code></pre>
<p>보통은 버튼마다 하나씩 이벤트를 붙이고 싶어진다.</p>
<pre><code class="language-js">const buttons = document.querySelectorAll(&quot;.delete-button&quot;);

buttons.forEach((button) =&gt; {
  button.addEventListener(&quot;click&quot;, () =&gt; {
    console.log(&quot;삭제&quot;);
  });
});</code></pre>
<p>처음에는 잘 동작한다.<br>하지만 나중에 <code>li</code>를 새로 추가하면, 새로 생긴 버튼에는 이 이벤트가 자동으로 붙지 않는다.</p>
<p>왜냐하면 이 코드는 <strong>현재 존재하는 버튼들만</strong> 찾아서 이벤트를 등록했기 때문이다.</p>
<hr>
<h2 id="그래서-부모에-한-번만-등록한다">그래서 부모에 한 번만 등록한다</h2>
<p>이벤트 위임을 사용하면 부모 요소인 <code>ul</code>에만 이벤트를 등록하면 된다.</p>
<pre><code class="language-js">const todoList = document.querySelector(&quot;#todo-list&quot;);

todoList.addEventListener(&quot;click&quot;, (event) =&gt; {
  if (event.target.classList.contains(&quot;delete-button&quot;)) {
    event.target.parentElement.remove();
  }
});</code></pre>
<p>이 코드가 가능한 이유는 간단하다.</p>
<ul>
<li>실제 클릭은 버튼에서 일어난다</li>
<li>그 이벤트가 버블링되어 <code>ul</code>까지 올라온다</li>
<li><code>ul</code>이 그 클릭을 듣고 처리한다</li>
</ul>
<p>즉, <strong>버튼이 아니라 부모가 대신 처리하는 것</strong>이다.</p>
<hr>
<h2 id="이벤트-위임에서-eventtarget이-중요한-이유">이벤트 위임에서 <code>event.target</code>이 중요한 이유</h2>
<p>부모에 이벤트를 걸면 그 안에서 일어나는 모든 클릭이 올라온다.<br>그렇다면 부모는 어떻게 “삭제 버튼을 누른 것인지”, “그냥 리스트를 누른 것인지” 구분할까?</p>
<p>그때 쓰는 것이 <code>event.target</code>이다.</p>
<pre><code class="language-js">todoList.addEventListener(&quot;click&quot;, (event) =&gt; {
  console.log(event.target);
});</code></pre>
<p>실제로 클릭된 요소를 보고, 원하는 대상인지 검사하는 방식이다.</p>
<pre><code class="language-js">if (event.target.classList.contains(&quot;delete-button&quot;)) {
  // 삭제 버튼일 때만 실행
}</code></pre>
<p>즉 이벤트 위임은 이렇게 이해하면 쉽다.</p>
<blockquote>
<p>부모는 이벤트를 듣고 있고,<br><code>event.target</code>으로 실제 클릭된 자식을 확인한 뒤 필요한 동작만 실행한다.</p>
</blockquote>
<hr>
<h2 id="그런데-클릭한-게-버튼-안쪽-요소라면">그런데 클릭한 게 버튼 안쪽 요소라면?</h2>
<p>실제 프로젝트에서는 버튼 안에 글자나 아이콘이 들어가는 경우가 많다.</p>
<pre><code class="language-html">&lt;button class=&quot;delete-button&quot;&gt;
  &lt;span&gt;삭제&lt;/span&gt;
&lt;/button&gt;</code></pre>
<p>이 상황에서 사용자가 <code>span</code>을 클릭하면 <code>event.target</code>은 <code>button</code>이 아니라 <code>span</code>이 될 수 있다.</p>
<p>그러면 이런 코드는 놓칠 수 있다.</p>
<pre><code class="language-js">if (event.target.classList.contains(&quot;delete-button&quot;)) {
  ...
}</code></pre>
<p>그래서 이럴 때는 <code>closest()</code>를 자주 쓴다.</p>
<hr>
<h2 id="closest를-같이-쓰면-더-안전하다"><code>closest()</code>를 같이 쓰면 더 안전하다</h2>
<pre><code class="language-js">todoList.addEventListener(&quot;click&quot;, (event) =&gt; {
  const deleteButton = event.target.closest(&quot;.delete-button&quot;);

  if (!deleteButton) return;

  deleteButton.parentElement.remove();
});</code></pre>
<p><code>closest()</code>는 현재 요소에서 시작해서, 가장 가까운 조상 중 조건에 맞는 요소를 찾아준다.</p>
<p>즉,</p>
<ul>
<li>버튼 자체를 눌러도 찾을 수 있고</li>
<li>버튼 안의 <code>span</code>을 눌러도 찾을 수 있다</li>
</ul>
<p>그래서 이벤트 위임에서는 <code>event.target</code>과 <code>closest()</code>를 함께 쓰는 경우가 많다.</p>
<hr>
<h2 id="왜-이벤트-위임이-특히-유용할까">왜 이벤트 위임이 특히 유용할까?</h2>
<h3 id="1-반복되는-요소를-한-번에-관리할-수-있다">1. 반복되는 요소를 한 번에 관리할 수 있다</h3>
<p>삭제 버튼이 100개 있어도 부모 하나만 관리하면 된다.</p>
<h3 id="2-나중에-추가된-요소도-동작한다">2. 나중에 추가된 요소도 동작한다</h3>
<p>이게 정말 중요하다.<br>예를 들어 목록에 새 항목을 추가해도:</p>
<pre><code class="language-js">todoList.innerHTML += `
  &lt;li&gt;
    장보기
    &lt;button class=&quot;delete-button&quot;&gt;삭제&lt;/button&gt;
  &lt;/li&gt;
`;</code></pre>
<p>삭제 버튼이 따로 다시 이벤트 등록을 하지 않아도 그대로 동작한다.</p>
<p>부모는 그대로이고, 이벤트는 계속 버블링되어 올라오기 때문이다.</p>
<h3 id="3-코드가-덜-복잡해진다">3. 코드가 덜 복잡해진다</h3>
<p>비슷한 이벤트 처리 로직이 여기저기 퍼지지 않고, 한 곳에 모이게 된다.<br>그래서 유지보수도 더 쉬워진다.</p>
<hr>
<h2 id="언제-많이-쓸까">언제 많이 쓸까?</h2>
<p>이벤트 위임은 특히 이런 상황에서 자주 쓴다.</p>
<ul>
<li>투두 리스트</li>
<li>댓글 목록</li>
<li>장바구니 목록</li>
<li>테이블 행 클릭</li>
<li>수정 / 삭제 버튼이 반복되는 UI</li>
</ul>
<p>반대로 버튼 하나만 있는 간단한 화면이라면, 굳이 이벤트 위임을 쓸 필요는 없다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>이벤트는 브라우저에서 보통 다음 흐름으로 전달된다.</p>
<ol>
<li><strong>캡처링</strong>: 바깥 조상에서 안쪽 요소로 내려옴</li>
<li><strong>타깃</strong>: 실제 이벤트가 발생한 요소에 도착</li>
<li><strong>버블링</strong>: 다시 부모 방향으로 올라감</li>
</ol>
<p>그리고 이벤트 위임은 이 중 <strong>버블링</strong>을 활용한 방식이다.</p>
<p>부모 요소 하나에 이벤트를 걸어두고,<br><code>event.target</code>으로 실제 클릭된 요소를 확인해서 필요한 동작만 처리한다.</p>
<p>처음에는 버튼마다 직접 이벤트를 붙이는 방식이 더 익숙하지만,<br>동적으로 요소가 추가되는 화면을 만들기 시작하면 이벤트 위임이 왜 필요한지 확실히 느끼게 된다.</p>
<p>나도 처음엔 그냥 “부모에 걸면 편하구나” 정도로만 생각했는데,<br>실제로 목록 UI를 만들수록 이 방식이 훨씬 자연스럽고 관리하기 쉽다는 걸 느꼈다.</p>
<p>특히 삭제, 수정처럼 반복되는 버튼이 있는 화면이라면<br>이벤트 위임은 거의 한 번쯤 꼭 만나게 되는 개념인 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SEO에서 GEO로: 생성형 검색 시대, 웹 콘텐츠는 어떻게 발견될까?]]></title>
            <link>https://velog.io/@jina___/SEO%EC%97%90%EC%84%9C-GEO%EB%A1%9C-%EC%83%9D%EC%84%B1%ED%98%95-%EA%B2%80%EC%83%89-%EC%8B%9C%EB%8C%80-%EC%9B%B9-%EC%BD%98%ED%85%90%EC%B8%A0%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B0%9C%EA%B2%AC%EB%90%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jina___/SEO%EC%97%90%EC%84%9C-GEO%EB%A1%9C-%EC%83%9D%EC%84%B1%ED%98%95-%EA%B2%80%EC%83%89-%EC%8B%9C%EB%8C%80-%EC%9B%B9-%EC%BD%98%ED%85%90%EC%B8%A0%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B0%9C%EA%B2%AC%EB%90%A0%EA%B9%8C</guid>
            <pubDate>Fri, 10 Apr 2026 09:46:25 GMT</pubDate>
            <description><![CDATA[<h3 id="seo와-geo란-무엇인가">SEO와 GEO란 무엇인가</h3>
<blockquote>
<h4 id="seosearch-engine-optimization--검색-엔진-최적화">SEO(Search Engine Optimization) : 검색 엔진 최적화</h4>
<p>검색엔진이 내 콘텐츠를 이해하게 돕고, 사용자가 검색엔진을 통해 내 사이트를 더 잘 발견하도록 만드는 작업으로, 검색엔진이 페이지를 발견하고(crawl), 이해하고(index), 적절한 질의에 보여줄 수 있게 만드는 것이 핵심이다.</p>
</blockquote>
<blockquote>
<h4 id="geogenerative-engine-optimization--생성형-엔진-최적화">GEO(Generative Engine Optimization) : 생성형 엔진 최적화</h4>
<p>생성형 AI가 답변을 만들 때 내 콘텐츠를 참고·인용·언급하도록 돕는 최적화</p>
</blockquote>
<p>SEO가 전통적으로 검색 결과 페이지에서의 발견과 클릭을 중심으로 했다면,
GEO는 <strong>그 기반 위에서</strong> AI 답변 안에서의 인용과 기여까지 함께 본다.</p>
<p>&nbsp;</p>
<p>그러므로 먼저 SEO에 대해 자세히 살펴보자.</p>
<h3 id="seo-노출">SEO 노출</h3>
<p><strong>크롤링</strong> 가능해야 하고 → <strong>인덱싱</strong> 가능해야 하고 → <strong>질의와 관련 있고 유용</strong>하다고 평가돼야 한다.</p>
<h4 id="1-크롤링--발견">1. 크롤링 : 발견</h4>
<p><strong>링크가 잘 연결되어 있으면 발견되기 쉽고, 
sitemap이 있으면 새 페이지/업데이트를 알리기 쉬우며, 
robots.txt를 잘못 막으면 아예 읽히지 않을 수 있다.</strong></p>
<p>crawler가 웹의 URL을 찾아가 텍스트, 이미지, 비디오 등을 가져온다. 보통 이 발견은 <strong><em>내부 링크</em></strong>, <strong><em>외부 링크</em></strong>, <strong><em>sitemap</em></strong>을 통해 일어난다. <strong><em>robots.txt</em></strong>는 크롤러가 어떤 URL을 요청할 수 있는지 제어하지만, Google 공식 문서에 따르면 robots.txt는 “검색 결과에서 숨기기” 용도가 아니라 “크롤링 요청 제어” 용도이다.</p>
<ul>
<li><em>내부링크</em> : 내 사이트 안에서 다른 내 페이지로 연결되는 링크</li>
<li><em>외부링크</em> : 다른 사이트에서 내 사이트로 들어오는 링크</li>
<li><em>sitemap</em> : “내 사이트에는 이런 페이지들이 있습니다” 하고 검색엔진에 알려주는 목록표</li>
<li><em>robots.txt</em> : 크롤러에게 주는 안내문 - 어떤 주소는 들어와도 되고, 어떤 주소는 들어오지 말라고 말하는 규칙표</li>
</ul>
<h4 id="2-인덱싱--이해">2. 인덱싱 : 이해</h4>
<p><strong>페이지가 존재한다고 해서 바로 검색에 뜨는 게 아니고
읽힘 → 해석됨 → 색인됨 단계를 거쳐야 한다.</strong></p>
<p>수집한 페이지의 텍스트, 이미지, 비디오를 분석해서 색인(index)에 저장한다. 이때 _<strong>중복 페이지</strong>_가 많으면 _<strong>canonical 판단</strong>_도 함께 일어나고, 자바스크립트로 렌더링되는 사이트는 렌더링 큐를 거칠 수 있어서 단순 HTML보다 처리 복잡도가 높아질 수 있다.</p>
<ul>
<li><em>중복 페이지</em> : 같거나 거의 같은 내용이 여러 URL로 존재하는 경우</li>
<li><em>canonical 판단</em> : 대표 URL을 선택하는 과정</li>
</ul>
<h4 id="3-검색결과-제공--ranking--serving">3. 검색결과 제공 : Ranking / Serving</h4>
<p>사용자가 검색하면 Google은 관련성 있는 정보를 반환한다. &#39;랭킹&#39;은 무엇을 먼저 보여줄지 순서를 정하는 것이고, 검색 결과 몇 번째에 뜨느냐와 관련된 개념이다. Google은 helpful, reliable, people-first content를 우선하는 방향을 강조한다.</p>
<p>&nbsp;</p>
<h3 id="html에서-seo에-중요한-부분">HTML에서 SEO에 중요한 부분</h3>
<h4 id="1-title">1. <code>&lt;title&gt;</code></h4>
<p>  <strong>검색 결과에서 제목 후보가 되고, 페이지 주제를 가장 강하게 알려주는 HTML 요소 중 하나</strong></p>
<p>  모든 페이지에 <code>&lt;title&gt;</code>이 있어야 하고, 설명적이고 간결해야 하며, 너무 모호한 “Home”, “Profile” 같은 제목은 피해야 한다. 검색 결과의 제목 링크는 <code>&lt;title&gt;</code>만 그대로 쓰는 게 아니라 Google이 여러 신호를 바탕으로 자동 생성할 수 있다.</p>
<h4 id="2-meta-namedescription">2. <code>&lt;meta name=&quot;description&quot;&gt;</code></h4>
<p><strong>순위 자체보단 클릭을 유도하는 설명문 역할</strong></p>
<p>메타 설명은 직접적인 랭킹 보장 요소라기보다, 검색 결과 snippet 후보다. 검색 결과의 요약문으로 사용될 수 있고, 사용자에게 페이지를 설명하는 짧고 관련성 있는 요약이어야 한다.</p>
<h4 id="3-headings-h1-h2-">3. Headings (<code>&lt;h1&gt;, &lt;h2&gt; ...</code>)</h4>
<p><strong>페이지 구조를 명확히 하고, 검색엔진과 사용자 모두에게 “이 문단은 무엇에 대한 설명인지” 알려주는 역할</strong></p>
<p>사용자가 검색할 만한 단어를 제목과 main heading 같은 눈에 띄는 위치에 두어야 한다. heading은 페이지 구조를 보여주고, 주제를 덩어리로 나눠 이해하게 도와준다.</p>
<h4 id="4-본문-텍스트---가장-중요">4. 본문 텍스트 - !!!!!!!!!!가장 중요!!!!!!!!!!</h4>
<p>Google은 계속해서 helpful, reliable, people-first content를 강조한다. 키워드만 넣는 게 아니라 실제로 질문에 답하고, 경험과 전문성이 드러나는 콘텐츠가 중요하다.</p>
<h4 id="5--링크-a">5.  링크 (<code>&lt;a&gt;</code>)</h4>
<p>Google Search Essentials는 링크가 crawlable해야 다른 페이지를 찾을 수 있다고 설명한다. 즉, 내부 링크 구조는 SEO에서 매우 중요하다. 앵커 텍스트도 페이지 맥락을 이해하는 데 도움을 준다.</p>
<h4 id="6-이미지-alt">6. 이미지 alt</h4>
<p>Google은 alt text를 사람들이 검색에 사용할 단어를 넣을 수 있는 “설명적 위치” 중 하나로 본다. alt는 접근성에도 중요하고 이미지 이해에도 도움을 준다.</p>
<h4 id="7-canonical">7. Canonical</h4>
<p>비슷하거나 중복된 페이지가 여러 URL에 있을 때는 아래와 같이 rel=&quot;canonical&quot;을 사용해 대표 URL을 알려주는 게 중요하다.</p>
<pre><code>&lt;link rel=&quot;canonical&quot; href=&quot;https://example.com/article&quot; /&gt;</code></pre><p>Google은 canonicalization 문서에서 중복 페이지 그룹 중 어떤 URL을 선호하는지 검색엔진에 알리는 여러 방법을 설명한다.</p>
<h4 id="8-robots-meta">8. Robots meta</h4>
<p><code>&lt;meta name=&quot;robots&quot; content=&quot;noindex&quot;&gt;</code> 같은 태그는 인덱싱 허용/비허용을 제어한다. robots.txt는 크롤링 제어고, noindex는 색인 제외를 뜻한다.</p>
<h4 id="9-structured-data">9. Structured data</h4>
<p><strong>검색엔진이 웹페이지의 성격을 더 잘 이해하도록 돕는 구조화된 정보</strong></p>
<p>Google이 페이지를 이해하는 데 도움을 주며, 경우에 따라 별점, 가격, 조리 시간 같은 rich result 형태의 검색결과로 이어질 수 있다.
예를 들어 HTML만으로는 “상품처럼 보이는 글”일 수 있지만, structured data를 사용하면 “이 페이지는 Product이고, 가격은 얼마이며, 재고 상태는 어떠하다”처럼 더 명확하게 설명할 수 있다.</p>
<pre><code>// JSON-LD로 작성한 structured data의 예시
&lt;script type=&quot;application/ld+json&quot;&gt;
{
  &quot;@context&quot;: &quot;https://schema.org&quot;,
  &quot;@type&quot;: &quot;Recipe&quot;,
  &quot;name&quot;: &quot;초코칩 쿠키&quot;,
  &quot;cookTime&quot;: &quot;PT30M&quot;
}
&lt;/script&gt;</code></pre><hr>
<p>자. 이제 GEO에 대해 살펴보자. </p>
<blockquote>
<h4 id="geogenerative-engine-optimization">GEO(Generative Engine Optimization)</h4>
<p>생성형 AI가 답변을 만들 때 내 콘텐츠를 참고·인용·언급하도록 돕는 최적화</p>
</blockquote>
<h3 id="geo-노출">GEO 노출</h3>
<h4 id="1-콘텐츠가-웹에서-발견되고-이해돼야-함">1. 콘텐츠가 웹에서 발견되고 이해돼야 함</h4>
<p>GEO의 바닥은 여전히 <strong>색인 가능성, 접근 가능성, 신뢰 가능한 콘텐츠</strong></p>
<p>AI 답변도 결국 웹의 콘텐츠를 참고해야 하니까, 기초 SEO가 안 되어 있으면 GEO도 어렵다.</p>
<h4 id="2-ai가-답변을-만들-때-쓰기-좋은-정보-단위여야-함">2. AI가 답변을 만들 때 “쓰기 좋은 정보 단위”여야 함</h4>
<p>AI에 잘 쓰이는 콘텐츠는 대체로 
<strong>질문에 바로 답하고, 문단 구조가 명확하고, 정의·비교·단계가 분명하고, 근거가 드러나는 글</strong>이다. </p>
<p>여기서 SEO와 느낌이 갈린다. AI는 전통 검색처럼 “페이지 전체를 하나의 랭킹 객체”로만 보지 않고, <strong>특정 문단, 정의, 요약, 절차, 비교, 근거 문장</strong> 같은 더 잘게 쪼개진 정보 단위를 활용할 수 있다. 업계 자료들은 GEO에서 answer-first structure, definitional clarity, well-structured content의 중요성을 반복해서 말한다.</p>
<h4 id="3-인용·언급-가능성이-중요해짐">3. 인용·언급 가능성이 중요해짐</h4>
<p>GEO에서 노출의 단위가 “몇 위냐”뿐 아니라 “<strong>얼마나 자주 답변의 근거로 쓰이느냐</strong>”로 바뀌고 있다.</p>
<p>좋은 예로, Bing AI Performance는 이제 AI-generated answers에서 total citations(총 인용 횟수), cited pages(인용된 내 페이지), grounding query phrases(AI가 내 콘텐츠를 끌어오는 데 사용한 검색/질문 표현들) 같은 식으로 사이트 인용을 측정한다.</p>
<hr>
<p>그렇다면, 이제 SEO와 GEO의 차이에 대해 알아보자.</p>
<h3 id="seo와-geo-노출방식의-차이">SEO와 GEO 노출방식의 차이</h3>
<blockquote>
<p>SEO는 <strong>“검색 결과에서 선택되기”</strong>,
GEO는 <strong>“AI 답변을 구성하는 근거가 되기”</strong></p>
</blockquote>
<h4 id="seo의-노출-목표">SEO의 노출 목표</h4>
<p>SEO는 주로 <strong>검색 결과 목록에서 잘 발견되고 클릭되게 하는 것</strong>이다. 그래서 title, snippet, rich result, rank, CTR(클릭률) 같은 개념이 핵심. Google의 title link, meta description, structured data 문서가 전부 이 흐름과 연결돼 있다.</p>
<h4 id="geo의-노출-목표">GEO의 노출 목표</h4>
<p>GEO는 <strong>AI가 생성한 답변 안에서 인용·참조·언급되게 하는 것</strong>까지 본다. Bing의 AI Performance가 바로 이걸 측정하고, Search Engine Land도 GEO를 citations, mentions, recommendations 관점에서 설명한다.</p>
<p>&nbsp;</p>
<h3 id="seo와-geo에-잘-노출되는-방법">SEO와 GEO에 잘 노출되는 방법</h3>
<h4 id="공통">공통</h4>
<ul>
<li>페이지가 크롤링/인덱싱 가능해야 함</li>
<li>제목과 구조가 명확해야 함</li>
<li>사람에게 유용하고 신뢰 가능한 콘텐츠여야 함</li>
<li>중복/혼선이 적고 canonical이 정리돼 있어야 함</li>
<li>필요한 경우 structured data로 의미를 더 명확히 할 수 있음<h4 id="seo-쪽에서-특히-강하게-보는-것">SEO 쪽에서 특히 강하게 보는 것</h4>
</li>
<li>검색 의도와 맞는 키워드/주제 정렬</li>
<li>title, meta description, rich result 같은 SERP(검색 결과 페이지 자체) 표현력</li>
<li>내부 링크와 사이트 구조</li>
<li>색인 가능성과 중복 제어<h4 id="geo-쪽에서-더-중요해지는-것">GEO 쪽에서 더 중요해지는 것</h4>
</li>
<li>질문에 바로 답하는 문장 구조</li>
<li>정의, 비교, 단계별 설명처럼 인용하기 쉬운 형식</li>
<li>출처, 경험, 근거가 드러나는 신뢰성</li>
<li>하나의 페이지 안에서도 주제가 잘 분절된 구조 (h2, h3, 짧은 요약, 명확한 질문형 소제목)</li>
<li>브랜드/개체(entity)가 명확하게 식별되는 정보</li>
</ul>
<p>&nbsp;</p>
<h3 id="그래서-결론적으로-html-관점에서-seogeo에-도움되는-부분은">그래서 결론적으로, HTML 관점에서 SEO/GEO에 도움되는 부분은?</h3>
<h4 id="가장-중요한-html문서-구조-요소">가장 중요한 HTML/문서 구조 요소</h4>
<ul>
<li><code>&lt;title&gt;</code>: 페이지 핵심 주제 명시</li>
<li><code>&lt;meta name=&quot;description&quot;&gt;</code>: 검색 결과 요약 후보</li>
<li><code>&lt;h1&gt;, &lt;h2&gt;, &lt;h3&gt;</code>: 문서 구조와 주제 구분</li>
<li><code>&lt;a&gt;</code>와 내부 링크: 크롤링 경로와 문맥 연결</li>
<li>이미지 alt: 이미지 의미와 접근성 보완</li>
<li>rel=&quot;canonical&quot;: 중복 URL 정리</li>
<li>robots meta: 인덱싱 제어</li>
<li>structured data(JSON-LD 등): 콘텐츠 의미 명확화, rich result 가능성 확대<h4 id="geo-쪽에서-특히-더-중요해지는-문서-구조">GEO 쪽에서 특히 더 중요해지는 문서 구조</h4>
</li>
<li>질문형 소제목</li>
<li>첫 문단에 바로 답하는 요약</li>
<li>비교 항목이 분명한 섹션</li>
<li>정의 문장과 예시 분리</li>
<li>길게 뭉친 문단보다, 의미 단위가 분명한 단락</li>
</ul>
<hr>
<h3 id="글을-정리하며">글을 정리하며</h3>
<p>올해 초 『트렌드코리아 2026』에서 GEO라는 개념을 처음 접했다. 생성형 AI 시대로 변화해가는 흐름 속에서 SEO만이 아니라 GEO 역시 점점 중요해지겠다고 생각했는데, 이번 글을 통해 그 개념을 더 깊이 있게 정리해볼 수 있어서 의미 있었다.</p>
<p>특히 프론트엔드 개발자의 관점에서 SEO와 GEO를 고려하는 방법을 함께 살펴볼 수 있어 더 흥미로웠다. 앞으로는 단순히 화면을 구현하는 데서 그치지 않고, 검색엔진과 AI가 콘텐츠를 더 잘 이해할 수 있는 구조까지 고민하며 개발해보고 싶다.</p>
<p>&nbsp;</p>
<h4 id="참고-문헌">참고 문헌</h4>
<ul>
<li>Google Search Central, Search Essentials</li>
<li>Google Search Central, Crawling and Indexing</li>
<li>Google Search Central, Canonicalization</li>
<li>Google Search Central, JavaScript SEO Basics</li>
<li>Google Search Central, Title links</li>
<li>Google Search Central, Meta descriptions</li>
<li>Google Search Central, Structured data</li>
<li>Bing Webmaster Tools, AI Performance</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>