<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>부리부리몬의 머니코드 💰</title>
        <link>https://velog.io/</link>
        <description>https://wonjung-jang.github.io/ 로 이동했습니다!</description>
        <lastBuildDate>Wed, 18 Jun 2025 14:36:44 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>부리부리몬의 머니코드 💰</title>
            <url>https://velog.velcdn.com/images/jang_expedition/profile/78042b12-230c-43b0-8ae6-df3df509cdef/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 부리부리몬의 머니코드 💰. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jang_expedition" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[TanStack Query는 어떻게 queryKey를 비교할까?]]></title>
            <link>https://velog.io/@jang_expedition/TanStack-Query%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-queryKey%EB%A5%BC-%EB%B9%84%EA%B5%90%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jang_expedition/TanStack-Query%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-queryKey%EB%A5%BC-%EB%B9%84%EA%B5%90%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Wed, 18 Jun 2025 14:36:44 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-ts">// 서로 같은 쿼리
useQuery({ queryKey: [&#39;hello&#39;, &#39;world&#39;, 123, { a: 1, b: 2 }] })
useQuery({ queryKey: [&#39;hello&#39;, &#39;world&#39;, 123, { b: 2, c: undefined, a: 1 }] })</code></pre>
<p>TanStack Query를 학습하던 도중에 의문이 들었습니다.
왜 두 쿼리가 같다고 인식될까요?</p>
<p>궁금증을 해결하기 위해 TanStack Query 내부 코드를 뜯어보며 어떻게 <code>queryKey</code>를 저장하고 비교하는지 살펴봤습니다.</p>
<h2 id="1-쿼리-저장-형태">1. 쿼리 저장 형태</h2>
<hr>
<pre><code class="language-tsx">import { QueryClient, QueryClientProvider } from &quot;@tanstack/react-query&quot;;

export default function App() {
  const client = new QueryClient();

  return (
    &lt;QueryClientProvider client={client}&gt;
      ...
    &lt;/QueryClientProvider&gt;
  );
}</code></pre>
<p>저희가 TanStack Query를 사용하기 위해서 처음으로 하는 일은 <code>QueryClientProvider</code>를 선언해주는 일입니다.</p>
<pre><code class="language-ts">export class QueryClient {
  #queryCache: QueryCache
  #mutationCache: MutationCache
  #defaultOptions: DefaultOptions
  #queryDefaults: Map&lt;string, QueryDefaults&gt;
  #mutationDefaults: Map&lt;string, MutationDefaults&gt;
  #mountCount: number
  #unsubscribeFocus?: () =&gt; void
  #unsubscribeOnline?: () =&gt; void

  constructor(config: QueryClientConfig = {}) {
    this.#queryCache = config.queryCache || new QueryCache()
    this.#mutationCache = config.mutationCache || new MutationCache()
    this.#defaultOptions = config.defaultOptions || {}
    this.#queryDefaults = new Map()
    this.#mutationDefaults = new Map()
    this.#mountCount = 0
  }

  ...
}</code></pre>
<p>props으로 넘기는 <code>QueryClient</code> 인스턴스는 <code>#queryCache</code>를 갖고 있는데요.</p>
<pre><code class="language-ts">export class QueryCache extends Subscribable&lt;QueryCacheListener&gt; {
  #queries: QueryStore

  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map&lt;string, Query&gt;()
  }

  ...
}</code></pre>
<p><code>QueryCache</code> 내부에서 <code>#queries</code>로 쿼리들이 저장됩니다.
생성자 함수를 보면 <code>#queries</code>는 Map&lt;string, Query&gt; 형태로 저장되는 걸 확인할 수 있습니다.</p>
<p>저희가 <code>useQuery</code>를 사용해서 배열 형태로 넘긴 <code>queryKey</code>는 어떠한 과정을 거쳐 string 형태로 저장되게 됩니다.</p>
<p>결론부터 말씀드리면 직렬화 과정을 통해 앞서 본 두 쿼리를 같다고 인식합니다.</p>
<p>그렇다면 <code>useQuery</code>를 호출했을 때 내부적으로 어떤 직렬화 과정을 거쳐 <code>queryKey</code>가 비교되는지 살펴보겠습니다.</p>
<h2 id="2-usequery-호출">2. useQuery 호출</h2>
<hr>
<pre><code class="language-ts">export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
  return useBaseQuery(options, QueryObserver, queryClient)
}</code></pre>
<p><code>useQuery</code>를 호출하면 내부에서 <code>useBaseQuery</code>를 실행합니다.</p>
<pre><code class="language-ts">// note: this must be called before useSyncExternalStore
const result = observer.getOptimisticResult(defaultedOptions)</code></pre>
<p><code>useBaseQuery</code> 내부에서는 위 코드가 실행되는데요.</p>
<pre><code class="language-ts">const query = this.#client.getQueryCache().build(this.#client, options)</code></pre>
<p><code>getOptimisticResult</code>는 <code>QueryObserver</code> 클래스의 메서드로 실행 시에 <code>QueryClient</code>의 <code>QueryCache</code>를 가져와 <code>build</code> 메서드를 실행 시킵니다.</p>
<p><code>build</code> 메서드는 기존에 저장된 쿼리가 있는지 조회하고 없다면 새로 생성하는 역할을 합니다.</p>
<p>사용자가 <code>options</code>로 넣어준 커스텀 해싱 함수가 없다면 <code>hashKey</code> 함수에 <code>queryKey</code>를 넘겨줍니다.</p>
<p>정리하면 <code>useQuery</code>를 호출하면 내부적으로 저장된 <code>queryKey</code>를 불러와서 새로운 키인지 비교하고 없다면 추가, 있으면 재사용하게 됩니다.</p>
<pre><code class="language-ts">/**
 * Default query &amp; mutation keys hash function.
 * Hashes the value into a stable hash.
 */
export function hashKey(queryKey: QueryKey | MutationKey): string {
  return JSON.stringify(queryKey, (_, val) =&gt;
    isPlainObject(val)
      ? Object.keys(val)
          .sort()
          .reduce((result, key) =&gt; {
            result[key] = val[key]
            return result
          }, {} as any)
      : val,
  )
}</code></pre>
<p><code>JSON.stringify</code>의 두 번째 매개변수는 replacer가 들어갑니다.
조금 생소할 수도 있는데요(저는 생소했습니다).</p>
<p>먼저 JSON이 어떤 타입을 표현할 수 있는지 확인하고 넘어가겠습니다.</p>
<h2 id="3-json">3. JSON</h2>
<hr>
<p>JSON은 다음 타입만 표현할 수 있습니다.</p>
<ul>
<li>number</li>
<li>string</li>
<li>boolean</li>
<li>null</li>
<li>object (단, 값은 위 타입이어야 한다)</li>
<li>array</li>
</ul>
<p>여기서 주목할 것은 undefined는 JSON 사양에 존재하지 않는 타입이라는 점입니다.
그래서 JSON.stringify는 이를 무시하거나 null로 대체하거나 제거합니다.</p>
<h3 id="31-replacer">3.1. replacer</h3>
<p><code>replacer</code>는 문자열로 직렬화하기 전에 내부 값들을 순회하면서 재구성할 수 있도록 하는 매개변수입니다.
<code>replacer</code> 가 함수일 때 문자열화 될 key와 value, 두 개의 매개변수를 받는데요.
코드를 통해서 알아보겠습니다!</p>
<pre><code class="language-js">// number를 넣은 경우
JSON.stringify(1, (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value) // key:  value: 1
  return value
}) // &#39;1&#39;

// string을 넣은 경우
JSON.stringify(&#39;1&#39;, (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value) // key:  value: 1
  return value 
}) // &#39;&quot;1&quot;&#39;

// boolean을 넣은 경우
JSON.stringify(true, (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value) // key:  value: true
  return value
}) // &#39;true&#39;

// null을 넣은 경우
JSON.stringify(null, (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value)
  return value
}) // &#39;null&#39;

