<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>areumh__9.log</title>
        <link>https://velog.io/</link>
        <description>빙글빙글돌아가는..</description>
        <lastBuildDate>Wed, 25 Mar 2026 13:20:40 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>areumh__9.log</title>
            <url>https://images.velog.io/images/areumh__9/profile/2d6f0000-58a0-4b8a-9adf-cb814f666366/20210715_081454.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. areumh__9.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/areumh__9" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[웹 크롤링 + AI 기반 원문 적합성 판단 구현해보기]]></title>
            <link>https://velog.io/@areumh__9/%EC%9B%B9-%ED%81%AC%EB%A1%A4%EB%A7%81-AI-%EA%B8%B0%EB%B0%98-%EC%9B%90%EB%AC%B8-%EC%A0%81%ED%95%A9%EC%84%B1-%ED%8C%90%EB%8B%A8-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@areumh__9/%EC%9B%B9-%ED%81%AC%EB%A1%A4%EB%A7%81-AI-%EA%B8%B0%EB%B0%98-%EC%9B%90%EB%AC%B8-%EC%A0%81%ED%95%A9%EC%84%B1-%ED%8C%90%EB%8B%A8-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Wed, 25 Mar 2026 13:20:40 GMT</pubDate>
            <description><![CDATA[<p>AI 시대에도 직접 글을 읽고 요약하는 능력을 길러야 한다는 취지로 <a href="https://yojeong.ai.kr/">요약의 정석: 요정</a> 이라는 프로젝트를 진행 중에 있다. 기존엔 원문을 사용자가 직접 작성(혹은 복붙)해야 했는데, 이 수고를 덜기 위해 링크 입력 기능을 추가하여 원문을 크롤링해 가져올 수 있도록 업그레이드 시키기로 했다! 👊</p>
<h2 id="1️⃣-크롤링-구현">1️⃣ 크롤링 구현</h2>
<p>웹 크롤링 기능을 구현하기 위해 <code>cheerio</code> 라이브러리를 사용했다. 전체 흐름은 <code>URL 패칭 → HTML 파싱 → 노이즈 제거 → 본문 영역 탐색 → 텍스트 정제</code> 5단계로 구성된다.</p>
<h3 id="url-패칭">URL 패칭</h3>
<pre><code class="language-typescript">const response = await axios.get(url, {
  timeout: 10000,
  headers: {
    &#39;User-Agent&#39;: &#39;Mozilla/5.0 (compatible; YojeongBot/1.0)&#39;,
     Accept: &#39;text/html,application/xhtml+xml&#39;,
    &#39;Accept-Language&#39;: &#39;ko-KR,ko;q=0.9,en;q=0.8&#39;
  },
  maxRedirects: 5,
  responseType: &#39;text&#39;
});</code></pre>
<p><code>axios.get</code>으로 HTML을 가져온다. 한국어 콘텐츠 대상이라 <code>Accept-Language: ko-KR</code>을 설정하고, 10초 타임아웃과 최대 5회 리다이렉트를 허용한다.</p>
<h3 id="html-파싱">HTML 파싱</h3>
<pre><code class="language-typescript">const html = response.data;

if (!html || typeof html !== &#39;string&#39;) {
  return { status: &#39;fetch_failed&#39;, content: null };
}
</code></pre>
<p><code>cheerio.load(html)</code>로 서버사이드 DOM을 생성한다. cheerio는 jQuery 스타일 API를 제공하는 경량 HTML 파서로, 브라우저 없이 DOM 조작이 가능하다.</p>
<h3 id="노이즈-제거">노이즈 제거</h3>
<pre><code class="language-typescript">/** HTML에서 본문 텍스트를 추출 (CONTENT_SELECTORS 우선 탐색 → 실패 시 body 전체) */
export function extractText($: cheerio.CheerioAPI): string {
  $(REMOVE_SELECTORS.join(&#39;, &#39;)).remove();

  for (const selector of CONTENT_SELECTORS) {
    const el = $(selector);
    if (el.length &gt; 0) {
      const text = el.first().text();
      const cleaned = cleanText(text);
      if (cleaned.length &gt; 0) {
        return cleaned;
      }
    }
  }

  return cleanText($(&#39;body&#39;).text());
}
</code></pre>
<p>상수 <strong>REMOVE_SELECTORS</strong>에 정의된 요소들(script, style, nav, header, footer ...)을 DOM에서 먼저 제거한다. 본문과 무관한 UI/광고/댓글 영역을 사전에 걸러내는 단계이다.</p>
<h3 id="본문-영역-탐색">본문 영역 탐색</h3>
<p><strong>CONTENT_SELECTORS</strong> 배열(article → [role=&quot;main&quot;] → main → .post-content → .entry-content 등)을 순서대로 탐색한다. 먼저 매칭되면서 텍스트가 1,000자 이상인 셀렉터의 본문을 채택한다. 모든 셀렉터가 실패하면 body 전체 텍스트를 fallback으로 사용한다.</p>
<h3 id="텍스트-정제">텍스트 정제</h3>
<pre><code class="language-typescript">/** 연속 공백/개행을 정리하여 깔끔한 텍스트로 변환 */
export function cleanText(raw: string): string {
  return raw
    .replace(/\n{3,}/g, &#39;\n\n&#39;)       // 3줄 이상 연속 개행 → 2줄로 축소
    .replace(/[^\S\n]+/g, &#39; &#39;)        // 개행 제외 연속 공백 → 단일 공백
    .trim();
}
</code></pre>
<p>3줄 이상 연속 개행을 2줄로, 연속 공백을 단일 공백으로 정규식 치환하여 깔끔한 텍스트를 만든다.</p>
<h2 id="2️⃣-원문-처리">2️⃣ 원문 처리</h2>
<p>단순히 링크 입력 + 크롤링 기능만 추가하면 될 것 같았지만 생각해야할 부분이 너무 많았다. 그 중 첫 번째는 가져온 <strong>원문이 요약 가능한 글인지에 대한 확인 여부</strong>였다. 처음엔 크롤링 기능만 구현하려 했으나 요약이 충분히 가능한 글인지에 대한 검증을 거치지 않으면 사용자가 실수로 다른 링크를 넣었거나, 원치 않는 분석 결과를 얻게 될 경우에 대한 처리가 불가하다고 판단했다.</p>
<blockquote>
<p><strong>AI 없이 판단 가능한 것들</strong></p>
</blockquote>
<ul>
<li>로그인 필요: HTTP 응답 코드가 401, 403이거나 로그인 페이지로 리다이렉트된 경우</li>
<li>페이지 자체를 못 가져옴: 404, 500 등</li>
<li>텍스트가 너무 적음: 추출된 텍스트가 N자 미만이면 &quot;내용이 부족하다&quot;고 판단</li>
<li>JS로만 렌더링되는 SPA: fetch로 받은 HTML에 본문이 거의 없는 경우<blockquote>
<p><strong>AI 없이는 애매한 것들</strong></p>
</blockquote>
</li>
<li>텍스트는 있는데 의미 있는 글이 아닌 경우 (상품 목록, 버튼 텍스트 모음 등)</li>
<li>텍스트 양은 충분한데 요약할만한 내러티브가 없는 경우</li>
</ul>
<p>그렇게 ai를 활용하기로 결정했고, 우선 ai에게 <code>내가 긴 글이 포함되지 않은 링크를 주며 요약을 부탁하면 너는 어떻게 판단해?</code> 라고 물어봤다. <del>니가 해야될 일이니까....</del></p>
<h3 id="글이-없는-링크의-경우">글이 없는 링크의 경우</h3>
<p>예를 들어 쇼핑몰 상품 페이지라면 요약이라는 개념 자체가 맞지 않으니 대신 가져온 내용 기반으로 상품명, 가격, 주요 스펙, 특징 같은 걸 정리하는 방식으로 자연스럽게 전환된다. </p>
<h3 id="페이지를-아예-못-읽는-경우">페이지를 아예 못 읽는 경우</h3>
<p>로그인이 필요한 페이지이거나, JavaScript로만 렌더링되는 SPA라서 콘텐츠가 없거나, 접근 자체가 막힌 경우엔 <strong>페이지의 내용을 가져올 수 없다</strong>고 전달하고 가능하면 <strong>대안을 제안</strong>한다. </p>
<h3 id="읽을-수-있지만-내용이-빈약한-경우">읽을 수 있지만 내용이 빈약한 경우</h3>
<p>가져온 HTML에 텍스트가 거의 없거나 메타 정보만 있는 경우엔 그 정보 내에서 최대한 파악한 걸 알려주지만, <strong>충분한 정보가 없어 한계가 있다</strong>고 함께 전달한다.</p>
<p>링크를 받으면 무조건 요약이 아닌 <code>가져온 내용의 성격에 맞게 응답 방식을 조정</code>하는 것이라고 한다.</p>
<h3 id="원문-글자-수-검증">원문 글자 수 검증</h3>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/935a06f2-8140-4110-8d9e-f49428914d72/image.png" alt=""></p>
<p>기존의 원문을 직접 작성하던 방식에선 원문의 길이를 1000자 이상, 5000자 이하로 제한해두었기 때문에 크롤링해 가져온 원문에도 동일한 제한을 두기로 했다. 원문을 추출한 뒤 글자 수가 5000자가 넘으면 <code>slice</code> 함수로 원문을 잘랐고, 1000자 미만이면 요약 불가로 처리하기로 결정했다.</p>
<h2 id="3️⃣-api-명세">3️⃣ API 명세</h2>
<p>위 1번에서의 경우들을 전부 따졌을 때 총 5가지의 경우로 나뉜다고 판단했고, 사용자에게 추출된 원문에 대한 상태를 텍스트로 제시하기 위해 위의 각 상태를 api 응답에 추가하기로 했다.</p>
<pre><code class="language-typescript">Response: 200 OK
{
  success: true,
  data: {
    status: &quot;success&quot; | &quot;truncated&quot; | &quot;under_limit&quot; | &quot;unsuitable_content&quot; | &quot;fetch_failed&quot;,
    content: string | null  // fetch_failed, under_limit일 경우 null
  }
}</code></pre>
<ul>
<li><strong>success</strong> : 1000 ~ 5000자 범위로 원문이 정상 추출됨</li>
<li><strong>truncated</strong> : 원문이 정상적으로 추출되었으나 5000자 초과로 잘림</li>
<li><strong>under_limit</strong> : 1000자 미만으로 요약 불가</li>
<li><strong>unsuitable_content</strong> : 텍스트는 있으나 요약하기 어려운 내용</li>
<li><strong>fetch_failed</strong> : 페이지 접근 불가 (로그인 필요, 404 등)</li>
</ul>
<pre><code class="language-typescript">// src/constants/message.ts

export const MESSAGE = {
  EXTRACT: {
    SUCCESS: &#39;원문을 불러왔어요!&#39;,
    TRUNCATED: &#39;원문을 불러왔어요! 원문이 길어 앞 5000자만 분석에 사용돼요.&#39;,
    UNSUITABLE: &#39;요약하기 적합하지 않은 글이에요. 요약 결과가 다소 부정확할 수 있어요.&#39;,

    UNDER_LIMIT: &#39;원문이 너무 짧아 분석이 어려워요. (1000자 이상의 글을 입력해주세요.)&#39;,
    FAILED: &#39;페이지를 불러올 수 없어요. 링크를 다시 확인해주세요.&#39;,
  },
};


// src/hooks/extract/useExtractStatus.ts

interface ExtractStatusResult {
  isUsable: boolean;
  message: string;
}

const STATUS_MAP: Record&lt;ExtractStatus, ExtractStatusResult&gt; = {
  success: { isUsable: true, message: MESSAGE.EXTRACT.SUCCESS },
  truncated: { isUsable: true, message: MESSAGE.EXTRACT.TRUNCATED },
  unsuitable_content: { isUsable: true, message: MESSAGE.EXTRACT.UNSUITABLE },
  under_limit: { isUsable: false, message: MESSAGE.EXTRACT.UNDER_LIMIT },
  fetch_failed: { isUsable: false, message: MESSAGE.EXTRACT.FAILED },
};

export const useExtractStatus = (status: ExtractStatus | null): ExtractStatusResult | null =&gt; {
  if (!status) return null;
  return STATUS_MAP[status];
};
</code></pre>
<p>각 상태에 따른 메세지를 상수로 분리했고, api 응답의 상태 값을 인자로 받아 원문 요약 가능 여부와 메세지를 한 번에 리턴받을 수 있도록 <code>useExtractStatus</code> 훅을 작성했다.</p>
<p>이후 페이지 전환에 사용하기 위해 원문이 1000자 미만인 <code>under_limit</code>, 원문 추출에 실패한 <code>fetch_failed</code>는 isUsable 값을 <strong>false</strong>로 처리했다. </p>
<h2 id="4️⃣-요약-가능-판단-프롬프트">4️⃣ 요약 가능 판단 프롬프트</h2>
<p>ai에게 <code>텍스트 품질 검증 전문가</code>라는 직업을 심어주었고... 요약이 가능할 만한 글인지에 대한 여부와 그에 대한 간단한 이유를 JSON 형식으로 반환하도록 작성했다.</p>
<pre><code class="language-typescript">## 판단 기준

위 텍스트가 **요약할 만한 의미 있는 본문**인지 판단하세요.

**isSuitable: true 인 경우:**
- 기사, 블로그 글, 에세이, 논설문 등 논리적 구조가 있는 글
- 특정 주제에 대해 설명하거나 주장하는 글
- 읽고 요약할 수 있는 충분한 내용이 있는 글

**isSuitable: false 인 경우:**
- 네비게이션 메뉴, 버튼 텍스트, 링크 목록 등 UI 요소의 나열
- 로그인 폼, 에러 페이지, 검색 결과 목록 등 구조적 페이지
- 상품 스펙, 가격표 등 단순 데이터 나열
- 의미 없는 텍스트 조각이나 깨진 인코딩
- 여러 글의 제목/미리보기만 나열된 목록형 페이지

---

## 출력 형식 (JSON만 반환)

{
  &quot;isSuitable&quot;: true 또는 false,
  &quot;reason&quot;: &quot;판단 이유를 한 문장으로 간단히 설명&quot;
}</code></pre>
<p>추출된 원문이 1000자 이상일 때 위의 프롬프트를 통해 요약 적합성을 판단한 후, <code>isSuitable</code>이 <strong>false</strong>이면 <code>unsuitable_content</code>를 반환, <strong>true</strong>이면 5000자 이하인지 검증하는 구조이다.</p>
<h2 id="5️⃣-ux-개선">5️⃣ UX 개선</h2>
<p>원문 처리 다음으로 고민한 부분...</p>
<p>기존 페이지는 원문 작성 페이지와 요약 작성 페이지가 분리되어 있었고, 요약 작성 페이지에서 로컬 스토리지를 통해 이전 페이지의 원문 값을 가져와 요약 분석 요청 api에 함께 보내는 구조였다. </p>
<h3 id="원문과-로컬-스토리지-값-동기화">원문과 로컬 스토리지 값 동기화</h3>
<p>원문 작성만 존재할 땐 사용자가 입력한 값을 그대로 저장하면 됐기 때문에 문제가 없었지만, 링크 입력 방식이 추가되면서 각 모드에 맞게 로컬 스토리지와 원문 값을 일치시켜야 하는 문제가 생겼다. </p>
<ul>
<li><strong>원문 작성 모드</strong> : 사용자가 textarea에 직접 입력한 값이 state로 관리되는 <code>userContent(원문)</code>에 들어감.</li>
<li><strong>링크 입력 모드</strong> : 크롤링 API 응답으로 추출된 원문이 <code>setUserContent(res.data.content)</code>로 들어감. 하지만 링크 모드일 때는 textarea가 렌더링되지 않기 때문에 화면에 보이지 않고 내부적으로만 보관됨.</li>
</ul>
<p>원문 데이터는 다음 단계 버튼을 눌러야 로컬 스토리지에 저장되는데, <code>링크 모드로 원문 추출 -&gt; 다음 페이지로 이동한 뒤 다시 원문 작성 페이지로 돌아옴 -&gt; 원문 작성 모드로 전환</code> 과 같은 흐름이 이어진다면 원문 작성 textarea에 링크 모드로 추출된 원문 값이 남아있는 문제가 생긴다.</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/e79a331c-19d5-47dd-8d65-ba242e5280bd/image.png" alt=""></p>
<p>위 문제 해결을 위해 모드 전환 시 사용자에게 모달을 제시하도록 했다. 확인 버튼을 누르면 url, 원문, 추출 상태, 로컬 스토리지 값을 모두 비운다. </p>
<pre><code class="language-typescript">// 전체 데이터 흐름

[원문 입력 페이지]
   │
   ├─ 링크 모드: URL 입력 → 크롤링 API → userContent에 추출 원문 저장 (화면에는 미표시)
   ├─ 텍스트 모드: textarea 입력 → userContent에 직접 저장
   │
   ├─ 모드 전환 (입력값 있을 때) → 모달 확인 → state + localStorage 초기화
   │
   ├─ &quot;다음 단계로&quot; 클릭 → localStorage에 저장 → /summary 이동
   │
   └─ 뒤로 가기로 재진입 → useEffect에서 localStorage 복원

[요약 입력 페이지]
   │
   ├─ useEffect에서 localStorage 읽기 → 원문 없으면 /input으로 리다이렉트
   │
   └─ &quot;AI 분석 시작&quot; 클릭 → API 호출 → localStorage 삭제 → 메인으로 이동
</code></pre>
<h3 id="원문-추출-후-링크-수정-방지">원문 추출 후 링크 수정 방지</h3>
<p>만약 원문 추출이 완료된 이후 링크 값을 수정한다면 링크와 추출된 원문 값 사이에 불일치 문제가 생기게 된다. 해당 문제가 일어나지 않도록 원문 추출 여부 상태 값 <code>isContentLoaded</code>을 선언했고, 이를 링크 input 컴포넌트의 <strong>readonly 속성</strong>에 사용했다. </p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/abe34f2f-01b8-4f8d-b2ab-8b075a36339e/image.png" alt=""></p>
<pre><code class="language-typescript">&lt;Input
  id=&quot;url&quot;
  type=&quot;url&quot;
  placeholder=&quot;https://example.com/article&quot;
  value={url}
  onChange={(e) =&gt; setUrl(e.target.value)}
  readOnly={isContentLoaded}
  className={`... ${isContentLoaded ? &#39;bg-app-gray-50 cursor-not-allowed&#39; : &#39;&#39;}`}
/&gt;</code></pre>
<p>추출이 완료되면 Input은 읽기 전용으로 잠기고, 배경 색이 바뀌어 시각적으로도 수정 불가 상태가 된다. 원문 추출 버튼도 isContentLoaded 값에 따라 전환된다.</p>
<pre><code class="language-typescript">{isContentLoaded ? (
  &lt;Button onClick={resetInputText}&gt;
    &lt;RefreshCw className=&quot;w-4 h-4 mr-2&quot; /&gt;
    수정
  &lt;/Button&gt;
) : (
  &lt;Button
    onClick={handleExtract}
    disabled={!isValidUrl(url) || isExtracting}
  &gt;
    {isExtracting ? &#39;불러오는 중...&#39; : &#39;원문 불러오기&#39;}
  &lt;/Button&gt;
)}</code></pre>
<ul>
<li><strong>추출 전</strong> : 원문 불러오기 버튼이 표시됨. <code>isValidUrl(url)</code>로 URL 형식이 유효한지 검증하고, 유효하지 않으면 버튼 비활성화</li>
<li><strong>추출 후</strong> : 수정 버튼으로 전환됨. 클릭하면 <code>resetInputText()</code>가 호출되어 URL, 원문, 추출 상태가 모두 초기화되고 처음부터 다시 입력 가능</li>
</ul>
<p>URL을 부분 수정하는 것은 허용하지 않고, 초기화 후 새로 입력하는 방식으로 링크와 원문 간의 불일치 문제를 원천 차단했다. 👍</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[개인 프로젝트를 next v16으로 마이그레이션하기]]></title>
            <link>https://velog.io/@areumh__9/%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-next-v16%EC%9C%BC%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@areumh__9/%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-next-v16%EC%9C%BC%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 21 Mar 2026 07:52:32 GMT</pubDate>
            <description><![CDATA[<hr>
<h2 id="서버-컴포넌트-마이그레이션">서버 컴포넌트 마이그레이션</h2>
<p>기존 <code>search/page.tsx</code>는 전체가 <code>&#39;use client&#39;</code>였다. 이는 다음 문제를 야기한다.</p>
<blockquote>
</blockquote>
<ul>
<li>첫 페이지 데이터를 브라우저에서 fetch → 초기 로딩 느림</li>
<li>API 키가 <code>NEXT_PUBLIC_</code>으로 브라우저에 노출</li>
<li>Google API를 브라우저에서 직접 호출 → Referer 차단 이슈</li>
</ul>
<p>CSR로만 구현하면 초기 로딩 시 빈 화면이 잠깐 노출되거나 초기 로딩이 느린 이슈가 생길 수 있다. 이번 업데이트된 기능들을 사용하여 첫 페이지는 서버 컴포넌트, 두 번째 페이지부터는 클라이언트에서 가져오도록 마이그레이션하기로 했다!</p>
<h3 id="서버-컴포넌트-vs-클라이언트-컴포넌트">서버 컴포넌트 vs 클라이언트 컴포넌트</h3>
<table>
<thead>
<tr>
<th align="center"></th>
<th>서버 컴포넌트</th>
<th>클라이언트 컴포넌트</th>
</tr>
</thead>
<tbody><tr>
<td align="center">실행 위치</td>
<td>서버</td>
<td>브라우저</td>
</tr>
<tr>
<td align="center">JS 번들 포함</td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td align="center">브라우저 API</td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td align="center">DB/API 직접 접근</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td align="center"><code>useState</code>, <code>useEffect</code></td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td align="center">선언 방법</td>
<td>기본값</td>
<td><code>&#39;use client&#39;</code> 명시</td>
</tr>
</tbody></table>
<h3 id="use-cache">use cache</h3>
<p><code>&#39;use cache&#39;</code>는 Next.js 16에서 정식 도입된 캐싱 문법이다. 함수 상단에 선언하면 동일한 인자로 호출 시 캐시된 결과를 반환한다. 기존 fetch의 cache 옵션은 fetch 단위에서만 동작하지만, <code>&#39;use cache&#39;</code>는 어떤 비동기 함수에도 적용할 수 있다. <strong>next.config.ts</strong>에서 <code>cacheComponents: true</code> 활성화가 필요하다.</p>
<pre><code class="language-typescript">// api/serverSearch.ts

export const fetchNewsFirstPage = async (query: string, sort: string): Promise&lt;NewsResponse&gt; =&gt; {
  &#39;use cache&#39;; // 같은 query + sort 조합이면 재요청 없이 캐시 반환

  const res = await fetch(
    `https://openapi.naver.com/v1/search/news.json?query=${encodeURIComponent(query)}&amp;display=${PAGE_ELEMENT}&amp;start=1&amp;sort=${sort}`,
    {
      headers: {
        &#39;X-Naver-Client-Id&#39;: ENV.NAVER_CLIENT_ID,
        &#39;X-Naver-Client-Secret&#39;: ENV.NAVER_CLIENT_SECRET,
      },
    },
  );

  return res.json();
};

</code></pre>
<p>이 데이터는 HTML에 직접 포함되어 내려오므로, 사용자는 JS 번들이 로드되기 전에도 첫 20개의 뉴스를 볼 수 있다. 서버 컴포넌트는 이를 initialData로 클라이언트 컴포넌트에 전달한다. </p>
<pre><code class="language-typescript">// app/search/page.tsx

export default async function Search({
  searchParams,
}: {
  searchParams: Promise&lt;{ query?: string; sort?: &#39;sim&#39; | &#39;date&#39; }&gt;;
}) {
  const { query = &#39;&#39;, sort = &#39;sim&#39; } = await searchParams;
  const initialData = query ? await fetchNewsFirstPage(query, sort) : null;

  return &lt;NewsSearchClient query={query} sort={sort} initialData={initialData} /&gt;;
}
</code></pre>
<p>NewsSearchClient 컴포넌트는 initialData를 useNewsListQuery 훅에 넘기는데, 내부적으로 TanStack Query의 <strong>useInfiniteQuery</strong>가 이 데이터를 첫 번째 페이지의 캐시로 등록한다. 덕분에 하이드레이션 후에도 불필요한 재요청이 발생하지 않는다. </p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/9b10c5f3-ea54-4d54-9bc4-81a53541b0f8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/58869b1f-e9bb-43f5-85b7-2e467b88b562/image.png" alt=""></p>
<p>적용 이후 FCP 값이 3.3s에서 2.4s로 약 27%, LCP 값은 17.2s에서 6.2s로 약 64% 개선되었다!</p>
<h3 id="route-handler">route handler</h3>
<p>route handler는 v16 업데이트 내용은 아니지만, 파라미터로 key 값을 넘겨줘야 하기 때문에 브라우저에 해당 값이 노출되던 기존 google api 호출 방식의 문제를 해결하기 위해 도입했다. </p>
<p><code>app/api/*/route.ts</code> 파일로 만드는 서버 전용 API 엔드포인트다. 브라우저가 외부 API를 직접 호출하는 대신 서버를 거치도록 중간 역할을 한다.</p>
<p><strong>디렉토리 경로가 곧 URL의 경로</strong>이며, <code>route.ts</code>라는 이름 자체가 이 <strong>경로의 핸들러</strong>라는 뜻이 된다.</p>
<pre><code class="language-tsx">app/api/sentiment/route.ts
         ↓
URL: /api/sentiment</code></pre>
<p>그리고 export된 함수의 이름이 <strong>HTTP의 메서드</strong>가 된다.</p>
<pre><code class="language-tsx">export async function GET()    → GET  /api/sentiment
export async function POST()   → POST /api/sentiment
export async function DELETE() → DELETE /api/sentiment</code></pre>
<pre><code class="language-typescript">// app/api/sentiment/route.ts

export const POST = async (req: NextRequest) =&gt; {
  const body = await req.json();
  const key = ENV.GOOGLE_API_KEY; // 서버에만 존재하는 환경변수

  const res = await fetch(`https://language.googleapis.com/v2/documents:analyzeSentiment?key=${key}`, {
    method: &#39;POST&#39;,
    headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
    body: JSON.stringify(body),
  });

  const data = await res.json();
  return NextResponse.json(data, { status: res.status });
};



// api/sentiment.ts

export const postAnalyzeSentiment = async (text: string) =&gt; {
  const { data } = await axios.post&lt;SentimentResponse&gt;(&#39;/api/sentiment&#39;, {
    encodingType: &#39;UTF8&#39;,
    document: {
      type: &#39;PLAIN_TEXT&#39;,
      content: text,
    },
  });

  return data;
};</code></pre>
<p>route handler를 두어 google api 호출을 서버로 옮겼다. API 키는 env 파일에서 읽어오므로 클라이언트에 노출되지 않는다!</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/51be6b8c-418d-4cb2-8283-4649a8dcb0a1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/0ca27695-7b10-451d-84c0-ec5381db8475/image.png" alt=""></p>
<pre><code class="language-typescript">// 변경 전 구조

브라우저
└── search/page.tsx (&#39;use client&#39;)
    ├── useSearchParams()
    ├── useInfiniteQuery() → axios → /naver-api → Naver API
    └── NewsCard
        └── axios → /google-api → Google API
            └── NEXT_PUBLIC_GOOGLE_API_KEY (브라우저에 노출)</code></pre>
<pre><code class="language-typescript">// 변경 후 구조

서버
└── search/page.tsx (서버 컴포넌트)
    ├── searchParams prop으로 query, sort 수신
    ├── fetchNewsFirstPage() — &quot;use cache&quot;
    │   └── fetch → Naver API 직접 호출
    │       └── NAVER_API_CLIENT / NAVER_API_CLIENT_KEY (서버에만 존재)
    └── &lt;NewsSearchClient initialData={...} /&gt;

브라우저
└── NewsSearchClient.tsx (&#39;use client&#39;)
    ├── useInfiniteQuery() — initialData로 첫 페이지 수신
    ├── 2페이지~ → fetch → /api/news (Route Handler)
    │                         └── fetch → Naver API
    │                             └── NAVER_API_CLIENT / NAVER_API_CLIENT_KEY (서버에만 존재)
    ├── sort 변경 → router.push → URL 업데이트 → 서버 재렌더
    └── NewsCard
        └── POST /api/sentiment (Route Handler)
            └── fetch → Google API
                └── GOOGLE_API_KEY (서버에만 존재)
</code></pre>
<h2 id="proxyts">proxy.ts</h2>
<p>Next.js 16에서 <code>middleware.ts</code> 컨벤션이 <code>proxy.ts</code>로 변경됐다. 파일명과 export 함수명 모두 <strong>proxy</strong>로 바꿔야 한다.</p>
<pre><code class="language-tsx">// src/proxy.ts

import { NextRequest, NextResponse } from &#39;next/server&#39;;

export function proxy(req: NextRequest) {
  const { searchParams } = req.nextUrl;

  if (!searchParams.get(&#39;query&#39;)?.trim()) {
    return NextResponse.redirect(new URL(&#39;/&#39;, req.url));
  }
}

export const config = {
  matcher: &#39;/search&#39;,
};</code></pre>
<p><code>/search?query=</code> 없이 접근하면 <code>/</code>로 리다이렉트한다. 서버에서 요청을 가로채기 때문에 페이지가 렌더링되기 전에 처리된다.</p>
<h2 id="추가-리팩토링">추가 리팩토링</h2>
<p>긍정 / 부정 / 분석 중 3-상태 렌더링 뉴스 카드 컴포넌트의 리팩토링을 진행했다. 기존에는 <code>renderContent()</code> 함수 안에서 if/else로 3가지 상태를 처리했고, 감정 분석 호출에 useMutation + useEffect 구조를 사용했다. </p>
<pre><code class="language-typescript">// 변경 전
// NewsCard/index.tsx

const { mutation } = useAnalyzeSentiment(newsContent);

useEffect(() =&gt; {
  if (mutation.status === &#39;idle&#39; &amp;&amp; !!newsContent.trim()) {
    mutation.mutate();
  }
}, [mutation, newsContent]);

const renderContent = () =&gt; {
  if (isLoading) return &lt;NewsCardLoading /&gt;;
  if (isVisible &amp;&amp; news) return &lt;NewsCardContent ... /&gt;;
  return &lt;NewsCardNegative /&gt;;
};
</code></pre>
<p><code>useMutation</code>은 명시적으로 mutate()를 호출해야 실행된다. 그래서 컴포넌트가 마운트되면 useEffect 안에서 수동으로 트리거하고 있었다. 하지만 <strong>감정 분석은 데이터를 변경하는 게 아닌 조회하는 행위</strong>이다. google NLP API에 POST로 보내는 건 맞지만, 그건 HTTP 메서드의 문제였고, React Query에서의 역할은 다른 개념이었다. 그리고 useMutation 사용으로 인해 아래의 문제가 있었다.</p>
<ul>
<li>캐싱이 없어 같은 뉴스 제목으로 분석을 요청해도 매번 API를 호출</li>
<li>useEffect를 사용하여 마운트 시점에 수동으로 <code>mutate()</code>를 호출 필요</li>
<li>중복 호출을 막기 위해 <code>mutation.status === &#39;idle&#39;</code>와 같은 방어 코드 필요</li>
</ul>
<p>렌더링 로직도 <code>renderContent</code> 함수로 분기 처리를 통해 상태 체크로 useMutation의 한계를 메워가는 구조였다.</p>
<pre><code class="language-typescript">// 변경 후 
// hooks/api/sentiment.ts

export const useAnalyzeSentiment = (text: string) =&gt; {
  return useQuery({
    queryKey: [&#39;sentiment&#39;, text],
    queryFn: () =&gt; postAnalyzeSentiment(text),
    enabled: !!text,
  });
};


// NewsCard/index.tsx

type CardState = &#39;loading&#39; | &#39;visible&#39; | &#39;hidden&#39;;

const cardState: CardState = isLoading ? &#39;loading&#39; : isVisible ? &#39;visible&#39; : &#39;hidden&#39;;

const contentMap: Record&lt;CardState, React.ReactNode&gt; = {
  loading: &lt;NewsCardLoading /&gt;,
  visible: news &amp;&amp; &lt;NewsCardContent news={news} isTitleOnly={isTitleOnly} /&gt;,
  hidden: &lt;NewsCardNegative /&gt;,
};</code></pre>
<p>useMutation 대신 <code>useQuery</code>를 사용했다. <code>enabled</code> 조건이 충족되면 마운트 시 자동으로 fetch하므로 방어 코드도 필요가 없어졌다. 쿼리 키를 통해 동일한 텍스트에 대한 요청은 React Query가 캐싱해서 API를 다시 호출하지 않는다.</p>
<p>컴포넌트도 함수 분기가 아닌 객체 맵으로 처리하도록 했다. 이는 상태 결정과 렌더링이 분리되어 각 의도의 독립적 파악이 가능하며, 상태 추가 시 contentMap에 한 줄만 추가하면 된다. JSX 반환부도 <code>{contentMap[cardState]}</code> 한 줄로 깔끔히 유지가 가능하다.</p>
<h3 id="react-virtuoso">react-virtuoso</h3>
<pre><code class="language-typescript">const NewsList = ({ news, filter, hasNextPage, isFetchingNextPage, onLoadMore }: NewsListProps) =&gt; {
  return (
    &lt;Virtuoso
      // 브라우저 window 스크롤 기준으로 가상화
      useWindowScroll
      // 렌더링할 데이터 배열
      data={news}
      // 실제 화면 밖 아래쪽 500px 영역까지 미리 렌더링
      // endReached 조기 트리거 + 스크롤 버벅임 방지
      overscan={500}
      // 마지막 아이템에 도달했을 때 호출 (다음 페이지 fetch)
      endReached={() =&gt; {
        if (hasNextPage &amp;&amp; !isFetchingNextPage) onLoadMore?.();
      }}
      className=&quot;w-full&quot;
      // 각 아이템의 렌더링 내용 
      // idx: 인덱스, item: 해당 뉴스 데이터
      itemContent={(idx, item) =&gt; (
        &lt;div key={`${item.title}-${idx}`} className=&quot;pb-2 sm:pb-5&quot;&gt;
          &lt;NewsCard
            news={item}
            isTitleOnly={filter.showTitleOnly}
            isPositiveOnly={filter.showPositiveOnly}
          /&gt;
        &lt;/div&gt;
      )}
      components={{
        // 리스트 맨 하단에 고정으로 렌더링되는 영역 (페이징 중일 때 스피너 표시)
        Footer: () =&gt;
          isFetchingNextPage ? (
            &lt;div className=&quot;flex w-full justify-center items-center py-4&quot;&gt;
              &lt;SpinnerIcon className=&quot;w-8 h-8 text-indigo-400 animate-spin&quot; style={{ animationDuration: &#39;1.5s&#39; }} /&gt;
            &lt;/div&gt;
          ) : null,
      }}
    /&gt;
  );
};</code></pre>
<p>무한 스크롤 성능 최적화를 위해 <code>react-virtuoso</code> 라이브러리를 사용해 가상화를 적용했다.</p>
<p>브라우저 스크롤을 기준으로 가상화하기 위해 <strong>useWindowScroll</strong>을 적용했고, 화면 아래 500px를 미리 렌더링하여 스크롤 버벅임을 방지하고 endReached를 조기 트리거하기 위해 <strong>overscan</strong> 속성을 사용했다. </p>
<table>
<thead>
<tr>
<th>components 키</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>Header</code></td>
<td>리스트 최상단 고정 영역</td>
</tr>
<tr>
<td><code>Footer</code></td>
<td>리스트 최하단 고정 영역</td>
</tr>
<tr>
<td><code>Item</code></td>
<td>각 아이템의 wrapper 요소</td>
</tr>
<tr>
<td><code>List</code></td>
<td>전체 아이템을 감싸는 컨테이너</td>
</tr>
<tr>
<td><code>ScrollSeekPlaceholder</code></td>
<td>빠른 스크롤 중 실제 컴포넌트 대신 보여줄 placeholder</td>
</tr>
<tr>
<td><code>EmptyPlaceholder</code></td>
<td>데이터가 없을 때 렌더링되는 영역</td>
</tr>
</tbody></table>
<p>다음 페이지가 로딩 중인 동안엔 isFetchingNextPage 값이 true일 시 Footer에 스피너가 보여지도록 구현했다. </p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/512f38d5-721a-4e95-823a-eae71d7946fe/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/2bb25ae0-c3a1-4817-9921-6f28a84588eb/image.png" alt=""></p>
<p>가상화 적용 이전에는 스크롤할수록 DOM 노드가 누적되었지만, 적용 이후에는 스크롤량에 무관하게 DOM 노드 수가 10개 이하로 유지되었다.😊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[react-hook-form + zod + zustand persist 로 프로젝트 위자드 폼 작성하기]]></title>
            <link>https://velog.io/@areumh__9/react-hook-form-zod-zustand-persist-%EB%A1%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9C%84%EC%9E%90%EB%93%9C-%ED%8F%BC-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@areumh__9/react-hook-form-zod-zustand-persist-%EB%A1%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9C%84%EC%9E%90%EB%93%9C-%ED%8F%BC-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Mar 2026 05:20:39 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.commitnow.dev/">커밋나우</a>의 과제를 진행하면서 프로젝트 위자드 생성 폼을 구현하게 되었다. </p>
<p>새로고침을 해도 폼 입력 값이 유지되어야 한다는 요구사항을 보고 어떻게 구현해야할 지 고민하며 방법을 알아보던 중 <code>zustand persist</code>를 알게 되었고, 폼 데이터 자체를 전역 상태로 관리하게 되면서 <strong>키 입력마다 리렌더, 여러 곳에 흩어져버린 검증 로직, 필드 레벨 에러가 없는</strong> 등의 문제가 발생했다 🥲</p>
<p>멘토님께서 리뷰로 <strong>react-hook-form</strong> + <strong>zod</strong> + <strong>zustand</strong> 방식을 추천해주셨고, 해당 방식으로 리팩토링을 진행했다. 그 과정을 글로 남겨보려고 한다!</p>
<h2 id="zod로-폼-스키마-정의하기">zod로 폼 스키마 정의하기</h2>
<p>우선 <a href="https://zod.dev/">zod</a>를 사용하여 프로젝트 폼의 요구사항에 맞게 검증 스키마를 정의했다.</p>
<pre><code class="language-typescript">// src/schemas/wizard.ts

import { z } from &#39;zod&#39;;
import { PROJECT, ERROR, ROLES } from &#39;@/constants&#39;;

export const BasicInfoSchema = z.object({
  name: z.string().min(PROJECT.NAME.MIN, ERROR.NAME).max(PROJECT.NAME.MAX, ERROR.NAME),
  description: z.string().max(PROJECT.DESCRIPTION.MAX, ERROR.DESCRIPTION),
  isPublic: z.boolean(),
});

export const TeamMemberSchema = z.object({
  teamMembers: z
    .array(z.object({ userId: z.string(), role: z.enum(ROLES) }))
    .min(PROJECT.TEAM_MEMBER.MIN, ERROR.TEAM_MEMBER),
});

export const TechSchema = z.object({
  techStackIds: z.array(z.string()).min(PROJECT.TECH.MIN, ERROR.TECH),
});

export const ScheduleSchema = z
  .object({
    startDate: z.string().min(1, ERROR.SCHEDULE),
    endDate: z.string().min(1, ERROR.SCHEDULE),
    milestones: z.array(
      z.object({
        id: z.string(),
        name: z.string().min(1, ERROR.MILESTONE_NAME),
        targetDate: z.string().min(1, ERROR.MILESTONE_DATE),
      }),
    ),
  })
  .superRefine(({ startDate, endDate }, ctx) =&gt; {
    if (startDate &amp;&amp; endDate &amp;&amp; startDate &gt;= endDate) {
      ctx.addIssue({ code: &#39;custom&#39;, message: ERROR.START_DATE, path: [&#39;startDate&#39;] });
      ctx.addIssue({ code: &#39;custom&#39;, message: ERROR.END_DATE, path: [&#39;endDate&#39;] });
    }
  });

export type BasicInfoValues = z.infer&lt;typeof BasicInfoSchema&gt;;
export type TeamMemberValues = z.infer&lt;typeof TeamMemberSchema&gt;;
export type TechValues = z.infer&lt;typeof TechSchema&gt;;
export type ScheduleValues = z.infer&lt;typeof ScheduleSchema&gt;;
</code></pre>
<p><code>.object</code>, <code>.array</code>, <code>.string</code> 와 같이 스키마는 단순한 기본 값부터 복잡한 중첩 객체 및 배열에 이르기까지 다양한 데이터 유형을 나타낸다. 그리고 내장 문자열 유효성 검사 및 변환 API를 제공한다. </p>
<pre><code class="language-typescript">name: z.string().min(PROJECT.NAME.MIN, ERROR.NAME).max(PROJECT.NAME.MAX, ERROR.NAME)</code></pre>
<p><strong>min</strong>과 <strong>max</strong> 메서드를 사용하여 데이터의 최소, 최대 길이를 지정할 수 있다.
두 번째 인자로는 <strong>검증 실패 시 보여줄 에러 메세지 값</strong>을 넘겨준다!</p>
<h3 id="superrefine">superRefine</h3>
<p><code>.superRefine()</code>을 사용하면 zod의 내부 이슈 유형을 생성할 수 있다.</p>
<pre><code class="language-typescript">schema.superRefine((data, ctx) =&gt; {
  // 검증 로직
});</code></pre>
<p>첫 번째 인자 <code>data</code>는 스키마가 파싱한 값 전체이고, 위의 코드에서는 구조 분해로 <strong>{ startDate, endDate }</strong> 를 가리킨다.
두 번째 인자인 <code>ctx</code>는 refinement context로 에러를 직접 추가할 수 있는 도구이다. </p>
<ul>
<li><strong>code</strong> : 에러 코드 (custom이 가장 일반적)</li>
<li><strong>message</strong> : 에러 메세지</li>
<li><strong>path</strong> : 어떤 필드에 에러를 연결할 지 지정하는 배열</li>
</ul>
<h3 id="infer">infer</h3>
<p><code>.infer</code> 유틸리티를 사용하여 해당 스키마의 타입을 추출하고 원하는 대로 사용할 수 있다. 
위의 코드에서 <strong>BasicInfoValues</strong>는 아래와 같은 값을 가진다.</p>
<pre><code class="language-typescript">type BasicInfoValues = {
    name: string;
    description: string;
    isPublic: boolean;
}</code></pre>
<p>이는 react-hook-form의 <strong>useForm</strong>에 연결하여 사용할 수 있다!</p>
<h2 id="react-hook-form과-연결하기">react-hook-form과 연결하기</h2>
<p>react-hook-form의 <code>useForm</code>, zod의 <code>zodResolver</code>을 사용하여 폼 데이터를 다룰 수 있다.</p>
<pre><code class="language-typescript">// src/pages/BasicInfoPage.tsx

const { draft, updateDraft } = useWizardStore();
const { handleNext } = useWizardNavigation();

const {
  register,
  handleSubmit,
  watch,
  setValue,
  formState: { errors },
} = useForm&lt;BasicInfoValues&gt;({
  resolver: zodResolver(BasicInfoSchema),
  mode: &#39;onTouched&#39;,
  defaultValues: {
    name: draft.name,
    description: draft.description,
    isPublic: draft.isPublic,
  },
});

const isPublic = watch(&#39;isPublic&#39;);


// 컴포넌트에 함수 연결
&lt;form
  id=&quot;wizard-step-form&quot;
  onSubmit={handleSubmit((values) =&gt; {
    updateDraft(values);
    handleNext();
  })}
  className=&quot;flex flex-col w-full gap-10&quot;
&gt;
  &lt;div className=&quot;flex flex-col gap-1&quot;&gt;
    &lt;label className=&quot;font-bold px-1&quot;&gt;프로젝트 이름&lt;/label&gt;
    &lt;Input
      placeholder={PLACE_HOLDER.NAME}
      errorMessage={errors.name?.message}
      {...register(&#39;name&#39;, { onChange: (e) =&gt; updateDraft({ name: e.target.value }) })}
    /&gt;
  &lt;/div&gt;

  &lt;div className=&quot;flex flex-col gap-1&quot;&gt;
    &lt;label className=&quot;font-bold px-1&quot;&gt;프로젝트 설명&lt;/label&gt;
    &lt;TextArea
      placeholder={PLACE_HOLDER.DESCRIPTION}
      errorMessage={errors.description?.message}
      {...register(&#39;description&#39;, { onChange: (e) =&gt; updateDraft({ description: e.target.value }) })}
    /&gt;
  &lt;/div&gt;

  &lt;div className=&quot;flex flex-col w-full gap-1&quot;&gt;
    &lt;label className=&quot;font-bold px-1&quot;&gt;공개 여부&lt;/label&gt;
    &lt;div className=&quot;flex gap-5&quot;&gt;
      &lt;RadioOption
        name=&quot;visibility&quot;
        value=&quot;public&quot;
        checked={isPublic === true}
        onChange={() =&gt; {
          setValue(&#39;isPublic&#39;, true);
          updateDraft({ isPublic: true });
        }}
      &gt;
        공개
      &lt;/RadioOption&gt;
      &lt;RadioOption
        name=&quot;visibility&quot;
        value=&quot;private&quot;
        checked={isPublic === false}
        onChange={() =&gt; {
          setValue(&#39;isPublic&#39;, false);
          updateDraft({ isPublic: false });
        }}
      &gt;
        비공개
      &lt;/RadioOption&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/form&gt;</code></pre>
<h3 id="register">register</h3>
<p>입력 필드를 react-hook-form에 등록하는 함수이다. 
<code>{...register(&#39;name&#39;)}</code> 처럼 spread하면 <strong>onChange, onBlur, ref</strong> 등 필요한 이벤트 핸들러가 Input에 자동으로 연결된다. </p>
<p>두 번째 인자는 옵션 객체로, 추가로 실행할 콜백을 끼워 넣을 수 있다. 
즉, 위 코드의 경우엔 필드 값이 바뀔 때 form 내부 상태를 업데이트하면서 <strong>updateDraft</strong> 함수가 동시 실행된다.</p>
<h3 id="handlesubmit">handleSubmit</h3>
<p>form의 <strong>onSubmit</strong> 이벤트를 감싸는 래퍼이다. 
내부적으로 zod 스키마 유효성 검사를 먼저 실행하고, 통과하면 콜백(values =&gt; ...)을 실행한다. 
실패하면 콜백 호출 없이 <strong>errors</strong>를 업데이트한다.</p>
<h3 id="watch">watch</h3>
<p>특정 필드 값을 <strong>실시간으로 구독</strong>하는 함수이다. 
위의 경우엔 <code>watch(&#39;isPublic&#39;)</code>으로 라디오 버튼의 선택 상태를 읽어 <strong>checked</strong>의 prop에 전달하고, 값이 바뀔 때마다 리렌더링이 트리거된다.</p>
<h3 id="setvalue">setValue</h3>
<p>코드에서 <strong>직접 특정 필드 값을 설정</strong>한다. 
register로 연결하기 어려운 커스텀 컴포넌트에 사용할 수 있다. </p>
<pre><code class="language-typescript">// src/pages/TeamSetupPage.tsx

const setMembers = (updated: TeamMemberValues[&#39;teamMembers&#39;]) =&gt; {
  setValue(&#39;teamMembers&#39;, updated, { shouldValidate: true });
  updateDraft({ teamMembers: updated });
};</code></pre>
<p>첫 번째 인자는 값을 <strong>업데이트할 필드</strong>, 두 번째 인자는 <strong>설정할 새 값</strong>이다.
setValue는 기본적으로 값만 바꾸고 유효성 검사를 다시 실행하지 않는데, 세 번째 인자로 <code>{ shouldValidate: true }</code>를 넘겨주면 값을 바꾼 직후 즉시 재검사한다. </p>
<table>
<thead>
<tr>
<th align="center">옵션</th>
<th align="center">기본값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">shouldValidate</td>
<td align="center">false</td>
<td>값 변경 후 유효성 검사 즉시 실행</td>
</tr>
<tr>
<td align="center">shouldDirty</td>
<td align="center">false</td>
<td>isDirty, dirtyFields 업데이트</td>
</tr>
<tr>
<td align="center">shouldTouch</td>
<td align="center">false</td>
<td>touchedFields에 해당 필드 추가</td>
</tr>
</tbody></table>
<h3 id="formstate--errors-">formState: { errors }</h3>
<p>유효성 검사 실패 시 발생한 에러 메세지를 담는 객체이다. 
<code>errors.name?.message</code>처럼 접근하여 사용할 수 있고, 직접 구현한 Input 컴포넌트에 errorMessage를 prop으로 전달하여 컴포넌트 하단에 입력 값에 대한 에러 메세지를 제시하도록 했다.</p>
<table>
<thead>
<tr>
<th align="center">속성</th>
<th align="center">타입</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">errors</td>
<td align="center">object</td>
<td>유효성 검사 실패 시 에러 메세지</td>
</tr>
<tr>
<td align="center">isValid</td>
<td align="center">boolean</td>
<td>모든 필드가 유효한지 여부</td>
</tr>
<tr>
<td align="center">isDrity</td>
<td align="center">boolean</td>
<td>초기값에서 하나라도 변경됐는지</td>
</tr>
<tr>
<td align="center">dirtyFields</td>
<td align="center">object</td>
<td>변경된 필드 목록</td>
</tr>
<tr>
<td align="center">isSubmitting</td>
<td align="center">boolean</td>
<td>제출 중인지 (비동기 처리 시 유용)</td>
</tr>
<tr>
<td align="center">isSubmitted</td>
<td align="center">boolean</td>
<td>한 번이라도 제출을 시도했는지</td>
</tr>
<tr>
<td align="center">touchedFields</td>
<td align="center">object</td>
<td>포커스된 적 있는 필드 목록</td>
</tr>
</tbody></table>
<h3 id="mode">mode</h3>
<p><code>mode</code>는 에러를 언제 보여줄지 결정한다. 
<strong>onTouched</strong>는 <strong>필드에 한 번 포커스 후 벗어난 시점부터 검사를 시작</strong>하므로 처음부터 에러를 노출하지 않아 사용자 경험상 자연스럽다고 판단했다!</p>
<table>
<thead>
<tr>
<th align="center">mode</th>
<th>검사 시점</th>
</tr>
</thead>
<tbody><tr>
<td align="center">onChange</td>
<td>타이핑할 때마다</td>
</tr>
<tr>
<td align="center">onBlur</td>
<td>포커스 아웃 시</td>
</tr>
<tr>
<td align="center">onTouched</td>
<td>첫 포커스 아웃 이후부터 onChange</td>
</tr>
<tr>
<td align="center">onSubmit</td>
<td>제출 시에만</td>
</tr>
</tbody></table>
<h2 id="zustand--persist-미들웨어로-전역-상태-관리하기">zustand + persist 미들웨어로 전역 상태 관리하기</h2>
<p><code>persist</code>는 zustand의 미들웨어로 페이지를 새로고침하거나 애플리케이션을 다시 시작해도 스토어의 상태를 유지할 수 있다.</p>
<pre><code class="language-typescript">const nextStateCreatorFn = persist(stateCreatorFn, persistOptions)</code></pre>
<h3 id="statecreatorfn">stateCreatorFn</h3>
<p><code>(set, get) =&gt; ({ ... })</code>의 형태로 zustand 스토어의 상태와 액션을 정의하는 함수이다.
<strong>set</strong>으로 상태를 변경하고, <strong>get</strong>으로 현재 상태를 읽는다.</p>
<h3 id="persistoptions">persistOptions</h3>
<p><strong>localStorage에 어떻게 저장하고 복원할지</strong>에 대한 설정 객체이다.
<code>name</code>, <code>version</code>, <code>partialize</code>, <code>migrate</code> 등의 속성값이 존재하고, 각 속성이 존재하지 않으면 앱이 터질 수 있는 위험이 있다.</p>
<p>전역 데이터에 필드를 추가하거나 이름을 바꾸면 기존 사용자의 localStorage에는 옛날 구조의 데이터가 남아있기 때문에, 앱 업데이트 후 이 데이터가 복원될 때 새로 추가한 필드는 <strong>undefined</strong>가 된다. 
이를 방지하기 위해 현재 데이터 구조의 버전 번호인 <strong>version</strong>과 이전 버전 데이터를 새 구조로 변환시켜주는 <strong>migrate</strong>를 꼭 작성해야 한다.</p>
<p>추가로 <strong>partialize</strong> 없이 persist를 사용하면 store 전체를 localStorage에 저장하게 된다. 만약 store에 draft 외에 다른 상태가 추가될 경우 의도치 않은 동작이 생겨날 수 있다. 그러므로 partialize를 사용하여 저장할 데이터를 명시적으로 지정하는 과정이 필요하다! </p>
<pre><code class="language-typescript">// src/store/useWizardStore.ts

interface WizardStore {
  draft: ProjectDraft;

  updateDraft: (partial: Partial&lt;ProjectDraft&gt;) =&gt; void;
  isStepValid: (step: WizardStep) =&gt; boolean;
  reset: () =&gt; void;
}

const initialDraft: ProjectDraft = {
  name: &#39;&#39;,
  description: &#39;&#39;,
  isPublic: true,
  teamMembers: [],
  techStackIds: [],
  startDate: &#39;&#39;,
  endDate: &#39;&#39;,
  milestones: [],
};

export const useWizardStore = create&lt;WizardStore&gt;()(
  persist(
    (set, get) =&gt; ({
      draft: initialDraft,

      updateDraft: (partial) =&gt; set((state) =&gt; ({ draft: { ...state.draft, ...partial } })),
      isStepValid: (step) =&gt; {
        const { draft } = get();
        switch (step) {
          case 1:
            return BasicInfoSchema.safeParse(draft).success;
          case 2:
            return TeamMemberSchema.safeParse(draft).success;
          case 3:
            return TechSchema.safeParse(draft).success;
          case 4:
            return ScheduleSchema.safeParse(draft).success;
          default:
            return true;
        }
      },
      reset: () =&gt; set({ draft: initialDraft }),
    }),
    {
      name: &#39;wizard-draft&#39;,
      version: 1,
      partialize: (state) =&gt; ({ draft: state.draft }),
      migrate: (persistedState, version) =&gt; {
        const state = persistedState as { draft?: Partial&lt;ProjectDraft&gt; };
        if (version === 0) {
          return { ...state, draft: { ...state.draft, milestones: state.draft?.milestones ?? [] } };
        }
        return state;
      },
    },
  ),
);
</code></pre>
<p>위자드 상태 <strong>draft</strong>와 draft의 일부만 받아 기존 값에 병합하는 업데이트 함수 <strong>updateDraft</strong>, 각 스텝의 zod 스키마로 검사해 유효 여부를 반환하는 <strong>isStepValid</strong>, draft를 초기값으로 되돌리는 <strong>reset</strong> 함수로 구성하여 스토어를 생성했다.</p>
<p><code>.safeParse</code>는 zod의 유효성 검사 메서드로 <strong>{ success, data, error }</strong> 객체를 반환한다. 
<code>.parse</code>를 사용하면 예외가 발생하지만 isStepValid에서는 success 여부만 확인하면 되기 때문에 .safeParse를 사용하여 각 스텝의 검증이 통과되었는지 확인하도록 했다!</p>
<h2 id="form-바깥의-버튼에-submit-연결하기">form 바깥의 버튼에 submit 연결하기</h2>
<p>페이지마다 이전, 다음 버튼이 존재해야 한다는 과제의 요구 사항에 따라 각 스텝의 form은 Outlet 안에 렌더링되도록 했고, 이전 / 다음 버튼은 layout 컴포넌트에 고정했다. 즉, 버튼과 form이 DOM 상 다른 위치에 존재한다.</p>
<p>일반적으로 <code>type=&quot;submit&quot;</code> 버튼은 같은 form 안에 있어야 제출을 트리거할 수 있는데, 이 문제를 해결하기 위해 HTML 표준의 form 속성을 사용했다. </p>
<pre><code class="language-typescript">// src/layout/WizardLayout.tsx

&lt;Button type=&quot;submit&quot; form=&quot;wizard-step-form&quot; size=&quot;lg&quot; isActive={isStepValid(currentStep)}&gt;
  다음
&lt;/Button&gt;</code></pre>
<p>버튼의 <strong>form</strong> 속성에 form의 <strong>id</strong>를 지정하면, 위치와 무관하게 해당 form의 <strong>submit</strong> 이벤트를 발생시킬 수 있다. 다음 버튼 클릭 시 아래의 순서대로 실행된다.</p>
<blockquote>
</blockquote>
<ol>
<li>다음 버튼 클릭 <code>type=&quot;submit&quot;, form=&quot;wizard-step-form&quot;</code></li>
<li><code>&lt;form id=&quot;wizard-step-form&quot; onSubmit={...}&gt;</code> 의 submit 이벤트 발생</li>
<li>react-hook-form의 <code>handleSubmit</code> 실행</li>
<li>유효성 검사 통과 → <code>(values) =&gt; { updateDraft(values); handleNext(); }</code></li>
<li>유효성 검사 실패 → errors에 에러 채움, 콜백 실행 안 함</li>
</ol>
<pre><code class="language-typescript">// src/layout/WizardLayout.tsx

const { handleSubmit } = useSubmitWizard();

&lt;Button size=&quot;lg&quot; onClick={handleSubmit}&gt;
  프로젝트 생성
&lt;/Button&gt;
</code></pre>
<p>마지막 스텝에서는 다음 버튼 대신 프로젝트 생성 버튼을 제시하도록 했고, 더 이상 form 유효성 검사가 필요하지 않기 때문에 <strong>onClick</strong> 이벤트에 useWizardForm 훅의 handleSubmit 함수를 연결해주었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LeetCode 135. Candy]]></title>
            <link>https://velog.io/@areumh__9/LeetCode-135.-Candy</link>
            <guid>https://velog.io/@areumh__9/LeetCode-135.-Candy</guid>
            <pubDate>Sat, 17 Jan 2026 17:27:23 GMT</pubDate>
            <description><![CDATA[<p><a href="https://leetcode.com/problems/candy/description/?envType=study-plan-v2&amp;envId=top-interview-150">문제 링크</a></p>
<hr>
<h3 id="문제-해석">문제 해석</h3>
<p>아이들이 일렬로 서 있고, 각 아이는 ratings 배열로 평점를 가진다.
사탕을 나눠줄 때 다음의 조건을 만족해야 한다.</p>
<ol>
<li>모든 아이는 <strong>최소 1개의 사탕</strong>을 받아야 한다.</li>
<li>이웃한 아이보다 <strong>평점이 높은 경우</strong>, 그 아이는 <strong>더 많은 사탕</strong>을 받아야 한다.</li>
</ol>
<br />

<h3 id="예제-분석">예제 분석</h3>
<p><code>ratings = [1, 2, 2]</code></p>
<p>해당 예제에서 가장 헷갈렸던 부분은 왜 정답이 <code>[1, 2, 2]</code>가 아닌 <code>[1, 2, 1]</code>인지였다.</p>
<p>조건을 다시 보면 평점이 <strong>더 높은 경우에만 사탕을 더 많이 받으면 되므로</strong> 평점이 같으면 더 많이 줄 필요가 없다. 문제의 조건을 만족하는 사탕의 최소 개수를 구하는 문제이므로 평점이 높은 경우만 따지면 된다!</p>
<h3 id="코드-구현">코드 구현</h3>
<p>우선 모든 아이가 최소 1개의 사탕을 가지므로 길이가 ratings의 길이와 같은 배열을 1로 채운다.</p>
<pre><code class="language-javascript">const n = ratings.length;
const candies = new Array(n).fill(1);</code></pre>
<p>그리고 왼쪽 → 오른쪽으로 순회하며 현재 아이의 평점이 왼쪽보다 높으면 사탕의 수를 1씩 늘린다.</p>
<pre><code class="language-javascript">for (let i = 1; i &lt; candies.length; i++) {
    if (ratings[i] &gt; ratings[i - 1]) {
        candies[i] = candies[i - 1] + 1;
    }
}</code></pre>
<p>그 후 오른쪽 → 왼쪽으로 순회하며 현재 아이의 평점이 오른쪽보다 높으면 기존 값과 오른쪽의 사탕 수에서 1을 더한 값을 비교하여 더 큰 값을 선택한다. </p>
<p>두 수 중에서 더 큰 값을 선택하는 이유는 이미 왼쪽 → 오른쪽을 순회하며 만족한 조건을 오른쪽 → 왼쪽을 순회하며 깨뜨리면 안되기 때문이다! 좌우 이웃과의 조건을 동시에 만족해야 하기 때문에 <code>Math.max</code>를 사용하여 값을 업데이트해준다.</p>
<pre><code class="language-javascript">for (let i = n - 2; i &gt;= 0; i--) {
    if (ratings[i] &gt; ratings[i + 1]) {
        candies[i] = Math.max(candies[i], candies[i + 1] + 1);
    }
}</code></pre>
<p>최종적으로 구해야 하는 값은 사탕의 총 개수이므로 <code>reduce</code> 메서드를 사용하여 candies 배열의 총합을 구하면 된다.</p>
<pre><code class="language-javascript">return candies.reduce((a, b) =&gt; a + b, 0);</code></pre>
<hr>
<h3 id="전체-코드">전체 코드</h3>
<pre><code class="language-javascript">/**
 * @param {number[]} ratings
 * @return {number}
 */
var candy = function (ratings) {
    const n = ratings.length;
    const candies = new Array(n).fill(1);

    for (let i = 1; i &lt; candies.length; i++) {
        if (ratings[i] &gt; ratings[i - 1]) {
            candies[i] = candies[i - 1] + 1;
        }
    }

    for (let i = n - 2; i &gt;= 0; i--) {
        if (ratings[i] &gt; ratings[i + 1]) {
            candies[i] = Math.max(candies[i], candies[i + 1] + 1);
        }
    }

    return candies.reduce((a, b) =&gt; a + b, 0);
};</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드에게 개인 프로젝트 리팩토링을 시켜보았다]]></title>
            <link>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EC%97%90%EA%B2%8C-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%EC%9D%84-%EC%8B%9C%EC%BC%9C%EB%B3%B4%EC%95%98%EB%8B%A4</link>
            <guid>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EC%97%90%EA%B2%8C-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%EC%9D%84-%EC%8B%9C%EC%BC%9C%EB%B3%B4%EC%95%98%EB%8B%A4</guid>
            <pubDate>Sun, 07 Dec 2025 08:12:52 GMT</pubDate>
            <description><![CDATA[<p>예전에 급하게 만들고 그대로 놔뒀던 <strong>뉴스 감정 필터링 프로젝트</strong>를 오랜만에 다시 열어보았다.</p>
<p>최근들어 부정적인 기사가 너무 많은 것 같아 긍정적인 뉴스 기사만 골라볼 수 있는 기능이 있다면 좋겠다는 생각에 시작한 서비스인데, 필요한 기능만 빠르게 만들고 그대로 방치해버려서 일단 급하게 버셀 배포만 한 상태이다.. 늦은 감이 있지만 뒤늦게 리팩토링을 해보려 한다!!!</p>
<hr>
<h2 id="일단-클로드에게-부탁하기">일단 클로드에게 부탁하기</h2>
<p>우선 나는 클로드 프로를 사용 중이며, 클로드에게 현재 내 프로젝트에 리팩토링할 만한 부분이 있는지 질문해보았다. </p>
<blockquote>
<h2 id="🎯-핵심-리팩토링-포인트">🎯 핵심 리팩토링 포인트</h2>
</blockquote>
<h3 id="1️⃣-높은-우선순위-즉시-개선-권장">1️⃣ 높은 우선순위 (즉시 개선 권장)</h3>
<h3 id="1-에러-핸들링-부재">1. 에러 핸들링 부재</h3>
<ul>
<li>src/api/search.ts, src/api/sentiment.ts에 <code>try-catch</code> 없음</li>
<li>API 실패 시 사용자 피드백 부재<h3 id="2-과도하게-긴-컴포넌트">2. 과도하게 긴 컴포넌트</h3>
</li>
<li>FilterOption.tsx:19-103 - 여러 기능 혼재</li>
<li>NewsCard.tsx:15-81 - 복잡한 조건부 렌더링</li>
<li>개선방안: SortDropdown, FilterCheckboxGroup 등으로 분리<h3 id="3-useeffect-dependency-누락">3. useEffect dependency 누락</h3>
</li>
<li>FilterOption.tsx:36-41<pre><code>useEffect(() =&gt; {
window.addEventListener(&#39;click&#39;, handleOutsideClick);
return () =&gt; window.removeEventListener(&#39;click&#39;, handleOutsideClick);
}, []); // ❌ handleOutsideClick이 dependency에 없음</code></pre><h3 id="4-환경변수-접근-분산">4. 환경변수 접근 분산</h3>
</li>
<li>interceptor.ts:7-8, sentiment.ts:6에서 각각 접근</li>
<li>개선방안: <code>env.ts</code> 파일로 중앙화<h3 id="2️⃣-중간-우선순위">2️⃣ 중간 우선순위</h3>
<h3 id="5-중복된-코드-패턴">5. 중복된 코드 패턴</h3>
</li>
<li>Spinner 렌더링이 page.tsx:70, page.tsx:83에서 반복</li>
<li>개선방안: <code>&lt;LoadingSpinner /&gt;</code> 컴포넌트 생성<h3 id="6-icon-컴포넌트-인터페이스-불일관">6. Icon 컴포넌트 인터페이스 불일관</h3>
</li>
<li>SearchIcon, SpinnerIcon, SortIcon, CheckIcon이 각기 다른 props 구조</li>
<li>개선방안: 공통 IconProps 인터페이스 정의<h3 id="7-타입-정의-개선">7. 타입 정의 개선</h3>
</li>
<li>news.ts:3 - originallink 필드 미사용</li>
<li>sentiment.ts:9-16 - sentences 배열 타입 부정확<h3 id="3️⃣-낮은-우선순위">3️⃣ 낮은 우선순위</h3>
<h3 id="8-하드코딩된-매직-넘버">8. 하드코딩된 매직 넘버</h3>
</li>
<li>page.tsx:48 - Intersection Observer threshold 0.1</li>
<li>개선방안: 상수로 정의<h3 id="9-reactstrictmode-비활성화">9. reactStrictMode 비활성화</h3>
</li>
<li>next.config.ts:4 - 개발 중 버그 검출 어려움</li>
</ul>
<p><del>너무 많아...</del></p>
<p>여기서 나의 개인적인 판단...</p>
<p>Spinner 컴포넌트의 경우엔 페이지 로딩 / 무한 스크롤 하단 로딩으로 두 가지가 겹친다는 의견이었는데, 역할도 다르고 ui도 달라 중복 로직으로 보기 어려웠다. Icon 컴포넌트 인터페이스의 경우는 각 아이콘마다 기능이 달라 편의를 위해 isOpen, isChecked 등 개별 props를 추가해둔 부분이라 공통 인터페이스 정의의 필요성을 느끼지 못했다. 타입 정의도 api 응답 타입을 그대로 반영한 구조였기에 수정할 필요가 없다고 느꼈다.</p>
<p>그래서 간단히 높은 우선순위만 리팩토링을 진행하기로 결심했다!!! 👊</p>
<hr>
<h2 id="환경변수-중앙화">환경변수 중앙화</h2>
<p>네이버 오픈 api와 구글 오픈 api를 사용하는 데에 필요한 민감한 정보인 키 값들을 모두 <code>env.local</code> 파일에 작성해뒀는데, 클로드가 환경변수를 중앙화하는 방식을 추천해주었다.</p>
<p>환경변수를 한 파일로 모아서 관리하는 방식은 유지보수성과 안정성을 크게 높여준다고 한다.
<code>process.env.KEY</code>를 직접 접근할 때와 달리 <code>ENV</code> 객체로 중앙 집중화하면 <code>env.ts</code> 한 곳만 수정하면 되므로 변경 관리가 훨씬 용이해진다.</p>
<p>그리고 중앙화 파일에서 기본값<code>(|| &#39;&#39;)</code>을 처리하거나 타입을 명확하게 선언해두면 IDE 레벨에서 누락 여부를 빠르게 확인할 수 있고, 환경변수 사용 위치를 명확히 추적하기 쉬워진다.</p>
<pre><code class="language-typescript">// src/config/env.ts

/**
 * 환경변수 중앙 관리
 * Next.js는 빌드 타임에 process.env.NEXT_PUBLIC_* 변수를 문자열로 치환하므로
 * 직접 할당하는 방식으로 구현합니다.
 */
export const ENV = {
  // Naver API
  NAVER_CLIENT_ID: process.env.NEXT_PUBLIC_NAVER_API_CLIENT || &#39;&#39;,
  NAVER_CLIENT_SECRET: process.env.NEXT_PUBLIC_NAVER_API_CLIENT_KEY || &#39;&#39;,

  // Google API
  GOOGLE_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_API_KEY || &#39;&#39;,
} as const;</code></pre>
<h3 id="nextjs의-빌드-타임-환경변수-처리">Next.js의 빌드 타임 환경변수 처리</h3>
<pre><code class="language-typescript">function required(key: string) {
  const value = process.env[key]; // ❌
  if (!value) {
    throw new Error(`Missing environment variable: ${key}`);
  }
  return value;
}</code></pre>
<p>위의 코드는 ChatGPT가 만들어준 에러 처리 코드인데, Next.js는 빌드 시 <code>process.env.NEXT_PUBLIC_API_KEY</code>를 문자열로 치환하기 때문에 <code>process.env[key]</code>와 같은 동적 키 접근을 치환할 수 없다. 결과적으로 런타임에 <code>undefined</code>를 반환하게 된다.</p>
<p>Next.js에서는 반드시 정적 키로 접근해야하므로 위의 코드대로 사용하였다!</p>
<br />

<h2 id="컴포넌트-분리">컴포넌트 분리</h2>
<h3 id="여러-기능이-혼재하는-컴포넌트">여러 기능이 혼재하는 컴포넌트</h3>
<p>현재의 FilterOption 컴포넌트는 왼쪽에 정렬 드롭다운 컴포넌트를, 오른쪽에 제목만 / 긍정뉴스 옵션 컴포넌트를 배치하여 동시에 묶여있다. 개발 당시엔 뉴스 목록을 필터링하는 역할을 하나로 묶어 처리할 생각으로 만들었지만 꽤 복잡하게 얽힌 함수들을 보니 역시 분리하는게 맞다는 생각이 들었다.</p>
<pre><code class="language-typescript">import { useCallback } from &#39;react&#39;;
import SortDropdown from &#39;./SortDropdown&#39;;
import FilterCheckboxGroup from &#39;./FilterCheckboxGroup&#39;;

export interface FilterState {
  sort: &#39;sim&#39; | &#39;date&#39;;
  showPositiveOnly: boolean;
  showTitleOnly: boolean;
}

export interface FilterOptionProps {
  filter: FilterState;
  onChange?: &lt;K extends keyof FilterState&gt;(key: K, value: FilterState[K]) =&gt; void;
}

const FilterOption = ({ filter, onChange }: FilterOptionProps) =&gt; {
  const handleSortChange = useCallback((sort: &#39;sim&#39; | &#39;date&#39;) =&gt; {
    onChange?.(&#39;sort&#39;, sort);
  }, [onChange]);

  const handleTitleOnlyChange = useCallback((value: boolean) =&gt; {
    onChange?.(&#39;showTitleOnly&#39;, value);
  }, [onChange]);

  const handlePositiveOnlyChange = useCallback((value: boolean) =&gt; {
    onChange?.(&#39;showPositiveOnly&#39;, value);
  }, [onChange]);

  return (
    &lt;div className=&quot;flex w-full justify-between items-start px-1 sm:px-2 sm:h-20&quot;&gt;
      {/* 정렬 */}
      &lt;SortDropdown currentSort={filter.sort} onSortChange={handleSortChange} /&gt;

      {/* 필터 체크 */}
      &lt;FilterCheckboxGroup
        showTitleOnly={filter.showTitleOnly}
        showPositiveOnly={filter.showPositiveOnly}
        onTitleOnlyChange={handleTitleOnlyChange}
        onPositiveOnlyChange={handlePositiveOnlyChange}
      /&gt;
    &lt;/div&gt;
  );
};

export default FilterOption;</code></pre>
<p>정렬 컴포넌트와 필터 체크 컴포넌트를 따로 분리해주었고, 각 컴포넌트는 <code>memo</code>로 감싸주었다. FilterOption 컴포넌트에서는 각 함수에 <code>useCallback</code>을 사용하여 핸들러 함수를 메모이제이션해주었다.</p>
<h3 id="조건부-렌더링이-포함된-컴포넌트">조건부 렌더링이 포함된 컴포넌트</h3>
<p>뉴스 데이터를 보여주는 <code>NewsCard</code> 컴포넌트는 처음에 하나의 컴포넌트 안에서 <strong>뉴스 로딩 상태 / 정상적으로 뉴스가 표시되는 상태 / 부정적인 기사임을 보여주는 상태</strong> 이렇게 3가지의 ui를 모두 처리하고 있었다. </p>
<p>이를 각 상태에 따라 <strong>NewsCardLoading / NewsCardContent / NewsCardNegative</strong> 컴포넌트로 분리해주었다.</p>
<pre><code class="language-typescript">// src/components/NewsCard/index.tsx

export interface NewsCardProps {
  news?: NewsItem;
  isTitleOnly: boolean;
  isPositiveOnly: boolean;
}

const NewsCard = ({ news, isTitleOnly, isPositiveOnly }: NewsCardProps) =&gt; {
  const newsContent = `${news?.title} ${news?.description}`;
  const { mutation } = useAnalyzeSentiment(newsContent);

  useEffect(() =&gt; {
    if (mutation.status === &#39;idle&#39; &amp;&amp; !!newsContent.trim()) {
      mutation.mutate();
    }
  }, [mutation, newsContent]);

  const sentimentScore = mutation.data?.documentSentiment.score;
  const isVisible: boolean = isPositiveOnly ? isPositive(sentimentScore || 0) : true;
  const isLoading = !news || (mutation.isPending &amp;&amp; isPositiveOnly);

  const handleNewsCard = () =&gt; {
    if (!isVisible) return;
    window.location.href = `${news?.link}`;
  };

  const renderContent = () =&gt; {
    if (isLoading) {
      return &lt;NewsCardLoading /&gt;;
    }

    if (isVisible &amp;&amp; news) {
      return &lt;NewsCardContent news={news} isTitleOnly={isTitleOnly} /&gt;;
    }

    return &lt;NewsCardNegative /&gt;;
  };

  return (
    &lt;button
      onClick={handleNewsCard}
      className=&quot;flex flex-col w-full p-5 sm:p-7 text-left bg-white rounded-lg outline-1 sm:hover:outline-3 outline-gray-200  hover:outline-indigo-100 cursor-pointer&quot;
    &gt;
      {renderContent()}
    &lt;/button&gt;
  );
};

export default NewsCard;</code></pre>
<p>감정 분석 api 호출은 최초로 렌더링되는 시점에 <code>idle</code> 상태일 때만 실행하도록, 그리고 긍정 필터가 켜져 있으면 로딩 중에도 카드가 클릭되지 않도록 처리했다. NewsCard 컴포넌트는 <code>renderContent</code> 함수를 통해 상태를 판별하여 적절한 ui만 선택하여 렌더링하는 역할만 담당하도록 했다!</p>
<h3 id="useeffect-dependency-누락">useEffect dependency 누락</h3>
<pre><code class="language-typescript">// src/components/FilterOption.tsx - 기존 코드

  const handleOutsideClick = () =&gt; {
    setIsSortOpen(false);
  };

  useEffect(() =&gt; {
    window.addEventListener(&#39;click&#39;, handleOutsideClick);
    return () =&gt; {
      window.removeEventListener(&#39;click&#39;, handleOutsideClick);
    };
  }, []);</code></pre>
<p>위의 코드는 정렬 (정확도순/최신순) 필터링 드롭다운 컴포넌트에 연결된 함수이며, 의존성 배열에 함수가 들어가있지 않다. 그리고 전역 click 이벤트로 작성되어있기 때문에 내부 클릭과 외부 클릭을 구분하지 못한다.</p>
<pre><code class="language-typescript">// src/components/SortDropdown.tsx - 개선된 코드

  const dropdownRef = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    const handleOutsideClick = (event: MouseEvent) =&gt; {
      if (dropdownRef.current &amp;&amp; !dropdownRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };

    window.addEventListener(&#39;mousedown&#39;, handleOutsideClick);
    return () =&gt; {
      window.removeEventListener(&#39;mousedown&#39;, handleOutsideClick);
    };
  }, []);</code></pre>
<p>그래서 클릭된 위치가 드롭다운 요소 안인지 밖인지 확인하기 <code>useRef</code>를 사용하여 외부 클릭만 감지하도록 했다. 그리고 click 대신 <code>mousedown</code>을 사용하여 사용자의 액션이 발생했을 때 즉시 반응하도록 했다.</p>
<br>

<h2 id="에러-핸들링-처리">에러 핸들링 처리</h2>
<p>지금은 api 요청에 대한 에러 처리가 하나도 되어있지 않은 상태... <del>(왜지?)</del>
api 요청에 실패했을 때 페이지에 토스트 모달을 띄우기 위해 <code>sonner</code> 라이브러리를 설치하기로 했다! </p>
<h3 id="sonner-라이브러리">sonner 라이브러리</h3>
<pre><code class="language-typescript">// src/app/layout.tsx

import type { Metadata } from &#39;next&#39;;
import { Toaster } from &#39;sonner&#39;;
import { pretendard } from &#39;@/styles/font&#39;;
import ReactQueryProvider from &#39;@/providers/ReactQueryProvider&#39;;
import &#39;@/styles/globals.css&#39;;

export const metadata: Metadata = {
  title: &#39;GJ NEWS&#39;,
  description: &#39;긍정 뉴스만 뽑아보자!&#39;,
};

export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body className={`${pretendard.className} max-w-3xl mx-auto`}&gt;
        &lt;ReactQueryProvider&gt;{children}&lt;/ReactQueryProvider&gt;
        &lt;Toaster position=&quot;top-center&quot; closeButton richColors /&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p><code>sonner</code> 라이브러리의 토스트 모달을 사용하기 위해선 layout 파일에 <code>&lt;Toaster /&gt;</code> 을 추가해야한다.</p>
<p>페이지 상단 중앙에 띄우기 위해 <code>top-center</code> 속성을 넣었고, 닫기 버튼과 각 상태에 따른 색상을 넣기 위해 <code>closeButton</code>과 <code>richColors</code> 속성도 추가해주었다!</p>
<h3 id="queryclient에서의-에러-처리">QueryClient에서의 에러 처리</h3>
<p>뉴스 기사를 <code>useInfiniteQuery</code>를 통해 무한 스크롤로 가져오고, 각 뉴스에 대한 감정 분석을 위해 <code>mutation</code>도 수행하기 때문에 api 요청이 많은 만큼 에러 발생 횟수도 많아 중복되는 에러 메세지를 어떻게 처리할 지 고민이었다. </p>
<p>React Query v5부터는 useQuery 내부에서 onError가 제거되어서 error 상태를 감지해 useEffect로 토스트 모달을 띄우려 했지만, 무한 스크롤로 페이지를 리패치할 때 에러가 여러 번 동시에 발생하고, 그러면서 감정 분석 mutation이 각 뉴스 카드마다 실행되어 서로 다른 에러가 연속 발생하는 문제가 있었다. </p>
<p>React Query 관련 문서를 찾아보니, 개별 훅에서 처리하기보단 QueryClient 단에서 onError를 전역적으로 처리하는 방식이 더 권장되는 흐름이었다.</p>
<pre><code class="language-typescript">&#39;use client&#39;;

import { useState } from &#39;react&#39;;
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from &#39;@tanstack/react-query&#39;;
import { toast } from &#39;sonner&#39;;

export default function ReactQueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =&gt;
      new QueryClient({
        queryCache: new QueryCache({
          onError: () =&gt; {
            toast.error(&#39;요청 처리 중 오류가 발생했습니다.&#39;, {
              id: &#39;global-error&#39;,
            });
          },
        }),
        mutationCache: new MutationCache({
          onError: () =&gt; {
            toast.error(&#39;요청 처리 중 오류가 발생했습니다.&#39;, {
              id: &#39;global-error&#39;,
            });
          },
        }),
        defaultOptions: {
          queries: {
            retry: 1,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return &lt;QueryClientProvider client={queryClient}&gt;{children}&lt;/QueryClientProvider&gt;;
}</code></pre>
<p>그래서 위의 코드처럼 <code>QueryCache</code>와 <code>MutationCache</code>의 onError를 활용해 뉴스 조회 에러 / 감정 분석 에러를 모두 공통 메시지로 통일하기로 했다. </p>
<p>토스트가 중복으로 생성되는 문제는 Sonner의 <code>id</code> 옵션을 활용해 해결했다!! id를 지정하면 여러 요청이 동시에 실패하더라고 토스트는 한 번만 보여진다. </p>
<hr>
<blockquote>
</blockquote>
<h2 id="🎉-모든-리팩토링-완료">🎉 모든 리팩토링 완료!</h2>
<h2 id="✅-완료된-모든-작업-총정리">✅ 완료된 모든 작업 총정리:</h2>
<h3 id="높은-우선순위-완료">높은 우선순위 (완료):</h3>
<h3 id="1-✅-환경변수-중앙화-envts">1. ✅ 환경변수 중앙화 (env.ts)</h3>
<h3 id="2-✅-filteroption-대폭-리팩토링-103줄-→-47줄-54-감소">2. ✅ FilterOption 대폭 리팩토링 (103줄 → 47줄, 54% 감소)</h3>
<ul>
<li>SortDropdown.tsx 분리</li>
<li>FilterCheckboxGroup.tsx 분리</li>
<li>React.memo + useCallback 성능 최적화</li>
<li>useEffect 외부 클릭 ref 기반 처리<h3 id="3-✅-newscard-컴포넌트-분리-84줄-→-57줄-32-감소">3. ✅ NewsCard 컴포넌트 분리 (84줄 → 57줄, 32% 감소)</h3>
</li>
<li>폴더 구조로 정리 (NewsCard/index.tsx)</li>
<li>상태별 컴포넌트 분리 (Loading, Content, Negative)<h3 id="4-✅-api-에러-핸들링">4. ✅ API 에러 핸들링</h3>
</li>
<li>React Query 전역 에러 핸들러 (ReactQueryProvider.tsx)</li>
<li>Sonner 토스트 통합</li>
<li>중복 토스트 방지 (id: &#39;global-error&#39;)<h3 id="중간-우선순위-검토-완료">중간 우선순위 (검토 완료):</h3>
</li>
<li>Spinner 중복 → 용도가 달라서 현재 상태 유지</li>
<li>Icon 인터페이스 → 각자 다른 용도, 현재가 더 명확</li>
<li>타입 정의 → API 응답 구조 유지<h3 id="낮은-우선순위-완료">낮은 우선순위 (완료):</h3>
<h3 id="1-✅-매직-넘버-상수화-paginationts---observer_threshold">1. ✅ 매직 넘버 상수화 (pagination.ts - OBSERVER_THRESHOLD)</h3>
<h3 id="2-✅-미사용-import-확인-실제로는-사용-중">2. ✅ 미사용 import 확인 (실제로는 사용 중)</h3>
<h3 id="📈-최종-성과">📈 최종 성과:</h3>
</li>
<li>코드 라인 수: 187줄 감소 (FilterOption 56줄 + NewsCard 27줄 + 기타)</li>
<li>컴포넌트 분리: 4개 신규 컴포넌트 생성</li>
<li>성능 최적화: React.memo + useCallback 적용</li>
<li>에러 처리: 전역 핸들러로 체계화</li>
<li>코드 품질: 가독성, 재사용성, 유지보수성 대폭 향상</li>
</ul>
<p><a href="https://github.com/areumH/GJ-NEWS">깃허브 바로가기</a> | <a href="https://gj-news.vercel.app/">페이지 바로가기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js로 블로그 만들기 회고]]></title>
            <link>https://velog.io/@areumh__9/Next.js%EB%A1%9C-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Next.js%EB%A1%9C-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Tue, 21 Oct 2025 10:51:42 GMT</pubDate>
            <description><![CDATA[<p>항해 부트캠프를 수료한 후 참여한 스터디인 <strong>2주 만에 블로그 만들기</strong> 에 대한 회고...
크게 내가 고민했던 부분들, 막혔던 부분들 위주로 작성해보려고 한다!! </p>
<h2 id="📝-mdx-글-불러오기">📝 MDX 글 불러오기</h2>
<p>개인 블로그 글 포스팅 하는 방법으로 노션에서 불러오기, mdx 파일로 작성하기 등이 있었는데, 평소에 노션을 사용하지 않기도 하고 블로그 관련으로는 레포지토리 하나로 끝내고 싶어서 mdx 파일을 사용하기로 결정했다! </p>
<h3 id="카테고리별-글-작성하기">카테고리별 글 작성하기</h3>
<pre><code>areumh-blog/
├── app/             # Next.js App Router 페이지
│   ├── category/    # 카테고리 페이지
│   ├── post/        # 블로그 포스트 상세 페이지
│   └── portfolio/   # 포트폴리오 페이지
├── components/      # 공통 컴포넌트
│   ├── layout/      # 레이아웃 컴포넌트
│   └── ui/          # ui 컴포넌트
├── hooks/           # 커스텀 훅
├── lib/             # 블로그 포스트 관련 유틸리티 함수
├── posts/           # 카테고리별 MDX 블로그 포스트 파일
├── public/          # 정적 파일
├── styles/          # 전역 스타일
├── utils/           # 유틸리티 함수
├── constants.ts     # 상수 정의
└── types.ts         # 타입 정의</code></pre><p>루트 폴더에 <code>posts</code>라는 폴더를 생성한 후, 카테고리명으로 폴더를 생성한 뒤 <code>mdx</code> 파일을 작성하여 블로그 글을 추가하도록 했다.</p>
<pre><code class="language-typescript">// constants.ts
export const CATEGORIES = [
  { key: &#39;개발&#39;, label: &#39;tech&#39; },
  { key: &#39;회고&#39;, label: &#39;review&#39; },
];

// app/category/page.tsx
export default function Category() {
  return (
    &lt;div className=&quot;flex flex-col items-center gap-10 md:px-10 py-3 md:py-5&quot;&gt;
      {CATEGORIES.map(({ key, label }) =&gt; (
        &lt;CategoryPostList key={key} category={label} /&gt;
      ))}
    &lt;/div&gt;
  );
}</code></pre>
<p><strong>카테고리</strong>는 개발과 회고 외에는 딱히 작성할 것 같진 않고... 더 추가한다고 해도 글을 작성할 때 같이 수정하면 그만이라 상수 파일에 정의해두었고, 카테고리명을 인자로 받아 해당 카테고리의 글 목록을 보여주는 <code>CategoryList</code> 컴포넌트에 연결해주었다.</p>
<pre><code class="language-typescript">import fs from &#39;fs&#39;;
import path from &#39;path&#39;;
import matter from &#39;gray-matter&#39;;
import { Post } from &#39;@/types&#39;;

const postsDirectory = path.join(process.cwd(), &#39;posts&#39;);

/**
 * 모든 slug 가져오기
 * category - 폴더명, slug - 파일명
 */
export function getPostSlugs(): string[] {
  const categories = fs.readdirSync(postsDirectory).filter((name) =&gt; {
    const categoryPath = path.join(postsDirectory, name);
    return fs.statSync(categoryPath).isDirectory();
  });

  const slugs: string[] = [];

  categories.forEach((category) =&gt; {
    const files = fs.readdirSync(path.join(postsDirectory, category));
    files.forEach((file) =&gt; {
      if (file.endsWith(&#39;.mdx&#39;)) {
        const slug = path.basename(file, &#39;.mdx&#39;);
        slugs.push(`${category}/${slug}`);
      }
    });
  });

  return slugs;
}</code></pre>
<p><code>process.cwd()</code>는 현재 프로젝트의 루트 경로이고, <code>posts</code>는 블로그의 글이 담긴 폴더명으로 <strong>postsDirectory</strong>는 <code>루트 폴더/posts</code>가 된다.</p>
<p><code>fs.readdirSync()</code>는 해당 폴더 안의 모든 파일/폴더 이름을 동기적으로 가져온다. <code>fs.statSync()</code>는 해당 경로의 파일 상태 정보를 담은 <code>fs.Stats</code> 객체를 반환하고, <code>isDirectory()</code>는 그 경로가 디렉터리인지 아닌지를 알려준다.</p>
<p>따라서 filter는 모든 파일/폴더의 이름 중 폴더의 이름만 걸러주기 때문에 categories는 <strong>카테고리 폴더명만 담고있는 배열을 리턴</strong>한다.</p>
<p>그리고 카테고리 폴더 내부를 탐색하여 <code>.mdx</code> 파일만 필터링하고, <code>path.basename(file, &#39;.mdx&#39;)</code>를 통해 확장자를 제거한다. 이런 과정을 통해 위의 함수는 <strong><code>카테고리명/슬러그</code>의 문자열 배열을 리턴</strong>한다!</p>
<h3 id="gray-matter로-메타데이터-분리하기">gray-matter로 메타데이터 분리하기</h3>
<pre><code class="language-typescript">/**
 * 특정 slug의 post 가져오기
 */
export function getPostBySlug(url: string) {
  const [category, slug] = url.split(&#39;/&#39;);
  const fullPath = path.join(postsDirectory, category, `${slug}.mdx`);
  const fileContents = fs.readFileSync(fullPath, &#39;utf8&#39;);

  const { data, content } = matter(fileContents);

  const meta: Post = {
    title: data.title,
    date: data.date,
    description: data.description,
    tags: data.tags || [],
    slug,
    category,
  };

  return { meta, content };
}</code></pre>
<p><code>gray-matter</code> 라이브러리의 matter 함수를 사용하면 <strong>mdx 파일의 상단 메타데이터와 본문을 분리</strong>할 수 있다.</p>
<pre><code class="language-typescript">---
title: &quot;Next.js로 블로그 만들기 회고&quot;
date: &quot;2025-10-21&quot;
description: &quot;Next.js와 MDX를 이용한 블로그 만들기&quot;
tags: [&quot;Next.js&quot;, &quot;MDX&quot;, &quot;블로그&quot;]
---

블로그 본문 내용</code></pre>
<p>mdx 파일이 위와 같이 생겼다면 gray-matter은 이를 아래와 같이 반환한다.</p>
<pre><code class="language-typescript">{
  data: {
    title: &quot;Next.js로 블로그 만들기 회고&quot;,
    date: &quot;2025-10-21&quot;,
    description: &quot;Next.js와 MDX를 이용한 블로그 만들기&quot;,
    tags: [&quot;Next.js&quot;, &quot;MDX&quot;, &quot;블로그&quot;]
  },
  content: &quot;블로그 본문 내용&quot;
}</code></pre>
<p>이 data 객체를 기반으로 Post라는 타입을 따로 정의하여 카테고리와 슬러그 값을 포함하도록 했고, 블로그 본문의 헤더에 해당 데이터를 사용했다. </p>
<h3 id="전체-글--카테고리별-글-목록-가져오기">전체 글 / 카테고리별 글 목록 가져오기</h3>
<pre><code class="language-typescript">/**
 * 전체 글 목록 가져오기
 */
export function getAllPosts(): Post[] {
  const slugs = getPostSlugs();
  return slugs.map((slug) =&gt; getPostBySlug(slug).meta).sort((a, b) =&gt; (a.date &lt; b.date ? 1 : -1)); // 최신순
}</code></pre>
<p>블로그의 홈 화면에서는 카테고리에 상관없이 모든 글들을 한번에 보여주도록 했기 때문에 위에 작성했던 함수들을 사용하여 모든 글의 메타데이터를 수집하고 이를 최신순으로 정렬한 배열을 리턴하게 했다! </p>
<br />

<pre><code class="language-typescript">/**
 * 카테고리별 글 가져오기
 */
export function getPostsByCategory(category: string): Post[] {
  return getAllPosts().filter((post) =&gt; post.category === category);
}</code></pre>
<p>그리고 카테고리 페이지에서는 카테고리별 글 목록을 가져오기 때문에 카테고리 문자열을 인자로 받고 해당 카테고리의 글 목록을 리턴하는 함수를 작성했다. </p>
<h3 id="태그-목록-가져오기">태그 목록 가져오기</h3>
<pre><code class="language-typescript">/**
 * 전체 태그 목록 가져오기
 */
export function getAllTags(): string[] {
  const posts = getAllPosts();
  const tags = posts.flatMap((post) =&gt; post.tags || []);
  return Array.from(new Set(tags));
}</code></pre>
<p>홈 화면에서 태그별 글 목록 확인이 가능하도록 하기 위해 중복을 제거한 태그 목록을 리턴하는 함수도 작성했다. </p>
<h3 id="params와-suspense의-비동기-처리">params와 Suspense의 비동기 처리</h3>
<p>Next 15 부터는 params는 동기가 아닌 <strong>비동기식으로 접근</strong>하도록 변경되었다.
즉, 라우트 파라미터를 가져오는 과정을 <code>await</code> 처리해야 한다.</p>
<blockquote>
<ol>
<li>브라우저에서 <code>/post/review/next-blog</code> 와 같은 url에 접근</li>
<li><code>[category]/[slug]</code> 에 해당하는 동적 파라미터 추출 - 서버 컴포넌트에서 params로 제공 (Promise 형태)</li>
<li><code>await params</code>로 실제 객체 <code>{ category, slug }</code> 로 변환</li>
</ol>
</blockquote>
<pre><code class="language-typescript">export default async function Post({ params }: { params: Promise&lt;{ category: string; slug: string }&gt; }) {
  const { category, slug } = await params;
  const { meta, content } = getPostBySlug(`${category}/${slug}`);

  // ...
}</code></pre>
<p>위의 코드처럼 params의 타입을 <code>Promise&lt;{ category: string; slug: string }&gt;</code> 형태로 선언하고, 함수 내부에서 <code>await</code>으로 값을 꺼내는 방식으로 작성하여 mdx 파일의 글 데이터를 가져오도록 했다 👍</p>
<br />

<pre><code class="language-typescript">export default function Home() {
  const posts = getAllPosts();
  const tags = getAllTags();

  return (
    &lt;div className=&quot;flex flex-col w-full max-w-[800px] mx-auto items-center md:px-10&quot;&gt;
      &lt;Suspense fallback={null}&gt;
        &lt;HomeContent posts={posts} tags={tags} /&gt;
      &lt;/Suspense&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>추가로 React 18 이후부터는 <code>useSearchParams()</code>를 사용하는 클라이언트 컴포넌트를 데이터가 준비될 때까지 안전하게 렌더링되도록 <strong><code>Suspense</code>로 감싸는 처리</strong>가 필요하다. </p>
<p><code>fallback</code>은 데이터가 로딩 중일 때 보여주는 ui를 지정하는 속성인데, 이번 프로젝트는 페이지가 복잡하지 않고 굳이 로딩 상태 ui가 필요하다고 느껴지지 않아 null 값을 넣어 처리했다. 🫠</p>
<h2 id="🎨-mdx-글-스타일링">🎨 MDX 글 스타일링</h2>
<h3 id="next-mdx-remote로-글-렌더링하기">next-mdx-remote로 글 렌더링하기</h3>
<p>mdx의 블로그 글을 화면에 렌더링하기 위해 <code>next-mdx-remote</code>와 여러 플러그인을 활용했다.</p>
<pre><code class="language-typescript">import { MDXRemote } from &#39;next-mdx-remote/rsc&#39;;
import rehypePrettyCode from &#39;rehype-pretty-code&#39;;
import rehypeSlug from &#39;rehype-slug&#39;;
import remarkGfm from &#39;remark-gfm&#39;;
import remarkBreaks from &#39;remark-breaks&#39;;

export default async function PostContent({ content }: { content: string }) {
  return (
    &lt;div className=&quot;prose prose-sm md:prose-lg dark:prose-invert&quot;&gt;
      &lt;MDXRemote
        source={content}
        options={{
          mdxOptions: {
            remarkPlugins: [remarkGfm, remarkBreaks],
            rehypePlugins: [rehypeSlug, rehypePrettyCode],
          },
        }}
      /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<ul>
<li><strong>remarkGfm</strong>: GitHub 스타일 마크다운 확장(GFM) 적용</li>
<li><strong>remarkBreaks</strong>: 줄바꿈 시 <code>&lt;br&gt;</code> 처리</li>
<li><strong>rehypeSlug</strong>: 각 헤딩에 자동으로 id 부여 → 목차(TOC)와 연결</li>
<li><strong>rehypePrettyCode</strong>: 코드 블록 하이라이팅 적용</li>
</ul>
<br />

<pre><code class="language-typescript">// styles/globals.css 중 일부
@import &#39;tailwindcss&#39;;
@plugin &quot;@tailwindcss/typography&quot;;

/* prose 문단 간격 조정 */
.prose p {
  margin-top: 0.5rem;
  margin-bottom: 0.5rem;
}

.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
  margin-top: 1rem;
  margin-bottom: 1rem;
}

.prose ul,
.prose li,
.prose ol {
  margin-top: 0.5rem;
  margin-bottom: 0.5rem;
}

/* 인용구 */
.prose blockquote {
  border-left: 4px solid #6366f1;
  padding: 0.5rem;
  color: #374151;
  font-style: normal;
  margin-top: 1rem;
  margin-bottom: 1rem;
  background-color: #f9fafb;
}

/* 다크모드 대응 */
.dark .prose blockquote {
  color: #e5e7eb;
  background-color: #1f2937;
}

/* 코드 블록 스타일 */
pre {
  padding: 1rem;
  border-radius: 0.5rem;
  overflow-x: auto;
  margin: 0.5rem 0;
}

code {
  font-family: &#39;Courier New&#39;, Courier, monospace;
  font-size: 0.75rem;
}

@media (min-width: 768px) {
  code {
    font-size: 0.875rem;
  }
}

// ...</code></pre>
<p>globals.css 파일의 상단에 <code>@plugin &quot;@tailwindcss/typography&quot;;</code>를 추가하여 <a href="https://v1.tailwindcss.com/docs/typography-plugin"><strong>prose</strong></a> 클래스를 사용한 컨텐츠에 스타일이 적용되도록 했다. 그리고 각종 태그들과 인용구, 코드 블록 등에 적용할 스타일들을 직접 작성했다. 🖌️</p>
<h2 id="📎-toc-컴포넌트-구현">📎 TOC 컴포넌트 구현</h2>
<p>블로그 글의 헤딩 (h1, h2, h3)을 기반으로 동적인 목차를 제공하는 컴포넌트를 직접 구현해보았다. </p>
<h3 id="글의-모든-헤딩-가져오기">글의 모든 헤딩 가져오기</h3>
<pre><code class="language-typescript">  useEffect(() =&gt; {
    const headingElements = Array.from(document.querySelectorAll(&#39;h1, h2, h3&#39;)) as HTMLElement[];

    const newHeadings = headingElements.map((el) =&gt; ({
      id: el.id,
      text: el.innerText,
      level: Number(el.tagName.replace(&#39;H&#39;, &#39;&#39;)),
    }));

    setHeadings(newHeadings);
  }, []);</code></pre>
<p><code>useEffect</code>를 사용하여 컴포넌트 마운트 시 DOM에서 모든 h1, h2, h3 요소를 가져온다. 그리고 각 헤딩의 id, 텍스트, 레벨(h1일 경우 1, h2일 경우 2 ...)을 추출하여 상태에 저장한다.</p>
<h3 id="intersectionobserver로-현재-화면-위치-감지">IntersectionObserver로 현재 화면 위치 감지</h3>
<pre><code class="language-typescript">const observer = new IntersectionObserver(
  (entries) =&gt; {
    entries.forEach((entry) =&gt; {
      if (entry.isIntersecting) {
        setCurrentId(entry.target.id);
      }
    });
  },
  { rootMargin: &#39;0px 0px -80% 0px&#39; }
);</code></pre>
<ul>
<li><strong>entries</strong>: 관찰 대상 요소들의 상태 배열 (헤딩)</li>
<li><strong>entry.isIntersecting</strong>: 해당 요소가 뷰포트에 보이고 있는지에 대한 boolean 값</li>
<li><strong>entry.target.id</strong>: 관찰 중인 DOM 요소의 id 값</li>
<li><strong>rootMargin</strong>: 관찰 영역(뷰포트)에 여백 설정
(bottom: -80% → 화면 아래쪽 80% 지점에서 요소가 들어오면 감지)</li>
</ul>
<h3 id="observer-등록">observer 등록</h3>
<pre><code class="language-typescript">    const elements = document.querySelectorAll(&#39;h1, h2, h3&#39;);
    elements.forEach((el) =&gt; observer.observe(el));</code></pre>
<p><code>observer.observe(el)</code>로 각 헤딩을 관찰 대상으로 등록하고, 해당 요소가 화면에 들어오거나 나갈 때 브라우저가 콜백 함수를 자동으로 호출하도록 구현했다.</p>
<h3 id="헤딩-스타일링">헤딩 스타일링</h3>
<pre><code class="language-typescript">  const getHeadingMargin = (level: number): string =&gt; {
    const indentClass = {
      1: &#39;ml-0&#39;,
      2: &#39;ml-3&#39;,
      3: &#39;ml-6&#39;,
    } as const;

    return indentClass[level as keyof typeof indentClass] ?? &#39;ml-0&#39;;
  };

  // ...

  return (
    &lt;nav className=&quot;flex pr-2 text-sm&quot;&gt;
      &lt;ul className=&quot;space-y-1&quot;&gt;
        {headings.map((heading) =&gt; {
          return (
            &lt;li
              key={heading.id}
              className={`${getHeadingMargin(heading.level)} ${
                currentId === heading.id ? &#39;text-indigo-400 font-semibold&#39; : &#39;text-gray-400&#39;
              } transition-colors`}
            &gt;
              &lt;a href={`#${heading.id}`}&gt;{heading.text}&lt;/a&gt;
            &lt;/li&gt;
          );
        })}
      &lt;/ul&gt;
    &lt;/nav&gt;
  );</code></pre>
<p>헤딩 레벨에 따라 TOC 내에 들여쓰기 스타일을 적용하기 위한 함수를 따로 작성해주었고, 현재 화면에 표시된 헤딩의 텍스트 색상을 강조하도록 했다.</p>
<pre><code class="language-typescript">html {
  scroll-behavior: smooth;
}</code></pre>
<p>추가로 css 파일에 위의 코드를 추가하면 헤딩 클릭 시 애니메이션처럼 부드럽게 스크롤이 이동된다. 하지만 Next.js는 라우트 전환 시 스크롤 동작을 제어하기 위해 <strong>data-scroll-behavior 속성</strong>을 확인하기 때문에 하나의 페이지 내에서 스크롤이 이동될 때는 smooth, 라우트 전환 시엔 auto로 임시 변경 (즉시 스크롤) 되도록 html 태그에 속성 값을 추가해야한다. 하지 않으면 경고문이 뜬다 🤕</p>
<br />

<pre><code class="language-typescript">export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot; data-scroll-behavior=&quot;smooth&quot; suppressHydrationWarning&gt; // ✅
      &lt;body className=&quot;flex flex-col min-h-screen&quot;&gt;
        &lt;ThemeProvider attribute=&quot;class&quot; defaultTheme=&quot;system&quot; enableSystem&gt;
          &lt;Header /&gt;
          &lt;main className=&quot;flex flex-col flex-grow w-full mx-auto px-7 md:px-20 py-5 md:py-8&quot;&gt;{children}&lt;/main&gt;
          &lt;Footer /&gt;
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p><strong><code>data-scroll-behavior=&quot;smooth&quot;</code></strong> 코드를 추가하여 해결했다!</p>
<h2 id="🌙-다크모드-구현">🌙 다크모드 구현</h2>
<p>Tailwind CSS v4부터는 다크모드 설정 방식이 바뀌어서 globals.css 파일에 아래의 코드가 필수로 들어가야 한다. </p>
<pre><code class="language-typescript">@custom-variant dark (&amp;:where(.dark, .dark *));</code></pre>
<p>위의 코드가 있어야 <code>.dark</code> 클래스가 붙은 요소와 그 하위 요소에 <code>dark:</code> 스타일이 적용된다! </p>
<h3 id="next-themes-설정">next-themes 설정</h3>
<pre><code class="language-typescript">// components/ui/ThemeToggle.tsx
import { Sun, Moon } from &#39;lucide-react&#39;;
import { useTheme } from &#39;next-themes&#39;;

export default function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();

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

  return (
    &lt;button
      className=&quot;relative flex w-7 h-7 md:w-8 md:h-8 rounded-full justify-center items-center hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer&quot;
      onClick={handleTheme}
    &gt;
      &lt;Sun className=&quot;w-5 h-5 md:w-6 md:h-6 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0&quot; /&gt;
      &lt;Moon className=&quot;w-5 h-5 md:w-6 md:h-6 absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100&quot; /&gt;
    &lt;/button&gt;
  );
}</code></pre>
<p><code>next-themes</code>의 <strong>useTheme</strong> 훅으로 현재의 테마 상태를 가져오고, <strong>현재 적용된 실제 테마 값</strong>인 <strong><code>resolvedTheme</code></strong> 값을 기반으로 테마를 변경하는 <strong>setTheme</strong> 함수를 토글 컴포넌트에 연결해주었다. </p>
<br />

<pre><code class="language-typescript">import type { Metadata } from &#39;next&#39;;
import { ThemeProvider } from &#39;next-themes&#39;;
import Header from &#39;@/components/layout/Header&#39;;
import Footer from &#39;@/components/layout/Footer&#39;;
import &#39;@/styles/globals.css&#39;;

// ...

export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body className=&quot;flex flex-col min-h-screen&quot;&gt;
        &lt;ThemeProvider attribute=&quot;class&quot; defaultTheme=&quot;system&quot; enableSystem&gt; // ✅
          &lt;Header /&gt;
          &lt;main className=&quot;flex flex-col flex-grow w-full mx-auto px-7 md:px-20 py-5 md:py-8&quot;&gt;{children}&lt;/main&gt;
          &lt;Footer /&gt;
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p>그리고 루트 레이아웃에 <code>ThemeProvider</code>를 추가하여 전역에서 다크모드 토글이 가능하도록 했다 🌙</p>
<h3 id="suppresshydrationwarning-처리">suppressHydrationWarning 처리</h3>
<p>Next.js는 <strong>SSR(Server-Side Rendering)</strong> 방식을 사용한다.</p>
<blockquote>
<ol>
<li>서버에서 HTML을 먼저 생성</li>
<li>브라우저가 HTML을 받아 화면에 표시</li>
<li>React가 hydration -&gt; JS를 연결하여 인터렉티브하게 만듦</li>
</ol>
</blockquote>
<p>그로 인해 다크모드를 구현할 때 서버는 사용자의 테마 설정을 모르기 때문에 기본 값 기준으로 HTML을 생성하고, 하이드레이션 과정 중 <code>next-themes</code>를 통해 실제로 사용자가 설정한 테마 값을 읽게 되어 이를 기반으로 DOM을 업데이트하여 하이드레이션 불일치 문제가 발생한다. </p>
<pre><code class="language-typescript">export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot; data-scroll-behavior=&quot;smooth&quot; suppressHydrationWarning&gt; // ✅
      &lt;body className=&quot;flex flex-col min-h-screen&quot;&gt;
        &lt;ThemeProvider attribute=&quot;class&quot; defaultTheme=&quot;system&quot; enableSystem&gt;
          &lt;Header /&gt;
          &lt;main className=&quot;flex flex-col flex-grow w-full mx-auto px-7 md:px-20 py-5 md:py-8&quot;&gt;{children}&lt;/main&gt;
          &lt;Footer /&gt;
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p>이를 해결하기 위해 루트 레이아웃의 html 태그에 하이드레이션 불일치 경고를 허용하는 <strong>suppressHydrationWarning</strong> 속성을 추가했다. </p>
<hr>
<blockquote>
<p><a href="https://areumh.me/">블로그 바로가기</a> | <a href="https://github.com/areumH/areumh-blog">깃허브 바로가기</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드 명세 작성 및 문서화 : 살아 있는 문서 만들기]]></title>
            <link>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EB%AA%85%EC%84%B8-%EC%9E%91%EC%84%B1-%EB%B0%8F-%EB%AC%B8%EC%84%9C%ED%99%94-%EC%82%B4%EC%95%84-%EC%9E%88%EB%8A%94-%EB%AC%B8%EC%84%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EB%AA%85%EC%84%B8-%EC%9E%91%EC%84%B1-%EB%B0%8F-%EB%AC%B8%EC%84%9C%ED%99%94-%EC%82%B4%EC%95%84-%EC%9E%88%EB%8A%94-%EB%AC%B8%EC%84%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sun, 19 Oct 2025 08:43:00 GMT</pubDate>
            <description><![CDATA[<h2 id="1-api-문서-자동-생성">1. API 문서 자동 생성</h2>
<h3 id="openapi-swagger-명세-작성">OpenAPI (Swagger) 명세 작성</h3>
<ul>
<li>Express API 코드를 분석하여 OpenAPI 3.0 표준 형식의 API 명세 자동 생성</li>
<li>앤드포인트, 요청/응답 스키마, 파라미터 정보를 체계적으로 문서화</li>
<li>GraphQL 스키마 문서화, 포스트맨 콜렉션 생성 요청 가능</li>
</ul>
<br />

<h2 id="2-사용자-가이드-작성">2. 사용자 가이드 작성</h2>
<h3 id="getting-started-가이드">Getting Started 가이드</h3>
<ul>
<li>신규 사용자가 프로젝트를 빠르게 이해하고 시작할 수 있는 단계별 가이드 작성</li>
<li>설치, 설정, 첫 실행까지의 과정을 명확하고 따라하기 쉽게 문서화   </li>
</ul>
<br />

<h2 id="3-기술-문서-구조화">3. 기술 문서 구조화</h2>
<h3 id="adr-architecture-decision-records">ADR (Architecture Decision Records)</h3>
<ul>
<li>중요한 기술적 결정사항들을 체계적으로 문서화하여 나중에 왜 그런 선택을 했는지 추적 가능 </li>
<li>결정의 배경, 고려한 대안들, 예상되는 결과를 명확히 기록해 팀의 기술 부채를 줄이고 새로운 팀원이 합류했을 때 과거에 왜 그런 결정을 했는지 빠르게 이해할 수 있도록 도움</li>
<li>비슷한 상황에서 이전 결정을 참고하여 일관된 아키텍처 방향성 유지 </li>
</ul>
<h3 id="시스템-설계-문서">시스템 설계 문서</h3>
<ul>
<li>복잡한 시스템의 전체적인 구조와 컴포넌트 간 관계를 명확히 시각화</li>
<li>신규 개발자나 다른 팀이 시스템을 이해하는 데 필요한 시간 단축</li>
<li>확장성, 성능, 보안 요구사항을 체계적으로 정리하여 설계 품질 향상</li>
<li>시스템 변경 시 영향도 분석과 리스크 평가를 위한 기준 문서로 활용 가능</li>
</ul>
<br />

<h2 id="4-코드와-문서-동기화">4. 코드와 문서 동기화</h2>
<h3 id="jsdoc에서-문서-생성">JSDoc에서 문서 생성</h3>
<ul>
<li>JSDoc 기반으로 완전하고 정확한 API 문서를 자동 생성</li>
<li>단순한 API 문서뿐 아니라 샘플 코드와 실제 시나리오까지 포함된 개발자에게 유용한 문서 생성 가능</li>
</ul>
<h3 id="문서-자동-업데이트-스크립트">문서 자동 업데이트 스크립트</h3>
<ul>
<li>코드가 변경될 때마다 자동으로 문서를 업데이트하여 문서-코드 간 불일치 방지</li>
<li>CI/CD 파이프라인에 문서 생성 단계를 포함시켜 수동 작업 없이도 최신 문서 유지, 개발자가 문서 업데이트를 깜빡하더라도 자동화된 프로세스가 이를 보완</li>
<li>문서 변경사항을 깃 커밋으로 추적하여 문서의 변화 이력도 함께 관리</li>
</ul>
<br />

<h2 id="5-배포-및-운영-문서">5. 배포 및 운영 문서</h2>
<h3 id="배포-가이드">배포 가이드</h3>
<ul>
<li>배포 프로세스의 모든 단계를 체계적으로 문서화하여 배포 실수 최소화</li>
<li>환경별 설정 차이점과 주의사항을 명확히 기록해 일관된 배포 보장</li>
<li>롤백 절차와 장애 대응 방법을 포함하여 운영팀의 신속한 대응을 가능하게 함</li>
</ul>
<h3 id="운영-런북-runbook">운영 런북 (Runbook)</h3>
<ul>
<li>시스템 장애 발생 시 단계별 대응 절차를 명확하게 정리하여 빠른 문제 해결 지원 가능</li>
<li>모니터링 지표와 알람 설정 방법을 문서화해 사전 장애 예방 가능</li>
<li>일반적인 운영 작업들을 표준화하여 인적 오류를 줄이고 일관성 확보 가능</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해 플러스 프론트엔드 6기 수료 후기]]></title>
            <link>https://velog.io/@areumh__9/%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-6%EA%B8%B0-%EC%88%98%EB%A3%8C-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@areumh__9/%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-6%EA%B8%B0-%EC%88%98%EB%A3%8C-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Sun, 05 Oct 2025 16:31:11 GMT</pubDate>
            <description><![CDATA[<h2 id="🚢-항해를-하게-된-이유-">🚢 항해를 하게 된 이유 ?</h2>
<img src="https://velog.velcdn.com/images/areumh__9/post/b7f5e61a-178e-46a2-b486-ce7ae6f44c56/image.jpeg" width="50%" />


<p>난 작년에 대학을 졸업한 취준생이며, 혼자 프론트엔드를 공부하고 있었다.
개발 스터디에 들어가 팀 프로젝트를 한다거나.. open api로 개인 프로젝트를 한다거나...
꽤 오랜 기간 이어지던 팀 프로젝트는 흐지부지 끝나버렸고 결국 혼자 공부하는 것에 한계를 느끼게 된다 🙃</p>
<p>1년이라는 짧으면서도 긴 시간동안 해낸 완벽한 결과물이 없어서 내가 너무 작아진 느낌이었다. 
주변 동기들은 다 부트캠프 하던데... 난 뭐했지? 
<del>( ㄴ 그럼 너도 하면 돼 )</del></p>
<p>그렇게 바로 부트캠프를 알아보게 되었고... 수많은 부트캠프 중 <strong>항해</strong>를 선택했다.</p>
<blockquote>
</blockquote>
<ol>
<li>국비지원 부트캠프는 너무 평일 풀타임에 기간이 길어서 못 버틸 가능성이 높음</li>
<li>난 소비하는 돈이 많기에 알바와 병행 가능해야 함</li>
<li>너무 쌩기초부터 시작하지 않았으면 좋겠음</li>
</ol>
<p>내 나름대로 만든 기준을 모두 충족하기도 했고 예전부터 많이 들어봤던 부트캠프라 궁금하기도 했다.
그렇게 나는 아빠의 도움(💸)을 받아 항해를 시작하게 된다!!!</p>
<br />

<h2 id="📈-기술적-성장">📈 기술적 성장?</h2>
<h3 id="나의-부족함">나의 부족함</h3>
<p>솔직히 부트캠프를 수료했다고 드라마틱하게 성장했다고 하면 거짓말이다.
그치만 항해를 하지 않고 계속 혼자 공부했다면 자세히 들여다보지 않았을 개념을 깊게 공부하게 된건 확실하다.</p>
<p>사전적 정의 정도만 알고있던 개념들을 과제로 직접 접하며 나의 부족함을 뼈저리게 느낀 10주였다. 
난 대학교 3학년으로 올라가면서 프론트엔드의 길을 선택했는데, 바로 직전 학기인 2학년 2학기 자바스크립트 전공 과목에서 C+을 받았었다. 재수강으로 성적을 올리긴 했지만, 그렇게 자바스크립트에 큰 구멍이 난 상태에서 교내 웹 동아리에 들어가 바로 리액트 공부를 시작했다. </p>
<p>그렇게 이어져오던 나의 허접한 자바스크립트 실력이 1주차 과제에서부터 터져버린 것이었다...
코드를 썼다 지웠다 반복하면서 다른 사람들은 어떻게 했는지 코드를 훔쳐보기도 했고, 뒤늦게 학습 자료를 읽어보기도 했다. 과거의 내가 아무나 붙잡고 질문할 수 있는 성격의 소유자였다면 얼마나 좋았을까...</p>
<img src="https://velog.velcdn.com/images/areumh__9/post/b7b3dcab-993c-4555-b81e-ff8bbf933870/image.jpeg" width="70%" />


<p>1주차의 <strong>SPA</strong> 뿐만 아니라 두 번째 챕터의 <strong>클린 코드</strong>와 <strong>FSD</strong>, 세 번째 챕터의 <strong>테스트 코드</strong>, 네 번째 챕터의 <strong>SSR</strong>, <strong>SSG</strong> 그리고 <strong>성능 최적화</strong>. 모든 과제가 나에겐 도전이었던 것 같다. 쉽게 끝낼 수 있는 과제는 하나도 없었고, 매번 새로운 개념과 문제에 부딪히며 깊게 생각해야 했다. 그리고 테스트를 통과하지 못하면 무조건 과제 실패 처리가 되기 때문에 완벽이 아닌 완성을 목표로 해야 했다. 그치만 애초에 완성이 어려웠다. ⚰️</p>
<p>매주 금요일이 과제 제출이었기 때문에 목요일은 거의 고정으로 밤을 샜던 것 같다. 
대학생 때 시험 벼락치기하면서 마셨던 스누피 커피우유_(지금은 춘식이 커피우유)_를 마시기도 했다. 옛날 생각이 났다... </p>
<p>새벽까지 zep에서 팀원들과 같이 과제를 하면서 버틴게 정말 큰 힘이 되었다.
막히는 부분이 있으면 서로 어떻게 해결했는지 공유하고, 과제의 주제에 대해 의견을 나누며 한층 더 성장할 수 있었다.</p>
<h3 id="회고하는-습관">회고하는 습관</h3>
<p>나의 거지같은 실력 말고도 또 놀란건 다른 사람들의 PR이었다.</p>
<img src="https://velog.velcdn.com/images/areumh__9/post/c7d44595-a884-4631-8353-27247ecb0683/image.jpg" width="40%" />

<p>나는 ㅇㅇ님 수준의 글쓰기 실력을 갖고 있는데... 다른 사람들의 pr을 보니 무슨 전문 서적이 따로 없었다.
여기서 또 한번 벽을 느꼈다. 개발을 잘하는 사람은 글도 잘 쓰는구나...</p>
<p>코치님도, 매니저님도 회고의 중요성을 여러 번 언급하셨다. 그런데 나는 지금까지 회고를 써본 적이 없었다.
강제는 아니었지만 매주 과제를 마치고 WIL을 제출해야 했는데, 이거라도 꾸준히 해봐야겠다고 생각했다.
중간에 몇 주씩 밀리긴 했지만ㅎㅎ 그래도 지금은 10주차 과제까지 회고를 모두 작성한 상태다. <del>다행히도</del>
나중에 실력을 쌓고 과제를 다시 한 번 풀어본 후 내가 썼던 회고를 읽어보면 감회가 새롭지 않을까 하는 생각이 든다.</p>
<p>글은 계속 써봐야 느는 거라던 팀원들의 말이 떠오른다. 
앞으로도 꾸준히 회고를 쓰다보면 글쓰기 실력도 자연스럽게 나아지지 않을까??? 하는 나의 바람..</p>
<p><a href="https://velog.io/@areumh__9/series/%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-6%EA%B8%B0">항해 회고 모음집</a></p>
<h3 id="마침내-수료">마침내 수료</h3>
<img src="https://velog.velcdn.com/images/areumh__9/post/f93bd28e-92c2-4f38-8683-336beca99a18/image.png" width="40%" />

<p>나같은 어중간한 실력에도 어찌저찌 수료를 하긴 했다. 
매주 기본/심화로 나뉘는 과제가 있었고, 3번 심화 과제를 실패해서 총 7개의 과제를 통과했다.
사실 과제가 너무 어려워서 얼른 끝나면 좋겠다고 생각한 적도 있었는데, 막상 진짜 끝나니 너무 아쉬웠다.</p>
<p>솔직히 항해를 하지 않았다면 난 지금까지 뭘 어떻게 공부하고 있었을 지 상상이 가지 않는다.
뭔가를 공부하는 척 허송세월을 보내고 있진 않았을까.. 하고 예상해본다. 💦</p>
<br />

<h2 id="😰-힘들었던-점-">😰 힘들었던 점 ?</h2>
<h3 id="잘못-들어왔지만">잘못 들어왔지만...</h3>
<p>사실 난 모집 대상을 잘못 보고 항해를 신청했다...
경력 3~4년의 개발자를 대상으로 하는 이직 부트캠프였는데, 난 이를 공부 기간으로 잘못 이해했다.</p>
<p>개강하기 전, 사전에 초대된 노션 페이지에 팀 노트를 적는 시간이 있었는데 멤버 카드를 보니 거의 대부분이 현재 개발자로 일을 하고 계시거나 이직을 준비하고 계신 분들이었다. 나만 취준생 나부랭이... <del>?: 잘못 왔다;;</del></p>
<p>그래서 개강 날에 너무 위축됐고... 다 너무 멋진 사람들만 있고...
하필 1주차 과제가 너무 어려웠기 때문에 관두고 탈주할까 진지하게 고민했었다. ㅋㅋㅋㅋㅋㅋㅋㅋ
과제 난이도 조절 실패로 인한 채점 기준 변경과 제출 마감 날짜 연장은 최초였다고 한다.</p>
<p>다른 주차 과제들은 할만 하다던 학메님의 말이 아니었다면 정말 탈주했을지도 모른다.
실제로 과제 난이도는 단짠단짠...이었던 것 같다. 
근데 이제 달 때는 이거 단건가...? 싶은거고 짤 때는 와 개짜다;; 수준인..</p>
<h3 id="배포라는-산">배포라는 산...</h3>
<p>또 하나 힘들었던 점은 매주마다 과제를 깃 배포하여 제출해야 했던 거..?
난 기능 구현만 주구장창 해봤지, 배포 경험은 몇 번 없는 쪼렙이었고.. 
그래서 그런지 배포가 나한테 조금 무서운 존재였다.</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/4b3ee4dd-9a3e-4d4d-ab6d-4152723daf1b/image.png" alt=""></p>
<p>사실 두 번째 챕터 과제 중 배포가 잘 안돼서 새벽까지 붙잡고 있다가 펑펑 운 적이 있다. ^^;;</p>
<p>깃 액션으로 배포를 시도하다가 2~3시간 정도를 날려먹었고... 
고통받는 나를 본 팀원분이 gh-pages 라이브러리를 사용한 10분컷 배포 법을 정리하여 나를 도와주셨다. 너무 감사했다...</p>
<p>정리본을 하나하나 따라하면서 나는 진짜 멀었구나, 다른 사람들은 그냥 척척 해내는데 나는 몇 시간을 붙잡아도 안되는구나 하고 자괴감에 빠져 펑펑 울다가 지쳐서 잠들었었다. 지금은 아무렇지도 않다!!!! ㅎㅎ (아님)</p>
<br />


<h2 id="🫡-내가-얻은-것-">🫡 내가 얻은 것 ?</h2>
<p>힘들었다는 건 그만큼 얻은 것도 있다는 것..ㅎㅎ</p>
<h3 id="페어-1팀">페어 1팀</h3>
<p>우선 함께 한 사람들이 다 너무 좋았다. 다들 잘 챙겨주셨고, 많이 도와주시기도 했다.
위에 말했듯 배포 방법 정리본을 받기도 했고, 과제 관련 질문을 받아주시기도 했고, 
아주 개똥같은 나의 코드를 칭찬해주시기도 했다. 거짓말쟁이님들.</p>
<img src="https://velog.velcdn.com/images/areumh__9/post/97b01718-09a7-4108-85b9-b34154e13aa8/image.png" width="70%" />

<p>두 번째 챕터 쯤 부터 더 활발한 교류를 위해 기존의 두세 팀이 묶여 <strong>페어 팀</strong>으로 진행됐는데 
낯선 사람에게 먼저 다가가는게 어려웠던 나에겐 너무 좋은 전환점이었다.</p>
<p>내가 속한 페어 1팀은 매주 월~목 정해진 시간에 zep에 모여 스크럼을 가졌다.
오늘의 tmi, 오늘까지 한 일, 내일 할 일, 트러블 슈팅 등을 공유하며 다 함께 소통하는 시간을 가졌고,
덕분에 사이가 훨씬 더 가까워진 것 같다. 팀 막내라 그런지 더 챙겨주신 부분도 있고.. 😋</p>
<h3 id="네트워킹">네트워킹</h3>
<p>개강과 수료식 말고도 중간 네트워킹, 페어 1팀 회식, 모각코+파티 등 오프라인 모임이 여러 번 있었고, 
나답지 않게 대부분 참여했다.ㅋㅋㅋㅋㅋㅋ 집이 멀어서 일찍 헤어져야 하는 게 아쉬울 정도로 재밌었다.</p>
<p>아직 취준생이라 주변에 개발하는 사람이 많지 않았는데, 항해를 통해 만난 너무나도 멋있는 사람들과 정기적으로 꾸준히 교류할 수 있는게 너무 좋았다. 외향적이지 않은 나를 데리고 여러 테이블을 옮겨다니며 소개해주던 팀원분들... 상당히 기빨렸지만 덕분에 많은 사람들과 대화할 수 있었다 🫠</p>
<p>그리고 일단 맛있는거 먹는 게 좋았음 ㅎㅎ 페어 팀 회식 때 먹은 고기가 너무 맛있었다...</p>
<h3 id="멘토링">멘토링</h3>
<p>매주 진행됐던 멘토링도 정말 좋았던 것 같다. 과제를 하면서 막힌 부분이나 현재 개발자로서의 고민, 이력서 코칭 등 자유롭게 코치님과 소통할 수 있는 시간이었고 모든 코치님들이 늦은 시간까지 정성을 다해 멘토링을 진행해주셨다. </p>
<p>나는 기본적인 이력서의 틀 마저도 갖추지 못해 이력서 코칭은 받지 못했지만, 다른 팀원들의 코칭을 보면서 어떻게 이력서를 작성하는 게 효과적인지 감을 잡을 수 있었다. 매니저님이 먼저 나를 개인적으로 불러 이력서 관련 팁들을 알려주시기도 했다. </p>
<p>끄적일 이력서도 없는 나에게 도움을 퍼부어주시는 게 너무 감사하기도 했고...💧 
얼른 뭐라도 해서 결과물을 만들어 내야겠다는 생각을 했다. </p>
<h3 id="💻">💻</h3>
<img src="https://velog.velcdn.com/images/areumh__9/post/a467171b-1aad-4979-82f2-0f091dbdffe3/image.jpeg" width="60%" />

<p>엥?
또 하나 얻은 것은 <strong>맥북</strong>이다..</p>
<p>9주차 과제 중 서버를 여러 개 띄워야 하는 테스트가 있었는데, 윈도우에서는 라이브러리를 2개나 추가한 뒤 스크립트 명령어를 수정해줘야 했다. 이를 알기 전에는 테스트가 돌아가지 않는게 내가 코드를 잘못 건드려서 그런건지, 뭔 문제인 건지 이유를 알 수가 없어서 너무 스트레스를 받아 진심 폭발할 뻔했다.</p>
<p>안그래도 느려 터진 6년된 노트북 포맷해서 쓰고 있었는데 결국 참지 못하고 맥북을 질렀다.
맥북 구매에 도움을 준 페어 1팀에게 감사 인사를.. 🙇</p>
<p>그렇게 난 항해에서 피자 팔고 맥북 산 사람이 되었다 🍕</p>
<br />

<h2 id="🎯-앞으로는-">🎯 앞으로는 ?</h2>
<p>항해가 끝났음에도 불구하고 프론트엔드 6기는 아직 진행 중인 것 같다 ⚓️
공식이 아닌 새로 개설된 6기 디스코드 채널엔 학메님들과 팀원들, 코치님들이 들어와 계시고 
각종 스터디가 열리거나, 채용 공고 공유, 뉴스 레터 공유 등 활발히 운영되고 있다. </p>
<p>난 <strong>2주 안에 블로그 만들기 스터디</strong>, <strong>2주 1글 쓰기 스터디</strong>에 참여 중이다. 
당장 개인 프로젝트로 진행할 수 있고, 회고 글쓰는 연습이 필요한 나에게 너무 적합한 스터디들이다.👍</p>
<p>앞으로도 항해를 통해 얻은 것들을 토대로 꾸준히 노력하고 도전해보려 한다.
이번 항해가 끝이 아니라 시작이라는 마음으로, 목표를 하나씩 이뤄가며 성장해 나가고 싶다.
나의 항해는 이제부터 시작이다!!!!!! 🌊🌊🌊</p>
<img src="https://velog.velcdn.com/images/areumh__9/post/5794278b-b233-43db-9057-663bfec846cc/image.jpeg" width="70%" />

]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 실행 모드 마스터하기]]></title>
            <link>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%8B%A4%ED%96%89-%EB%AA%A8%EB%93%9C-%EB%A7%88%EC%8A%A4%ED%84%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%8B%A4%ED%96%89-%EB%AA%A8%EB%93%9C-%EB%A7%88%EC%8A%A4%ED%84%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 01 Oct 2025 16:26:13 GMT</pubDate>
            <description><![CDATA[<h2 id="1-클로드-코드의-실행-모드-개요">1. 클로드 코드의 실행 모드 개요</h2>
<h3 id="기본-동작-모드">기본 동작 모드</h3>
<table>
<thead>
<tr>
<th align="center">모드</th>
<th align="center">입출력 방식</th>
<th align="center">사용자 상호작용</th>
<th align="center">적용 상황</th>
</tr>
</thead>
<tbody><tr>
<td align="center">인터렉티브 모드</td>
<td align="center">대화형</td>
<td align="center">실시간 피드백</td>
<td align="center">일반 개발, 탐색, 학습</td>
</tr>
<tr>
<td align="center">프린트 모드</td>
<td align="center">비대화형</td>
<td align="center">단방향 출력</td>
<td align="center">스크립트, 자동화, CI/CD</td>
</tr>
</tbody></table>
<h3 id="권한-및-안전성-옵션">권한 및 안전성 옵션</h3>
<table>
<thead>
<tr>
<th align="center">옵션</th>
<th align="center">권한 확인</th>
<th align="center">안전성</th>
<th align="center">적용 상황</th>
</tr>
</thead>
<tbody><tr>
<td align="center">일반 모드</td>
<td align="center">매번 확인</td>
<td align="center">높음</td>
<td align="center">프로덕션 작업</td>
</tr>
<tr>
<td align="center">YOLO 모드</td>
<td align="center">권한 스킵</td>
<td align="center">낮음</td>
<td align="center">빠른 프로토타이핑</td>
</tr>
</tbody></table>
<h3 id="인터랙티브-모드-내-추가-기능">인터랙티브 모드 내 추가 기능</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">특징</th>
<th align="center">적용 상황</th>
</tr>
</thead>
<tbody><tr>
<td align="center">일반 모드</td>
<td align="center">기본 상태, 각 편집 확인</td>
<td align="center">일반적인 개발 작업</td>
</tr>
<tr>
<td align="center">Auto-Accept Edits</td>
<td align="center">편집 자동 수락</td>
<td align="center">반복 작업, 리팩토링</td>
</tr>
<tr>
<td align="center">플랜 모드</td>
<td align="center">실행 전 계획 수립</td>
<td align="center">복잡한 작업, 아키텍처 변경에 관한 계획 수립, 명령어 실행 및 파일 쓰기가 필요없는 상황</td>
</tr>
<tr>
<td align="center">Bypass Permissions 모드</td>
<td align="center">모든 권한 자동 수락</td>
<td align="center">좀 더 다양한 작업</td>
</tr>
<tr>
<td align="center">+) <code>Shift + Tab</code>으로 모드 순환</td>
<td align="center"></td>
<td align="center"></td>
</tr>
</tbody></table>
<br />

<h2 id="2-인터랙티브-모드-대화형-모드">2. 인터랙티브 모드 (대화형 모드)</h2>
<p><strong>특징</strong></p>
<ul>
<li>실시간 질문과 답변</li>
<li>단계별 확인과 피드백</li>
<li>복잡한 작업을 점진적으로 진행</li>
<li>사용자 승인 하에 파일 수정</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>이해 목적 : 새로운 기술 탐색, 코드 리뷰/해설, 난해한 개념 분해</li>
<li>탐색적 개발 : 요구가 불명확할 때, 옵션 비교가 필요할 때, 실험적 기능 초안</li>
<li>페어 프로그래밍 느낌으로 세밀한 의사결정이 잦은 직업 전반</li>
</ul>
<br />

<h2 id="3-프린트-모드-비대화형-모드">3. 프린트 모드 (비대화형 모드)</h2>
<p><strong>특징</strong></p>
<ul>
<li>비대화형 실행</li>
<li>결과를 직접 출력</li>
<li>스크립트 및 자동화에 적합</li>
<li>단일 명령으로 완료</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>CI/CD 파이프라인 통합 가능</li>
<li>자동화 스크립트 작성 용이</li>
<li>빠른 실행 속도</li>
<li>배치 작업 처리 가능</li>
</ul>
<br />

<h2 id="4-yolo-모드-권한-스킵-옵션">4. YOLO 모드 (권한 스킵 옵션)</h2>
<p><strong>특징</strong></p>
<ul>
<li>매우 빠른 실행 속도</li>
<li>프로토타이핑에 최적화</li>
<li>반복 작업 자동화</li>
<li>최소한의 질문과 확인</li>
</ul>
<p><strong>주의사항</strong></p>
<ul>
<li>최소한의 안전 검사</li>
<li>개발 환경에서만 권장</li>
<li>되돌릴 수 있는 변경만 수행</li>
<li>중요한 파일 수정 시 주의</li>
<li>격리된 환경에서 사용 권장</li>
</ul>
<br />

<h2 id="5-인터랙티브-모드의-특수-키-기능">5. 인터랙티브 모드의 특수 키 기능</h2>
<h3 id="작업-제어---esc">작업 제어 - ESC</h3>
<p><strong>ESC 1번 - 작업 중단</strong></p>
<ul>
<li>클로드의 현재 작업 즉시 중단</li>
<li>컨텍스트는 유지되어 다른 지시사항 제공 가능</li>
<li>잘못된 방향으로 진행 중일 때 유용</li>
</ul>
<p><strong>ESC 2번 - 히스토리 점프</strong></p>
<ul>
<li>대화 히스토리로 이동하여 이전 프롬프트 편집 가능</li>
<li>다른 방향으로 작업을 탐색하고 싶을 때 사용</li>
<li>새로운 분기를 만들어 대안적 접근 시도 가능</li>
</ul>
<p><strong>모드 전환 - shift + tab</strong></p>
<ul>
<li>일반 모드(기본) : 각 편집 사항을 개별적으로 확인</li>
<li>Auto-Accept Edits : 모든 편집을 자동으로 수락</li>
<li>플랜 모드 : 복잡한 작업의 계획을 먼저 수립 (읽기 전용)</li>
</ul>
<br />

<h2 id="6-모드별-상세-설명">6. 모드별 상세 설명</h2>
<h3 id="auto-accept-edits-자동-편집-수락">Auto-Accept Edits (자동 편집 수락)</h3>
<p><strong>특징</strong></p>
<ul>
<li>자동 편집 수락 : 모든 파일 변경을 자동으로 승인</li>
<li>빠른 작업 속도 : 확인 단계 생략으로 시간 단축</li>
<li>대략 작업에 최적화 : 여러 파일의 일괄 수정에 효과적</li>
<li>인터랙티브와 프린트의 중간 : 대화는 가능하지만 편집은 자동 수락</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>반복 작업 자동화 : 비슷한 패턴의 수정 작업을 빠르게 처리</li>
<li>리팩토링 효율성 : 대규모 코드 개선 작업에 이상적</li>
<li>생산성 향상 : 신뢰할 수 있는 작업에서 확인 시간 절약</li>
<li>일관된 코드 스타일 : 전체 코드 베이스에 일관된 규칙 적용</li>
</ul>
<br />

<h3 id="플랜-모드-계획-수립">플랜 모드 (계획 수립)</h3>
<p><strong>특징</strong></p>
<ul>
<li>단계별 계획 수립 : 복잡한 작업을 작은 단계로 분해</li>
<li>위험 분석 : 각 단계의 잠재적 위험 사전 식별</li>
<li>영향도 평가 : 변경사항이 미칠 영향 사전 분석</li>
<li>롤백 전략 : 문제 발생 시 복구 계획 포함</li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>리스크 최소화 : 실행 전 충분한 검토로 위험 감소</li>
<li>팀 협업 강화 : 계획을 팀과 공유하고 검토 가능</li>
<li>복잡한 작업 관리 : 대규모 변경사항을 체계적으로 관리</li>
<li>문서화 : 작업 과정이 자동으로 문서화됨</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 4-2. 코드 관점의 성능 최적화: 회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-4-2.-%EC%BD%94%EB%93%9C-%EA%B4%80%EC%A0%90%EC%9D%98-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-4-2.-%EC%BD%94%EB%93%9C-%EA%B4%80%EC%A0%90%EC%9D%98-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 28 Sep 2025 12:36:34 GMT</pubDate>
            <description><![CDATA[<h2 id="4-2-코드-관점의-성능-최적화">4-2. 코드 관점의 성능 최적화</h2>
<h2 id="wil">WIL</h2>
<h3 id="✅-api-호출-최적화">✅ API 호출 최적화</h3>
<pre><code class="language-typescript">// (이미 호출한 api는 다시 호출하지 않도록 - 클로저를 이용하여 캐시 구성)
const createLectureFetcher = () =&gt; {
  let lectureCache: Lecture[] | null = null; // 클로저에서만 유지되는 캐시

  const fetchAllLectures = async () =&gt; {
    if (lectureCache) return lectureCache; // 캐시가 있으면 api 호출 생략

    const res = await Promise.all([
      (console.log(&#39;API Call 1&#39;, performance.now()), fetchMajors()),
      (console.log(&#39;API Call 2&#39;, performance.now()), fetchLiberalArts()),
      (console.log(&#39;API Call 3&#39;, performance.now()), fetchMajors()),
      (console.log(&#39;API Call 4&#39;, performance.now()), fetchLiberalArts()),
      (console.log(&#39;API Call 5&#39;, performance.now()), fetchMajors()),
      (console.log(&#39;API Call 6&#39;, performance.now()), fetchLiberalArts()),
    ]);

    lectureCache = res.flatMap((r) =&gt; r.data); // 전공과 교양을 평탄화하여 하나의 배열로 처리
    return lectureCache;
  };

  return { fetchAllLectures };
};

// fetcher 생성
const lectureFetcher = createLectureFetcher();</code></pre>
<p>lectureCache는 createLectureFetcher 내부에만 존재하는 클로저 캐시이며, api를 호출한 후 데이터를 저장한다. 최초 호출 이후에 또 호출하게 되면 이를 즉시 반환하며, 외부에서는 캐시에 접근할 수 없다. 모달 컴포넌트 외부에 한 번만 생성하여 모달의 리렌더링에 관계 없이 캐시 유지가 가능하다!!</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/0f39bfab-de68-42ef-9f32-5f028f6dbcbd/image.png" alt=""> 모달을 열어보면 최고 한 번 열었을 때만 api를 호출하고, 그 이후엔 캐싱된 값을 사용하여 api를 호출하지 않는 것을 확인할 수 있다 👍</p>
<br />


<h3 id="✅-컴포넌트-렌더링-최적화">✅ 컴포넌트 렌더링 최적화</h3>
<pre><code class="language-typescript">// 각 컴포넌트로 분리 후 메모이제이션 적용
export default React.memo(LectureHeadItem);
export default React.memo(LectureItem);
export default React.memo(ScheduleTemplate);</code></pre>
<p>강의 헤더 아이템과 단일 아이템, 시간표 템플릿 컴포넌트 등을 분리한 뒤 memo를 사용하여 리렌더링을 방지했다. 이 외에도 filteredLectures, visibleLectures, allMajor, changeSearchOption 등에 <strong>useMemo, useCallback</strong>을 사용하여 최적화했다.</p>
<br />


<pre><code class="language-typescript">// 각 필터 분리 후 메모이제이션 적용
export default React.memo(CreditFilter);
export default React.memo(DayFilter);
export default React.memo(GradeFilter);
export default React.memo(MajorFilter);
export default React.memo(QueryFilter);
export default React.memo(TimeFilter);

// src/components/filter/SearchOptionFilter.tsx
interface SearchOptionFilterProps {
  searchOptions: SearchOption;
  allMajors: string[];
  changeSearchOption: (field: keyof SearchOption, value: SearchOption[typeof field]) =&gt; void;
}

const SearchOptionFilter = ({ searchOptions, allMajors, changeSearchOption }: SearchOptionFilterProps) =&gt; {
  return (
    &lt;&gt;
      &lt;HStack spacing={4}&gt;
        {/* 검색 필터링 */}
        &lt;QueryFilter query={searchOptions.query} changeSearchOption={changeSearchOption} /&gt;

        {/* 학점 필터링 */}
        &lt;CreditFilter credits={searchOptions.credits} changeSearchOption={changeSearchOption} /&gt;
      &lt;/HStack&gt;

      &lt;HStack spacing={4}&gt;
        {/* 학년 필터링 */}
        &lt;GradeFilter grades={searchOptions.grades} changeSearchOption={changeSearchOption} /&gt;

        {/* 요일 필터링 */}
        &lt;DayFilter days={searchOptions.days} changeSearchOption={changeSearchOption} /&gt;
      &lt;/HStack&gt;

      &lt;HStack spacing={4}&gt;
        {/* 시간 필터링 */}
        &lt;TimeFilter times={searchOptions.times} changeSearchOption={changeSearchOption} /&gt;

        {/* 전공 필터링 */}
        &lt;MajorFilter majors={searchOptions.majors} allMajors={allMajors} changeSearchOption={changeSearchOption} /&gt;
      &lt;/HStack&gt;
    &lt;/&gt;
  );
};

export default SearchOptionFilter;</code></pre>
<p>필터링 컴포넌트의 경우는 각 필터를 컴포넌트로 분리하여 <strong>React.memo</strong>로 감싸주었다. 
이를 통해 props의 값이 변경되지 않으면 리렌더링이 발생하지 않도록 개선했다! </p>
<br />


<h3 id="✅-시간표-블록-드래그-드롭-최적화">✅ 시간표 블록 드래그, 드롭 최적화</h3>
<pre><code class="language-typescript">const ScheduleTable = React.memo(({ index, disabled, tableId, initialSchedule, onDuplicate, onRemove }: Props) =&gt; {
  // 시간표 개별 상태 관리
  const [schedules, setSchedules] = useState&lt;Schedule[]&gt;(initialSchedule);
  // 현재 선택된 강의 정보 - 시간표 id, 요일, 시간
  const [searchInfo, setSearchInfo] = useState&lt;{
    tableId: string;
    day?: string;
    time?: number;
  } | null&gt;(null);

  const [isActive, setIsActive] = useState&lt;Active | null&gt;(null);

  const getColor = (lectureId: string): string =&gt; {
    const lectures = [...new Set(schedules.map(({ lecture }) =&gt; lecture.id))];
    const colors = [&#39;#fdd&#39;, &#39;#ffd&#39;, &#39;#dff&#39;, &#39;#ddf&#39;, &#39;#fdf&#39;, &#39;#dfd&#39;];
    return colors[lectures.indexOf(lectureId) % colors.length];
  };

  // 드래그 시작 시 active 업데이트
  const handleDragStart = ({ active }: { active: Active }) =&gt; {
    setIsActive(active);
  };

  // 드래그 종료 시 호출
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const handleDragEnd = (event: any) =&gt; {
    // active - 드래그한 아이템
    // delta - 이동 거리
    const { active, delta } = event;
    // 드래그 종료 시 active 제거
    setIsActive(null);

    const { x, y } = delta;
    const [, index] = active.id.split(&#39;:&#39;);
    const schedule = schedules[index];
    const nowDayIndex = DAY_LABELS.indexOf(schedule.day as (typeof DAY_LABELS)[number]);

    // 이동한 그리드 계산
    const moveDayIndex = Math.floor(x / 80);
    const moveTimeIndex = Math.floor(y / 30);

    setSchedules((prev) =&gt;
      prev.map((schedule, idx) =&gt;
        idx === Number(index)
          ? {
              ...schedule,
              day: DAY_LABELS[nowDayIndex + moveDayIndex],
              range: schedule.range.map((time) =&gt; time + moveTimeIndex),
            }
          : { ...schedule }
      )
    );
  };

  // SearchDialog에서 강의 추가
  const addLecture = useCallback(
    (lecture: Lecture) =&gt; {
      if (!searchInfo) return;

      const newSchedules: Schedule[] = parseSchedule(lecture.schedule).map((schedule) =&gt; ({
        ...schedule,
        lecture,
      }));

      setSchedules((prev) =&gt; [...prev, ...newSchedules]);
      setSearchInfo(null); // 모달 닫기
    },
    [searchInfo]
  );

  // 강의 삭제
  const deleteLecture = useCallback((day: string, time: number) =&gt; {
    setSchedules((prev) =&gt; prev.filter((schedule) =&gt; schedule.day !== day || !schedule.range.includes(time)));
  }, []);

  return (
    &lt;Stack key={tableId} width=&quot;600px&quot;&gt;
      &lt;Flex justifyContent=&quot;space-between&quot; alignItems=&quot;center&quot;&gt;
        &lt;Heading as=&quot;h3&quot; fontSize=&quot;lg&quot;&gt;
          시간표 {index + 1}
        &lt;/Heading&gt;
        &lt;ButtonGroup size=&quot;sm&quot; isAttached&gt;
          &lt;Button colorScheme=&quot;green&quot; onClick={() =&gt; setSearchInfo({ tableId })}&gt;
            시간표 추가
          &lt;/Button&gt;
          &lt;Button colorScheme=&quot;green&quot; mx=&quot;1px&quot; onClick={() =&gt; onDuplicate(tableId, schedules)}&gt;
            복제
          &lt;/Button&gt;
          &lt;Button colorScheme=&quot;green&quot; isDisabled={disabled} onClick={() =&gt; onRemove(tableId)}&gt;
            삭제
          &lt;/Button&gt;
        &lt;/ButtonGroup&gt;
      &lt;/Flex&gt;

      {/* DnDContext 적용 */}
      &lt;ScheduleDndProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd}&gt;
        &lt;Box position=&quot;relative&quot; outline={isActive ? &#39;5px dashed&#39; : undefined} outlineColor=&quot;blue.300&quot;&gt;
          &lt;ScheduleTemplate tableId={tableId} onCellClick={setSearchInfo} /&gt;

          {schedules.map((schedule, index) =&gt; (
            &lt;DraggableSchedule
              key={`${schedule.lecture.title}-${index}`}
              id={`${tableId}:${index}`}
              data={schedule}
              bg={getColor(schedule.lecture.id)}
              onDeleteButtonClick={() =&gt; deleteLecture(schedule.day, schedule.range[0])}
            /&gt;
          ))}

          {searchInfo?.tableId === tableId &amp;&amp; (
            &lt;SearchDialog
              searchInfo={searchInfo}
              onClose={() =&gt; setSearchInfo(null)}
              addLecture={addLecture} // 모달에서 강의 추가
            /&gt;
          )}
        &lt;/Box&gt;
      &lt;/ScheduleDndProvider&gt;
    &lt;/Stack&gt;
  );
});

export default ScheduleTable;</code></pre>
<p>개별 시간표 내에서 강의 추가, 삭제, 수정 (dnd) 등의 상태 변화가 일어날 때 전체 시간표의 렌더링을 최적화하기 위해 <code>useState</code>로 개별 시간표 상태를 만들어 관리하도록 수정했다. </p>
<p>그리고 기존에 App.tsx 파일에 작성되어있던 <code>ScheduleDndProvider</code>를 각 테이블 파일 내로 이동시켜 전체 테이블 리렌더링을 최적화했다. 😊</p>
<br />

<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>이번 과제는 React Developer Tools를 사용하여 현재 페이지 내에서 컴포넌트 렌더링이 어떻게 진행되고 있는지 확인해가면서 직접 개선해나가는 의미있는 경험이었다. 아무래도 성능 최적화를 목표로 하여 개발한 경험이 없다보니 신기하게 느껴지기도 했고, 실제로 성능 개선이 눈에 보이는 과정을 겪으며 성취감도 느껴졌다. </p>
<h3 id="problem">Problem</h3>
<p>위에서 말했듯 성능 최적화 경험이 없어서 그런지 과제를 시작할 때 감이 잘 잡히지 않았고, useMemo나 useCallback을 남발하게 됐던 것 같다. 어디까지 최적화를 적용해야 할지 판단하는 기준에 대해서도 확신이 서지 않는다. 코드를 작성할 때부터 성능을 의식하기보단 먼저 기능을 구현한 뒤에 성능을 측정하고 병목을 확인한 후에 최적화를 진행하는 습관을 들이면 감이 잡히지 않을까 하는 생각이 든다.</p>
<h3 id="try">Try</h3>
<p>예전에 시간표 컴포넌트를 구현해본 경험이 있는데, 그 때 스타일을 하나하나 계산하여 강의 아이템을 배치했었기 때문에 이번 과제로 처음 알게된 dnd 라이브러리가 너무 신기했다...😳 라이브러리를 사용하여 아주 간단한 사이트를 만들면서 글로도 정리해보고 싶다는 생각이 들었당 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드에서 제공하는 내장 명령어]]></title>
            <link>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C-%EC%A0%9C%EA%B3%B5%ED%95%98%EB%8A%94-%EB%82%B4%EC%9E%A5-%EB%AA%85%EB%A0%B9%EC%96%B4</link>
            <guid>https://velog.io/@areumh__9/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EC%97%90%EC%84%9C-%EC%A0%9C%EA%B3%B5%ED%95%98%EB%8A%94-%EB%82%B4%EC%9E%A5-%EB%AA%85%EB%A0%B9%EC%96%B4</guid>
            <pubDate>Mon, 22 Sep 2025 11:30:05 GMT</pubDate>
            <description><![CDATA[<h2 id="클로드-코드에서-제공하는-내장-명령어">클로드 코드에서 제공하는 내장 명령어</h2>
<h3 id="1-기본-기능-및-대화-제어">1. 기본 기능 및 대화 제어</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/help</td>
<td align="center">사용법 도움말 및 사용 가능한 명령어 확인</td>
</tr>
<tr>
<td align="center">/clear (reset)</td>
<td align="center">대화 기록 지우기</td>
</tr>
<tr>
<td align="center">/compact [instructions]</td>
<td align="center">대화 압축 (선택적 집중 지침 포함)</td>
</tr>
<tr>
<td align="center">/export</td>
<td align="center">대화를 클립보드 또는 파일로 내보내기</td>
</tr>
<tr>
<td align="center">/resume</td>
<td align="center">특정 세션의 대화를 계속 진행</td>
</tr>
<tr>
<td align="center">/bug</td>
<td align="center">버그 신고 (대화 내용을 앤트로픽으로 전송)</td>
</tr>
<tr>
<td align="center">/exit (quit)</td>
<td align="center">현재 세션 종료</td>
</tr>
</tbody></table>
<br />

<h3 id="2-모델-설정-및-계정-관리">2. 모델 설정 및 계정 관리</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/model</td>
<td align="center">인공지능 모델 선택 또는 변경</td>
</tr>
<tr>
<td align="center">/login</td>
<td align="center">앤트로픽 계정 전환</td>
</tr>
<tr>
<td align="center">/logout</td>
<td align="center">앤트로픽 계정에서 로그아웃</td>
</tr>
</tbody></table>
<br />

<h3 id="3-파일-및-프로젝트-관리">3. 파일 및 프로젝트 관리</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/init</td>
<td align="center">클로드 코드가 프로젝트를 이해할 수 있도록 도와주는 초기 명령어</td>
</tr>
<tr>
<td align="center">/memory</td>
<td align="center">CLAUDE.md의 메모리 파일 편집</td>
</tr>
<tr>
<td align="center">/permissions (allowed-tools)</td>
<td align="center">파일에 접근하는 도구의 허용 여부 설정</td>
</tr>
<tr>
<td align="center">/add-dirs</td>
<td align="center">클로드 코드가 여러 디렉터리 간 작업을 가능하게 함</td>
</tr>
</tbody></table>
<br />

<h3 id="4-시스템-점검-및-요금-통계">4. 시스템 점검 및 요금 통계</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/status</td>
<td align="center">현재 상태 및 시스템 정보 확인</td>
</tr>
<tr>
<td align="center">/cost</td>
<td align="center">토큰 사용량 및 비용 통계 표시</td>
</tr>
<tr>
<td align="center">/doctor</td>
<td align="center">클로드 코드의 설치 상태 및 건강 상태 검사</td>
</tr>
</tbody></table>
<br />

<h3 id="5-보기-및-입출력-설정">5. 보기 및 입출력 설정</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/config (theme)</td>
<td align="center">클로드 코드의 보기 설정 및 수정</td>
</tr>
<tr>
<td align="center">/vim</td>
<td align="center">Vim 모드를 사용</td>
</tr>
<tr>
<td align="center">!</td>
<td align="center">배시 모드를 사용</td>
</tr>
<tr>
<td align="center">/bashes</td>
<td align="center">백그라운드에서 실행 중인 배시 세션들의 조회 및 관리</td>
</tr>
<tr>
<td align="center">/output-style</td>
<td align="center">출력 스타일 변경</td>
</tr>
<tr>
<td align="center">/output-style:new</td>
<td align="center">새로운 출력 스타일을 생성</td>
</tr>
<tr>
<td align="center">/statusline</td>
<td align="center">클로드 코드의 현재 상태를 표시하고 제어</td>
</tr>
</tbody></table>
<br />

<h3 id="6-코드-리뷰-및-분석">6. 코드 리뷰 및 분석</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/review</td>
<td align="center">코드 리뷰 수행</td>
</tr>
<tr>
<td align="center">/security-review</td>
<td align="center">보안 관점의 코드 리뷰 수행</td>
</tr>
<tr>
<td align="center">/pr_comments</td>
<td align="center">풀 리퀘스트(Pull Request) 코멘트 관련 기능</td>
</tr>
</tbody></table>
<br />

<h3 id="7-편의성">7. 편의성</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/terminal-setup</td>
<td align="center">사용자의 터미널 환경 설정</td>
</tr>
<tr>
<td align="center">/ide</td>
<td align="center">내장 IDE 환경 열기 및 코드 작업 (ex. VS Code 또는 커서 AI)</td>
</tr>
<tr>
<td align="center">/migrate-installer</td>
<td align="center">클로드 코드의 설치를 다른 형태로 마이그레이션 (ex. npm 로컬)</td>
</tr>
</tbody></table>
<br />

<h3 id="8-기능-확장">8. 기능 확장</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/agents</td>
<td align="center">전문화된 인공지능 서브에이전트의 생성 및 관리</td>
</tr>
<tr>
<td align="center">/mcp</td>
<td align="center">MCP(Model Context Protocol) 관련 기능 제어</td>
</tr>
<tr>
<td align="center">/install-github-app</td>
<td align="center">깃허브 저장소에 클로드 앱 설치 및 프로젝트 연동</td>
</tr>
<tr>
<td align="center">/hooks</td>
<td align="center">사용자 정의 훅 설정 및 이벤트 연동</td>
</tr>
</tbody></table>
<br />

<h3 id="9-기타">9. 기타</h3>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">/release-notes</td>
<td align="center">현재까지의 릴리스 노트 확인</td>
</tr>
<tr>
<td align="center">/upgrade</td>
<td align="center">구독 플랜을 업그레이드</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 4-1. 성능 최적화: SSR, SSG, Infra: 회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-4-1.-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-SSR-SSG-Infra-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-4-1.-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-SSR-SSG-Infra-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 15 Sep 2025 17:57:43 GMT</pubDate>
            <description><![CDATA[<h2 id="4-1-성능-최적화-ssr-ssg-infra">4-1. 성능 최적화: SSR, SSG, Infra</h2>
<h2 id="wil">WIL</h2>
<p>우선 개념 정리를 해보자..!</p>
<h3 id="✅-csr-client-side-rendering">✅ CSR (Client Side Rendering)</h3>
<p>서버는 기본적으로 빈 HTML + JS 파일을 내려주고 브라우저가 JS를 실행해 화면을 그리는 방식</p>
<ul>
<li>초기 로딩 속도가 상대적으로 느림 (JS를 실행한 후 화면이 보이기 때문)</li>
<li>첫 화면은 늦게 뜨지만, 이후 페이지 전환은 빠름</li>
</ul>
<h3 id="✅-ssr-server-side-rendering">✅ SSR (Server Side Rendering)</h3>
<p>서버가 요청을 받을 때마다 완성된 HTML을 만들어 내려주고, 브라우저가 바로 렌더링하는 방식</p>
<ul>
<li>첫 화면 로딩 속도가 빠름</li>
<li>SEO에 유리</li>
<li>요청마다 HTML을 생성해야 하므로 서버 부하 증가</li>
</ul>
<h3 id="✅-ssg-static-site-generation">✅ SSG (Static Site Generation)</h3>
<p>빌드 시점에 HTML을 미리 생성해두고, 사용자가 접속하면 즉시 정적 파일을 내려주는 방식</p>
<ul>
<li>빠른 응답 속도</li>
<li>SEO에 최적화</li>
<li>빌드 시점 이후의 데이터 반영이 어려움</li>
</ul>
<h3 id="✅-hydration">✅ Hydration</h3>
<p>클라이언트에서 JS가 실행되면서 기존 HTML에 이벤트 바인딩을 입히는 과정</p>
<ul>
<li>빠른 첫 화면 제공</li>
<li>페이지가 크고 복잡할수록 하이드레이션 지연 문제 발생</li>
</ul>
<br />

<h3 id="1-express-ssr-서버">1. Express SSR 서버</h3>
<pre><code class="language-typescript">const prod = process.env.NODE_ENV === &quot;production&quot;;
const app = express();

// node 환경에서 msw 서버 세팅
server.listen({
  onUnhandledRequest: &quot;bypass&quot;,
});

let vite;

if (!prod) { // prod를 사용한 환경 분기 처리
  const { createServer } = await import(&quot;vite&quot;);

  // 개발 환경 - vite 미들웨어 주입
  vite = await createServer({
    server: { middlewareMode: true },
    appType: &quot;custom&quot;,
  });
  app.use(vite.middlewares);
} else {
  const compression = (await import(&quot;compression&quot;)).default;
  const sirv = (await import(&quot;sirv&quot;)).default;
  app.use(compression());
  app.use(
    base,
    sirv(&quot;dist/vanilla&quot;, {
      extensions: [],
    }),
  );
}

app.use(&quot;*all&quot;, async (req, res) =&gt; {
    // ~

    const rendered = await render(url, req.query);

    // HTML 템플릿 치환
    const html = template
      .replace(`&lt;!--app-head--&gt;`, rendered.head ?? &quot;&quot;)
      .replace(`&lt;!--app-html--&gt;`, rendered.html ?? &quot;&quot;)
      .replace(
        `&lt;!--app-initial-data--&gt;`,
        `&lt;script&gt;window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)}&lt;/script&gt;`,
      );

    res.status(200).set({ &quot;Content-Type&quot;: &quot;text/html&quot; }).send(html);
  } catch (e) {
    vite?.ssrFixStacktrace(e);
    console.log(e.stack);
    res.status(500).end(e.stack);
  }
});

// Start http server
app.listen(port, () =&gt; {
  console.log(`React Server started at http://localhost:${port}`);
});</code></pre>
<ul>
<li>Express 기반 서버 구축</li>
<li>prod를 사용한 개발/프로덕션 환경 분기 처리</li>
<li>HTML 템플릿 치환
  : vite 미들웨어를 개발 환경에 적용하고, prod에서는 sirv로 정적 파일 제공, 모든 요청에서 render 결과를 HTML 템플릿에 치환해 클라이언트로 전달</li>
</ul>
<br />

<h3 id="2-서버-사이드-렌더링">2. 서버 사이드 렌더링</h3>
<pre><code class="language-typescript">import { Router, ServerRouter } from &quot;../lib&quot;;
import { BASE_URL } from &quot;../constants.js&quot;;

export const router = typeof window !== &quot;undefined&quot; ? new Router(BASE_URL) : new ServerRouter(&quot;&quot;);</code></pre>
<pre><code class="language-typescript">router.addRoute(&quot;/&quot;, HomePage);
router.addRoute(&quot;/product/:id/&quot;, ProductDetailPage);

export async function render(url, query = {}) {
  // ~

  const { path, params } = matched;

  let initialData;

  // 서버 데이터 프리페칭
  if (path === &quot;/&quot;) {
    initialData = await fetchProductsDataSSR(query);
  } else if (path === &quot;/product/:id/&quot;) {
    initialData = await fetchProductDataSSR(params.id);
  }

  let pageTitle;
  let pageHtml;

  // 페이지 상태 결정 및 HTML 렌더링
  if (path === &quot;/&quot;) {
    pageTitle = &quot;쇼핑몰 - 홈&quot;;
    pageHtml = HomePage({ initialData, query });
  } else if (path === &quot;/product/:id/&quot;) {
    pageTitle = initialData?.currentProduct?.title ? `${initialData?.currentProduct?.title} - 쇼핑몰` : &quot;쇼핑몰&quot;;
    pageHtml = ProductDetailPage({ initialData });
  } else {
    pageHtml = NotFoundPage();
  }

  // 렌더링 결과 반환 (initialData - 하이드레이션에 필요한 초기 상태)
  return {
    head: `&lt;title&gt;${pageTitle}&lt;/title&gt;`,
    html: pageHtml,
    initialData,
  };
}</code></pre>
<ul>
<li>서버에서 동작하는 Router 구현</li>
<li>서버 데이터 프리페칭</li>
<li>서버 상태관리 초기화
  : 요청 url에 따라 fetchProductDataSSR 함수를 통해 데이터를 미리 가져오고 initialData를 페이지 컴포넌트에 전달하여 서버에서 HTML 렌더링</li>
</ul>
<br />


<h3 id="3-클라이언트-hydration">3. 클라이언트 Hydration</h3>
<pre><code class="language-typescript">const createMemoryStorage = () =&gt; {
  let value = {};

  return {
    getItem: (key) =&gt; (key in value ? value[key] : null),
    setItem: (key, value) =&gt; {
      value[key] = value;
    },
    removeItem: (key) =&gt; {
      delete value[key];
    },
    clear: () =&gt; {
      value = {};
    },
  };
};

const memoryStorage = createMemoryStorage();

// 브라우저 환경이 아닐 때를 위한 메모리 저장 storage 추가
export const createStorage = (key, storage = typeof window === &quot;undefined&quot; ? memoryStorage : window.localStorage) =&gt; {</code></pre>
<pre><code class="language-typescript">export const HomePage = withLifecycle(
  {
    onMount: () =&gt; {
      if (typeof window === &quot;undefined&quot;) return;
      loadProductsAndCategories(); // csr
    },
    watches: [
      [
        () =&gt; {
          const { search, limit, sort, category1, category2 } = router.query;
          return [search, limit, sort, category1, category2];
        },
        () =&gt; {
          loadProducts(true);
        },
      ],
    ],
  },
  // 서버에서 렌더링한 초기 데이터를 클라이언트에서 가져옴
  ({ initialData = window.__INITIAL_DATA__, query = router.query } = {}) =&gt; {
    if (!productStore.getState().products.length &amp;&amp; initialData) {
      // initialData를 전역 상태에 넣어 SSR와 클라이언트 상태를 일치시킴
      productStore.dispatch({
        type: PRODUCT_ACTIONS.SETUP,
        payload: initialData,
      });
    }

    // SSR - initialData, CSR - store
    const productState = typeof window === &quot;undefined&quot; ? initialData : productStore.getState();

    const {
      search: searchQuery,
      limit,
      sort,
      category1,
      category2,
    } = typeof window === &quot;undefined&quot; ? query : router.query;


    return (
      // ~
    )
  } 
)</code></pre>
<ul>
<li>window._<em>INITIAL_DATA_</em> 스크립트 주입</li>
<li>클라이언트 상태 복원</li>
<li>서버-클라이언트 데이터 일치
  : 서버에서 내려준 initialData를 클라이언트 store에 넣어 SSR과 동일한 상태를 복원하고, onMount에서 필요한 CSR API 호출로 상태를 최신화</li>
</ul>
<br />


<h3 id="4-static-site-generation">4. Static Site Generation</h3>
<pre><code class="language-typescript">async function generateStaticSite(url, query) {

  const rendered = await render(url, query);

  const html = template
    .replace(`&lt;!--app-head--&gt;`, rendered.head ?? &quot;&quot;)
    .replace(`&lt;!--app-html--&gt;`, rendered.html ?? &quot;&quot;)
    .replace(
      `&lt;/head&gt;`,
      `
        &lt;script&gt;
          window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData || {})};
        &lt;/script&gt;
        &lt;/head&gt;
      `,
    );

  if (url == &quot;/404&quot;) {
    fs.writeFileSync(&quot;../../dist/vanilla/404.html&quot;, html);
  } else {
    // 지정한 경로에 폴더가 없으면 생성
    if (!fs.existsSync(`../../dist/vanilla${url}`)) {
      fs.mkdirSync(`../../dist/vanilla${url}`, { recursive: true });
    }
    // 렌더링된 HTML을 해당 폴더 안에 index.html 파일로 저장 - 정적 배포 가능한 파일
    fs.writeFileSync(`../../dist/vanilla${url}/index.html`, html);
  }
}

const { products } = await getProducts();

// 홈 페이지와 404 페이지를 빌드 타임에 미리 생성
await generateStaticSite(&quot;/&quot;, {});
await generateStaticSite(&quot;/404&quot;, {});
// 상품 목록을 순회하여 각 상품 페이지를 url 기준으로 HTML 생성
for (let i = 0; i &lt; products.length; i++) {
  await generateStaticSite(`/product/${products[i].productId}/`, {});
}

vite.close();</code></pre>
<ul>
<li>동적 라우트 SSG (상품 상세 페이지들)</li>
<li>빌드 타임 페이지 생성</li>
<li>파일 시스템 기반 배포
  : render로 HTML을 생성하고, url 별 폴더에 index.html로 저장하며 홈 페이지와 404 페이지는 빌드 시점에 미리 생성</li>
</ul>
<br />

<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>으음</p>
<h3 id="problem">Problem</h3>
<p>여기에 적어도 되는건진 모르겠지만.. </p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/5b9df2bb-835b-4d66-ba8b-3da39506ea40/image.png" alt=""></p>
<p>윈도우에서는 한번에 여러 서버를 띄우는게 정상 작동하지 않아 라이브러리를 2개 추가한 뒤 스크립트 명령어를 수정해줘야 했다.. 이를 알기 전엔 테스트가 돌아가지 않는게 내가 코드를 잘못 건드려서 그런건지 이유를 알 수가 없어서 너무 스트레스를 받았다. </p>
<p>항해 매니저님께서 직접 원격 제어로 도와주신 덕분에 테스트는 돌아가게 되었으나.. 이미 의욕을 잃은 나는 기본 과제만 마무리하고 심화 과제는 통과하지 못했다.. ^^;</p>
<br />

<p><img src="https://velog.velcdn.com/images/areumh__9/post/a467171b-1aad-4979-82f2-0f091dbdffe3/image.jpeg" alt=""></p>
<p>그리고 다음날 맥북 삼</p>
<h3 id="try">Try</h3>
<p>심화 과제 꼭 시도해보기..!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 3-2. 프론트엔드 테스트 코드: 회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-3-2.-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-3-2.-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Wed, 10 Sep 2025 18:45:47 GMT</pubDate>
            <description><![CDATA[<h2 id="3-2-프론트엔드-테스트-코드">3-2. 프론트엔드 테스트 코드</h2>
<p>저번 회고에서는 테스트 코드 개념 위주로 정리를 했다면, 이번 회고에서는 내가 과제 요구사항을 어떻게 구현했고 그 요구사항에 대한 테스트 코드를 어떻게 작성했는지에 대해 적어보려고 한다!!</p>
<h2 id="wil">WIL</h2>
<h3 id="✅-테스트-코드">✅ 테스트 코드</h3>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/5e25b7fb-6da1-4c04-a841-f62cf7070b2e/image.png" alt=""></p>
<blockquote>
<ol>
<li><strong>(필수) 반복 유형 선택</strong><ul>
<li>일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.</li>
<li>반복 유형은 다음과 같다: 매일, 매주, 매월, 매년<ul>
<li>31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.</li>
<li>윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!</li>
</ul>
</li>
</ul>
</li>
<li><strong>(필수) 반복 일정 표시</strong><ul>
<li>캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다.</li>
</ul>
</li>
<li><strong>(필수) 반복 종료</strong><ul>
<li>반복 종료 조건을 지정할 수 있다.</li>
<li>옵션: 특정 날짜까지<ul>
<li>예제 특성상, 2025-10-30까지 최대 일자를 만들어 주세요.</li>
</ul>
</li>
</ul>
</li>
<li><strong>(필수) 반복 일정 단일 수정</strong><ul>
<li>반복일정을 수정하면 단일 일정으로 변경됩니다.</li>
<li>반복일정 아이콘도 사라집니다.</li>
</ul>
</li>
<li><strong>(필수) 반복 일정 단일 삭제</strong><ul>
<li>반복일정을 삭제하면 해당 일정만 삭제합니다.</li>
</ul>
</li>
</ol>
</blockquote>
<p>필수 요구사항은 위와 같다. 그리고 <strong>TDD (테스트 주도 개발)</strong> 방식으로 구현하는 것이 포인트였다.
테스트를 먼저 작성한 후 기능을 구현해야 했기에... 테스트 쪽으로는 경험이 적다보니 테스트 자체에 오류가 있어 결국 테스트 코드를 다시 건드리게 되는 경우가 다수였다. 🥹</p>
<br />

<pre><code class="language-javascript">// src/utils/repeatUtils.ts

// 반복 일정을 단일 일정으로 변환
export const toSingleEvent = (event: Event | EventForm): Event | EventForm =&gt; ({
  ...event,
  repeat: { type: &#39;none&#39;, interval: 0 },
});

// 반복 시작 날짜가 반복 종료 날짜보다 앞인지 확인
export const checkEndDateValid = (start: Date, end: Date): boolean =&gt; {
  return start &lt;= end;
};

// ...

export const getRepeatEventList = (event: EventForm): EventForm[] =&gt; {
  if (event.repeat.type === &#39;none&#39;) return [event];

  switch (event.repeat.type) {
    case &#39;daily&#39;:
      return getDailyRepeatEvents(event);
    case &#39;weekly&#39;:
      return getWeeklyRepeatEvents(event);
    case &#39;monthly&#39;:
      return getMonthlyRepeatEvents(event);
    case &#39;yearly&#39;:
      return getYearlyRepeatEvents(event);
    default:
      return [event];
  }
};</code></pre>
<p>우선 요구사항 중 반복 생성된 일정을 수정할 시 단일 일정으로 수정되어야 하기 때문에 인자로 받은 일정을 단일 일정으로 수정하는 유틸 함수를 작성해주었다. 그리고 반복 유형에 따라 매일, 매주, 매월, 매년 반복 일정을 생성하는 함수를 각각 작성한 후, 각 케이스에 맞게 호출되도록 하여 가독성을 높였다! 👍</p>
<p>추가로 반복 종료일을 지정하지 않은 상태에서 반복 일정을 (과제 특성 상) 2025-10-30 이후부터 생성할 경우, 해당 일정을 하나만 생성되도록 하기 위해 반복 시작 날짜가 반복 종료 날짜보다 앞인지 확인하는 검증 함수를 추가했다.</p>
<br />

<pre><code class="language-javascript">// 반복 설정 컴포넌트
{isRepeating &amp;&amp; (
  &lt;Stack spacing={2}&gt;
    &lt;FormControl fullWidth&gt;
      &lt;FormLabel&gt;반복 유형&lt;/FormLabel&gt;
      &lt;Select
        size=&quot;small&quot;
        aria-label=&quot;반복 유형 선택&quot;
        value={repeatType}
        onChange={(e) =&gt; setRepeatType(e.target.value as RepeatType)}
      &gt;
        &lt;MenuItem value=&quot;daily&quot; aria-label=&quot;daily-option&quot;&gt;
          매일
        &lt;/MenuItem&gt;
        &lt;MenuItem value=&quot;weekly&quot; aria-label=&quot;weekly-option&quot;&gt;
          매주
        &lt;/MenuItem&gt;
        &lt;MenuItem value=&quot;monthly&quot; aria-label=&quot;monthly-option&quot;&gt;
          매월
        &lt;/MenuItem&gt;
        &lt;MenuItem value=&quot;yearly&quot; aria-label=&quot;yearly-option&quot;&gt;
          매년
        &lt;/MenuItem&gt;
      &lt;/Select&gt;
    &lt;/FormControl&gt;

    &lt;Stack direction=&quot;row&quot; spacing={2}&gt;
      &lt;FormControl fullWidth&gt;
        &lt;FormLabel htmlFor=&quot;repeat-interval&quot;&gt;반복 간격&lt;/FormLabel&gt;
        &lt;TextField
          id=&quot;repeat-interval&quot;
          size=&quot;small&quot;
          type=&quot;number&quot;
          aria-label=&quot;반복 간격 선택&quot;
          value={repeatInterval}
          onChange={(e) =&gt; setRepeatInterval(Number(e.target.value))}
          slotProps={{ htmlInput: { min: 1 } }}
        /&gt;
      &lt;/FormControl&gt;

      &lt;FormControl fullWidth&gt;
        &lt;FormLabel htmlFor=&quot;repeat-end&quot;&gt;반복 종료일&lt;/FormLabel&gt;
        &lt;TextField
          id=&quot;repeat-end&quot;
          size=&quot;small&quot;
          type=&quot;date&quot;
          aria-label=&quot;반복 종료일 선택&quot;
          value={repeatEndDate}
          onChange={(e) =&gt; setRepeatEndDate(e.target.value)}
        /&gt;
      &lt;/FormControl&gt;
    &lt;/Stack&gt;
  &lt;/Stack&gt;
)}


// 반복 일정 생성
it(&#39;생성한 반복 일정이 반복 설정에 맞게 표시된다&#39;, async () =&gt; {
  setupMockHandlerRepeatCreation();
  const { user } = setup(&lt;App /&gt;);

  await user.click(screen.getAllByText(&#39;일정 추가&#39;)[0]);

  await user.type(screen.getByLabelText(&#39;제목&#39;), &#39;정기 회의&#39;);
  await user.type(screen.getByLabelText(&#39;날짜&#39;), &#39;2025-10-01&#39;);
  await user.type(screen.getByLabelText(&#39;시작 시간&#39;), &#39;11:00&#39;);
  await user.type(screen.getByLabelText(&#39;종료 시간&#39;), &#39;12:00&#39;);
  await user.type(screen.getByLabelText(&#39;설명&#39;), &#39;정기 팀 미팅&#39;);
  await user.type(screen.getByLabelText(&#39;위치&#39;), &#39;회의실 A&#39;);
  await user.click(screen.getByLabelText(&#39;카테고리&#39;));
  await user.click(within(screen.getByLabelText(&#39;카테고리&#39;)).getByRole(&#39;combobox&#39;));
  await user.click(screen.getByRole(&#39;option&#39;, { name: `업무-option` }));

  const repeatCheckbox = screen.getByLabelText(&#39;반복 일정&#39;);
  await user.click(repeatCheckbox);

  // 반복 유형 선택 - 매주
  const repeatSelect = within(screen.getByLabelText(&#39;반복 유형 선택&#39;)).getByRole(&#39;combobox&#39;);
  await user.click(repeatSelect);
  await user.click(screen.getByRole(&#39;option&#39;, { name: &#39;weekly-option&#39; }));

  // 반복 간격 선택 - 2주 간격
  await user.clear(screen.getByLabelText(&#39;반복 간격&#39;));
  await user.type(screen.getByLabelText(&#39;반복 간격&#39;), &#39;2&#39;);

  // 반복 종료일 선택
  await user.type(screen.getByLabelText(&#39;반복 종료일&#39;), &#39;2025-10-30&#39;);

  // 일정 추가
  await user.click(screen.getByTestId(&#39;event-submit-button&#39;));

  const eventList = within(screen.getByTestId(&#39;event-list&#39;));
  expect(eventList.getAllByText(&#39;정기 회의&#39;)).toHaveLength(3); 
  // 2025-10-01, 2025-10-15, 2025-10-29
});</code></pre>
<p>테스트 라이브러리는 <code>aria-label=&quot;반복 유형 선택&quot;</code>의 label 값을 통해 select를 찾을 수 있게 된다. 그리고 select는 시맨틱 상 <code>combobox</code> 역할을 가지므로, <strong>getByRole(&#39;combobox&#39;)</strong>를 사용하여 더욱 정확히 select를 가져올 수 있다.</p>
<p>내부 항목들은 <code>role=&quot;option&quot;</code>이면서 <code>weekly-option&#39;</code>와 같은 이름으로 노출되므로 <strong>getByRole(&#39;option&#39;, { name: &#39;weekly-option&#39; })</strong>로 가져와 선택할 수 있게 된다.</p>
<br />

<p>반복 간격과 종료일의 경우 FormLabel 내의 텍스트로 컴포넌트를 찾은 뒤, 해당 컴포넌트의 <code>htmlFor</code> 속성과 <code>id</code> 값의 매칭을 통해 각 label에 맞는 TextField를 가져온다. 추가로 반복 간격의 경우엔 input 필드에 이미 들어있는 값을 지우고 새로운 값을 확실히 입력하기 위해 <strong>userEvent.clear</strong>을 먼저 실행해주었다 😊</p>
<br />

<pre><code class="language-javascript">// 단일 일정 컴포넌트
&lt;Stack direction=&quot;row&quot; spacing={1} alignItems=&quot;center&quot;&gt;
  {isNotified &amp;&amp; &lt;Notifications fontSize=&quot;small&quot; /&gt;}

  &lt;Typography
    variant=&quot;caption&quot;
    noWrap
    sx={{ fontSize: &#39;0.75rem&#39;, lineHeight: 1.2 }}
  &gt;
    {event.repeat.type !== &#39;none&#39; &amp;&amp; &lt;span&gt;*&lt;/span&gt;}
    {event.title}
  &lt;/Typography&gt;
&lt;/Stack&gt;


// 반복 일정은 제목 앞에 * 표시
it(&#39;캘린더 뷰에서 반복 일정은 아이콘으로 표시된다&#39;, async () =&gt; {
  setupMockHandlerRepeatCreation();
  const { user } = setup(&lt;App /&gt;);

  await user.click(screen.getAllByText(&#39;일정 추가&#39;)[0]);

  await user.type(screen.getByLabelText(&#39;제목&#39;), &#39;정기 회의&#39;);
  await user.type(screen.getByLabelText(&#39;날짜&#39;), &#39;2025-10-01&#39;);
  await user.type(screen.getByLabelText(&#39;시작 시간&#39;), &#39;11:00&#39;);
  await user.type(screen.getByLabelText(&#39;종료 시간&#39;), &#39;12:00&#39;);
  await user.type(screen.getByLabelText(&#39;설명&#39;), &#39;정기 팀 미팅&#39;);
  await user.type(screen.getByLabelText(&#39;위치&#39;), &#39;회의실 A&#39;);
  await user.click(screen.getByLabelText(&#39;카테고리&#39;));
  await user.click(within(screen.getByLabelText(&#39;카테고리&#39;)).getByRole(&#39;combobox&#39;));
  await user.click(screen.getByRole(&#39;option&#39;, { name: `업무-option` }));

  const repeatCheckbox = screen.getByLabelText(&#39;반복 일정&#39;);
  await user.click(repeatCheckbox);

  // 반복 유형 선택 - 매일
  await user.click(within(screen.getByLabelText(&#39;반복 유형 선택&#39;)).getByRole(&#39;combobox&#39;));
  await user.click(screen.getByRole(&#39;option&#39;, { name: &#39;daily-option&#39; }));

  // 반복 종료일 선택
  await user.type(screen.getByLabelText(&#39;반복 종료일&#39;), &#39;2025-10-03&#39;);

  // 일정 추가
  await user.click(screen.getByTestId(&#39;event-submit-button&#39;));

  const monthView = within(screen.getByTestId(&#39;month-view&#39;));
  expect(monthView.getAllByText(&#39;*&#39;)).toHaveLength(3);
});</code></pre>
<p>필수 요구사항에 따르면 반복 생성된 일정은 아이콘을 넣어 구분 표시를 해야 했기 때문에, 난 간단하게 * 문자열 하나만 추가했다...ㅎㅎ 이때 event.title과 * 을 문자열 하나로 합치지 않고, span 태그로 감싸줬다. </p>
<p><strong>getAllByText</strong>는 DOM 내 텍스트 노드 단위로 요소를 찾기 때문에 span과 같은 태그로 감싸줬을 경우엔 테스트가 동작하지만, title 값과 합쳤을 경우엔 그 자체가 하나의 문자열 노드가 되기 때문에 테스트에서 인식되지 않는다!! </p>
<br />

<h3 id="✅-테스트-전략">✅ 테스트 전략</h3>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/231bdcee-5b31-4272-b035-5219c39a8717/image.png" alt=""></p>
<p><strong>테스트 피라미드 전략</strong></p>
<ul>
<li>단위 테스트가 많고 빠름 → 로직 검증 빠르게 가능</li>
<li>통합 테스트는 중간 수준 → 모듈 간 상호작용 검증</li>
<li>E2E는 적고 느림 → 전체 흐름 검증, 비용 높음</li>
<li>목표 : 빠르고 안정적인 테스트를 많이 확보하면서, 전체 기능 검증도 최소한으로 유지</li>
</ul>
<br />

<p><img src="https://velog.velcdn.com/images/areumh__9/post/a1f25a8f-cf47-46d2-884c-e0b718f56a20/image.png" alt=""></p>
<p><strong>테스트 트로피 전략</strong></p>
<ul>
<li>통합 테스트가 중심 → UI와 컴포넌트 상호작용을 많이 검증</li>
<li>단위 테스트는 로직만 검증, 피라미드보다 비율 줄임</li>
<li>UI 중심 앱에 적합 → React, Vue 같은 SPA에서 많이 사용</li>
</ul>
<br />

<p>+) 팀원들과의 회의 후 테스트 트로피 전략을 선택한 후 통합 테스트와 훅 테스트를 추가했으나, e2e나 시각적 회귀 테스트를 작성하지 않아서 심화 과제는 실패했다.. 🥹 앞으론 과제 통과 기준을 확실히 알자...</p>
<br/>

<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/1c8bf53b-2bf9-4ec9-97f9-610d89037bee/image.png" alt=""> 잘하고있다.. !</p>
<h3 id="problem">Problem</h3>
<p>TDD 방식의 구현은 처음이라 시작할 때 좀 막막했던 것 같고, 위에 적은 말이지만 테스트 코드 자체에 오류가 있어 시간을 많이 쓴 것 같다. 그래도 이번 과제를 통해 테스트에서 요소를 정확히 찾아 이벤트를 트리거하는 과정을 거치면서 DOM 조작과 이벤트 시뮬레이션에 대한 이해가 향상된 것 같다! </p>
<h3 id="try">Try</h3>
<p>사실 아직 어떤 테스트가 불필요한 테스트인지 명확히 구분하는 게 어려운 것 같다. 다다익선이 아니라니... 
공부 필요...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 3-1. 프론트엔드 테스트 코드: 회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-3-1.-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C</link>
            <guid>https://velog.io/@areumh__9/Chapter-3-1.-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C</guid>
            <pubDate>Sat, 06 Sep 2025 17:34:45 GMT</pubDate>
            <description><![CDATA[<h2 id="3-1-프론트엔드-테스트-코드">3-1. 프론트엔드 테스트 코드</h2>
<p>너무나 겁먹었던 테스트 코드 챕터... 간단히 기록해보자</p>
<br />

<h2 id="wil">WIL</h2>
<h3 id="✅-테스트-코드의-흐름">✅ 테스트 코드의 흐름</h3>
<h3 id="1-렌더링하기">1. 렌더링하기</h3>
<ul>
<li><strong>render</strong> - 렌더링 수행</li>
<li><strong>screen</strong> - 렌더링된 화면처럼 인터페이스가 구현된 객체에 접근<pre><code class="language-javascript">import { render, screen } from &#39;@testing-library/react&#39;;
</code></pre>
</li>
</ul>
<p>test(&#39;should show login form&#39;, () =&gt; {
  render(<Login />)</p>
<p>  // 화면에서 Username 레이블 텍스트를 가진 요소 가져옴
  const input = screen.getByLabelText(&#39;Username&#39;);
})</p>
<pre><code>
### 2. 사용자 인터렉션
- **fireEvent** – 이벤트를 직접 디스패치, 일부 특수 이벤트나 userEvent 미지원 시 사용
- **userEvent** – 실제 사용자가 상호작용하는 것처럼 이벤트 시뮬레이션
- **userEvent.setup()** – 테스트마다 독립된 user 인스턴스를 생성, 연속적인 사용자 동작을 정확히 시뮬레이션

&lt;br /&gt;

- **user.click(element)** – 요소 클릭
- **user.dblClick(element)** – 더블 클릭
- **user.hover(element)** – 요소 위로 마우스 이동
- **user.unhover(element)** – 요소에서 마우스 이동
- **user.type(element, &quot;text&quot;)** – 입력 필드에 텍스트 입력
- **user.clear(element)** – 입력 필드 초기화
- **user.keyboard(&quot;text&quot;)** – 키보드 입력 시뮬레이션
- **user.tab()** – 탭키로 포커스 이동
- **user.selectOptions(selectElement, &quot;value&quot;)** – 셀렉트 박스 선택

```javascript
it(&#39;기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다&#39;, async () =&gt; {
  setupMockHandlerUpdating();
  const { user } = setup(&lt;App /&gt;);

  const editButtons = await screen.findAllByRole(&#39;button&#39;, { name: &#39;Edit event&#39; });
  const titleInput = screen.getByLabelText(&#39;제목&#39;);

  await user.click(editButtons[0]); // 수정 버튼 클릭

  expect(screen.getByRole(&#39;button&#39;, { name: &#39;일정 수정&#39; })).toBeInTheDocument();
  expect(screen.getByDisplayValue(&#39;기존 회의&#39;)).toBeInTheDocument();

  await user.clear(titleInput); // 입력 필드 값 초기화
  await user.type(titleInput, &#39;기존 회의3&#39;); // 입력 필드에 &#39;기존 회의3&#39; 텍스트 입력
  await user.click(screen.getByRole(&#39;button&#39;, { name: &#39;일정 수정&#39; })); // 일정 수정 버튼 클릭

  const eventList = within(screen.getByTestId(&#39;event-list&#39;));
  expect(await eventList.findByText(&#39;기존 회의3&#39;)).toBeInTheDocument();
});</code></pre><h3 id="3-대상-가져오기">3. 대상 가져오기</h3>
<ul>
<li>단일 요소 가져오기</li>
</ul>
<table>
<thead>
<tr>
<th>쿼리 유형</th>
<th>0개 일치</th>
<th>1개 일치</th>
<th>1개 이상 일치</th>
<th>재시도(비동기)</th>
</tr>
</thead>
<tbody><tr>
<td><code>getBy...</code></td>
<td>오류 발생</td>
<td>요소 반환</td>
<td>오류 발생</td>
<td>x</td>
</tr>
<tr>
<td><code>queryBy...</code></td>
<td><code>null</code> 반환</td>
<td>요소 반환</td>
<td>오류 발생</td>
<td>x</td>
</tr>
<tr>
<td><code>findBy...</code></td>
<td>오류 발생</td>
<td>요소 반환</td>
<td>오류 발생</td>
<td>o</td>
</tr>
</tbody></table>
<ul>
<li>다중 요소 가져오기</li>
</ul>
<table>
<thead>
<tr>
<th>쿼리 유형</th>
<th>0개 일치</th>
<th>1개 일치</th>
<th>1개 이상 일치</th>
<th>재시도(비동기)</th>
</tr>
</thead>
<tbody><tr>
<td><code>getAllBy...</code></td>
<td>오류 발생</td>
<td>배열 반환</td>
<td>배열 반환</td>
<td>x</td>
</tr>
<tr>
<td><code>queryAllBy...</code></td>
<td><code>[]</code> 반환</td>
<td>배열 반환</td>
<td>배열 반환</td>
<td>x</td>
</tr>
<tr>
<td><code>findAllBy...</code></td>
<td>오류 발생</td>
<td>배열 반환</td>
<td>배열 반환</td>
<td>o</td>
</tr>
</tbody></table>
<br />

<ul>
<li><strong>getByRole</strong> - 접근성 트리에서 role을 기반으로 가져옴<pre><code class="language-javascript">test(&#39;&#39;, () =&gt; {
  screen.getByRole(&#39;button&#39;);
});</code></pre>
</li>
<li><strong>getByTestId</strong> test용 id 속성을 넣어 가져올 수 있음<pre><code class="language-javascript">test(&#39;&#39;, () =&gt; {
  // data-testid=&quot;test-button&quot; 속성을 가진 요소
  screen.getByTestId(&#39;test-button&#39;);
});</code></pre>
</li>
<li><strong>getByText</strong> - 텍스트를 기반으로 가져옴<pre><code class="language-javascript">// &lt;div&gt;Hello World&lt;/div&gt;
</code></pre>
</li>
</ul>
<p>screen.getByText(&#39;Hello World&#39;) // 텍스트가 매치되는 요소
screen.getByText(&#39;llo Worl&#39;, {exact: false}) // 일부만 매치
screen.getByText(/World/) // 정규식 활용
screen.getByText((content, element) =&gt; content.startsWith(&#39;Hello&#39;)) // 함수로 가져옴</p>
<pre><code>
### ✅ 자주 쓰이는 API
- **beforeEach** – 각 테스트 실행 전 공통 준비
- **afterEach** – 각 테스트 실행 후 정리
- **beforeAll** – 모든 테스트 시작 전 한 번 실행
- **afterAll** – 모든 테스트 종료 후 한 번 실행
&lt;br /&gt;
- **vi.fn()** – 모의 함수 생성
- **vi.mock()** – 모듈의 모의화
- **vi.spyOn()** – 객체의 메서드를 감시
- **vi.waitFor()** – 비동기 작업이 완료될 때까지 기다림

&lt;br /&gt;

+) 🤜 과제를 통해 알게된 점.. 🥸
```javascript
// src/__mocks__/handlersUtils.ts
export const setupMockHandlerDeletion = () =&gt; {
  const mockEvents: Event[] = [
    {
      id: &#39;1&#39;,
      title: &#39;삭제할 이벤트&#39;,
      date: &#39;2025-10-15&#39;,
      startTime: &#39;09:00&#39;,
      endTime: &#39;10:00&#39;,
      description: &#39;삭제할 이벤트입니다&#39;,
      location: &#39;어딘가&#39;,
      category: &#39;기타&#39;,
      repeat: { type: &#39;none&#39;, interval: 0 },
      notificationTime: 10,
    },
  ];
  server.use(
    http.get(&#39;/api/events&#39;, () =&gt; {
      return HttpResponse.json({ events: mockEvents });
    }),
    http.delete(&#39;/api/events/:id&#39;, ({ params }) =&gt; {
      const { id } = params;
      const index = mockEvents.findIndex((event) =&gt; event.id === id);
      mockEvents.splice(index, 1);
      return new HttpResponse(null, { status: 204 });
    })
  );
};

// src/__tests__medium.integration.spec.tsx
it(&quot;네트워크 오류 시 &#39;일정 삭제 실패&#39;라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다&quot;, async () =&gt; {
  setupMockHandlerDeletion();

  // 삭제 처리가 실패하도록 덮어씀
  server.use(
    http.delete(&#39;/api/events/:id&#39;, () =&gt; {
      return HttpResponse.error();
    })
  );

  const { result } = renderHook(() =&gt; useEventOperations(true));

  await act(async () =&gt; {
    result.current.deleteEvent(&#39;1&#39;);
  });

  expect(enqueueSnackbarFn).toHaveBeenCalledWith(&#39;일정 삭제 실패&#39;, { variant: &#39;error&#39; });
});</code></pre><p>우선 각 테스트에서 사용되는 목 데이터와 서버 처리 로직을 하나의 함수로 묶어서 모킹한 부분이 되게 인상적이었다... 반복되는 목 데이터 재사용도 가능하고 엄청 깔끔해보였다. 하나의 함수를 여러 개의 테스트에 적용할 수 있으니까!!</p>
<p>그리고 처음 네트워크 오류 처리를 다루는 테스트를 접하고 도대체 어떻게 작성해야 하는건지 상상도 되지 않았다... 이러한 특수한 상황도 테스트가 가능하다는 사실을 처음 알게 되었다. 실제 환경에서 발생할 수 있는 여러 케이스를 시뮬레이션하고, ui 반응이나 오류 처리 로직을 검증할 수 있다는게 놀라웠다. 👍</p>
<h2 id="ktp">KTP</h2>
<h3 id="keep">Keep</h3>
<p>꾸준히... 과제를 하자</p>
<h3 id="problem">Problem</h3>
<p>역시 경험이 많이 없다보니 너무 생소하게 느껴졌다... 앞으로 테스트 코드와 더 친해져보자..</p>
<h3 id="try">Try</h3>
<p>개인 플젝에 테스트 코드 도입해보기</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 2-3. 관심사 분리와 폴더구조 :회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-2-3.-%EA%B4%80%EC%8B%AC%EC%82%AC-%EB%B6%84%EB%A6%AC%EC%99%80-%ED%8F%B4%EB%8D%94%EA%B5%AC%EC%A1%B0-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-2-3.-%EA%B4%80%EC%8B%AC%EC%82%AC-%EB%B6%84%EB%A6%AC%EC%99%80-%ED%8F%B4%EB%8D%94%EA%B5%AC%EC%A1%B0-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Fri, 22 Aug 2025 19:37:57 GMT</pubDate>
            <description><![CDATA[<h2 id="2-3-관심사-분리와-폴더구조">2-3. 관심사 분리와 폴더구조</h2>
<p>얼마 전에 FSD 패턴이라는 걸 알게 되었고.. 꼭 공부해보고 싶다는 생각이 들었는데 딱 마침 이번 과제가 FSD 패턴. 어째 이런 타이밍이!!! <del>근데 너무 어려워</del></p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/b92b3806-9550-4d0b-b444-809339ccfabb/image.png" alt=""></p>
<br />

<h2 id="wil">WIL</h2>
<h3 id="✅-fsd-패턴">✅ FSD 패턴</h3>
<p><a href="https://feature-sliced.design/kr/docs/get-started/overview">Feature-Sliced Design</a> - 프론트엔드 애플리케이션 구조를 위한 아키텍처 방법론. 코드를 어떻게 분리하고 구성할지를 명확히 정의하여, 변화하는 비즈니스 요구 속에서도 프로젝트를 이해하기 쉽고 안정적으로 유지할 수 있도록 함.</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/a11dcc68-cc04-4750-a3a5-ba488ca5b525/image.png" alt=""></p>
<ol>
<li><strong>App</strong> - Routing, Entrypoint, Global Styles, Provider 등 앱을 실행하는 모든 요소</li>
<li>Processes(더 이상 사용되지 않음) - 페이지 간 복합 시나리오</li>
<li><strong>Pages</strong> - 전체 page 또는 중첩 Routing의 핵심 영역</li>
<li><strong>Widgets</strong> - 독립적으로 동작하는 대형 UI·기능 블록</li>
<li><strong>Features</strong> - 제품 전반에서 재사용되는 비즈니스 기능</li>
<li><strong>Entities</strong> - user, product 같은 핵심 도메인 Entity</li>
<li><strong>Shared</strong> - 프로젝트 전반에서 재사용되는 일반 유틸리티</li>
</ol>
<br />

<ul>
<li><p><strong>Slice</strong>는 Layer 내부를 비즈니스 도메인별로 나누고, 같은 Layer 내 다른 Slice를 참조할 수 없다 = 응집도↑ 결합도↓</p>
</li>
<li><p>Slice와 App, Shared Layer는 <strong>Segment</strong>로 세분화되어, 기술적 목적에 따라 코드를 그룹화한다</p>
<ul>
<li><strong>ui</strong> - UI components, date formatter, styles 등 UI 표현과 직접 관련된 코드</li>
<li><strong>api</strong> - request functions, data types, mappers 등 백엔드 통신 및 데이터</li>
<li><strong>model</strong> - schema, interfaces, store, business logic 등 애플리케이션 도메인 모델</li>
<li><strong>lib</strong> - 해당 Slice에서 여러 모듈이 함께 사용하는 공통 library code</li>
<li><strong>config</strong> - configuration files, feature flags 등 환경·기능 설정</li>
</ul>
</li>
</ul>
<hr>
<pre><code>src
├── app 
├── entities
│   ├── comment
│   │   ├── api
│   │   ├── config
│   │   ├── model
│   │   └── ui
│   ├── post
│   │   └── ..
│   └── user
│       └── ..
├── features
│   ├── comment
│   │   ├── add-comment
│   │   │   ├── model
│   │   │   └── ui
│   │   ├── delete-comment
│   │   │   └── ..
│   │   ├── like-comment
│   │   │   └── ..
│   │   └── update-comment
│   │   │   └── ..
│   └── post
│       └── ..
├── pages
├── shared
│   ├── hook
│   ├── lib
│   └── ui 
├── widgets
│   ├── dialog
│   ├── post-detail-content
│   ├── post-filter
│   ├── post-pagination
│   └── post-table
└── main.tsx</code></pre><p>취향껏.. 마음대로 리팩토링을 진행한 결과 위의 폴더 구조가 완성됐다. <code>main.tsx</code>를 <code>app</code> 폴더에 넣었다가 다시 밖으로 뺐는데 그대로 둘걸 하는 생각이..🥲 </p>
<p>가장 고민을 많이 한 부분은 <code>widgets</code>인 것 같다. <code>post-filter</code> 내에 검색어, 태그, 정렬 등 게시물 필터와 관련된 ui를 분리하지 않고 두었는데 이를 하나하나 컴포넌트로 따로 분리해야 하는게 나은지, 필터와 관련된 로직은 전부 쿼리 훅 하나로 해결이 되는데 굳이 분리가 필요한지, 아님 게시물의 필터가 바뀌는 것도 하나의 기능으로 보고 features에 두어야 할지 등등.. 아직도 답을 모르겠는 고민들을 엄청 했다. 결국 굳이 세분화하지 않고 하나로 두었지만..!! </p>
<br />

<p>이번 과제에서 fsd 못지않게 고민하고 애썼던 부분은 tanstack-query로 낙관적인듯 아닌듯 낙관적 업데이트를 구현하는 거였다.. </p>
<p>기존 코드는 더미 데이터와 목 api를 사용하고 탄스택 쿼리를 사용하지 않기에 프론트 내에서 따로 상태로 관리하여 페이지 기능이 처리되도록 되어 있었다. 나는 이를 탄스택 쿼리로 리팩토링해야했다...</p>
<pre><code class="language-javascript">// src/entities/commment/model/store.ts

export const commentModel = {
  /**
   * 댓글 추가
   */
  addComment: (commentData: IComments, newComment: IComment): IComments =&gt; {
    return {
      ...commentData,
      comments: [newComment, ...commentData.comments],
    };
  },
}</code></pre>
<p>우선 <code>entities</code>에 각 도메인마다 상태 업데이트에 사용될 순수 함수를 작성해주었다. 항해 과제를 하면서 매번 순수 함수를 작성하고 있는데 너무 깔끔하고 좋은 것 같다... </p>
<br />

<pre><code class="language-javascript">// src/features/commment/add-comment/model/useAddComment.ts

export const useAddComment = (postId: number, onSuccess?: () =&gt; void) =&gt; {
  const queryClient = useQueryClient();

  const initialComment: IAddComment = { body: &#39;&#39;, postId: postId, userId: 1 };
  const [newComment, setNewComment] = useState&lt;IAddComment&gt;(initialComment);

  const mutation = useMutation({
    mutationFn: (comment: IAddComment) =&gt; addCommentApi(comment),

    onSuccess: (createdComment) =&gt; {
      const newComment = commentModel.addResponseToComment(createdComment);

      queryClient.setQueryData&lt;IComments&gt;([&#39;comments&#39;, postId], (prev) =&gt; {
        if (!prev) {
          return {
            comments: [newComment],
            total: &#39;1&#39;,
            skip: 0,
            limit: 10,
          };
        }

        return commentModel.addComment(prev, newComment);
      });

      onSuccess?.();
      setNewComment(initialComment);
    },
    onError: (error) =&gt; {
      console.error(&#39;댓글 추가 오류:&#39;, error);
    },
  });

  const setBody = (body: string) =&gt;
    setNewComment((prev) =&gt; ({ ...prev, body }));

  const addComment = () =&gt; {
    mutation.mutate(newComment);
  };

  return { newComment, setBody, addComment };
};</code></pre>
<p>features 내의 model 세그먼트 폴더에 탄스택 쿼리 훅을 작성해주었고, onSuccess에 entities 내에 만들었던 도메인 상태 업데이트 순수 함수를 사용했다. </p>
<p>(기존 코드에서는 api 요청의 응답으로 온 값으로 상태를 업데이트하도록 되어 있어서 onSuccess 내에서 처리하도록 했는데 이제 생각해보니 onMutate에서 처리하는게 맞는 것 같다 😶)</p>
<br />

<pre><code class="language-javascript">// src/features/commment/add-comment/ui/AddCommentForm.ts

const AddCommentForm = ({ postId }: AddCommentFormProps) =&gt; {
  const { setCommentModal } = useDialogStore();
  const { newComment, setBody, addComment } = useAddComment(postId, () =&gt; {
    setCommentModal({ show: false, content: null });
  });

  return (
    &lt;DialogContent&gt;
      &lt;DialogHeader&gt;
        &lt;DialogTitle&gt;새 댓글 추가&lt;/DialogTitle&gt;
      &lt;/DialogHeader&gt;
      &lt;div className=&quot;space-y-4&quot;&gt;
        &lt;Textarea
          placeholder=&quot;댓글 내용&quot;
          value={newComment.body}
          onChange={(e) =&gt; setBody(e.target.value)}
        /&gt;
        &lt;Button onClick={addComment}&gt;댓글 추가&lt;/Button&gt;
      &lt;/div&gt;
    &lt;/DialogContent&gt;
  );
};</code></pre>
<p>최종 코드는 이런 느낌.. </p>
<p>+) 추가적으로 팀원들 덕분에 인터페이스 앞에 I를 붙이는게 좋지 않다는 걸 알게 되었다...  이제부터 안써준다.</p>
<br />

<h2 id="ktp">KTP</h2>
<h3 id="keep">Keep</h3>
<p>잘 모르겠는 부분은 팀원에게 (다른 팀이어도) 질문해보는 자세
다같이 의견 나누는게 너무 좋당</p>
<h3 id="problem">Problem</h3>
<p>충분히 잘하고 있어... (라고 믿기)</p>
<h3 id="try">Try</h3>
<p>개인 프로젝트에 FSD 구조 사용해보기..!!
사람마다 기준이 너무 달라서 팀 프로젝트에서는 FSD 패턴을 도입하고 싶은 생각이 든다 해도 제안하기엔 무리일 것 같다... 그치만 더 공부해보고 싶음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 :회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-2-2.-%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4%EA%B3%BC-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-2-2.-%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4%EA%B3%BC-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sat, 09 Aug 2025 17:01:23 GMT</pubDate>
            <description><![CDATA[<h2 id="2-2-디자인-패턴과-함수형-프로그래밍">2-2. 디자인 패턴과 함수형 프로그래밍</h2>
<br />


<h2 id="wil">WIL</h2>
<p>이번 과제는 저번주처럼 후회하고 싶지 않기 때문에... 제공된 리팩토링 힌트 폴더 구조를 그대로 따르기로 했다.</p>
<pre><code>refactoring(hint)
├── components - icon 및 ui
├── constants - 초기 데이터 상수
├── hooks - 상태 관리 (localStorage 연동)
├── models - 비즈니스 로직 (순수 함수)
├── utils - 공용 유틸리티
│   ├── validators.ts
│   ├── formatters.ts
│   └── hooks - 유틸 전용 훅
│       ├── useLocalStorage.ts
│       └── useDebounce.ts
├── App.tsx
└── main.tsx</code></pre><p>제일 먼저는 상품 컴포넌트, 알림 모달 컴포넌트, 쿠폰 컴포넌트, svg 아이콘 등 <strong>작은 단위의 컴포넌트 분리</strong>를 진행했다. 이게 좋은 선택이었는진 모르겠지만 딱 작은 컴포넌트만 분리하고 그 이상은 진행하지 않았기에.. 이후에 걸림돌이 되지 않았던 것 같다.</p>
<p>컴포넌트 분리 후 <strong>models 폴더 내에 비즈니스 로직 (순수 함수) 구현</strong>을 했는데, 이때 models과 hooks의 차이가 뭔지 잘 이해되지 않았다. 결국 하는 역할은 똑같은거 아닌가? 하는 생각이 들었고.. 이 의문은 <strong>useLocalStorage 훅을 구현</strong>하고 적용한 이후에 해결되었다. model은 내가 연산 기호를 정의한 거라면 hook은 그 연산 기호를 사용하여 직접 상태 값을 업데이트 해주는 느낌..? 이 구현 순서를 의도하여 힌트를 주신 건가? 하는 생각도 들었다.</p>
<pre><code class="language-javascript">// src/basic/models/cart.ts

export const cartModel = {
  /**
   * 장바구니에 상품 추가
   */
  addToCart: (cart: ICartItem[], product: IProductWithUI): ICartItem[] =&gt; {
    // 이미 장바구니에 존재하는 상품 처리
    const existingItem = cart.find((item) =&gt; item.product.id === product.id);

    if (existingItem) {
      const newQuantity = existingItem.quantity + 1;

      // 재고 초과 시 기존 cart 반환
      if (newQuantity &gt; product.stock) {
        return cart;
      }

      // 수량만 업데이트
      return cart.map((item) =&gt;
        item.product.id === product.id
          ? { ...item, quantity: newQuantity }
          : item
      );
    }

    // 장바구니에 없는 상품이면 새 아이템 추가
    return [...cart, { product, quantity: 1 }];
  },
};

// src/basic/hooks/useCart.ts

export const useCart = () =&gt; {
  // 로컬스토리지 연동된 cart
  const [cart, setCart] = useLocalStorage&lt;ICartItem[]&gt;(&quot;cart&quot;, initialCarts);

  /**
   * 장바구니에 상품 추가
   */
  const addToCart = (product: IProductWithUI) =&gt; {
    setCart((prev) =&gt; cartModel.addToCart(prev, product));
  };

  return { addToCart };
};</code></pre>
<p>장바구니에 상품을 추가하는 함수를 기준으로 설명하자면, models의 순수 함수는 장바구니 배열과 추가할 상품을 둘 다 인자로 받아 이미 장바구니에 존재하는 상품인지를 확인한 후 각 상황에 맞는 값을 반환한다. 그리고 hooks에서는 장바구니와 연동된 setCart 함수를 통해 cartModel의 순수 함수를 사용하여 cart 상태를 업데이트해주었다.</p>
<p>hooks 함수를 구현하면서 가장 고민했던 부분은 <code>addNotification 함수 처리</code>인 것 같다. 상태 관리만 담당하는 함수가 ui 관련 처리까지 담당해도 되는가에 대해 오래 고민했는데, 단일 책임 원칙에 따라 역할을 분리하는 것이 맞다고 판단하여 해당 함수가 필요한 컴포넌트 내에서 hooks 함수와 addNotification 함수를 같이 받아와 처리하도록 구현했다.</p>
<br />


<pre><code class="language-javascript">// 🌟 개선 예시

// models/cart.ts (pure)
export const cartModel = {
  isCouponApplicable: (cartItems: CartItem[], coupon: Coupon) =&gt; {
    const total = calculateCartTotalFromItems(cartItems); // pure fn
    if (coupon.discountType === &#39;percentage&#39; &amp;&amp; total.totalAfterDiscount &lt; ORDER.MIN_FOR_COUPON) {
      return { ok: false, reason: &#39;MIN_PRICE&#39; };
    }
    return { ok: true };
  },

  applyCoupon: (cartItems: CartItem[], coupon: Coupon) =&gt; {
    if (!cartModel.isCouponApplicable(cartItems, coupon).ok) {
      return { cartItems, selectedCoupon: null, error: &#39;NOT_APPLICABLE&#39; };
    }
    // 실제 할인 계산은 calculateCartTotalFromItems가 적용하도록 selectedCoupon만 반환
    return { cartItems, selectedCoupon: coupon, error: null };
  }
};


// hooks/useCart.ts
export const useCart = () =&gt; {
  const [cart, setCart] = useLocalStorage(&#39;cart&#39;, []);
  const [selectedCoupon, setSelectedCoupon] = useState&lt;ICoupon|null&gt;(null);

  const applyCoupon = (coupon: ICoupon, options?: { onSuccess?: ()=&gt;void; onError?: (msg:string)=&gt;void }) =&gt; {
    const result = cartModel.applyCoupon(cart, coupon);

    if (result.error) {
      options?.onError?.(MESSAGES.COUPON.MIN_PRICE);
      return false;
    }

    setSelectedCoupon(coupon);
    options?.onSuccess?.();

    return true;
  };

  return { cart, selectedCoupon, applyCoupon, setSelectedCoupon, ... };
};


// CouponSelector.tsx (TO-BE)
const { applyCoupon } = useCart();
const { addNotification } = useNotification();

const onSelect = (coupon) =&gt; {
  applyCoupon(coupon, {
    onSuccess: () =&gt; addNotification(MESSAGES.COUPON.APPLIED, &#39;success&#39;),
    onError: (msg) =&gt; addNotification(msg ?? MESSAGES.COUPON.MIN_PRICE, &#39;error&#39;)
  });
};</code></pre>
<p>그런데 코드 리뷰로 받은 개선 예시를 보고... 이런 방법이 있구나 싶었다.</p>
<p>나는 applyCoupon 함수 내에 setSelectedCoupont, addNotification 등의 함수 처리를 어떻게 해야할지 모르겠어서 useCart 훅 내부가 아닌 해당 함수를 필요로 하는 컴포넌트 내에 작성했다.</p>
<p>그런데 위의 예시를 보면 <strong>model</strong>에서 장바구니 금액이 쿠폰 최소 조건을 충족하는지 판단하고 -&gt; <strong>hook</strong>에서 성공, 에러에 따라 콜백 함수를 호출하고 -&gt; 컴포넌트에선 <strong>콜백 함수</strong>로 addNotification 함수를 보내 알림 ui 처리를 한다. </p>
<p>비즈니스 로직, 상태, UI를 분리해 테스트 용이성과 유연성을 높인.. 너무나도 깔끔한 구조이다. 왜 이럴 생각을 못했을까 🥲 (<del>isCouponApplicable 함수까지는 고민해봤는데 그 이후에 진도가 나가지 않아 지웠버렸다.. ㅜㅜ</del>)</p>
<pre><code class="language-javascript">// src/advanced/components/product/ProductForm.tsx

interface ProductFormProps {
  // product
  setShowProductForm: React.Dispatch&lt;React.SetStateAction&lt;boolean&gt;&gt;;
  editingProduct: string | null;
  setEditingProduct: React.Dispatch&lt;React.SetStateAction&lt;string | null&gt;&gt;;
  productForm: IProductForm;
  setProductForm: React.Dispatch&lt;React.SetStateAction&lt;IProductForm&gt;&gt;;
}</code></pre>
<p>심화 과제는 기본 과제에 전역 상태 라이브러리를 적용하는 것이었는데 상품 생성 및 수정 폼 관련 props는 끝내 지우지 못했다.. 커스텀 훅으로의 추상화가 필요하다는 걸 인지하고 있었지만 과제 마무에 집중하면서 미루고 미루다 결국 리팩토링하지 못했다. 추후에는 이 부분을 폼 전용 커스텀 훅으로 분리하여 좀 더 읽기 쉽고 관리하기 쉬운 구조로 개선하고 싶다!!!</p>
<br />

<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>하라는 대로 하는 자세.. 👍</p>
<h3 id="problem">Problem</h3>
<p>이렇게 구현해볼까? 하는 마음이 생기면 망설이지 말고 해보기</p>
<h3 id="try">Try</h3>
<p>코드 리뷰로 알게된 내용들을 6주차 과제에 녹여보기..!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 2-1. 클린코드와 리팩토링 :회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-2-1.-%ED%81%B4%EB%A6%B0%EC%BD%94%EB%93%9C%EC%99%80-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%ED%9A%8C</link>
            <guid>https://velog.io/@areumh__9/Chapter-2-1.-%ED%81%B4%EB%A6%B0%EC%BD%94%EB%93%9C%EC%99%80-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%ED%9A%8C</guid>
            <pubDate>Sat, 09 Aug 2025 14:05:02 GMT</pubDate>
            <description><![CDATA[<h2 id="2-1-클린코드와-리팩토링">2-1. 클린코드와 리팩토링</h2>
<p>벌써 두번째 챕터...
이번 챕터의 주제는 클린코드이다.</p>
<br />

<blockquote>
<p><strong>What: 클린 코드는 다음과 같은 특징을 가진 코드입니다.</strong></p>
</blockquote>
<ul>
<li><strong>가독성</strong>: 좋은 코드는 읽기 쉽고 이해하기 쉽습니다.</li>
<li><strong>유지보수성</strong>: 좋은 코드는 수정사항에 대응하기 쉬우며, 수정에 독립적이고 찾기 쉽습니다.</li>
<li><strong>확장성</strong>: 좋은 코드는 새로운 기능을 추가할 때, 기존 코드를 크게 수정하지 않을 수 있습니다.</li>
<li><strong>견고성</strong>: 좋은 코드는 에러가 발생했을 경우에도 동작하거나 대응하고, 에러를 발견하기 쉽습니다.</li>
<li><strong>테스트 가능성</strong>: 좋은 코드는 테스트를 작성하기 쉬우며, 단위별 테스트를 할 수 있습니다.</li>
<li><strong>자기문서화</strong>: 좋은 코드는 요구사항을 코드 자체로 이해할 수 있게 합니다.</li>
<li><strong>일관성</strong>: 좋은 코드는 같은 규칙과 철학으로 작성되어 예측이 가능합니다. </li>
</ul>
<blockquote>
<p><strong>Why: 클린 코드를 작성하는 것은 다음과 같은 장점을 가져다줍니다.</strong></p>
</blockquote>
<ul>
<li>코드의 가독성이 높아져 유지보수가 용이해집니다.</li>
<li>버그 발생 가능성이 낮아져 코드의 안정성이 높아집니다.</li>
<li>개발 속도가 향상되고, 개발 비용이 절감됩니다.</li>
<li>팀원 간의 협업이 용이해집니다.</li>
</ul>
<blockquote>
<p><strong>How: 클린 코드를 작성하기 위해서는 다음과 같은 원칙을 따르는 것이 좋습니다.</strong></p>
</blockquote>
<ul>
<li>DRY(Don&#39;t Repeat Yourself) 원칙<ul>
<li>같은 코드를 반복적으로 작성하지 않도록 합니다.</li>
</ul>
</li>
<li>KISS(Keep It Simple, Stupid) 원칙<ul>
<li>코드를 최대한 간단하게 작성합니다.</li>
</ul>
</li>
<li>YAGNI(You Ain&#39;t Gonna Need It) 원칙<ul>
<li>필요하지 않은 코드는 작성하지 않습니다.</li>
</ul>
</li>
</ul>
<p>내가 지금까지 <strong>클린코드를 작성하자</strong> 라는 생각을 갖고 개발을 한 적이 있나 되돌아보게 되는 주제였다. 그래서 이번 주에 클린코드를 마스터하자는 다짐과 함께 과제를 봤는데</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/5f817cb5-5f72-4971-a0e5-1bbe6c35b369/image.png" alt=""> <img src="https://velog.velcdn.com/images/areumh__9/post/aac12c85-f174-4e3e-a193-8bb9c9d651c2/image.png" alt="">
<del>이건 아니잖아</del></p>
<p>기본 과제는 위 코드를 리액트와 타입스크립트로 리팩토링할 것을 염두해 두고 순수 js로만 리팩토링하는 거였고, 심화 과제는 기본 과제를 바탕으로 리액트와 타입스크립트로 리팩토링을 마친 뒤 배포까지 하는 거였다.</p>
<br />

<h2 id="wil">WIL</h2>
<p>1주차부터 나의 자바스크립트 구멍을 뼈저리게 느끼고 있다...
처음엔 진짜 어떻게 시작해야할지 감도 잡히지 않아서 일단 전체 코드 해석을 시작했고 그렇게 하루가 그냥 지나갔다.</p>
<p>다음 날 일단 뭐라도 해야겠다는 생각에 제일 기본적인 var 변수 및 불필요한 코드 없애기, 간단한 util 함수 작성, 비즈니스 함수 작성을 진행했고.. 함수 내에서 innerHTML을 쓰든 말든 일단 연산 코드와 ui에 관여하는 코드를 분리하는 데에만 바빴다. </p>
<p>그런데 화요일 밤에 진행된 과제 QnA를 듣고 내가 완전히 잘못된 방향으로 가고 있다는 걸 깨달았다. 난 리액트로의 리팩토링에 하등 도움이 되지 않는 코드를 작성하고 있었다. </p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/ac7b48d3-d28b-486a-afa9-69170f14e107/image.png" alt="">
<del>어떡하긴.. 과제 제출이 2일 정도 남았지만 이제라도 엎어야지</del>
 <br /></p>
<pre><code class="language-javascript">// 테오 코치의 멘토링 중 일부..

 DOM의 역할은 그리는 애다!! (얘는 데이터의 원천이 되지 못한다.)

 Model
 - state
 - calc

 View
 - computed value -&gt; render
 - event

 Controller

 event -&gt; state를 변경하게
 rerender를 요청한다.</code></pre>
<p> 우선 전역 상태를 기반으로 ui가 렌더링 되도록 하는 것이 제일 급한 부분이었다. </p>
<pre><code class="language-javascript"> const appState = {
  totalPoints: 0, // 최종 적립 포인트
  pointsDetail: [], // 포인트 상세 문자열

  totalProductCount: 0, // 장바구니 내 총 상품 수 (헤더)
  totalBeforeDiscount: 0, // 할인 전 장바구니 내 총 상품 가격
  totalAfterDiscount: 0, // 장바구니 내 총 상품 가격

  totalDiscountedRate: 0, // 총 할인율
  discountedProductList: [], // 할인 적용된 상품 목록
  lastSelectedProductId: null, // 제일 최근에 장바구니에 담은 상품의 id
};

// 최종 상태 관리
const state = {
  productState: productList,
  cartState: cartList,
};</code></pre>
<p>시간이 너무 촉박했기에 상품 목록과 장바구니 목록, 그리고 화면에 보여지는 값(상품과 장바구니 상태에 따라 달라지는 파생 계산 값)들을 모두 묶어 전역 상태로 만들었다. 좋지 않은 방식인건 알았지만 과거의 나는 너무 급했다...</p>
<pre><code class="language-javascript">export const renderCartProductList = (state) =&gt; {
  const { cartState, productState } = state;

  const container = document.getElementById(&#39;cart-items&#39;);
  container.innerHTML = &#39;&#39;; // 기존 초기화

  cartState.forEach((item) =&gt; {
    // { id: , count: }
    const product = findProductById(productState, item.id);
    const productItem = createCartProduct(product, item.count); // HTML 요소 반환
    container.appendChild(productItem);
  });
};

// ...

export const updateUI = ({ state, appState }) =&gt; {
  renderTuesdaySpecial(appState);
  renderCartSummaryDetail({ state, appState });
  renderCartTotalPrice(appState);
  renderDiscountRate(appState);
  renderTotalProductCount(appState);
  renderBonusPoints(appState);
  renderStockMessage(state);
  renderCartProductList(state);
};</code></pre>
<p>위의 <code>renderCartProductList</code>처럼 전역 상태를 바탕으로 화면에 ui를 다시 렌더링하는 함수를 만들었고, <code>updateUI</code>에 ui 렌더링 함수를 모아 실행되도록 구현했다. 어찌저찌 작성했더니 코드가 돌아가긴 했다... 휴</p>
<p>사실 시간이 너무 부족해서 기본 과제가 완벽히 끝나지 않은 상태에서 심화 과제로 넘어갔다. react + tsx 조합에 useReducer을 사용하니 숨이 트인 느낌이었다.🤩 리액트야 고마워!!!!!!!!!!</p>
<pre><code class="language-javascript">const StateContext = createContext&lt;State | undefined&gt;(undefined);
const DispatchContext = createContext&lt;React.Dispatch&lt;Action&gt; | undefined&gt;(undefined);

export const GlobalProvider = ({ children }: { children: React.ReactNode }) =&gt; {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    &lt;StateContext.Provider value={state}&gt;
      &lt;DispatchContext.Provider value={dispatch}&gt;{children}&lt;/DispatchContext.Provider&gt;
    &lt;/StateContext.Provider&gt;
  );
};</code></pre>
<p>useReducer을 사용하기 위해 Provider을 만들어 App을 감싸준 후에</p>
<pre><code class="language-javascript">export type State = {
  productList: Product[];
  cartList: CartProduct[];
  appState: AppState;
};

export type Action =
  | { type: &#39;CHANGE_QUANTITY&#39;; productId: string; delta: number }
  | { type: &#39;REMOVE_FROM_CART&#39;; productId: string }
  | { type: &#39;APPLY_FLASH_SALE&#39;; productId: string }
  | { type: &#39;APPLY_SUGGEST_SALE&#39;; productId: string };


export const reducer = (state: State, action: Action): State =&gt; {
  switch (action.type) {
    case &#39;CHANGE_QUANTITY&#39;: {
      const { productId, delta } = action;

      const product = findProductById(state.productList, productId);
      if (!product) return state;

      // 재고 부족 시
      if (delta &gt; 0 &amp;&amp; product.quantity &lt;= 0) {
        alert(MESSAGE.NO_STOCK);
        return state;
      }

      let newCartList = [...state.cartList];
      let newProductList = [...state.productList];

      const productIndex = newProductList.findIndex((item) =&gt; item.id === productId);
      const cartIndex = newCartList.findIndex((item) =&gt; item.id === productId);

      // 상품 추가
      if (cartIndex === -1 &amp;&amp; delta &gt; 0) {
        newCartList.push({ id: productId, count: 1 });
        newProductList[productIndex] = {
          ...newProductList[productIndex],
          quantity: product.quantity - 1,
        };
      }
      // 상품 제거
      else if (cartIndex !== -1 &amp;&amp; newCartList[cartIndex].count === 1 &amp;&amp; delta &lt; 0) {
        newCartList.splice(cartIndex, 1);
        newProductList[productIndex] = {
          ...newProductList[productIndex],
          quantity: product.quantity + 1,
        };
      }
      // 정상 증감
      else if (cartIndex !== -1) {
        newCartList[cartIndex] = {
          ...newCartList[cartIndex],
          count: newCartList[cartIndex].count + delta,
        };
        newProductList[productIndex] = {
          ...newProductList[productIndex],
          quantity: product.quantity - delta,
        };
      }

      const tempState = {
        cartState: newCartList,
        productState: newProductList,
      };

      const summary = calculateCartSummary(tempState);
      const bonus = calculateBonusPoint({ state: tempState, appState: summary });

      return {
        cartList: newCartList,
        productList: newProductList,
        appState: {
          ...state.appState,
          ...summary,
          ...bonus,
          lastSelectedProductId: productId,
        },
      };
    }

    // ...

    default:
      return state;
  }
};</code></pre>
<p>각 액션 키에 연산 결과를 전역 상태에 맞게 반환하도록 구현해주었다.
급하게 만든 전역 상태에 맞추느라 코드가 더럽긴 하지만 기본 과제에서 사용한 함수가 그대로 쓰이긴 했다. 🫥</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/d05e7057-5e76-4961-a855-33f0b1927d30/image.png" alt=""> ?: 더티코드가 <strong>어쨌든</strong> React 위에서 돌아가고 있고 ...</p>
<br />


<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>포기하지 않는 자세... 
솔직히 4주차 과제를 심화까지 통과한게 기적이라고 생각한다. </p>
<h3 id="problem">Problem</h3>
<p>역시나 무턱대고 아무거나 막 시작한 부분이...</p>
<h3 id="try">Try</h3>
<p>4주차가 아쉬웠던 만큼 남은 2번째 챕터 과제 열심히 임하기 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 1-3. React, Beyond the Basic :회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-1-3.-React-Beyond-the-Basic-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-1-3.-React-Beyond-the-Basic-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Mon, 28 Jul 2025 06:30:02 GMT</pubDate>
            <description><![CDATA[<h2 id="1-3-react-beyond-the-basic">1-3. React, Beyond the Basic</h2>
<p>3주차 과제는 리액트의 훅을 직접 구현해보는 거였다. 
사실 메모이제이션 훅을 사용해본 경험이 많지 않기에 너무 걱정스러웠다...
일단 해</p>
<h2 id="wil">WIL</h2>
<h3 id="✅-usecallback--useautocallback">✅ useCallback / useAutoCallback</h3>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/7d730259-f676-419a-8f65-4b56d85de170/image.png" alt=""> 저번 주의 나: <del>이게 무슨 말이지...</del></p>
<br />

<pre><code class="language-javascript">const cachedFn = useCallback(fn, dependencies);</code></pre>
<p><a href="https://ko.react.dev/reference/react/useCallback">useCallback</a>은 리렌더링 간에 함수 정의를 캐싱해 주는 훅이다. 
이 훅은 fn 함수가 의존하는 배열인 dependencies를 넘겨 주어야 하는데, 값을 빠뜨릴 경우 너무 오래된 값을 사용하게 되거나 너무 많이 넣을 경우 과도한 리렌더링을 유발하는 문제가 생길 수 있다.</p>
<p>이러한 문제를 덜기 위해 의존성 배열을 넘겨주지 않아도 항상 최신값을 유지하도록 하는 훅이 바로 <strong>useAutoCallback</strong>이다!</p>
<pre><code class="language-javascript">// lib/src/hooks/useAutoCallback.ts

// 참조가 변경되지 않으면 항상 새로운 값을 참조
export const useAutoCallback = &lt;T extends AnyFunction&gt;(fn: T): T =&gt; {
  const ref = useRef(fn);

  const callback = useCallback((...args: Parameters&lt;T&gt;) =&gt; {
    return ref.current(...args);
  }, []);

  ref.current = fn;
  return callback as T;
};</code></pre>
<p><code>callback</code>은 의존성 배열이 비어있기 때문에 최초 한 번만 생성되고, 최신 ref의 함수를 호출한다. <code>ref.current = fn;</code>에서 매 렌더마다 ref의 함수가 갱신되기 때문에 고정된 callback 내부에서는 <strong>항상 최신 상태의 함수를 참조</strong>하게 된다!</p>
<p>이로 인해 의존성 배열을 신경쓸 필요 없이, 매 렌더링 시점의 함수 로직을 유지하면서 불필요한 함수 재생성을 방지할 수 있다. 🤩</p>
<br />

<h3 id="✅-usesyncexternalstore">✅ useSyncExternalStore</h3>
<p><a href="https://ko.react.dev/reference/react/useSyncExternalStore">useSyncExternalStore</a>이라는 훅을 사용하여 나보고 직접 훅을 만들라는데...
진짜 처음 보는 훅이었다. 이게 뭔질 알아야 사용할 수 있으니... 한번 알아보자...🤦‍♀️</p>
<pre><code class="language-javascript">const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)</code></pre>
<p>이는 컴포넌트 최상위 레벨에 호출하여 외부 저장소(store)의 상태를 구독하고 읽는 훅이다. </p>
<ul>
<li><p><strong>subscribe</strong>
외부 저장소의 변경을 구독하는 함수</p>
</li>
<li><p><strong>getSnapShot</strong>
현재 저장소 데이터 상태를 반환하는 함수</p>
</li>
<li><p><strong>getServerSnapShot</strong>
서버 사이드 렌더링(SSR) 환경에서 사용되는 데이터의 초기 상태를 반환하는 함수 (선택)</p>
</li>
</ul>
<br />

<p>그럼 store은 또 무엇인가...</p>
<pre><code class="language-javascript">// lib/src/createStore.ts

export const createStore = &lt;S, A = (args: { type: string; payload?: unknown }) =&gt; S&gt;(
  reducer: (state: S, action: A) =&gt; S,
  initialState: S,
) =&gt; {
  const { subscribe, notify } = createObserver();

  let state = initialState;

  const getState = () =&gt; state;

  const dispatch = (action: A) =&gt; {
    const newState = reducer(state, action);
    if (!Object.is(newState, state)) {
      state = newState;
      notify();
    }
  };

  return { getState, dispatch, subscribe };
};</code></pre>
<p>store로 전역 상태를 만들고, 변경, 구독할 수 있는 관리 시스템을 구성하는 함수이다. 상태를 변경하는 함수인 <strong>reducer</strong>과 초기 상태인 <strong>initialState</strong>를 인자로 받는다.</p>
<p><code>state</code>는 현재 store의 상태를 저장해두는 변수이며, <code>dispatch</code>를 호출할 때마다 바뀐다. 그리고 <code>subscribe</code>는 store의 상태 변화를 감지하는 함수, <code>getState</code>는 현재 store의 상태를 반환한다.</p>
<p>이렇게 구성된 createStore 함수로 만든 store을 useSyncExternalStore와 함께 사용하면 안전하고 일관되게 전역 상태를 구독할 수 있다!</p>
<br />

<h3 id="✅-useshallowselector">✅ useShallowSelector</h3>
<p>개인적으로 가장 막막했던 부분이 useShallowSelector 코드였다.</p>
<pre><code class="language-javascript">// lib/src/hooks/useShallowSelector.ts

type Selector&lt;T, S = T&gt; = (state: T) =&gt; S;

export const useShallowSelector = &lt;T, S = T&gt;(selector: Selector&lt;T, S&gt;) =&gt; {
  const ref = useRef&lt;S | null&gt;(null);

  return (state: T): S =&gt; {
    const result = selector(state);

    if (ref.current &amp;&amp; shallowEquals(ref.current, result)) {
      return ref.current;
    }

    ref.current = result;
    return result;
  };
};</code></pre>
<p>위 코드는 <strong>이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인</strong>하는 훅이다.</p>
<pre><code class="language-javascript">type Selector&lt;T, S = T&gt; = (state: T) =&gt; S;</code></pre>
<p>인자로 받을 selector 함수의 타입 정의이다.
T 타입의 state를 받아 S 타입의 결과를 반환한다는 뜻!!</p>
<p>이전 결과를 useRef를 통해 기억한 후, 현재 상태에서의 값(<code>const result = selector(state);</code>)과 비교하여 값을 상태를 업데이트한다.</p>
<p>shallowEquals 함수를 통해 얕은 비교를 진행한 후에 값이 같으면 ref의 값을 그대로 반환하고, 값이 바뀌었다면 ref를 새로운 값인 result로 업데이트해준 뒤 result를 반환한다!</p>
<pre><code class="language-javascript">// lib/src/hooks/useStore.ts

type Store&lt;T&gt; = ReturnType&lt;typeof createStore&lt;T&gt;&gt;;

const defaultSelector = &lt;T, S = T&gt;(state: T) =&gt; state as unknown as S;

export const useStore = &lt;T, S = T&gt;(store: Store&lt;T&gt;, selector: (state: T) =&gt; S = defaultSelector&lt;T, S&gt;) =&gt; {
  const shallowSelector = useShallowSelector(selector);

  return useSyncExternalStore(store.subscribe, () =&gt; shallowSelector(store.getState()));
};</code></pre>
<p>위의 useShallowSelector 를 사용하여 <strong>store의 상태를 구독하고 가져오는 훅</strong>이다.</p>
<p><code>shallowSelector</code>은 selector가 반환하는 값이 얕은 비교로 동일하면 이미 캐시된 값을 반환하여 <strong>불필요한 리렌더링을 방지하는 역할</strong>을 한다. 👏</p>
<br />



<pre><code class="language-javascript">// 예시)

type AppState = {
  count: number;
  user: { name: string };
};

const store = createStore&lt;AppState&gt;({ count: 0, user: { name: &#39;areumH&#39; } });

function UserName() {
  const name = useStore(store, (state) =&gt; state.user.name);
  return &lt;p&gt;User: {name}&lt;/p&gt;;
}</code></pre>
<p>state.count 값이 바뀌어도 name은 state.name 값만을 비교하기 때문에 UserName은 리렌더링되지 않는다! 👍</p>
<br />

<h3 id="✅-contextprovider">✅ Context.Provider</h3>
<p>e2e 테스트 중 <code>장바구니를 추가하거나 삭제했을 때, 토스트 호출로 인하여 리렌더링이 되지 않도록 한다</code>를 통과하기 위해선 이미 주어진 ToastProvider.tsx 코드를 수정해야 했다..!</p>
<pre><code class="language-javascript">// app/src/components/toast/ToastProvider.tsx

const ToastContext = createContext&lt;{
  message: string;
  type: ToastType;
  show: ShowToast;
  hide: Hide;
}&gt;({
  ...initialState,
  show: () =&gt; null,
  hide: () =&gt; null,
});

export const ToastProvider = memo(({ children }: PropsWithChildren) =&gt; {
  // 생략

  return (
    &lt;ToastContext value={{ show: showWithHide, hide, ...state }}&gt;
      {children}
      {visible &amp;&amp; createPortal(&lt;Toast /&gt;, document.body)}
    &lt;/ToastContext&gt;
  );
});</code></pre>
<p>위의 코드는 기본적으로 주어진 내가 수정해야할 ToastProvider 코드의 일부분이다. </p>
<br />

<pre><code class="language-javascript">// 예시)

import { createContext } from &#39;react&#39;;

const ThemeContext = createContext(&#39;light&#39;);

function App() {
  const [theme, setTheme] = useState(&#39;light&#39;);
  // ...
  return (
    &lt;ThemeContext value={theme}&gt;
      &lt;Page /&gt;
    &lt;/ThemeContext&gt;
  );
}</code></pre>
<p>우선 <a href="https://react.dev/reference/react/createContext">createContext</a>는 위와 같은 형태로 컨텍스트를 생성하고, 여러 컴포넌트 간에 데이터를 전역적으로 공유할 수 있게 해준다!</p>
<p>그리고 <strong>Provider</strong>를 사용해 context 값을 하위 컴포넌트에 전달한다. 그런데 이미 주어진 코드를 보면 ToastContext.Provider가 아닌 <code>ToastContext</code>를 사용했다..!??</p>
<pre><code class="language-javascript">// 3주차 학습 자료) 3-3. React 프로파일링 및 기본 최적화

// Provider 컴포넌트
const ThemeProvider: React.FC&lt;PropsWithChildren&gt; = ({ children }) =&gt; {
  const [theme, setTheme] = useState&lt;Theme&gt;(&#39;light&#39;);
  const toggleTheme = useCallback(() =&gt; {
    setTheme(prev =&gt; prev === &#39;light&#39; ? &#39;dark&#39; : &#39;lht&#39;);
  }, []);
  const value = useMemo(() =&gt; ({ theme, toggleTheme }), [theme, toggleTheme]);
  return &lt;ThemeContext.Provider value={value}&gt;{children}&lt;/ThemeContext.Provider&gt;;
};</code></pre>
<p>이건 과제 시작 전 훑어봤던 학습 자료인데, 이 코드에선 ThemeContext가 아닌 <code>ThemeContext.Provider</code>로 감싸서 리턴해주는 걸 볼 수 있다. 
Provider을 붙인 것과 안 붙인 것의 차이가 궁금해져서 한번 서치해봤다.</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/468480fd-d9c8-40c0-9c04-7612501b0019/image.png" alt=""> 리액트 19부터는 Context 뒤에 Provider을 붙인 것과 안 붙인 것이 기능적으로 동일하게 작동한다고 한다. 따봉리액트야 고마워..~~ 👍</p>
<br />

<h3 id="✅-shx-패키지">✅ shx 패키지</h3>
<p>배포를 위해 <code>pnpm run gh-pages</code>를 실행했더니 터미널에 <code>&#39;cp&#39;은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다.</code> 와 같은 에러가 떴다. </p>
<pre><code class="language-javascript">// app/package.json

  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;vite build &amp;&amp; cp ./dist/index.html ./dist/404.html&quot;,
    // ...
  },</code></pre>
<p>package.json의 스크립트 명령어인데, <code>cp</code>는 Unix 기반 환경에서의 shell 명령어이기 때문에 윈도우에서는 작동하지 않는 것이었다... 관련하여 찾아보니 Unix shell  명령어를 Node.js 스크립트에서 사용할 수 있게 해주는 유틸리티 패키지 <a href="https://github.com/shelljs/shx">shx</a> 라는게 있었다! 너무 다행...</p>
<p>해당 패키지 설치 후, 명령어를 <code>&quot;build&quot;: &quot;vite build &amp;&amp; shx cp ./dist/index.html ./dist/404.html&quot;</code> 로 수정하여 페이지 배포에 성공했다. 과제와 직접적인 연관이 있는 건 아니지만 그래도 하나 더 알게 되었다..!! 😶
<del>(cp 대신 copy로 실행하면 된다는 걸 나중에 알았다......)</del></p>
<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>학습 자료를 적극 활용하자...</p>
<h3 id="problem">Problem</h3>
<p>테스트를 통과했다고 코드를 방치하지 말자...</p>
<h3 id="try">Try</h3>
<p>겁먹지 말자... (다음 과제 너무 겁남)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 1-2. 프레임워크 없이 SPA 만들기 :회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-1-2.-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%EC%97%86%EC%9D%B4-SPA-%EB%A7%8C%EB%93%A4%EA%B8%B0-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-1-2.-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%EC%97%86%EC%9D%B4-SPA-%EB%A7%8C%EB%93%A4%EA%B8%B0-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sat, 19 Jul 2025 11:26:33 GMT</pubDate>
            <description><![CDATA[<h2 id="1-2-프레임워크-없이-spa-만들기">1-2. 프레임워크 없이 SPA 만들기</h2>
<p>2주차 과제는 1주차와 같이 프레임워크 없이 spa 페이지 구현하기지만, 더 자세하게는 <strong>가상 돔</strong>과 <strong>diff 알고리즘</strong>을 사용하여 이벤트 관리를 최적화하고, 불필요한 렌더링을 줄이는 것이었다. </p>
<p><code>실제 DOM의 복사본 - 변경된 것만 실제의 DOM에 반영하여 불필요한 렌더링을 줄임</code>
과제 시작 직전, 내가 가상 돔에 대해 알고 있는 그대로 쓴 문장 하나이다. 기본 지식이라곤 이 한 문장이 전부였기 때문에 이번 과제는 또 어떻게 헤쳐나가야 할지 너무 막막했다.. 하지만 걱정과 달리 1주차 과제보다 훨씬 재밌게 느껴졌다. 저번주 과제는... 백틱에 둘러싸인 html 코드가 너무 보기가 싫었다...🥹 <del>나가</del></p>
<p>우선 이번 회고에서는 과제를 하면서 막혔던 부분을 크게 3가지 정도 작성해보려 한다.</p>
<h3 id="1-normalizevnode-">1. normalizeVNode() ?</h3>
<p>createVNode나 createElement, updateElement와 같은 함수는 함수명만 봐도 어떤 역할인지 대강 알 수 있었는데, <strong>normalizeVNode</strong>는 정확히 어떤 역할의 함수인지 감이 잡히지 않았다. VNode를 정규화하는 건 어떤 과정일까.. </p>
<p>renderElement 함수는 인자로 받은 vNode를 normalizeVNode 함수를 사용해 정규화하고, 그 반환값을 createElement 함수를 사용하여 요소로 만든다. 즉, <strong>VNode를 렌더링 가능한 일관된 형태로 변환</strong>해주는 것이다. createElement가 정제된 vNode를 사용해 요소를 만들 수 있도록 도움을 주는 함수이다! </p>
<p>추가로 normalizeVNode 함수의 테스트에는 <code>null, undefined, boolean 값은 빈 문자열로 변환되어야 한다.</code> 와 <code>Falsy 값 (null, undefined, false)은 자식 노드에서 제거되어야 한다.</code>가 있는데, 처음에 이 둘의 차이를 정확히 이해하지 못했다. </p>
<ul>
<li><p><strong>null, undefined, boolean 값은 빈 문자열로 변환</strong>
: 함수의 인자로 null, undefined, boolean 값이 들어왔을 때 함수의 결과 값이 빈 문자열 (ex. <code>normalizeVNode(null)</code> =&gt; <code>&quot;&quot;</code> )</p>
<pre><code class="language-javascript">if (typeof vNode === &quot;boolean&quot; || vNode === null || vNode === undefined) {
vNode = &quot;&quot;;
}</code></pre>
</li>
<li><p><strong>Falsy 값 (null, undefined, false)은 자식 노드에서 제거</strong>
: 인자의 타입이 객체이면서, 그 자식 노드가 Falsy 값일 때 아예 그 값이 제거된 객체를 반환 (ex. <code>&lt;div&gt;{null}&lt;/div&gt;</code> =&gt; <code>&lt;div&gt;&lt;/div&gt;</code>를 위한 과정)</p>
<pre><code class="language-javascript">if (typeof vNode === &quot;object&quot;) {
const children = vNode.children ?? [];
const normalizedChildren = children
  .map(normalizeVNode)
  .filter((child) =&gt; child !== &quot;&quot; &amp;&amp; child !== null &amp;&amp; child !== undefined &amp;&amp; typeof child !== &quot;boolean&quot;);

vNode = {
  ...vNode,
  children: normalizedChildren,
};
}</code></pre>
</li>
</ul>
<br />

<h3 id="2-updateattribute-">2. updateAttribute() ?</h3>
<p>위의 함수는 createElement 함수에서 새 노드에 속성을 세팅할 때, updateElement 함수에서 이전 노드와 새 노드를 비교하여 속성을 업데이트할 때 사용하는 함수이다. </p>
<pre><code class="language-javascript">// src/lib/updateElement.js

// props 업데이트 및 추가
Object.entries(originNewProps).forEach(([attr, value]) =&gt; {
  if (attr === &quot;className&quot;) {
    target.setAttribute(&quot;class&quot;, value);
  } else if (typeof value === &quot;boolean&quot;) {
    value &amp;&amp; target.setAttribute(attr, &quot;&quot;); // ⚠️
  } else if (attr.startsWith(&quot;on&quot;)) {
    const eventType = attr.slice(2).toLowerCase();
    const oldHandler = originOldProps[attr];

    if (oldHandler) {
      // 이벤트를 제거하지 않으면 누적됨
      removeEvent(target, eventType, oldHandler);
    }
    addEvent(target, eventType, value);
  } else {
    target.setAttribute(attr, value);
  }
});</code></pre>
<p>위의 코드는 내가 처음 작성했던 코드이고, 문제가 됐던 부분은 value가 boolean 타입일 때의 처리였다. createElement와 같은 코드로 작성하면 정상 작동할 줄 알았는데, <code>boolean type props가 property로 직접 업데이트되어야 한다.</code>의 checked, disabled, selected 테스트를 모두 실패했다. </p>
<p>내가 쓴 코드에는 두 가지 문제가 있는데, 첫번째는 value가 false일 때 아무 처리도 하지 않는다는 것이고, 두번째는 이미 attr가 존재할 때 직접적으로 업데이트를 해주지 않는다는 것이다.</p>
<pre><code class="language-javascript">else if (typeof value === &quot;boolean&quot;) {
  if (attr in target) {
    // target에 attr가 존재하는지 확인
    target[attr] = value; // 있으면 직접 업데이트
    if (!value) {
      // attr가 존재하는데 값이 false
      // 해당 attr 삭제
      target.removeAttribute(attr);
    }
  } else {
    // attr가 존재하지 않음
    // true이면 빈 문자열로 추가, false이면 제거
    value ? target.setAttribute(attr, &quot;&quot;) : target.removeAttribute(attr);
  }
}</code></pre>
<p>위와 같이 직접적으로 값을 업데이트하게 수정하여 테스트를 통과했다!
지금 보니까 <code>target[attr] = value;</code>만 작성해도 될 것 같은 느낌..</p>
<br />

<h3 id="3-이벤트-위임-문제-이벤트-버블링">3. 이벤트 위임 문제 (이벤트 버블링)</h3>
<p>함수 작동 테스트인 basic.test와 advanced.test를 모두 통과하고, e2e 테스트를 시작하기 전에 페이지를 열고 기본 동작들을 확인해보니 제일 기본적인 페이지 라우트가 동작하지 않았다. <del>(테스트 다 통과했는데 대체 왜)</del></p>
<pre><code class="language-javascript">// src/components/ProductCard.jsx

&lt;div className=&quot;cursor-pointer product-info mb-3&quot; onClick={handleClick}&gt;
  &lt;h3 className=&quot;text-sm font-medium text-gray-900 line-clamp-2 mb-1&quot;&gt;{title}&lt;/h3&gt;
  &lt;p className=&quot;text-xs text-gray-500 mb-2&quot;&gt;{brand}&lt;/p&gt;
  &lt;p className=&quot;text-lg font-bold text-gray-900&quot;&gt;{price.toLocaleString()}원&lt;/p&gt;
&lt;/div&gt;</code></pre>
<p>위 코드는 ProductCard 컴포넌트의 일부분인데, div 범위에는 포함되지만 h3, p 태그의 범위는 아닌 아주 애매한 부분을 클릭해야만 클릭 이벤트가 작동했다... 
이딴 현상 처음봤다.</p>
<p>이는 setupEventListeners 함수에서의 문제였다.</p>
<pre><code class="language-javascript">// src/lib/eventManager.js (setupEventListeners())

const handleEvent = (event) =&gt; {
  const target = event.target;
  if (!handlerMap.has(target)) return;

  const handler = handlerMap.get(target);
  if (handler) handler(event);
};</code></pre>
<p>내가 처음 작성했던 setupEventListeners 함수 코드의 일부분이다. 이 코드는 클릭한 요소에 등록되어있는 이벤트를 동작시키기 때문에, 부모 요소에 등록된 이벤트가 동작하지 않는다.</p>
<pre><code class="language-javascript">const handleEvent = (event) =&gt; {
  let target = event.target;

  // 클릭된 요소와 이벤트가 연결된 요소가 다름
  while (target &amp;&amp; target !== event.currentTarget) {
    if (handlerMap.has(target)) {
      // 등록된 이벤트 함수
      const handler = handlerMap.get(target);
      // 실행시킨 후 종료
      if (handler) handler(event);
      break;
    }
    // 부모 요소로 올라가며 반복
    target = target.parentElement;
  }
};</code></pre>
<p>바로 부모 요소를 <code>event.currentTarget</code>으로 확인하며 이벤트를 연결해주는 코드가 빠져있었기 때문이었다. 현재 클릭된 요소와 이벤트가 연결된 요소가 다를 경우에 부모 요소를 하나씩 검사하면서 이벤트 함수가 있으면 해당 이벤트를 실행하도록 코드를 수정하여 테스트에 통과했다. 💪</p>
<br />

<h2 id="wil">WIL</h2>
<h3 id="✅-diff-알고리즘">✅ DIFF 알고리즘</h3>
<p>diff 알고리즘은 두 개의 데이터를 비교해서 어떤 부분이 다른지, 어떤 부분이 변경됐는지를 찾아내는 알고리즘이다. 처음 diff 알고리즘 테스트를 통과해야 한다는 말을 듣고 추가로 엄청난 어려운 과제가 남아있는 줄 알았는데 그냥 내가 구현한 모든 코드 자체가 diff 알고리즘이었다..😅💦</p>
<pre><code class="language-javascript">// src/lib/updateElement.js

const newChildren = newNode.children ?? [];
const oldChildren = oldNode.children ?? [];

// 공통 길이까지 자식 요소를 비교하며 요소 업데이트
for (let i = 0; i &lt; Math.min(newChildren.length, oldChildren.length); i++) {
  updateElement(target, newChildren[i], oldChildren[i], i);
}

// newChildren가 더 많으면 추가
for (let i = oldChildren.length; i &lt; newChildren.length; i++) {
  target.appendChild(createElement(newChildren[i]));
}

// oldChildren가 더 많으면 역순으로 삭제
for (let i = oldChildren.length - 1; i &gt;= newChildren.length; i--) {
  const child = target.childNodes[i];
  if (child) target.removeChild(child);
}</code></pre>
<p>자식 노드끼리 비교하며 재귀 업데이트를 하는 부분이 조금 헷갈렸었다.</p>
<p>우선 현재 자식 노드와 이전 자식 노드의 공통 길이를 Math.min으로 계산한 뒤에 순서대로 updateElement 함수를 통해 업데이트해주었다.
그 이후 추가될 자식 노드가 있는 경우엔 appendChild로 노드를 요소로 추가해주었고, 이전 자식 노드가 더 많은 경우엔 순서(인덱스)의 영향을 받지 않기 위해 역순으로 삭제해주었다! </p>
<br />


<h3 id="✅-renderelement">✅ renderElement()</h3>
<p>처음에 과제를 하면서 테스트 코드를 보며 각 함수를 작성하면서 <del>근데 이런 함수들이 어떻게 연결되는거지...?</del> 라는 생각이 들었다. 각 함수의 기능도 알겠고 역할도 알겠는데 결국 이 함수들이 어느 순서를 거쳐 페이지에 컴포넌트를 보여주는 건지 감이 잡히지 않았다.</p>
<pre><code class="language-javascript">// src/lib/renderElement.js

// 최초 렌더링시에는 createElement로 DOM을 생성하고
// 이후에는 updateElement로 기존 DOM을 업데이트한다.
// 렌더링이 완료되면 container에 이벤트를 등록한다.

const prevVNode = new WeakMap();

export function renderElement(vNode, container) {
  // 이전 VNode
  const currentNode = prevVNode.get(container);
  // 새로운 VNode를 정규화
  const normalizedVNode = normalizeVNode(vNode);

  if (!currentNode) {
    // 최초 렌더링 시 DOM 생성
    const element = createElement(normalizedVNode);
    container.appendChild(element);
  } else {
    // 기존 DOM 업데이트
    updateElement(container, normalizedVNode, currentNode);
  }

  // 렌더링한 VNode 저장 후 이벤트 위임
  prevVNode.set(container, normalizedVNode);
  setupEventListeners(container);
}</code></pre>
<ol>
<li>먼저 최초 렌더링 시 createElement를 통해 정규화된 vNode를 DOM으로 변환하여 container에 넣음</li>
<li>그 이후엔 이전 vNode와 현재의 vNode를 비교하여 diff 알고리즘을 통해 변경된 부분만 업데이트</li>
<li>렌더링된 vNode는 이후 업데이트의 기준이 되어야 하기 때문에 저장하고, 이벤트 위임을 등록</li>
</ol>
<p>친절한 주석 덕분에 위의 순서 그대로 구현할 수 있었다👍</p>
<br />

<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>이번 과제는 시작하기 전 학습 자료를 정독하는 시간을 가졌다. 
테스트 코드도 미리 보면서 어떤 기능을 구현해야 하는지 알고 시작하니 저번 주보다 훨씬 수월하게 진행된 느낌이었다... </p>
<h3 id="problem">Problem</h3>
<p>테스트를 통과해서 코드가 지저분해보이는데도 그냥 넘어간 부분이 몇 있다.
(updateElement나 setupEventListener 함수 부분)
테스트 통과한건 좋지만, 시간이 남으면 리팩토링 미루지 말고 꼭 해보기.</p>
<h3 id="try">Try</h3>
<p>정확히 모르겠는 개념은 질문하기, 다른 팀원의 코드도 살펴보기!!
저번 주부터 지금까지 계속 모든 걸 너무 나 혼자 하는 것 같다.
물론 개인 과제라 혼자 해야 하지만 부트캠프 내에서 할 수 있는 일을 안하고 있는 느낌..? 훨씬 어려워보이는 3주차 과제부턴 꼭 질문 많이하기.. 😶</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Chapter 1-1. 프레임워크 없이 SPA 만들기 :회고]]></title>
            <link>https://velog.io/@areumh__9/Chapter-1-1.-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%EC%97%86%EC%9D%B4-SPA-%EB%A7%8C%EB%93%A4%EA%B8%B0-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@areumh__9/Chapter-1-1.-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%EC%97%86%EC%9D%B4-SPA-%EB%A7%8C%EB%93%A4%EA%B8%B0-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 13 Jul 2025 13:15:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>회고 작성 전...</p>
</blockquote>
<p>대학 졸업 후...
혼자 오픈 api를 사용하며 프로젝트를 하다 도저히 어떻게 취업 준비를 해야할 지 막막함을 느껴 부트캠프를 알아보게 되었고, 어쩌다보니 <strong>항해 플러스 프론트엔드 6기</strong>에 참여하게 되었다. 능력자들 사이에서 잘 섞여나갈 수 있을 지 걱정이 되었지만... 아무것도 안하고 있을 바엔 뭐라도 하자는 마음으로 신청했다. 
<del>살짝 후회될 지도</del></p>
<p>매주 개인 과제가 진행되고, 챕터 별로 회고를 작성해보려고 했으나 <code>WIL (Weekly I Learned)</code>을 작성한 후 제출하는 것까지가 과제의 마무리이기 때문에 급하게 1주차 회고를 작성해본다. 👊</p>
<hr>
<h2 id="1-1-프레임워크-없이-spa-만들기">1-1. 프레임워크 없이 SPA 만들기</h2>
<p>1주차 과제는 라이브러리의 도움 없이 복잡한 spa 페이지 구현하기였다. </p>
<p><strong>라우터</strong>와 <strong>전역 상태 관리</strong>가 이번 과제의 중점이었다고 생각하는데, 전역 상태라고는 <code>redux</code> 라이브러리를 사용하여 로그인 토큰을 관리해본 것이 전부였기 때문에 처음부터 너무 막막하게 느껴졌다. 처음에 순수 자바스크립트로 전역 상태를 관리하는 방법에 대해 서치해보는 데에 시간을 많이 쓴 것 같다.</p>
<h2 id="wil">WIL</h2>
<h3 id="✅-observer-pattern">✅ Observer Pattern</h3>
<pre><code class="language-javascript">export function createStore(initialState) {
  let state = { ...initialState };
  const listeners = new Set();

  function subscribe(listener) {
    listeners.add(listener);
    listener(state);

    return () =&gt; listeners.delete(listener);
  }

  function getState() {
    return state;
  }

  function setState(newState) {
    state = { ...state, ...newState };
    listeners.forEach((listener) =&gt; listener(state));
  }

  return {
    subscribe,
    getState,
    setState,
  };
}
</code></pre>
<p>전역 상태 관리에 사용했던 옵저버 패턴 기반의 함수이다.</p>
<ul>
<li><strong>createStore()</strong> 
상태(state)와 상태 변경을 구독할 수 있는 기능을 가진 store 객체를 만들어서 반환, state 변수에 initialState를 복사하여 setState 함수로 상태를 업데이트한다.</li>
<li><strong>subscribe()</strong>
상태가 바뀔 때 실행될 콜백(listener)을 등록한다.</li>
<li><strong>getState()</strong>
현재 상태를 가져오는 함수, 외부에서 상태를 읽을 때 사용한다.</li>
<li><strong>setState()</strong>
상태를 업데이트하는 함수, state를 newState로 덮어쓰고, listeners에 등록된 모든 콜백을 실행한다.</li>
</ul>
<br />

<h3 id="✅-router">✅ Router</h3>
<p>사실 난 이번 과제를 하며 라우터를 따로 구현하지 않았는데, 이 부분이 가장 후회됐다. 지금 생각해보면 가장 기본적이면서도 당연한 기능인데 아무래도 어떻게 구현해야할 지 감이 안잡혔기에 구현을 안한게 아닌가 싶다.</p>
<p><a href="https://developer.mozilla.org/ko/docs/Web/API/Window/popstate_event">MDN popstate</a>
<strong>popstate</strong>도 이번 과제를 하며 처음 알게 된 개념 중 하나이다.</p>
<p>popstate 이벤트는 사용자의 세션 기록 탐색으로 인해 현재 활성화된 기록 항목이 바뀔 때 발생하며, pushState() 메서드는 세션 기록에 새 항목을 추가하고, replaceState() 메서드는 현재 페이지의 세션 기록 항목을 업데이트한다.</p>
<pre><code class="language-javascript">export const navigate = (path) =&gt; {
  history.pushState(null, &quot;&quot;, path);
  window.dispatchEvent(new PopStateEvent(&quot;popstate&quot;));
};</code></pre>
<p>위의 코드는 내가 뭣도 모르고 작성한 후 남용한 함수이다...</p>
<p>다음엔 navigate 함수 내에서 직접적으로 history를 사용하지 않고, router를 직접 생성한 후, navigate 함수 내에서는 router를 호출하는 방식으로 구현할 것 같다. </p>
<hr>
<h2 id="kpt">KPT</h2>
<h3 id="keep">Keep</h3>
<p>아직 없다고 본다... 
첫 과제라 시간 분배도 잘 못한 것 같고, 발제와 함께 주어지는 학습 자료를 처음부터 정독하지 않았던 것이 후회됐다. 과제 양이 많아 일단 뭐라도 하자라는 생각에 아무것도 모르는 상태에서 코드를 작성한 탓에 뒤로 갈수록 코드가 엉망이 됐다. </p>
<h3 id="problem">Problem</h3>
<p>위에서 말한 것 처럼 일단 기본적인 공부를 하고나서 틀을 잡고 시작하기!
한번 시작한건 마무리 하고 진행하기!
컴포넌트 분리 조금 하다가 갑자기 기능 건드리고.. 기능 구현하다가 갑자기 다른 테스트 코드 실행하고.. 침착하게 차근차근 구현하자..</p>
<h3 id="try">Try</h3>
<p>AI 활용을 덜 하자... 학습 자료를 제대로 읽지도 않고 AI에게 폭풍질문하는 내가 너무 한심하게 느껴졌었다. 그리고 막혔을 땐 질문을 하는 자세가 필요할 것 같다. 부트캠프에 참여해놓고 혼자 끙끙대고 있는 고집부렁...</p>
<p>그리고 이번 과제 때는 페이지에서는 실행되지만 직접 테스트 코드를 실행하면 통과하지 못하는 부분이 많았기 때문에, 2주차부터는 테스트 코드를 돌려보며 기능을 구현해야겠다고 생각했다.</p>
<p><img src="https://velog.velcdn.com/images/areumh__9/post/dbc0b9b7-8bd4-4d51-bc7a-b71489f85f13/image.png" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>