// undefined를 넣은 경우
JSON.stringify(undefined, (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value) // key:  value: undefined
  return value
}) // undefined</code></pre>
<p>먼저 원시 데이터를 넣은 경우를 살펴보면 key 속성이 없기 때문에 value에 값이 그대로 담겨 반환됩니다.</p>
<p>다만 undefined를 단일값으로 넣은 경우, 문자열화되지 못하고 그대로 undefined로 반환되게 됩니다.</p>
<pre><code class="language-js">JSON.stringify(undefined, (key, value) =&gt; {
  if(value === undefined) return &quot;__undefined__&quot;
  return value
}) // &#39;&quot;__undefined__&quot;&#39;</code></pre>
<p>replacer 메서드를 통해 undefined를 처리할 수 있는 로직을 만들 수 있습니다.
replacer는 이렇게 JSON이 문자열로 직렬화하기 전에 재구성할 수 있습니다.</p>
<pre><code class="language-js">// array를 넣은 경우
JSON.stringify([1, &quot;2&quot;, true, null, undefined], (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value)
  // key:  value: (3) [1, 2, &#39;3&#39;]
  // key: 0 value: 1
  // key: 1 value: 2
  // key: 2 value: true
  // key: 3 value: null
  // key: 4 value: undefined
  return value
}) // &#39;[1,&quot;2&quot;,true,null,null]&#39;</code></pre>
<p>배열을 넣은 경우 key에 index가 담기고 각 요소의 값이 value에 담깁니다.
undefined를 제외한 각 요소는 동일하게 동작하지만 undefined가 있을 경우 null로 처리됩니다.</p>
<pre><code class="language-js">// object를 넣은 경우
JSON.stringify({a: 1, b: &quot;2&quot;, c: true, d: null, e: undefined}, (key, value) =&gt; {
  console.log(&quot;key:&quot;, key, &quot;value:&quot;, value)
  // key: a value: 1
  // key: b value: 2
  // key: c value: true
  // key: d value: null
  // key: e value: undefined
  return value
}) // &#39;{&quot;a&quot;:1,&quot;b&quot;:&quot;2&quot;,&quot;c&quot;:true,&quot;d&quot;:null}&#39;</code></pre>
<p>객체를 넣은 경우 value가 undefined라면 제거되는 걸 확인할 수 있습니다.</p>
<h2 id="4-결론">4. 결론</h2>
<hr>
<pre><code class="language-ts">export function hashKey(queryKey: QueryKey | MutationKey): string {
  return JSON.stringify(queryKey, (_, val) =&gt;
    isPlainObject(val)
      ? Object.keys(val)
          .sort()
          .reduce((result, key) =&gt; {
            result[key] = val[key]
            return result
          }, {} as any)
      : val,
  )
}</code></pre>
<p>다시 <code>hashKey</code> 함수를 살펴보겠습니다.</p>
<p><code>queryKey</code>는 배열이기 때문에 replacer에서 각 배열을 순회하면서 직렬화되기 전에 각 요소를 재구성하게 됩니다.</p>
<ol>
<li><code>isPlainObject</code>는 배열과 null의 타입이 object이기 때문에 정말로 객체인 요소인지 확인하는 함수입니다. -&gt; <code>{ b: 2, c: undefined, a: 1 } 통과!</code></li>
<li>요소가 객체라면 객체의 key들을 배열로 뽑아내어 정렬합니다. 정렬하는 이유는 JS에서 객체의 순서가 보장되지 않기 때문입니다. -&gt; <code>[&#39;b&#39;, &#39;c&#39;, &#39;a&#39;] -&gt; [&#39;a&#39;, &#39;b&#39;, &#39;c&#39;]</code></li>
<li>정렬된 key 배열을 다시 순회하며 알맞은 value를 넣어줍니다. -&gt; <code>{ a: 1, b: 2, c: undefined}</code></li>
<li>마지막으로 직렬화 과정을 거치면서 undefined가 제거됩니다. -&gt; <code>{ a: 1, b: 2 }</code></li>
</ol>
<pre><code class="language-ts">// 서로 같은 쿼리
useQuery({ queryKey: [&#39;hello&#39;, &#39;world&#39;, 123, { a: 1, b: 2 }] })
useQuery({ queryKey: [&#39;hello&#39;, &#39;world&#39;, 123, { b: 2, c: undefined, a: 1 }] })</code></pre>
<p>결론적으로, 두 <code>useQuery</code> 호출에서 전달한 <code>queryKey</code>는 배열 내부의 객체가 순서만 다를 뿐 같은 내용을 담고 있기 때문에, <code>hashKey</code> 함수에 의해 같은 문자열로 직렬화됩니다.</p>
<ul>
<li>객체의 키는 <code>hashKey</code>에서 정렬된 순서로 재구성되고,</li>
<li><code>undefined</code> 값은 <code>JSON.stringify</code>에서 자동으로 제거되므로,</li>
<li>최종적으로 두 <code>queryKey</code>는 동일한 문자열로 직렬화되어 같은 쿼리로 인식됩니다.</li>
</ul>
<p>결국 TanStack Query는 내부적으로 일관된 문자열로 정규화하여 비교하기 때문에 순서가 다르거나 undefined가 포함된 경우라도 동일한 키로 처리할 수 있게 됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Object와 Array 성능 평가]]></title>
            <link>https://velog.io/@jang_expedition/Object%EC%99%80-Array-%EC%84%B1%EB%8A%A5-%ED%8F%89%EA%B0%80</link>
            <guid>https://velog.io/@jang_expedition/Object%EC%99%80-Array-%EC%84%B1%EB%8A%A5-%ED%8F%89%EA%B0%80</guid>
            <pubDate>Mon, 02 Jun 2025 02:51:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>[JavaScript 알고리즘 &amp; 자료구조 마스터클래스](<a href="https://www.udemy.com/course/best-javascript-data-structures/?couponCode=ACCAGE0923">JavaScript (JS) Algorithms and Data Structures Masterclass</a>) 강의를 듣고 정리한 내용입니다.</p>
</blockquote>
<h2 id="1-object">1. Object</h2>
<hr>
<ul>
<li>특징: 정렬되어 있지 않고 빠른 접근, 추가, 삭제, 수정이 가능합니다.<ul>
<li>접근, 추가, 삭제, 수정: O(1)</li>
<li>조회: O(N)</li>
</ul>
</li>
</ul>
<p>여기서 접근과 조회의 차이가 뭘까요?</p>
<pre><code class="language-js">const object = { a: &quot;A&quot;};

object.a; // 접근
Object.values(object).find(value =&gt; value === &quot;A&quot;); // 조회</code></pre>
<p>접근은 Object를 기준으로 알고 싶은 값의 key를 알고 있을 때 key를 통해 value에 접근하는 상황입니다.
조회는 key를 모를 때, 어떤 값이 있는지 찾기 위해 전체를 순회하며 value를 찾는 과정을 거칩니다.</p>
<h3 id="11-object-method">1.1. Object Method</h3>
<ul>
<li>Object.keys(), Object.values(), Objec.entries(): O(N)</li>
</ul>
<p>keys, values, entries는 모두 객체 안에 저장된 값이 많아질 수록 증가하기 때문에 선형 시간을 갖습니다.</p>
<ul>
<li>Object.hasOwnProperty(): O(1)</li>
</ul>
<p>hansOwnProperty가 왜 상수 시간을 갖는지 의아할 수 있는데요.
위에서 접근은 상수 시간을 갖는 것처럼 hasOwnProperty는 key를 통해 접근 가능한 값이 있으면 <code>true</code>, 없으면 <code>false</code>를 반환하는 메서드이기 때문에 상수 시간을 갖습니다.</p>
<blockquote>
<p>나중에 해쉬 테이블과 해쉬 맵에 대해서 알게 되면 더 자세하게 알 수 있습니다.</p>
</blockquote>
<h2 id="2-array">2. Array</h2>
<hr>
<ul>
<li>Array는 정렬된 데이터 구조가 필요할 때 사용합니다.</li>
<li>성능 최적화를 위해서는 싱글 리스트와 더블 링크 리스트처럼 선형 리스트 구조가 작업에 따라 배열보다 더 빠를 수 있습니다.</li>
<li>특히 배열은 추가, 삭제는 상황에 따라 성능이 좋지 않습니다.<ul>
<li>접근: O(1)</li>
<li>추가, 삭제: 상황에 따라 다름</li>
<li>탐색: O(N)</li>
</ul>
</li>
</ul>
<h3 id="21-array의-추가-삭제">2.1. Array의 추가, 삭제</h3>
<p>Array의 추가, 삭제는 상황에 따라 다른 시간 복잡도를 가지는데요.
배열의 끝 지점에 추가, 삭제를 한다면 <code>O(1)</code>의 상수 시간을 갖습니다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/d75531af-d37b-4dd6-b1db-4efc106b2292/image.png" alt=""></p>
<p>배열 <code>[&quot;가&quot;, &quot;나&quot;, &quot;다&quot;]</code>가 있다면 위의 그림처럼 0, 1, 2의 인덱스를 갖습니다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a0278d4c-a37a-4159-a2c2-0c46e331386e/image.png" alt=""></p>
<p>배열의 끝에 <code>”라”</code>라는 값을 추가한다면, 값을 추가하고 다음 인덱스를 할당하기만 하면 됩니다.
기존 배열 요소들은 영향을 받지 않기 때문에 상수 시간을 갖게 되는 겁니다.</p>
<p>하지만 배열의 시작에 요소를 추가한다면 어떨까요?</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/209b7a75-546c-43af-bb09-63c92ec7cc56/image.png" alt=""></p>
<p>배열의 시작에 요소를 추가하게 되면, 기존 배열 요소 모두에게 영향을 미칩니다.
모든 요소의 인덱스를 수정해야 하기 때문입니다.
따라서 배열의 시작에 요소를 추가하면 <code>O(N)</code>이라는 선형 시간을 갖습니다.
삭제도 동일합니다.</p>
<p>따라서 배열의 끝에 요소를 추가, 삭제하는 <code>push</code>, <code>pop</code>은 상수 시간을, 시작에 요소를 추가, 삭제하는 <code>shift</code>, <code>unshift</code>는 선형 시간을 갖게 됩니다.</p>
<h3 id="22-array-method">2.2. Array Method</h3>
<ul>
<li>push, pop: O(1)</li>
<li>shift, unshift: O(N)</li>
<li>concat, slice, splice: O(N)</li>
<li>sort: O(NlogN)</li>
<li>forEach, map, filter, reduce, …: O(N)</li>
</ul>
<p><code>push</code>, <code>pop</code>, <code>shift</code>, <code>unshift</code>의 시간 복잡도는 앞서 알아봤는데요.</p>
<p><code>concat</code>은 두 배열을 붙여 새로운 배열을 만드는 메서드입니다.
N개의 요소를 갖고 있는 배열과 M개의 요소를 갖고 있는 배열을 합치면 <code>O(N + M)</code>이 되는데, 대략적인 추세만 보기때문에 <code>O(N)</code>이라고 봅니다.</p>
<p><code>slice</code>는 기존 배열을 잘라 복사한 배열을 만드는 메서드입니다.
50개의 요소를 갖고 있는 배열에 10개를 복사하는 것, 50개를 복사하는 시간은 모두 복사하는 요소의 갯수만큼 증가하기 때문에 <code>O(N)</code>의 시간 복잡도를 갖습니다.</p>
<p><code>splice</code>는 기존 배열에 요소를 추가하거나, 삭제하는 작업을 합니다.
배열의 중간 요소를 교체할 수도, 시작에 추가할 수도, 마지막에 추가할 수도 있습니다.
중간 요소들을 이동시켜야 할 수 있기 때문에 일반적으로 <code>O(N)</code>으로 표현합니다.</p>
<p><code>sort</code>는 <code>O(NlogN)</code>의 시간 복잡도를 갖습니다.
왜냐하면 정렬은 단순히 순회하지 않고, 값을 비교하고 요소를 바꾸는 복잡한 작업을 수행하기 때문입니다.
따라서 단순히 순회하는 <code>O(N)</code>의 시간보다는 더 걸리게 됩니다.
추후에 정렬 과정에서 더 자세히 다루겠습니다.</p>
<p>그리고 나머지 <code>forEach</code>, <code>map</code>, <code>filter</code>, <code>reduce</code> 등은 순회하며 요소마다 한 가지 작업을 수행하기 때문에 <code>O(N)</code>의 시간 복잡도를 갖습니다.</p>
<h2 id="3-정리">3. 정리</h2>
<hr>
<table>
<thead>
<tr>
<th>구조</th>
<th>작업</th>
<th>시간 복잡도</th>
</tr>
</thead>
<tbody><tr>
<td>Object</td>
<td>접근</td>
<td>O(1)</td>
</tr>
<tr>
<td>Object</td>
<td>조회</td>
<td>O(N)</td>
</tr>
<tr>
<td>Array</td>
<td>인덱스로 접근</td>
<td>O(1)</td>
</tr>
<tr>
<td>Array</td>
<td>push/pop</td>
<td>O(1)</td>
</tr>
<tr>
<td>Array</td>
<td>shift/unshift</td>
<td>O(N)</td>
</tr>
<tr>
<td>Array</td>
<td>탐색(map/filter 등)</td>
<td>O(N)</td>
</tr>
<tr>
<td>Array</td>
<td>정렬(sort)</td>
<td>O(NlogN)</td>
</tr>
</tbody></table>
<ul>
<li>Object는 정렬되어 있지 않지만, 거의 모든 작업을 빠르게 수행합니다.</li>
<li>Array는 정렬되어 있고, 시작에 요소를 추가, 삭제하는 것보다 마지막에 요소를 추가, 삭제하는 것이 더 빠릅니다.</li>
<li>추후에 공부할 링크드 리스트는 시작에 요소를 추가, 삭제해도 상수 시간을 갖습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[제네릭 (Generics)]]></title>
            <link>https://velog.io/@jang_expedition/%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics</link>
            <guid>https://velog.io/@jang_expedition/%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics</guid>
            <pubDate>Fri, 30 May 2025 07:07:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8/dashboard">한 입 크기로 잘라먹는 타입스크립트</a>를 보고 정리한 내용입니다.</p>
</blockquote>
<h2 id="1-제네릭">1. 제네릭</h2>
<hr>
<pre><code class="language-ts">function func(value: any) {
  return value;
}

let num = func(10);
let bool = func(true);
let str = func(&quot;string&quot;);</code></pre>
<p>위와 같은 코드를 작성하면 <code>num</code>, <code>bool</code>, <code>str</code>의 타입은 모두 any입니다.
함수는 리턴값을 기준으로 타입을 추론하기 때문입니다.</p>
<pre><code class="language-ts">num.toUpperCase();</code></pre>
<p>하지만 <code>num</code> 이 any 타입으로 추론되기 때문에 String 메서드인 <code>toUpperCase</code>를 사용해도 오류를 발생시키지 않습니다.</p>
<pre><code class="language-ts">function func(value: unknown) {
  return value;
}

let num = func(10);
num.toUpperCase(); // 오류!
num.toFixed(); // 오류!</code></pre>
<p><code>unknown</code> 타입으로 정의하면 <code>toUpperCase</code> 메서드 사용 시 오류를 발생 시킬 수 있지만, <code>toFixed</code>와 같은 Number 메서드도 오류를 발생 시키는 문제가 있습니다.</p>
<pre><code class="language-ts">function func&lt;T&gt;(value: T): T {
  return value;
}</code></pre>
<p>이때 <code>func</code> 함수를 제네릭 함수로 만들어 인자에 따라 반환 타입을 가변적으로 정해줄 수 있습니다.
<code>T</code>는 타입을 저장하는 타입 변수입니다.
타입 변수는 인자로 어떤 타입을 전달하는지에 따라 저장되는 타입이 달라지며, 타입 파라미터, 제네릭 타입 변수, 제네릭 타입 파라미터 등으로도 불립니다.</p>
<pre><code class="language-ts">let num = func(10);
let bool = func(true);
let str = func(&quot;string&quot;);</code></pre>
<p>제네릭 함수로 변환하면 <code>num</code>, <code>bool</code>, <code>str</code>의 타입은 <code>number</code>, <code>boolean</code>, <code>string</code>으로 정의됩니다.
타입 변수 <code>T</code>는 자바스크립트의 변수처럼 상황에 따라 다른 타입을 담을 수 있습니다.
타입스크립트는 함수 호출 시 전달된 인자의 타입을 기준으로 타입 변수의 타입을 추론합니다.</p>
<pre><code class="language-ts">let arr = func([1, 2, 3]);</code></pre>
<p><code>number[]</code> 타입을 갖는 값을 인자로 전달하면 타입 변수에 <code>number[]</code>이 담겨 <code>arr</code>의 타입은 <code>number[]</code>가 되는데요.
이때 <code>arr</code>의 타입을 <code>tuple</code>로 정의하고 싶다면 어떻게 해야 할까요?</p>
<pre><code class="language-ts">// 타입 단언
let arr = func([1, 2, 3] as [number, number, number]);
// 또는 직접 작성
let arr = func&lt;[number, number, number]&gt;([1, 2, 3]);</code></pre>
<p>타입 단언을 사용해도 되지만 함수 호출할 때 타입 변수 <code>T</code>에 할당해주고 싶은 타입을 직접 작성하여 해결할 수 있습니다.</p>
<h2 id="2-타입-변수-응용하기">2. 타입 변수 응용하기</h2>
<hr>
<h3 id="21-같은-타입일-수도-있고-다른-타입일-수도-있는-두-개의-인자를-받는-함수">2.1. 같은 타입일 수도 있고 다른 타입일 수도 있는 두 개의 인자를 받는 함수</h3>
<pre><code class="language-ts">function swap&lt;T&gt;(a: T, b: T) {
  return [b, a];
}

const [a, b] = swap(&quot;1&quot;, 2); // 오류!</code></pre>
<p>오류가 발생하는 이유는 첫 번째 인자로 string 타입의 값을 전달하면 타입 변수에 string 타입이 할당되면서 두 번째 인자도 string 타입이 할당되기 때문입니다.</p>
<pre><code class="language-ts">function swap&lt;T, U&gt;(a: T, b: U) {
 return [b, a];
}</code></pre>
<p>이 문제는 타입 변수를 하나 더 선언해줌으로써 해결할 수 있습니다.
타입 변수는 하나만 선언할 필요없이, 여러 개 선언할 수 있습니다.</p>
<h3 id="22-인자로-받은-배열의-첫-번째-값을-반환하는-함수">2.2. 인자로 받은 배열의 첫 번째 값을 반환하는 함수</h3>
<pre><code class="language-ts">function returnFirstValue&lt;T&gt;(data: T[]) {
  return data[0];
}

let num = returnFirstValue([0, 1, 2]);
let str = returnFirstValue([&quot;hello&quot;, &quot;mynameis&quot;]);</code></pre>
<p>위 코드에서 <code>data: T[]</code>이 아닌 <code>data: T</code>로 작성하면 오류가 발생합니다.</p>
<p>함수 내부에서는 호출되어 타입 변수의 타입이 결정되기 전에 최대한 오류를 발생시키지 않기 위해 unknown으로 추론합니다.
따라서 unknown 타입의 값에 배열 인덱스를 사용했기 때문에 오류가 발생합니다.</p>
<p><code>data: T[]</code>로 선언해주면 어떤 배열이 오든 인덱스 접근이 가능하기 때문에 오류가 사라지고 <code>num</code>과 <code>str</code>는 <code>number</code>와 <code>string</code> 타입으로 추론합니다.</p>
<pre><code class="language-ts">let str = returnFristValue([1, &quot;hello&quot;, &quot;mynameis&quot;]);</code></pre>
<p>만약 위와 같이 작성하게 된다면 <code>number | string</code>으로 추론하는데요.</p>
<pre><code class="language-ts">function returnFirstValue&lt;T&gt;(data: [T, ...unknown[]]) {
  return data[0];
}</code></pre>
<p>만약 배열의 첫 번째 인덱스 타입만을 할당받고 싶다면 인자의 타입을 <code>[T, …unknown[]]</code>로 바꿔 받아 해결할 수 있습니다.</p>
<h3 id="23-특정-속성을-갖고-있는-인자만-받고-싶은-함수">2.3. 특정 속성을 갖고 있는 인자만 받고 싶은 함수</h3>
<pre><code class="language-ts">function getLength&lt;T extends { length: number }&gt;(data: T) {
  return data.length;
}</code></pre>
<p>만약 <code>length</code>와 같이 특정 속성이 있는 값만 인자로 받는 함수를 만들고 싶다면 <code>extends</code>을 이용할 수 있습니다.</p>
<h2 id="3-arraypropertymap-타입-정의하기">3. Array.property.map() 타입 정의하기</h2>
<hr>
<pre><code class="language-ts">const arr = [1, 2, 3];
const newArr = arr.map((it) =&gt; it * 2);</code></pre>
<p>위와 같은 상황일 때 <code>map</code> 메서드에서 콜백 함수의 <code>it</code>는 number 타입으로 추론합니다.
인자의 타입을 추론할 수 있는 이유는 <code>map</code> 메서드의 타입이 어딘가에 별도로 선언되어 있기 때문입니다.</p>
<p><img src="image.png" alt=""></p>
<p><code>lib.es5.d.ts</code>라는 자바스크립트 내장 함수의 타입들이 선언된 파일이 있습니다.
여기서 <code>map</code> 메서드의 타입을 볼 수 있는데요.
꽤 복잡해보이는 <code>map</code> 메서드의 타입을 직접 구현해보도록 하겠습니다.</p>
<pre><code class="language-ts">function map&lt;T&gt;(arr: T[], callback: (item: T) =&gt; T) {
  let result = [];
  for (let i = 0; i &lt; arr.length; i++){
    result.push(callback(arr[i]));
  }
  return result;
}</code></pre>
<p><code>Array.prototype.map()</code>은 이미 선언되어 있기 때문에 별도로 메서드 타입을 정의하기 위해  <code>map</code> 함수를 만들어줍니다.</p>
<pre><code class="language-ts">map(arr, (it) =&gt; it * 2);
map(&quot;hi&quot;, &quot;hello&quot;], (it) =&gt; it.toUpperCase());
map([&quot;hi&quot;, &quot;hello&quot;], (it) =&gt; parseInt(it)); // 오류!</code></pre>
<p>오류가 발생하는 이유는 콜백 함수의 반환 타입이 number가 되기 때문입니다.
하지만 실제 <code>map</code> 메서드는 string 타입의 배열을 인자로 넘겨도 결과가 반드시 string 배열 타입은 아닙니다.</p>
<pre><code class="language-ts">function map&lt;T, U&gt;(arr: T[], callback: (item: T) =&gt; U) {
  let result = [];
  for(let i = 0; i &lt; arr.length; i++){
    result.push(callback(arr[i]));
  }
  return result;
}

map([&quot;hi&quot;, &quot;hello&quot;], (it) =&gt; parseInt(it));</code></pre>
<p>이럴 때는 타입 변수를 추가해줍니다.
<code>arr</code>에 string[] 타입이 들어오고 <code>item</code> 타입도 string이 됩니다.
콜백 함수의 반환값은 number 타입이기 때문에 <code>U</code>의 타입을 이때 추론하여 number 타입이 들어오게 됩니다.</p>
<h2 id="4-제네릭-인터페이스와-타입-별칭">4. 제네릭 인터페이스와 타입 별칭</h2>
<hr>
<h3 id="41-제네릭-인터페이스">4.1. 제네릭 인터페이스</h3>
<pre><code class="language-ts">interface KeyPair&lt;K, V&gt; {
  key: K;
  value: V;
};

let keyPair: KeyPair&lt;string, number&gt; = {
  key: &quot;key&quot;,
  value: 0,
};</code></pre>
<p>제네릭 인터페이스를 사용할 때에는 반드시 제네릭 타입 인자를 명시해야 합니다.
제네릭 인터페이스는 특히 객체의 인덱스 시그니처 문법과 함께 사용하면 유연한 객체 타입을 만들 수 있습니다.</p>
<pre><code class="language-ts">interface Map&lt;V&gt; {
  [key: string]: V;
};

let stringMap: Map&lt;string&gt; = {
  key: &quot;value&quot;,
};

let booleanMap: Map&lt;boolean&gt; = {
  key: true,
};</code></pre>
<p>인덱스 시그니처와 제네릭 인터페이스를 사용해서 만든 Map 타입은 다양한 객체를 표현할 수 있습니다.</p>
<h3 id="42-제네릭-타입-별칭">4.2. 제네릭 타입 별칭</h3>
<pre><code class="language-ts">type Map2&lt;V&gt; = {
  [key: string]: V;
}

let stringMap2: Map2&lt;string&gt; = {
  key: &quot;hello&quot;,
};</code></pre>
<p>제네릭 타입 별칭을 만드는 방법은 인터페이스와 거의 비슷합니다.</p>
<h3 id="43-제네릭-인터페이스-활용-예시">4.3. 제네릭 인터페이스 활용 예시</h3>
<pre><code class="language-ts">interface Student {
  type: &quot;student&quot;;
  school: string;
}

interface Developer {
  type: &quot;developer&quot;;
  skill: string;
}

interface User {
  name: string;
  profile: Student | Developer;
}

function goToSchool(user: User) {
  if(user.profile.type !== &quot;student&quot;){
    console.log(&quot;잘 못 오셨습니다.&quot;);
    return;
  }

  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

const developerUser: User = {
  name: &quot;장원정&quot;,
  profile: {
    type: &quot;developer&quot;,
    skill: &quot;TypeScript&quot;,
  },
}

const studentUser: User = {
  name: &quot;홍길동&quot;,
  profile: {
    type: &quot;student&quot;,
    school: &quot;신성중학교&quot;,
  },
}</code></pre>
<p><code>goToSchool</code> 함수는 User 타입을 인자로 받아서 <code>profile.type</code>이 student일 때만 등교 완료를 출력하는 함수입니다.
User의 구분이 많아지고 특정 회원만 이용할 수 있는 함수가 많아지면 함수를 만들 때마다 타입 좁히기를 사용해야 하기 때문에 확장성이 좋지 않습니다.
이럴 때 제네릭 인터페이스를 사용하면 깔끔하게 코드를 작성할 수 있습니다.</p>
<pre><code class="language-ts">interface Student {
  type: &quot;student&quot;;
  school: string;
}

interface Developer {
  type: &quot;developer&quot;;
  skill: string;
}

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

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

const developerUser: User&lt;Developer&gt; = {
  name: &quot;장원정&quot;,
  profile: {
    type: &quot;developer&quot;,
    skill: &quot;TypeScript&quot;,
  },
}

const studentUser: User&lt;Student&gt; = {
  name: &quot;홍길동&quot;,
  profile: {
    type: &quot;student&quot;,
    school: &quot;신성중학교&quot;,
  }
}

goToSchool(developerUser); // 오류!</code></pre>
<p>User 인터페이스를 제네릭 인터페이스로 바꾸고, <code>profile</code>의 타입으로 제네릭 타입 매개 변수를 사용합니다.
이렇게 수정하면 <code>goToSchool</code> 함수에서 타입 좁히기 코드없앨 수 있습니다.
복잡한 객체 타입을 정의하여 사용할 때, 제네릭 인터페이스를 활용하면 코드와 타입 정의를 깔끔하게 분리할 수 있어 유용합니다.</p>
<h2 id="5-제네릭-클래스">5. 제네릭 클래스</h2>
<hr>
<pre><code class="language-ts">class NumberList {
  constructor(private list: number[]) {}

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

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

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

const numberList = new NumberList([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print(); // [1, 2, 4]</code></pre>
<p>위 코드에서 추가로 <code>StringList</code> 클래스도 필요한 상황이라면, 어떻게 확장하면 좋을까요?</p>
<pre><code class="language-ts">class List&lt;T&gt; {
  constructor(private list: T[]) {}

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

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

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

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

const stringList = new List([&quot;1&quot;, &quot;2&quot;]);</code></pre>
<p>기존의 <code>NumberList</code> 클래스는 타입을 모두 number로 고정해놔서 확장성이 좋지 않습니다.
<code>List</code>라는 제네릭 클래스를 제네릭 클래스로 만들면, 생성자 함수에 들어오는 타입을 타입 변수에 할당하여 인자로 전달하는 배열 타입에 대응할 수 있습니다.</p>
<p>제네릭 클래스는 제네릭 인터페이스와 제네릭 타입 별칭과는 다르게 클래스의 생성자를 호출할 때 생성자의 인자로 전달하는 값을 기준으로 타입을 추론하기 때문에 반드시 타입 명시를 해주지 않아도 됩니다.</p>
<h2 id="6-프로미스와-제네릭">6. 프로미스와 제네릭</h2>
<hr>
<pre><code class="language-ts">const promise = new Promise((resolve, reject) =&gt; {
  setTimeout(() =&gt; {
    resolve(20);
  }, 3000);
});

promise.then((response) =&gt; {
  console.log(response); // 20;
});</code></pre>
<p>위 코드에서 <code>console.log(response * 10);</code>으로 수정하면 오류가 발생하는데요.
왜냐하면 <code>response</code>는 unknown 타입으로 추론하기 때문입니다.
Promise는 resolve로 전달되는 값의 타입을 명시하지 않으면 기본적으로 unknown으로 추론합니다.</p>
<pre><code class="language-ts">const promise = new Promise&lt;number&gt;((resolve, reject) =&gt; {
  setTimeout(() =&gt; {
    resolve(20);
  }, 3000);
});

promise.then((response) =&gt; {
  console.log(response * 10);
});</code></pre>
<p>자바스크립트의 내장 클래스인 Promise는 타입스크립트에서 제네릭 클래스로 타입이 선언되어 있습니다.
생성자 함수를 호출할 때 비동기 작업의 결과값 타입을 제네릭 타입 인자로 명시해주면 <code>response</code>의 타입이 해당 타입으로 추론되고 <code>resolve</code> 함수도 반드시 그 타입으로만 받을 수 있게 바뀝니다.</p>
<pre><code class="language-ts">const promise = new Promise&lt;number&gt;((resolve, reject) =&gt; {
  setTimeout(() =&gt; {
    reject(&quot;~~ 때문에 실패&quot;);
  }, 3000);
}

promise.catch((err) =&gt; {
  if(typeof err === &quot;string&quot;) {
    console.log(err);
  }
});</code></pre>
<p><code>reject</code> 함수는 내부적으로 <code>reject: (reason?:any) =&gt; void</code>로 정의되어 있습니다.
<code>catch</code>의 인자 <code>err</code>의 타입도 <code>any</code>로 추론합니다.
따라서 인자의 타입을 정확히 알 수 없기 때문에 프로젝트의 상황에 맞게 타입 좁히기를 사용해야 합니다.</p>
<h3 id="61-프로미스를-반환하는-함수의-타입-정의">6.1. 프로미스를 반환하는 함수의 타입 정의</h3>
<pre><code class="language-ts">interface Post {
  id: number;
  title: string;
  content: string;
}

function fetchPost() {
  return new Promise&lt;Post&gt;((resolve, reject) =&gt; {
    setTimeout(() =&gt; {
      resolve({
        id: 1,
        title: &quot;게시글 제목&quot;,
        content: &quot;게시글 컨텐츠&quot;,
      });
    }, 3000);
  });
}

const postRequest = fetchPost();

postRequest.then(post =&gt; post.id);</code></pre>
<p>Promise를 반환하는 함수의 타입을 정의할 때는 <code>return</code>문의 Promise 생성자 함수에서 제네릭 타입 인자를 명시하거나,</p>
<pre><code class="language-ts">function fetchPost(): Promise&lt;Post&gt; {
  return new Promise((resolve, reject) =&gt; {
    setTimeout(() =&gt; {
      resolve({
        id: 1,
        title: &quot;게시글 제목&quot;,
        content: &quot;게시글 컨텐츠&quot;,
      });
    }, 3000);
  });
}</code></pre>
<p><code>fetchPost</code> 함수의 반환 타입으로 <code>Promise&lt;Post&gt;</code>를 작성해줄 수도 있습니다.
후자가 함수의 인터페이스만 봐도 반환 타입을 알 수 있기 때문에 가독성 면에서 더 좋습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[빅오 표기법 (Big O Notation)]]></title>
            <link>https://velog.io/@jang_expedition/%EB%B9%85%EC%98%A4-%ED%91%9C%EA%B8%B0%EB%B2%95-Big-O-Notation-4719gere</link>
            <guid>https://velog.io/@jang_expedition/%EB%B9%85%EC%98%A4-%ED%91%9C%EA%B8%B0%EB%B2%95-Big-O-Notation-4719gere</guid>
            <pubDate>Wed, 28 May 2025 04:49:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://www.udemy.com/course/best-javascript-data-structures/?couponCode=ACCAGE0923">JavaScript 알고리즘 &amp; 자료구조 마스터클래스</a>강의를 듣고 정리한 내용입니다.</p>
</blockquote>
<h2 id="1-문제-해결은-하나가-아니다">1. 문제 해결은 하나가 아니다</h2>
<hr>
<p>하나의 문제에는 여러 해결책이 존재합니다.
우리는 이 가운데 더 좋은 해결책을 선택해야 합니다.
그렇다면, 더 좋은 해결책이란 무엇일까요?</p>
<ul>
<li>실행 속도가 더 빠른? </li>
<li>메모리를 덜 사용하는? </li>
<li>가독성이 더 좋은?</li>
</ul>
<p>이런 기준은 상황과 우선 순위에 따라 달라지지만, 성능을 중점으로 둔다면 일반적으로 실행 속도가 가장 중요한 기준이 됩니다.</p>
<h2 id="2-두-가지-접근-방식의-예시">2. 두 가지 접근 방식의 예시</h2>
<hr>
<pre><code class="language-js">function A (n) {
  let result = 0;
  for(let i = 1; i &lt;= n; i++){
    result += i;
  }
  return result;
}

function B (n) {
  return n * (n + 1) / 2;
}</code></pre>
<p>숫자 n을 넣으면 1부터 n까지의 합을 반환하는 함수 A, B가 있습니다.
두 함수에 6을 넣으면 1부터 6까지 더해서 21이 출력됩니다.
즉, 두 함수는 같은 인자를 넘겨주면 같은 결과를 반환합니다.</p>
<h3 id="21-실행-시간-비교">2.1. 실행 시간 비교</h3>
<p>두 함수 가운데, 어떤 함수의 실행 속도가 더 빠른지 <code>performance.now()</code>를 활용해서 시간을 직접 측정해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/2dd5c793-2d7c-462f-86e1-8ec482524616/image.png" alt=""></p>
<p>A 함수에 10억을 인자로 넘기면  0.96 초가 소요됩니다.
그러면 같은 수를 B 함수에 넘기면 얼마나 걸릴까요?</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/446967c6-dfdb-44d2-b476-7fc2e7f8e9b2/image.png" alt=""></p>
<p>B 함수는 0.0001 초 정도로 A 함수에 비해 적은 시간이 소요되는 걸 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/f9f12804-3fbc-430a-b352-9371ade08caa/image.png" alt=""></p>
<p>하지만 여러 번 실행하다보면, 조금씩 소요 시간이 바뀌게 되는데요.
그렇다고 해서 A 함수가 B 함수보다 빠르게 나올 정도의 차이는 아니지만, 만약 다른 컴퓨터, 다른 환경이라면 어떨까요?</p>
<h3 id="22-시간-측정의-한계">2.2. 시간 측정의 한계</h3>
<p>만약 A를 측정할 때는 백그라운드 작업이 없었고 B는 무거운 작업이 동시에 실행되고 있는 환경이었다면 결과가 바뀔 수도 있습니다.</p>
<p>이처럼 직접 시간을 측정하는 방법은 환경에 영향받기 때문에 신뢰성이 떨어집니다.
어떤 방법으로 측정해야 환경에 영향받지 않고 객관적일 수 있을까요?</p>
<h3 id="23-시간-대신-연산-횟수를-세자">2.3. 시간 대신 연산 횟수를 세자</h3>
<p>바로 컴퓨터가 실행할 연산 횟수를 세는 방법입니다.
어떤 컴퓨터든 횟수는 같을테니까요.
하지만 로직이 복잡해질 수록 몇 번 할당되고, 몇 번 계산되는지를 일일히 세는 것도 일입니다.</p>
<h3 id="24-연산량의-대략적인-추세만-본다">2.4. 연산량의 대략적인 추세만 본다</h3>
<p>입력값 n을 0부터 점점 늘린다면, 연산량은 일정한 그래프를 그립니다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/23350ca1-501f-4927-ab13-d722e6741709/image.png" alt=""></p>
<p>그래프 상에서 본다면 n과 n + 2는 큰 차이를 보이지 않기 때문에 큰 영향을 주는 대략적인 추세만 파악합니다.
n이든 n + 2든 2n이든 똑같이 n으로, 입력값에 상관없이 300번 연산을 한다면 1로 보는 거죠.</p>
<h2 id="3-빅-오-표기법">3. 빅 오 표기법</h2>
<hr>
<p>입력값에 따른 연산량에 대해 어떤 기준으로 대략적인 추세를 파악할까요?
<code>전체 연산 횟수에서 계수와 상수항은 무시하고, 가장 영향력이 큰 항만을 남긴다.</code>가 기준입니다.</p>
<p>예를 들어 <code>5n² - 3n + 1</code>번 연산하는 알고리즘이 있다면,</p>
<ul>
<li><strong>계수 무시</strong>: <code>5n² - 3n + 1</code> → <code>n² - n + 1</code></li>
<li><strong>상수항 무시</strong>: <code>+ 1</code> → 제거</li>
</ul>
<p>마지막으로 가장 영향력이 큰 하나의 항만 남겨 <code>O(n²)</code>으로 표기합니다.</p>
<p><code>O(n²)</code>은 입력값이 커질 수록 연산량이 n의 제곱만큼 증가한다는 의미입니다.
이로써 입력 크기에 따른 알고리즘의 성능을 예측할 수 있습니다.</p>
<h3 id="31-성능을-빠르게-파악하는-기준">3.1. 성능을 빠르게 파악하는 기준</h3>
<p>실전에서 알고리즘의 성능을 빠르게 예측하기 위해 주의깊게 살펴봐야 할 것들이 있습니다.</p>
<p>대표적으로 반복문입니다.
반복문은 중첩 횟수에 큰 영향을 받습니다.
만약 하나의 반복문이라면 <code>O(n)</code>, 두 번 반복문이 중첩되어 있다면 <code>O(n²)</code>, 세 번 중첩되어 있다면 <code>O(n³)</code>의 복잡도를 가집니다.</p>
<pre><code class="language-js">for(...) {...}
for(...) {...}</code></pre>
<p>헷갈리지 말아야 할 것은 반복문이 중첩되지 않고 두 번 실행된다면 <code>O(n + n)</code>이 되므로 <code>O(n)</code>의 복잡도를 갖습니다.</p>
<p>이 밖에도 단순한 사칙 연산이나 조건문만 있다면 상수 시간인 <code>O(1)</code>의 복잡도를 갖고, 이진 탐색이라면 <code>O(log n)</code>의 복잡도를 갖는데요.
앞으로 알고리즘을 공부하면서 탐색, 삽입, 수정, 삭제에서 어떤 복잡도를 갖는지 살펴보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[단위 테스트]]></title>
            <link>https://velog.io/@jang_expedition/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@jang_expedition/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Tue, 27 May 2025 08:17:36 GMT</pubDate>
            <description><![CDATA[<p>항해 교육 과정에서 가장 아쉬움이 남았던 챕터가 테스트 코드였습니다.
이렇게 테스트하면 되겠다고 생각했지만 문법을 모르고 비동기로 동작하는 코드인지 몰라 다른 결과가 나오고 제출은 다가오는 어려움을 겪었습니다.
우여곡절 끝에 과제는 모두 해결했지만, PR 링크 제출을 깜빡하여 체점을 받지 못했습니다.</p>
<p>이후 꼭 한 번은 정리하고 자주 사용하면서 익숙해져야겠다고 생각했는데요.
계속 미루다가 입사 예정인 회사에서 Jest와 Storybook을 사용한다는 얘기를 듣고 부랴부랴 정리하고 사이드 프로젝트에 적용해보는 시간을 가졌습니다.</p>
<p>먼저, 테스트 코드에 대해서 알아보고 사이드 프로젝트에 적용시키며 문법에 익숙해지는 걸 목표로 진행할 예정입니다.
사이드 프로젝트가 Vite 기반의 React 프로젝트이므로 Jest가 아닌 Vitest를 사용할 예정입니다.</p>
<h2 id="1-테스트란">1. 테스트란?</h2>
<hr>
<p>테스트란 작성한 코드가 의도하는 대로 동작하는지 검증하는 과정을 의미합니다.
테스트를 통해 오류를 쉽게 찾아내고 애플리케이션의 품질을 높여 사용자에게 안정적인 서비스를 제공할 수 있습니다.</p>
<h2 id="2-좋은-테스트-코드-작성을-위한-원칙">2. 좋은 테스트 코드 작성을 위한 원칙</h2>
<hr>
<p>좋은 테스트 코드를 작성하기 위해 지켜야 할 원칙이 있습니다.</p>
<h3 id="21-인터페이스-기준으로-작성하기">2.1. 인터페이스 기준으로 작성하기</h3>
<p>테스트 코드는 인터페이스 기준으로 작성해야 합니다.
인터페이스란 모듈의 내부 구현이 아닌, 외부에 공개된 public method입니다.</p>
<p>모듈의 캡슐화된 내부 구현을 테스트하게 되면 수많은 테스트를 작성해야 하고 모듈에 종속적인 성격을 띄게 되기 때문에 내부 구현이 조금만 변경돼도 깨지기 쉬운 코드가 됩니다.</p>
<p>테스트는 실제 사용자의 사용하는 방식과 유사할 수록 테스트의 신뢰성이 올라갑니다.
따라서 내부 구현에 종속적이지 않고 이벤트 인터페이스 기반으로 검증해야 좋습니다.</p>
<h3 id="22-커버리지보다는-의미있는-테스트">2.2. 커버리지보다는 의미있는 테스트</h3>
<p>커버리지보다는 의미있는 테스트 코드를 작성해야 합니다.
100% 커버리지는 오히려 무의미한 테스트가 포함됐을 가능성을 의미할 수 있습니다.
커버리지 수치에 집착하기 보단, 어떤 걸 테스트해야 하고 테스트하지 말아야 할지, 의미있는 테스트를 작성할 수 있도록 고민해봐야 합니다.</p>
<h3 id="23-단일-책임-원칙을-지켜라">2.3. 단일 책임 원칙을 지켜라</h3>
<p>테스트 코드도 단일 책임 원칙을 지켜, 하나의 테스트는 하나의 검증만 하도록 작성합니다.
또한 description을 명확히 적음으로써 어떤 테스트를 하고 있는지 명확히 작성해야 합니다.</p>
<h2 id="3-테스트-코드로-얻게-되는-장점들">3. 테스트 코드로 얻게 되는 장점들</h2>
<hr>
<p>테스트 코드를 통해 얻게 되는 장점은 무엇일까요?</p>
<h3 id="31-좋은-설계를-위한-가이드-역할">3.1. 좋은 설계를 위한 가이드 역할</h3>
<p>첫 번째는 좋은 설계를 할 수 있도록 도와줍니다.
좋은 테스트 코드를 작성하기 위해서는 단일 책임 원칙을 지켜야 한다고 말씀 드렸는데요.
그러기 위해서는 모듈 간의 역할이 분리되고 의존성이 낮아야 합니다.
만약 페이지 컴포넌트에서 비지니스 로직, UI, 상태 관련한 코드들이 서로 높은 의존성을 띄고 있다면, 특정 부분을 따로 테스트하기가 어렵습니다.</p>
<p>테스트 코드는 독립성이 중요합니다.
독립성이 높은 테스트란, 실행 순서와 다른 테스트 결과에 영향을 받지 않는 테스트를 의미합니다.
하지만 하나의 컴포넌트에 여러 로직이 얽혀서 높은 의존성이 형성된다면 독립성은 떨어지고 특정 로직만 별도로 테스트하기 어렵습니다.</p>
<p>따라서 테스트 코드를 작성하기 위해서 모듈 간의 의존성을 낮추고 책임과 역할을 분리함으로써 좋은 모듈 구조를 가질 수 있도록 설계하게 됩니다.</p>
<h3 id="32-안전한-리팩토링-지원">3.2. 안전한 리팩토링 지원</h3>
<p>두 번째로는 리팩토링을 원활하게 해줍니다.
리팩토링이란, 같은 결과를 내지만 내부 로직이나 코드를 새로 작성하는 작업을 의미합니다.
테스트 코드는 같은 결과를 내는지를 검증하는 최소한의 기준이 됩니다.
또한 리팩토링 과정에서 문제가 발생한다면, 발생 범위를 좁혀 어디에서 문제가 발생했는지 빠르게 파악할 수 있도록 도와줍니다.</p>
<h3 id="33-살아있는-문서-역할">3.3. 살아있는 문서 역할</h3>
<p>세 번째로는 잘 짜여진 테스트 코드는 하나의 문서가 될 수 있습니다.
잘 짜여진 테스트 코드는 테스트 코드만 봐도 어떻게 동작해야 하는지 알 수 있습니다.
이는 온보딩 과정에서 로직 파악을 위한 시간을 단축시켜 줍니다.</p>
<h2 id="4-단위-테스트">4. 단위 테스트</h2>
<hr>
<h3 id="41-단위-테스트란">4.1. 단위 테스트란?</h3>
<p>먼저, 단위 테스트란 무엇일까요?
단위 테스트란 소프트웨어의 가장 작은 단위인 단일 함수나 단일 컴포넌트 등을 검증하는 테스트입니다.
단위 테스트는 상호 작용을 검증하기 보단 모듈의 행위를 독립적으로 검증합니다.
주로 공통 컴포넌트, 공통 유틸, 헬퍼 함수가 대상입니다.</p>
<h3 id="42-aaa-패턴-arrange-act-assert">4.2. AAA 패턴 (Arrange-Act-Assert)</h3>
<p>단일 테스트를 작성할 때는 AAA(Arrange Act Assert) 패턴을 사용합니다.</p>
<ul>
<li>Arrange: 테스트를 위한 환경을 설정합니다.</li>
<li>Act: 테스트를 할 동작을 발생시킵니다.</li>
<li>Assert: 검증합니다.</li>
</ul>
<h3 id="43-단위-테스트의-한계">4.3. 단위 테스트의 한계</h3>
<p>단위 테스트는 하나의 모듈이 의도한대로 동작하는지 검증할 수 있지만 다른 모듈과의 상호작용을 통해 애플리케이션 내에서 요구사항에 맞게 동작하는지 검증하기는 어렵습니다.</p>
<h2 id="5-testing-library">5. Testing Library</h2>
<hr>
<p>Testing Library는 UI 컴포넌트를 사용자가 사용하는 방식으로 테스트하자는 철학을 갖고 있습니다.
앞서 사용자가 사용하는 방식과 유사하게 테스트할 수록 신뢰성이 올라간다고 말씀드렸는데요.</p>
<p>Testing Library는 컴포넌트 내부 구현이나 상태에 직접 접근하지 않고 DOM 요소를 조회하고 이벤트를 발생시키는 방법으로 테스트합니다.
실제 사용자 시나리오와 유사하게 <code>DOM 렌더링 -&gt; 사용자 인터렉션(이벤트 발생) -&gt; 검증 DOM 선택 -&gt; 검증</code> 순서로 검증 절차를 밟습니다.</p>
<p>Testing Library는 특정 프레임워크나 테스트 프레임워크에 종속되지 않고, 여러 프레임워크와 테스트 프레임워크에서 사용할 수 있는데요.
React 환경에 최적화된 버전이 React Testing Library입니다.</p>
<h2 id="6-적용해보기">6. 적용해보기</h2>
<hr>
<p>이제 간단한 Button 컴포넌트와 유틸 함수를 테스트해볼텐데요.
공통 컴포넌트는 UI보다는 사용자가 발생시키는 이벤트를 기준으로 테스트할 예정입니다.</p>
<h3 id="61-button-컴포넌트-테스트">6.1. Button 컴포넌트 테스트</h3>
<p>사용자가 버튼을 클릭하는 상황을 시뮬레이션하고 prop으로 넘겨준 onClick이 동작하는지 검증해보도록 하겠습니다.</p>
<pre><code class="language-tsx">it(&#39;버튼 클릭 시 onClick prop으로 넘긴 함수가 실행된다.&#39;, async () =&gt; {
  // Arrange - 테스트를 위한 환경을 설정한다. (테스트할 Button UI를 렌더링한다)
  const spy = vi.fn();
  const { user } = await render(&lt;Button onClick={spy}&gt;test&lt;/Button&gt;);

  // Act - 테스트할 동작을 수행한다. (userEvent를 통해서 사용자가 버튼을 클릭한 상황을 시뮬레이션한다)
  const button = screen.getByRole(&#39;button&#39;);
  await user.click(button);

  // Assert - 검증한다. (spy 함수가 호출됐는지를 확인한다)
  expect(spy).toHaveBeenCalled();
});</code></pre>
<p>추가로 Button 컴포넌트는 props로 넘긴 <code>variant</code>, <code>color</code>, <code>size</code>에 따라 스타일이 적용되지만, 스타일을 변경하고 싶을 때는 <code>className</code>을 통해 Tailwind CSS 클래스를 전달하기도 합니다.</p>
<pre><code class="language-tsx">import { screen } from &#39;@testing-library/react&#39;;
import { Button } from &#39;./Button&#39;;
import render from &#39;@/utils/test/render&#39;;

it(&#39;className prop으로 넘긴 class가 적용된다.&#39;, () =&gt; {
  // Arrange - 테스트를 위한 환경 설정(테스트할 Button UI를 렌더링한다.
  render(&lt;Button className=&quot;my-class&quot;&gt;test&lt;/Button&gt;);

  // Assert - 검증한다(prop으로 넘겨준 my-class가 적용되어 있는지 확인한다).
  const button = screen.getByRole(&#39;button&#39;);
  expect(button).toHaveClass(&#39;my-class&#39;);
});</code></pre>
<p>이제 작성한 테스트를 실행시켜 볼까요?</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/abb8317f-7d42-42b8-91eb-842543ee1b3f/image.png" alt=""></p>
<p>모두 의도한대로 동작하는 걸 확인할 수 있습니다.</p>
<h3 id="62-유틸-함수-테스트">6.2. 유틸 함수 테스트</h3>
<p>이제 간단한 유틸 함수도 테스트해보겠습니다.</p>
<p>formatRelativeDate 함수는 작성일과 수정일 가운데 최신 날짜를 기준으로 오늘 날짜와 비교하여 
현재 시간 기준으로 게시글이 언제 작성 또는 수정됐는지를 출력해주는 함수입니다.</p>
<pre><code class="language-tsx">describe(&#39;formatRelativeDate&#39;, () =&gt; {
  beforeAll(() =&gt; {
    vi.useFakeTimers();
    vi.setSystemTime(new Date(&#39;2025-05-27T09:00:00&#39;));
  });

  afterAll(() =&gt; {
    vi.useRealTimers();
  });

  it(&#39;3초 전 날짜를 인자로 넘기면 &quot;3s ago&quot;를 출력한다&#39;, () =&gt; {
    const result = formatRelativeDate(&#39;2025-05-27T08:59:57.000000&#39;, &#39;2025-05-27T08:59:57.000000&#39;);
    expect(result).toBe(&#39;3s ago&#39;);
  });

  it(&#39;30분 전 날짜를 인자로 넘기면 &quot;30m ago&quot;를 출력한다&#39;, () =&gt; {
    const result = formatRelativeDate(&#39;2025-05-27T08:30:00.000000&#39;, &#39;2025-05-27T08:30:00.000000&#39;);
    expect(result).toBe(&#39;30m ago&#39;);
  });

  it(&#39;3시간 전 날짜를 인자로 넘기면 &quot;3h ago&quot;를 출력한다&#39;, () =&gt; {
    const result = formatRelativeDate(&#39;2025-05-27T06:00:00.000000&#39;, &#39;2025-05-27T06:00:00.000000&#39;);
    expect(result).toBe(&#39;3h ago&#39;);
  });

  it(&#39;3일 전 날짜를 인자로 넘기면 &quot;3d ago&quot;를 출력한다&#39;, () =&gt; {
    const result = formatRelativeDate(&#39;2025-05-24T09:00:00.000000&#39;, &#39;2025-05-24T09:00:00.000000&#39;);
    expect(result).toBe(&#39;3d ago&#39;);
  });

  it(&#39;생성일과 수정일 중 더 최신 날짜를 기준으로 결과가 출력된다.&#39;, () =&gt; {
    const createdAt = &#39;2025-05-24T09:00:00.000000&#39;;
    const modifiedAt = &#39;2025-05-27T08:59:57.000000&#39;;
    const result = formatRelativeDate(createdAt, modifiedAt);
    expect(result).toBe(&#39;3s ago&#39;);
  });
});</code></pre>
<p>이 과정에서 FakeTimer를 사용했는데요.
날짜를 비교해야 할 경우 실제 시간을 사용하면, 테스트 결과가 실행 시점에 영향을 받기 때문에 FakeTimer로 시간을 지정하여 사용합니다.</p>
<p>Setup과 TearDown을 사용해서 시스템 시간을 맞추고 테스트가 완료되면 다시 원래 시간으로 돌려놓도록 했습니다.
만약 테스트 별로 현재 시간을 다르게 설정한다면 beforeEach와 afterEach를 사용하여 각 테스트 별로 setSytemTime을 따로 설정해주면 됩니다.</p>
<p>추가로 기대값을 검증하는 과정에서 toBe과 toEqual 두 가지가 있는데요.
보통 원시 자료형을 비교할 때는 toBe를 사용합니다.
둘의 차이는 참조형 자료를 비교할 때 있는데요.
toBe는 참조값을 비교하여 같은 메모리를 가르키고 있는지를, toEqual은 참조 자료의 내부 값을 비교합니다.</p>
<pre><code class="language-tsx">it(&#39;toBe와 toEqual을 비교해보자&#39;, () =&gt; {
  const a = { v: 1 };
  const b = { v: 1 };

  expect(a).toBe(b); // 테스트 실패
  expect(a).toEqual(b); // 테스트 성공
});</code></pre>
<p>따라서 참조형 자료를 비교할 때, 같은 메모리를 바라보고 있는지 검사하려면 toBe, 같은 값을 갖고 있는지 비교하고 싶다면 toEqual을 필요에 따라 적절하게 선택해서 사용하면 됩니다.</p>
<p>이제 테스트 코드를 실행하면!</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/199ed9d4-f1dc-48a4-8477-804b008c56a0/image.png" alt=""></p>
<p>정상적으로 통과하는 걸 확인할 수 있습니다.</p>
<p>이렇게 공통 컴포넌트와 유틸 함수를 검증해봤습니다.
다음 글에서는 Storybook을 활용한 UI 테스트와 모듈 간의 상호작용을 테스트하는 통합 테스트에 대해 다뤄보겠습니다.
아무쪼록 긴 글 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[리플로우와 리페인트를 줄이는 CSS 전략]]></title>
            <link>https://velog.io/@jang_expedition/%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-CSS-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@jang_expedition/%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-CSS-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Sun, 11 May 2025 05:23:43 GMT</pubDate>
            <description><![CDATA[<p>예전에 스터디에서 어떤 분이 리렌더링을 발생시키는 CSS 속성을 최소화하여 브라우저 렌더링 성능을 향상하는 방법이 있다고 얘기해준 적이 있다.
언제 한 번 공부해서 정리해야지 하고 미루고 있던 숙제를 늦게나마 풀어본다.</p>
<h2 id="리렌더링이란">리렌더링이란?</h2>
<hr>
<p>클라이언트 단에서 성능 최적화에 가장 중요한 건 이미지 최적화와 리렌더링 최소화가 아닐까 싶다.</p>
<p>여기서 말할 리렌더링은 리액트에서 말하는 리렌더링과는 다르다.
리액트의 리렌더링은 컴포넌트 재실행되어 가상 DOM이 갱신되는 과정을 말한다.
이 역시 성능에 큰 영향을 미치고 최소화하는 것이 중요하다.</p>
<p>하지만 이 글에서 다룰 리렌더링은 리플로우(Reflow)와 리페인트(Repaint), 즉 브라우저가 화면을 다시 계산하고 그리는 과정을 포괄하는 용어다.
보통 이 두 단계를 묶어 리렌더링이라고 부르지만, 공식적인 렌더링 엔진 용어는 아니라고 한다.</p>
<p>아무튼 리렌더링을 최소화한다는 건, 리플로우와 리페인트를 최소화한다는 말과 같다.
이 중에서도 리플로우는 레이아웃 자체를 다시 계산하는 가장 무거운 작업이기 때문에 특히 신경을 써야 한다.</p>
<p>리렌더링이 발생하지 않는 CSS 속성으로는 <code>transform</code>, <code>opacity</code> 등이 있다.
이 속성들은 GPU에서 처리하기 때문에 성능 부담을 낮춰준다.</p>
<p>리페인트를 발생시키는 속성으로는 <code>color</code>, <code>background-color</code>, <code>visibility</code>, <code>border-color</code>, <code>outline</code>, <code>box-shadow</code> 등이 있다.</p>
<p>마지막으로 리플로우를 발생시키는 속성으로는 <code>width</code>, <code>height</code>, <code>padding</code>, <code>margin</code>, <code>border-width</code>, <code>position</code>, <code>top</code>, <code>right</code>, <code>bottom</code>, <code>left</code>, <code>display</code>, <code>flex</code>, <code>grid</code>, <code>inline-block</code>, <code>font-size</code>, <code>line-height</code>, <code>overflow</code>, <code>white-space</code> 등이 있다.</p>
<p>속성을 성의없이 나열한 이유는 하나하나 암기하는 것보다 렌더링 이후에 어떤 속성이 변경되느냐에 따라 성능에 미치는 영향이 훨씬 크다고 느꼈기 때문이다.
처음 CSS를 적용할 때 어떤 속성이든 큰 문제는 없지만, 렌더링된 DOM이 동적으로 변화할 때 발생하는 불필요한 리플로우, 리페인트를 줄이는 게 핵심이다.</p>
<p>리렌더링은 결국 렌더링 이후, 화면을 다시 계산하고 그리는 과정이기 때문에 &quot;어떤 CSS를 썼느냐&quot;보다, &quot;언제, 어떻게 바뀌느냐가 중요하다&quot;는 건 어찌 보면 당연한 말이다.</p>
<p>처음에 이 주제에 접근할 땐 &quot;어떤 속성을 써야 하지?&quot;라고 접근했다가, 어떤 CSS 속성이 리렌더링을 유발하는지 아는 것도 중요하지만, 정작 중요한 건 그것들이 &quot;렌더링 후에 동적으로 바뀔 때&quot; 성능에 영향을 준다는 점이었다.</p>
<h2 id="전략">전략</h2>
<hr>
<h3 id="1-상위-요소의-변화는-모든-자식-요소에게-영향을-미친다">1. 상위 요소의 변화는 모든 자식 요소에게 영향을 미친다.</h3>
<p>자식 요소는 <code>position</code>이 <code>fixed</code> 또는 <code>absolute</code>가 아니라면 부모 요소의 크기나 위치가 변경될 때마다 영향을 받는다.
즉, 상위 요소에 스타일을 변경하면 그 하위 전체가 리플로우 대상이 될 수 있다.</p>
<p>따라서 특정 부분만 변화가 필요한 상황이라면, 가능한 그 요소에만 직접 스타일을 주는 것이 좋다.</p>
<p>반면 <code>fixed</code>, <code>absolute</code>는 Normal Flow(문서의 기본 배치 흐름)에 벗어나기 때문에 주변 요소나 부모 레이아웃에 영향을 주지 않는다.
애니메이션이 들어가는 요소라면 <code>position</code>을 <code>fixed</code>나 <code>absolute</code>로 주고, 위치 변경은 <code>top</code>, <code>left</code> 대신, <code>transform</code>으로 처리하는 것이 좋다.</p>
<h3 id="2-레이아웃-계산을-강제하는-코드를-조심하자">2. 레이아웃 계산을 강제하는 코드를 조심하자.</h3>
<p>JavaScript에서 <code>element.style</code>을 이용해 직접 스타일을 변경할 때, 다음과 같이 스타일을 연달아 변경하면</p>
<pre><code class="language-js">el.style.width = &#39;100px&#39;;
el.style.height = &#39;50px&#39;;
el.style.margin = &#39;10px&#39;;</code></pre>
<p>브라우저는 이를 최적화해서 한 번에 처리할 수 있다.</p>
<p>하지만 스타일을 변경하는 중간에 레이아웃 정보를 읽는 작업이 들어가면 이야기가 달라진다.
<code>offsetWidth</code>, <code>offsetHeight</code>, <code>clientWidth</code>, <code>clientHeight</code>, <code>scrollTop</code>, <code>scrollLeft</code>, <code>scrollHeight</code>, <code>getBoundingClientRect()</code>, <code>getComputedStyle(el)</code>과 같은 속성들은 모두 브라우저가 현재 레이아웃을 계산해야만 알 수 있다.</p>
<p>따라서 아래와 같이 스타일을 조작하면서 중간에 레이아웃 정보를 읽는 경우</p>
<pre><code class="language-js">el.style.width = &#39;100px&#39;;
const h = el.offsetHeight;
el.style.margin = &#39;10px&#39;;</code></pre>
<p>브라우저는 <code>offsetHeight</code>를 계산하기 위해 지금까지 적용된 스타일을 반영해 레이아웃을 강제로 리플로우를 발생시킨다.
이런 <code>조작 → 읽기 → 또 조작</code> 흐름이 반복되면 레이아웃 스레싱(layout thrashing)이 발생할 수 있다.</p>
<p>이를 방지하려면 읽기 작업과 쓰기 작업을 분리하거나, 스타일 변경은 모아서 한 번에 처리하는 것이 좋다.
또한 <code>requestAnimationFrame</code>을 활용해 다음 프레임에 반영되도록 조정하는 것도 좋은 방법이다.</p>
<h4 id="requestanimationframe">requestAnimationFrame</h4>
<p><code>requestAnimationFrame</code>은 브라우저가 다음 프레임을 렌더링하기 직전에 콜백 함수를 실행시켜주는 API다.
여기서 말한 프레임은 단순한 DOM 조작을 넘어, 스타일, 애니메이션, 스크롤, 포커스 등 모든 시각적인 요소가 반영되는 단위를 말한다.</p>
<p>처음엔 &quot;프레임을 렌더링한다&quot;는 표현이 꽤 헷갈렸지만, 브라우저가 화면을 실제로 다시 그리는 시점이라고 이해하면 조금 더 감이 온다.</p>
<p>브라우저는 초당 60 프레임을 목표로 렌더링하고, <code>transform</code>과 같은 속성이 바뀌면 브라우저는 반영하기 위해 다음 프레임을 예약한다.
즉, <code>transform</code>은 DOM 자체를 바꾸진 않지만, 시각적인 변화가 생기기 때문에 프레임을 유발한다.</p>
<p><code>requestAnimationFrame</code>은 프레임 타이밍에 맞춰 콜백을 실행하여 부드러운 애니메이션을 제공하고, 읽기와 조작을 분리하여 레이아웃 스레싱을 방지할 수 있다는 장점이 있다.</p>
<p>음... 사실 적으면서도 렌더링과 프레임에 대한 생각이 분리되지 않는다.
뭔가 같이 묶어서 생각하자니 별개같고, 별개로 생각하자니 엮인 부분이 많은 것 같다.
어벤져스와 X맨의 관계랄까.</p>
<p>굳이 나눠서 정의를 한다면, 렌더링은 변화에 대한 계산 과정, 프레임은 그걸 사용자에게 보여주는 타이밍이라고 볼 수 있다.
대충 몽말인지 알지?</p>
<h3 id="3-기타">3. 기타</h3>
<h4 id="3-1-테이블-기반-레이아웃-사용을-자제하자">3-1. 테이블 기반 레이아웃 사용을 자제하자.</h4>
<p><code>&lt;table&gt;</code>은 내부 셀 간 의존 관계가 복잡하여 작은 변화에도 전체 테이블이 리플로우된다.
단순히 레이아웃을 구성하는 목적이라면 <code>flex</code>나 <code>grid</code>를 사용하는 것이 좋다.</p>
<h4 id="3-2-css에서-js-표현식을-자제하자">3-2. CSS에서 JS 표현식을 자제하자.</h4>
<p>이 항목은 CSS-in-JS 또는 런타임 기반의 스타일링 방식에서 해당된다.
<code>${}</code> 같은 표현식은 렌더링 시점마다 다시 계산될 수 있으므로, 불필요한 재계산을 유발하지 않도록 주의해야 한다.</p>
<h4 id="3-3-깊고-복잡한-선택자-사용을-자제하자">3-3. 깊고 복잡한 선택자 사용을 자제하자.</h4>
<p><code>div ul li span {...}</code>처럼 구조가 깊은 선택자를 사용하면, 브라우저는 <code>span</code>을 찾은 뒤, 부모가 <code>li</code>, <code>ul</code>, <code>div</code>인지 역방향으로 계속 탐색한다.
이는 스타일 계산 성능에 영향을 줄 수 있으며, 유지보수 측면에서도 불리하다.
가능하면 선택자의 깊이를 단순하게 유지하고, 필요하다면 클래스를 명확하게 지정하는 것이 좋다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[앱 화면 같은 웹뷰 만들기]]></title>
            <link>https://velog.io/@jang_expedition/%EC%95%B1-%ED%99%94%EB%A9%B4-%EA%B0%99%EC%9D%80-%EC%9B%B9%EB%B7%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@jang_expedition/%EC%95%B1-%ED%99%94%EB%A9%B4-%EA%B0%99%EC%9D%80-%EC%9B%B9%EB%B7%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 10 Apr 2025 05:17:25 GMT</pubDate>
            <description><![CDATA[<h2 id="💰-기-발단">💰 기 (발단)</h2>
<hr>
<p>지금 하고 있는 사이드 프로젝트는 웹뷰 기반으로, 웹과 앱 모두에서 제공되는 서비스를 만들고 있다.
웹뷰는 처음이지만, 앱 개발자 분과 메시지 규격도 맞춰가며 나름 재미있게 협업 중이다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/31972fe4-9bc4-42ec-89e2-bb60c7783f82/image.gif" alt=""></p>
<p>게시글 작성 과정은 <code>제목 -&gt; 이미지 첨부 -&gt; 내용 -&gt; 타입, 카테고리 선택 -&gt; 완료</code> 순서로 구성되어 있다.
아직 개발 중이지만, 전반적인 기능은 원하는 대로 잘 동작하고 큰 문제 없이 구현되고 있었다.
하지만 개발하면서 한 가지 아쉬운 점이 있었는데, 앱처럼 화면이 스무스하지 않는 점이었다.</p>
<h3 id="💵-화면-전환이-끊기는-이유">💵 화면 전환이 끊기는 이유</h3>
<p>앱처럼 화면이 슬라이딩 되면서 넘어가지 않는 이유는 리액트의 기본 라우팅 방식이 페이지 컴포넌트를 언마운트하고 새로 마운트하기 때문이다.</p>
<p>A 페이지에서 B 페이지로 이동한다고 하면 A 페이지를 언마운트 하고 B 페이지를 마운트 하기 때문에 전환 시에 애니메이션을 넣을 수 없다.</p>
<p>그렇다는 말은 이전 페이지를 갖고 있으면 애니메이션 효과를 넣을 수 있다!</p>
<h3 id="💵-stackflow">💵 Stackflow</h3>
<p>이를 해결하기 위해 고민하다가 당근의 Stackflow를 발견했다.
오픈 소스를 파보고 싶었지만, 오픈 소스를 읽는 건 왜이리 힘든지...
일단 구현이 우선이니 깊게 파보는 건 나중에 하고 간단하게 써보고 대략적인 아이디어만 가져오기로 했다.</p>
<p>Stackflow는 말 그대로 스택에 페이지 컴포넌트에 해당하는 Activity를 쌓아서 관리한다.
스택에 A 페이지 위에 B 페이지를 올리기 때문에 A를 유지한 채로 B를 보여줄 수 있기 때문에, 슬라이딩 효과를 넣거나 이전 페이지를 복원할 수도 있다.</p>
<h3 id="💵-stackflow를-사이드-프로젝트에-적용하기-어려운-점">💵 Stackflow를 사이드 프로젝트에 적용하기 어려운 점</h3>
<p>그러면 Stackflow를 쓰면 해결이 될까?
Stackflow는 리액트 라우터를 대체하는 라이브러리라서 기존 라우터와 병행해서 사용하긴 어렵다.
이미 프로젝트에 많은 부분(라우터 가드, 프로바이더 등)에서 리액트 라우터를 사용하고 있고, 개인 프로젝트가 아니기 때문에 독단적으로 바꾸기는 어려웠다.</p>
<h3 id="💵-stackflow-아이디어-가져오기">💵 Stackflow 아이디어 가져오기</h3>
<p>전체 라우터를 바꾸는 게 아니라, 게시글 작성처럼 하나의 프로세스 안에서만 Stackflow 구조를 흉내내면 되지 않을까?
게시글 작성 페이지에서 스택을 관리하고 각 단계를 Activity처럼 사용하면 되지 않을까?</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/3d9a6962-8fb0-4f88-9259-de40ab96e18f/image.png" alt=""></p>
<p>그림으로 표현해보면 위와 같다.
페이지 컴포넌트에서 스택을 관리하고, 스택의 가장 윗 부분의 컴포넌트를 사용자에게 보여준다.
(물론 공식 문서나, 소스를 자세하게 뜯어본 건 아니라서 틀릴 수도 있다. 아님 말구 ㅋ)</p>
<h2 id="💰-승-구현-과정">💰 승 (구현 과정)</h2>
<hr>
<p>어떻게 구현해야 할까?
일단 냅다 훅으로 만들고 보자.</p>
<pre><code class="language-ts">import { useState } from &#39;react&#39;;

type Activity = {
  key: string;
  element: React.ReactNode;
};

export const useStack = () =&gt; {
  const [stack, setStack] = useState&lt;Activity[]&gt;([]);

  const push = (activity: Activity) =&gt; {
    setStack((prev) =&gt; [...prev, activity]);
  };

  const pop = () =&gt; {
    setStack((prev) =&gt; prev.slice(0, -1));
  };

  const init = (activities: Activity[]) =&gt; {
    setStack(activities);
  };

  const clear = () =&gt; {
    setStack([]);
  };

  return { stack, push, pop, clear, init };
};</code></pre>
<h3 id="💵-context-api로-관리하기">💵 Context API로 관리하기</h3>
<p>훅으로 만든 다음 적용하려고 보니, 문제가 될 만한 점이 생각났다.
각 페이지에서 다음 또는 이전을 눌러서 이동할 때 <code>push</code>, <code>pop</code> 메서드를 사용해야 한다.</p>
<p>페이지 컴포넌트와 Activity는 한 뎁스 차이라서 <code>props</code>로 넘겨줘도 괜찮을 것 같지만, 만약 Activity가 추가된다면 매번 <code>props</code>에 <code>push</code>와 <code>pop</code>을 받을 수 있게 처리해야 한다.
<code>props</code> 두 개 쯤은 상관없을 수도 있지만, 스스로 생각해낸게 기특해서 이 악물고 ContextAPI를 사용했다.
ContextAPI를 써도 어차피 페이지에서 매번 호출해야 되긴 하지만 구조가 훨씬 깔끔해진다.</p>
<pre><code class="language-ts">import { createContext, useContext } from &#39;react&#39;;
import { Activity } from &#39;@/types&#39;;

export interface StackStateContextType {
  stack: Activity[];
}

export interface StackActionContextType {
  push: (activity: Activity) =&gt; void;
  pop: () =&gt; void;
  clear: () =&gt; void;
  init: (activities: Activity[]) =&gt; void;
}

export const StackStateContext = createContext&lt;StackStateContextType | undefined&gt;(undefined);
export const StackActionContext = createContext&lt;StackActionContextType | undefined&gt;(undefined);

export const useStackStateContext = () =&gt; {
  const state = useContext(StackStateContext);
  if (state === undefined) {
    throw new Error(&#39;useStackStateContext must be used within an StackStateContextProvider&#39;);
  }
  return state;
};

export const useStackActionContext = () =&gt; {
  const actions = useContext(StackActionContext);
  if (actions === undefined) {
    throw new Error(&#39;useStackActionContext must be used within an StackActionContextProvider&#39;);
  }
  return actions;
};</code></pre>
<pre><code class="language-tsx">import { StackActionContext, StackStateContext } from &#39;@/hooks&#39;;
import { Activity } from &#39;@/types&#39;;
import { useMemo, useState } from &#39;react&#39;;
import { Outlet } from &#39;react-router-dom&#39;;

export const StackContextProvider = () =&gt; {
  const [stack, setStack] = useState&lt;Activity[]&gt;([]);

  const push = (activity: Activity) =&gt; {
    setStack((prev) =&gt; [...prev, activity]);
  };

  const pop = () =&gt; {
    setStack((prev) =&gt; prev.slice(0, -1));
  };

  const init = (activities: Activity[]) =&gt; {
    setStack(activities);
  };

  const clear = () =&gt; {
    setStack([]);
  };

  const stateValue = useMemo(() =&gt; ({ stack }), [stack]);
  const actionValue = useMemo(() =&gt; ({ push, pop, clear, init }), [push, pop, clear, init]);

  return (
    &lt;StackStateContext.Provider value={stateValue}&gt;
      &lt;StackActionContext.Provider value={actionValue}&gt;
        &lt;Outlet /&gt;
      &lt;/StackActionContext.Provider&gt;
    &lt;/StackStateContext.Provider&gt;
  );
};</code></pre>
<p>이제 각 페이지들을 스택에서 관리해보자.</p>
<h3 id="💵-애니메이션-겹쳐보이는-문제">💵 애니메이션 겹쳐보이는 문제</h3>
<p>먼저 스택에 내용이 바뀔 때, 즉 페이지가 전환될 때 애니메이션을 보여줄 수 있는 컴포넌트를 먼저 작성했다.
아마 Stackflow에서 <code>AppScreen</code>이 이 역할을 하는 컴포넌트가 아닐까 싶다.</p>
<pre><code class="language-tsx">import { useStackStateContext } from &#39;@/hooks&#39;;

export const StackRenderer = () =&gt; {
  const { stack } = useStackStateContext();

  return (
    &lt;div className=&quot;w-full min-h-screen relative overflow-hidden&quot;&gt;
      {stack.map((activity, i) =&gt; (
        &lt;div
          key={activity.key}
          className=&quot;absolute top-0 left-0 w-full h-full transition-transform duration-300&quot;
          style={{
            transform: `translateX(${(i - stack.length + 1) * 100}%)`,
            zIndex: i,
          }}
        &gt;
          {activity.element}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  );
};</code></pre>
<p>스택에 여러 페이지가 쌓여 있을 때 어떻게 마지막 페이지를 사용자에게 보여줄 수 있을까?
여기서 핵심은 <code>(i - stack.length + 1) * 100</code> 이 부분이다.
이 수식이 스택의 가장 마지막 요소를 <code>translateX(0%)</code>로 만들어, 화면의 중심에 오도록 한다.</p>
<p>만약 스택에 페이지가 하나만 있다면, 보여줘야 할 페이지 인덱스는 0이고, <code>(0 - 1 + 1) * 100</code>이 되어 <code>0</code>이 된다.
스택에 두 페이지가 있고, 두 번째 페이지를 보여줘야 된다면?
<code>(1 - 2 + 1) * 100</code>으로 <code>translateX(0%)</code>가 된다.</p>
<p>스택의 모든 페이지를 렌더링하기 때문에, 이전 페이지를 갖고 있고, 현재 보여질 페이지를 보여주면서 이전 페이지를 슬라이딩하게 넘길 수 있다.</p>
<p>추가로 &#39;Provider에 한 번에 작성하면 되지 않을까?&#39;라는 고민도 했었는데, Provider는 스택의 상태를 제공해주는 역할이고 Renderer는 애니메이션 효과를 주는 역할로, 서로의 역할이 다르다고 생각해서 분리했다(이렇게 생각한 나 자신 조금 뿌듯쓰).</p>
<pre><code class="language-tsx">const CommunityPostPage = () =&gt; {
  const { push, pop, init } = useStackActionContext();
  const { setFile } = useCommunityFormActionContext();

  const handleAlbumDataFromRN = (event: MessageEvent) =&gt; {
    // RN에서 앨범 데이터를 받아 처리하는 함수
  };

  const handleWebFileSelection = () =&gt; {
    // 웹에서 파일 선택을 처리하는 함수
  };

  const handleImageSelection = () =&gt; {
    // RN과 웹 환경에 따라 적절한 이미지 선택 방식을 처리하는 함수
  };

  useEffect(() =&gt; {
    const getActivity = (key: string): Activity =&gt; {
      switch (key) {
        case &#39;write&#39;:
          return {
            key: &#39;write&#39;,
            element: &lt;Write onNext={() =&gt; push(getActivity(&#39;title&#39;))} /&gt;,
          };
        case &#39;title&#39;:
          return {
            key: &#39;title&#39;,
            element: (
              &lt;Title
                onNext={() =&gt; {
                  handleImageSelection();
                  push(getActivity(&#39;description&#39;));
                }}
                onExit={() =&gt; pop()}
              /&gt;
            ),
          };
        case &#39;description&#39;:
          return {
            key: &#39;description&#39;,
            element: &lt;Description onNext={() =&gt; push(getActivity(&#39;preview&#39;))} onExit={() =&gt; pop()} /&gt;,
          };
        case &#39;preview&#39;:
          return {
            key: &#39;preview&#39;,
            element: &lt;Preview onNext={() =&gt; push(getActivity(&#39;complete&#39;))} onExit={() =&gt; pop()} /&gt;,
          };
        case &#39;complete&#39;:
          return {
            key: &#39;complete&#39;,
            element: &lt;Complete /&gt;,
          };
        default:
          throw new Error(`Unknown activity key: ${key}`);
      }
    };

    init([getActivity(&#39;write&#39;)]);

    const handlePopState = () =&gt; {
      pop();
    };

    window.addEventListener(&#39;popstate&#39;, handlePopState);
    window.addEventListener(&#39;message&#39;, handleAlbumDataFromRN);
    window.history.replaceState({ step: 0 }, &#39;&#39;, window.location.href);

    return () =&gt; {
      window.removeEventListener(&#39;message&#39;, handleAlbumDataFromRN);
      window.removeEventListener(&#39;popstate&#39;, handlePopState);
    };
  }, []);

  return &lt;StackRenderer /&gt;;
};

export default CommunityPostPage;</code></pre>
<p>페이지 컴포넌트가 렌더링 되면 스택에 첫 번째 페이지에 해당하는 컴포넌트를 스택의 초기값으로 넣어준다.
그리고 각 페이지 별로 다음 화면에 넘어갈 때 <code>getActivity(&quot;다음 페이지 key값&quot;)</code>을 호출하여 페이지를 이동한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/741a008e-4a16-465e-bb85-1e6c014cd9fd/image.gif" alt=""></p>
<p>적용하면 위와 같이 페이지 전환 시에 애니메이션이 발생한다.
하지만 문제가 있다.
다음 화면으로 넘어갈 때, 이전 화면은 좌측으로 넘어가는 애니메이션이 발생하지만 그 위에 현재 페이지가 바로 나와 애니메이션이 무색해진다.
쉽게 설명하면, 이전 페이지는 슬라이딩 애니메이션이 들어가지만, 다음 페이지는 애니메이션 없이 떨어진다.</p>
<h3 id="💵-현재-페이지에-애니메이션-넣기">💵 현재 페이지에 애니메이션 넣기</h3>
<p>이 문제를 해결하려면 다음 페이지에 애니메이션 효과를 넣어줘야 한다.
다시 <code>translateX</code>를 결정하는 수식을 보면서 생각해보면, 이전 페이지는 0% -&gt; -100%가 되면서 슬라이딩 효과가 발생한다.
하지만 다음 페이지는 0%로 고정이 되기 때문에 슬라이딩 효과없이 떨어지는 것이다.</p>
<p>그러면 마지막 페이지, 즉 보여지고 싶은 페이지를 100% -&gt; 0%로 되게끔 변경해주면 될까?
그럴경우 <code>push</code>에는 대응할 수 있을 것 같은데, <code>pop</code>에는 대응할 수 없다.</p>
<p>사용자가 뒤로 가기를 눌러서 <code>pop</code>이 실행될 경우, 스택에서는 현재 페이지가 없어진다.
그러면 전 페이지를 보여줘야 하는데, 이게 스택의 마지막이므로 오른쪽에서 등장하게 된다.
뒤로 가기를 눌렀는데, 페이지가 오른쪽에서 등장하면 좀 당황스러울 것 같지 않나?</p>
<p>뭐 머리로만 생각한 거라서 실제 구현해보면 조금 다를 수도 있다.
아무튼 <code>pop</code>이 대응 안 된다고 생각했는데, 그러면 어떻게 할 수 있을까?</p>
<pre><code class="language-ts">export type TransitionState = &#39;current&#39; | &#39;animating&#39;;
export type TransitionDirection = &#39;left&#39; | &#39;right&#39;;

export interface Activity {
  key: string;
  element: React.ReactNode;
  transition: TransitionState;
  direction: TransitionDirection;
}</code></pre>
<p>먼저 Activity가 상태를 갖도록 설정했다.
저렇게 타입을 선언하기까지 많은 우여곡절이 있었지만... 결론만 말하면 저렇다.</p>
<p><code>TransitionState</code>는 사용자에게 보여지는 페이지를 <code>current</code>로, 움직이거나 움직인 페이지를 <code>animating</code>으로 정했다.</p>
<p>음... TMI로 우여곡절을 좀 설명하면, <code>push</code>될 때, <code>pop</code>될 때 상태를 따로 두다가 굳이 분리해야 되나 싶어서 하나로 합쳤다.</p>
<p>하지만 이미 움직인 상태와 움직이고 있는 상태가 모두 <code>animating</code>이라는 상태로 관리되는 게 이상하다고 생각했다.
<code>animating</code>이라고 하면 움직이고 있는 상태라고 생각이 들기 때문이다.
<code>exited</code>라는 상태를 둬서 구분을 해볼까 했지만, 움직인 이후에 상태를 바꿔줘야 하는 로직을 추가해야 하기 때문에 오히려 로직이 복잡해질 것 같아서 제외했다.</p>
<pre><code class="language-tsx">const push = (activity: Omit&lt;Activity, &#39;transition&#39; | &#39;direction&#39;&gt;) =&gt; {
  const newActivity: Activity = {
    ...activity,
    transition: &#39;animating&#39;,
    direction: &#39;right&#39;,
  };

  setStack((prev) =&gt; [...prev, newActivity]);

  requestAnimationFrame(() =&gt; {
    setStack((prev) =&gt;
             prev.map((item, i) =&gt;
                      i === prev.length - 1
                      ? { ...item, transition: &#39;current&#39; }
                      : item.transition === &#39;current&#39;
                      ? { ...item, transition: &#39;animating&#39;, direction: &#39;left&#39; }
                      : item
                     )
            );
  });
};

const pop = () =&gt; {
  setStack((prev) =&gt; {
    const newStack = [...prev];
    const current = newStack[newStack.length - 1];
    const prevPage = newStack[newStack.length - 2];

    if (current) {
      current.transition = &#39;animating&#39;;
      current.direction = &#39;right&#39;;
    }
    if (prevPage) {
      prevPage.transition = &#39;animating&#39;;
      prevPage.direction = &#39;left&#39;;
    }

    return newStack;
  });

  requestAnimationFrame(() =&gt; {
    setStack((prev) =&gt;
             prev.map((item, i) =&gt;
                      i === prev.length - 2 &amp;&amp; item.transition === &#39;animating&#39; ? { ...item, transition: &#39;current&#39; } : item
                     )
            );
  });

  setTimeout(() =&gt; {
    setStack((prev) =&gt; prev.slice(0, -1));
  }, 300);
};

const init = (activities: Omit&lt;Activity, &#39;transition&#39; | &#39;direction&#39;&gt;[]) =&gt; {
  if (activities.length === 0) {
    setStack([]);
    return;
  }

  const newStack = activities.map((activity, i) =&gt; {
    const isLast = i === activities.length - 1;
    return {
      ...activity,
      transition: isLast ? (&#39;current&#39; as TransitionState) : (&#39;animating&#39; as TransitionState),
      direction: isLast ? (&#39;right&#39; as TransitionDirection) : (&#39;left&#39; as TransitionDirection),
    };
  });

  setStack(newStack);
};</code></pre>
<p>Provider 쪽의 코드도 바꿔줬다.
중요하게 볼 점은 <code>push</code>, <code>pop</code>이다.</p>
<p>공통적으로 <code>requestAnimationFrame</code>을 사용한다.
사용하는 이유는</p>
<pre><code class="language-ts">setStack((prev) =&gt; [...prev, newActivity]);

setStack((prev) =&gt;
         prev.map((item, i) =&gt;
                  i === prev.length - 1
                  ? { ...item, transition: &#39;current&#39; }
                  : item.transition === &#39;current&#39;
                  ? { ...item, transition: &#39;animating&#39;, direction: &#39;left&#39; }
                  : item
                 ));</code></pre>
<p>만약 <code>requestAnimationFrame</code> 없이 위와 같이 사용된다면 리액트의 Batching으로 마지막 상태만 화면에 반영된다.
상태가 동적으로 변함에 따라 애니메이션이 구현되어야 하는데, 상태가 바뀌지 않아서 원하는 대로 동작하지 않게 된다.</p>
<p><code>requestAnimationFrame</code>은 브라우저가 다음 프레임을 그리기 직전에 콜백을 실행하기 때문에, 애니메이션 시작 시점을 명확하게 분리할 수 있다.</p>
<p>추가로 <code>pop</code>에서는 나간 페이지에 애니메이션을 보여준 뒤 스택에서 없애기 위해 <code>setTimeout</code>을 활용했다.</p>
<pre><code class="language-tsx">import { useStackStateContext } from &#39;@/hooks&#39;;
import clsx from &#39;clsx&#39;;

export const StackRenderer = () =&gt; {
  const { stack } = useStackStateContext();

  return (
    &lt;div className=&quot;w-full min-h-screen relative overflow-hidden&quot;&gt;
      {stack.map((activity, i) =&gt; {
        const { transition, direction } = activity;

        const translateClass =
          transition === &#39;animating&#39;
            ? direction === &#39;right&#39;
              ? &#39;translate-x-full&#39;
              : &#39;-translate-x-full&#39;
            : &#39;translate-x-0&#39;;

        return (
          &lt;div
            key={activity.key}
            className={clsx(
              &#39;absolute top-0 left-0 w-full h-full&#39;,
              &#39;transition-transform duration-300 ease-in-out&#39;,
              translateClass,
              transition === &#39;animating&#39; ? &#39;pointer-events-none&#39; : &#39;pointer-events-auto&#39;,
              `z-[${i}]`
            )}
          &gt;
            {activity.element}
          &lt;/div&gt;
        );
      })}
    &lt;/div&gt;
  );
};</code></pre>
<p>Renderer에서 현재 상태에 따른 <code>translateX</code> 값을 동적으로 넣어 애니메이션 효과가 보이도록 했다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/497d0dd1-3a2b-4337-81b6-8cedaaea802c/image.gif" alt=""></p>
<p>적용 결과를 보면 원하는 대로 앱처럼 넘어가는 웹 화면이 구현됐다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/17e6272d-9489-47fb-8086-1d38a5b03668/image.png" alt=""></p>
<h2 id="💰-전결-마무리-ㅋ">💰 전결 (마무리 ㅋ)</h2>
<hr>
<p>추가적으로 해결해야 하는 문제들이 있다.
새로고침 시에 현재 페이지를 유지하게 한다거나(로컬 스토리지에 저장하면 될듯?!), 뒤로 가기에 반응 한다거나(push, pop 시에 popstate를 넣어주면 될듯?!) 등.</p>
<p>그리고 현재는 <code>getAcitivity</code> 함수 내부에 스택에 보여질 페이지들이 명시적으로 적혀있지만, 훅으로 빼서 사용하는 쪽에서 추가하도록 개선할 수도 있을 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/75905685-d4ed-41b4-9fe3-30994a6ac940/image.png" alt=""></p>
<p>이 내용들을 <code>전</code>에 담고 <code>결</code>로 회고를 쓰려고 했지만, 길어지니 힘도 빠지고 주제에 벗어나는 것 같아서 그건 그냥 내가 따로 해보기로 했다.</p>
<p>아무튼 기존 라이브러리에서 아이디어를 얻어서 생각나는 대로 구현해보니, 구현 과정에서 생각과 다른 점들도 많고, 미처 고려하지 못한 상황들도 나왔지만 구현하는 과정이 재밌었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TCP/IP]]></title>
            <link>https://velog.io/@jang_expedition/TCPIP</link>
            <guid>https://velog.io/@jang_expedition/TCPIP</guid>
            <pubDate>Thu, 03 Apr 2025 15:28:30 GMT</pubDate>
            <description><![CDATA[<h2 id="💰-인터넷">💰 인터넷</h2>
<hr>
<blockquote>
<p>다양한 네트워크 종류와 다양한 컴퓨터가 연결되어 있는 전 세계적인 네트워크</p>
</blockquote>
<p>인터넷을 통해 데이터를 디지털 신호로 바꾸어 전달하고, 받은 디지털 신호를 다시 데이터로 바꿔가면서 네트워크 통신이 이뤄진다.</p>
<p>이들이 통신하기 위해서는 TCP/IP(Transmisstion Control Protocol / Internet Protocol)  프로토콜을 이용한다.</p>
<p>인터넷에 연결된 네트워크 종류도 다양하고 사용하는 프로토콜 또한 다양하다.
네트워크는 어떤 프로토콜을 사용하냐에 따라 데이터를 처리하는 효율이 달라지기 때문에 사용 환경에 맞는 다양한 프로토콜이 생겨났다.</p>
<h2 id="💰-tcpip">💰 TCP/IP</h2>
<hr>
<blockquote>
<p>인터넷에서 컴퓨터들이 서로 정보를 주고 받는데 쓰이는 프로토콜의 집합</p>
</blockquote>
<p>이러한 문제점을 해결하기 위해 공통적으로 쓸 수 있는 프로토콜이 필요하게 되었는데, 이것이 TCP/IP다.
TCP/IP가 마련됨으로써 네트워크에 연결된 컴퓨터 기종에 상관없이 정보 교환이 가능하게 됐다.</p>
<p>TCP/IP를 지원하는 네트워크에 TCP/IP를 지원하는 소프트웨어가 있는 컴퓨터가 연결됐다면 아무 문제없이 서로 다른 기종이라도 통신이 가능하다.</p>
<p>TCP/IP 프로토콜은 OSI 참조 모델 이전부터 이미 개발됐었다.
따라서 TCP/IP 프로토콜의 계층 구조는 OSI 참조 모델과 일치하지 않는다.
TCP/IP는 5개의 계층 구조로 되어 있고 현재 가장 널리 사용되고 있는 통신 프로토콜이다.</p>
<p>가장 널리 퍼지고 사용되는 프로토콜 중 하나인데도 불구하고 표준을 따르지 않는 이유는 발생 과정에 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/71563ecc-fb86-48d6-9a61-0bcf2b62bd2c/image.png" alt=""></p>
<p>초기 네트워크는 다르게 만들어진 컴퓨터, 다른 회사 제품들 그리고 각 컴퓨터의 다른 운영체제 등 복잡한 환경에서 사용되어져야 했고, 각 컴퓨터의 모델마다 통신 방식이 만들어져야 하는 번거로운 상황이었다.</p>
<p>이런 상황에서 국제적인 표준안의 필요성을 느끼고 생각하게 됐지만, OSI 참조 모델은 전 세계적인 표준 기구에서 추진하는 것이었으므로 진척이 더뎠다.</p>
<p>표준안의 확정을 오래 기다릴 수 없는 상황에서 미국 정부가 긴박한 필요성에 의해 만들어낸 것이 바로 TCP/IP다.</p>
<p>그 후 TCP/IP는 계속 발전하여 퍼져나가 가장 널리 쓰이고, 그만큼 다양한 서비스를 갖고 있는 프로토콜이다.</p>
<h3 id="💵-계층-구조">💵 계층 구조</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/19a541f7-447f-4853-950b-d345fc2bd103/image.png" alt=""></p>
<h4 id="application-layer">Application Layer</h4>
<p>특정 서비스를 제공하기 위해 애플리케이션 끼리 정보를 주고 받을 수 있다.
브라우저와 웹 서버가 HTTP 요청, 응답을 통해 통신하는 걸 예로 들 수 있다.
FTP, HTTP, SSH, Talnet, DNS, SMTP와 같은 프로토콜이 사용된다.</p>
<ul>
<li>FTP: 장치와 장치 간의 파일을 전송하는 데 사용되는 표준 통신 프로토콜</li>
<li>SSH: 보안되지 않은 네트워크에서 네트워크 서비스를 안전하게 운영하기 위한 암호화 네트워크 프로토콜</li>
<li>HTTP: World Wide Web을 위한 데이터 통신의 기초이자 웹 사이트를 이용하는 데 쓰는 프로토콜</li>
<li>SMTP: 전자 메일 전송을 위한 인터넷 표준 통신 프로토콜</li>
<li>DNS: 도메인 이름과 IP 주소를 매핑해주는 서버, 예를 들어 <code>www.naver.com</code>에 DNS 쿼리가 오면 <code>[Root DNS] -&gt; [.com DNS] -&gt; [.naver DNS] -&gt; [.www DNS]</code> 과정을 거쳐 완벽한 주소를 찾아 IP 주소를 매핑한다.</li>
</ul>
<h4 id="transport-layer">Transport Layer</h4>
<p>송신자와 수신자를 연결하는 통신 서비스를 제공한다.
네트워크 통신을 하는 애플리케이션은 포트 번호를 사용하게 된다.
포트 번호를 사용해서 애플리케이션을 찾아 주는 역할을 한다.
TCP, UDP, RTP, RTCP와 같은 프로토콜이 사용된다.</p>
<ul>
<li>TCP: 패킷 사이의 순서를 보장하고 연결지향 프로토콜을 사용해서 연결하여 신뢰성을 구축해서 수신 여부를 확인하며 가상회선 패킷 교환 방식을 사용한다.</li>
<li>UDP: 순서를 보장하지 않고 수신 여부를 확인하지 않으며 단순히 데이터만 주는 데이터그램 패킷 교환 방식을 사용한다.</li>
</ul>
<h4 id="internet-layer">Internet Layer</h4>
<p>수신 측까지 데이터를 전달하기 위해 사용된다.
송신측, 수신측 모두 IP 주소를 갖고 있다.
IP 주소를 바탕으로 올바른 목적지로 찾아갈 수 있도록 해준다.
상대방이 제대로 받았는지에 대해 보장하지 않는 비연결 지향형적인 특징을 갖고 있다.
IP, ARP, ICMP, RARP, OSPF와 같은 프로토콜이 사용된다.</p>
<h4 id="network-access-layer">Network Access Layer</h4>
<p>네트워크에 직접 연결된 기기 간 전송을 할 수 있도록 한다.
물리적 주소인 MAC 주소를 사용한다.
Ethernet, PPP, Token Ring과 같은 프로토콜이 사용된다.</p>
<h2 id="💰-tcpip-흐름">💰 TCP/IP 흐름</h2>
<blockquote>
<p><a href="http://www.google.com">www.google.com</a> 을 웹 브라우저에 입력하면 무슨 일이 일어날까?</p>
</blockquote>
<p>웹브라우저에 <a href="http://www.google.com">www.google.com</a> 을 입력한다는 것은 구글 웹서버의 80포트로 HTTP Request 메세지를 보내는 것이다.</p>
<p>해당 요청을 인터넷을 통해 구글 서버로 전달하기 위해 패킷을 만들어야 한다.
패킷에는 각 계층에 필요한 정보들이 담겨야 한다.</p>
<p>각 계층 별로 HTTP, TCP, IP, Ethernet 프로토콜을 사용한다고 가정해보자.
패킷의 Application Layer에는 HTTP Request가 들어간다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/4c1a9d55-dcb2-485a-8d0f-04e23700757d/image.png" alt=""></p>
<p>TCP 패킷의 헤더에서 중요하게 볼 곳은 Source Port(시작 포트 번호)와 Destination Port(목적지 포트 번호)다.
시작 포트 번호는 내 컴퓨터에서 만든 소켓의 포트 번호라서 당연히 내 컴퓨터는 알고 있다.
그리고 목적지 포트 번호 또한 80으로 알고 있다.
80은 웹 서버의 웰 노운 포트 번호다.</p>
<h3 id="💵--dns를-통한-ip-주소-받아오기">💵  DNS를 통한 IP 주소 받아오기</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/c7767206-996c-42d9-88fc-4da1eda51f21/image.png" alt=""></p>
<p>IP 헤더에서 중요한 정보는 Source IP Address(시작 IP 주소)와 Destination IP Address(목적지 IP 주소)다.
나의 시작 IP 주소는 알고 있지만, 목적지 IP 주소는 모르고 <a href="http://www.google.com">www.google.com</a> 이라는 도메인 정보만 알고 있다.
그렇지만 DNS 프로토콜을 통해서 도메인 정보로 IP 주소를 알아 낼 수 있다.</p>
<p>브라우저는 OS에게 도메인에 대한 IP 주소를 알고 싶다고 요청하면 OS에서 DNS 서버로 요청을 보내게 된다.
그럼 OS가 DNS 서버를 어떻게 알고 있을까?</p>
<p>DNS 서버 주소는 이미 컴퓨터에 등록이 되어 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/4413df3b-3a66-4bd7-bbca-230bf450c134/image.png" alt=""></p>
<p>DNS 또한 HTTP와 같은 애플리케이션 계층 프로토콜이고 53번 포트를 사용한다.
DNS도 HttpRequest와 비슷하게 도메인이 담긴 쿼리를 도메인 서버로 보낸다.
그러면 도메인 서버가 IP 주소를 응답해준다.</p>
<p>DNS는 Transport Layer에서 UDP라는 프로토콜을 사용한다.
UDP는 TCP와 다르게 헤더에 포트 번호말고 다른 게 없다.
UDP가 비연결 지향형 프로토콜이기 때문이다.</p>
<p>이제 DNS를 통해 성공적으로 도메인 이름에 대한 IP 주소를 받아왔다.</p>
<h3 id="💵--arp를-통해-ip-주소로-mac-주소-받아오기">💵  ARP를 통해 IP 주소로 MAC 주소 받아오기</h3>
<p>마지막으로 Ethernet 프로토콜에 대한 헤더를 만들어야 하는데, MAC 주소를 알지 못한다.</p>
<p>IP 주소를 알아보기 위해서는 구글 서버에 대한 정보가 필요했다.
Ethernet 프레임에는 출발지 MAC 주소와 목적지 MAC 주소가 들어가야 한다.
그러면 목적지 MAC 주소에 구글 웹서버의 MAC 주소를 넣어야 할까?</p>
<p>아니다.
MAC 주소는 구글의 MAC 주소 대신 물리적으로 연결된 우리집 공유기의 MAC 주소가 필요하다.
MAC 주소는 최종 목적지가 아닌 첫 번째로 만날 장치의 MAC 주소가 필요하다.</p>
<p>이 공유기를 통해 다른 네트워크와 연결이 가능하니 게이트웨이라 부르기도 한다.
우리는 이미 게이트웨이에 대한 정보를 알고 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/c4e4c536-7c31-4a79-a126-79ab76ca733d/image.png" alt=""></p>
<p>Netstat 명령어(<code>netstat -rn</code>)를 통해 확인할 수 있다.</p>
<p>그럼 어떻게 IP 주소로 MAC 주소를 알 수 있을까?</p>
<p>이때 사용하는 것이 ARP 프로토콜이다.
ARP 프로토콜은 IP 주소를 MAC 주소로 바꿔주는 주소 해석 프로토콜이다.</p>
<h3 id="💵--3-way-handshaking">💵  3 Way Handshaking</h3>
<p>이제 MAC 주소까지 알아냈으니 패킷이 네트워크 세계로 나갈 준비가 됐다.
하지만 요청을 보내기 전에 한 가지 더 봐야할 게 있다.</p>
<p>바로 TCP가 연결지향형 프로토콜이라는 점이다.
그래서 TCP 프로토콜은 데이터를 전송하기 전에 송신측과 수신측이 서로 연결되는 작업이 필요하다.
이러한 작업을 3 Way Handshaking이라고 부른다.</p>
<blockquote>
<ul>
<li>SYN: SYNchronization의 약자, 연결 요청 플래그.</li>
<li>ACK: ACKnowledgement의 약자, 응답 플래그.</li>
<li>ISN: Initial Sequence Numbers의 약자, 초기 네트워크 연결을 할 때 할당된 32비트 고유 시퀀스 번호.</li>
</ul>
</blockquote>
<p>3 Way Handshaking을 수행하기 위해서는 TCP 헤더에 표시한 플레그들이 사용된다.
이러한 플래그들을 컨트롤 비트라고 부른다.</p>
<ol>
<li>클라이언트는 클라이언트의 ISN을 담아 서버에게 SYN 패킷을 보낸다.</li>
<li>서버는 SYN 요청을 받고, 클라이언트에게 요청을 수락한다는 ACK과 서버의 ISN을 보내며 승인 번호로 클라이언트의 ISN + 1을 보낸다.</li>
<li>클라이언트는 서버의 ISN + 1 한 값인 승인 번호를 담아 서버에게 다시 ACK을 보낸다.</li>
<li>이제부터 연결이 이뤄지고 데이터가 오간다.</li>
</ol>
<p>이제 연결이 성립되었으니, 데이터가 보내질 차례다.</p>
<h3 id="💵--nat-network-address-translation">💵  NAT (Network Address Translation)</h3>
<p>한 가지 더 부가적인 설명을 하면 내가 사용하는 컴퓨터는 Private IP를 사용하고 있다.
Private IP는 외부의 네트워크 환경에서 IP 주소를 찾지 못한다.
그래서 공유기를 통해 나갈 때 Public IP 주소를 변환하여 나가는 작업이 필요하다.
이러한 작업을 NAT(Network Address Translation)이라고 한다.</p>
<h3 id="💵--라우팅">💵  라우팅</h3>
<p>공유기를 거치고 구글 서버에 도착하기 위해 여러 라우터를 거쳐야 한다.
라우터는 네트워크와 네트워크를 연결해주는 역할을 한다.
라우터가 목적지 경로를 찾아 나가는 과정을 라우팅이라 한다.</p>
<h3 id="💵--데이터-도착-후-과정">💵  데이터 도착 후 과정</h3>
<blockquote>
<p>브로드 캐스팅(broadcasting): 네트워크에 있는 모든 장치에게 메세지를 전송하는 행위.</p>
</blockquote>
<p>라우팅을 거쳐 구글 서버가 연결된 라우터까지 데이터가 도착을 하면 패킷의 IP 헤더에 기록된 구글 서버 IP 주소를 통해 MAC 주소를 얻어와야 한다.
이때 이전에 설명했던 ARP 프로토콜을 사용한다.
이때 ARP는 라우터가 연결된 네트워크에 브로드캐스팅된다.
목적지 구글 서버가 자신의 IP로 온 ARP 요청을 받고 MAC 주소를 응답해준다.
이제 목적지 구글 서버의 MAC 주소를 알았으니, 데이터가 물리적으로 전달될 수 있다.</p>
<p>ARP로 IP주소를 통해 MAC 주소를 얻고 드디어 목적지 구글 서버에 데이터가 도착했다.
Internet IP의 IP 주소와 Network Address Layer의 MAC 주소를 사용해서 올바른 목적지에 도착했으니 Transport Layer의 목적지 포트 번호에는 80번이 적혀있다.
이걸 보고 80번 포트를 사용하고 있는 애플리케이션에게 데이터를 전달해줘야 하는 걸 알 수 있다.</p>
<p>Application Layer 까지 오면 웹 서버가 사용될 HTTP Request 데이터를 얻을 수 있게 된다.
이제 서버에서 정상적으로 HTTP Request를 받고 응답을 돌려보낸다.
<code>&quot;/&quot;</code>에 매핑된 GET 요청을 처리해서 적절한 HTML을 응답해준다.</p>
<p>실제 크롬 개발자 도구를 통해 확인해보면 HTML을 받은 걸 확인할 수 있다.</p>
<h3 id="💵--4-way-handshaking">💵  4 Way Handshaking</h3>
<blockquote>
<p>FIN: FINish의 약자, 연결 종료 요청 플래그.</p>
</blockquote>
<p>HTTP의 요청과 응답 과정이 끝나면 연결을 종료해야 한다.
여기서도 TCP 컨트롤 비트가 사용된다.
이 단계에서는 ACK, FIN 플래그가 사용된다.</p>
<ol>
<li>클라이언트가 서버로 연결을 종료하겠다는 FIN 플래그를 전송한다.</li>
<li>서버는 클라이언트에게 ACK 메세지를 보내고, 자신의 통신이 끝날 때까지 기다린다.</li>
<li>서버가 통신이 끝나면 클라이언트로 FIN을 보낸다.</li>
<li>클라이언트는 확인했다는 의미로 서버에게 ACK을 보내면 연결이 종료된다.</li>
</ol>
<p>총 4단계에 거쳐 진행되고 이걸 4 Way Handshaking 이라고 부른다.</p>
<h3 id="💵-time_wait">💵 TIME_WAIT</h3>
<p>그런데 서버가 FIN을 보내기 전에 보냈던 데이터가 늦게 도착할 경우, 문제가 발생할 수 있다.
서버로 부터 FIN을 수신했다고 클라이언트가 바로 연결된 소켓을 닫아버리면 FIN을 보내기 전에 보낸 패킷은 영영 클라이언트가 받을 수 없게 된다.</p>
<p>그래서 클라이언트는 서버로 부터 FIN 요청을 받더라도 일정시간 동안 소켓을 닫지 않고 혹시나 아직 도착하지 않은 잉여 패킷을 기다린다.
이렇게 4 Way Handshaking 과정이 완료되어도 소켓을 닫지 않고 잉여 패킷을 기다리는 상태를 TIME_WAIT이라고 한다.</p>
<h2 id="💰-신뢰할-수-있는-tcp">💰 신뢰할 수 있는 TCP</h2>
<hr>
<p>TCP는 연결 지향의 신뢰성 있는 프로토콜이다.</p>
<p>요즘 우리는 엄청 큰 데이터를 주고 받기 때문에 한 번에 다 보내긴 어렵다.
그래서 데이터를 잘게 쪼개서 많은 패킷으로 나눠서 보내게 된다.</p>
<p>복잡한 인터넷 환경에서 과연 패킷들이 유실 되지 않고 순서대로 도착할 수 있을까?</p>
<p>이걸 가능하게 해주는 게 프로토콜인 TCP다.
TCP는 흐름 제어, 오류 제어, 혼잡 제어를 통해 신뢰성을 보장한다.</p>
<h3 id="💵-흐름-제어">💵 흐름 제어</h3>
<p>데이터를 너무 빨리 보내면, 받는 쪽에서 다 처리하지 못할 수도 있다.
TCP는 송신자와 수신자의 속도를 맞추기 위해 흐름 제어를 사용한다.</p>
<p>수신 TCP 프로세스가 송신 TCP 프로세스에게 흐름 제어 메커니즘을 요구한다.
연결이 설정되면, 각 방향으로의 전송을 위한 윈도우 크기가 결정된다.
여기서, 윈도우 크기란 확인을 받기 전까지 송신측이 전송할 수 있는 바이트의 수를 나타낸다.
송신측에 돌아온 확인에는 새로운 윈도우 크기가 포함되어 있으며, 그것은 다음 확인 전까지 전송될 수 있는 바이트의 수를 나타낸다.</p>
<p>쉽게 얘기하면, 윈도우 크기라는 걸 이용해 한 번에 보낼 수 있는 데이터의 양을 정한다.
그리고 수신자는 &quot;이만큼만 보내줘&quot;라고 알려주고, 송신자는 그 범위 안에서만 데이터를 보낸다.
이렇게 하면, 수신자가 감당할 수 있을 만큼만 데이터를 보내게 된다.</p>
<h3 id="💵-오류-제어">💵 오류 제어</h3>
<p>인터넷에서는 데이터가 사라지거나, 중복되거나, 순서가 뒤바뀌거나, 깨질 수도 있다.
TCP는 이런 문제를 해결하기 위해 다음과 같은 방법을 사용한다.</p>
<ul>
<li>각 데이터 조각(세그먼트)마다 순서 번호를 붙여서, 수신자가 순서를 확인할 수 있게 한다.</li>
<li>잘못된 데이터는 재전송 타이머를 사용해 송신자가 세그먼트를 보내면서 타이머를 시작시키고, 해당 세그먼트에 대한 확인이 오기 전에 끝나면 송신자는 세그먼트를 재전송한다.</li>
<li>각 송신 세그먼트에 체크섬 값을 계산하여 포함시켜, 잘못된 세그먼트에 대해 확인을 보내지 않는다.</li>
</ul>
<p>수신자가 제대로 받지 못하면, 송신자는 다시 보내게 된다.
이렇게 해서 데이터가 빠짐없이, 정확하게 도착하도록 보장한다.</p>
<h3 id="💵-혼잡-제어">💵 혼잡 제어</h3>
<p>혼잡(congestion)은 인터넷의 전부 또는 일부에 과부하가 걸리고, 요구된 트래픽 양만큼 충분한 통신 자원이 없을 때 발생된다.</p>
<p>TCP는 이런 상황을 막기 위해 인터넷에 너무 많은 데이터를 한 번에 보내지 않도록 조절하고, 혼잡이 생겼을 때는 보내는 양을 줄여서 상황을 개선한다.</p>
<p>즉, 인터넷 상태에 따라 데이터를 얼마나 빨리, 얼마나 많이 보낼지 조절한다.</p>
<h2 id="💰-참조">💰 참조</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=BEK354TRgZ8">https://www.youtube.com/watch?v=BEK354TRgZ8</a></li>
<li><a href="https://product.kyobobook.co.kr/detail/S000001834833?utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=googleSearch&amp;gt_network=g&amp;gt_keyword=&amp;gt_target_id=aud-901091942354:dsa-435935280379&amp;gt_campaign_id=9979905549&amp;gt_adgroup_id=132556570510&amp;gad_source=1">https://product.kyobobook.co.kr/detail/S000001834833?utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=googleSearch&amp;gt_network=g&amp;gt_keyword=&amp;gt_target_id=aud-901091942354:dsa-435935280379&amp;gt_campaign_id=9979905549&amp;gt_adgroup_id=132556570510&amp;gad_source=1</a></li>
<li>집에 있는 컴퓨터 통신 책</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 스트리밍, 에러 핸들링, 서버 액션]]></title>
            <link>https://velog.io/@jang_expedition/Next.js-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EC%97%90%EB%9F%AC-%ED%95%B8%EB%93%A4%EB%A7%81-%EC%84%9C%EB%B2%84-%EC%95%A1%EC%85%98</link>
            <guid>https://velog.io/@jang_expedition/Next.js-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EC%97%90%EB%9F%AC-%ED%95%B8%EB%93%A4%EB%A7%81-%EC%84%9C%EB%B2%84-%EC%95%A1%EC%85%98</guid>
            <pubDate>Tue, 01 Apr 2025 05:01:43 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard">https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard</a></p>
<p>인프런의 한 입 크기로 잘라먹는 Next.js 강의를 듣고 정리한 내용입니다.</p>
<hr>
<h2 id="💰-스트리밍과-에러-핸들링">💰 스트리밍과 에러 핸들링</h2>
<hr>
<h3 id="💵-스트리밍">💵 스트리밍</h3>
<p>스트리밍은 서버에서 클라이언트로 데이터를 넘겨줄 때 데이터의 용량이 너무 크거나 서버 측에서 데이터를 준비하는 시간이 오래 걸려서 빠른 전송이 어려울 때, 데이터를 여러 조각으로 잘라 하나씩 전송하는 기술이다.</p>
<p>스트리밍을 이용하면 클라이언트 입장에서 모든 데이터가 불러와지지 않은 상황에서도 조금씩 받은 데이터에 접근할 수 있기 때문에 사용자에게 긴 로딩없는 좋은 경험을 제공할 수 있다.</p>
<p>Next는 스트리밍 기술을 동영상이 아닌 일반적인 웹 서비스에서도 누릴 수 있게 HTML을 스트리밍할 수 있는 기능을 자체적으로 제공한다.</p>
<p>Next의 스트리밍 기술은 동적 페이지에 자주 활용된다.
동적 페이지는 빌드 타임에 생성되지 않기 때문에 풀 라우트 캐시에 저장되지 않는다.
동적 페이지는 브라우저로부터 접속 요청이 있을 때마다 페이지의 모든 컴포넌트들을 실행해서 페이지를 새롭게 렌더링해줘야 하기 때문에 특정 컴포넌트 내부의 데이터 패칭이 오래 걸릴 경우 전체 페이지의 응답이 느려져 사용자 경험을 해치게 된다.</p>
<p>스트리밍을 사용하게 되면 접속 요청이 들어왔을 때 빠르게 렌더링할 수 있는 컴포넌트들을 응답하여 보여주고 느리게 렌더링되는 컴포넌트는 로딩바와 같은 대체 UI를 보여주다가 서버측에서 데이터 패칭이 완료되어 렌더링이 완료되면 후속으로 보내줌으로써 사용자 경험을 향상 시킨다.</p>
<h4 id="페이지-컴포넌트-스트리밍-적용">페이지 컴포넌트 스트리밍 적용</h4>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a837325c-ea32-4313-a009-c3a9c6fa7e9e/image.png" alt=""></p>
<p>페이지 컴포넌트에 스트리밍을 적용하기 위해 페이지 컴포넌트와 동일한 위치에 <code>loading.tsx</code> 파일을 생성하여 대체 UI를 작성하면 자동 적용된다.</p>
<p>페이지 컴포넌트에 스트리밍 사용 시에 주의 사항으로는</p>
<ol>
<li><code>loading.tsx</code> 파일은 <code>layout.tsx</code> 파일처럼 해당하는 경로 아래에 있는 모든 페이지 컴포넌트들에 적용된다.</li>
<li><code>loading.tsx</code> 파일이 스트리밍 하도록 설정하는 페이지 컴포넌트는 모든 페이지 컴포넌트가 아닌 <code>async</code>가 붙은 비동기로 동작하는 페이지 컴포넌트에만 적용된다.</li>
<li><code>loading.tsx</code> 파일은 무조건 페이지 컴포넌트에만 적용할 수 있다. 따라서 레이아웃이나 페이지 컴포넌트 내부의 일반적인 컴포넌트들은 적용되지 않는다.</li>
<li><code>loading.tsx</code> 파일로 설정된 스트리밍은 브라우저에서 쿼리 스트링이 변경될 때에는 트리거되지 않는다.</li>
</ol>
<h4 id="컴포넌트-스트리밍-적용">컴포넌트 스트리밍 적용</h4>
<p>리액트의 <code>Suspense</code>를 이용하면 페이지 단위가 아닌 컴포넌트 단위로 스트리밍을 적용할 수 있다.</p>
<pre><code class="language-tsx">export default function Page({
  searchParams,
}: {
  searchParams: {
    q?: string,
  },
}) {
  return (
    &lt;Suspense
      key={searchParams.q || &quot;&quot;}
      fallback={&lt;BookListSkeleton count={3} /&gt;}
    &gt;
      &lt;SearchResult q={searchParams.q || &quot;&quot;} /&gt;
    &lt;/Suspense&gt;
  );
}</code></pre>
<p>비동기 작업을 하고 있는 컴포넌트를 <code>Suspense</code> 태그로 감싸주고 대체 UI는 <code>fallback</code>이라는 <code>props</code>로 넘겨주면 스트리밍이 적용된다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/8a6d4a3a-b149-4443-be3c-82282d7955d4/image.png" alt=""></p>
<p><code>fallback</code>에 주로 스켈레톤 UI를 넘겨준다.
스켈레톤 UI란 페이지의 컴포넌트가 로딩되는 동안 실제 렌더링될 컴포넌트의 뼈대를 보여주는 UI로 사용자에게 어떤 컨텐츠가 나타날지 예상할 수 있도록 하여 사용자 경험을 향상 시킬 수 있다.</p>
<p>하지만 <code>fallback</code>만으로는 쿼리 스트링이 변경됐을 경우에 대체 UI가 표시되지 않는 문제가 있다.
<code>loading.tsx</code>는 이를 해결할 수 있는 방법이 없었지만, <code>Suspense</code>는 쿼리스트링을 <code>key</code>라는 <code>props</code>에 전달하면 값이 변경될 때마다 다시 로딩 상태로 돌아가게 할 수 있다.</p>
<pre><code class="language-tsx">export default async function Home() {
  return (
    &lt;div className={style.container}&gt;
      &lt;section&gt;
        &lt;h3&gt;지금 추천하는 도서&lt;/h3&gt;
        &lt;Suspense fallback={&lt;BookListSkeleton count={3} /&gt;}&gt;
          &lt;RecoBooks /&gt;
        &lt;/Suspense&gt;
      &lt;/section&gt;
      &lt;section&gt;
        &lt;h3&gt;등록된 모든 도서&lt;/h3&gt;
        &lt;Suspense fallback={&lt;BookListSkeleton count={10} /&gt;}&gt;
          &lt;AllBooks /&gt;
        &lt;/Suspense&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p><code>Suspense</code>는 하나의 페이지 내에서 여러 비동기 컴포넌트를 동시에 스트리밍할 때 적용할 수 있다.
<code>RecoBooks</code>와 <code>AllBooks</code> 컴포넌트가 불러와지는 시간이 달라도 완료되는 순서대로 렌더링 시킬 수 있다.
이러한 장점 덕분에 <code>loading.tsx</code>를 활용하기 보다 <code>Suspense</code>를 사용하는 방식이 선호된다.</p>
<h2 id="💰-에러-핸들링">💰 에러 핸들링</h2>
<hr>
<pre><code class="language-tsx">try {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/random`,
    { next: { revalidate: 3 } }
  );
  if (!response.ok) {
    return &lt;div&gt;오류가 발생했습니다 ...&lt;/div&gt;;
  }
  const recoBooks: BookData[] = await response.json();

  return (
    &lt;div&gt;
      {recoBooks.map((book) =&gt; (
        &lt;BookItem key={book.id} {...book} /&gt;
      ))}
    &lt;/div&gt;
  );
} catch (err) {
  cosnole.error(err);
  return &lt;div&gt;오류가 발생했습니다...&lt;/div&gt;;
}</code></pre>
<p>기존에 에러를 처리하기 위해서는 <code>try-catch</code> 블록을 통해 에러를 처리해야 했다.
하지만 이런 방식은 데이터 패칭같이 오류가 발생할 수 있는 모든 코드 블럭마다 <code>try-catch</code>문을 작성해야 하는 문제가 있다.
추가로 예상하지 못한 부분에서 에러가 발생할 수 있기 때문에 신경 써야 할 점도 많아진다.</p>
<p>Next는 특정 경로에서 발생하는 모든 오류를 한 번에 처리할 수 있는 편리한 에러 핸들링 기능을 제공한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/9b684565-4c8d-4553-a4ec-89fb16ba6714/image.png" alt=""></p>
<p>페이지 컴포넌트와 같은 위치에 <code>error.tsx</code> 파일을 생성해준다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

export default function Error() {
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p><code>error.tsx</code>의 최상단에 <code>&quot;use client&quot;</code> 지시자를 추가해줌으로써 클라이언트 컴포넌트로 설정해준다.
클라이언트 컴포넌트로 설정하는 이유는 클라이언트 측과 서버측에서 발생하는 모든 오류를 대응하기 위해서다.</p>
<p><code>layout.tsx</code>나 <code>loading.tsx</code>처럼 해당하는 경로 아래에 페이지에서 오류가 발생하면 <code>error.tsx</code> 컴포넌트가 페이지 컴포넌트 대신에 나타난다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

import { useEffect } from &quot;react&quot;;

export default function Error({ error, reset }: { error: Error }) {
  useEffect(() =&gt; {
    console.error(error.message);
  }, [error]);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>현재 발생하는 에러의 원인이나 에러 메세지를 출력하고 싶은 경우 <code>Error</code> 컴포넌트에 <code>props</code>로 전달되는 <code>error</code>를 이용하면 된다.
Next는 <code>error</code>라는 이름으로 현재 발생한 오류의 정보를 보내준다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

import { useEffect } from &quot;react&quot;;

export default function Error({
  error,
  reset,
}: {
  error: Error,
  reset: () =&gt; void,
}) {
  useEffect(() =&gt; {
    console.error(error.message);
  }, [error]);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
      &lt;button
        onClick={() =&gt; {
          reset();
        }}
      &gt;
        다시 시도
      &lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p><code>Error</code> 컴포넌트에게는 <code>reset</code>이라는 하나의 <code>props</code>가 더 제공된다.
<code>reset</code>은 에러가 발생한 페이지를 복구하기 위해서 다시 한 번 컴포넌트들을 렌더링 시키는 기능을 갖는 함수다.</p>
<p>하지만 <code>reset</code>은 브라우저 측에서만 화면을 다시 렌더링하는 함수이기 때문에 컴포넌트는 다시 실행하지 않아서 데이터 패칭을 수행하지 않는다.
따라서 <code>reset</code> 함수는 클라이언트 컴포넌트 내부에서 발생한 오류만 복구할 수 있다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

import { useEffect } from &quot;react&quot;;

export default function Error({
  error,
  reset,
}: {
  error: Error,
  reset: () =&gt; void,
}) {
  useEffect(() =&gt; {
    console.error(error.message);
  }, [error]);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
      &lt;button onClick={() =&gt; window.location.reload()}&gt;다시 시도&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>서버 컴포넌트에서 발생한 오류를 복구하기 위해서 새로 고침을 통해 해결할 수 있는 방법이 있다.
하지만 새로 고침을 하면 브라우저에 보관한 <code>state</code>나 클라이언트 컴포넌트의 데이터들이 사라지고 에러가 발생하지 않는 레이아웃이나 다른 컴포넌트들까지 리렌더링되어야 하기 때문에 우아한 처리 방법은 아니다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

import { useRouter } from &quot;next/navigation&quot;;
import { useEffect } from &quot;react&quot;;

export default function Error({
  error,
  reset,
}: {
  error: Error,
  reset: () =&gt; void,
}) {
  const router = useRouter();
  useEffect(() =&gt; {
    console.error(error.message);
  }, [error]);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
      &lt;button
        onClick={() =&gt; {
          router.refresh();
          reset();
        }}
      &gt;
        다시 시도
      &lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>우아하게 처리하기 위해서는 <code>router.refresh()</code> 메서드를 활용할 수 있다.</p>
<p><code>refresh</code> 메서드는 현재 페이지에 필요한 서버 컴포넌트들을 다시 불러오는 메서드다.
하지만 서버 컴포넌트들을 다시 렌더링한다고 해도 클라이언트 컴포넌트인 <code>Error</code> 컴포넌트가 사라지진 않는다.</p>
<p>Next 서버에게 서버 컴포넌트만 새롭게 렌더링 요청을 한 다음에 <code>reset</code> 함수를 실행시켜 <code>refresh</code>를 통해 새롭게 전달받은 서버 컴포넌트 데이터를 화면에 렌더링하도록 해야 한다.</p>
<p>하지만 위와 같이 사용하면, <code>refresh</code> 메서드가 비동기로 동작하기 때문에 <code>refresh</code>가 끝나기 전에 <code>reset</code> 함수가 실행되어 새로운 서버 컴포넌트 없이 화면을 새로 그려 오류가 해결되지 않는다.</p>
<p>추가로 <code>refresh</code> 메서드는 <code>void</code>를 반환 타입으로 갖기 때문에 <code>await</code>를 통해 동기적으로 작동할 수 없다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

import { useRouter } from &quot;next/navigation&quot;;
import { startTransition, useEffect } from &quot;react&quot;;

export default function Error({
  error,
  reset,
}: {
  error: Error,
  reset: () =&gt; void,
}) {
  const router = useRouter();
  useEffect(() =&gt; {
    console.error(error.message);
  }, [error]);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
      &lt;button
        onClick={() =&gt; {
          startTransition(() =&gt; {
            router.refresh();
            reset();
          });
        }}
      &gt;
        다시 시도
      &lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>리액트 18 버전부터 추가된 <code>startTransition</code> 메서드를 활용하면 이 문제를 해결할 수 있다.
<code>startTransition</code> 메서드는 콜백 함수를 인수로 받아서 콜백 함수 안에 들어있는 UI를 변경시키는 작업들을 일괄적으로 처리해준다.
따라서 <code>startTransition</code>의 인자로 넘겨주는 콜백 함수 안에 <code>refresh</code>와 <code>reset</code>을 차례대로 실행시켜 주면 일괄적으로 처리하여 해결할 수 있다.</p>
<p><code>error.tsx</code>가 <code>layout.tsx</code>와 다른 점은 하위 경로에서 <code>error.tsx</code>를 추가해도 중첩되지 않는다는 점이다.
<code>error.tsx</code>는 같은 경로의 <code>layout.tsx</code>까지만 렌더링을 시켜주기 때문에 아래 경로에서 오류가 발생해도 해당 경로에만 있는 레이아웃 컴포넌트를 렌더링하기 위해서는 <code>error.tsx</code>를 따로 작성하여 같은 경로에 위치시켜야 한다.</p>
<h2 id="💰-서버-액션">💰 서버 액션</h2>
<hr>
<p>서버 액션은 브라우저에서 직접 호출할 수 있는 서버측에서 실행되는 비동기 함수를 말한다.
서버 액션을 활용하면 별도의 API를 만들 필요없이 간단한 함수 하나만으로 브라우저에서 Next 서버측에서 실행되는 함수를 직접 호출할 수 있다.</p>
<pre><code class="language-tsx">export default function Page() {
  const saveName = async (formData: FormData) =&gt; {
    &quot;use server&quot;;

    const name = formData.get(&quot;name&quot;);
    await saveDB({ name: name });
  };

  return (
    &lt;form action={saveName}&gt;
      &lt;input name=&quot;name&quot; placeholder=&quot;이름을 알려주세요 ...&quot; /&gt;
      &lt;button type=&quot;submit&quot;&gt;제출&lt;/button&gt;
    &lt;/form&gt;
  );
}</code></pre>
<p><code>saveName</code> 함수에 <code>&quot;use server&quot;</code> 지시자가 있으면 Next 서버에서만 실행되는 서버 액션으로 설정된다.</p>
<p><code>saveName</code> 함수에서 전달받은 <code>formData</code>로 부터 사용자가 입력한 값을 꺼내 <code>saveDB</code>같은 특정 함수를 실행하여 데이터 베이스에 직접 데이터를 저장하거나</p>
<pre><code class="language-tsx">await sql`INSERT INTO Names (name) VALUES (${name})`;</code></pre>
<p>SQL 문을 직접 실행하여 데이터를 추가하는 등의 서버에서만 실행할 수 있는 다양한 동작을 자유롭게 수행할 수 있다.</p>
<p>기존에는 API를 통해 진행했어야 하는 브라우저와 서버간의 데이터 통신을 오직 자바스크립트 함수 하나만으로 쉽고 간결하게 설정할 수 있다.</p>
<p>결국 서버 액션은 브라우저에서 특정 폼의 제출 이벤트가 발생했을 때, 서버에서만 실행되는 함수를 직접 호출하면서 데이터까지 <code>FormData</code> 형식으로 전달할 수 있게 해주는 기능이다.</p>
<pre><code class="language-tsx">export default function ReviewEditor({ movieId }: { movieId: string }) {
  async function createRevieAction(formData: FormData) {
    &quot;use server&quot;;
    console.log(&quot;server action called&quot;);

    const content = formData.get(&quot;content&quot;);
    const author = formData.get(&quot;author&quot;);
  }

  return (
    &lt;form action={createRevieAction}&gt;
      &lt;input name=&quot;content&quot; placeholder=&quot;리뷰 내용&quot; /&gt;
      &lt;input name=&quot;author&quot; placeholder=&quot;작성자&quot; /&gt;
      &lt;button type=&quot;submit&quot;&gt;작성하기&lt;/button&gt;
    &lt;/form&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/5c9b43ac-e0e1-44eb-bcaf-99bbe86a8252/image.png" alt=""></p>
<p>폼을 제출하고 개발자 도구의 네트워크 탭을 확인하면 요청이 발송되는 걸 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/95915deb-2cfc-40b9-aff6-951b6034d089/image.png" alt=""></p>
<p>서버 터미널에도 콘솔이 잘 출력된 걸 확인할 수 있다.
이를 통해 <code>&quot;use server&quot;</code> 지시지를 통해 서버 액션을 만든 다음에 폼을 제출하면 자동으로 <code>HTTPRequest</code>가 전송되는 걸 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/fade2bbf-582c-4c90-b516-595525ce548f/image.png" alt=""></p>
<p>추가로 서버 액션들은 컴파일 결과, 자동으로 해시값을 갖는 API로써 설정되기 때문에 브라우저 측에서 서버 액션을 호출할 때 <code>RequestHeader</code>에 <code>Next-Action</code>이라는 이름으로 현재 호출하고자 하는 서버 액션의 해시값까지 함께 명시된다.</p>
<p>서버 액션을 만들게 되면 자동으로 API를 만들고 폼 제출 시에 호출된다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/88d96ada-0203-4314-938a-d6e1ffc52ecc/image.png" alt=""></p>
<p>페이로드를 확인하면 요청과 함께 전송된 데이터를 확인할 수 있다.
<code>ACTION_ID</code>라는 현재 호출하려는 서버 액션의 해시값이 자동으로 설정되고 폼에 입력한 데이터가 전달되는 걸 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/801c37c8-da9b-4373-bffe-ae176b371c1b/image.png" alt=""></p>
<p>전송된 데이터의 타입이 <code>FormDataEntryValue | null</code>으로 추론되는데, <code>FormDataEntryValue</code>는 <code>string</code>이나 <code>File</code> 타입을 의미한다.
지금처럼 <code>string</code> 타입을 전달받고 있고 있는 상황에는 적절하지 않다.</p>
<pre><code class="language-tsx">const content = formData.get(&quot;content&quot;)?.toString();
const author = formData.get(&quot;author&quot;)?.toString();</code></pre>
<p>따라서 <code>?.toString()</code>을 통해 값이 있을 경우 문자열 타입으로 변환하도록 설정해준다.</p>
<pre><code class="language-tsx">&quot;use server&quot;;

import { revalidateTag } from &quot;next/cache&quot;;

export async function createReviewAction(_: any, formData: FormData) {
  const movieId = formData.get(&quot;movieId&quot;)?.toString();
  const content = formData.get(&quot;content&quot;)?.toString();
  const author = formData.get(&quot;author&quot;)?.toString();

  if (!movieId || !content || !author) {
    return {
      status: false,
      error: &quot;리뷰 내용과 작성자를 입력해주세요&quot;,
    };
  }

  try {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`,
      {
        method: &quot;POST&quot;,
        body: JSON.stringify({ movieId, content, author }),
      }
    );

    if (!response.ok) {
      throw new Error(response.statusText);
    }

    revalidateTag(`review-${movieId}`);
    return {
      status: true,
      error: &quot;&quot;,
    };
  } catch (error) {
    console.error(error);
    return {
      status: false,
      error: `리뷰 등록에 실패했습니다 : ${error}`,
    };
  }
}</code></pre>
<p>최종적으로 작성된 서버 액션 코드다.</p>
<p>페이지 컴포넌트에 함께 작성할 경우 파일의 내용이 길어지기 때문에 따로 분리하는 것이 좋다.
서버 액션을 별도의 파일로 분리하면 함수 안에 자리하고 있던 <code>&quot;use server&quot;</code> 지시자를 파일의 최상단에 작성할 수 있고 이 방법이 더 일반적이다.</p>
<p><code>revalidateTag</code>를 통해 Next 서버 측에 다시 생성하도록 요청하여 서버 액션의 결과를 바로 화면에 나타나게 할 수 있다.</p>
<h3 id="💵-클라이언트-컴포넌트에서-서버-액션">💵 클라이언트 컴포넌트에서 서버 액션</h3>
<pre><code class="language-tsx">export default function ReviewEditor({ movieId }: { movieId: string }) {
  return (
    &lt;form action={createReviewAction}&gt;
      &lt;input name=&quot;content&quot; placeholder=&quot;리뷰 내용&quot; /&gt;
      &lt;input name=&quot;author&quot; placeholder=&quot;작성자&quot; /&gt;
      &lt;button type=&quot;submit&quot;&gt;작성하기&lt;/button&gt;
    &lt;/form&gt;
  );
}</code></pre>
<p>사용자가 작성하기 버튼을 클릭하여 서버 액션이 실행되는 동안에 로딩 상태가 전혀 설정되어 있지 않다.
만약 <code>createReviewAction</code>이 2초 정도 걸린다면 2초 동안 사용자에게 어떠한 피드백이 제공되지 않기 때문에 사용자 경험이 나빠진다.
사용자 경험을 넘어 서버 액션이 실행되는 2초 동안 작성하기 버튼을 여러 번 클릭하면 폼이 중복 제출되는 문제가 있다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;
import { createReviewAction } from &quot;@/actions/create-review.action&quot;;
import { useActionState, useEffect } from &quot;react&quot;;

export default function ReviewEditor({ movieId }: { movieId: string }) {
  const [state, formAction, isPending] = useActionState(
    createReviewAction,
    null
  );

  useEffect(() =&gt; {
    if (state &amp;&amp; !state.status) {
      alert(state.error);
    }
  }, [state]);

  return (
    &lt;form className=&quot;flex flex-col gap-2.5&quot; action={formAction}&gt;
      &lt;input type=&quot;hidden&quot; value={movieId} name=&quot;movieId&quot; /&gt;
      &lt;textarea
        name=&quot;content&quot;
        className=&quot;resize-none w-full h-20 border-[1px] border-gray-300 p-2&quot;
        placeholder=&quot;리뷰 내용&quot;
        disabled={isPending}
        required
      /&gt;
      &lt;div className=&quot;flex gap-1 justify-end&quot;&gt;
        &lt;input
          name=&quot;author&quot;
          placeholder=&quot;작성자&quot;
          className=&quot;border-[1px] border-gray-300 p-1&quot;
          disabled={isPending}
          required
        /&gt;
        &lt;button
          type=&quot;submit&quot;
          className=&quot;bg-white text-black p-1 font-semibold&quot;
          disabled={isPending}
        &gt;
          {isPending ? &quot;...&quot; : &quot;작성하기&quot;}
        &lt;/button&gt;
      &lt;/div&gt;
    &lt;/form&gt;
  );
}</code></pre>
<p>이 문제를 해결하기 위해 <code>ReviewEditor</code> 컴포넌트의 최상단에 <code>&quot;use client&quot;</code> 지시자로 클라이언트 컴포넌트로 전환한다.</p>
<p><code>useActionState</code>는 리액트 19 버전부터 추가된 최신 훅으로 <code>form</code> 태그의 상태를 쉽게 핸들링할 수 있도록 도와주는 여러 기능을 갖고 있다.
첫 번째 인수로 액션 함수를 전달하고, 두 번째 인수로 폼 상태의 초기값을 넣어준다.
그러면 <code>state</code>, <code>formAction</code>, <code>isPending</code>이라는 값이 배열로 반환된다.</p>
<p><code>formAction</code>을 폼의 <code>action</code>으로 설정해주면 폼 제출 시에 <code>useActionState</code>의 인수로 전달한 <code>createReviewAction</code>이 자동으로 실행되고 액션의 상태를 <code>state</code>나 <code>isPending</code>으로 관리한다.</p>
<p>서버 액션의 반환값은 <code>state</code>에 담긴다.</p>
<pre><code class="language-tsx">return {
  status: false,
  error: &quot;에러 내용&quot;,
};</code></pre>
<p>서버 액션 함수에서 액션이 실해하게 되었다면 <code>status</code>를 <code>false</code>로 설정하여 실패했음을 알리고, <code>error</code>에 에러 내용을 입력하여 객체로 반환해준다.</p>
<pre><code class="language-tsx">return {
  status: true,
  error: &quot;&quot;,
};</code></pre>
<p>서버 액션이 성공했다면 <code>status</code>를 <code>true</code>로 설정하고 <code>error</code>는 빈 문자열로 입력하여 객체로 반환해준다.</p>
<pre><code class="language-tsx">export async function createReviewAction(state: any, formData: FormData) {
  ...
}</code></pre>
<p><code>useActionState</code> 사용 시 전달한 서버 액션에게 자동으로 첫 번째 인수로 <code>state</code>의 값이 전달되기 때문에 서버 액션에서는 첫 번째 파라미터로 <code>formData</code>가 아닌 <code>state</code>를 받도록 해야 한다.</p>
<p><code>isPending</code>은 서버 액션이 현재 실행중인지 아닌지를 나타내는 값이다.
따라서 <code>isPending</code>의 값이 <code>true</code>라면 서버 액션이 아직 종료되지 않은 것이므로 그때 로딩 UI를 표시하고 중복으로 폼이 제출되는 상황을 방지해줄 수 있다.</p>
<p><code>createReviewAction</code>의 반환값인 <code>state</code>의 상태에 따라 에러가 발생했는지 알수 있기 때문에 <code>useEffect</code>를 통해 <code>state</code>의 상태값이 변경됐을 때 <code>state.status</code>의 값에 따라 사용자에게 에러가 발생했음을 알려줄 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 데이터 패칭과 페이지 캐싱]]></title>
            <link>https://velog.io/@jang_expedition/Next.js-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8C%A8%EC%B9%AD%EA%B3%BC-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%BA%90%EC%8B%B1</link>
            <guid>https://velog.io/@jang_expedition/Next.js-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8C%A8%EC%B9%AD%EA%B3%BC-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%BA%90%EC%8B%B1</guid>
            <pubDate>Mon, 31 Mar 2025 10:21:42 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard">https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard</a></p>
<p>인프런의 한 입 크기로 잘라먹는 Next.js 강의를 듣고 정리한 내용입니다.</p>
<hr>
<h2 id="💰-데이터-패칭">💰 데이터 패칭</h2>
<hr>
<p>페이지 라우터는 <code>getServerSideProps</code>, <code>getStaticProps</code>, <code>getStaticPaths</code>와 같은 서버측에서만 실행되는 함수를 이용하여 데이터를 패칭했다.</p>
<pre><code class="language-tsx">export default async function Page() {
  const response = await fetch(&quot;...&quot;);

  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<p>앱 라우터는 <code>React Server Component</code>가 도입되면서 컴포넌트를 비동기 함수로 만들 수 있다.
서버 컴포넌트는 서버에서만 실행되기 때문에 별도의 함수를 따로 사용할 필요없이 컴포넌트 내부에서 데이터 패칭 로직을 작성해도 아무론 문제가 발생하지 않는다.
따라서 데이터가 필요한 컴포넌트에서 직접 데이터를 요청하여 사용할 수 있다.</p>
<h2 id="💰-데이터-캐시">💰 데이터 캐시</h2>
<hr>
<p><code>fetch</code> 메서드를 활용해 불러온 데이터를 Next 서버에 보관하는 기능으로 영구적으로 데이터를 보관하거나, 특정 주기로 갱신 시킬 수 있어 불필요한 데이터 요청을 줄여 성능을 개선할 수 있다.</p>
<pre><code class="language-ts">const response = await fetch(`~/api`, { cache: &quot;force-cache&quot; });</code></pre>
<p><code>fetch</code> 메서드에 두 번째 인수로 다양한 캐싱 옵션을 설정할 수 있다.</p>
<pre><code class="language-ts">logging: {
  fetches: {
    fullUrl: true,
  },
},</code></pre>
<p>데이터 패칭마다 로그를 출력하고 싶다면 <code>next.config.mjs</code> 파일에 <code>logging</code> 옵션을 작성해준다.</p>
<h3 id="💵--cache-no-store-">💵 { cache: &quot;no-store&quot; }</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/31deb30e-7f3c-4bee-8659-7b1ed18143e4/image.png" alt=""></p>
<p>데이터 패칭의 결과를 캐싱하지 않는 옵션이다.</p>
<pre><code class="language-tsx">const response = await fetch(&quot;~/book&quot;, {
  cache: &quot;no-store&quot;,
});</code></pre>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a094c655-6e12-4d28-8daf-38b1154f819a/image.png" alt=""></p>
<p>로그를 확인하면 오른쪽에 <code>(cache skip)</code>이 적혀있고 데이터 캐싱이 동작하지 않았음을 알려준다.
데이터 캐싱이 동작하지 않는 이유에 대해서 <code>(cache: no-store)</code> 옵션이 설정되어 있어서라고 알려준다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/ad45a916-8f72-417f-a461-ffa88a1e426e/image.png" alt=""></p>
<p>캐시 옵션을 넣지 않으면 <code>(auto-nocache)</code>로 캐싱되지 않는 이유를 알려준다.
Next 14 버전까지의 캐시 옵션 기본값은 무조건 캐싱되는 것이었지만 15 버전 이후 부터는 캐싱되지 않는다.</p>
<h3 id="💵--cache-force-cache-">💵 { cache: &quot;force-cache&quot; }</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/1a6ef0e8-6f50-4a29-a5c2-e2e86fde4806/image.png" alt=""></p>
<ol>
<li>접속 요청을 받게 되면 데이터 캐시에서 저장된 데이터를 찾는다.</li>
<li>첫 요청일 경우 <code>MISS</code>라는 판정을 내린다.</li>
<li>백엔드 서버에 요청한 뒤 받은 데이터를 저장(<code>SET</code>)해준다.</li>
<li>추가로 들어온 요청에 대해서 데이터 캐시에 저장된 데이터를 찾아(<code>HIT</code>) 반환하고 백엔드 서버에 요청을 보내지 않는다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a844e123-06c1-4f78-9a03-920d5f80d43b/image.png" alt=""></p>
<p>로그를 확인하면 <code>(cache hit)</code>으로 추가적인 데이터 요청이 발생하지 않았음을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a5047d6c-c58d-414d-8157-ed28f1433544/image.png" alt=""></p>
<p>캐싱된 데이터는 JSON 형태로 Next 서버에 보관되고 파일 탐색기에서 확인할 수 있다.</p>
<h3 id="💵--next--revalidate-3--">💵 { next: { revalidate: 3 } }</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/eff690b8-cf21-4f33-bd69-66fcb3ebaf45/image.png" alt=""></p>
<p>특정 시간을 주기로 캐시를 업데이트하는 ISR 방식과 유사하다.</p>
<ol>
<li>첫 요청일 경우 <code>MISS</code> 판정을 내린 뒤 백엔드 서버에 데이터를 요청하여 저장한다.</li>
<li>이후 요청에 대해서는 데이터 캐시에 저장된 데이터를 반환한다.</li>
<li>설정 시간 이후에 접속 요청이 들어오면 데이터를 <code>STALE</code> 상태로 설정한 뒤 반환하고 백엔드 서버로 요청하여 데이터를 최신화 시킨다.</li>
</ol>
<h3 id="💵--next--tags-review-bookid--">💵 { next: { tags: [<code>review-${bookId}</code>] } }</h3>
<pre><code class="language-ts">const response = await fetch(&quot;~/book&quot;, {
  next: { tags: [`review-${bookId}`] },
});</code></pre>
<p>데이터 패칭에 특정 태그를 붙일 수 있도록 해주는 옵션으로 태그를 통해서 데이터 캐시를 초기화하거나 재검증 시키도록 설정할 수 있는 옵션이다.</p>
<h2 id="💰-페이지-재검증">💰 페이지 재검증</h2>
<hr>
<p>서버 액션이 성공적으로 종료가 됐을 때, 페이지 또는 서버 컴포넌트들을 다시 렌더링해서 사용자가 보고 있는 페이지의 데이터를 최신화하고 싶을 때 사용하는 방법이다.</p>
<h3 id="💵-revalidatepathbookbookid">💵 revalidatePath(<code>/book/${bookId}</code>);</h3>
<p><code>revalidatePath</code> 함수가 호출되면 Next 서버가 자동으로 인수로 전달한 경로의 페이지를 재검증한다.
페이지가 다시 렌더링되기 때문에 페이지 컴포넌트의 자식 컴포넌트들이 모두 리렌더링 되면서 컴포넌트에 있는 데이터 패칭 또한 다시 수행된다.
오직 서버측에서만 호출할 수 있는 메서드이기 때문에 서버 컴포넌트 내부 또는 서버 액션에서만 호출할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/2a1b7dcc-178f-4c01-8d78-5a42516d1bdd/image.png" alt=""></p>
<p><code>revalidatePath</code>는 인자로 넘겨준 경로의 페이지를 전부 재검증하는 기능이기 때문에 페이지에 포함된 모든 캐시를 무효화 시킨다.
따라서 컴포넌트 내부에서 <code>{ cache: &quot;force-cache&quot; }</code>로 설정한 데이터 패칭이 있더라도 데이터 캐시가 삭제된다.</p>
<p>데이터 캐시뿐만 아니라 페이지를 캐싱하는 풀 라우트 캐시까지 무효화시키고 새롭게 생성된 페이지를 풀 라우트 캐시에 저장하진 않는다.
따라서 새로 고침을 통해 다시 페이지에 접속하면 캐시를 사용할 수 없으므로 Next에서 동적 페이지를 만들 듯, 페이지를 새롭게 생성하여 브라우저에게 보내주게 되고 그때 풀 라우트 캐시에 업데이트된다.
풀 라우트 캐시에 저장되지 않기 때문에 다음 접속 요청 시에 비교적 느린 응답 속도를 보인다.
저장되지 않는 이유는 <code>revalidate</code> 요청 이후에 브라우저에서 페이지에 접속하게 됐을 때, 무조건 최신의 데이터를 보장하기 위해서다.</p>
<h3 id="💵-revalidatepathbookid-page">💵 revalidatePath(&quot;/book/[id]&quot;, &quot;page&quot;);</h3>
<p>특정 경로의 모든 동적 페이지를 재검증하는 방식이다.
첫 번째 인수로 폴더 경로를 넣어주고, 두 번째 인수로 <code>&quot;page&quot;</code>를 넣어주면 <code>/book/[id]</code>라는 형태를 갖는 모든 동적 페이지가 전부 재검증 된다.</p>
<h3 id="💵-revalidatepathwith-seachbar-layout">💵 revalidatePath(&quot;/(with-seachbar)&quot;, &quot;layout&quot;);</h3>
<p>특정 레이아웃을 기준으로 해당하는 레이아웃을 갖는 모든 페이지들을 재검증하는 방식이다.
첫 번째 인수로 재검증 기준이 되는 <code>layout.tsx</code> 파일의 경로를 적고, 두 번째 인수로 문자열 <code>&quot;layout&quot;</code>을 전달하면 해당 레이아웃을 갖는 모든 페이지가 재검증된다.</p>
<h3 id="💵-revalidatepath-layout">💵 revalidatePath(&quot;/&quot;, &quot;layout&quot;);</h3>
<p>루트 레이아웃을 넣어줌으로써 모든 페이지를 재검증할 수도 있다.</p>
<h3 id="💵-revalidatetagreview-bookid">💵 revalidateTag(<code>review-${bookId}</code>);</h3>
<p>태그값을 기준으로 데이터 캐시를 재검증하는 방식으로 인수로 특정 태그를 입력하면 특정 태그를 갖는 모든 데이터 캐시가 재검증된다.</p>
<p>앞서 본 <code>revalidatePath</code>는 경로에 해당하는 데이터 캐시를 모두 삭제하는 반면, <code>revalidateTag</code>는 태그값을 갖고 있는 패치 메서드의 데이터 캐시만 삭제해주기 때문에 효율적으로 재검증할 수 있다.</p>
<h2 id="💰-requestmemoization">💰 RequestMemoization</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/01304938-32d6-495d-bad2-67e57ed0f901/image.png" alt=""></p>
<p>하나의 페이지를 이루고 있는 여러 컴포넌트에서 중복으로 발생한 요청을 캐싱해서 한 번만 요청하도록 데이터 패칭을 최적화해주는 기능이다.</p>
<p>접속 요청을 받은 페이지에 동일한 주소의 동일한 데이터를 불러오는 요청이 있고 캐싱 옵션이 <code>no-store</code>로 되어있을 경우 리퀘스트 메모이제이션이 자동으로 캐싱하여 한 번만 요청하여 캐싱한다.</p>
<p>주의할 점은 리퀘스트 메모이제이션은 하나의 페이지를 요청하는 동안에만 존재하는 캐시로 중복되는 API 요청을 방지하는 데에만 목적을 두고 있다.
따라서 렌더링이 종료되면 캐시가 소멸되어 다음 접속 요청에는 데이터를 다시 요청하기 때문에 데이터 캐시와는 다르다.</p>
<p>Next에서 리퀘스트 메모이제이션을 제공하는 이유는 서버 컴포넌트가 도입되면서 컴포넌트가 각각 필요한 데이터를 직접 패칭하는 방식으로 데이터 패칭이 진행되고 컴포넌트의 구조가 복잡해질 경우 동일한 데이터를 요청하는 경우가 발생하기 때문이다.</p>
<h2 id="💰-페이지-캐싱">💰 페이지 캐싱</h2>
<hr>
<h3 id="💵-full-route-cache">💵 Full Route Cache</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/947b028f-1dca-40c3-9660-bb8b0beee982/image.png" alt=""></p>
<p><code>/a</code> 페이지가 풀 라우트 캐시에 저장되는 페이지로 설정되었다면, 빌드 타임에 렌더링을 진행한다.
페이지에 필요한 데이터를 리퀘스트 메모이제이션이나 데이터 캐시 등의 기능을 거쳐서 렌더링이 완료된 결과를 풀 라우트 캐시라는 이름으로 저장한다.</p>
<p>빌드 타임 이후에 <code>/a</code> 페이지로 접속 요청이 들어오면 캐시가 <code>HIT</code>되어 빠른 속도로 처리한다.</p>
<p>풀 라우트 캐시는 SSG 방식과 유사하게 빌드 타임에 정적으로 페이지를 만들어 놓고 캐시에 보관한 다음, 브라우저에서 요청이 오면 캐시에 저장된 페이지를 응답하는 페이지 캐싱 기능이다.</p>
<p>Next 앱에 만든 모든 페이지는 자동으로 정적 페이지와 동적 페이지로 나뉘고 정적 페이지만 풀 라우트 캐시가 적용된다.</p>
<h4 id="정적-페이지-vs-동적-페이지">정적 페이지 vs 동적 페이지</h4>
<p>기본적으로 동적 페이지가 아니면 모두 정적 페이지가 된다.</p>
<p>페이지가 접속 요청을 받을 때마다 변화가 생기거나 데이터가 달라질 경우(캐시되지 않는 데이터 패칭을 사용하거나, 컴포넌트 내부에서 쿠키, 헤더, 쿼리스트링 등 동적 함수를 사용할 경우) 동적 페이지로 구분된다.</p>
<table>
<thead>
<tr>
<th>동적 함수</th>
<th>데이터 캐시</th>
<th>페이지 분류</th>
</tr>
</thead>
<tbody><tr>
<td>O</td>
<td>X</td>
<td>동적 페이지</td>
</tr>
<tr>
<td>O</td>
<td>O</td>
<td>동적 페이지</td>
</tr>
<tr>
<td>X</td>
<td>X</td>
<td>동적 페이지</td>
</tr>
<tr>
<td>X</td>
<td>O</td>
<td>정적 페이지</td>
</tr>
</tbody></table>
<p>풀 라우트 캐시는 정적 페이지에 대해서만 적용되고 빌드 타임에 생성되기 때문에 빠른 속도로 응답할 수 있다.
따라서 대부분의 페이지를 정적 페이지로 작성하는 걸 권장한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/fe240eb3-cdd2-4a1b-95c1-acacf6d18217/image.png" alt=""></p>
<p>풀 라우트 캐시된 페이지도 ISR처럼 Revalidate가 가능하다.
<code>revalidate: 3</code>으로 설정한 경우 빌드 타임에 페이지가 캐싱된다.
3초 이전에 들어온 접속 요청에 대해서 캐싱된 페이지를 보여주고 3초가 지난 이후에 <code>STALE</code> 표시를 한 뒤 캐싱된 페이지를 응답한 다음, 서버에 다시 데이터를 불러와 데이터 캐시를 <code>SET</code>한 뒤 페이지를 다시 렌더링하여 풀 라우트 캐시에 저장된 페이지를 최신화한다.</p>
<h4 id="generatestaticparams">generateStaticParams</h4>
<p>정적인 페이지는 빌드 타임에 만들어져 캐싱되기 때문에 최대한 활용하는 것이 좋다.</p>
<pre><code class="language-tsx">export default function Page({
  searchParams,
}: {
  searchParams: {
    q?: string;
  };
}) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
    { cache: &quot;force-cache&quot; }
  );

  ...

  return (
    &lt;div&gt;...&lt;/div&gt;
  );
}</code></pre>
<p>쿠키, 헤더, 쿼리 스트링을 사용하는 동적 함수를 포함한 컴포넌트는 동적 페이지로 분류된다.
동적 페이지로 분류되면 브라우저로 부터 접속 요청을 받을 때마다 다시 생성되겠지만 최대한 데이터 캐시를 활용하여 최적화할 수 있다.</p>
<p>동적 경로를 갖는 페이지가 있다면 기본적으로 동적 페이지로 분류된다.
<code>generateStaticParams</code> 함수는 빌드 타임에 어떠한 URL 파라미터가 존재할 수 있는지 알려줌으로써 동적 경로를 갖는 페이지를 정적 페이지로 빌드 타임에 생성되도록 해주는 함수다.</p>
<pre><code class="language-tsx">export function generateStaticParams(){
  return [{id: &quot;1&quot;}, {id: &quot;2&quot;}, {id: &quot;3&quot;}];
}

export default function Page({
  params,
}: {
  params: {id: string};
}) {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/${params.id}`);

  ...

  return (
    &lt;div&gt;...&lt;/div&gt;
  );
}</code></pre>
<p><code>generateStaticParams</code> 함수를 통해 정적인 URL 파라미터들을 담은 배열을 반환하면 빌드 타임에 Next 서버가 자동으로 읽어 반환값에 해당하는 페이지를 정적으로 생성한다.</p>
<p>빌드 결과를 확인해보면,</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a7d39fd1-c8e3-4d20-802c-9e31dc92132e/image.png" alt=""></p>
<p>반환값으로 내보냈던 1, 2, 3에 해당하는 페이지가 빌드 타임에 생성된 걸 확인할 수 있다.</p>
<p><code>generateStaticParams</code> 함수의 URL 파라미터 값은 문자열로만 반환해야 한다.
또한 페이지 컴포넌트 내부에 데이터 캐싱이 설정되지 않는 데이터 패칭이 존재해도 정적 페이지로 설정된다.</p>
<p><code>generateStaticParams</code> 함수는 <code>getStaticPaths</code> 함수와 동일한 역할을 한다.</p>
<p>반환값으로 설정하지 않은 URL 파라미터로 접속하면 동적 페이지로 실시간으로 만들어진다.</p>
<pre><code class="language-tsx">if (!response.ok) {
  if (response.status === 404) {
    return notFound();
  }
  return &lt;div&gt;오류가 발생했습니다...&lt;/div&gt;;
}</code></pre>
<p>존재하지 않는 데이터일 경우 <code>notFound()</code>를 반환하여 404 페이지로 리다이렉트 시킬 수 있다.</p>
<pre><code class="language-ts">export const dynamicParams = false;</code></pre>
<p><code>dynamicParams</code> 옵션을 <code>false</code>로 내보내주면 정적으로 설정한 URL 파라미터가 아닐 경우 모두 404 페이지로 리다이렉트한다.</p>
<h3 id="💵-라우트-세그먼트-옵션">💵 라우트 세그먼트 옵션</h3>
<p>풀 라우트 캐시를 적용하기 위해 정적 페이지로 변환하는 과정을 거치면서 페이지에 존재하는 컴포넌트들이 동적 함수를 사용하는지, 캐싱되지 않는 데이터 패칭을 하고 있지 않은지 검사해야 하는 번거로운 과정이 필요하다.
모든 컴포넌트들을 확인하지 않아도 강제로 동적, 정적 페이지로 설정하는 옵션이 라우트 세그먼트 옵션이다.</p>
<p><code>dynamicParams</code> 옵션도 라우트 세그먼트 옵션 가운데 하나다.
라우트 세그먼트 옵션은 많은 옵션을 제공하지만 모두 사용되지 않고 있기 때문에 자주 사용되는 <code>dynamic</code> 옵션만 다뤄보겠다.</p>
<pre><code class="language-ts">export const dynamic = &quot;&quot;;</code></pre>
<p><code>dynamic</code> 옵션은 페이지의 유형을 강제로 동적, 정적으로 설정해주는 옵션으로 <code>auto</code>, <code>force-dynamic</code>, <code>force-static</code>, <code>error</code>라는 네 가지 옵션을 제공한다.</p>
<p><code>auto</code>는 기본값으로 자동으로 동적, 정적인 페이지로 설정해주고 생략 가능하다.</p>
<pre><code class="language-tsx">export const dynamic = `force-static`;

export default function Page({
  searchParams,
}: {
  searchParams: {
    q?: string;
  };
}) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
    { cache: &quot;force-cache&quot; }
  );

  ...

  return (
    &lt;div&gt;...&lt;/div&gt;
  );
}</code></pre>
<p>만약 동적 페이지에 <code>force-static</code> 옵션을 주면 강제로 정적 페이지로 설정된다.
이때 페이지 내부에서 사용되는 쿼리 스트링 같은 동적 함수들은 <code>undefined</code>를 반환, 데이터 패칭은 <code>no-store</code>로 설정된다.
동적 함수를 <code>undefined</code>로 반환하도록 하기 때문에 쿼리 스트링을 사용하는 페이지의 경우 제대로 동작하지 않는다.</p>
<p><code>force-error</code>는 <code>force-static</code>과 동일하게 정적 페이지로 설정해주지만 동적 함수나 캐싱되지 않는 데이터 패칭 등이 있다면 빌드 오류를 발생시킨다.</p>
<p>앱 라우터는 모든 컴포넌트들이 어떻게 동작하는지에 따라 동적, 정적 페이지로 자동으로 설정해주기 때문에 <code>dynamic</code> 옵션은 권장되지 않는다.
하지만 개발 중에 의도적으로 동적, 정적 페이지로 설정해야 되는 경우 <code>dynamic</code> 옵션을 적용한 다음에 나중에 고쳐나가는 식으로 진행할 수 있다.</p>
<h3 id="💵-클라이언트-라우터-캐시">💵 클라이언트 라우터 캐시</h3>
<p>클라이언트 라우터 캐시는 클라이언트의 브라우저에 저장되는 캐시로 페이지 이동을 효율적으로 진행하기 위해 페이지의 일부 데이터를 보관하는 기능이다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/eb32c63e-6f44-4f91-ad03-5d33498c53ef/image.png" alt=""></p>
<p><code>~/</code> 경로의 인덱스 페이지는 정적 페이지, <code>~/search</code> 경로의 페이지는 동적 페이지이고 루트 레이아웃을 같이 사용하고 있는 상황이라고 가정해보자.</p>
<p>인덱스 페이지로 요청을 보내면 풀 라우트 캐시에 저장된 페이지를 반환한다.
<code>~/search</code> 경로로 이동하면 동적 페이지이기 때문에 캐시는 <code>SKIP</code>되고 실시간으로 페이지를 생성해 반환한다.
클라이언트 라우터 캐시가 없다면 인덱스 페이지와 search 페이지에서 공통으로 사용하고 있는 루트 레이아웃을 중복으로 요청하게 된다.</p>
<p>자세하게 얘기하면 Next 서버는 브라우저에게 사전 렌더링된 HTML 파일, 클라이언트 컴포넌트들의 데이터를 포함하고 있는 JS Bundle, 서버 컴포넌트들의 데이터를 포함하고 있는 RSC Payload를 보낸다.
브라우저는 인덱스 페이지에서 search 페이지로 이동하는 과정에서 Next 서버로 부터 전달받은 RSC Payload에는 중복된 레이아웃이 있는 문제가 있다.</p>
<p>Next는 이러한 비효율을 줄이기 위해 브라우저에 클라이언트 라우터 캐시라는 새로운 캐시 공간을 추가해 서버로 부터 받는 RSC Payload 값들 중에 레이아웃에 해당하는 데이터만 따로 보관한다.</p>
<p>클라이언트 라우터 캐시는 따로 적용할 필요없이 자동 적용되어 있다.
클라이언트 라우터 캐시를 사용해도 새로 고침을 하거나 탭을 껐다가 다시 접속하는 경우에는 사라지기 때문에 동작하지 않는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] App Router]]></title>
            <link>https://velog.io/@jang_expedition/Next.js-App-Router</link>
            <guid>https://velog.io/@jang_expedition/Next.js-App-Router</guid>
            <pubDate>Fri, 28 Mar 2025 05:45:48 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard">https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard</a></p>
<p>인프런의 한 입 크기로 잘라먹는 Next.js 강의를 듣고 정리한 내용입니다.</p>
<hr>
<p>앱 라우터는 Next 13 버전에 새롭게 추가된 페이지 라우터를 대체한 라우터다.
앱 라우터를 사용하면 페이지 라우팅, 레이아웃, 데이터 패칭 방식이 변경되고 리액트 18 버전부터 추가된 <code>React Server Component</code>, <code>Streaming</code> 등 최신 기능들도 함께 사용할 수 있다.</p>
<h2 id="💰-vs-페이지-라우터">💰 vs 페이지 라우터</h2>
<h3 id="💵-페이지-라우팅">💵 페이지 라우팅</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/ea2786ed-d9ca-432c-b53a-60dca38059cd/image.png" alt=""></p>
<p>앱 라우터는 <code>page</code>라는 이름은 갖는 파일만 페이지 파일로 취급한다.
<code>/search</code>라는 경로에 대응하는 페이지는 <code>search</code> 폴더를 아래에 <code>page</code> 파일을 넣어줘야 한다.</p>
<p>동적 경로에 대응하는 페이지도 동일하게 만든다.</p>
<h3 id="💵-레이아웃">💵 레이아웃</h3>
<pre><code class="language-tsx">Page.getLayout = (page: ReactNode) =&gt; {
  return &lt;Layout&gt;{page}&lt;/Layout&gt;;
};</code></pre>
<pre><code class="language-tsx">export default function App({
  Component,
  pageProps,
}: AppProps &amp; {
  Component: NextPageWithLayout,
}) {
  const getLayout = Component.getLayout ?? ((page: ReactNode) =&gt; page);

  return &lt;GlobalLayout&gt;{getLayout(&lt;Component {...pageProps} /&gt;)}&lt;/GlobalLayout&gt;;
}</code></pre>
<p>페이지 라우터는 레이아웃을 적용하기 위해 페이지마다 <code>getLayout</code>이라는 메서드를 만들어서 <code>App</code> 컴포넌트에서 분기 처리를 해주는 번거로움이 있었다.</p>
<pre><code class="language-tsx">import { ReactNode } from &quot;react&quot;;
import Searchbar from &quot;../../components/searchbar&quot;;

export default function Layout({ children }: { children: ReactNode }) {
  return (
    &lt;div&gt;
      &lt;Searchbar /&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<p>앱 라우터는 레이아웃 컴포넌트 파일만 작성하면 해당 경로 아래에 모든 페이지에 자동으로 레이아웃이 적용된다.</p>
<p>만약 <code>search</code> 폴더에 <code>layout.tsx</code>를 만들고 아래에 <code>setting</code> 폴더에 <code>page</code> 파일을 만든다면, <code>/search/setting</code> 경로로 진입했을 때도 레이아웃 컴포넌트가 먼저 렌더링되고 페이지 컴포넌트가 렌더링된다.</p>
<p>만약 <code>setting</code> 폴더 아래에도 <code>layout</code> 파일을 만든다면 중첩되어 적용된다.
<code>search</code> 폴더의 레이아웃, <code>setting</code> 폴더의 레이아웃, 페이지 컴포넌트 순으로 렌더링된다.</p>
<pre><code class="language-tsx">import &quot;./globals.css&quot;;
import Link from &quot;next/link&quot;;

export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode,
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p><code>RootLayout</code>은 <code>app</code> 폴더 아래에 자동으로 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/2d1e90f6-f4d9-4500-933f-73d3c55e9f65/image.png" alt=""></p>
<p>만약 특정 페이지들만 동일한 레이아웃을 설정하고 싶다면 앱 라우터의 새로운 기능인 <code>Route Group</code>을 사용할 수 있다.
<code>Route Group</code>은 소괄호로 감싼 이름의 폴더를 지칭하고, 경로상에는 아무런 영향을 미치지 않는다.
<code>Route Group</code> 아래에 있는 페이지 컴포넌트에게만 동일한 레이아웃을 구성할 수 있고 바깥에 존재하는 페이지에는 레이아웃이 적용되지 않는다.</p>
<h2 id="💰-react-server-component">💰 React Server Component</h2>
<hr>
<p>기존 컴포넌트와 달리 브라우저에서 실행되지 않고 서버측에서만 실행되는 컴포넌트다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/c6fb3fd1-a833-4c58-ae65-a6f122b56974/image.png" alt=""></p>
<p>서버에서 브라우저에게 전달하는 JS Bundle에는 페이지에 필요한 모든 컴포넌트들이 들어있고 브라우저에서 Hydration을 위해 한 번 더 실행된다.</p>
<p>하지만 JS Bundle에 모든 컴포넌트가 포함될 필요는 없다.
리액트 훅이 있거나 이벤트 핸들러가 있어서 상호 작용이 필요한 컴포넌트들을 제외한 정적인 컴포넌트들은 굳이 브라우저에서 한 번 더 실행될 필요가 없다.</p>
<p>페이지 라우터는 모든 컴포넌트를 JS Bundle로 묶어서 브라우저에게 전달한다.
JS Bundle의 용량이 불필요하게 커지고 불러오는 시간과 Hydraion하는 시간도 늘어나 TTI가 늦어진다.</p>
<p>앱 라우터는 상호 작용을 하는 컴포넌트와 그렇지 않은 컴포넌트를 분류해준다.
서버 컴포넌트는 사용자와 상호 작용하지 않는 컴포넌트로, JS Bundle에 포함하지 않아 서버측에서 한 번만 실행된다.
클라이언트 컴포넌트는 상호 작용이 필요한 컴포넌트로, 서버에서 한 번, 브라우저에서 한 번 실행된다.</p>
<p>앱 라우터는 브라우저에게 JS Bundle을 보낼 때 클라이언트 컴포넌트만 보냄으로써 파일의 용량을 줄여 앞선 문제점들을 해결한다.
Next.js 공식 문서에서도 페이지의 대부분을 서버 컴포넌트로 구성할 것을 권장한다.</p>
<pre><code class="language-tsx">export default function Page() {
  console.log(&quot;서버 컴포넌트!&quot;);
  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<p>앱 라우터는 기본적으로 서버 컴포넌트로 동작한다.
컴포넌트 내부에서 로그를 출력한다면 브라우저에서 확인할 수 없고 서버 터미널에서 확인할 수 있다.</p>
<p>서버에서만 실행되기 때문에 컴포넌트 내부에서 보안 키를 사용해도 브라우저에게 전달되지 않기 때문에 보안적인 문제가 발생하지 않을 뿐더러 직접 데이터를 불러오도록 설정할 수 있다.</p>
<p>기존 페이지 라우터에서 <code>getServerSideProps</code>, <code>getStaticProps</code> 함수가 했던 역할을 컴포넌트가 할 수 있게 설정해줄 수 있다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;

export default function Page() {
  console.log(&quot;클라이언트 컴포넌트!&quot;);
  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<p>리액트 훅이나 이벤트 핸들러같은 브라우저에서만 할 수 있는 일들을 사용하기 위해서는 클라이언트 컴포넌트를 사용해야 한다.
파일 최상단에 <code>&quot;useclient&quot;</code>라고 적어주면 클라이언트 컴포넌트로 인식한다.</p>
<p>클라이언트 컴포넌트와 서버 컴포넌트는 자바 스크립트 기능을 활용하여 사용자와 상호작용을 하거나, 안 하냐로 나눠서 사용할 수 있다.
자바 스크립트 기능을 활용한에 주목해야 하는데, <code>Link</code> 태그를 클릭하여 페이지를 이동하는 건 HTML 고유 기능이기 때문에 상호 작용에 해당하지 않는다.</p>
<h3 id="💵-서버-컴포넌트-사용-시-주의-사항">💵 서버 컴포넌트 사용 시 주의 사항</h3>
<h4 id="클라이언트-컴포넌트에서-서버-컴포넌트를-import할-수-없다">클라이언트 컴포넌트에서 서버 컴포넌트를 <code>import</code>할 수 없다.</h4>
<p>클라이언트 컴포넌트에서 서버 컴포넌트를 <code>import</code>할 경우, 서버 측에서 실행될 때는 문제가 없지만 브라우저에서 Hydration할 때 클라이언트 컴포넌트만 존재하여 문제가 된다.</p>
<pre><code class="language-tsx">&quot;use client&quot;;
import ServerComponent from &quot;./ServerComponent&quot;;

export default function ClientComponent() {
  return &lt;ServerComponent /&gt;;
}</code></pre>
<p>프로젝트가 크고 복잡해지면 의도치 않게 클라이언트 컴포넌트 안에서 서버 컴포넌트를 사용할 수도 있다.
Next.js는 오류를 발생시키는 대신에 서버 컴포넌트를 클라이언트 컴포넌트로 바꿔준다.</p>
<pre><code class="language-tsx">export default function ServerComponent() {
  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<pre><code class="language-tsx">&quot;use client&quot;;

export default function ClientComponent({ children }: { children: ReactNode }) {
  return (
    &lt;div&gt;
      &lt;children /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<pre><code class="language-tsx">export default function Page() {
  return (
    &lt;div&gt;
      &lt;ClientComponent&gt;
        &lt;SeverComponent /&gt;
      &lt;/ClientComponent&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>어쩔 수 없이 클라이언트 컴포넌트에서 서버 컴포넌트를 사용해야 한다면 직접 <code>import</code>하지 않고 <code>children</code>으로 받아 렌더링 시켜주면 된다.</p>
<p>클라이언트 컴포넌트는 서버 컴포넌트를 직접 실행할 필요없이 서버 컴포넌트의 결과물만 <code>props</code>로 전달 받으면 되기 때문이다.</p>
<h4 id="서버-컴포넌트에서-클라이언트-컴포넌트에게-직렬화-되지-않는-props를-전달할-수-없다">서버 컴포넌트에서 클라이언트 컴포넌트에게 직렬화 되지 않는 props를 전달할 수 없다.</h4>
<blockquote>
<p>직렬화란 객체, 배열, 클래스 등 복잡한 구조의 데이터를 네트워크 상으로 전송하기 위해 단순한 형태(바이트, 문자열)로 변환하는 과정을 말한다.</p>
</blockquote>
<p>자바 스크립트의 함수는 값이 아닌 코드 블럭들을 포함하고 있는 특수한 형태를 갖고 클로저, 렉시컬 스코프 등의 다양한 환경에 의존한 경우가 많기 때문에 모든 정보를 단순한 형태로 표현할 수 없다.
직렬화되지 않는 함수는 서버 컴포넌트에서 클라이언트 컴포넌트로 향하는 <code>props</code>가 될 수 없다.</p>
<p>사전 렌더링 과정에서 클라이언트 컴포넌트와 서버 컴포넌트가 함께 실행하여 HTML 페이지를 생성하지 않는다.
서버 컴포넌트들이 먼저 실행되고 클라이언트 컴포넌트들이 실행된다.
서버 컴포넌트가 실행되면 결과로 <code>RSC Payload</code>라는 JSON과 비슷한 문자열을 생성한다.</p>
<p>RSC란 <code>React Server Component</code>의 줄임말로 <code>RSC Payload</code>란 서버 컴포넌트를 직렬화한 결과다.</p>
<p><code>RSC Payload</code>에는 서버 컴포넌트의 렌더링 결과, 연결된 클라이언트 컴포넌트의 위치, 클라이언트 컴포넌트에게 전달하는 <code>props</code> 등 서버 컴포넌트와 관련된 모든 데이터가 들어있다.</p>
<p>이후 클라이언트 컴포넌트들도 실행되어 <code>RSC Payload</code> 결과와 합쳐져 HTML 페이지가 생성된다.
하지만 서버 컴포넌트들을 먼저 실행해서 <code>RSC Payload</code>라는 형태로 직렬화하는 과정에서 만약 특정 서버 컴포넌트가 자식인 클라이언트 컴포넌트에게 함수 형태의 값을 전달한다면 함수 값 또한 함께 직렬화 되어 <code>RSC Payload</code>에 포함되어야 한다.
하지만 함수는 직렬화 할 수 없기 때문에 런타임 에러가 발생한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] PageRouter]]></title>
            <link>https://velog.io/@jang_expedition/Next.js-PageRouter</link>
            <guid>https://velog.io/@jang_expedition/Next.js-PageRouter</guid>
            <pubDate>Thu, 27 Mar 2025 09:07:35 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard">https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs/dashboard</a></p>
<p>인프런의 &lt;한 입 크기로 잘라먹는 Next.js&gt; 강의를 듣고 정리한 내용입니다.</p>
<hr>
<h2 id="💰-페이지-렌더링">💰 페이지 렌더링</h2>
<hr>
<h3 id="💵-vs-기존-react-렌더링-과정csr">💵 vs 기존 React 렌더링 과정(CSR)</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/c796b103-27d3-4507-bd09-d1dc7e9837b4/image.png" alt=""></p>
<p>사용자가 접속 요청을 서버로 보내면 빈 HTML 껍데기(index.html)를 반환한다.
브라우저는 사용자에게 빈 화면을 렌더링한다.</p>
<p>서버는 HTML을 보낸 뒤 JS Bundle 파일을 브라우저로 보낸다.
JS Bundle 파일에는 해당 사이트에서 접근 가능한 모든 컴포넌트가 존재하는 리액트 앱 자체다.
브라우저는 파일을 실행하여 컨텐츠를 렌더링하고 사용자에게 보여준다.</p>
<p>사용자가 페이지를 이동할 경우, 요청이 서버까지 가지 않고 브라우저에서 JS 파일을 실행하여 렌더링하기 때문에 페이지 이동이 빠르다는 장점을 갖고 있다.</p>
<p>하지만 초기 요청으로 부터 컨텐츠가 렌더링된 화면을 보기까지의 시간이 오래걸린다.
이 시간을 FCP(First Contentful Paint)라고 한다.
FCP는 웹 성능을 대표할 정도로 중요한 지표다.</p>
<blockquote>
<p><strong>FCP에 따른 이탈률</strong></p>
<ul>
<li>3초 이상: 32% 증가</li>
<li>5초 이상: 90% 증가</li>
<li>6초 이상: 105% 증가</li>
<li>10초 이상: 123% 증가</li>
</ul>
</blockquote>
<p>정리하면 리액트 앱들은 CSR을 사용하여 초기 접속 이후에 일어나는 페이지 이동은 빠르고 쾌적한 반면, FCP가 늦어지게 된다는 치명적인 단점이 존재한다.</p>
<h3 id="💵-nextjs-사전-렌더링">💵 Next.js 사전 렌더링</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/836b93a5-71bd-44ba-99a6-d48521747ca7/image.png" alt=""></p>
<p>사용자가 페이지에 접속하면, 서버는 컴포넌트를 렌더링하여 HTML을 보낸다. 브라우저는 이 HTML을 즉시 화면에 보여주지만, 이 시점에는 JS가 아직 로드되지 않았기 때문에 사용자 상호 작용을 할 수는 없다.</p>
<p>서버는 리액트와 동일하게 JS Bundle 파일을 후속으로 보내준다.
브라우저는 JS Bundle 파일을 실행하여 HTML과 연결한다.
이 과정을 Hydration이라 부른다.</p>
<p>JS Bundle 파일이 연결된 뒤부터는 사용자와 상호 작용할 수 있다.
사용자와 상호 작용할 수 있게 되는 시간까지를 TTI(Time To Interactive)라고 부른다.
페이지 이동을 하게 되면 서버까지 갈 필요없이 브라우저에서 JS 파일을 실행하여 페이지를 교체한다.</p>
<p>정리하면 서버에서 렌더링된 HTML 파일을 보냄으로써 FCP 시간을 줄이고 초기 접속 이후에 일어나는 페이지 이동에 대해서는 CSR 방식과 같이 효율적으로 페이지를 이동한다.</p>
<h3 id="💵-pre-fetching">💵 Pre-fetching</h3>
<p>Next.js는 사용자가 보고 있는 페이지에서 이동할 가능성이 있는 모든 페이지들을 미리 불러온다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/d2eacec4-28ba-4bf2-9b44-e9325474d2fe/image.png" alt=""></p>
<p>Next.js는 앱에 작성된 모든 컴포넌트들을 자동으로 페이지별로 분리해서 미리 저장한다.
초기 접속 요청할 때 받는 JS Bundle 파일은 리액트처럼 앱 내의 모든 컴포넌트 코드가 아닌 현재 페이지에 해당하는 컴포넌트 코드들만 전달한다.
왜냐하면 초기 접속 요청이 있을 때마다 모든 페이지에 해당하는 코드들을 매번 번들링해서 전달하게 되면 파일의 용량이 커져서 다운로드 속도도 느려지고 Hydration 과정도 오래 걸려 TTI가 늦어지는 문제가 발생하기 때문이다.</p>
<p>하지만 현재 페이지에 해당하는 코드들만 보내주면 페이지 이동을 CSR 방식으로 처리할 수 없다.
현재 페이지에 대한 코드들만 존재하기 때문에 다른 페이지를 이동할 때 페이지 코드를 추가로 불러와야 하는 상황이 생기기 때문이다.</p>
<p>프리패칭은 페이지 이동이 느려지는 문제를 방지한다.
페이지 이동이 이뤄지기 전에 프리패칭이 발생하여 현재 페이지와 연결된 모든 페이지들의 코드를 미리 불러온다.
따라서 추가적인 데이터를 서버에 요청할 필요없이 CSR 방식의 빠른 페이지 이동이 가능하다.</p>
<h2 id="💰-데이터-패칭">💰 데이터 패칭</h2>
<hr>
<h3 id="💵-기존-리액트의-데이터-패칭">💵 기존 리액트의 데이터 패칭</h3>
<pre><code class="language-tsx">export default function Page() {
  const [state, setState] = useState();

  const fetchData = async () =&gt; {
    const response = await fetch(&quot;...&quot;);
    const data = await response.json();

    setState(data);
  };

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

  if (!state) return &quot;Loading ...&quot;;

  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<p>기존 리액트의 처리 방식은 서버로 부터 불러온 데이터가 화면에 나타나기까지 오랜 시간이 걸린다는 단점이 있다.
데이터 요청 자체가 컴포넌트가 마운트된 이후에 실행되기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/aecd1967-528d-4455-9175-4dc05c9f9030/image.png" alt=""></p>
<p>느린 FCP를 거치고 데이터를 요청하기 때문에 데이터 로딩이 완료되기까지 추가적인 시간이 필요하다.</p>
<h3 id="💵-nextjs의-데이터-패칭">💵 Next.js의 데이터 패칭</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/0b2d1cb0-663c-443d-981a-cfba79d894bc/image.png" alt="">
Next.js는 서버에서 사전 렌더링을 진행하는 과정에서 서버로부터 현재 페이지에 필요한 데이터를 미리 불러오도록 설정해줄 수 있다.
리액트의 데이터 패칭보다 빠른 시점에 데이터를 요청하고 받은 데이터를 HTML에 렌더링해주기 때문에 사용자는 FCP가 끝나고 추가적으로 기다릴 필요가 없다.</p>
<p>하지만 사전 렌더링 과정에서 데이터를 받아올 때 데이터의 용량이 크거나 서버의 상태가 좋지 못해 데이터를 받기까지 오래 걸린다면 사용자는 아무런 화면도 볼 수 없는 문제가 있다.
Next.js는 사전 렌더링이 오래 걸릴 것으로 예상되는 페이지의 경우 빌드 타임에 사전 렌더링 마처두도록 설정할 수도 있다.</p>
<p>Next.js의 사전 렌더링 방식으로 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG), 증분 정적 재생성(ISR)을 제공한다.</p>
<h3 id="💵-서버-사이드-렌더링-ssr">💵 서버 사이드 렌더링 (SSR)</h3>
<pre><code class="language-tsx">export default async function fetchData(): Promise&lt;Data[]&gt; {
  let url = &quot;...&quot;;

  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error();
    }

    return await response.json();
  } catch (e) {
    console.error(e);
    return [];
  }
}

export const getServerSideProps = async (q?: string) =&gt; {
  // 서버 사이드에서 실행되는 코드기 때문에 브라우저에서 조회 불가, 터미널에서 확인 가능
  console.log(&quot;서버사이드프롭스!!!&quot;);

  const data = await fetchData();

  return {
    props: {
      data
    },
  };
};

export default function Page({
  data,
}: InferGetServerSidePropsType&lt;typeof getServerSideProps&gt;) {
  return (
    &lt;div&gt;...&lt;/div&gt;
  );
}</code></pre>
<p>SSR 방식은 가장 기본적인 사전 렌더링 방식으로 요청이 들어왔을 때 사전 렌더링을 진행하는 방식이다.</p>
<p>컴포넌트 바깥에 약속된 이름인 <code>getServerSideProps</code> 함수를 만들고 <code>export</code>로 내보내주면 SSR이 동작하도록 설정된다.
<code>getServerSideProps</code> 함수는 사전 렌더링할 때 컴포넌트 보다 먼저 실행돼서 필요한 데이터를 불러오는 기능을 한다.
반환 데이터는 반드시 <code>props</code>라는 객체를 요소로 갖고 있는 객체여야만 한다.
반환 객체의 <code>props</code>를 읽어와서 페이지 컴포넌트에 전달하기 때문에 꼭 준수해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/900c2599-c6f2-4163-9aff-fafd079e2a93/image.png" alt=""></p>
<p><code>getServerSideProps</code> 함수는 서버 측에서만 실행되기 때문에 콘솔에 출력한 내용은 브라우저가 아닌 서버 터미널에서 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/37ab84ca-95d6-42fa-b6e3-e212c710b36c/image.png" alt=""></p>
<p>같은 이유로 <code>getServerSideProps</code> 함수 내부에서 브라우저 환경에서만 사용할 수 있는 <code>window</code> 객체를 사용하면 오류가 발생한다.</p>
<p>페이지 컴포넌트 또한 서버에서 한 번, 브라우저에서 한 번 실행되기 때문에 아무런 조건없이 <code>window</code> 객체를 사용하면 오류가 발생한다.
만약 페이지 컴포넌트에서 <code>window</code> 객체를 사용하고 싶으면 가장 쉬운 방법은 <code>useEffect</code>를 사용하는 방법이다.
<code>useEffect</code>는 컴포넌트가 마운트된 이후에 실행되기 때문에 서버에서 실행되지 않는다.</p>
<p><code>InferGetServerSidePropsType</code>은 <code>getServerSideProps</code>의 반환 타입을 자동으로 추론해주는 기능이다.
컴포넌트에서 매개 변수 타입은 <code>InferGetServerSidePropsType&lt;typeof getServerSideProps&gt;</code>를 통해 정의할 수 있다.</p>
<pre><code class="language-tsx">export const getServerSideProps = async (
  context: GetServerSidePropsContext
) =&gt; {
  const q = context.query.q;
  const books = await fetchData(q as string);

  return {
    props: { books },
  };
};</code></pre>
<p>만약 쿼리 스트링을 사용해야 한다면 <code>context: GetServerSidePropsContext</code>를 매개 인자로 받아서 <code>context.query.쿼리 스트링 이름</code>과 같은 방식으로 사용할 수 있다.</p>
<h3 id="💵-정적-사이트-생성-ssg">💵 정적 사이트 생성 (SSG)</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/3690bf7e-0b25-4988-81a1-af7cf196d931/image.png" alt=""></p>
<p>SSG 방식은 SSR의 단점을 해결하는 사전 렌더링 방식으로, 빌드 타임에 실행된다.
사용자가 접속 요청을 보내면 빌드 타임에 만들어둔 페이지를 지체없이 응답할 수 있다.
SSG 방식은 사전 렌더링 과정에서 데이터를 불러오는 과정이 오래 걸려도 사용자의 경험에는 아무런 영향을 미치지 않는다.
사전 렌더링에 많은 시간이 소요되도 요청에 빠른 응답을 하는 장점을 갖고 있다.</p>
<p>하지만 빌드 타임 이후에는 새롭게 페이지를 생성하지 않기 때문에 사용자가 언제 접속 요청을 보내더라도 매번 같은 페이지만 응답한다.
따라서 최신 데이터 반영이 어려워 최신 데이터가 빠르게 반영되어야 하는 페이지보다는 데이터가 자주 업데이트 되지 않는 정적 페이지에 적합한 사전 렌더링 방식이다.</p>
<pre><code class="language-tsx">export const getStaticProps = async () =&gt; {
  console.log(&quot;인덱스 페이지&quot;);

  const data = await fetchData();

  return {
    props: {
      data,
    },
  };
};

export default function Page({
  data,
}: InferGetStaticPropsType&lt;typeof getStaticProps&gt;) {
  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<p><code>getStaticProps</code>라는 이름을 갖는 함수를 만들고 반환값은 동일하게 <code>props</code>를 요소로 갖는 객체를 반환해준다.
SSG 방식으로 동작하기 때문에 <code>getStaticProps</code> 함수 내부에 콘솔을 출력하면 빌드 시에 터미널에 한 번만 출력된다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/bc940ac1-fbb2-456a-9d79-379a18595626/image.png" alt=""></p>
<ul>
<li>빌드 명령어를 실행해보면 <code>Generating static pages</code>라는 메세지가 나오면서 SSG로 동작하는 페이지들이 생성되고 있다고 나온다.</li>
<li>이 과정에서 <code>인덱스 페이지</code>라는 메세지가 출력되는 걸 미뤄보아 <code>getStaticProps</code> 함수가 실행된 걸 확인할 수 있다.</li>
<li><code>Route (pages)</code> 아래 쪽을 보면 SSG 방식으로 사전 렌더링한 페이지는 흰색 동그라미가 앞에 붙어있다.</li>
<li>다른 페이지들은 <code>f</code>라는 <code>function</code> 기호가 붙어있는데, 기호의 의미는 메세지 최하단에서 확인할 수 있다.</li>
<li>빈 동그라미는 <code>prerendered as static content</code>라고 나와있고 기본값으로 설정된 SSG 페이지라는 뜻이다.</li>
<li>Next.js는 <code>getServerSideProps</code>, <code>getStaticProps</code>와 같은 메서드를 사용하지 않고 아무런 설정도 안 했을 경우, 정적 페이지로 빌드 타입에 사전 렌더링하도록 설정해준다.</li>
</ul>
<pre><code class="language-tsx">export const getStaticProps = async (
  context: GetStaticPropsContext
) =&gt; {
  const q = context.query.q;
  const books = await fetchData(q as string);

  return {
    props: { books },
  };
};</code></pre>
<p>쿼리 스트링을 사용하기 위해 SSR 방식과 같은 방법으로 사용하면 오류가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/5890c5a9-4e17-4d4a-b2b1-7fc86489cc40/image.png" alt=""></p>
<p><code>getStaticProps</code> 함수에 전달되는 <code>context</code>에 <code>query</code> 속성이 존재하지 않는 이유는 빌드 타임에 한 번 실행되기 때문에 사용자의 동작에 의해 전달되는 쿼리 스트링을 알 수 없기 때문이다.</p>
<p>동적 경로를 갖는 페이지 컴포넌트에 SSG를 적용시켜 보면,</p>
<pre><code class="language-tsx">export const getStaticPaths = async () =&gt; {
  const datas = await fetchDatas();

  const paths = datas
    .map((data) =&gt; data.id.toString())
    .reduce(
      (acc: { params: { id: string } }[], cur) =&gt; [
        ...acc,
        { params: { id: cur } },
      ],
      []
    );

  return {
    paths,
    fallback: false,
  };
};</code></pre>
<p>페이지 컴포넌트 바깥에 <code>getStaticPaths</code>라는 이름으로 함수를 만들고 <code>export</code>로 내보내 준다.
<code>getStaticPaths</code>는 <code>paths</code>와 <code>fallback</code> 속성을 갖는 객체로 반환해줘야 한다.</p>
<p><code>paths</code>는 어떤 경로들이 존재할 수 있는지를 객체 배열로 반환해줘야 하며 <code>URL Parameter</code>를 의미하는 <code>params</code>라는 값으로 설정해준다.</p>
<p><code>fallback</code>은 만약 브라우저에서 <code>paths</code>의 값으로 설정한 URL에 해당하지 않는 경로로 접속 요청을 할 경우 대비책을 설정하는 역할로 3가지 옵션이 존재한다.</p>
<h4 id="1-fallback-false">1. fallback: false</h4>
<p><code>false</code>로 설정할 경우 존재하지 않는 경로의 요청에 대해 <code>NotFoundPage</code>를 반환한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/82e50964-e7d9-4767-b9aa-7fe3162a3c33/image.png" alt=""></p>
<p><code>npm run build</code> 명령어를 실행하면 빌드된 산출물 안에서 직접 확인할 수 있다.
만약 브라우저가 <code>/book/1</code>로 요청하게 되면 지체없이 HTML 파일을 보여줄 수 있다.</p>
<h4 id="2-fallback-blocking">2. fallback: &quot;blocking&quot;</h4>
<p><code>&quot;blocking&quot;</code>을 넣어주면 존재하지 않는 경로에 대해 SSR 방식으로 사전 렌더링하여 보여준다.
빌드 타입 이후에 생성된 페이지는 Next 서버에서 자동으로 저장되기 때문에 청므 요청 시에 SSR 방식으로 동작하여 조금 느릴 수 있으나, 이후 요청에 대해서는 새롭게 생성할 필요가 없이 빠른 속도로 렌더링된다.</p>
<p>따라서 SSR과 SSG가 결합된 형태로 동작한다.
동적인 페이지를 구현할 때 빌드 타임에 모든 데이터를 불러오기 어려운 상황이거나 새로운 데이터가 추가되어야 하는 상황에서 사용할 수 있다.</p>
<p>하지만 존재하지 않았던 페이지를 SSR 방식으로 생성할 때 사전 렌더링 시간이 길어지면 브라우저에게 서버가 아무런 응답도 하지 않기 때문에 페이지 크기에 따라 오랜 시간을 기다려야 하는 문제가 있다.</p>
<h4 id="3-fallback-true">3. fallback: true</h4>
<p><code>true</code>로 설정할 경우 존재하지 않는 페이지 요청을 받았을 때 <code>getStaticProps</code>의 반환값인 <code>props</code>가 없는 페이지를 먼저 반환한다.
이후 페이지에 필요한 데이터인 <code>props</code>만 따로 계산하여 완료되면 브라우저에게 보낸다.
UI만 먼저 렌더링하고 데이터는 나중에 전달하게 되는 것이다.</p>
<pre><code class="language-tsx">export default function Page({
  data,
}: InferGetStaticPropsType&lt;typeof getStaticProps&gt;) {
  const router = useRouter();

  if (router.isFallback) return &quot;로딩중입니다.&quot;;
  if (!book) return &quot;문제가 발생했습니다. 다시 시도하세요.&quot;;

  const { id, title, subTitle, description, author, publisher, coverImgUrl } =
    data;

  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<p>페이지 컴포넌트가 아직 <code>getStaticProps</code>의 계산 결과를 <code>props</code>로 받지 못한 상황을 fallback 상태라고 부른다.
<code>useRouter.isFallback</code>를 사용하여 fallback 상태에 따른 분기 처리를 할 수 있다.</p>
<pre><code class="language-tsx">export const getStaticProps = async (context: GetStaticPropsContext) =&gt; {
  const id = context.params!.id;
  const data = await fetchOneData(Number(id));

  if(!data){
    return {
      notFound: true,
    }
  }

  return {
    props: { data },
  };
};</code></pre>
<p>추가적으로 만약 존재하지 않는 데이터를 요청하는 페이지로 들어왔을 경우 <code>Not Found Page</code>를 보여주고 싶다면, <code>getStaticProps</code> 함수에서 <code>{ notFound: true }</code>를 반환해주면 된다.</p>
<h3 id="💵-증분-정적-재생성-isr">💵 증분 정적 재생성 (ISR)</h3>
<p>SSG 방식으로 생성된 정적 페이지를 일정 시간을 주기로 다시 생성하는 방식이다.
앞서 SSG 사전 렌더링 방식은 빌드 타임 이후에는 다시 생성하지 않기 때문에 매번 같은 페이지만 보여주는 문제가 있었다.
하지만 ISR 방식을 이용하면 SSG 방식으로 빌드 타입에 생성된 정적 페이지에 유통 기한을 설정할 수 있다.</p>
<p>60초로 설정했다면, 60초 전에는 빌드 타입에 생성한 페이지를, 후에는 원래 갖고 있는 페이지를 반환하고 새로운 페이지를 생성한다.</p>
<p>ISR 방식은 기본적으로 이미 만들어진 페이지를 반환하기 때문에 빠른 속도로 응답한다는 SSG 방식의 장점과 주기적으로 페이지를 업데이트 해줌으로써 최신 데이터를 반영해줄 수 있는 SSR 방식의 장점까지 갖고 있는 강력한 렌더링 전략이다.</p>
<pre><code class="language-tsx">export const getStaticProps = async (context: GetStaticPropsContext) =&gt; {
  const id = context.params!.id;
  const data = await fetchOneData(Number(id));

  if(!data){
    return {
      notFound: true,
    }
  }

  return {
    props: { data },
    revalidate: 3,
  };
};</code></pre>
<p><code>getStaticProps</code>의 반환값에 <code>revalidate: 유통 기한</code>을 넣어 반환하면 ISR 방식이 적용된다.</p>
<h3 id="💵-on-demand-isr">💵 On-Demand ISR</h3>
<p>ISR 방식은 SSG와 SSR 방식의 장점을 모두 갖고 있기 때문에 ISR 방식을 최대한 이용하는 것이 좋다.
하지만 시간과 관계없이 사용자의 행동으로 업데이트된 페이지는 ISR 방식을 적용하기 어렵다.</p>
<p>게시글 수정이나 삭제 등의 사용자 행동에 따라서 즉각적으로 업데이트가 필요한 게시글 페이지의 경우, ISR 방식으로 렌더링한다면 유통 기한 전에 수정한 경우 수정 전 페이지를 보게 된다.
추가로 60로 설정했지만 24시간 이후에 게시글을 수정한다면 불필요하게 페이지를 재생성하는 과정 또한 발생한다.</p>
<p>SSR로 해결한다면 요청마다 새롭게 렌더링하여 응답 시간이 느려지고 동시에 접속자가 많이 몰리면 서버 부하가 커진다.</p>
<p>Next.js는 기존의 ISR 방식이 아닌 요청을 기반으로 페이지를 업데이트 시킬 수 있는 새로운 ISR 방식을 제공한다.
요청을 받을 때마다 페이지를 다시 생성하는 방식을 <code>On-Demand ISR</code> 방식이라고 한다.</p>
<p>사용자가 게시글을 수정할 때마다 페이지 Revalidate 요청을 보내 페이지를 다시 생성할 수 있다.
<code>On-Demand ISR</code> 방식을 이용하면 대부분의 페이지를 최신 데이터로 유지하면서 정적 페이지로 처리할 수 있다.</p>
<pre><code class="language-tsx">export const getStaticProps = async (context: GetStaticPropsContext) =&gt; {
  const id = context.params!.id;
  const data = await fetchOneData(Number(id));

  if(!data){
    return {
      notFound: true,
    }
  }

  return {
    props: { data },
  };
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    await res.revalidate(&quot;/&quot;);
    return res.json({ revalidate: true });
  } catch (err) {
    res.status(500).send(&quot;Revalidation Faild&quot;);
  }
}</code></pre>
<p><code>getStaticProps</code> 함수를 작성하고 Revalidate 요청을 처리할 새로운 핸들러를 만들어 준다.
핸들러 함수 내부에서 <code>response.revalidate</code> 메서드의 인자로 재생성할 페이지 경로를 전달한다.
요청이 성공하면 <code>{ revalidate: true }</code>를 반환하여 재생성이 완료되었음을 알려준다.</p>
<p><code>On-Demand ISR</code> 방식은 거의 대부분의 케이스를 커버할 수 있는 사전 렌더링 방식이기 때문에 Next.js로 구축된 웹 서비스들에서 활발히 사용되고 있다고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[useEffect]]></title>
            <link>https://velog.io/@jang_expedition/useEffect</link>
            <guid>https://velog.io/@jang_expedition/useEffect</guid>
            <pubDate>Tue, 18 Mar 2025 07:22:02 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@jang_expedition/JWT-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EC%A7%80">지난 포스팅</a>에서 <code>useEffect</code>를 활용해 토큰을 갱신하는 로직을 구현하는 것이 페이지 이동마다 요청을 보내는 방식이라 적절하지 않다고 생각했다고 적었다.</p>
<p>하지만 라우트 가드에서 <code>useEffect</code>를 사용한다고 페이지 이동마다 요청이 실행되는 건 아니다.
의존성 배열을 빈 배열로 설정하면, 새로고할 때만 서버로 요청을 보내도록 할 수 있다.</p>
<p>이에 따라, 로컬 스토리지에 저장한 엑세스 토큰을 다시 axios의 헤더에 설정하도록 변경했다.
또한, 라우트 가드에서 새로고침 시에만 서버로 요청을 보내 엑세스 토큰을 갱신하도록 수정했다.
나는 왜 이런 오해를 했을까?</p>
<p>돌이켜보면, <code>useEffect</code>에 대해서 제대로 이해하지 못했기 때문이었다.
이번 기회에 <code>useEffect</code>를 제대로 정리하며 확실히 이해해보자.</p>
<h2 id="💰-side-effects">💰 Side Effects</h2>
<hr>
<p><code>useEffect</code>를 알아보기 전에 사이드 이팩트에 대해 먼저 이해해 보자.</p>
<p>리액트의 컴포넌트에서 사이드 이팩트란, 앱이 정상적으로 동작하기 위해 필요하지만, 현재 컴포넌트의 렌더링 과정에 직접적인 영향을 미치지는 않는 작업을 의미한다.</p>
<p>쉽게 말해, 컴포넌트의 주된 목적은 JSX를 반환하는 것이다.
이 목적과 직접적으로 관련이 없는 작업은 모두 사이드 이팩트라 볼 수 있다.</p>
<h3 id="💵-사이드-이팩트의-예시">💵 사이드 이팩트의 예시</h3>
<pre><code class="language-js">export default function App() {
    navigator.geolocation.getCurrentPosition((position) =&gt; {
        const { latitude, longitude } = position.coords;
        console.log(latitude, longitude);
    });

    return &lt;div&gt;&lt;/div&gt;;
}</code></pre>
<p>위 코드에서는 <code>navigator.geolocation.getCurrentPosition()</code>을 실행하여 사용자의 위치 정보를 가져온다.</p>
<p>사용자의 위치를 가져오는 작업은 애플리케이션이 정상적으로 동작하는 데 필요하지만, 컴포넌트의 렌더링과는 직접적인 관련이 없기 때문에 사이드 이팩트다.</p>
<p>즉, 위치 정보를 가져오는 작업은 렌더링을 위한 과정이 아니라 외부 환경(여기서는 브라우저)와 상호 작용에 해당하므로 사이드 이팩트라고 할 수 있다.</p>
<h3 id="💵-사이드-이팩트가-문제를-일으키는-경우">💵 사이드 이팩트가 문제를 일으키는 경우</h3>
<pre><code class="language-js">import { useState } from &quot;react&quot;;

export default function App() {
    const [position, setPosition] = useState({ latitude: 0, longitude: 0 });

    navigator.geolocation.getCurrentPosition((position) =&gt; {
        const { latitude, longitude } = position.coords;
        setPosition({latitude, longitude});
    });

    return (
        &lt;div&gt;
            &lt;p&gt;위도 {position.latitude}&lt;/p&gt;
            &lt;p&gt;경도 {position.longitude}&lt;/p&gt;
        &lt;/div&gt;
    );
}</code></pre>
<p>컴포넌트 내부에 사이드 이팩트가 있는 코드를 실행하는 것이 항상 문제되는 건 아니지만, 위와 같은 코드는 문제가 될 수 있다.</p>
<p>왜냐하면 무한 루프를 야기하기 때문이다.</p>
<ol>
<li><code>position</code>의 초기값은 <code>{ latitude: 0, longitude: 0 }</code>이다.</li>
<li><code>getCurrentPosition()</code>이 실행되면 사용자의 현재 위도와 경도를 가져와 <code>setPosition</code>으로 상태를 변경한다.</li>
<li>상태가 변경되면 컴포넌트가 리렌더링된다.</li>
<li>컴포넌트가 다시 렌더링되면서 <code>getCurrentPosition()</code>이 또 실행된다.</li>
<li>만약 사용자의 위치가 계속 바뀐다면, 이 과정이 끝없이 반복되면서 무한 루프가 발생할 수 있다.</li>
</ol>
<p>이처럼 사이드 이팩트를 적절히 관리하지 않으면 무한 루프와 같은 문제가 발생할 수 있다.
이러한 문제를 방지하기 위해 <code>useEffect</code>를 사용한다.</p>
<h3 id="💵-useeffect를-통한-해결">💵 useEffect를 통한 해결</h3>
<pre><code class="language-js">useEffect(() =&gt; {
    navigator.geolocation.getCurrentPosition((position) =&gt; {
        const { latitude, longitude } = position.coords;
        setPosition({latitude, longitude});
    });
}, [])</code></pre>
<p>위와 같이 <code>useEffect</code>의 첫 번째 인수로 전달하는 함수 내부에 사이드 이팩트에 해당하는 코드를 작성하면 된다.
기본적으로 함수 내부의 코드는 JSX가 모두 렌더링 된 이후에 실행된다.</p>
<p>하지만 두 번째 인수로 들어가는 배열을 생략한다면 다시 무한 루프에 빠질 위험이 커진다.
의존성 배열에 대해서는 뒤에 자세히 다룰 예정이니 지금은 빈 배열로 해줘야 한다는 것만 알고 넘어가자.</p>
<h3 id="💵-useeffect가-필요하지-않는-상황-1">💵 useEffect가 필요하지 않는 상황 (1)</h3>
<p>여기서 주의할 점이 있는데, 모든 사이드 이팩트가 <code>useEffect</code>를 필요로 하진 않는다는 점이다.
<code>useEffect</code>의 과한 사용은 좋지 않다.
<code>useEffect</code> 내부 로직은 컴포넌트가 실행된 이후 추가적으로 실행된다는 사실을 잊지 말아야 한다.</p>
<pre><code class="language-js">import { useState, useEffect } from &quot;react&quot;;

export default function App() {
    const [position, setPosition] = useState({ latitude: 0, longitude: 0 });

    const onClickCapture = () =&gt; {
        const userLats = JSON.parse(localStorage.getItem(&quot;userLats&quot;)) || [];    
        if (!userLats.includes(position.latitude)) {
            localStorage.setItem(
                &quot;userLats&quot;,
                JSON.stringify([position.latitude, ...userLats])
            );
        }
    };

    useEffect(() =&gt; {
        navigator.geolocation.getCurrentPosition((position) =&gt; {
            const { latitude, longitude } = position.coords;
            setPosition({latitude, longitude});
        });
    }, [])

    return (
        &lt;div&gt;
            &lt;p&gt;위도 {position.latitude}&lt;/p&gt;
            &lt;p&gt;경도 {position.longitude}&lt;/p&gt;
            &lt;button onClick={onClickCapture}&gt;Capture&lt;/button&gt;
        &lt;/div&gt;
    );
}</code></pre>
<p>로컬 스토리지에 사용자의 현재 위도를 저장하는 로직은 JSX 렌더링과 직접적인 연관이 없으므로 사이드 이팩트다.</p>
<p>하지만 <code>getCurrentPosition()</code>과 달리 <code>useEffect</code>로 묶어줄 필요가 없다.
추가로 <code>onClickCapture</code> 함수 내부에 있기 때문에 <code>useEffect</code>를 사용할 수도 없다.
일반 함수 내부에서 사용하는 건 훅 사용 규칙을 어기기 때문이다.</p>
<p>훅 사용 규칙을 떠나, 현재는 로컬 스토리지에 데이터를 저장하는 역할만 하므로 <code>useEffect</code>가 필요하지 않다. 또한, 상태를 변경하더라도 무한 루프에 빠지지 않는다.
왜냐하면 컴포넌트의 라이프 사이클과 무관하게 사용자가 버튼을 클릭했을 때 로직이 실행되기 때문이다.</p>
<p><code>useEffect</code>는 주로 무한 루프를 방지하거나, 컴포넌트가 최소 한 번 렌더링된 후 실행해야 하는 코드가 있을 때 사용한다.</p>
<h3 id="💵-useeffect가-필요하지-않는-상황-2">💵 useEffect가 필요하지 않는 상황 (2)</h3>
<pre><code class="language-js">import { useState, useEffect } from &quot;react&quot;;

export default function App() {
    const [position, setPosition] = useState({ latitude: 0, longitude: 0 });
    const [savedLats, setSavedLats] = useState([]);

    const onClickCapture = () =&gt; {
        const storedLats = JSON.parse(localStorage.getItem(&quot;userLats&quot;)) || [];

        if (!storedLats.includes(position.latitude)) {
            const updatedLats = [position.latitude, ...storedLats];
            localStorage.setItem(&quot;userLats&quot;, JSON.stringify(updatedLats));
            setSavedLats(updatedLats);
        }
    };

    useEffect(() =&gt; {
        const storedLats = JSON.parse(localStorage.getItem(&quot;userLats&quot;)) || [];
        setSavedLats(storedLats);
    });

    useEffect(() =&gt; {
        navigator.geolocation.getCurrentPosition((position) =&gt; {
            const { latitude, longitude } = position.coords;
            setPosition({ latitude, longitude });
        });
    }, []);

    return (
        &lt;div&gt;
            {savedLats.map((lat) =&gt; (
                &lt;p&gt;{lat}&lt;/p&gt;
            ))}
            &lt;p&gt;위도 {position.latitude}&lt;/p&gt;
            &lt;p&gt;경도 {position.longitude}&lt;/p&gt;
            &lt;button onClick={onClickCapture}&gt;Capture&lt;/button&gt;
        &lt;/div&gt;
    );

}</code></pre>
<p>저장한 위도들을 화면에 보여주기 위해 로컬 스토리지에 저장한 위도 배열을 가져와 <code>savedLats</code>를 업데이트 한다고 했을 때, <code>useEffect</code>를 사용하는 건 어찌보면 자연스러워 보인다.
실제로 코드를 실행해도 문제없이 동작한다.</p>
<p>하지만 위 코드는 불필요하게 사용된 <code>useEffect</code>의 예시다.</p>
<p>기존의 <code>getCurrentPosition()</code>과 로컬 스토리지 접근의 차이점은 실행 방식과 결과가 반환되는 시점이다.</p>
<p><code>getCurrentPosition()</code>은 비동기적으로 동작하며, 호출 즉시 결과를 반환하지 않고 브라우저가 사용자의 위치 정보를 가져오는 과정이 끝나야 콜백 함수가 실행된다.
반면, 로컬 스토리지에 데이터를 가져오는 작업은 동기적으로 실행되며, 데이터를 가져오는데 시간이 걸리지 않는다.
즉, 로컬 스토리지에서 위도 배열을 가져오는 코드는 렌더링 중에도 즉시 값을 가져 올 수 있기 때문에 <code>useEffect</code>를 사용할 필요가 없다.</p>
<pre><code class="language-js">import { useState, useEffect } from &quot;react&quot;;

const storedLats = JSON.parse(localStorage.getItem(&quot;userLats&quot;)) || [];

export default function App() {
    const [position, setPosition] = useState({ latitude: 0, longitude: 0 });
    const [savedLats, setSavedLats] = useState(storedLats);

    const onClickCapture = () =&gt; {
        const storedLats = JSON.parse(localStorage.getItem(&quot;userLats&quot;)) || [];

        if (!storedLats.includes(position.latitude)) {
            const updatedLats = [position.latitude, ...storedLats];
            localStorage.setItem(&quot;userLats&quot;, JSON.stringify(updatedLats));
            setSavedLats(updatedLats);
        }
    };

    useEffect(() =&gt; {
        navigator.geolocation.getCurrentPosition((position) =&gt; {
            const { latitude, longitude } = position.coords;
            setPosition({ latitude, longitude });
        });
    }, []);

    return (
        &lt;div&gt;
            {savedLats.map((lat) =&gt; (
                &lt;p&gt;{lat}&lt;/p&gt;
            ))}
            &lt;p&gt;위도 {position.latitude}&lt;/p&gt;
            &lt;p&gt;경도 {position.longitude}&lt;/p&gt;
            &lt;button onClick={onClickCapture}&gt;Capture&lt;/button&gt;
        &lt;/div&gt;
    );

}</code></pre>
<p>따라서 <code>useEffect</code>를 걷어내고 무한 루프에 빠질 수 있는 <code>setSavedLats</code>를 없앤다.
다음으로 컴포넌트 밖에서 위도 배열을 받아와 <code>savedLats</code>의 초기값으로 넣어줌으로써 해결할 수 있다.</p>
<p>변수를 컴포넌트 바깥에 선언한 이유는 컴포넌트의 라이프 사이클이 아닌 애플리케이션의 라이프 사이클에서 단 한 번만 실행되도록 하기 위해서다.
앱 컴포넌트가 실행될 때마다 로컬 스토리지에서 가져올 필요가 없기 때문이다.</p>
<h2 id="💰-리액트-컴포넌트의-라이프-사이클">💰 리액트 컴포넌트의 라이프 사이클</h2>
<hr>
<p>라이프 사이클을 직역하면 생애 주기로 탄생부터 죽음까지를 단계별로 나눠논 것이다.
리액트의 라이프 사이클은 <code>Mount</code>, <code>Update</code>, <code>UnMount</code> 3단계로 구분된다.</p>
<ul>
<li><strong>Mount</strong><ul>
<li>컴포넌트가 탄생하는 순간</li>
<li>화면에 처음 렌더링 되는 순간</li>
</ul>
</li>
<li><strong>Update</strong><ul>
<li>컴포넌트가 다시 렌더링 되는 순간</li>
<li>리렌더링 될 때를 의미</li>
</ul>
</li>
<li><strong>UnMount</strong><ul>
<li>컴포넌트가 화면에서 사라지는 순간</li>
<li>렌더링에서 제외되는 순간을 의미</li>
</ul>
</li>
</ul>
<p>라이프 사이클을 잘 알고 있다면 원하는 타이밍에 원하는 작업을 수행하게 할 수 있다.</p>
<h2 id="💰-useeffect">💰 useEffect</h2>
<hr>
<p>앞서 살펴 본대로 <code>useEffect</code>란 컴포넌트의 <strong>사이드 이팩트</strong>를 제어하는 리액트 훅이다.</p>
<pre><code class="language-js">import { useEffect, useState } from &quot;react&quot;;

export default function App() {
    const [count, setCount] = useState(0);
    const onClickButton = () =&gt; {
        setCount(count + 1);
        console.log(`onClick: ${count}`);
    };

    useEffect(() =&gt; {
        console.log(`useEffect: ${count}`);
    }, [count]);

    return (
        &lt;div&gt;
            &lt;span&gt;{count}&lt;/span&gt;
            &lt;button onClick={onClickButton}&gt;+&lt;/button&gt;
        &lt;/div&gt;
    );
}</code></pre>
<p>위와 같은 코드가 있다고 했을 때 버튼을 클릭하면 <code>onClickButton</code>, <code>useEffect</code>에서 콘솔에 출력한 결과가 다르게 나온다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/b22cb0a0-084e-4113-8911-89a6714e786d/image.png" alt=""></p>
<p><code>onClickButton</code>에서 출력한 결과는 <code>count</code>에서 1 증가한 값이 아닌 이전 값이 나온다.
이유는 <code>setCount</code>가 비동기로 작동하기 때문이다.</p>
<p>위와 같은 이유때문에 <code>count</code>가 변경된 시점에 무언가를 하고 싶다면 <code>useEffect</code>의 의존성 배열에 <code>count</code>를 넣어서 처리할 수 있다.</p>
<p>의존성 배열은 선택 사항으로 각각의 의존성들은 <code>Object.is</code>로 이전 값과 비교한다.</p>
<h3 id="💵-마운트-시점">💵 마운트 시점</h3>
<pre><code class="language-js">useEffect(() =&gt; {
    // 마운트 될 때 실행할 로직
}, []);</code></pre>
<p>앞서 마운트 시점은 최초 렌더링 시점이라고 얘기했다.
최초에 렌더링되는 시점에 무언가 실행하고 싶다면 의존성 배열에 빈 배열을 넣어주면 된다.
앞에 최초가 붙은 이유는 리렌더링할 때는 실행되지 않기 때문이다.</p>
<pre><code class="language-js">import { useEffect, useState } from &quot;react&quot;;

export default function App() {
    const [count, setCount] = useState(0);
    const onClickButton = () =&gt; {
        setCount(count + 1);
    };

    useEffect(() =&gt; {
        console.log(&quot;mount!!!&quot;);
    }, []);

    return (
        &lt;div&gt;
            &lt;span&gt;{count}&lt;/span&gt;
            &lt;button onClick={onClickButton}&gt;+&lt;/button&gt;
        &lt;/div&gt;
    );
}</code></pre>
<p>따라서 위와 같이 작성하면 <code>count</code>값이 변경되어 리렌더링 되더라도 콘솔에 <code>mount!!!</code>는 최초에 한 번만 출력된다.</p>
<p>라우트 가드에서 <code>useEffect</code>를 사용해서 서버에 요청을 보내면 페이지 이동마다 요청을 보낼 거라고 오해했던 이유가 마운트 시점과 리렌더링을 헷갈렸기 때문이다.</p>
<p>기본적으로 리액트에서 컴포넌트가 리렌더링되는 시점은 <code>props</code>와 <code>state</code>가 변경되거나 부모 컴포넌트가 리렌더링 될 때이다.
하지만 <code>useEffect</code>의 의존성 배열에 빈 배열을 넣어준다면 <code>count</code>라는 상태가 변경되어 리렌더링 되더라도 다시 실행되지 않는다.</p>
<h3 id="💵-업데이트-시점">💵 업데이트 시점</h3>
<pre><code class="language-js">useEffect(() =&gt; {
    // 업데이트마다 실행할 로직
});</code></pre>
<p>업데이트 시점이란 리렌더링 시점을 의미한다.
만약 의존성 배열을 넣지 않는다면 리렌더링할 때마다 실행된다.</p>
<p>부모로 부터 받은 <code>props</code>의 값이 변해서 리렌더링 될 때, 내부적으로 사용하는 상태값이 변경돼서 리렌더링될 때만 어떤 로직을 수행하고 싶으면 의존성 배열에 해당 <code>props</code>와 <code>state</code>를 넣어주면 된다.</p>
<h3 id="💵-언마운트-시점">💵 언마운트 시점</h3>
<pre><code class="language-js">useEffect(() =&gt; {
    // 클린업, 정리 함수
    return () =&gt; {};
}, [])</code></pre>
<p>언마운트 시점은 컴포넌트가 화면에서 사라지는 순간을 의미한다.
<code>useEffect</code>의 콜백 함수 안에서 반환하는 함수를 클린업 또는 정리 함수라고 부른다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT 어디에 저장하지?]]></title>
            <link>https://velog.io/@jang_expedition/JWT-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EC%A7%80</link>
            <guid>https://velog.io/@jang_expedition/JWT-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EC%A7%80</guid>
            <pubDate>Wed, 12 Mar 2025 07:57:34 GMT</pubDate>
            <description><![CDATA[<h2 id="💰-발단">💰 발단</h2>
<hr>
<p><a href="https://velog.io/@jang_expedition/%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-JWT">지난 포스팅</a>에서 JWT을 이용한 로그인에 대해서 알아보고 개인 프로젝트에 적용했다.
로그인이 성공하면,</p>
<ul>
<li>리프래시 토큰은 서버에서 HttpOnly 쿠키에 저장한다.</li>
<li>엑세스 토큰은 <code>axios</code> 전역 헤더에 저장한다.</li>
</ul>
<p>이후의 요청에 대해서 토큰이 없거나, 토큰이 만료되면 서버로부터 401 에러를 반환받는다.
401 에러가 발생하면 토큰을 재발급하는 인터셉터를 만들었다.
추가로 로그인된 사용자가 로그인 화면에 접근하지 못하게 라우트 가드를 만들었다.</p>
<p>하지만 새로고침을 하게 되면 토큰이 날아가는 문제가 생겼다...!
엑세스 토큰이 날아가면서 로그인 상태가 유지되지 않는 것이다!</p>
<p>HTTP 헤더에 저장하게 되면 만사가 형통할 줄 알았는데, 해당 값은 자바스크립트 메모리에 저장되어 새로고침을 하게 되면 날아간다는 사실을 알았다.</p>
<blockquote>
<p>인터셉터를 만들었음에도 왜 새로고침 시에 토큰을 다시 가져오지 못하는 걸까?</p>
</blockquote>
<p>이유는 간단했다.
새로고침을 해도 서버로 API 요청을 보내지 않기 때문이다.
서버로 요청을 보내야 토큰을 검사해서 401 에러를 반환하고 재발급 과정이 이뤄지는데 새로고침만으로는 서버로 요청을 보내지 않는다.</p>
<p>어떻게 해결할 수 있을까?
내가 생각한 해결 방향은 세 가지였다.</p>
<ol>
<li>라우트 가드에서 <code>useEffect</code>를 사용해서 컴포넌트가 마운트 됐을 때 서버에 요청을 보낸다.</li>
<li>라우트 가드에서 로그인 유무를 판별하는 다른 기준을 만든다.</li>
<li>서버로부터 받은 엑세스 토큰을 로컬 스토리지에 저장한다.</li>
</ol>
<h3 id="💵-해결책-1-라우트-가드에서-useeffect-활용">💵 해결책 1: 라우트 가드에서 <code>useEffect</code> 활용</h3>
<p>라우트 가드에 <code>useEffect</code>를 사용해서 컴포넌트가 마운트 됐을 때 서버로 사용자 정보를 요청한다.
서버에서는 리프래시 토큰을 확인하고 엑세스 토큰을 재발급한다.</p>
<pre><code class="language-jsx">useEffect(() =&gt; {
    fetchUserData(); // 서버에 사용자 정보 요청
}, [])</code></pre>
<p>하지만 &#39;내가 만들려는 프로젝트는 로그인 기반이기 때문에 모든 페이지마다 요청을 보내는 게 좋을까?&#39;라는 의문이 생겼다.</p>
<p>벼룩을 잡으려고 초가삼간을 태우는 느낌이랄까...</p>
<h3 id="💵-해결책2-라우트-가드에서-로그인-유무를-판별하는-다른-기준을-만든다">💵 해결책2: 라우트 가드에서 로그인 유무를 판별하는 다른 기준을 만든다.</h3>
<p>라우트 가드에서 토큰 유무가 아니라 다른 방법으로 로그인 유무를 확인하면 어떨까?</p>
<p>생각은 그럴싸했지만 어쨌든 토큰이 아닌 어떤 정보를 기준으로 판별하게 될텐데, 그 정보는 어디에 담지?
어차피 똑같은 짓이구나?!</p>
<h3 id="💵-해결책3-로컬-스토리지에-저장한다">💵 해결책3: 로컬 스토리지에 저장한다.</h3>
<p>스토리지에 저장하는 건 피하고 싶었다.
로컬 스토리지에 저장하자니 왠지 모를 거부감이 들었기 때문이다.
아무래도 개발자 도구를 켜서 손쉽게 삭제할 수 있기 때문인가?!</p>
<p>돌이켜보면 왠지 모를 거부감도 왠지 모르게 거부감이 든다.
뭐가 나쁜지도 모르면서 거부감만 표하는 것 같아서다.</p>
<p>이번 기회에 토큰 저장 위치에 따른 장단점을 정리해야겠다고 생각했다.</p>
<h2 id="💰--토큰-탈취-방법">💰  토큰 탈취 방법</h2>
<hr>
<p>지피지기 백전백승!
토큰을 탈취하는 방법을 알아야 지키는 방법도 알 수 있지 않을까?!</p>
<h3 id="💵-xsscross-site-scripting">💵 XSS(Cross-Site-Scripting)</h3>
<p>XSS는 공격자가 웹사이트에 악의적인 스크립트를 넣어 방문자의 브라우저에서 실행되도록 유도하는 방법이다.
이를 통해 사용자의 쿠키, 세션 토큰, 개인정보 등을 탈취할 수 있다.</p>
<p>XSS 공격은 크게 세 가지 유형이 있다.</p>
<h4 id="1-저장형-xss-stored-xss">1. 저장형 XSS (Stored XSS)</h4>
<p>공격자가 악의적인 스크립트를 웹사이트의 데이터베이스(게시글, 댓글) 등에 저장하고, 다른 사용자가 이를 조회할 때 스크립트가 실행되는 방식이다.</p>
<p>예를 들어, 사용자가 블로그에 댓글을 작성할 때, 입력값을 검증하거나 이스케이프 처리하지 않고 그대로 데이터베이스에 저장한다면, 공격자는 <code>&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</code>와 같은 스크립트를 댓글에 입력할 수 있다.</p>
<p>이 댓글을 읽는 다른 사용자의 브라우저에서는 저장된 스크립트가 실행되어 공격자가 의도한 동작이 발생한다.</p>
<h4 id="2-반사형-xss-reflected-xss">2. 반사형 XSS (Reflected XSS)</h4>
<p>공격자가 조작한 데이터를 웹 서버에 전송하면, 서버가 그 데이터를 바로 응답에 포함시켜 사용자에게 전달하는 경우 발생한다.
주로 검색어, 에러 메세지, URL 파라미터 등에서 나타난다.</p>
<p>예를 들어, 검색 기능이 있는 웹사이트에서 사용자가 입력한 검색어를 별도의 검증 없이 결과 페이지에 출력한다고 했을 때, 공격자가 URL에 <code>https://example.com/search?q=&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;</code>와 같이 스크립트를 포함시켜 링크를 만들어 이메일이나 메세지로 배포할 수 있다.</p>
<p>사용자가 해당 URL을 클릭하면, 서버는 사용자의 입력값을 그대로 결과 페이지에 출력하고, 브라우저는 악의적인 스크립트를 실행하게 된다.</p>
<h4 id="3-dom-기반-xss-dom-based-xss">3. DOM 기반 XSS (DOM-based XSS)</h4>
<p>DOM 기반 XSS는 클라이언트 측 자바스크립트 코드에서 사용자 입력(URL의 해시, 쿼리스트링 등)을 안전하게 처리하지 않을 때 발생한다.
이 경우 서버는 문제가 없으며, 브라우저 내에서 DOM 조작 과정에서 취약점이 발생한다.</p>
<p>예를 들어, 자바스크립트로 URL의 해시 값을 읽어 페이지 일부에 동적으로 삽입하는 경우에 URL이 <code>https://example.com/#&lt;img src=x onerror=alert(&quot;XSS&quot;)&gt;</code>와 같이 구성되어 있다면, 클라이언트 측 스크립트가 이 해시 값을 DOM에 그대로 삽입할 경우, 브라우저는 삽입된 <code>img</code> 태그의 <code>onerror</code> 이벤트를 실행해 스크립트를 실행할 수 있다.</p>
<p>얼마 전, 같이 항해를 했던 팀원분께서 <a href="%5Bhttps://youtu.be/BR8p4llfl64?si=Tvq_7kAX9-TkaEBm%5D(https://youtu.be/BR8p4llfl64?si=Tvq_7kAX9-TkaEBm" title="https://youtu.be/BR8p4llfl64?si=Tvq_7kAX9-TkaEBm">해당 내용과 관련한 영상</a>)을 보여준 적이 있다.
스크립트 태그로 DOM 조작이 가능하니, CNN 사이트에서 카지노 사이트를 홍보하는 기사를 넣는 식의 내용이 있었다.
XSS로 정보를 탈취하는 것뿐만 아니라 가짜 뉴스를 만든다거나 할 수도 있다는 뜻이다.</p>
<h4 id="방지-방법">방지 방법</h4>
<p>XSS 공격에 대응하기 위해서는 </p>
<ul>
<li>사용자로 부터 입력받은 데이터를 반드시 신뢰할 수 없는 값으로 간주하고 검증 및 필터링하는 방법.</li>
<li>사용자 입력을 HTML에 출력할 때 반드시 이스케이프 처리를 하여 코드로 인식하지 않고 단순 텍스트로 표시하도록 한다.
프론트엔드에서 주로 사용하는 리액트, 앵귤러, 뷰 등에서는 기본적으로 XSS 공격에 대해 어느 정도의 보호를 해준다고 한다.
리액트를 처음 공부할 때는 선언형으로 바꿔줘 개발할 때 편리하게 해주는 도구 정도로 생각했는데, 알면 알 수록 여러 귀찮은 일들을 대신 해주는 구나 싶다.</li>
</ul>
<p>추가로 CSP(Content Security Policy)가 있다.
CSP는 웹사이트에서 실행 가능한 리소스(스크립트, 스타일, 이미지 등)의 출처를 명시적으로 제한하여, 악의적인 콘텐츠가 실행되는 것을 방지한다.</p>
<p>아무튼 토큰 관점에서 보면 HttpOnly 쿠키를 사용한다.
HttpOnly 쿠키는 웹 애플리케이션에서 민감한 정보를 저장할 때 사용하는 쿠키의 한 종류로 클라이언트 측 스크립트에서 접근할 수 없도록 설정된 쿠키다.</p>
<p>스크립트에서 접근할 수는 없지만, 브라우저가 서버로 HTTP 요청을 보낼 때만 자동으로 전송된다.
따라서 클라이언트 측에서 쿠키 값을 기반으로 하는 로직(로그인 유무 검사)를 구현할 때는 별도의 API를 통해 서버에서 인증 정보를 받아와야 한다.</p>
<p>HTTPS를 사용하지 않으면 쿠키의 전송 도중에 도청 위험이 있기 때문에 <code>secure</code> 옵션을 함께 사용하는 것이 좋다.
<code>secure</code> 옵션은 쿠키를 HTTPS와 같은 안전한 연결에서만 전송하도록 제한하는 속성이다.</p>
<p>리프래쉬 토큰을 HttpOnly 저장하는 이유가 XSS 공격을 방지하기 위해서다.
클라이언트에서 사용하지 않으면서, 엑세스 토큰 재발급을 위해 서버에서 사용되므로.</p>
<h3 id="💵-csrfcross-site-request-forgery">💵 CSRF(Cross-Site Request Forgery)</h3>
<p>CSRF는 사용자가 의도하지 않은 요청을 악의적인 사이트를 통해 다른 사이트에 보내도록 만드는 공격 방식이다.
이를 통해 공격자가 피해자의 브라우저를 이용해 피해자가 이미 로그인한 사이트에 원하지 않는 요청(계좌 이체, 비밀번호 변경 등)을 실행시키게 만든다.</p>
<h4 id="csrf-동작-방식">CSRF 동작 방식</h4>
<ul>
<li><strong>사용자 인증</strong>: 피해자가 신뢰할 수 있는 사이트(은행 등)에 로그인하면, 브라우저에는 인증 정보를 담은 쿠키가 저장된다.</li>
<li><strong>악의적인 사이트 방문</strong>: 피해자가 악의적인 사이트를 방문하면, 이 사이트에 포함된 스크립트나 링크를 통해 자동으로 요청이 전송된다.</li>
<li><strong>쿠키 자동 전송</strong>: 브라우저는 동일한 도메인에 대한 요청 시 자동으로 인증 쿠키를 포함하므로, 피해자의 인증 정보가 포함된 요청을 보낸다.</li>
</ul>
<h4 id="방지-방법-1">방지 방법</h4>
<p>CSRF 공격에 대응하기 위해서는</p>
<ul>
<li>CSRF 토큰을 발급하여 쿠키가 아닌 클라이언트에서 관리하도록 한다.</li>
<li>SameSite 쿠키 속성을 <code>Strict</code>또는 <code>Lax</code>로 설정하면, 타 도메인에서 오는 요청에 쿠키가 자동 전송되지 않도록 할 수 있다.</li>
<li>요청 헤더의 <code>Referer</code>나 <code>Origin</code>을 검사하여, 요청이 신뢰할 수 있는 출처에서 온 건지 확인할 수 있다.</li>
<li>민감한 동작 전에 OTP, 2차 인증 등을 요구한다.</li>
</ul>
<h2 id="💰--개인적인-결론">💰  개인적인 결론</h2>
<hr>
<p>CSRF에 대해서 알게 되니 엑세스 토큰은 로컬 스토리지에 저장하는 것이 좋다고 생각했다.
CSRF 공격을 받았을 때 쿠키에 엑세스 토큰과 리프래쉬 토큰이 모두 들어있다면 모든 요청이 가능해지기 때문이다.
인피니티 스톤이 한 곳에 있었으면 어벤져스가 결성되기도 전에 인구의 반이 사라질 수도 있으니까?!</p>
<p>애초에 JWT가 탈취됐을 경우 위험을 최소화하기 위해 엑세스 토큰과 리프래쉬 토큰으로 나눈 건데 모두 쿠키에 저장한다면 자체가 둘로 나눈 의미를 무색하게 만드는 게 아닌가 생각한다.</p>
<p>아무튼 새로고침 시에 로그인이 유지가 되지 않는 문제는 라우트 가드에서 로컬 스토리지에 저장된 토큰의 유무에 따라 판단하도록 로직을 수정했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인증, 인가 그리고 JWT]]></title>
            <link>https://velog.io/@jang_expedition/%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-JWT</link>
            <guid>https://velog.io/@jang_expedition/%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-JWT</guid>
            <pubDate>Fri, 07 Mar 2025 08:59:51 GMT</pubDate>
            <description><![CDATA[<p>이번에 개인 프로젝트에 로그인, 회원가입 등을 구현하면서 내용을 정리했다.</p>
<p>인증(Authentication)이란 보통 아이디, 비밀번호를 통해서 도메인에 등록된 사용자임을 확인하는 과정이다.
회원가입한 뒤 로그인하는 과정이라고 볼 수 있다.</p>
<p>인가(Authorization)란 한 번 인증된 사용자에 대해서 권한을 검증하는 과정을 의미한다.
대부분의 서비스가 로그인한 뒤 로그아웃 전까지 다시 로그인을 하지 않아도 서비스의 기능을 사용할 수 있도록 인가 과정을 거친다.</p>
<p><strong>쉽게 얘기하면 인증은 누구인가를 확인하고 인가는 무엇을 할 수 있는가를 결정한다.</strong></p>
<p>전통적으로는 세션-쿠키 방식이 사용됐지만 최근에는 JWT(JSON Web Token)를 많이 사용하는 추세인 것 같다.
JWT란 토큰 기반의 인증/인가 기술이다.</p>
<p>우선적으로 얘기할 건 세션-쿠키 방식이 오래된 건 맞지만 뒤쳐지는 기술은 아니다.
각각의 장단점이 있고 서비스의 성격에 맞춰 선택하면 된다.</p>
<p>그렇다면 JWT는 세션-쿠키와 어떤 점이 다르고 왜 JWT가 주로 사용될까?</p>
<h3 id="💵-세션-쿠키-방식">💵 세션-쿠키 방식</h3>
<p>세션-쿠키 방식에서 로그인을 하면 서버는 메모리에 세션 정보를 저장하고 세션 ID를 브라우저 쿠키에 전달한다.
그 이후에 온 요청에 대해서 세션 ID를 메모리의 세션 정보와 대조하는 과정을 통해 인가 처리를 했다.</p>
<p>하지만 세션 정보를 메모리에 저장하기 때문에 따라오는 문제점들이 있다.</p>
<ol>
<li><strong>서버 재부팅 문제</strong>: 에러 발생 혹은 서버 재부팅 시에 메모리 내용이 삭제되어 세션 정보가 사라질 수 있다.</li>
<li><strong>분산 서버 환경 문제</strong>: 여러 서버를 갖고 있을 경우, 세션 정보를 공유하기가 어려워 인가 처리가 복잡해진다.</li>
<li><strong>데이터 베이스/캐시 사용의 한계</strong>: 위 문제를 해결하고자 세션 정보를 데이터 베이스에 저장하면 속도가 느리고, Redis와 같은 캐시를 사용하더라도 관리가 복잡하다는 문제가 있다.</li>
</ol>
<p>예전에 &#39;클라이언트는 상태를 잘 관리하는 게 목적이고 서버는 상태를 갖지 않도록 하는 게 목적이다.&#39;라는 말을 어디서 들은 것 같은데... 갑자기 생각났다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/aeefe9c4-7a87-4b50-8212-4739aa90d61b/image.png" alt=""></p>
<p>아무튼 이런 문제때문에 서버에서 관리하지 않고 클라이언트에 전달하여 서버측의 부담을 줄일 수 있게 나온 방법이 JWT다.</p>
<h3 id="💵-jwt-인증인가-프로세스">💵 JWT 인증/인가 프로세스</h3>
<p>JWT에 대해서 자세하게 알아보기 전에 인증/인가 프로세스가 어떻게 되는지 먼저 알아보자.</p>
<ol>
<li>회원가입을 하게 되면 서버에서 비밀번호를 암호화하여 데이터베이스에 저장한다.</li>
<li>이후에 로그인을 하면 아이디, 비밀번호 등을 검증한다.</li>
<li>검증이 완료되면 엑세스 토큰 및 리프레시 토큰을 생성한다.</li>
<li>생성한 토큰을 클라이언트에 전달하면 회원만 볼 수 있는 리소스를 요청할 때 발급받은 토큰을 사용하여 요청한다.</li>
</ol>
<h3 id="💵-회원가입">💵 회원가입</h3>
<p>프로세스를 코드로 따라가보자.
프로젝트의 기술 스택은 <code>React</code>와 <code>Nest</code>를 사용했다.</p>
<pre><code class="language-ts">import { UserSignupRequest, UserResponse } from &quot;@/types&quot;;

export const signUp = async (requestData: UserSignupRequest): Promise&lt;UserResponse&gt; =&gt; {
  const response = await fetch(&quot;http://localhost:3000/auth/signup&quot;, {
    method: &quot;POST&quot;,
    headers: {
      &quot;Content-Type&quot;: &quot;application/json&quot;,
    },
    body: JSON.stringify(requestData),
  });

  if (!response.ok) {
    throw Error(&quot;회원가입 실패&quot;);
  }

  return response.json();
};</code></pre>
<p>클라이언트에서 보내는 회원가입 요청이다.
<code>requestData</code>에는 간단하게 <code>email</code>, <code>name</code>, <code>password</code>만 넣었다.</p>
<pre><code class="language-ts">async signup(createUserDto: CreateUserDto) {
  const { email, name, password } = createUserDto;

  const user = await this.userRepository.findOne({ where: { email } });

  if (user) {
    throw new BadRequestException(&#39;이미 가입한 이메일입니다.&#39;);
  }

  const hash = await bcrypt.hash(
    password,
    this.configService.get&lt;number&gt;(&#39;HASH_ROUNDS&#39;) as number,
  );

  await this.userRepository.save({ email, name, password: hash });

  return this.userRepository.findOne({ where: { email } });
}</code></pre>
<p>서버에서 <code>controller</code> 로직은 생략하고 <code>service</code> 로직만 살펴보면 클라이언트로 부터 받은 정보를 받아 <code>email</code> 중복 검사, 비밀번호 암호화, 데이터 베이스에 저장한 뒤 가입된 사용자를 반환한다.</p>
<p>이때, 사용자의 모든 정보를 반환하면 비밀번호 또한 반환되기 때문에 응답에 실어보내지 않도록 처리를 해줘야 한다.</p>
<p>Nestjs에서는 Entity의 <code>password</code> 필드에 <code>@Exclude({toPlainOnly: true,})</code> 옵션을 넣어줘서 응답에 포함되지 않도록 설정할 수 있다.</p>
<h3 id="💵-jwt-구조">💵 JWT 구조</h3>
<p>로그인으로 넘어가기 전에 JWT에 대해 설명하면,</p>
<pre><code class="language-json">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30</code></pre>
<p>JWT 토큰은 위와 같이 생겼다.
<code>.</code>을 기준으로 헤더, 페이로드, 서명 세 부분으로 이뤄져 있다.</p>
<ul>
<li><strong>헤더(Header)</strong><ul>
<li>토큰의 타입과 암호화 알고리즘 정보를 담고 있다.</li>
<li>type: 토큰의 타입(JWT 고정)</li>
<li>alg: 서명 생성에 사용될 암호화 알고리즘(예: HS256)</li>
</ul>
</li>
<li><strong>페이로드(Payload)</strong><ul>
<li>사용자의 정보가 담겨 있다.</li>
<li>암호화되지 않으므로 민감한 정보는 포함하지 말아야 한다.</li>
</ul>
</li>
<li><strong>서명(Signature)</strong><ul>
<li>서명은 헤더와 페이로드, 서버에 저장된 비밀 키를 암호화하여 생성된 값이다.</li>
</ul>
</li>
</ul>
<h3 id="💵-jwt의-한계">💵 JWT의 한계</h3>
<p>처음에 세션-쿠키 방식을 보면서 서버측의 부담을 줄이기 위해 나온 방법이라고 했지만 더 나은 방법이 아닌 서비스의 성격에 맞게 선택하는 문제라고 얘기했다.</p>
<p>JWT는 서버측에서 세션 정보를 저장, 관리하지 않도록 하는 장점을 가지고 있지만 서버에서 저장하지 않기 때문에 오는 단점 또한 갖고 있다.</p>
<p>서버에 세션 정보를 저장하고 잘 관리될 경우, 서버측에 제어권이 있다는 말과 같다.
제어권이 있으면, 서버에서 의도적으로 세션 정보를 삭제하여 로그인을 해지할 수 있다.
하지만 JWT는 서버에 정보를 갖고 있지 않기 때문에 서버에서 제어할 수 없다.</p>
<ul>
<li><strong>동시 제어 접속</strong>: 기기의 갯수 제한이 있는 서비스일 경우, 모바일에서 접속한 사용자가 데스크톱으로 접속하였을 때, 세션-쿠키 방식에서는 기존 세션을 종료할 수 있지만 JWT는 할 수 없다.</li>
<li><strong>토큰 탈취 문제</strong>: JWT 토큰이 탈취되면 서버에서 즉시 토큰을 무효화시킬 수 없다.</li>
</ul>
<h3 id="💵-보완-방안-access-token과-refresh-token">💵 보완 방안: Access Token과 Refresh Token</h3>
<p>단점을 보완하는 방법으로 수명이 짧은 엑세스 토큰과 수명이 긴 리프레시 토큰을 발급하는 방법이 있다.</p>
<p>서버에서는 두 가지 토큰을 만들고 리프레시 토큰의 상응값을 캐싱하거나 데이터베이스에 저장한다.
사용자의 엑세스 토큰이 수명이 다하면 리프레시 토큰을 대조하여 새 엑세스 토큰을 발급한다.</p>
<p>이 과정을 통해 엑세스 토큰이 탈취될 경우 리프레시 토큰을 제거하여 엑세스 토큰을 재발급하지 못하게 막을 수도 있다.
하지만 엑세스 토큰이 살아있는 동안 차단할 수 있는 방법이 없다는 한계가 있다.</p>
<h3 id="💵-로그인">💵 로그인</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/a0130a7e-744b-4877-8b2f-e3d14e2bc550/image.png" alt=""></p>
<p>로그인 과정은 클라이언트 측에서 Request Body에 <code>email</code>과 <code>password</code>를 보내면 서버에서 검증한 뒤 엑세스 토큰과 리프레시 토큰을 생성한다.
리프레시 토큰은 <code>httpOnly</code>, 엑세스 토큰은 응답의 바디에 실어 보낸다.
클라이언트 측에서는 엑세스 토큰을 <code>Authorization</code> 헤더에 넣어 관리한다.</p>
<p>&#39;서버측에서 어떻게 쿠키에 저장할 수 있을까?&#39;라는 의문이 있었다.
왜냐하면 쿠키는 브라우저 영역이기 때문이다.
알고 보니 서버에서 직접 저장하는 건 아니고 HTTP 응답 헤더에 쿠키를 포함시켜서 브라우저가 해당 쿠키를 저장하도록 하는 거라고 한다.</p>
<p>로그인 과정 시에 <code>Authorization</code> 헤더에 베이직 토큰으로 보내는 경우도 있던데, 사실 어떤 게 더 좋을지 모르겠다.</p>
<p>바디에 JSON 형태로 보내는 게 더 통상적으로 사용하는 것 같아서 이 방법을 사용했다.</p>
<p>&#39;헤더에 베이직 토큰 형태로 보내면 더 안전하지 않을까?&#39;라고 생각도 했는데, 이것도 노출되기 때문에 보안상 더 뛰어난 것도 아닌듯하다.</p>
<pre><code class="language-ts">@Post(&#39;login&#39;)
async login(
  @Body() loginUserDto: LoginUserDto,
  @Res({ passthrough: true }) response: Response,
  ) {
    const { refreshToken, accessToken } =
          await this.authService.login(loginUserDto);

    response.cookie(&#39;refreshToken&#39;, refreshToken, {
      httpOnly: true,
      secure: this.configService.get(&#39;ENV&#39;) === &#39;prod&#39;,
      sameSite: &#39;strict&#39;,
      maxAge: 24 * 60 * 60 * 1000,
    });

    return { accessToken };
  }</code></pre>
<p>서버의 <code>controller</code> 로직을 살펴보면 <code>service</code> 로직에서 받은 리프레시 토큰을 쿠키에 저장하고, 엑세스 토큰을 반환한다.</p>
<pre><code class="language-ts">import { login } from &quot;@/api/users/login&quot;;
import { LoginResponse } from &quot;@/types&quot;;
import { useMutation, useQueryClient } from &quot;@tanstack/react-query&quot;;

export const useLoginMutation = () =&gt; {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ email, password }: { email: string; password: string }) =&gt;
      login({ email, password }),
    onSuccess: (data: LoginResponse) =&gt; {
      queryClient.setQueryData([&quot;accessToken&quot;], data.accessToken);
    },
    onError: (error: Error) =&gt; {
      console.log(error);
    },
  });
};</code></pre>
<p>클라이언트 측에서는 서버로 부터 받은 엑세스 토큰을 받아서 리액트 쿼리를 통해 캐싱했다.</p>
<p><code>axios</code>는 <code>axios.defaults.headers.common[&#39;Authorization&#39;]</code>을 통해서 바로 전역 헤더 설정을 할 수 있는데, <code>fetch</code>는 안 되는 것 같아서 캐싱을 하고 추후 요청 시에 헤더에 설정하도록 했다.</p>
<h3 id="💵-accesstoken-재발급">💵 AccessToken 재발급</h3>
<p>엑세스 토큰이 만료되면 리프래쉬 토큰을 통해 재발급 받는다.
말은 쉬운데 엑세스 토큰이 만료됐는지 어떻게 알 수 있을까?
만료 시간을 클라이언트 측에서 보낸다 한들, 일일히 측정할 수도 없는 노릇아닌가?!</p>
<pre><code class="language-tsx">import { useQueryClient } from &quot;@tanstack/react-query&quot;;
import { refreshToken } from &quot;./refreshToken&quot;;

const customFetch = async (url: string, options: RequestInit = {}) =&gt; {
  const queryClient = useQueryClient();
  const accessToken = queryClient.getQueryData&lt;string&gt;([&quot;accessToken&quot;]);

  const headers = {
    ...options.headers,
    ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
  };
  const newOptions = { ...options, headers };

  const response = await fetch(url, newOptions);

  if (response.status === 401) {
    const retryResponse = await refreshToken(url, newOptions);
    return retryResponse;
  }

  return response;
};</code></pre>
<p>위와 같은 <code>customFetch</code> 함수를 만들어서 서버의 응답이 401일 경우 토큰을 재발급하는 API 요청을 보내도록 설정할 수 있다.
하지만 위 함수는 동작하지 않는다.
눈치가 빠른 사람은 이상한 점을 느꼈겠지만 리액트 쿼리에서 토큰값을 가져오기 위해서는 <code>useQueryClient</code> 훅을 사용해야 하는데, 일반 함수 내부에서는 훅을 사용할 수 없다.</p>
<p><code>axios</code>의 전역 헤더 설정 기능이 얼마나 유용한지 새삼 깨닫게 된다...
전역 상태 관리 라이브러리로 관리하는 방법도 있지만 <code>axios</code>를 쓰기로 했다.</p>
<pre><code class="language-ts">import { UserLoginRequest, LoginResponse } from &quot;@/types&quot;;
import { authClient } from &quot;../common&quot;;
import axios from &quot;axios&quot;;

export const login = async (requestData: UserLoginRequest): Promise&lt;LoginResponse&gt; =&gt; {
  try {
    const response = await authClient.post&lt;LoginResponse&gt;(&quot;/auth/login&quot;, requestData);
    const { accessToken } = response.data;
    axios.defaults.headers.common[&quot;Authorization&quot;] = `Bearer ${accessToken}`;
    return response.data;
  } catch (error: any) {
    const serverMessage = error.response?.data?.message;
    throw new Error(serverMessage || &quot;이메일 또는 비밀번호가 일치하지 않습니다.&quot;);
  }
};</code></pre>
<p>우선 기존 로그인 함수를 <code>axios</code>를 사용하도록 바꿨다.
로그인에 성공하면 전역 헤더 설정으로 토큰을 포함하도록 했다.
여기서 <code>authClient</code>는 인가가 필요하지 않은, 인증받기 전의 요청에 대해서 요청을 보내는 인스턴스다.</p>
<pre><code class="language-ts">import axios from &quot;axios&quot;;

const apiClient = axios.create({
  baseURL: &quot;http://localhost:3000&quot;,
  withCredentials: true,
});

apiClient.interceptors.response.use(
  (response) =&gt; response,
  async (error) =&gt; {
    const originRequest = error.config;
    if (error.response.status === 401 &amp;&amp; !originRequest._retry) {
      originRequest._retry = true;
      try {
        const { data } = await axios.post(
          &quot;http://localhost:3000/auth/refresh&quot;,
          {},
          { withCredentials: true }
        );
        const { accessToken } = data;

        axios.defaults.headers.common[&quot;Authorization&quot;] = `Bearer ${accessToken}`;
        originRequest.headers.Authorization = `Bearer ${accessToken}`;
        return apiClient(originRequest);
      } catch (refreshError) {
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);</code></pre>
<p>이후 요청부터는 <code>apiClient</code>로 보내게 된다.
<code>apiClient</code>에 <code>interceptor</code>를 설정하여 토큰이 만료되어 서버로 부터 401 에러를 반환받을 경우 토큰을 재발급하는 요청을 보내고 다시 엑세스 토큰을 설정해서 재요청을 보내도록 했다.</p>
<h3 id="💵-마치며">💵 마치며</h3>
<p>로그인 로직이 이렇게 복잡했었나...
처음에는 JWT 탄생 배경, 정보, 한계 등을 설명하고 간략하게 로직으로 설명하려고 했다.
음... 돌이켜보니 처음 생각한대로 한 것 같지만, 중간에 XSS, CSRF 공격 등 파생되는 개념도 많았고 세션-쿠키 방식도 해볼까하다가 내용이 너무 방대해지는 것 같아서 잘랐다.</p>
<p>사용자의 정보를 보낼 때, 서버에서 토큰을 발행하고 응답할 때 찾아보는 정보마다 달라서 혼란스러웠다.
엑세스 토큰을 재발급할 때에도 서버에서 <code>AuthGard</code>를 통해서 엑세스 토큰의 유효 기간이 만료됐는지 확인하고 재발급하는 방법도 있는 것 같고... 사용자의 정보를 베이직 토큰으로 보내는 방법 등 여러 방법이 존재했다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/3c0d560e-0d36-41e8-886d-e92d30f8d98c/image.jpg" alt=""></p>
<p>강백호가 레이업슛을 풋내기슛이라고 생각하듯, 로그인 기능은 서비스에서 기본적인 기능이라 생각했는데, 생각보다 복잡하고 모르는 내용이 많아서 약간의 좌절감을 맛봤다.</p>
<p>왜 프로그래밍은 알려고 들춰보면 모르는 게 더 생겨날까? ㅎㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[항해 플러스 프론트엔드 4기] 나의 항해 원정기]]></title>
            <link>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-%EB%82%98%EC%9D%98-%ED%95%AD%ED%95%B4-%EC%9B%90%EC%A0%95%EA%B8%B0</link>
            <guid>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-%EB%82%98%EC%9D%98-%ED%95%AD%ED%95%B4-%EC%9B%90%EC%A0%95%EA%B8%B0</guid>
            <pubDate>Mon, 03 Mar 2025 08:27:26 GMT</pubDate>
            <description><![CDATA[<p>10주간의 항해가 끝났다.
지난 항해를 돌아보며 느낀 점을 생각나는 대로 배설해보면...</p>
<h3 id="💵-항해를-하게-된-계기">💵 항해를 하게 된 계기</h3>
<p>프로그래밍을 처음 접하면서 자바스크립트에 대해서 매력을 느꼈다.
이유를 돌이켜보면 이클립스는 구린데, 그에 비해 VSCODE는 쌈@뽕해서?!
사용자와 상호 작용을 통해 화면을 동적으로 바꾸는 게 재밌어서?!
뭐 이런 이유였던 것 같다.</p>
<p>사람도 좋아하는 데 작은 이유로 시작하듯이 별 거 아닌 이유로 자바스크립트에 매력을 느끼고 자연스레 프론트 엔드에 관심을 갖게 됐다.</p>
<p>일을 하면서 리액트, 넥스트, 타입 스크립트 등의 인터넷 강의를 들었다.
하지만 프론트엔드의 역량에 대해서 궁금해서 채용 공고를 보게 되면 바닐라 자바스크립트, 리액트와 타입 스크립트, 전역 상태 관리 라이브러리, 테스트 코드에대한 이해와 경험 등의 요구 사항이 있었는데 강의로 배운 부분적인 지식들이 이어지지 않았다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/ed98878d-999a-483b-b07b-04b2b82a6129/image.gif" alt=""></p>
<p>리액트 쿼리는 전역 상태 관리 라이브러리이면서 또 다른 뭔가라고 하는데 뭔가가 뭘까?
강의마다 폴더 구조가 조금씩 다른데 통상적으로 사용하는 방식은 뭘까?
이 모든 걸 하나로 합쳐서 전체적인 흐름을 알려주는 강의는 없을까?</p>
<p>주변에 개발자가 없다보니 물어볼 사람도 없었다.</p>
<p>빈 구멍을 메꿔줄 멘토가 필요했다.
처음에는 다른 멘토링 서비스를 하려다가 여차저차해서 항해에 대해서 알게 됐다.</p>
<p>항해의 좋은 점은 비슷한 고민을 하는 사람들과 함께 한다는 것이다.
또한 현업자를 대상으로 하다보니 다양한 썰을 들을 수 있다.
게다가 일을 다니며 성장을 위해 투자하는 멋진 사람들을 만날 수 있는 기회?!</p>
<h3 id="💵-항해를-하면서">💵 항해를 하면서</h3>
<p>다양한 주제에 대해서 과제를 진행하며 많은 걸 배웠다
혼자했다면 그보다 깊게 고민하지 않고 넓히지도 않았을 것 같다.
또한 앞선 경험을 한 여러 코치님들께서 기술적인 부분은 물론이고 태도, 자세 등에 대해 각자의 시각에서 조언해주셔서 좋았다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/90bc9c98-b25a-462f-824f-b3665cc34f60/image.jpg" alt=""></p>
<p>그래도 항해를 하면서 얻은 가장 큰 건 같이 고민하는 동료아닐까?!
다양한 면모에서 배울 점이 많은 분들이 많았다.
분위기를 재밌게 풀어가는 능력이 있으신 분, 뭐든 똑부러지게 하시는 분, 코드를 정말 잘 짜시는 분, 깊은 지식과 다양한 경험을 하신 분, 여러 인사이트에 대한 내용을 공유해주시는 분, 매일 새벽까지 근면 성실함을 행동으로 보여주시는 분 등등.
항해가 끝났지만 취업과 이직에 대한 니즈가 맞는 분들 끼리 모여서 스터디를 진행하기로 했는데, 꼭 스터디가 아니더라도 인연을 이어가고 싶은 사람들이 많다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/5fbe13f2-1863-4214-9b46-a52d4b478bbb/image.jpg" alt=""></p>
<p>좋은 팀원분들을 만나서 베스트팀까지 선정됐다.
안 그래도 운동할 때 막 쓸만한 헤드폰이 있었으면 좋겠다고 생각했는데, 베스트팀 경품으로 헤드폰을 얻게 됐다.</p>
<p>매주 항해를 하면서 회고글을 작성했다.
PR 제출할 때 과제에 대한 회고를 적게 되어있는데, 이왕 적는 거 좀 더 자세하게 써서 블로그 업로드도 하면 좋겠다 싶어서 시작했다.</p>
<p>회고글에 각 과제에 대한 깊은 지식, 트러블 슈팅 등을 기술적으로 풀어내는 내용보다는 일기를 쓰는 느낌으로 작성했다.
velog를 하기 전에 개인 블로그에 공부한 내용을 자세하게 적어서 포스팅한 적이 있었는데, 단기간에 집어넣으려고 정리한 지식이다 보니 까먹게 됐다.
블로그에 깊은 지식을 정리해놓고 모르는 내 자신이 척하는 사람인 것 같다라는 생각에 velog로 플랫폼을 옮기면서 진짜 내가 고민하고 경험한 것들 위주로 적어야겠다고 생각했다.</p>
<p>기술 블로그를 운영하는 다른 분들과 얘기를 해보면 까먹는 게 당연하지만 다시 보기 위해 기록한다는 분들도 계셔서 앞으로 블로그를 어떻게 운영할지 아직도 고민중이다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/ad870d12-1159-47de-985c-6746a6dfa4c1/image.png" alt=""></p>
<p>아무튼 모자란 글임에도 항해 기간동안 회고글을 잘 보고 있다는 말씀을 종종 해주실 때마다 괜스레 부끄러웠지만 꾸준히 쓰다보니 수료날에 회고상을 받게 됐다.</p>
<p>무거운 글이 싫어서 ~습니다/ 입니다와 같은 격식체를 피하고 ~했다/한다처럼 서술문을 쓰고, 부리부리몬을 컨셉으로 중간중간 적절한 이미지를 사용한 게 잘 먹혀들었나보다.</p>
<p>앞으로는 팀원들과 스터디를 하며 좀 더 기술적인 내용을 포스팅하려 하는데, 앞선 후회를 반복하고 블로그를 뒤엎을까 걱정된다.</p>
<h3 id="💵-앞으로의-항해">💵 앞으로의 항해</h3>
<p>항해를 하며 이런 저런 고민을 했다.
고민 가운데 하나를 뽑자면 전략과 흥미간의 균형을 잘 맞춰야 되겠다는 생각이다.
무슨 말이냐면 프론트엔드에 국한되어 생각하지 말고 흥미 위주로 공부를 하고 싶다.
프론트엔드 개발자라기 보단 그냥 개발자로서?!
하지만 지금 당장 취업도 해야하고 취업하게 되면 현업이 우선이니 당장 필요한 지식과 흥미있는 지식 사이의 균형을 잘 맞추며 지속적으로 성장하고 싶다.</p>
<p>항해가 끝나고 해이해질 것을 우려하여 스터디를 이어가기로 했다.
나는 내가 의지가 부족해서 혼자 스스로 못하는 타입이다라고 생각했는데, 항해를 하며 개인의 의지보다 환경이 중요하다는 것을 여러 항해원과 얘기하면서 깨달았다.
아무튼 서로가 서로의 자극과 동기부여가 되고 때때로는 강제성을 부여하도록 노력하고 있다.
이 모임을 오래 유지하고 싶다.</p>
<p>우리 팀이 한 분을 제외하고 모두 I 성향이라 다른 팀분들과 교류가 부족했다.
zep에 들어가면 항상 계시던 분들, 아고라에 남다른 질문을 하시는 분들, 토요 지식회에서 발표하시는 분들, 과제 등등을 보며 알게 모르게 많은 자극을 받았다.
다들 고생하셨고 응원한다고 이 글을 빌어 전하고 싶다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/17b6fd3b-8c5f-4e34-aa56-dd4561e12cac/image.jpeg" alt=""></p>
<p>프론트엔드 분야에 전체적으로 한 사이클을 돌면서 딥다이브를 하고 싶거나, 같이 성장하고 고민할 수 있는 동료를 찾는 니즈가 있다면 항해를 적극 추천한다.</p>
<blockquote>
<p>jg0hs4</p>
</blockquote>
<p>위 코드를 사용하면 할인 20만원 할인 받을 수 있으니 이왕하실 분들은 좀 더 저렴하게 이용하시길..!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[항해 플러스 프론트엔드 4기] 10주차 과제 회고]]></title>
            <link>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-10%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-10%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 03 Mar 2025 06:59:30 GMT</pubDate>
            <description><![CDATA[<p>어느덧 마지막 과제.
이번 챕터는 저번 과제와 동일하게 성능 최적화지만 9주차에는 인프라 수준의 최적화였다면, 이번에는 코드 수준의 최적화다.</p>
<p>이번 과제는 기본, 심화 과제가 별도의 프로젝트로 되어 있었다.
기본 과제는 바닐라 자바스크립트, 심화는 리액트 코드에서 최적화를 진행한다.</p>
<h2 id="💰-바닐라-자바스크립트-프로젝트-성능-최적화">💰 바닐라 자바스크립트 프로젝트 성능 최적화</h2>
<hr>
<p>지난 과제에서 S3와 CloudFront를 통해 배포한 것과 동일하게 프로젝트를 배포했다.
아무런 처리를 하지 않았을 때의 Lighthouse 지표는 아래와 같다.</p>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>점수</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>Performance</td>
<td>72%</td>
<td>🟠</td>
</tr>
<tr>
<td>Accessibility</td>
<td>82%</td>
<td>🟠</td>
</tr>
<tr>
<td>Best Practices</td>
<td>75%</td>
<td>🟠</td>
</tr>
<tr>
<td>SEO</td>
<td>82%</td>
<td>🟠</td>
</tr>
<tr>
<td>PWA</td>
<td>0%</td>
<td>🔴</td>
</tr>
</tbody></table>
<p>또한 구글의 Page Speed Insight를 이용하여 점수를 측정했다.
Page Speed Insight로 측정하게 되면 모바일, 데스크톱 환경을 각각 측정해준다.
데스크톱은 이미지 최적화만 해도 성능 부분이 90점이 넘어가기 때문에 모바일을 기준으로 잡았다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/28082791-ba50-4c8d-a42f-5da56f38bf43/image.png" alt=""></p>
<p>개선 전 성능을 보면 66점으로 저조한 점수를 보인다.
성능 개선은 크게 이미지 최적화, 폰트 최적화, 자바스크립트 파일 병렬 로딩 3가지로 진행했다.</p>
<h3 id="💵-이미지-최적화">💵 이미지 최적화</h3>
<p>먼저 모든 이미지 파일을 JPEG에서 WebP로 변환했다.
WebP는 구글에서 개발한 최신 이미지 포맷으로 JPEG와 같은 기존 포맷보다 더 효율적인 압축방식을 사용한다.
따라서 JPEG보다 적은 용량으로 비슷한 이미지 품질을 제공한다고 한다.</p>
<pre><code class="language-html">&lt;img class=&quot;desktop&quot; src=&quot;images/Hero_Desktop.jpg&quot;&gt;
&lt;img class=&quot;mobile&quot; src=&quot;images/Hero_Mobile.jpg&quot;&gt;
&lt;img class=&quot;tablet&quot; src=&quot;images/Hero_Tablet.jpg&quot;&gt;</code></pre>
<p>추가로 기존 <code>img</code> 태그로 보여주던 이미지를 <code>srcset</code>으로 변경했다.
<code>srcset</code>은 팀원분들과 CS 스터디를 하다가 알게 된 개념이다.
사용자의 화면 크기에 맞게 적절한 크기의 이미지를 가져오는 속성으로 <code>picture</code> 태그 아래에 <code>source</code> 태그에서 주로 사용된다.</p>
<pre><code class="language-html"> &lt;picture&gt;
   &lt;source media=&quot;(max-width: 768px)&quot; srcset=&quot;images/Hero_Mobile.webp&quot; /&gt;
   &lt;source media=&quot;(max-width: 1024px)&quot; srcset=&quot;images/Hero_Tablet.webp&quot; /&gt;
   &lt;source media=&quot;(min-width: 1025px)&quot; srcset=&quot;images/Hero_Desktop.webp&quot; /&gt;
   &lt;img src=&quot;images/Hero_Desktop.webp&quot; alt=&quot;Hero Image&quot; /&gt;
&lt;/picture&gt;</code></pre>
<p>팀원분들과 이야기를 하다보면 HTML, CSS를 잘 다뤄야겠다는 생각이 든다.
기존에는 성능 최적화라고 하면 자바스크립트 코드에서 무거운 연산을 효율적으로 바꾼다거나 그런 생각을 했다.
하지만 이미지 파일이나, 사용자의 동작에 따라 화면의 요소를 바꾼다거나, 애니메이션을 넣을 때 자바스크립트로 처리하지 않고 CSS로 할 수 있는게 많고 잘 활용하면 훨씬 성능을 최적화할 수 있다고 한다.
자바스크립트 코드에서의 최적화는 리액트와 같은 라이브러리, 앵귤러, 뷰 같은 프레임워크에서 자동으로 해주니 HTML과 CSS를 잘 다루는 것이 좋지 않을까라는 생각이 부쩍 든다.</p>
<p>아무튼 <code>srcset</code>은 Nextjs에서 <code>Image</code> 태그에도 쓰인다.
Nextjs의 코드를 살펴보면 <code>generateImgAttrs</code> 함수가 있다.</p>
<pre><code class="language-ts">function generateImgAttrs({
  config,
  src,
  unoptimized,
  layout,
  width,
  quality,
  sizes,
  loader,
}: GenImgAttrsData): GenImgAttrsResult {
  if (unoptimized) {
    return { src, srcSet: undefined, sizes: undefined }
  }

  const { widths, kind } = getWidths(config, width, layout, sizes)
  const last = widths.length - 1

  return {
    sizes: !sizes &amp;&amp; kind === &#39;w&#39; ? &#39;100vw&#39; : sizes,
    srcSet: widths
      .map(
        (w, i) =&gt;
          `${loader({ config, src, quality, width: w })} ${
            kind === &#39;w&#39; ? w : i + 1
          }${kind}`
      )
      .join(&#39;, &#39;),

    // It&#39;s intended to keep `src` the last attribute because React updates
    // attributes in order. If we keep `src` the first one, Safari will
    // immediately start to fetch `src`, before `sizes` and `srcSet` are even
    // updated by React. That causes multiple unnecessary requests if `srcSet`
    // and `sizes` are defined.
    // This bug cannot be reproduced in Chrome or Firefox.
    src: loader({ config, src, quality, width: widths[last] }),
  }
}</code></pre>
<p><code>generateImgAttrs</code> 함수는 전달된 속성들을 바탕으로 <code>srcset</code>과 <code>sizes</code>를 자동으로 계산하는 역할을 하고 그 결과를 <code>ImageElement</code> 컴포넌트가 <code>img</code> 태그에 속성으로 적용한다.</p>
<pre><code class="language-html">&lt;picture&gt;
  &lt;source
          width=&quot;576&quot;
          height=&quot;576&quot;
          media=&quot;(max-width: 575px)&quot;
          srcset=&quot;images/Hero_Mobile.webp&quot;
          /&gt;
  &lt;source
          width=&quot;960&quot;
          height=&quot;770&quot;
          media=&quot;(min-width: 576px) and (max-width: 960px)&quot;
          srcset=&quot;images/Hero_Tablet.webp&quot;
          /&gt;
  &lt;img width=&quot;1920&quot; height=&quot;893&quot; src=&quot;images/Hero_Desktop.webp&quot; /&gt;
&lt;/picture&gt;</code></pre>
<p>또한 이미지 최적화 기법으로 <code>width</code>와 <code>height</code>를 정하는 방법이 있다.
이를 통해 이미지 공간을 미리 확보하여 레이아웃 시프트를 방지하여 안정적인 레이아웃을 유지한다.
레이아웃 시프트는 페이지가 로드되는 동안 콘텐츠의 위치가 예기치 않게 이동하는 현상으로 사용자 경험을 해친다.</p>
<p>헷갈렸던 점이 레리아웃 시프트를 방지한다는 걸 리렌더링 방지를 얘기하는 거라고 생각했다.
하지만 리렌더링 방지와는 다르고, 브라우저가 초기 렌더링 시에 해당 영역을 미리 예약해서 레이아웃 변화없이 안정적으로 화면을 구성할 수 있게 한다는 말이 더 적합하다.</p>
<blockquote>
<p><a href="https://web.dev/articles/optimize-cls?utm_source=lighthouse&amp;utm_medium=lr&amp;hl=ko#images_without_dimensions">구글에서는 이를 측정하는 Core Web Vitals 중 하나인 CLS(Cumulative Layout Shift)로 평가한다.</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/927ea2f7-86d2-4fd4-8585-7b026bc40d5b/image.png" alt=""></p>
<p>이미지 최적화를 끝내면 성능이 66점에서 92점으로 확연하게 좋아진다.</p>
<h3 id="💵-폰트-최적화">💵 폰트 최적화</h3>
<p>폰트 최적화는 간단하다.
폰트 최적화는 기존에 외부에서 불러와서 사용하던 웹폰트를 폰트 파일을 넣어줌으로써 네트워크 요청없이 사용할 수 있도록 했다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/e811843a-3897-4ac2-9463-be18a4faf8b1/image.png" alt=""></p>
<p>그 결과 92점에서 95점으로 3점 향상됐다.</p>
<h3 id="💵-자바스크립트-파일-병렬-로딩">💵 자바스크립트 파일 병렬 로딩</h3>
<p>브라우저가 HTML 파일을 해석할 때 <code>script</code> 태그를 만난다면 해석을 중단한다.
이를 해결하기 위한 방법으로 <code>async</code>와 <code>defer</code>을 추가할 수 있다.</p>
<p>두 속성 모두 HTML 파싱과 동시에 파일을 다운로드한다는 특징이 있다.</p>
<p>차이점은 <code>async</code>는 다운로드가 완료되면 즉시 HTML을 중단하고 스크립트를 실행한다.
또한 순서가 보장되지 않는다.
여러 <code>async</code> 스크립트가 있다면 다운로드를 시작한 순서가 아닌, 다운로드가 완료된 순서로 실행된다.
따라서 외부 라이브러리에 의존하지 않거나 DOM 조작을 하지 않는 경우 사용하면 좋다.
보통 분석 도구나 광고 스크립트 등이 있다.</p>
<p><code>defer</code>는 다운로드가 완료되도 HTML 파싱이 완료된 다음 실행된다.
또한 순서가 보장되어 먼저 선언한 스크립트가 먼저 실행된다.
따라서 DOM을 직접 조작하거나 순서가 중요한 스크립트에 사용하면 좋다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/9e99df87-532b-4d2c-a13b-683dcbd3ee0e/image.png" alt=""></p>
<p><code>defer</code>를 추가한 결과 성능 1점이 향상됐다.</p>
<h3 id="💵-성능-측정-결과">💵 성능 측정 결과</h3>
<hr>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>점수</th>
<th>상태</th>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>Performance</td>
<td>90%</td>
<td>🟢</td>
<td>+18% ⬆️</td>
</tr>
<tr>
<td>Accessibility</td>
<td>82%</td>
<td>🟠</td>
<td>변화 없음</td>
</tr>
<tr>
<td>Best Practices</td>
<td>75%</td>
<td>🟠</td>
<td>변화 없음</td>
</tr>
<tr>
<td>SEO</td>
<td>82%</td>
<td>🟠</td>
<td>변화 없음</td>
</tr>
<tr>
<td>PWA</td>
<td>0%</td>
<td>🔴</td>
<td>변화 없음</td>
</tr>
</tbody></table>
<p>위 최적화를 마친 결과 Lighthouse의 Performance 부분에서 18% 향상된 효과를 불러왔다.
Page Speed Insight에서 성능 부분 또한 30점으로 개선됐다.</p>
<p>이미지와 같은 무거운 파일을 가볍게 처리하고 어떻게 효율적?으로 불러오는지가 정말 중요한가 보다.
Lazy Loading 같은 기법들도 불필요한 이미지 파일을 불러오지 않으려고 나온 기법이니...</p>
<h2 id="💰-리액트-프로젝트-최적화">💰 리액트 프로젝트 최적화</h2>
<hr>
<h3 id="💵-리액트의-메모이제이션-활용">💵 리액트의 메모이제이션 활용</h3>
<p>3주차에서 미리 경험한 리액트의 메모이제이션을 다시 활용하는 시간이었다.
다시 간략하게 정리하면</p>
<ul>
<li><strong>useMemo</strong>: 함수의 결과를 저장하여 불필요한 연산이 반복되지 않도록 한다.</li>
<li><strong>useCallback</strong>: 함수의 참조를 유지하여 불필요하게 함수가 재생성되지 않도록 한다. <code>props</code>로 넘겨줄 경우 함수가 재생성되어 리렌더링되는 경우를 방지하는데, <code>React.memo</code>와 함께 사용해야 한다.</li>
<li><strong>React.memo</strong>: 컴포넌트 재생성 방지를 위해 사용한다. 부모 컴포넌트가 리렌더링되더라도 <code>props</code>가 변화가 없다면 리렌더링하지 않는다.</li>
</ul>
<p>과제를 하다가 기계적으로 <code>props</code>로 넘기는 함수에 대해서 <code>useCallback</code>을 사용했다.</p>
<pre><code class="language-tsx">{scheduleEntries.map(([tableId, schedules], index) =&gt; (
  // ...
  &lt;Button
      colorScheme=&quot;green&quot;
      mx=&quot;1px&quot;
      onClick={() =&gt; {
        setSchedulesMap((prev) =&gt; ({
          ...prev,
          [`schedule-${Date.now()}`]: [...prev[tableId]],
        }));
      }}
  &gt;
    복제
  &lt;/Button&gt;
  // ...
))}</code></pre>
<p>하지만 함수 내부에서 사용하는 값이 어떤 배열의 <code>map</code> 메서드 안에서 생성되는 경우가 애매했다.</p>
<pre><code class="language-tsx">const duplicate = useCallback(
  (targetId: string) =&gt; {
    setSchedulesMap((prev) =&gt; ({
      ...prev,
      [`schedule-${Date.now()}`]: [...prev[targetId]],
    }));
  },
  [setSchedulesMap]
);

// ...

{scheduleEntries.map(([tableId, schedules], index) =&gt; (
  // ...
  &lt;Button
    colorScheme=&quot;green&quot;
    mx=&quot;1px&quot;
    onClick={() =&gt; duplicate(tableId)}
  &gt;
    복제
  &lt;/Button&gt;
  // ...
))}</code></pre>
<p>함수에 <code>tableId</code>를 넘겨줘야 하는데, <code>onClick{() =&gt; duplicate(tableId)}</code>로 하자니 <code>useCallback</code>을 감싼 의미가 사라진다.
왜냐하면 렌더링이 될 때마다 화살표 함수는 재생성되기 때문이다.</p>
<p>심화 과제를 제출날에 급하게 시작하느라 <code>useCallback</code>을 거두고 제출했지만, 제출한 이후 찾아보니 커링 기법으로 해결할 수 있다고 한다.</p>
<pre><code class="language-tsx">const duplicate = useCallback(
  (targetId: string) =&gt; () =&gt; {
    setSchedulesMap((prev) =&gt; ({
      ...prev,
      [`schedule-${Date.now()}`]: [...prev[targetId]],
    }));
  },
  [setSchedulesMap]
);

// ...

{scheduleEntries.map(([tableId, schedules], index) =&gt; (
  // ...
  &lt;Button
    colorScheme=&quot;green&quot;
    mx=&quot;1px&quot;
    onClick={duplicate(tableId)}
  &gt;
    복제
  &lt;/Button&gt;
  // ...
))}</code></pre>
<p>이를 고민할 당시에도 &#39;고차 함수를 사용하면 되려나?!&#39;라는 생각을 하긴 했는데, 막상 어떻게 구현할지 고민해보니 생각나질 않았다.</p>
<p>앞선 과제를 하며 함수형 프로그래밍도 공부했지만, 활용하는 건 많이 써봐야 하는 영역인가 보다.</p>
<h3 id="💵-contextapi-활용-시에-provider의-위치">💵 ContextAPI 활용 시에 Provider의 위치</h3>
<hr>
<p>심화 과제에서 가장 주요한 부분은 드래그 드랍 시에 해당 부분만 리렌더링되도록 하는 것이었다.
음... 과제를 말로 설명하자면 여러 시간 표가 있고, 기존에 등록된 시간을 드래그 드랍을 통해 시간과 요일을 바꿀 수 있다.
하지만 드래그 드랍 시에 해당 시간표만 리렌더링되는 것이 아니라 전체 시간표가 모두 리렌더링된다.</p>
<p>이 문제는 혼자 고민해봤는데, 도무지 생각이 나질 않아서 같은 팀원분에게 물어봤다.
결론적으로 말하자면 ContextAPI의 Provider 위치 문제였다.
<code>App.tsx</code>에 있던 <code>Provider</code>를 시간표 배열의 반복문 내부에서 시간표 당 하나씩 할당했더니 해결됐다.</p>
<p>하지만 해결책을 안 이후에도 잘 이해되지 않았다.</p>
<blockquote>
<p>&#39;ContextAPI에서 사용하는 전역 상태가 변경되면 어차피 다 리렌더링되는 거 아닌가? 그럼 Provider가 App에 있던 반복문 내부에 시간표 당 하나씩 있던 무슨 상관이지?&#39;</p>
</blockquote>
<p>ContextAPI는 가장 가까운 Provider 값을 참조한다.
따라서 <code>App.tsx</code>에 선언을 하면 하나의 시간표 Context 값이 변경되도 전체가 리렌더링 되지만 시간표 당 하나씩 선언하게 되면 Context의 값이 변경되도 해당 시간표만 리렌더링된다.</p>
<p>클린 코드 때 ContextAPI를 사용하며 이해했다고 생각했지만 이것 역시 많이 사용해보고 익숙해져야 하나보다.</p>
<h2 id="💰-마치며">💰 마치며</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/c1ca519b-2ed4-4063-b7db-a1a719f6ec1a/image.jpg" alt=""></p>
<p>&#39;코딩은 운동과 비슷하다.&#39;라는 말을 들은 적있다.
이론적인 학습도 중요하지만 결국 많이 사용해서 근육을 만들어야 한다.
자주 사용하지 않으면 근손실나는 것도 비슷하다.
대충 이런 내용이었다.
사실 코딩 뿐만아니라 그림, 글쓰기 등 다양한 분야에서 비슷한 얘기를 들었다.
이론적인 학습도 중요하지만 자주 사용함으로써 감을 익히는 것이 어떤 분야든 중요한가 보다.</p>
<p>마지막 과제를 마치면서 이론적인 내용은 한 번 훑었으니 앞으로는 많이 써보면서 익숙해져야 할 시간을 가져야 하지 않을까 싶다.
그러다가 다시 생각나지 않을 때는 지난 과제와 자료들을 살펴보며 지식을 쌓고 익숙해지기를 반복해야 하지 않을까?!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[항해 플러스 프론트엔드 4기] 9주차 과제 회고]]></title>
            <link>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-9%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-9%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Thu, 20 Feb 2025 16:16:57 GMT</pubDate>
            <description><![CDATA[<p>어느덧 항해의 마지막 &lt;성능 최적화&gt; 챕터가 시작됐다.
빠르다면 빠르고 느리다면 느리게 지나온 것 같다.
빠르다면 빠르고 느리다면 느린 건 그래서 빠른 걸까 느린 걸까?</p>
<p>최적화는 크게 두 가지로 나눌 수 있다.
인프라 수준의 최적화와 코드 수준의 최적화다.
이번 챕터에서는 인프라 수준의 최적화에 대해 다룬다.
인프라 수준의 최적화란 무엇일까?</p>
<p>인프라 수준의 최적화에서의 핵심 지표는 TTFB(Time To First Byte)다.
TTFB란 클라이언트가 서버에 요청을 보내고 서버로부터 첫 번째 바이트 응답을 받기까지 걸리는 시간을 의미한다.
TTFB를 줄이기 위해서는 캐시 적용, CDN 등의 인프라 레벨의 최적화가 필요하다.</p>
<p>과제는 배포한 Next.js 프로젝트의 CDN 도입 전과 후의 성능 개선 보고서를 작성하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/d5d9d16b-dff2-445f-b4bc-c548fd711d07/image.png" alt=""></p>
<p>과제의 요구 사항은 위와 같다.
Next.js 프로젝트를 main 브랜치에 push하면 GitHub Actions에서 빌드를 하고 빌드된 산출물을 AWS S3로 업로드한다.</p>
<p>AWS Cloud Front는 CDN 서비스다.
이를 통해 인프라 수준의 성능 최적화를 한다.</p>
<p>이 과정에서 IAM을 통해 AWS 접근을 통제하고 관리한다.</p>
<h2 id="💰-github-actions과-cicd-도구">💰 GitHub Actions과 CI/CD 도구</h2>
<hr>
<p>GitHub Actions는 CI/CD 도구다.
GitHub 레파지토리 내에서 코드의 빌드, 테스트, 배포 등 여러 작업을 자동화하기 위해 사용한다.</p>
<p>GitHub Actions는 코드 푸쉬, 풀 리퀘스트, 이슈 생성 등 다양한 GitHub 이벤트에 반응하여 워크 플로우가 실행된다.</p>
<p>레파지토리 내 <code>.github/workflows</code> 폴더에서 YAML 파일로 워크 플로우를 정의한다.</p>
<h3 id="💵-jenkins와의-차이점">💵 Jenkins와의 차이점</h3>
<p>예전에 CI/CD라고 하면 Jenkins만 떠올렸는데, 요즘에는 GitHub Actions를 많이 사용하는 듯하다.</p>
<p>Jenkins와의 차이점은 Jenkins는 오픈소스 CI/CD 도구로, 사용자가 직접 서버에 설치하고 관리해야 한다.
오랜 시간 동안 사용되어 온 만큼 다양한 커뮤니티 지원이 되고, 많은 플러그인을 통해 다양한 환경에 맞춰 확장할 수 있지만 그만큼 러닝커브가 높다.</p>
<p>하지만 GitHub Actions는 GitHub에서 제공하여 별도의 설치가 필요없다.
또한 YAML 파일 하나로 워크플로우를 정의하고 GitHub 이벤트에 기반하여 트리거된다.</p>
<h3 id="💵-github-actions에서-워크-플로우-파일을-찾는-방법">💵 GitHub Actions에서 워크 플로우 파일을 찾는 방법</h3>
<p>GitHub 이벤트가 발생하면 레파지토리 내 <code>.github/workflows</code> 폴더에 있는 모든 YAML 파일을 스캔한다.
각 YAML 파일에 정의된 <code>on</code> 키워드를 확인하여 이벤트에 대한 워크 플로우가 트리거되어 실행된다.</p>
<h3 id="💵-repository-secret과-환경변수">💵 Repository secret과 환경변수</h3>
<p>워크플로우 파일에서 빌드된 파일을 AWS에 저장하기 위해 IAM 계정 정보를 입력하여 접근 권한을 얻게 된다.</p>
<p>하지만 이런 정보는 보안상 공개되면 안되는 정보기 때문에 Repository secrets에 저장한다.
Repository secrets에 저장하면 Github 내부에서 암호화되어 저장된다.</p>
<p>워크플로우 파일에서 <code>${{ secrets.AWS_ACCESS_KEY_ID }}</code> 형식으로 참조할 수 있으며, AWS_ACCESS_KEY_ID는 저장 시에 설정할 수 있는 이름이다.</p>
<p>환경 변수는 워크플로우 실행에 필요한 설정값을 정의할 때 사용한다.</p>
<pre><code class="language-yaml">env:
  NODE_ENV: production
  API_URL: https://api.example.com</code></pre>
<p>워크플로우 파일 안에서 <code>env</code> 키를 사용하여 정의할 수 있고 <code>$NODE_ENV</code>, <code>${{ env.API_URL }}</code> 형태로 참조할 수 있다.</p>
<p>이번 과제에서 사용하진 않았지만 민감한 정보가 아니고 워크플로우 파일에서 전역적으로 쓴다면 활용할 수 있다.</p>
<h2 id="💰-s3와-스토리지">💰 S3와 스토리지</h2>
<hr>
<p>S3는 서버라기 보단 스토리지다.
여지껏 jsp + Spring으로 만들어진 프로젝트를 Docker에 배포한 적만 있어서 이 개념이 헷갈렸다.</p>
<p>서버라 함은 OS 위에 Java면 JVM, JavaScript라면 Node.js를 띄운 다음에 코드를 실행시키는 이미지다.
빌드된 파일을 S3에 저장하고 제공된 URL로 접근 시에 페이지를 띄워줘서 서버인 줄 알았다.</p>
<blockquote>
<p>S3는 빌드가 완료된 HTML, CSS, JavaScript 파일을 포함한 정적 파일을 갖고 있다가 전달하는 것뿐인데 어떻게 동작할 수 있을까?</p>
</blockquote>
<p>빌드된 파일을 S3에 저장하고 제공된 URL로 접근 시에 페이지를 띄워주긴 하지만 그 파일들은 브라우저에서 실행된다.
따라서 S3가 정적 파일을 저장하고 있다가 제공만 해도 브라우저에서 화면을 확인할 수 있다.</p>
<h3 id="💵-nextjs의-image-컴포넌트">💵 Next.js의 Image 컴포넌트</h3>
<p>과제에서는 <code>create-next-app</code>으로 만든 프로젝트를 빌드하고 배포해서 괜찮았지만 Next.js 프로젝트를 S3에 배포하는 것은 사실 위험하다.</p>
<p>만약에 <code>png</code> 파일을 <code>Image</code> 컴포넌트를 사용하여 보여준다면 오류가 발생한다.
Next.js의 Image 컴포넌트는 서버 측에서 이미지 최적화를 하기 때문이다.</p>
<p>Image 컴포넌트는 요청이 들어오면 해당 이미지를 변환하고 최적화 하는 과정을 거치는데, 브라우저가 아닌 Node.js나 Vercel과 같은 서버 환경에서 이뤄진다.
즉 동적 서버 환경에서 이미지 최적화 API를 활용하기 때문에 정적 스토리지인 S3에 빌드 결과물을 올리면 오류가 발생한다.</p>
<h2 id="💰-cloudfront와-cdn">💰 CloudFront와 CDN</h2>
<hr>
<p>CloudFront는 AWS에서 제공하는 CDN 서비스다.
그럼 CDN 서비스란 무엇일까?</p>
<p>CDN은 Content Delivery Network의 약자로, 쉽게 말해 사용자와 가까운 곳에 파일을 저장하는 저장소이다.</p>
<p>S3 저장소가 사용자와 물리적인 거리가 멀어 파일을 받을 때까지 시간이 오래 걸리는 문제를 사용자와 가까운 엣지 로케이션이라는 곳에 캐싱하여 시간을 단축한다.</p>
<p>사용자가 요청을 하면 CloudFront는 가장 가까운 엣지 로케이션에서 캐싱된 데이터를 제공한다.</p>
<p>또한 HTTP 압축, 불필요한 헤더 제거 등 캐싱 정책에 따라 최적화된 컨텐츠를 전달한다.</p>
<h3 id="💵-성능-비교">💵 성능 비교</h3>
<p>CDN을 사용하면 얼마나 빨라질까?
S3의 엔드포인트와 CloudFront의 배포 도메인 주소를 비교해보자.
비교를 위해 <a href="https://www.webpagetest.org/">https://www.webpagetest.org/</a> 사이트를 이용했다.
Chapt GPT한테 성능 비교 시각화하고 싶은데 알려달라니 알려줬다. ㅋ</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/25caf287-b027-447f-8678-46dbf4c3886a/image.png" alt=""></p>
<p>먼저 S3에 직접 접근했을 때의 지표이다.
첫 번째로 나오는 TTFB는 위에서 언급한 핵심 지표다.
그 다음으로 차례대로 설명하면</p>
<ul>
<li><strong>Start Render</strong>: 브라우저가 화면에 처음으로 비어있지 않은 콘텐츠가 그려지는 시점까지 걸리는 시간이다.</li>
<li><strong>First Contentful Paint</strong>: 브라우저가 첫 번째 텍스트나 이미지, 캔버스 등 DOM의 실제 콘텐츠를 그려내는 데 걸린 시간이다.</li>
<li><strong>Speed Index</strong>: 페이지가 얼마나 빠르게 보여지는지를 수치화한 것으로, 낮을 수록 좋다.</li>
<li><strong>Largest Contentful Paint</strong>: 화면에 나타나는 요소들 가운데 가장 큰 텍스트 혹은 이미지 요소가 렌더링된 시점까지 걸린 시간이다.</li>
<li><strong>Cumulative Layout Shift</strong>: 페이지가 로드되는 동안 발생하는 레이아웃 변경을 정량화한 지표로 0에 가까울수록 사용자가 화면 이동없이 안정적인 레이아웃을 경험한다.</li>
<li><strong>Total Blocking Time</strong>: 메인 스레드가 사용자가 입력에 응답할 수 없을 정도로 막혀 있는 시간을 합산한 지표로 오래 걸리는 JavaScript 작업이 있을 경우 증가할 수 있고 사용자의 반응성에 영향을 준다.</li>
<li><strong>Page Weight</strong>: 페이지가 로딩하는 전체 리소스 크기를 합산한 수치다.</li>
</ul>
<p>Cloud Front를 사용하면 얼마나 좋아질까?</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/2f2c220d-b6e6-49a3-b785-704f690f26ad/image.png" alt=""></p>
<p>모든 수치가 1초를 넘지 않고 확연히 좋아진 걸 알 수 있다.
Page Weight는 약 200kb 줄어들었다.
두 수치를 비교하면 아래와 같다.</p>
<table>
<thead>
<tr>
<th>성능 지표</th>
<th>CloudFront (CDN 적용)</th>
<th>S3 (직접 접근)</th>
</tr>
</thead>
<tbody><tr>
<td>TTFB</td>
<td>0.175 s</td>
<td>3.37 s</td>
</tr>
<tr>
<td>Start Render</td>
<td>약 0.5 s</td>
<td>약 3.70 s</td>
</tr>
<tr>
<td>FCP</td>
<td>약 0.48 s</td>
<td>약 3.71 s</td>
</tr>
<tr>
<td>Speed Index</td>
<td>약 0.51 s</td>
<td>약 3.81 s</td>
</tr>
<tr>
<td>LCP</td>
<td>약 0.48 s</td>
<td>약 3.95 s</td>
</tr>
<tr>
<td>Page Weight</td>
<td>194 KB</td>
<td>483 KB</td>
</tr>
</tbody></table>
<h3 id="💵-캐시-무효화cache-invalidation">💵 캐시 무효화(Cache Invalidation)</h3>
<p>캐시 무효화는 CDN에 저장된 캐시된 컨텐츠가 최신 상태가 아닐 때 이를 삭제하거나 갱신하는 과정을 의미한다.</p>
<p>캐시 무효화가 필요한 이유는 웹 애플리케이션의 콘텐츠가 변경되었을 때 사용자에게 최신 데이터를 제공하여 모든 사용자가 동일한 최신 컨텐츠를 받을 수 있게 하기 위해서다.
또한 잘못된 파일이 발견된 경우에 해당 캐시를 제거함으로써 문제를 해결할 수 있다.</p>
<h2 id="💰-마치며">💰 마치며</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/80559c1e-94c9-44e7-af25-18bac5be89eb/image.jpg" alt=""></p>
<p>이번주 과제는 적혀있는 가이드대로 따라하기만 하면 돼서 비교적 쉬웠다.
하지만 개념이 두루뭉술해서 팀 스터디 시간에 과제 내용에 대해서 얘기를 했다.</p>
<p>개인적으로 공부한 내용을 서술하고 확인받고 그래도 안 잡힌 개념을 다른 팀원분께서 명확하게 설명해주셨다.
두루뭉술했던 부분이 S3는 서버가 아니라 스토리지인데 어떻게 동작할까라는 물음이었는데 시원하게 해결됐다.</p>
<p>요즘 나만의 루틴을 찾으려 노력중인데, 잠이 너무 많다.
줄일 필요가 있다.
사실 잠을 많이 자고 일어나 있는 시간을 알차게 쓰면 베스트지만.. ㅎㅎ</p>
<p>&lt;스시 장인: 지로의 꿈&gt;이라는 다큐멘터리 영화를 봤다.
지난 멘토링에서 추천받은? 영화인데 대충 내용은 스시 하나로 미슐랭 3스타를 받은 스시 장인 할아버지의 일에 대한 이야기다.</p>
<blockquote>
<p>어떤 직업을 가질지 결정을 내렸다면 그 일에 몰두해야 합니다.
자신이 하는 일을 좋아하고 그 일에 반해야 합니다.
이게 안돼 저게 안돼 하면 평생을 한들 제대로 되지 않습니다.
기술을 익히겠다고 생각하면 평생 노력하며 기술을 연마해야 합니다.
그게 성공의 비결이죠.
또한 길이길이 남들로 부터 존경받는 비결이라 하겠습니다.</p>
</blockquote>
<p>처음 도입부에서 위와 같은 말을 한다.
뭐 저 말에 감명을 받아서 &#39;무조건 저렇게 해야한다!&#39;는 아니지만 뭔가 좋아해서 하는 것보단 좋아해야 해서 하는 것도 중요하구나?라는 느낌을 받았다.</p>
<p>같은 팀원분 중에 개발과 관련된 이야기를 하면 &quot;오~ 흥미로운 이야기인데요?&quot; 혹은 &quot;오~ 너무 재밌는 이야기인데요?&quot;라고 하시는 분이 계신다.
그 분이 전에 좋아한다고 하다보면 어느새 진짜로 좋아지게 된다라고 얘기했는데 오버랩됐다.</p>
<p>가끔 웨이트를 좋아하는 사람(이해되지 않지만) 가운데 특히 하체 운동을 더 좋아하는 사람(더 이해되지 않는다)을 보며 변태가 아닐까 생각했다.
사실 그 사람들도 과정을 즐기기 위해 의식적으로 재밌다고 하는 걸까?
음... 아니다. 하체 운동을 좋아하는 사람은 변태다.</p>
<p>아무튼 흥미를 쫓는 것도 좋지만 의식적으로 흥미롭게 보는 것도 중요한건가 싶다~</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[항해 플러스 프론트엔드 4기] 8주차 과제 회고]]></title>
            <link>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-8%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-8%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Fri, 14 Feb 2025 11:47:29 GMT</pubDate>
            <description><![CDATA[<p>과제를 제출하지 못했다.
정확히 얘기하면 과제를 했는데 제출하는 걸 까먹었다.</p>
<p>2주동안 테스트 코드 챕터를 겪으면서 내 힘으로 했다기 민망할 정도로 AI에 많은 의존을 했는데, 양심 좀 챙기라는 신호인가?!</p>
<p>예전에 학원에서 HTML, CSS를 배우고 자기 소개를 위한 홈페이지를 만드는 과제가 있었다.
그 과제를 한 뒤로 CSS를 통해 홈페이지를 보고 레이아웃을 만드는 데에는 나름 자신감이 생겼었다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/d588844c-28cd-46fb-ae22-37f845020ef5/image.jpg" alt=""></p>
<p>지금은 익숙하게 하는 것들이지만 과제를 하는 동안은 정말 머리에 쥐가 날 것 같은 경험을 했다.
그 뒤로 새로운 개념을 배울 때마다 비슷한 경험을 한다.
항상 새로운 걸 배울 때는 답답한 마음도 들고, 짜증, 분노, 온갖 부정적인 마음은 다 드는 것 같다.</p>
<p>테스트 코드 과정도 같았다.
테스트 코드가 원하는 대로 동작하지 않는데, 어디가 문제인 건지...
분명히 이 동작을 하면 요소가 생겨야 하는데, 왜 없다고 하는 건지...</p>
<p>이번 과제의 목표 중 하나는 TDD를 경험해보는 건데, 되려 테스트 코드가 의도한 대로 동작하지 않아서 실제 로직을 작성해서 화면에서 검사하는 주객이 전도된 상황을 경험했다.
실제 로직이 이상없는지 검사하는 게 테스트 코드인데, 테스트 코드를 검사하기 위해 실제 로직을 만들고 브라우저에서 확인을 하는... 도파민 디톡스를 하고 싶어서 유튜브에 도파민 근절하는 방법을 검색했다가 숏폼에 빠지는 느낌?!</p>
<h3 id="💵-cursor와-chatgpt">💵 cursor와 ChatGPT</h3>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/9a92d5fd-18f3-4bae-90c2-4a9f44530637/image.jpg" alt=""></p>
<p>그러다가 결국 cursor와 ChatGPT의 도움을 적극적으로 받았다.
그동안 흥선대원군 마인드로 AI 활용하는 걸 반대했던 것은 아니다.
다만 항해는 공부하러 온 입장이고, AI에 의존하다 보면 내가 학습하지 않고 어느 순간 프롬프트만 작성하고 있을 것 같아서 사용하지 않았다.</p>
<p>하지만 테스트 코드를 하면서 뭐랄까... 어디부터 시작해야 할지도 모르겠어서 도움을 받기로 했다.</p>
<p>좋았던 점은 몰라도 무지성으로 알려주는 대로 읽어보고 사용해보니 익숙해지는 점이다.
그래서 단위, 통합 테스트는 좋은 테스트를 짜는 법까지는 모르겠지만, 테스트 코드를 작성할 수는 있게 됐다.</p>
<p>안 좋은 점은 우려했던 대로 막히는 부분이 나오면 해답을 나올 때까지 AI에게 화내고 있는 나를 발견할 수 있다.
그 모습을 자각했을 때는 현타가 쎄게 밀려온다.
이래가지고 회사에서 1인분이나 할 수 있겠어?!</p>
<p>코드의 앞부분만 작성하면 cursor에서 코드를 추천?해준다.
cursor에서 보여주는 코드를 읽고 내가 의도하는 게 맞다 싶으면 Tab을 눌러 자동 완성을 시킨다.
하지만 내가 읽고 확인했다고 해서 그걸 내 지식이라고 할 수 있을까?
만약 추천을 안 해줬으면 내가 그대로 코드를 작성했을까?
휴대폰을 사용하면서 전화번호를 더이상 외우지 않는 것처럼 자동 완성에 의존하다 보면 내 머릿속에서 끄집어 낼 수 없는 건 아닐까?!</p>
<p>무언가 학습한 이후에 보조 수단으로써 AI를 쓰는 건 좋을 것 같은데, 잘 모르는 개념에 대해서 의존하는 건 안 좋을 것 같다는 생각이 들었다.
앞서 학원에서 한 경험을 토대로 머리에 쥐나는 과정을 거쳐야 내 껄로 소화할 수 있다는 걸 알지만 게으른 건지... ㅎㅎ
그런 의미로 다음 챕터에서는 VSCode로 돌아가서 과제를 해야겠다.
테스트 코드는 항해 기간이 끝나기 전에 한 번 학습하여 별도의 아티클로 게시해야징 ㅎㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[항해 플러스 프론트엔드 4기] 7주차 과제 회고]]></title>
            <link>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-7%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@jang_expedition/%ED%95%AD%ED%95%B4-%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-4%EA%B8%B0-7%EC%A3%BC%EC%B0%A8-%EA%B3%BC%EC%A0%9C-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sat, 08 Feb 2025 11:31:28 GMT</pubDate>
            <description><![CDATA[<p>7주차에는 바닐라 자바스크립트로 리액트 만들기, 클린 코드에 이어 테스트 코드를 배우게 됐다.</p>
<p>3주차에 리액트에 메모이제이션을 배우며, 리액트의 메모이제이션 훅을 쓰는 것보다 평소에 클린 코드를 통해 최적화할 상황을 만들지 않는 것이 우선이구나라고 생각했다.
클린 코드를 배우며, 클린 코드란 테스트가 용이한 코드로 자연스레 테스트 코드에 대한 호기심이 생겼다.
항해에서 커리큘럼 사이를 자연스레 넘어가도록 의도한 게 아닐까 생각한다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/8c6c8ddc-de73-4074-b34b-0a23ec6a925d/image.jpg" alt=""></p>
<p>아무튼 그래서 테스트 코드를 잘 하고 싶다는 생각이 들었다.
&#39;테스트 코드를 잘 한다&#39;라 함은 상황에 맞게 올바른 테스트 도구를 활용하고, 테스트가 용이하도록 코드를 잘 짜는 것을 포괄한다.</p>
<h2 id="💰-테스트-코드">💰 테스트 코드</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/f4ce75a0-be98-414c-85b2-7c45a734ef0a/image.JPG" alt=""></p>
<p>테스트란 코드가 의도한 대로 동작하는지 검증하는 것을 의미한다.</p>
<p>바닐라 스크립트로 리액트 만들기 과제에 테스트 코드가 작성되어 있었다.
코드를 바꾸면서 테스트 코드를 실행했고, 그때마다 초록불이 보이면 안심했던 기억이 있다.
그때 코치님께서 테스트 코드의 가장 큰 장점은 내 코드가 안전하다는 걸 보장받는 거라고 하신 말씀이 와닿았다.
테스트 코드는 이런 의미에서 장기적으로 개발 속도를 향상 시킨다고 한다.
코드를 바꿀 때마다 화면에서 일일히 확인할 필요없이 테스트 코드를 통해 빠르게 점검할 수 있기 때문이다.</p>
<p>위에서 얘기한 것처럼 테스트 코드는 클린 코드와 밀접하다.
테스트 코드를 위해서는 클린 코드를 해야하고 클린 코드를 하면 테스트 코드를 작성하기 용이하다.
클린 코드는 단순히 코드를 깔끔하게 짜는 것을 넘어, 동료 개발자에 대한 배려라고 한다.</p>
<p>또한 테스트 코드는 하나의 문서화가 된다.
테스트를 읽으면서 해당 기능을 명확하게 이해할 수 있게 되기 때문이다.</p>
<p>테스트 코드는 인터페이스 위주로 작성해야 한다고 한다.
인터페이스란 세부 구현에 대한 메서드가 아닌 public하게 외부에 노출되는 메서드를 이야기한다.</p>
<p>또한 실제 사용자가 사용하는 것과 최대한 비슷하게 작성하는 것이 좋은 테스트라고 한다.</p>
<blockquote>
<p>그러면 브라우저 환경에서 하는 테스트가 더 좋은가?</p>
</blockquote>
<p>브라우저는 실제 사용 환경과 유사한 테스트가 가능하지만 구동 속도가 느리고 용량이 크다는 단점이 있다.
반면 Node.js는 실행이 간단하고 속도가 빠르지만 브라우저 동작과 100% 일치하지 않는다.
그러니 각 장점을 활용할 수 있는 적절한 상황에 알맞는 도구를 사용하는 것이 중요하다.</p>
<h3 id="💵-단위-통합-테스트">💵 단위, 통합 테스트</h3>
<p>단위 테스트는 가장 작은 단위인 함수나 메서드 등을 독립적으로 검증하는 과정이다.
주로 공통 컴포넌트나 유틸, 헬퍼 함수가 대상이다.</p>
<p>통합 테스트는 단위 테스트된 모듈들을 결합하여 모듈 사이의 상호 작용을 검증한다.
주로 특정 상태를 기준으로 동작하는 컴포넌트 조합, API와 함께 상호 작용하는 컴포넌트 조합이 대상이다.
가능한 한 모킹을 하지 않고 실제와 유사하게 검증하는 것이 좋은데 어쩔 수 없다면 API 응답 같은 것은 msw같은 도구를 활용할 수 있다.</p>
<h3 id="💵-vitest와-react-testing-library">💵 Vitest와 React Testing Library</h3>
<p>나는 자바스크립트 테스트면 Jest만 있는 줄 알았다.
하지만 Vitest, React Testing Library, Cypress 등등 여러 도구가 있었다.
이번 과제에는 Vitest에서 React Testing Library를 사용하여 단위, 통합 테스트를 했다.</p>
<p>Vitest는 Vite 환경에서 사용할 수 있는, Jest와 비슷한 테스트 러너다.
테스트 러너란, 테스트를 실행하고 결과를 평가하는 도구다.</p>
<p>React Testing Library는 React 컴포넌트 테스트를 위한 라이브러리다.
즉 React 컴포넌트를 브라우저에서 동작하는 것처럼 테스트할 수 있도록 도와준다.</p>
<pre><code class="language-js">import { render, screen } from &quot;@testing-library/react&quot;;
import { describe, it, expect } from &quot;vitest&quot;;
import MyComponent from &quot;./MyComponent&quot;;

describe(&quot;MyComponent&quot;, () =&gt; {
  it(&quot;renders a button with text &#39;Click me&#39;&quot;, () =&gt; {
    render(&lt;MyComponent /&gt;);
    expect(screen.getByText(&quot;Click me&quot;)).toBeInTheDocument();
  });
});</code></pre>
<p>예를 들어 위 코드에서 Vitest는 <code>describe</code>, <code>it</code>, <code>expect</code>를 제공하여 테스트를 작성할 수 있게 한다.
React Testing Library는 <code>render</code>, <code>screen</code>, <code>getByText</code>를 제공하여 컴포넌트를 렌더링하고 요소를 찾아낸다.</p>
<p>여기서 조금 헷갈렸던 게 <code>expect(기대값).toBeInTheDocument</code>에서 <code>toBeInTheDocument</code>는 <code>expect().</code> 뒤에 와서 Vitest에서 제공하는 기능이겠거니 했다.</p>
<p>&#39;그러면 React Testing Library에서 제공하는 건가?&#39;라고 하면 그것도 아니다.</p>
<p><code>toBeInTheDocument</code>는 <code>@testing-library/jest-dom</code>에서 제공한다.
얜 또 뭘까?</p>
<p><code>@testing-library/jest-dom</code>은 <code>expect</code> 매처를 확장해서, DOM 요소를 테스트할 수 있도록 도와주는 역할을 한다.
쉽게 얘기하면 DOM 관련 테스트를 더 편하게 도와주는 확장 도구다.</p>
<p>그 외에도 사용자가 발생하는 이벤트를 테스트할 수 있도록 돕는 <code>@testing-library/user-event</code>도 있다.</p>
<p>각 라이브러리에 대한 사용법과 예시는 나중에 따로 정리할 예정이니 일단은 이정도만 알고 넘어가자.</p>
<p>정리하면 단위 테스트는 Vitest만으로도 가능하다.
하지만 통합 테스트를 할 경우, <code>Vitest</code>에서 기본적으로 제공되는 테스트 도구들만으로는 불편하다.
이를 돕는 여러 라이브러리가 있는데, 대표적으로 <code>@testing-library/jest-dom</code>, <code>@testing-library/user-event</code>가 있다.</p>
<h3 id="💵-통합-테스트의-역할이-뭘까">💵 통합 테스트의 역할이 뭘까?</h3>
<p>단위 테스트가 테스트하는 범위는 명확하다고 생각한다.
유틸 함수들이 제일 대표적인 예시다.
유틸 함수들이 테스트가 용이해지려면 순수 함수로 작성해야 할 것이다.</p>
<p>하지만 통합 테스트가 애매하다.
앞서 얘기했지만 좋은 테스트는 사용자가 사용하는 것과 유사한 거라고 한다.
즉 브라우저 환경에서 동작하는 e2e 테스트가 통합 테스트보다 더 좋은 테스트라고 생각한다.
하지만 규모가 크다면?
규모가 큰 애플리케이션에서 모든 컴포넌트의 상호작용에 대해서 e2e 테스트를 작성하면 배포할 때 테스트하는 시간이 오래 걸리지 않을까?
통합 테스트의 역할은 뭘까?</p>
<p>팀원분들과 얘기를 나누다가, 요구사항이 바뀌는 시기에는 통합 테스트로 작성하다가 확정이 되면 e2e 테스트로 작성하지 않을까란 얘기도 나왔다.</p>
<p>다음주 과제도 테스트 코드인데, 팀원 분들과 테스트 전략을 짜면서 여러 이야기를 나눠봐야 할 것 같다.</p>
<h2 id="💰-마치며">💰 마치며</h2>
<hr>
<p>설연휴로 일주일을 쉬고 다시 항해를 시작하니, 흐름이 끊긴 느낌이다.
설연휴에도 팀원분들과 스터디를 계획하고 진행했으나, 갑자기 생긴 대상포진으로 내리 쉬었다.
6주차 회고때 밤을 새고 면역력이 떨어진 느낌을 받았다고 적었는데, 정말로 면역력이 떨어진 게 맞았다.
로또 1등될 것 같다고 써놓을 걸 ㅋ
원래 7주차쯤 되면 지치는 건데 설연휴와 건강을 핑계로 삼고 있는 걸지도 모르겠다.</p>
<p><img src="https://velog.velcdn.com/images/jang_expedition/post/2988c467-d45c-46f2-8637-4628ab61885a/image.JPG" alt=""></p>
<p>이번주부터는 사이드 프로젝트와 병행했는데, 과제에 신경을 쓰니 사이드 프로젝트에 소홀해지고 사이드 프로젝트에 신경쓰면 과제에 소홀해지는 느낌이다.
시간 분배를 적절하게 하고 적극적으로 지켜야 하는데, 아무래도 강제성이 주입된 일의 느낌이 아니라서 그런지 너무 유연하게 대처한다... ㅎㅎ
그런 의미에서 일과 병행하면서 과제를 완벽하게 끝내는 분들 정말 대단하다.</p>
<p>다음주부터는 오전에 집에서 하고 오후에 카페를 나가서 리프레시를 해볼까?</p>
]]></description>
        </item>
    </channel>
</rss>