<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>bom_0320.log</title>
        <link>https://velog.io/</link>
        <description>FE 개발 꿈틀이</description>
        <lastBuildDate>Thu, 06 Aug 2026 09:38:33 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>bom_0320.log</title>
            <url>https://velog.velcdn.com/images/bom_0320/profile/65f8fd9f-a573-40c7-9fd8-f4d0f71fe786/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. bom_0320.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/bom_0320" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Amplitude로 포트폴리오 사용자 흐름을 측정하고 IA 개선하기]]></title>
            <link>https://velog.io/@bom_0320/Amplitude%EB%A1%9C-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%9D%90%EB%A6%84-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@bom_0320/Amplitude%EB%A1%9C-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%9D%90%EB%A6%84-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 06 Aug 2026 09:38:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>2026.07.07~2026.08.06 섹션별 고유 사용자 추이</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/1c77687f-ad9d-413a-8800-9bcf3d6f6e08/image.png" alt=""></p>
<p>포트폴리오를 배포한 뒤 방문자 수는 확인할 수 있었지만, 사람들이 실제로 <strong>어떤 콘텐츠까지 확인하고 어느 구간에서 이탈하는지</strong>는 알기 어려웠습니다.</p>
<p>제 포트폴리오는 한 페이지 안에서 여러 섹션이 길게 이어지는 구조이기 때문에 페이지뷰만으로는 사용자가 핵심 콘텐츠나 Contact 영역까지 도달했는지 확인할 수 없었습니다.</p>
<p>또한 피드백을 받아 콘텐츠나 정보 구조를 변경하더라도, <strong>그 변경이 실제 사용자 탐색에서도 효과가 있었는지 판단할 기준</strong>이 필요했습니다.</p>
<p>그래서 Amplitude를 적용해 섹션 도달과 주요 링크 클릭을 직접 이벤트로 수집했습니다.</p>
<h2 id="이벤트-정의">이벤트 정의</h2>
<p>모든 클릭을 자동으로 수집하기보다는 실제로 확인하고 싶은 행동만 이벤트로 정리했습니다.</p>
<pre><code class="language-tsx">export const ANALYTICS_EVENT = {
  PORTFOLIO_VIEWED: &quot;portfolio_viewed&quot;,
  SECTION_VIEWED: &quot;section_viewed&quot;,
  PROJECT_CLICKED: &quot;project_clicked&quot;,
  PROJECT_EXTERNAL_LINK_CLICKED: &quot;project_external_link_clicked&quot;,
  CTA_CLICKED: &quot;cta_clicked&quot;,
  CONTACT_FORM_STARTED: &quot;contact_form_started&quot;,
  CONTACT_FORM_SUBMITTED: &quot;contact_form_submitted&quot;,
  CONTACT_FORM_FAILED: &quot;contact_form_failed&quot;,
} as const;</code></pre>
<p>이벤트 이름을 각 컴포넌트에 직접 작성하면 오탈자나 이름 불일치가 발생할 수 있기 때문에 한 파일에서 관리했습니다.</p>
<p>Amplitude 설정에서는 페이지뷰, 세션, 유입 정보는 자동으로 수집하고, 클릭과 폼 인터랙션은 필요한 행동만 직접 기록하도록 설정했습니다.</p>
<pre><code class="language-tsx">amplitude.init(apiKey, undefined, {
  autocapture: {
    attribution: true,
    pageViews: true,
    sessions: true,
    elementInteractions: false,
    fileDownloads: true,
    formInteractions: false,
  },
});</code></pre>
<h2 id="섹션-도달-기준">섹션 도달 기준</h2>
<p>섹션이 화면 끝에 조금만 걸려도 도달로 기록하면 실제보다 수치가 높게 집계될 수 있다고 판단했습니다.</p>
<p>그래서 <code>IntersectionObserver</code>의 <code>rootMargin</code>을 사용해 <strong>섹션이 화면 중앙 영역에 진입했을 때</strong> 도달 이벤트를 기록했습니다.</p>
<pre><code class="language-tsx">const observer = new IntersectionObserver(
  ([entry]) =&gt; {
    if (!entry || !entry.isIntersecting || hasTracked.current) {
      return;
    }

    hasTracked.current = true;

    trackAmplitudeEvent(ANALYTICS_EVENT.SECTION_VIEWED, {
      section_name: sectionName,
      section_order: sectionOrder,
      visibility_ratio: entry.intersectionRatio,
    });

    observer.disconnect();
  },
  {
    threshold: 0,
    rootMargin: &quot;-40% 0px -40% 0px&quot;,
  }
);</code></pre>
<p>같은 섹션을 여러 번 오가더라도 중복으로 기록되지 않도록 <code>hasTracked</code>를 사용했고, 한 번 기록한 뒤에는 Observer를 정리했습니다.</p>
<p>모든 이벤트에는 현재 경로, 화면 너비, 기기 유형도 공통으로 추가했습니다.</p>
<pre><code class="language-tsx">amplitude.track(eventName, {
  ...properties,
  pathname: window.location.pathname,
  viewport_width: window.innerWidth,
  device_type: getDeviceType(),
});</code></pre>
<p>모바일과 데스크톱은 스크롤 길이와 화면 구성이 다르기 때문에 이후 기기별 탐색 흐름을 나누어 확인할 수 있도록 함께 수집했습니다.</p>
<h2 id="첫-번째-사용자-흐름-측정">첫 번째 사용자 흐름 측정</h2>
<p>수집한 이벤트를 기준으로 다음과 같은 퍼널을 만들었습니다.</p>
<pre><code>포트폴리오 방문
→ 핵심 역량 영역 도달
→ Contact 영역 도달</code></pre><p>2026년 8월 6일 기준 첫 번째 측정 결과는 다음과 같았습니다.</p>
<p>핵심 역량 영역에 도달한 400명 중 Contact까지 내려간 사용자는 216명으로, <strong>54.0%</strong>였습니다.</p>
<p>Amplitude를 적용하기 전에는 포트폴리오가 길다는 피드백을 받아도 실제로 얼마나 많은 사용자가 중간과 마지막까지 확인하는지 알 수 없었습니다.</p>
<p>측정 이후에는 단순 방문자 수가 아니라 <strong>섹션별 도달과 외부 링크 클릭을 기준으로 사용자 탐색 흐름을 확인할 수 있게 됐습니다.</strong></p>
<p>다만 이 데이터만으로 사용자가 왜 이탈했는지까지 알 수 있는 것은 아닙니다. 따라서 특정 구간이 문제라고 단정하기보다, 정성적인 피드백과 함께 <strong>어떤 구조를 우선적으로 수정할지 판단하는 기준</strong>으로 사용했습니다.</p>
<h2 id="첫-번째-측정-결과">첫 번째 측정 결과</h2>
<ul>
<li>전체 방문자 <strong>653명</strong></li>
<li>핵심 역량 영역 도달 사용자 <strong>400명, 61.3%</strong></li>
<li>Contact 도달 사용자 <strong>216명, 33.1%</strong></li>
<li>핵심 역량 도달 사용자 중 Contact 도달률 <strong>54.0%</strong></li>
</ul>
<p>단순히 방문자 수만 확인하던 상태에서, 실제 사용자가 어느 구간까지 탐색했는지 확인할 수 있는 측정 환경을 구축했습니다.</p>
<h2 id="이후-실제로-정보-구조를-다시-설계했습니다">이후 실제로 정보 구조를 다시 설계했습니다.</h2>
<p>첫 번째 분석 이후에는 데이터를 확인하는 데서 끝내지 않고, 실제 포트폴리오 구조를 다시 살펴봤습니다.</p>
<p>관련 pr: <a href="https://github.com/bom0320/web-portfolio/pull/11">https://github.com/bom0320/web-portfolio/pull/11</a></p>
<p>초기에는 제가 작업하는 방식과 여러 프로젝트를 <strong>하나의 긴 서사 흐름 안에서 이어서 보여주는 구조</strong>로 구성했습니다.</p>
<p>하지만 현업자 피드백을 받는 과정에서 여러 프로젝트가 하나의 흐름 안에 묶여 있어, <strong>사용자가 원하는 프로젝트를 빠르게 탐색하기 어렵다</strong>는 문제를 확인했습니다.</p>
<p>이를 반영해 <code>Build</code>와 <code>Projects</code>를 분리하고, <strong>프로젝트를 독립적으로 탐색할 수 있도록 IA(정보 구조)를 재설계했습니다.</strong></p>
<p>재설계 이후에는 <code>Intro</code>, <code>Build</code>, <code>Projects</code>, <code>Contact</code>를 각각 독립적으로 측정할 수 있도록 섹션 이벤트 구조도 다시 정리했습니다.</p>
<blockquote>
<p>IA 재설계 이후 · 2026.08.01~2026.08.31 섹션별 일별 고유 사용자 추이</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/006e06c3-c6d7-43fe-b2fc-3895f658e27d/image.png" alt=""></p>
<h2 id="변경-이후-사용자-행동을-다시-검증했습니다">변경 이후 사용자 행동을 다시 검증했습니다.</h2>
<p>정보 구조를 변경한 뒤에는 단순히 “더 좋아진 것 같다”고 판단하지 않고, <strong>동일한 측정 기준으로 변경 전후의 사용자 행동을 다시 비교했습니다.</strong></p>
<p>Amplitude 이벤트 수집을 시작한 <strong>2026.07.14부터 08.31까지 <code>portfolio_viewed</code>를 1회 이상 발생시킨 중복 제거 고유 사용자는 877명</strong>이었습니다.</p>
<p>이 데이터를 기반으로 정보 구조 개편 전후의 Contact 도달률을 비교한 결과는 다음과 같았습니다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>변경 전</th>
<th>변경 후</th>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>Contact 도달률</td>
<td>39.6%</td>
<td><strong>48.3%</strong></td>
<td><strong>+8.7%p</strong></td>
</tr>
</tbody></table>
<p>정보 구조를 재설계한 이후 Contact까지 도달한 사용자 비율은 <strong>39.6%에서 48.3%로 8.7%p 증가</strong>했습니다.</p>
<p>초기 분석에서 확인한 <code>33.1%</code>는 2026년 8월 6일 시점의 첫 번째 퍼널 결과이고, <code>39.6% → 48.3%</code>는 이후 IA 변경 효과를 확인하기 위해 <strong>동일한 조건으로 개편 전후를 비교한 별도의 지표</strong>입니다.</p>
<p>따라서 두 수치를 직접 비교하지 않고 각각 <strong>초기 사용자 흐름을 파악하기 위한 기준</strong>과 <strong>정보 구조 변경 효과를 검증하기 위한 기준</strong>으로 사용했습니다.</p>
<h2 id="결과">결과</h2>
<p>처음 Amplitude를 도입한 목적은 단순히 방문자 수를 더 자세히 확인하기 위해서가 아니었습니다.</p>
<p><strong>사용자가 실제로 어디까지 탐색하는지 확인할 기준을 만들고, 변경한 경험이 실제 사용자 행동에서는 어떻게 나타나는지 다시 확인하기 위해서였습니다.</strong></p>
<ul>
<li>섹션 도달·외부 링크 클릭 등 필요한 행동을 커스텀 이벤트로 정의</li>
<li>화면 중앙 영역 진입을 기준으로 섹션 도달을 측정</li>
<li>초기 <strong>653명의 사용자 흐름을 분석해 탐색 흐름의 기준선 확보</strong></li>
<li>현업자 피드백을 반영해 <code>Build</code>와 <code>Projects</code>를 분리하고 IA 재설계</li>
<li><strong>2026.07.14~08.31 동안 877명의 중복 제거 고유 사용자 행동 분석</strong></li>
<li>정보 구조 개편 이후 <strong>Contact 도달률 39.6% → 48.3%, +8.7%p 개선</strong></li>
</ul>
<p>처음에는 사용자의 탐색 흐름을 확인하기 위한 측정 환경을 만드는 것에서 시작했지만, 이후에는 그 데이터를 실제 구조 변경과 검증까지 연결하게 됐습니다.</p>
<blockquote>
<p><strong>정성적인 피드백만으로 판단하는 데서 끝나지 않고, 피드백 → 구조 변경 → 사용자 행동 검증으로 이어지는 반복 개선 과정으로 확장했습니다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[GSAP ScrollTrigger 아키텍처: 스크롤 장면이 늘어나도 유지보수 가능한 구조 만들기]]></title>
            <link>https://velog.io/@bom_0320/GSAP-ScrollTrigger-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%9E%A5%EB%A9%B4%EC%9D%B4-%EB%8A%98%EC%96%B4%EB%82%98%EB%8F%84-%EC%9C%A0%EC%A7%80%EB%B3%B4%EC%88%98-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B5%AC%EC%A1%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@bom_0320/GSAP-ScrollTrigger-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%9E%A5%EB%A9%B4%EC%9D%B4-%EB%8A%98%EC%96%B4%EB%82%98%EB%8F%84-%EC%9C%A0%EC%A7%80%EB%B3%B4%EC%88%98-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B5%AC%EC%A1%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 06 Aug 2026 08:14:46 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>DOM 탐색, Animation 생성, ScrollTrigger 연결과 cleanup의 책임을 분리하고, 모든 Stage가 같은 실행 순서를 따르도록 구조화한 과정</p>
</blockquote>
<h2 id="관련-링크">관련 링크</h2>
<ul>
<li><a href="https://kimbom.dev">Portfolio</a></li>
<li><a href="https://github.com/bom0320/web-portfolio">GitHub Repository</a></li>
<li><a href="https://github.com/bom0320/web-portfolio/pull/11">관련 Pull Request</a></li>
</ul>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>이 포트폴리오는 <code>Intro</code>, <code>Build</code>, <code>Projects</code>, <code>Contact</code>처럼 여러 Stage가 연결된 스크롤 기반 페이지입니다.</p>
<p>각 Stage 안에는 사용자의 스크롤 위치에 따라 실행되는 여러 Animation이 존재합니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/a648fa02-1734-4e1f-aa05-22e4ae082d2d/image.png" alt=""></p>
<pre><code>IntroStage
→ 첫 화면과 About 영역 전환

BuildStage
→ Structure, AI, Visual 콘텐츠 reveal

ProjectsStage
→ 프로젝트 목록과 미리보기 전환

ContactStage
→ Contact 메시지와 Footer reveal</code></pre><p>초기에는 각 Stage에 필요한 Animation을 빠르게 구현하는 데 집중했습니다.</p>
<p>Stage 전용 Hook 안에서 움직일 DOM을 찾고, Animation을 생성하고, ScrollTrigger를 등록한 뒤 cleanup까지 처리했습니다.</p>
<pre><code>Stage Hook
├─ DOM 탐색
├─ Animation 생성
├─ progress 초기화
├─ ScrollTrigger 등록
└─ cleanup</code></pre><p>장면이 적을 때는 한 파일 안에서 전체 실행 흐름을 확인할 수 있어 편했습니다.</p>
<p>하지만 Stage와 Animation이 늘어나면서 Hook마다 관리 방식이 조금씩 달라지기 시작했습니다.</p>
<ul>
<li>어떤 Hook은 내부에서 <code>querySelector</code>를 직접 호출했습니다.</li>
<li>어떤 Animation은 GSAP Timeline을 반환하고, 다른 Animation은 Controller를 반환했습니다.</li>
<li>초기 progress를 설정하는 위치가 Stage마다 달랐습니다.</li>
<li>ScrollTrigger를 등록하는 방법도 서로 달랐습니다.</li>
<li>어떤 Animation이 역재생되는지, 한 번 나타난 뒤 유지되는지 코드를 끝까지 읽어야 알 수 있었습니다.</li>
<li>Trigger와 Timeline을 누가 제거해야 하는지도 명확하지 않았습니다.</li>
</ul>
<p>문제는 단순히 코드가 반복된다는 점이 아니었습니다.</p>
<p>새로운 장면을 추가하거나 기존 Animation을 수정할 때마다 다음 내용을 다시 확인해야 했습니다.</p>
<pre><code>DOM은 어디에서 찾는가?
Animation은 어디에서 생성하는가?
초기 상태는 어디에서 설정하는가?
ScrollTrigger는 어디에서 등록하는가?
종료할 때 어떤 객체를 제거해야 하는가?</code></pre><p>그래서 모든 Stage Animation이 같은 책임과 실행 순서를 따르도록 구조를 다시 설계했습니다.</p>
<hr>
<h2 id="1-먼저-실행-순서를-하나로-정했습니다">1. 먼저 실행 순서를 하나로 정했습니다</h2>
<p>파일을 나누기 전에 모든 Stage Animation이 따라야 하는 실행 순서부터 정했습니다.</p>
<pre><code>Stage root 참조
→ Stage 내부 DOM 수집
→ Animation Controller 생성
→ 초기 progress 설정
→ ScrollTrigger 등록
→ Controller에 progress 전달
→ Trigger와 Controller 정리</code></pre><p>이 실행 순서를 파일의 책임으로 바꾸면 다음과 같습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/5fd667da-8508-4988-a85e-2966691c1a34/image.png" alt=""></p>
<pre><code>Stage.tsx
→ Stage root와 Scene 구성

get*StageElements.ts
→ Stage 내부의 큰 DOM 구간 수집

StageControllers.ts
→ Animation Controller 생성·초기화·정리

use*StageAnimation.ts
→ ScrollTrigger 등록과 progress 연결

.animation.ts
→ GSAP Timeline과 실제 움직임 정의</code></pre><p>중요한 점은 코드를 단순히 여러 파일로 나눈 것이 아닙니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/bbf0f305-8823-4d72-86df-71d13c2b2b61/image.png" alt=""></p>
<p>각 파일이 답해야 하는 질문을 하나씩 정했습니다.</p>
<pre><code>Stage의 시작점은 어디인가?
→ Stage.tsx

어떤 DOM이 필요한가?
→ get*StageElements.ts

어떤 Animation을 생성해야 하는가?
→ *StageControllers.ts

스크롤과 Animation을 어떻게 연결하는가?
→ use*StageAnimation.ts

요소가 실제로 어떻게 움직이는가?
→ *.animation.ts</code></pre><hr>
<h2 id="2-stage-animation을-위한-10가지-규칙">2. Stage Animation을 위한 10가지 규칙</h2>
<p>Stage마다 구현 방식이 다시 달라지지 않도록 다음 규칙을 기준으로 삼았습니다.</p>
<pre><code>1. Stage root는 React ref로 잡는다.

2. 정적인 Animation target은
   Stage Hook 내부에서 직접 querySelector 하지 않는다.

3. Stage 단위 DOM 탐색은
   get*StageElements.ts에서 담당한다.

4. Animation 생성은
   create*StageControllers()에서 담당한다.

5. 모든 Animation Controller는
   setProgress()와 destroy()를 제공한다.

6. ScrollTrigger 등록은
   use*StageAnimation.ts에서 담당한다.

7. 역재생되는 장면은
   registerProgressTrigger()를 사용한다.

8. 한 번 나타난 뒤 유지되는 장면은
   registerMaxProgressTrigger()를 사용한다.

9. pin, activeIndex, progress split처럼
   동작이 다른 경우에는
   직접 createScrollTrigger()를 사용한다.

10. ScrollTrigger는 trigger.kill()로,
    Animation은 destroy*StageControllers()로 정리한다.</code></pre><p>이 규칙을 통해 새로운 Stage를 만들거나 기존 Stage를 수정할 때도 파일의 위치와 실행 순서를 예측할 수 있도록 했습니다.</p>
<hr>
<h2 id="3-stage-root는-react-ref로-관리합니다">3. Stage root는 React ref로 관리합니다</h2>
<p>Stage는 하나의 큰 스크롤 구간입니다.</p>
<pre><code class="language-tsx">export default function BuildStage() {
  const stageRef = useRef&lt;HTMLElement | null&gt;(null);

  useBuildStageAnimation(stageRef);

  return (
    &lt;section ref={stageRef}&gt;
      &lt;BuildIntroScene /&gt;
      &lt;BuildExperienceScene /&gt;
    &lt;/section&gt;
  );
}</code></pre>
<p>Stage 컴포넌트는 다음 역할만 담당합니다.</p>
<pre><code>Stage root ref 생성
Scene 배치
Stage Animation Hook 호출</code></pre><p>반대로 Stage 컴포넌트 안에서는 다음 작업을 하지 않습니다.</p>
<pre><code>querySelector를 통한 DOM 탐색
GSAP Timeline 생성
ScrollTrigger 등록
Animation cleanup</code></pre><p>Stage root는 이미 <code>stageRef.current</code>로 직접 참조할 수 있기 때문에 별도의 JavaScript용 selector를 만들지 않았습니다.</p>
<pre><code>Stage root
→ React ref로 참조

Stage 내부 요소
→ selector를 통해 탐색</code></pre><p>이를 통해 Stage 컴포넌트는 화면의 큰 구조를 표현하는 역할에 집중하도록 했습니다.</p>
<hr>
<h2 id="4-스타일-class와-animation-selector를-분리했습니다">4. 스타일 class와 Animation selector를 분리했습니다</h2>
<p>Stage 내부에서 JavaScript가 찾아야 하는 Animation target에는 <code>.js-*</code> class를 사용했습니다.</p>
<pre><code class="language-tsx">&lt;section className=&quot;build-intro-scene js-build-intro&quot;&gt;</code></pre>
<p>두 class는 서로 다른 역할을 가집니다.</p>
<pre><code>build-intro-scene
→ SCSS 스타일을 위한 class

js-build-intro
→ JavaScript DOM 탐색을 위한 class</code></pre><p>스타일용 class와 Animation target class를 함께 사용하면 스타일 구조를 변경했을 때 Animation selector까지 깨질 수 있습니다.</p>
<p>따라서 다음 규칙을 적용했습니다.</p>
<pre><code>스타일 변경
→ BEM class 수정

Animation 대상 변경
→ js-* selector 수정</code></pre><p>Stage root는 React ref로 직접 참조하므로 <code>.js-*</code> class가 필요하지 않습니다.</p>
<pre><code class="language-tsx">&lt;section ref={stageRef}&gt;</code></pre>
<p>반면 Stage 내부에서 <code>querySelector</code>로 찾아야 하는 요소에만 <code>.js-*</code> class를 사용합니다.</p>
<hr>
<h2 id="5-hook-안에서-정적인-dom을-직접-찾지-않습니다">5. Hook 안에서 정적인 DOM을 직접 찾지 않습니다</h2>
<p>기존에는 Stage Hook에서 필요한 DOM을 직접 찾을 수 있었습니다.</p>
<pre><code class="language-tsx">const intro = stage.querySelector(&quot;.js-build-intro&quot;);
const structure = stage.querySelector(&quot;.js-build-structure&quot;);
const ai = stage.querySelector(&quot;.js-build-ai&quot;);</code></pre>
<p>이 방식에서는 Hook이 두 가지 책임을 동시에 가집니다.</p>
<pre><code>Stage의 DOM 구조 파악
+
스크롤 실행 흐름 관리</code></pre><p>selector가 변경되면 ScrollTrigger를 관리하는 Hook까지 수정해야 하고, Hook을 읽을 때도 DOM 탐색과 Trigger 등록 코드가 섞여 보입니다.</p>
<p>그래서 Stage 단위 DOM 탐색을 별도 함수로 분리했습니다.</p>
<pre><code class="language-tsx">const elements = getBuildStageElements(stage);</code></pre>
<pre><code class="language-tsx">export function getBuildStageElements(stage: HTMLElement) {
  return {
    intro: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.intro
    ),

    introProof: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.introProof
    ),

    structure: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.structure
    ),

    ai: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.ai
    ),

    visual: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.visual
    ),
  };
}</code></pre>
<p>이제 Stage Hook은 selector 문자열이나 실제 class 이름을 알 필요가 없습니다.</p>
<pre><code class="language-tsx">elements.intro
elements.structure
elements.ai
elements.visual</code></pre>
<p>이미 수집된 요소를 장면 이름으로 사용하면 됩니다.</p>
<pre><code>DOM 구조 또는 selector 변경
→ Stage Elements와 selector 수정

스크롤 시작·종료 위치 변경
→ Stage Hook 또는 Scroll Config 수정</code></pre><p>서로 다른 이유로 변경되는 코드를 다른 파일로 분리한 것입니다.</p>
<hr>
<h2 id="6-stage-elements와-scene-dom-helper의-탐색-범위를-구분했습니다">6. Stage Elements와 Scene DOM Helper의 탐색 범위를 구분했습니다</h2>
<p>Stage Animation을 구성하려면 서로 다른 범위의 DOM이 필요합니다.</p>
<p>첫 번째는 ScrollTrigger의 기준이 되는 <strong>Scene 단위의 큰 구간</strong>입니다.</p>
<pre><code>BuildStage
├─ Intro
├─ Intro Proof
├─ Structure
├─ AI
└─ Visual</code></pre><p>두 번째는 GSAP Timeline이 실제로 움직일 <strong>Scene 내부의 세부 요소</strong>입니다.</p>
<pre><code>AI Scene
├─ Header
├─ Grid
├─ Cards
├─ Card Icons
├─ Card Subtitles
├─ Card Titles
├─ Card Messages
└─ Card Descriptions</code></pre><p>두 종류의 DOM을 Stage Hook에서 모두 찾으면, Hook이 ScrollTrigger 흐름뿐 아니라 각 Scene 내부의 카드와 텍스트 구조까지 알아야 합니다.</p>
<p>예를 들어 Hook 안에서 다음 요소를 모두 직접 찾는 구조가 될 수 있습니다.</p>
<pre><code class="language-tsx">const ai = stage.querySelector(&quot;.js-build-ai&quot;);

const aiHeader = ai?.querySelector(
  &quot;.js-build-ai-header&quot;
);

const aiCards = ai?.querySelectorAll(
  &quot;.js-build-ai-card&quot;
);</code></pre>
<p>이 경우 AI Scene 내부의 마크업이나 selector가 변경될 때, 스크롤 실행 흐름을 관리하는 Stage Hook까지 함께 수정해야 합니다.</p>
<p>그래서 DOM 탐색을 두 단계로 나눴습니다.</p>
<hr>
<h3 id="stage-elements-큰-scene-root를-수집합니다">Stage Elements: 큰 Scene root를 수집합니다</h3>
<p>Stage Hook에서 필요한 것은 각 Animation의 세부 요소가 아니라, ScrollTrigger의 기준이 되는 큰 구간입니다.</p>
<pre><code class="language-tsx">const elements =
  getBuildStageElements(stage);</code></pre>
<p><code>getBuildStageElements()</code>는 BuildStage 내부에서 각 Scene의 root DOM만 수집합니다.</p>
<pre><code class="language-tsx">export function getBuildStageElements(
  stage: HTMLElement
): BuildStageElements {
  return {
    intro: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.intro
    ),

    introProof:
      stage.querySelector&lt;HTMLElement&gt;(
        BUILD_STAGE_SELECTORS.introProof
      ),

    structure:
      stage.querySelector&lt;HTMLElement&gt;(
        BUILD_STAGE_SELECTORS.structure
      ),

    ai: stage.querySelector&lt;HTMLElement&gt;(
      BUILD_STAGE_SELECTORS.ai
    ),

    visual:
      stage.querySelector&lt;HTMLElement&gt;(
        BUILD_STAGE_SELECTORS.visual
      ),
  };
}</code></pre>
<p>반환된 <code>elements</code>는 다음과 같은 구조를 가집니다.</p>
<pre><code>elements
├─ intro
├─ introProof
├─ structure
├─ ai
└─ visual</code></pre><p>여기서 <code>elements.ai</code>는 AI 내부의 카드나 아이콘이 아니라, <strong>AI Scene 전체를 감싸는 root DOM 하나</strong>입니다.</p>
<pre><code>elements.ai
→ AI Scene root HTMLElement</code></pre><p>Stage Hook은 이 큰 구간을 ScrollTrigger의 기준으로 사용합니다.</p>
<pre><code class="language-tsx">registerMaxProgressTrigger({
  triggerElement: elements.ai,
  config: scrollConfig.ai,
  controller: controllers.ai,
  registerTrigger,
});</code></pre>
<hr>
<h3 id="scene-dom-helper-root-내부의-세부-요소를-수집합니다">Scene DOM Helper: root 내부의 세부 요소를 수집합니다</h3>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/5bb164fd-3dc4-4f72-bc23-4d6d4a68822f/image.png" alt="">
GSAP Animation에는 Scene root만으로는 부족합니다.</p>
<p>AI Animation은 제목, Grid, 카드, 아이콘과 같이 실제로 움직일 세부 요소가 필요합니다.</p>
<p>이 요소들은 Stage Hook이 아니라 AI Scene의 DOM Helper에서 수집합니다.</p>
<pre><code class="language-tsx">getBuildAIAnimationElements(
  elements.ai
);</code></pre>
<p><code>elements.ai</code>를 <code>root</code>로 전달하고, 해당 root 내부에서만 세부 요소를 탐색합니다.</p>
<pre><code class="language-tsx">// src/components/scenes/build/dom/experience/buildExperience.selectors.ts

export const getBuildAIAnimationElements = (
  root: HTMLElement | null
): BuildAIAnimationElements =&gt; {
  const selectors =
    BUILD_EXPERIENCE_SELECTORS.ai;

  return {
    root,

    header: queryElement(
      root,
      selectors.header
    ),

    grid: queryElement(
      root,
      selectors.grid
    ),

    cards: queryElements(
      root,
      selectors.cards
    ),

    cardIcons: queryElements(
      root,
      selectors.cardIcons
    ),

    cardSubtitles: queryElements(
      root,
      selectors.cardSubtitles
    ),

    cardTitles: queryElements(
      root,
      selectors.cardTitles
    ),

    cardMessages: queryElements(
      root,
      selectors.cardMessages
    ),

    cardDescs: queryElements(
      root,
      selectors.cardDescs
    ),
  };
};</code></pre>
<p>탐색 범위는 다음처럼 제한됩니다.</p>
<pre><code>BuildStage 전체
└─ elements.ai
   ├─ header
   ├─ grid
   ├─ cards
   ├─ cardIcons
   ├─ cardSubtitles
   ├─ cardTitles
   ├─ cardMessages
   └─ cardDescs</code></pre><p>즉 AI Animation과 관련된 selector는 BuildStage 전체가 아니라, <code>elements.ai</code> 내부에서만 검색합니다.</p>
<hr>
<h3 id="stage-controller-파일이-두-탐색-단계를-연결합니다">Stage Controller 파일이 두 탐색 단계를 연결합니다</h3>
<p>Stage Elements와 Scene DOM Helper는 <code>createBuildStageControllers()</code>에서 연결됩니다.</p>
<pre><code class="language-tsx">// src/components/stages/hooks/helpers/build/buildStageControllers.ts

export function createBuildStageControllers(
  elements: BuildStageElements
) {
  return {
    ai: BuildAIAnimation.create(
      getBuildAIAnimationElements(
        elements.ai
      )
    ),
  };
}</code></pre>
<p>이 코드는 안쪽부터 다음 순서로 실행됩니다.</p>
<pre><code>1. elements.ai
   → AI Scene의 큰 root DOM

2. getBuildAIAnimationElements(elements.ai)
   → AI 내부의 세부 DOM 수집

3. BuildAIAnimation.create(animationElements)
   → 수집한 요소로 GSAP Timeline 생성

4. controllers.ai
   → 생성된 Animation Controller 저장</code></pre><p>전체 흐름을 정리하면 다음과 같습니다.</p>
<pre><code>getBuildStageElements(stage)
→ elements.ai
→ getBuildAIAnimationElements(elements.ai)
→ BuildAIAnimationElements
→ BuildAIAnimation.create(...)
→ controllers.ai</code></pre><p><code>buildStageControllers.ts</code>는 Stage에서 수집한 큰 root DOM과 실제 Animation을 연결하는 중간 계층입니다.</p>
<hr>
<h3 id="각-계층이-알고-있는-범위">각 계층이 알고 있는 범위</h3>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/8ba9058d-a83a-4a99-942a-fc86641258cf/image.png" alt=""></p>
<pre><code>Stage Hook
→ Intro, Structure, AI, Visual 같은 큰 구간
→ ScrollTrigger 기준과 progress 연결

Stage Elements
→ 각 Scene의 root DOM

Scene DOM Helper
→ Scene 내부의 카드·아이콘·텍스트 등 세부 요소

Animation
→ 전달받은 세부 요소가 실제로 움직이는 방식</code></pre><p>Stage Hook은 <code>AI</code>라는 큰 구간과 <code>controllers.ai</code>만 다룹니다.</p>
<pre><code class="language-tsx">triggerElement: elements.ai,
controller: controllers.ai,</code></pre>
<p>반면 AI 내부에 어떤 카드와 아이콘이 존재하는지는 Scene DOM Helper와 AI Animation만 알고 있습니다.</p>
<p>이 구조를 통해 Scene 내부의 세부 마크업을 Stage Hook이 직접 알지 않도록 탐색 책임을 분리했습니다.</p>
<p>카드나 텍스트 selector가 변경되는 경우에는 주로 Scene DOM Helper를 수정하고, ScrollTrigger의 시작점이나 progress 정책이 변경되는 경우에는 Stage Hook과 Scroll Config를 수정할 수 있습니다.</p>
<p>다만 AI Scene의 root selector 자체가 변경된다면 <code>getBuildStageElements()</code>도 함께 수정해야 합니다. 즉 모든 변경의 영향을 완전히 제거한 것이 아니라, <strong>Scene 내부 세부 구조와 Stage 단위 스크롤 흐름의 의존 범위를 분리한 것</strong>입니다.</p>
<hr>
<h2 id="7-animation-생성은-stage-controller-파일에서-담당합니다">7. Animation 생성은 Stage Controller 파일에서 담당합니다</h2>
<p>Stage Hook에서 각 Animation을 직접 생성하면 Hook이 개별 Animation의 이름과 필요한 DOM 구조까지 모두 알아야 합니다.</p>
<pre><code class="language-tsx">const introController =
  BuildIntroAnimation.create(...);

const structureController =
  BuildStructureAnimation.create(...);

const aiController =
  BuildAIAnimation.create(...);</code></pre>
<p>Animation이 늘어날수록 Hook의 생성 코드도 함께 길어집니다.</p>
<p>그래서 Stage에서 사용하는 Controller 집합을 별도 함수에서 생성합니다.</p>
<pre><code class="language-tsx">const controllers =
  createBuildStageControllers(elements);</code></pre>
<pre><code class="language-tsx">// src/components/stages/hooks/helpers/build/buildStageControllers.ts

export function createBuildStageControllers(
  elements: BuildStageElements
) {
  return {
    intro: BuildIntroAnimation.create(
      getBuildIntroAnimationElements(elements.intro)
    ),

    introProof: BuildIntroProofAnimation.create(
      getBuildIntroProofAnimationElements(elements.introProof)
    ),

    structure: BuildStructureAnimation.create(
      getBuildStructureAnimationElements(elements.structure)
    ),

    ai: BuildAIAnimation.create(
      getBuildAIAnimationElements(elements.ai)
    ),

    visual: BuildVisualAnimation.create(
      getBuildVisualAnimationElements(elements.visual)
    ),
  };
}</code></pre>
<p>이 파일은 Stage에서 수집한 큰 DOM과 실제 Animation을 연결하는 역할을 합니다.</p>
<pre><code>elements.ai
→ AI 내부의 세부 DOM 수집
→ BuildAIAnimation 생성
→ AI Controller 반환</code></pre><p>Stage Hook은 각 Animation이 어떤 세부 요소를 필요로 하는지 알 필요가 없습니다.</p>
<hr>
<h2 id="8-나뉜-파일들은-실제로-어떻게-연결되는가">8. 나뉜 파일들은 실제로 어떻게 연결되는가</h2>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/943b5dbb-9810-46c1-87c9-7661ff5e86ea/image.png" alt=""></p>
<p>파일은 역할에 따라 나뉘어 있지만, 실행될 때는 앞 단계에서 만든 값을 다음 단계로 넘기며 하나의 흐름으로 연결됩니다.</p>
<p>AI 장면 하나만 따라가면 다음과 같습니다.</p>
<pre><code>BuildStage DOM
→ AI 장면 DOM
→ AI Animation Controller
→ ScrollTrigger progress
→ GSAP Timeline</code></pre><h3 id="1단계-buildstage-내부의-큰-장면을-찾습니다">1단계: BuildStage 내부의 큰 장면을 찾습니다</h3>
<pre><code class="language-tsx">const elements =
  getBuildStageElements(stage);</code></pre>
<p>이 함수가 반환한 객체에는 각 장면의 root DOM이 들어 있습니다.</p>
<pre><code class="language-tsx">elements.intro
elements.structure
elements.ai
elements.visual</code></pre>
<p>여기서 <code>elements.ai</code>는 AI 장면 전체를 감싸는 DOM입니다.</p>
<h3 id="2단계-ai-장면-내부의-세부-요소를-찾습니다">2단계: AI 장면 내부의 세부 요소를 찾습니다</h3>
<pre><code class="language-tsx">getBuildAIAnimationElements(
  elements.ai
);</code></pre>
<p>이 함수는 AI 장면 안에서 실제로 움직일 요소를 찾습니다.</p>
<pre><code>header
grid
cards
cardIcons
cardTitles
cardMessages
cardDescriptions</code></pre><h3 id="3단계-수집한-요소로-ai-animation을-만듭니다">3단계: 수집한 요소로 AI Animation을 만듭니다</h3>
<pre><code class="language-tsx">BuildAIAnimation.create(
  getBuildAIAnimationElements(elements.ai)
);</code></pre>
<p>안쪽부터 순서대로 실행하면 다음과 같습니다.</p>
<pre><code>elements.ai
→ AI 장면 전체 DOM

getBuildAIAnimationElements(elements.ai)
→ AI 내부의 세부 DOM 수집

BuildAIAnimation.create(...)
→ GSAP Timeline 생성

controllers.ai
→ Timeline을 제어하는 Controller</code></pre><h3 id="4단계-controller를-scrolltrigger에-연결합니다">4단계: Controller를 ScrollTrigger에 연결합니다</h3>
<pre><code class="language-tsx">registerMaxProgressTrigger({
  triggerElement: elements.ai,
  config: scrollConfig.ai,
  controller: controllers.ai,
  registerTrigger,
});</code></pre>
<p>여기서 각 값은 다음 역할을 가집니다.</p>
<pre><code>triggerElement
→ 어느 DOM을 기준으로 스크롤을 측정할지

config
→ 언제 시작하고 끝낼지

controller
→ 어느 Animation에 progress를 전달할지

registerTrigger
→ 생성한 Trigger를 cleanup 목록에 저장</code></pre><p>위 코드는 React props 전달이 아니라, ScrollTrigger 등록 함수에 필요한 값들을 하나의 객체로 전달한 것입니다.</p>
<h3 id="5단계-스크롤-진행률이-timeline까지-전달됩니다">5단계: 스크롤 진행률이 Timeline까지 전달됩니다</h3>
<p>사용자가 스크롤하면 ScrollTrigger가 현재 진행률을 계산합니다.</p>
<pre><code class="language-tsx">controller.setProgress(progress);</code></pre>
<p>Controller는 받은 값을 내부 Timeline에 적용합니다.</p>
<pre><code class="language-tsx">setProgress(progress) {
  timeline.progress(progress);
}</code></pre>
<p>전체 연결 흐름은 다음과 같습니다.</p>
<pre><code>stage
→ getBuildStageElements(stage)
→ elements.ai
→ getBuildAIAnimationElements(elements.ai)
→ BuildAIAnimation.create(...)
→ controllers.ai
→ controller.setProgress(progress)
→ timeline.progress(progress)</code></pre><p>파일은 나뉘어 있지만, 앞 단계에서 만든 값을 다음 단계에 전달하는 하나의 실행 흐름으로 이어집니다.</p>
<hr>
<h2 id="9-stage-hook에서-controller의-생명주기를-연결했습니다">9. Stage Hook에서 Controller의 생명주기를 연결했습니다</h2>
<p>Controller의 생성·초기화·정리 함수는 <code>buildStageControllers.ts</code>에 모아두고, 실제 실행 순서는 <code>useBuildStageAnimation.ts</code>에서 연결했습니다.</p>
<pre><code>buildStageControllers.ts
→ Controller를 어떻게 생성·초기화·정리할지 정의

useBuildStageAnimation.ts
→ 생성한 함수를 어떤 순서로 실행할지 연결</code></pre><p><code>BuildStage</code>의 ScrollTrigger를 설정하는 코드를 보면 Controller의 전체 생명주기가 하나의 흐름으로 드러납니다.</p>
<pre><code class="language-tsx">// src/components/stages/hooks/useBuildStageAnimation.ts

const setupBuildTriggers = (
  scrollConfig: BuildStageScrollConfig
) =&gt; {
  // 1. ScrollTrigger의 기준이 되는
  //    Stage 내부의 큰 DOM 구간을 수집합니다.
  const elements =
    getBuildStageElements(stage);

  // 2. 수집한 DOM을 각 Animation과 연결해
  //    BuildStage에서 사용할 Controller들을 생성합니다.
  const controllers =
    createBuildStageControllers(elements);

  // 3. Trigger를 등록하기 전에 모든 Animation을
  //    progress 0의 시작 상태로 초기화합니다.
  resetBuildStageControllers(controllers);

  const triggers: ScrollTriggerInstance[] = [];

  const registerTrigger = (
    trigger: ScrollTriggerInstance
  ) =&gt; {
    triggers.push(trigger);
  };

  // 4. 현재 스크롤 progress를 그대로 전달하는
  //    Intro Animation을 등록합니다.
  registerProgressTrigger({
    triggerElement:
      BUILD_STAGE_SELECTORS.introPinned,
    config: scrollConfig.intro,
    controller: controllers.intro,
    registerTrigger,
  });

  // 5. 한 번 나타난 뒤 유지되는 장면에는
  //    최대 progress를 전달하는 Trigger를 등록합니다.
  BUILD_STAGE_PROGRESS_KEYS.forEach((key) =&gt; {
    registerMaxProgressTrigger({
      triggerElement: elements[key],
      config: scrollConfig[key],
      controller: controllers[key],
      registerTrigger,
    });
  });

  refreshScrollTrigger();

  return () =&gt; {
    // 6. 이 setup에서 생성한 ScrollTrigger를 제거합니다.
    triggers.forEach((trigger) =&gt; {
      trigger.kill();
    });

    // 7. 모든 Controller를 정리해 내부 Timeline과
    //    Animation이 남긴 상태를 제거합니다.
    destroyBuildStageControllers(
      controllers
    );
  };
};</code></pre>
<p>전체 흐름을 압축하면 다음과 같습니다.</p>
<pre><code>DOM 수집
→ Controller 생성
→ progress 0으로 초기화
→ ScrollTrigger에서 Controller 사용
→ Trigger 제거
→ Controller 정리</code></pre><p>Controller 관점에서는 다음과 같은 대칭적인 생명주기를 가집니다.</p>
<pre><code>create
→ reset
→ use
→ destroy</code></pre><p><code>create</code>, <code>reset</code>, <code>destroy</code> 함수는 같은 <code>buildStageControllers.ts</code>에 배치했습니다.</p>
<pre><code class="language-tsx">// src/components/stages/hooks/helpers/build/buildStageControllers.ts

export function createBuildStageControllers(
  elements: BuildStageElements
) {
  // 각 Animation Controller 생성
}

export function resetBuildStageControllers(
  controllers: BuildStageControllers
) {
  // 각 Controller를 progress 0으로 초기화
}

export function destroyBuildStageControllers(
  controllers: BuildStageControllers
) {
  // 각 Controller의 destroy() 호출
}</code></pre>
<p>따라서 Controller를 하나 추가하면 생성·초기화·정리 대상을 한 파일에서 함께 확인할 수 있고, Stage Hook에서는 전체 실행 순서만 읽을 수 있습니다.</p>
<hr>
<h2 id="10-모든-animation-controller의-사용법을-통일했습니다">10. 모든 Animation Controller의 사용법을 통일했습니다</h2>
<p>Stage Hook에서 Controller들을 같은 방식으로 다루려면 각 Animation이 동일한 외부 규격을 제공해야 합니다.</p>
<p>기존에는 Animation에 따라 GSAP Timeline을 직접 반환하거나 별도의 객체를 반환할 수 있었습니다.</p>
<pre><code class="language-tsx">return timeline;</code></pre>
<pre><code class="language-tsx">return {
  setProgress,
  destroy,
};</code></pre>
<p>이렇게 반환 형식이 다르면 Stage Hook이 각 Animation의 내부 구현에 맞춰 사용법을 달리해야 합니다.</p>
<pre><code class="language-tsx">timeline.progress(progress);
timeline.kill();</code></pre>
<pre><code class="language-tsx">controller.setProgress(progress);
controller.destroy();</code></pre>
<p>그래서 모든 scroll-driven Animation이 다음 <code>AnimationController</code> 규격을 반환하도록 통일했습니다.</p>
<pre><code class="language-tsx">export type AnimationController = {
  setProgress: (progress: number) =&gt; void;
  destroy: () =&gt; void;
};</code></pre>
<p>GSAP Timeline을 직접 반환하던 Animation도 Controller로 감쌌습니다.</p>
<pre><code class="language-tsx">const timeline = gsap.timeline({
  paused: true,
});

return {
  setProgress(progress) {
    timeline.progress(
      clampProgress(progress)
    );
  },

  destroy() {
    timeline.kill();
  },
};</code></pre>
<p>Stage Hook은 각 Animation의 내부 Timeline 구조를 알 필요가 없습니다.</p>
<pre><code class="language-tsx">controller.setProgress(progress);
controller.destroy();</code></pre>
<p>외부에서는 두 가지 질문에만 답할 수 있으면 됩니다.</p>
<pre><code>현재 스크롤 진행률을 전달할 수 있는가?
종료할 때 생성한 Animation을 정리할 수 있는가?</code></pre><p>Animation 내부에서 Timeline을 몇 개 사용하는지, 어떤 easing과 stagger를 적용하는지, 어떤 요소를 움직이는지는 Controller 내부에 숨겨집니다.</p>
<hr>
<h2 id="11-scrolltrigger-등록은-stage-hook에서-한눈에-확인할-수-있습니다">11. ScrollTrigger 등록은 Stage Hook에서 한눈에 확인할 수 있습니다</h2>
<p>Animation 파일은 요소가 실제로 어떻게 움직이는지를 정의합니다.</p>
<pre><code>초기 opacity
이동 거리
Timeline 순서
stagger
easing</code></pre><p>반면 Stage Hook은 스크롤과 Animation을 연결하는 역할을 담당합니다.</p>
<pre><code>어떤 요소를 Trigger로 사용할지
언제 시작하고 끝낼지
scrub을 어떻게 적용할지
어떤 Controller에 progress를 전달할지
어떤 Trigger를 cleanup할지</code></pre><p><code>BuildStage</code>에서는 Controller를 생성하고 초기화한 뒤, 각 장면의 동작에 맞는 Trigger를 등록합니다.</p>
<pre><code class="language-tsx">const elements =
  getBuildStageElements(stage);

const controllers =
  createBuildStageControllers(elements);

resetBuildStageControllers(controllers);</code></pre>
<p>Intro는 현재 스크롤 progress와 계속 대응해야 하므로 일반 Progress Trigger를 사용합니다.</p>
<pre><code class="language-tsx">registerProgressTrigger({
  triggerElement:
    BUILD_STAGE_SELECTORS.introPinned,
  config: scrollConfig.intro,
  controller: controllers.intro,
  registerTrigger,
});</code></pre>
<p>한 번 나타난 뒤 유지되는 장면들은 동일한 key를 사용해 연결합니다.</p>
<pre><code class="language-tsx">BUILD_STAGE_PROGRESS_KEYS.forEach((key) =&gt; {
  registerMaxProgressTrigger({
    triggerElement: elements[key],
    config: scrollConfig[key],
    controller: controllers[key],
    registerTrigger,
  });
});</code></pre>
<p><code>key</code>가 <code>&quot;ai&quot;</code>라면 다음 값들이 하나의 장면으로 연결됩니다.</p>
<pre><code class="language-tsx">elements.ai
scrollConfig.ai
controllers.ai</code></pre>
<pre><code>elements.ai
→ 어떤 DOM을 기준으로 측정할지

scrollConfig.ai
→ 언제 시작하고 끝낼지

controllers.ai
→ 어느 Animation에 progress를 전달할지</code></pre><p>따라서 Stage Hook만 읽어도 해당 Stage에 어떤 장면이 있고, 각 장면에 어떤 스크롤 정책을 적용했는지 확인할 수 있습니다.</p>
<hr>
<h2 id="12-장면의-동작에-따라-trigger-등록-방식을-구분했습니다">12. 장면의 동작에 따라 Trigger 등록 방식을 구분했습니다</h2>
<p>스크롤을 위로 되돌렸을 때 모든 Animation이 같은 방식으로 움직여야 하는 것은 아닙니다.</p>
<p>다음 질문을 기준으로 Trigger의 종류를 구분했습니다.</p>
<blockquote>
<p>사용자가 이미 확인한 콘텐츠가 위로 스크롤할 때 다시 숨는 것이 자연스러운가?</p>
</blockquote>
<h3 id="스크롤-위치와-함께-역재생되는-장면">스크롤 위치와 함께 역재생되는 장면</h3>
<p>현재 스크롤 위치와 화면 상태가 계속 대응해야 하는 장면에는 현재 progress를 그대로 전달합니다.</p>
<pre><code>아래로 스크롤
0 → 0.5 → 1

위로 스크롤
1 → 0.5 → 0</code></pre><pre><code class="language-tsx">registerProgressTrigger({
  triggerElement,
  config,
  controller,
  registerTrigger,
});</code></pre>
<p>Helper 내부에서는 현재 ScrollTrigger progress가 그대로 전달됩니다.</p>
<pre><code class="language-tsx">onUpdate(self) {
  controller.setProgress(
    self.progress
  );
}</code></pre>
<h3 id="한-번-나타난-뒤-유지되는-장면">한 번 나타난 뒤 유지되는 장면</h3>
<p>Structure, AI, Visual처럼 사용자가 이미 확인한 정보는 위로 스크롤해도 다시 숨기지 않도록 했습니다.</p>
<pre><code>실제 progress
0 → 0.5 → 1 → 0.6 → 0.2

Controller에 전달되는 progress
0 → 0.5 → 1 → 1 → 1</code></pre><pre><code class="language-tsx">registerMaxProgressTrigger({
  triggerElement,
  config,
  controller,
  registerTrigger,
});</code></pre>
<p>내부에서는 지금까지 도달한 가장 큰 progress를 유지합니다.</p>
<pre><code class="language-tsx">let maxProgress = 0;

onUpdate(self) {
  maxProgress = Math.max(
    maxProgress,
    self.progress
  );

  controller.setProgress(
    maxProgress
  );
}</code></pre>
<p>Contact Stage에서도 두 동작을 명시적으로 구분했습니다.</p>
<pre><code class="language-tsx">registerMaxProgressTrigger({
  triggerElement: elements.intro,
  config:
    CONTACT_STAGE_SCROLL_CONFIG.intro,
  controller: controllers.intro,
  registerTrigger,
});

registerProgressTrigger({
  triggerElement: elements.footer,
  config:
    CONTACT_STAGE_SCROLL_CONFIG.footer,
  controller: controllers.footer,
  registerTrigger,
});</code></pre>
<p>코드를 읽는 사람은 개별 Timeline을 열어보지 않아도 장면의 동작을 파악할 수 있습니다.</p>
<pre><code>Contact Intro
→ 한 번 나타나면 유지

Contact Footer
→ 스크롤 방향에 따라 역재생</code></pre><p>Animation의 사용자 경험이 Trigger 등록 방식에 드러나도록 구성했습니다.</p>
<hr>
<h2 id="13-특수한-동작은-공통-helper에-억지로-넣지-않았습니다">13. 특수한 동작은 공통 Helper에 억지로 넣지 않았습니다</h2>
<p>모든 ScrollTrigger를 공통 Helper로 처리하지는 않았습니다.</p>
<p>다음처럼 단순히 progress를 Controller에 전달하는 역할만 필요하다면 공통 Helper를 사용합니다.</p>
<pre><code>ScrollTrigger progress
→ Controller.setProgress()</code></pre><p>하지만 일부 장면은 일반적인 progress 전달 외의 동작이 필요합니다.</p>
<h3 id="intro-stage">Intro Stage</h3>
<p>Intro에서는 viewport 높이를 기준으로 ScrollTrigger의 종료 지점을 계산합니다.</p>
<pre><code class="language-tsx">end: () =&gt;
  `+=${
    window.innerHeight *
    scrollLengthMultiplier
  }`</code></pre>
<p>또한 하나의 progress를 두 구간으로 나눠 서로 다른 Controller에 전달합니다.</p>
<pre><code class="language-tsx">const {
  enterProgress,
  sceneProgress,
} = getLifeToAboutProgress(
  self.progress
);

controllers.lifeToAbout.setProgress(
  enterProgress
);

controllers.aboutScene.setProgress(
  sceneProgress
);</code></pre>
<h3 id="projects-stage">Projects Stage</h3>
<p>Projects Navigator에서는 다음 동작이 함께 필요합니다.</p>
<pre><code>화면 pin
프로젝트 수에 따른 동적 end 계산
progress를 active index로 변환
React state 변경
미리보기 layer 전환
모바일에서 pin 비활성화</code></pre><p>이 동작까지 공통 Helper에 포함하면 다음과 같은 옵션과 callback이 계속 추가됩니다.</p>
<pre><code>pin 사용 여부
동적 end 함수
index 계산 함수
상태 변경 callback
layer 전환 callback
모바일 예외</code></pre><p>결국 Helper가 단순 progress 전달이라는 본래 책임을 벗어나게 됩니다.</p>
<p>그래서 다음 기준을 적용했습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/63298b30-d4b7-49cc-91d8-6e62f00995d1/image.png" alt=""></p>
<pre><code>현재 progress 전달
→ registerProgressTrigger()

최대 progress 유지
→ registerMaxProgressTrigger()

pin, activeIndex, progress split
→ 직접 createScrollTrigger()</code></pre><p>코드의 형태가 비슷한지를 기준으로 묶지 않고, 실제로 같은 책임을 가진 경우에만 공통 Helper를 사용했습니다.</p>
<hr>
<h2 id="14-데스크톱과-모바일은-같은-실행-흐름에-다른-설정을-주입했습니다">14. 데스크톱과 모바일은 같은 실행 흐름에 다른 설정을 주입했습니다</h2>
<p>스크롤 기반 인터랙션은 화면 크기에 따라 필요한 스크롤 거리와 반응 속도가 달라집니다.</p>
<p>그래서 데스크톱과 모바일에 별도의 Scroll Config를 만들었습니다.</p>
<pre><code class="language-tsx">const media = gsap.matchMedia();

media.add(&quot;(min-width: 901px)&quot;, () =&gt;
  setupBuildTriggers(
    BUILD_STAGE_DESKTOP_SCROLL_CONFIG
  )
);

media.add(&quot;(max-width: 900px)&quot;, () =&gt;
  setupBuildTriggers(
    BUILD_STAGE_MOBILE_SCROLL_CONFIG
  )
);</code></pre>
<p>두 화면에서 실행하는 과정은 같습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/627648a6-0770-4ba0-9e43-dd9bddef25df/image.png" alt=""></p>
<pre><code>DOM 수집
→ Controller 생성
→ 초기화
→ Trigger 등록
→ cleanup</code></pre><p>달라지는 것은 각 장면의 <code>start</code>, <code>end</code>, <code>scrub</code> 설정입니다.</p>
<pre><code>공통 실행 절차
→ setupBuildTriggers()

화면별 차이
→ Desktop / Mobile Scroll Config</code></pre><p>실행 로직을 데스크톱과 모바일용으로 각각 작성하지 않고, 달라지는 설정 객체만 <code>setupBuildTriggers()</code>에 전달했습니다.</p>
<p>Projects Navigator처럼 모바일에서 동일한 상호작용 방식 자체가 적절하지 않은 경우에는 설정값만 변경하지 않고 pin을 비활성화했습니다.</p>
<p>즉 반응형 대응을 단순한 수치 변경으로 끝내지 않고, 작은 화면에서도 같은 interaction이 필요한지 함께 판단했습니다.</p>
<hr>
<h2 id="15-cleanup은-자원을-생성한-계층이-담당하도록-했습니다">15. cleanup은 자원을 생성한 계층이 담당하도록 했습니다</h2>
<p>Stage Animation이 실행될 때는 여러 종류의 객체와 상태가 생성됩니다.</p>
<pre><code>ScrollTrigger 인스턴스
GSAP Timeline
GSAP inline style
matchMedia 조건
GSAP context</code></pre><p>각 자원은 서로 다른 계층에서 생성됩니다.</p>
<pre><code>ScrollTrigger
→ Stage Hook에서 생성

GSAP Timeline
→ Animation Controller에서 생성

matchMedia
→ Stage Hook에서 생성

GSAP context
→ useLayoutEffect에서 생성</code></pre><p>따라서 자원을 만든 계층이 해당 자원을 정리하도록 책임을 맞췄습니다.</p>
<h3 id="stage-hook이-생성한-scrolltrigger">Stage Hook이 생성한 ScrollTrigger</h3>
<p>공통 Helper가 생성한 Trigger는 <code>registerTrigger()</code>를 통해 Hook의 배열에 저장됩니다.</p>
<pre><code class="language-tsx">const triggers: ScrollTriggerInstance[] = [];

const registerTrigger = (
  trigger: ScrollTriggerInstance
) =&gt; {
  triggers.push(trigger);
};</code></pre>
<p>cleanup에서는 이 setup에서 생성한 Trigger만 제거합니다.</p>
<pre><code class="language-tsx">triggers.forEach((trigger) =&gt; {
  trigger.kill();
});</code></pre>
<h3 id="animation이-생성한-timeline">Animation이 생성한 Timeline</h3>
<p>각 Animation은 Controller의 <code>destroy()</code>에서 자신이 만든 Timeline을 제거합니다.</p>
<pre><code class="language-tsx">destroy() {
  timeline.kill();
}</code></pre>
<p>필요한 경우 Animation이 적용한 inline style도 함께 정리합니다.</p>
<pre><code class="language-tsx">gsap.set(elements, {
  clearProps: &quot;all&quot;,
});</code></pre>
<h3 id="stage-단위-cleanup">Stage 단위 cleanup</h3>
<p><code>setupBuildTriggers()</code>가 반환하는 cleanup에서는 Trigger와 Controller를 함께 정리합니다.</p>
<pre><code class="language-tsx">return () =&gt; {
  triggers.forEach((trigger) =&gt; {
    trigger.kill();
  });

  destroyBuildStageControllers(
    controllers
  );
};</code></pre>
<h3 id="반응형-조건과-gsap-scope-cleanup">반응형 조건과 GSAP scope cleanup</h3>
<p>화면 크기 조건은 <code>media.revert()</code>로 정리합니다.</p>
<pre><code class="language-tsx">return () =&gt; {
  media.revert();
};</code></pre>
<p>React Effect의 최종 cleanup에서는 GSAP context를 되돌립니다.</p>
<pre><code class="language-tsx">return () =&gt; {
  context.revert();
};</code></pre>
<p>실제 cleanup 호출 흐름은 바깥쪽에서 안쪽으로 이어집니다.</p>
<pre><code>context.revert()
→ media.revert()
→ setupBuildTriggers cleanup
   ├─ trigger.kill()
   └─ destroyBuildStageControllers()
      └─ controller.destroy()
         └─ timeline.kill()</code></pre><p>Stage Hook이 개별 Timeline의 내부 구현을 직접 알지 않아도, 각 계층이 자신이 생성한 자원을 정리할 수 있도록 구성했습니다.</p>
<hr>
<h2 id="16-파일-구조에-각-책임을-반영했습니다">16. 파일 구조에 각 책임을 반영했습니다</h2>
<p>최종적으로 Stage Animation 관련 파일은 다음 구조를 가집니다.</p>
<pre><code>components/
├─ stages/
│  ├─ BuildStage.tsx
│  │
│  ├─ constants/
│  │  ├─ buildStageKeys.ts
│  │  ├─ buildStageSelectors.ts
│  │  └─ buildStageScrollConfig.ts
│  │
│  └─ hooks/
│     ├─ useBuildStageAnimation.ts
│     │
│     └─ helpers/
│        └─ build/
│           ├─ buildStageElements.ts
│           ├─ buildStageControllers.ts
│           └─ index.ts
│
├─ scenes/
│  └─ build/
│     └─ dom/
│        ├─ intro/
│        └─ experience/
│
animations/
└─ build/
   ├─ intro/
   └─ experience/</code></pre><p>각 파일이 담당하는 역할은 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>파일</th>
<th>책임</th>
</tr>
</thead>
<tbody><tr>
<td><code>BuildStage.tsx</code></td>
<td>Stage root ref 생성과 Scene 구성</td>
</tr>
<tr>
<td><code>buildStageKeys.ts</code></td>
<td>같은 정책으로 처리할 장면 key 관리</td>
</tr>
<tr>
<td><code>buildStageSelectors.ts</code></td>
<td>Stage가 찾을 큰 구간 selector 관리</td>
</tr>
<tr>
<td><code>buildStageScrollConfig.ts</code></td>
<td>ScrollTrigger의 start·end·scrub 설정</td>
</tr>
<tr>
<td><code>buildStageElements.ts</code></td>
<td>Stage 단위의 큰 DOM 구간 수집</td>
</tr>
<tr>
<td><code>buildStageControllers.ts</code></td>
<td>Controller 생성·초기화·정리</td>
</tr>
<tr>
<td><code>useBuildStageAnimation.ts</code></td>
<td>전체 실행 순서와 ScrollTrigger 연결</td>
</tr>
<tr>
<td>Scene <code>dom</code> Helper</td>
<td>Animation에 필요한 세부 DOM 수집</td>
</tr>
<tr>
<td><code>*.animation.ts</code></td>
<td>GSAP Timeline과 실제 모션 정의</td>
</tr>
</tbody></table>
<p>폴더 구조는 파일을 종류별로 나열하기 위해 만든 것이 아닙니다.</p>
<p><strong>변경하려는 내용에 따라 어느 파일을 확인해야 하는지 알려주는 구조</strong>로 설계했습니다.</p>
<hr>
<h2 id="17-새로운-장면을-추가하는-순서도-예측할-수-있게-됐습니다">17. 새로운 장면을 추가하는 순서도 예측할 수 있게 됐습니다</h2>
<p>새로운 reveal 장면을 추가할 때는 다음 순서로 작업합니다.</p>
<pre><code>1. Scene에 Animation target 추가

2. Stage selector와 key 추가

3. get*StageElements()에서 큰 Scene root 수집

4. Scene DOM Helper에서 세부 요소 수집

5. Animation Controller 구현

6. create*StageControllers()에서 Animation 연결

7. reset과 destroy 대상에 추가

8. 장면의 동작에 맞는 Trigger 등록</code></pre><p>마지막 Trigger 등록 방식은 장면의 사용자 경험에 따라 선택합니다.</p>
<pre><code>스크롤 방향에 따라 역재생
→ registerProgressTrigger()

한 번 나타난 뒤 유지
→ registerMaxProgressTrigger()

pin, index 계산, progress 분할
→ 직접 createScrollTrigger()</code></pre><p>리팩토링 이전에는 새로운 장면을 추가할 때 기존 Hook의 구현 방식을 다시 읽고 따라야 했습니다.</p>
<p>리팩토링 이후에는 정해진 순서와 파일 책임에 따라 필요한 위치를 연결할 수 있게 됐습니다.</p>
<hr>
<h2 id="18-리팩토링-이후-달라진-점">18. 리팩토링 이후 달라진 점</h2>
<h3 id="stage마다-실행-순서가-같아졌습니다">Stage마다 실행 순서가 같아졌습니다</h3>
<pre><code>Stage root
→ Elements
→ Controllers
→ reset
→ ScrollTrigger
→ progress
→ cleanup</code></pre><p>어떤 Stage Hook을 열어도 같은 순서로 코드를 읽을 수 있습니다.</p>
<h3 id="변경할-위치를-예측할-수-있게-됐습니다">변경할 위치를 예측할 수 있게 됐습니다</h3>
<table>
<thead>
<tr>
<th>변경하려는 내용</th>
<th>수정 위치</th>
</tr>
</thead>
<tbody><tr>
<td>Stage root 또는 Scene 구성</td>
<td><code>Stage.tsx</code></td>
</tr>
<tr>
<td>Stage의 큰 구간 selector</td>
<td><code>*StageSelectors.ts</code></td>
</tr>
<tr>
<td>Stage 단위 DOM 탐색</td>
<td><code>*StageElements.ts</code></td>
</tr>
<tr>
<td>Animation과 DOM 연결</td>
<td><code>*StageControllers.ts</code></td>
</tr>
<tr>
<td>실제 모션 순서와 스타일</td>
<td><code>*.animation.ts</code></td>
</tr>
<tr>
<td>ScrollTrigger 시작·종료 지점</td>
<td><code>*StageScrollConfig.ts</code></td>
</tr>
<tr>
<td>역재생 또는 reveal 유지 여부</td>
<td>Trigger 등록 방식</td>
</tr>
<tr>
<td>pin·index·progress 분할</td>
<td><code>use*StageAnimation.ts</code></td>
</tr>
<tr>
<td>Timeline cleanup</td>
<td>Controller <code>destroy()</code></td>
</tr>
<tr>
<td>Trigger cleanup</td>
<td>Stage Hook</td>
</tr>
</tbody></table>
<h3 id="stage-hook이-animation-내부-구현을-몰라도-됩니다">Stage Hook이 Animation 내부 구현을 몰라도 됩니다</h3>
<p>Stage Hook은 모든 Animation을 같은 Controller 규격으로 사용합니다.</p>
<pre><code class="language-tsx">controller.setProgress(progress);
controller.destroy();</code></pre>
<p>Animation 내부의 Timeline 구조가 변경되더라도 Controller 규격이 유지된다면 Stage Hook의 사용 방식은 바뀌지 않습니다.</p>
<h3 id="장면의-동작이-trigger-등록-코드에-드러납니다">장면의 동작이 Trigger 등록 코드에 드러납니다</h3>
<pre><code>registerProgressTrigger
→ 스크롤과 함께 역재생

registerMaxProgressTrigger
→ 한 번 나타난 뒤 유지

createScrollTrigger
→ pin, activeIndex, progress split 등 특수 처리</code></pre><p>각 Animation 파일을 열지 않아도 Stage Hook에서 장면의 스크롤 행동을 확인할 수 있습니다.</p>
<h3 id="생성과-cleanup의-책임이-일치하게-됐습니다">생성과 cleanup의 책임이 일치하게 됐습니다</h3>
<pre><code>Hook이 만든 ScrollTrigger
→ Hook이 kill

Animation이 만든 Timeline
→ Controller가 destroy</code></pre><p>자원을 생성한 위치와 제거하는 위치가 서로 대응하도록 만들었습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 리팩토링에서 중요했던 것은 파일 수를 늘리거나 모든 코드를 하나의 공통 함수로 묶는 일이 아니었습니다.</p>
<p>스크롤 Animation이 실행되는 절차를 먼저 정의하고, 각 절차의 책임이 파일 구조에서도 그대로 드러나도록 만드는 일이었습니다.</p>
<pre><code>Stage.tsx
→ Stage의 시작점과 Scene 구성

get*StageElements.ts
→ Stage 단위의 큰 DOM 수집

*StageControllers.ts
→ Animation 생성·초기화·정리

use*StageAnimation.ts
→ ScrollTrigger와 Controller 연결

*.animation.ts
→ GSAP Timeline과 실제 모션</code></pre><p>또한 모든 ScrollTrigger를 하나의 Helper로 통합하지 않았습니다.</p>
<pre><code>현재 progress 전달
→ registerProgressTrigger()

최대 progress 유지
→ registerMaxProgressTrigger()

pin, activeIndex, progress split
→ 직접 createScrollTrigger()</code></pre><p>공통화의 기준을 코드의 형태가 아니라 실제 책임이 같은지에 두었습니다.</p>
<p>결과적으로 새로운 Stage나 Animation이 추가되더라도 다음 질문에 빠르게 답할 수 있는 구조가 됐습니다.</p>
<pre><code>DOM은 어디에서 찾는가?
Animation은 어디에서 생성하는가?
ScrollTrigger는 어디에서 연결하는가?
이 장면은 역재생되는가?
한 번 나타난 뒤 유지되는가?
특수한 progress 처리가 필요한가?
생성한 자원을 누가 정리하는가?</code></pre><p>이 기준을 통해 스크롤 장면이 늘어나더라도 실행 방식과 수정 위치를 예측할 수 있는 Stage Animation 구조를 만들었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모든 API 오류가 강제 로그아웃으로 이어지던 인증 흐름 개선하기]]></title>
            <link>https://velog.io/@bom_0320/%EB%AA%A8%EB%93%A0-API-%EC%98%A4%EB%A5%98%EA%B0%80-%EA%B0%95%EC%A0%9C-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83%EC%9C%BC%EB%A1%9C-%EC%9D%B4%EC%96%B4%EC%A7%80%EB%8D%98-%EC%9D%B8%EC%A6%9D-%ED%9D%90%EB%A6%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@bom_0320/%EB%AA%A8%EB%93%A0-API-%EC%98%A4%EB%A5%98%EA%B0%80-%EA%B0%95%EC%A0%9C-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83%EC%9C%BC%EB%A1%9C-%EC%9D%B4%EC%96%B4%EC%A7%80%EB%8D%98-%EC%9D%B8%EC%A6%9D-%ED%9D%90%EB%A6%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 05 Aug 2026 07:04:11 GMT</pubDate>
            <description><![CDATA[<p>Axios와 Zod에서 발생하는 오류를 공통 오류 모델로 정규화하고, 실제 인증 만료에서만 세션을 정리하도록 오류 처리 흐름을 개선했습니다.</p>
<p> <strong>관련 구현</strong></p>
<ul>
<li><a href="https://github.com/team-washer/Washer-Client-v2/pull/19">PR #19 — 주요 조회 API 응답 검증 및 데이터 흐름 개선</a></li>
<li><a href="https://github.com/team-washer/Washer-Client-v2/pull/20">PR #20 — API 오류 정규화 및 인증 오류 처리 개선
</a></li>
</ul>
<hr>
<h2 id="0-프롤로그">0. 프롤로그</h2>
<p>Washer 관리자 화면에서는 사용자 정보와 관리자 권한을 확인하기 위해 <code>AdminLayout</code>에서 내 정보 API를 조회하고 있었습니다.</p>
<p>기존에는 해당 요청이 실패하면 오류의 원인을 확인하지 않고 로그인 세션을 정리했습니다.</p>
<pre><code class="language-tsx">if (isMyInfoError) {
  toast.error(
    &quot;로그인이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.&quot;,
  );

  queryClient.clear();

  deleteCookie(COOKIE_KEYS.ACCESS_TOKEN);
  deleteCookie(COOKIE_KEYS.REFRESH_TOKEN);

  window.location.href = &quot;/sign-in&quot;;
}</code></pre>
<p>처음에는 내 정보 조회 실패를 로그인 만료와 동일하게 처리해도 큰 문제가 없어 보였습니다.</p>
<p>하지만 주요 조회 API에 Zod 런타임 검증을 적용하면서 이 구조의 문제가 드러났습니다.</p>
<p>기존에는 서버 응답 타입을 TypeScript 제네릭으로만 지정했습니다.</p>
<pre><code class="language-tsx">get&lt;BaseResponseType&lt;ReservationResponseType&gt;&gt;(...);</code></pre>
<p>이 코드는 응답이 <code>ReservationResponseType</code>이라고 TypeScript에 알려줄 뿐, 서버가 실제로 해당 구조를 반환했는지 검사하지는 않습니다.</p>
<p>이를 보완하기 위해 응답을 <code>unknown</code>으로 받고 Zod Schema로 검증하도록 변경했습니다.</p>
<pre><code class="language-tsx">const response = await get&lt;BaseResponseType&lt;unknown&gt;&gt;(...);

const parsedData = reservationResponseSchema.parse(
  response.data,
);</code></pre>
<p>이제 서버 응답과 Schema가 일치하지 않으면 <code>ZodError</code>가 발생했습니다.</p>
<p>문제는 이 검증 오류도 TanStack Query의 <code>isError</code> 상태로 전달된다는 점이었습니다.</p>
<p><code>AdminLayout</code>은 오류의 종류를 확인하지 않았기 때문에, 인증과 관계없는 응답 검증 오류까지 로그인 만료로 처리했습니다.</p>
<pre><code>서버 응답과 Schema 불일치
→ ZodError 발생
→ TanStack Query isError
→ 로그인 만료로 오판
→ Query Cache 초기화
→ 인증 토큰 삭제
→ 로그인 페이지 이동</code></pre><p>즉, API 계약 오류가 사용자 인증 오류로 바뀌면서 강제 로그아웃으로 이어질 수 있는 구조였습니다.</p>
<p>Zod 응답 검증을 적용한 과정은 아래 기록에서 더 자세히 정리했습니다.</p>
<blockquote>
<p><a href="https://github.com/bom0320/TIL_2026/blob/main/Projects/Washer/%EC%98%88%EC%95%BD%20API%20%EC%9D%91%EB%8B%B5%20%EB%9F%B0%ED%83%80%EC%9E%84%20%EA%B2%80%EC%A6%9D%20%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81.md">예약 API 응답 런타임 검증 리팩토링</a></p>
</blockquote>
<hr>
<h1 id="1-zod-검증-이후-드러난-강제-로그아웃-문제">1. Zod 검증 이후 드러난 강제 로그아웃 문제</h1>
<h2 id="1-1-iserror-기반-세션-정리">1-1. <code>isError</code> 기반 세션 정리</h2>
<p>기존 관리자 레이아웃에서는 내 정보 조회 결과의 <code>isError</code>만 확인했습니다.</p>
<pre><code class="language-tsx">const {
  data: myInfo,
  isPending: isMyInfoPending,
  isError: isMyInfoError,
} = useGetMyInfo();</code></pre>
<p>요청이 실패하면 다음 동작을 수행했습니다.</p>
<pre><code>Query Cache 초기화
→ Access Token 삭제
→ Refresh Token 삭제
→ 로그인 페이지 이동</code></pre><p>하지만 TanStack Query의 <code>isError</code>는 Query 함수가 실패했다는 사실만 나타냅니다.</p>
<p>실패 원인이 무엇인지는 구분하지 않습니다.</p>
<pre><code>401 인증 오류
403 권한 오류
500 서버 오류
네트워크 오류
Zod 검증 오류
일반 JavaScript 오류</code></pre><p>따라서 <code>isError === true</code>라는 이유만으로 인증 만료라고 판단하면, 서로 다른 원인의 오류가 모두 같은 흐름으로 처리됩니다.</p>
<h2 id="1-2-데이터-계약-오류가-인증-오류로-바뀌는-과정">1-2. 데이터 계약 오류가 인증 오류로 바뀌는 과정</h2>
<p>Zod 검증 실패는 서버 응답과 클라이언트 Schema가 일치하지 않는 문제입니다.</p>
<p>예를 들어 프론트엔드에서는 숫자를 기대했지만 서버가 문자열을 반환할 수 있습니다.</p>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;1&quot;,
  &quot;status&quot;: &quot;WAITING&quot;
}</code></pre>
<p>또는 nullable로 정의되지 않은 필드에 <code>null</code>이 들어올 수도 있습니다.</p>
<p>이런 오류는 인증 정보와 관계가 없습니다.</p>
<p>하지만 기존 구조에서는 다음과 같이 처리됐습니다.</p>
<pre><code>잘못된 API 응답
→ ZodError
→ Query 실패
→ 로그인 만료로 판단
→ 토큰 삭제</code></pre><p>이 방식에는 세 가지 문제가 있었습니다.</p>
<p>첫째, 서버나 데이터 계약 문제를 다시 로그인하는 것으로 해결할 수 없습니다.</p>
<p>둘째, 사용자는 실제 원인을 알지 못한 채 갑자기 로그인 화면으로 이동합니다.</p>
<p>셋째, 개발자는 모든 오류가 로그인 만료 메시지로 가려지기 때문에 원인을 추적하기 어렵습니다.</p>
<p>강제 로그아웃 문제의 원인과 오류 분류 기준은 아래 기록에서 먼저 정리했습니다.</p>
<blockquote>
<p><a href="https://github.com/bom0320/TIL_2026/blob/main/Projects/Washer/Washer%20%EA%B0%95%EC%A0%9C%20%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83%20%EB%AC%B8%EC%A0%9C%20%EB%B6%84%EC%84%9D%20%EB%B0%8F%20%EC%98%A4%EB%A5%98%20%EC%B2%98%EB%A6%AC%20%EC%84%A4%EA%B3%84.md">Washer 강제 로그아웃 문제 분석 및 오류 처리 설계</a></p>
</blockquote>
<hr>
<h1 id="2-실제-인증-만료의-기준">2. 실제 인증 만료의 기준</h1>
<h2 id="2-1-최초-401과-토큰-재발급">2-1. 최초 401과 토큰 재발급</h2>
<p>Washer의 Axios 인터셉터에서는 API 요청이 <code>401 Unauthorized</code>를 반환하면 Refresh Token을 이용해 Access Token 재발급을 시도합니다.</p>
<pre><code>API 요청
→ 401 응답
→ Refresh Token 확인
→ Access Token 재발급 요청</code></pre><p>재발급에 성공하면 새로운 토큰을 저장하고, 실패했던 기존 요청을 다시 실행합니다.</p>
<pre><code>토큰 재발급 성공
→ 새로운 토큰 저장
→ 기존 요청 재실행
→ 정상 응답 반환</code></pre><p>따라서 최초의 401은 바로 로그아웃해야 하는 오류가 아닙니다.</p>
<p>Refresh Token으로 복구할 수 있는 인증 오류일 수 있습니다.</p>
<h2 id="2-2-세션을-정리해야-하는-시점">2-2. 세션을 정리해야 하는 시점</h2>
<p>실제로 세션을 유지할 수 없는 시점은 다음과 같습니다.</p>
<ul>
<li>Refresh Token이 존재하지 않음</li>
<li>Refresh Token이 만료됨</li>
<li>토큰 재발급 API 실패</li>
<li>재발급 이후에도 인증 실패</li>
<li>서버가 재인증이 필요하다고 판단함</li>
</ul>
<p>이 경우에만 인증 정보를 정리하고 로그인 화면으로 이동해야 합니다.</p>
<pre><code>최초 401
→ 재발급 시도
→ 성공: 기존 요청 재실행
→ 실패: 인증 오류로 분류
→ 세션 정리</code></pre><p>반면 다음 오류에서는 세션을 삭제하면 안 됩니다.</p>
<ul>
<li>서버 내부 오류</li>
<li>네트워크 연결 실패</li>
<li>요청 타임아웃</li>
<li>권한 부족</li>
<li>Zod 검증 실패</li>
<li>잘못된 요청</li>
<li>데이터 충돌</li>
</ul>
<p>이 기준을 바탕으로 오류를 단순 성공·실패가 아니라 원인별로 분류했습니다.</p>
<hr>
<h1 id="3-공통-오류-모델">3. 공통 오류 모델</h1>
<h2 id="3-1-오류-분류-기준">3-1. 오류 분류 기준</h2>
<p>프로젝트에서 사용하는 오류 유형을 다음과 같이 정리했습니다.</p>
<pre><code class="language-tsx">export const APP_ERROR_TYPE = {
  AUTHENTICATION: &quot;AUTHENTICATION&quot;,
  FORBIDDEN: &quot;FORBIDDEN&quot;,
  BAD_REQUEST: &quot;BAD_REQUEST&quot;,
  NOT_FOUND: &quot;NOT_FOUND&quot;,
  CONFLICT: &quot;CONFLICT&quot;,
  SERVER: &quot;SERVER&quot;,
  NETWORK: &quot;NETWORK&quot;,
  VALIDATION: &quot;VALIDATION&quot;,
  UNKNOWN: &quot;UNKNOWN&quot;,
} as const;</code></pre>
<p>각 오류의 의미와 처리 기준은 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>발생 조건</th>
<th>오류 유형</th>
<th>처리</th>
</tr>
</thead>
<tbody><tr>
<td>401 또는 재발급 실패</td>
<td><code>AUTHENTICATION</code></td>
<td>세션 정리 후 로그인 이동</td>
</tr>
<tr>
<td>403</td>
<td><code>FORBIDDEN</code></td>
<td>세션 유지, 접근 제한</td>
</tr>
<tr>
<td>400·422</td>
<td><code>BAD_REQUEST</code></td>
<td>요청 내용 안내</td>
</tr>
<tr>
<td>404</td>
<td><code>NOT_FOUND</code></td>
<td>대상 없음 안내</td>
</tr>
<tr>
<td>409</td>
<td><code>CONFLICT</code></td>
<td>현재 상태와 충돌 안내</td>
</tr>
<tr>
<td>500 이상</td>
<td><code>SERVER</code></td>
<td>세션 유지, 재시도</td>
</tr>
<tr>
<td>서버 응답 없음</td>
<td><code>NETWORK</code></td>
<td>연결 확인 및 재시도</td>
</tr>
<tr>
<td>Zod 검증 실패</td>
<td><code>VALIDATION</code></td>
<td>세션 유지, 서비스 오류 안내</td>
</tr>
<tr>
<td>기타</td>
<td><code>UNKNOWN</code></td>
<td>일반 오류 안내</td>
</tr>
</tbody></table>
<p>오류 유형을 나눈 목적은 화면마다 Axios와 Zod의 오류 형식을 다시 해석하지 않도록 하기 위해서였습니다.</p>
<h2 id="3-2-apperror">3-2. <code>AppError</code></h2>
<p>Axios는 <code>AxiosError</code>, Zod는 <code>ZodError</code>를 반환합니다.</p>
<p>UI에서 두 라이브러리의 오류를 직접 처리하면 화면마다 분기 코드가 반복되고, 오류 처리 방식도 달라질 수 있습니다.</p>
<p>이를 방지하기 위해 프로젝트 내부에서 사용하는 공통 오류 클래스인 <code>AppError</code>를 정의했습니다.</p>
<pre><code class="language-tsx">export class AppError extends Error {
  readonly type: AppErrorType;
  readonly status?: number;
  readonly cause?: unknown;

  constructor({
    type,
    message,
    status,
    cause,
  }: AppErrorOptions) {
    super(message);

    this.name = &quot;AppError&quot;;
    this.type = type;
    this.status = status;
    this.cause = cause;
  }
}</code></pre>
<p>각 필드의 역할은 다음과 같습니다.</p>
<ul>
<li><code>type</code>: UI가 처리 방식을 결정하는 기준</li>
<li><code>message</code>: 사용자 안내 또는 로그에 사용할 메시지</li>
<li><code>status</code>: HTTP 상태 코드</li>
<li><code>cause</code>: 원본 <code>AxiosError</code>, <code>ZodError</code> 또는 기타 오류</li>
</ul>
<p><code>AppError</code>가 기본 <code>Error</code>를 확장하기 때문에 TanStack Query의 오류 흐름과도 자연스럽게 연결할 수 있습니다.</p>
<h2 id="3-3-normalizeapierror">3-3. <code>normalizeApiError</code></h2>
<p>서로 다른 형태의 오류를 <code>AppError</code>로 변환하는 <code>normalizeApiError</code> 함수를 만들었습니다.</p>
<pre><code>AxiosError ─┐
ZodError   ─┼→ normalizeApiError → AppError
Error      ─┤
기타 값    ───┘</code></pre><p>개략적인 분류 흐름은 다음과 같습니다.</p>
<pre><code class="language-tsx">export const normalizeApiError = (
  error: unknown,
): AppError =&gt; {
  if (error instanceof AppError) {
    return error;
  }

  if (error instanceof ZodError) {
    return new AppError({
      type: APP_ERROR_TYPE.VALIDATION,
      message: &quot;서버 응답 형식이 올바르지 않습니다.&quot;,
      cause: error,
    });
  }

  if (isAxiosError(error)) {
    if (!error.response) {
      return new AppError({
        type: APP_ERROR_TYPE.NETWORK,
        message: &quot;네트워크 연결을 확인해주세요.&quot;,
        cause: error,
      });
    }

    const status = error.response.status;

    // 상태 코드에 따라 AppError로 변환
  }

  return new AppError({
    type: APP_ERROR_TYPE.UNKNOWN,
    message: &quot;알 수 없는 오류가 발생했습니다.&quot;,
    cause: error,
  });
};</code></pre>
<p>오류를 공통 형태로 변환하면서도 원본 오류는 <code>cause</code>에 유지했습니다.</p>
<p>이를 통해 UI가 Axios나 Zod에 직접 의존하지 않으면서도, 개발 중에는 다음 정보를 확인할 수 있습니다.</p>
<ul>
<li>원본 Axios 오류</li>
<li>HTTP 상태 코드</li>
<li>서버 오류 응답</li>
<li>Zod 검증 실패 경로</li>
<li>최초 오류 발생 지점</li>
</ul>
<hr>
<h1 id="4-api-경계의-오류-처리">4. API 경계의 오류 처리</h1>
<h2 id="4-1-axios-요청과-zod-검증">4-1. Axios 요청과 Zod 검증</h2>
<p>오류 분류는 UI가 아니라 API 경계에서 수행했습니다.</p>
<p>조회 API 함수는 다음 흐름을 따릅니다.</p>
<pre><code>Axios 요청
→ unknown 응답
→ Zod Schema 검증
→ Mapper
→ 오류 발생 시 AppError로 정규화</code></pre><p>대표적인 형태는 다음과 같습니다.</p>
<pre><code class="language-tsx">export async function getSomething() {
  try {
    const response =
      await get&lt;BaseResponseType&lt;unknown&gt;&gt;(...);

    const parsedData =
      responseSchema.parse(response.data);

    return mapData(parsedData);
  } catch (error) {
    throw normalizeApiError(error);
  }
}</code></pre>
<p>응답을 <code>unknown</code>으로 받는 이유는 아직 검증되지 않은 외부 데이터이기 때문입니다.</p>
<p>Zod 검증을 통과한 데이터만 DTO와 Mapper 단계로 전달합니다.</p>
<pre><code>API 응답
→ unknown
→ Zod 검증
→ DTO
→ Mapper
→ UI</code></pre><p>검증 실패는 <code>VALIDATION</code>, 네트워크 실패는 <code>NETWORK</code>, 서버 응답 실패는 상태 코드에 따라 <code>SERVER</code>, <code>BAD_REQUEST</code> 등으로 변환됩니다.</p>
<h2 id="4-2-조회·mutation-api-표준화">4-2. 조회·Mutation API 표준화</h2>
<p>기존에는 일부 Mutation Hook에서 HTTP 요청과 TanStack Query 상태 관리가 함께 처리되고 있었습니다.</p>
<pre><code>Mutation Hook
→ HTTP 요청
→ 캐시 갱신
→ 오류 처리
→ 사용자 안내</code></pre><p>이를 다음과 같이 분리했습니다.</p>
<pre><code>API 함수
→ HTTP 요청
→ 응답 검증
→ 오류 정규화

Mutation Hook
→ API 함수 실행
→ 캐시 갱신
→ 사용자 안내</code></pre><p>적용한 주요 조회 API는 다음과 같습니다.</p>
<ul>
<li>내 정보 조회</li>
<li>사용자 목록 조회</li>
<li>기기 목록 조회</li>
<li>예약 목록 조회</li>
<li>예약 히스토리 조회</li>
<li>대시보드 조회</li>
<li>고장 신고 목록 조회</li>
</ul>
<p>주요 Mutation API에도 같은 기준을 적용했습니다.</p>
<ul>
<li>기기 삭제</li>
<li>기기 상태 변경</li>
<li>예약 삭제</li>
<li>사용자 패널티 해제</li>
<li>신고 상태 변경</li>
</ul>
<p>구현 과정 전체는 아래 기록에 정리했습니다.</p>
<blockquote>
<p><a href="https://github.com/bom0320/TIL_2026/blob/main/Projects/Washer/Washer%20%EC%9D%B8%EC%A6%9D%20%EB%B0%8F%20API%20%EC%98%A4%EB%A5%98%20%EC%B2%98%EB%A6%AC%20%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81%20%EA%B5%AC%ED%98%84%20%EA%B8%B0%EB%A1%9D.md">Washer 인증 및 API 오류 처리 리팩터링 구현 기록</a></p>
</blockquote>
<hr>
<h1 id="5-인증-오류와-일반-오류의-처리-분리">5. 인증 오류와 일반 오류의 처리 분리</h1>
<h2 id="5-1-인증-만료">5-1. 인증 만료</h2>
<p><code>AdminLayout</code>에서는 이제 <code>isError</code>만 확인하지 않고 실제 오류 유형을 확인합니다.</p>
<pre><code class="language-tsx">if (
  myInfoError instanceof AppError &amp;&amp;
  myInfoError.type ===
    APP_ERROR_TYPE.AUTHENTICATION
) {
  clearAuthSession(queryClient);

  window.location.replace(&quot;/sign-in&quot;);
}</code></pre>
<p>실제 인증 오류인 경우에만 다음 흐름을 수행합니다.</p>
<pre><code>AUTHENTICATION
→ Query Cache 초기화
→ Access Token 삭제
→ Refresh Token 삭제
→ 로그인 페이지 이동</code></pre><h2 id="5-2-권한-부족">5-2. 권한 부족</h2>
<p>일반 사용자가 관리자 페이지에 접근한 경우는 인증 실패가 아니라 권한 문제입니다.</p>
<p>사용자 인증 자체는 유효하기 때문에 토큰을 삭제하지 않았습니다.</p>
<pre><code>role === USER
→ 세션 유지
→ 관리자 페이지 접근 차단
→ /app-download 이동</code></pre><p>인증과 인가를 분리하면서, 권한이 없다는 이유로 정상적인 사용자 세션까지 제거하는 문제를 막았습니다.</p>
<h2 id="5-3-서버·네트워크·검증-오류">5-3. 서버·네트워크·검증 오류</h2>
<p>일반 오류에서는 사용자 세션을 유지했습니다.</p>
<pre><code>SERVER / NETWORK / VALIDATION / UNKNOWN
→ 인증 토큰 유지
→ Query Cache 유지
→ 오류 안내
→ 다시 시도 제공</code></pre><p>오류 유형별 사용자 대응은 다음과 같이 나눌 수 있습니다.</p>
<table>
<thead>
<tr>
<th>오류 유형</th>
<th>사용자 처리</th>
</tr>
</thead>
<tbody><tr>
<td><code>SERVER</code></td>
<td>잠시 후 다시 시도</td>
</tr>
<tr>
<td><code>NETWORK</code></td>
<td>네트워크 연결 확인</td>
</tr>
<tr>
<td><code>VALIDATION</code></td>
<td>서비스 오류 안내</td>
</tr>
<tr>
<td><code>BAD_REQUEST</code></td>
<td>요청 내용 확인</td>
</tr>
<tr>
<td><code>CONFLICT</code></td>
<td>현재 상태와 충돌 안내</td>
</tr>
<tr>
<td><code>FORBIDDEN</code></td>
<td>접근 권한 없음 안내</td>
</tr>
</tbody></table>
<p>모든 오류를 로그인 페이지로 보내는 대신, 오류 원인에 맞는 복구 흐름을 제공할 수 있게 됐습니다.</p>
<hr>
<h1 id="6-세션-정리-책임-분리">6. 세션 정리 책임 분리</h1>
<p>기존에는 <code>AdminLayout</code>과 Header가 토큰과 Query Cache를 직접 정리했습니다.</p>
<pre><code class="language-tsx">queryClient.clear();

deleteCookie(COOKIE_KEYS.ACCESS_TOKEN);
deleteCookie(COOKIE_KEYS.REFRESH_TOKEN);</code></pre>
<p>이 로직을 공통 함수로 분리했습니다.</p>
<pre><code class="language-tsx">export const clearAuthSession = (
  queryClient: QueryClient,
): void =&gt; {
  queryClient.clear();

  deleteCookie(COOKIE_KEYS.ACCESS_TOKEN);
  deleteCookie(COOKIE_KEYS.REFRESH_TOKEN);
};</code></pre>
<p>이 함수는 다음 상황에서 공통으로 사용합니다.</p>
<ul>
<li>실제 인증 만료</li>
<li>사용자의 로그아웃</li>
</ul>
<p>페이지 이동은 함수 내부에 포함하지 않았습니다.</p>
<p>세션 정리 함수는 인증 데이터 삭제만 담당하고, 어느 화면으로 이동할지는 호출한 UI가 결정하도록 책임을 분리했습니다.</p>
<pre><code>clearAuthSession
→ 인증 데이터 정리

AdminLayout / Header
→ 이후 이동할 화면 결정</code></pre><p>작은 차이지만, 세션 정리와 라우팅 책임을 분리함으로써 다른 화면에서도 같은 함수를 재사용할 수 있게 됐습니다.</p>
<hr>
<h1 id="7-검증-결과">7. 검증 결과</h1>
<p>리팩터링 이후 다음 시나리오를 확인했습니다.</p>
<h2 id="정상-관리자-접근">정상 관리자 접근</h2>
<ul>
<li>내 정보 조회 성공</li>
<li>관리자 권한 확인</li>
<li>관리자 페이지 정상 렌더링</li>
<li>주요 목록 조회 정상 동작</li>
</ul>
<h2 id="일반-사용자-접근">일반 사용자 접근</h2>
<ul>
<li>관리자 페이지 접근 차단</li>
<li>인증 토큰 유지</li>
<li>앱 다운로드 페이지로 이동</li>
</ul>
<h2 id="인증-만료">인증 만료</h2>
<ul>
<li>토큰 재발급 실패</li>
<li><code>AUTHENTICATION</code> 오류로 분류</li>
<li>Query Cache 초기화</li>
<li>Access Token·Refresh Token 삭제</li>
<li>로그인 페이지 이동</li>
</ul>
<h2 id="zod-응답-검증-실패">Zod 응답 검증 실패</h2>
<ul>
<li><code>VALIDATION</code> 오류로 분류</li>
<li>사용자 세션 유지</li>
<li>강제 로그아웃되지 않음</li>
<li>오류 안내와 다시 시도 흐름 제공</li>
</ul>
<h2 id="서버·네트워크-오류">서버·네트워크 오류</h2>
<ul>
<li><code>SERVER</code> 또는 <code>NETWORK</code> 오류로 분류</li>
<li>인증 토큰 유지</li>
<li>오류 상태 표시</li>
<li>사용자가 다시 시도 가능</li>
</ul>
<h2 id="로그아웃">로그아웃</h2>
<ul>
<li>Query Cache 초기화</li>
<li>인증 토큰 삭제</li>
<li>로그인 페이지 이동</li>
</ul>
<p>이제 API 요청이 실패했다는 사실만으로 세션을 삭제하지 않습니다.</p>
<p>오류의 실제 원인을 기준으로 사용자 세션 유지 여부와 화면 동작을 결정합니다.</p>
<hr>
<h1 id="8-마무리">8. 마무리</h1>
<p>이번 리팩터링은 Zod를 도입하면서 새롭게 발생한 문제를 해결한 작업은 아니었습니다.</p>
<p>Zod 검증은 기존에 가려져 있던 오류를 명확하게 드러냈고, 그 과정에서 애플리케이션의 오류 처리 구조가 모든 실패를 인증 만료로 단순화하고 있었다는 점을 확인할 수 있었습니다.</p>
<p>처음에는 내 정보 조회 실패를 로그인 만료로 처리하는 것이 단순하고 안전한 방식처럼 보였습니다.</p>
<p>하지만 실제로는 서버 오류, 네트워크 오류, 권한 부족, API 계약 오류가 모두 다른 원인과 복구 방법을 가지고 있었습니다.</p>
<p>이를 해결하기 위해 다음 기준을 세웠습니다.</p>
<ul>
<li>최초 401은 바로 로그아웃하지 않고 토큰 재발급 시도</li>
<li>재발급까지 실패한 경우에만 인증 만료로 판단</li>
<li>Axios와 Zod 오류를 <code>AppError</code>로 정규화</li>
<li>오류 분류는 API 경계에서 수행</li>
<li>UI는 오류 유형을 기준으로 사용자 동작만 결정</li>
<li>인증과 무관한 오류에서는 세션 유지</li>
<li>토큰과 Query Cache 정리 로직 공통화</li>
</ul>
<p>그 결과 인증과 무관한 오류로 사용자가 강제 로그아웃되는 문제를 방지했고, 서버·네트워크·검증 오류에서 로그인 상태를 유지할 수 있게 됐습니다.</p>
<p>또한 오류 원인에 맞는 메시지와 재시도 흐름을 제공하고, 원본 오류를 보존해 디버깅 가능성도 유지했습니다.</p>
<p>결국 이번 작업의 핵심은 오류를 더 많이 잡는 것이 아니라, <strong>서로 다른 오류를 같은 실패로 취급하지 않는 구조를 만드는 것</strong>이었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TanStack Query 캐시 전략으로 관리자 화면 API 요청을 45회에서 7회로 줄인 과정]]></title>
            <link>https://velog.io/@bom_0320/TanStack-Query-%EC%BA%90%EC%8B%9C-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%99%94%EB%A9%B4-API-%EC%9A%94%EC%B2%AD%EC%9D%84-45%ED%9A%8C%EC%97%90%EC%84%9C-7%ED%9A%8C%EB%A1%9C-%EC%A4%84%EC%9D%B8-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@bom_0320/TanStack-Query-%EC%BA%90%EC%8B%9C-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%99%94%EB%A9%B4-API-%EC%9A%94%EC%B2%AD%EC%9D%84-45%ED%9A%8C%EC%97%90%EC%84%9C-7%ED%9A%8C%EB%A1%9C-%EC%A4%84%EC%9D%B8-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Wed, 05 Aug 2026 06:47:39 GMT</pubDate>
            <description><![CDATA[<h2 id="0-프롤로그">0. 프롤로그</h2>
<p>Washer는 광주소프트웨어마이스터고 학생들이 사용하는 세탁기 관리 서비스입니다.</p>
<p>관리자 화면에서는 기기 상태, 예약 내역, 고장 신고, 사용자 패널티 등 서비스 운영에 필요한 여러 데이터를 확인하고 변경할 수 있습니다.</p>
<p>기존 V1 관리자 페이지에서는 서버 데이터를 화면 컴포넌트에서 직접 요청하고 각각의 <code>useState</code>에 저장했습니다. 관리자 권한 확인이 끝나면 기기, 예약, 사용자, 고장 신고 데이터를 한 번에 불러왔고, 예약 삭제나 기기 상태 변경이 발생하면 관련 조회 함수를 다시 호출하는 구조였습니다.</p>
<pre><code class="language-tsx">await Promise.all([
  loadReports(),
  loadOutOfOrderDevices(),
  loadAdminReservations(),
  loadAdminUsers(),
  fetchMachines(),
]);</code></pre>
<p>초기 기능을 구현하기에는 단순한 방식이었지만, 페이지를 이동하거나 다시 방문했을 때 이전 응답을 재사용할 수 없었습니다.</p>
<p>또한 기기 상태처럼 자주 변하는 데이터와 사용자 정보처럼 변화가 적은 데이터를 구분하지 않고 매번 새로 요청하고 있었습니다.</p>
<p>V2에서는 서버 상태 관리를 TanStack Query로 전환하고, 데이터의 변경 주기에 따라 캐시의 신선도와 유지 시간을 구분했습니다.</p>
<p>그 결과 동일한 관리자 화면 탐색 시나리오에서 발생하던 API 요청을 <strong>45회에서 7회로 줄였습니다.</strong></p>
<hr>
<h1 id="1-v1의-수동-데이터-로딩-구조">1. V1의 수동 데이터 로딩 구조</h1>
<h2 id="1-1-화면-내부의-서버-데이터-관리">1-1. 화면 내부의 서버 데이터 관리</h2>
<p>V1 관리자 페이지에서는 고장 신고, 고장 기기, 예약, 사용자 정보를 각각 <code>useState</code>로 관리했습니다.</p>
<pre><code class="language-tsx">const [reports, setReports] = useState&lt;Report[]&gt;([]);

const [outOfOrderDevices, setOutOfOrderDevices] = useState&lt;
  OutOfOrderDevice[]
&gt;([]);

const [adminReservations, setAdminReservations] = useState&lt;
  AdminReservationInfo[]
&gt;([]);

const [adminUsers, setAdminUsers] = useState&lt;AdminUserInfo[]&gt;([]);</code></pre>
<p>각 데이터는 화면 내부의 조회 함수를 실행한 뒤 응답을 상태에 저장했습니다.</p>
<pre><code class="language-tsx">const loadAdminUsers = async () =&gt; {
  try {
    const response = await userApi.getUsers();
    setAdminUsers(response.data);
  } catch (error) {
    console.error(error);
  }
};</code></pre>
<p>서버에서 가져온 데이터와 이를 불러오는 로직, 화면에서 사용하는 상태가 하나의 컴포넌트 안에 함께 존재했습니다.</p>
<p>정리하면 다음과 같은 구조였습니다.</p>
<pre><code>UI 컴포넌트
├─ API 요청
├─ 응답 처리
├─ useState 저장
└─ 화면 렌더링</code></pre><p>기능 수가 적을 때는 흐름을 따라가기 어렵지 않았지만, 관리자 기능이 늘어나면서 화면이 담당하는 책임도 함께 증가했습니다.</p>
<h2 id="1-2-mutation-이후-직접-재조회">1-2. Mutation 이후 직접 재조회</h2>
<p>서버 데이터를 변경한 뒤에는 관련 조회 함수를 직접 실행했습니다.</p>
<p>예약을 삭제한 경우에는 예약 목록을 다시 불러왔습니다.</p>
<pre><code class="language-tsx">await reservationApi.forceDeleteReservation(reservationId);
await loadAdminReservations();</code></pre>
<p>기기 고장 상태를 변경한 경우에는 고장 기기 목록과 전체 기기 목록을 모두 다시 조회했습니다.</p>
<pre><code class="language-tsx">await machineApi.updateOutOfOrderStatus(
  deviceName,
  !currentStatus,
);

await loadOutOfOrderDevices();
await fetchMachines();</code></pre>
<p>이 구조에서 각 이벤트 함수는 다음 내용을 모두 알고 있어야 했습니다.</p>
<ul>
<li>어떤 서버 데이터를 변경했는지</li>
<li>변경 이후 어떤 조회 함수를 다시 실행해야 하는지</li>
<li>조회 결과를 어느 상태에 저장해야 하는지</li>
</ul>
<p>예를 들어 기기 상태 변경은 하나의 동작이지만, 이후 갱신해야 하는 데이터는 고장 기기 목록과 전체 기기 목록 두 가지였습니다.</p>
<p>새로운 기능을 추가하거나 데이터 의존 관계가 바뀌면 관련 이벤트 함수를 찾아 직접 수정해야 했습니다.</p>
<h2 id="1-3-반복-요청의-원인">1-3. 반복 요청의 원인</h2>
<p>V1에서는 서버에서 받아온 데이터를 화면의 로컬 상태로만 관리했습니다.</p>
<p>따라서 화면을 벗어난 뒤 다시 진입하면 이전에 받아온 데이터를 재사용할 수 없었습니다. 컴포넌트가 다시 마운트될 때마다 조회 함수가 실행됐고, 같은 데이터를 다시 요청했습니다.</p>
<p>또한 데이터의 성격과 관계없이 모두 같은 방식으로 처리했습니다.</p>
<p>기기 상태처럼 빠르게 바뀌는 데이터는 자주 다시 확인할 필요가 있습니다. 반면 사용자 목록이나 관리자 본인 정보는 짧은 시간 안에 반복해서 요청할 필요가 적습니다.</p>
<p>하지만 V1에서는 이러한 차이를 표현할 수 있는 기준이 없었습니다.</p>
<p>문제는 단순히 API 요청이 많다는 데 있지 않았습니다.</p>
<blockquote>
<p>어떤 데이터를 언제 다시 가져와야 하는지에 대한 기준이 화면과 이벤트 함수에 분산돼 있다는 점이 더 큰 문제였습니다.</p>
</blockquote>
<hr>
<h1 id="2-tanstack-query-기반-서버-상태-구조">2. TanStack Query 기반 서버 상태 구조</h1>
<h2 id="2-1-api-요청-계층-분리">2-1. API 요청 계층 분리</h2>
<p>V2에서는 서버 데이터 요청 흐름을 다음과 같이 분리했습니다.</p>
<pre><code>HTTP Client
→ 도메인 API 함수
→ TanStack Query Hook
→ UI</code></pre><p>기기 조회를 예로 들면 UI에서 직접 API 함수를 호출하지 않고, <code>useGetMachines</code> Hook을 통해 데이터를 사용하도록 변경했습니다.</p>
<pre><code class="language-tsx">export const useGetMachines = (
  params?: MachineParamsType,
  options?: UseGetMachinesOptions,
) =&gt; {
  const queryKey = machineQueryKeys.getMachines(params ?? {});

  return useQuery({
    staleTime: STALE_TIME.MACHINE,
    queryKey,
    queryFn: () =&gt; fetchMachines(params),
    enabled: options?.enabled,
  });
};</code></pre>
<p>실제 V2 코드에서도 기기 조회 Hook은 중앙화된 Query Key와 도메인별 <code>staleTime</code>, 별도의 API 함수를 조합하는 구조로 작성돼 있습니다.</p>
<p>UI는 서버 데이터를 어떻게 저장할지 직접 관리하지 않습니다.</p>
<p>대신 Query Hook이 반환하는 다음 값을 사용합니다.</p>
<pre><code class="language-tsx">const {
  data,
  isPending,
  isFetching,
  error,
} = useGetMachines(params);</code></pre>
<p>서버 응답의 저장과 재사용은 TanStack Query의 Query Cache가 담당하게 됐습니다.</p>
<p>이 구조를 적용하면서 화면은 다음 역할에 집중할 수 있었습니다.</p>
<ul>
<li>어떤 조회 조건을 전달할지</li>
<li>로딩과 오류 상태를 어떻게 표현할지</li>
<li>조회된 데이터를 어떻게 렌더링할지</li>
</ul>
<h2 id="2-2-도메인별-query-key-설계">2-2. 도메인별 Query Key 설계</h2>
<p>Query Key는 TanStack Query가 캐시된 데이터를 구분하는 식별자입니다.</p>
<p>V2에서는 Query Key를 화면마다 직접 작성하지 않고 도메인별 객체로 관리했습니다.</p>
<pre><code class="language-tsx">export const machineQueryKeys = {
  all: [&quot;machines&quot;] as const,

  getMachines: (params: { floor?: number } = {}) =&gt;
    [&quot;machines&quot;, &quot;list&quot;, params] as const,
};</code></pre>
<p>예약 데이터는 목록과 기기별 예약 이력을 구분했습니다.</p>
<pre><code class="language-tsx">export const reservationQueryKeys = {
  all: [&quot;reservations&quot;] as const,

  getReservations: (params?: ReservationParamsType) =&gt;
    [&quot;reservations&quot;, &quot;list&quot;, params] as const,

  getMachineReservationHistory: (
    machineName: string | null,
  ) =&gt;
    [&quot;reservations&quot;, &quot;history&quot;, machineName] as const,
};</code></pre>
<p>실제 V2에서는 기기, 예약, 사용자, 신고, 대시보드 데이터를 각각 별도의 Query Key 체계로 분리했습니다.</p>
<p>이 구조를 통해 다음 범위를 구분할 수 있었습니다.</p>
<pre><code>[&quot;machines&quot;]
[&quot;machines&quot;, &quot;list&quot;, { floor: 3 }]

[&quot;reservations&quot;]
[&quot;reservations&quot;, &quot;list&quot;, { status: &quot;ACTIVE&quot; }]
[&quot;reservations&quot;, &quot;history&quot;, &quot;washer-01&quot;]</code></pre><p>Query Key 중앙화의 목적은 단순히 문자열 중복을 줄이는 데 있지 않았습니다.</p>
<blockquote>
<p>데이터의 범위와 캐시 무효화 단위를 코드에 명시하는 것이 핵심이었습니다.</p>
</blockquote>
<p>기기와 관련된 모든 캐시를 갱신하고 싶다면 <code>machineQueryKeys.all</code>을 사용할 수 있고, 특정 조건의 목록만 구분하고 싶다면 매개변수가 포함된 Key를 사용할 수 있습니다.</p>
<h2 id="2-3-queryclient와-기본-옵션">2-3. QueryClient와 기본 옵션</h2>
<p>TanStack Query의 캐시는 <code>QueryClient</code> 인스턴스에 저장됩니다.</p>
<p>따라서 <code>QueryClient</code>가 렌더링마다 새로 생성되면 기존 캐시가 모두 사라질 수 있습니다.</p>
<p>V2에서는 <code>useState</code>의 초기화 함수를 이용해 인스턴스를 한 번만 생성했습니다.</p>
<pre><code class="language-tsx">const [queryClient] = useState(
  () =&gt;
    new QueryClient({
      defaultOptions: {
        queries: {
          retry: false,
          staleTime: DEFAULT_STALE_TIME,
          gcTime: DEFAULT_GC_TIME,
        },
      },
    }),
);</code></pre>
<p>실제 Provider에서도 이 방식으로 QueryClient 인스턴스를 고정하고 있습니다.</p>
<p>전역 기본값은 다음과 같이 설정했습니다.</p>
<pre><code class="language-tsx">staleTime: 1분
gcTime: 30분
retry: false</code></pre>
<p>다만 모든 데이터가 전역 기본값을 사용하는 것은 아닙니다.</p>
<p>기기, 예약, 사용자처럼 성격이 명확한 데이터는 각 Query Hook에서 도메인별 값을 별도로 지정했습니다.</p>
<hr>
<h1 id="3-데이터-변경-주기에-따른-캐시-정책">3. 데이터 변경 주기에 따른 캐시 정책</h1>
<h2 id="3-1-staletime-기준">3-1. <code>staleTime</code> 기준</h2>
<p>TanStack Query의 기본 <code>staleTime</code>은 <code>0</code>입니다.</p>
<p>조회가 완료된 직후 데이터는 stale 상태가 되며, 컴포넌트가 다시 마운트되거나 refetch 조건을 만족하면 네트워크 요청이 발생할 수 있습니다.</p>
<p>하지만 Washer의 모든 데이터가 같은 속도로 변경되는 것은 아닙니다.</p>
<p>기기 상태는 학생이 세탁기를 사용하거나 관리자가 상태를 변경하면 빠르게 달라질 수 있습니다.</p>
<p>예약도 사용자 행동에 따라 비교적 자주 변경됩니다.</p>
<p>반면 사용자 목록이나 고장 신고 목록은 상대적으로 변화가 적으며, 관리자 본인의 정보는 세션 중 거의 바뀌지 않습니다.</p>
<p>따라서 모든 Query에 같은 <code>staleTime</code>을 적용하지 않았습니다.</p>
<pre><code class="language-tsx">export const STALE_TIME = {
  MACHINE: 30 * SECOND,
  RESERVATION: MINUTE,
  REPORT: 3 * MINUTE,
  USER: 3 * MINUTE,
  MY_INFO: 30 * MINUTE,
} as const;</code></pre>
<p>이 설정은 실제 V2의 공통 Query 옵션 파일에 정의돼 있습니다.</p>
<h2 id="3-2-gctime-설정">3-2. <code>gcTime</code> 설정</h2>
<p><code>staleTime</code>은 데이터를 언제까지 fresh 상태로 볼지를 결정합니다.</p>
<p>반면 <code>gcTime</code>은 현재 화면에서 사용되지 않는 비활성 Query가 캐시에 얼마나 오래 남아 있을지를 결정합니다.</p>
<p>V2에서는 기본 <code>gcTime</code>을 30분으로 설정했습니다.</p>
<pre><code class="language-tsx">export const DEFAULT_GC_TIME = 30 * MINUTE;</code></pre>
<p>가장 긴 <code>staleTime</code>은 관리자 본인 정보의 30분입니다.</p>
<p>만약 <code>gcTime</code>이 이보다 짧으면 화면에서 사용되지 않는 Query가 아직 fresh한 상태임에도 캐시에서 먼저 제거될 수 있습니다.</p>
<p>따라서 기본 <code>gcTime</code>을 가장 긴 <code>staleTime</code> 이상으로 맞췄습니다.</p>
<pre><code class="language-tsx">export const DEFAULT_STALE_TIME = MINUTE;
export const DEFAULT_GC_TIME = 30 * MINUTE;</code></pre>
<p><code>gcTime</code>이 요청 감소에 직접 관여하는 방식은 <code>staleTime</code>과 다릅니다.</p>
<ul>
<li><code>staleTime</code>: 기존 데이터를 언제까지 최신으로 볼 것인가</li>
<li><code>gcTime</code>: 사용하지 않는 캐시를 언제 제거할 것인가</li>
</ul>
<p>화면을 벗어났다가 다시 진입했을 때 캐시가 남아 있어야 <code>staleTime</code> 정책도 의미가 있기 때문에 두 값을 함께 고려했습니다.</p>
<h2 id="3-3-데이터별-신선도-구분">3-3. 데이터별 신선도 구분</h2>
<p>최종적으로 적용한 기준은 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>데이터</th>
<th><code>staleTime</code></th>
<th>설정 기준</th>
</tr>
</thead>
<tbody><tr>
<td>기기 상태</td>
<td>30초</td>
<td>사용 및 상태 변경이 자주 발생</td>
</tr>
<tr>
<td>예약</td>
<td>1분</td>
<td>최신성이 중요하지만 기기 상태보다는 변화가 적음</td>
</tr>
<tr>
<td>고장 신고</td>
<td>3분</td>
<td>운영자의 처리 시점에 주로 변경</td>
</tr>
<tr>
<td>사용자 목록</td>
<td>3분</td>
<td>짧은 시간 안에 반복 변경될 가능성이 낮음</td>
</tr>
<tr>
<td>관리자 본인 정보</td>
<td>30분</td>
<td>로그인 세션 중 거의 변경되지 않음</td>
</tr>
</tbody></table>
<p>값을 길게 설정할수록 요청 수는 줄어듭니다.</p>
<p>하지만 그만큼 오래된 데이터가 화면에 노출될 가능성도 커집니다.</p>
<p>따라서 요청 수 자체를 최소화하는 방향으로 값을 정하지 않았습니다.</p>
<blockquote>
<p>캐시 정책의 기준은 요청을 최대한 적게 보내는 것이 아니라, 데이터마다 허용할 수 있는 오래됨의 범위를 정하는 것이었습니다.</p>
</blockquote>
<hr>
<h1 id="4-mutation-이후-캐시-갱신">4. Mutation 이후 캐시 갱신</h1>
<h2 id="4-1-직접-재조회-방식">4-1. 직접 재조회 방식</h2>
<p>V1에서는 서버 데이터를 변경한 뒤 조회 함수를 직접 호출했습니다.</p>
<pre><code>Mutation 실행
→ 관련 조회 함수 직접 호출
→ useState 갱신</code></pre><p>예약 삭제를 예로 들면 다음과 같았습니다.</p>
<pre><code class="language-tsx">await reservationApi.forceDeleteReservation(reservationId);
await loadAdminReservations();</code></pre>
<p>기기 상태 변경은 두 개의 조회 함수를 다시 실행했습니다.</p>
<pre><code class="language-tsx">await machineApi.updateOutOfOrderStatus(
  deviceName,
  !currentStatus,
);

await loadOutOfOrderDevices();
await fetchMachines();</code></pre>
<p>이 방식에서는 Mutation 함수가 이후 갱신해야 할 조회 함수를 직접 알고 있어야 합니다.</p>
<p>관련 데이터가 늘어날수록 갱신 대상도 함께 늘어나고, 하나를 빠뜨리면 일부 화면만 오래된 상태로 남을 수 있습니다.</p>
<h2 id="4-2-query-무효화-방식">4-2. Query 무효화 방식</h2>
<p>V2에서는 Mutation 성공 후 관련 도메인의 Query Key를 무효화했습니다.</p>
<pre><code>Mutation 실행
→ 관련 Query Key 무효화
→ 활성 Query 재조회
→ Query Cache 갱신</code></pre><p>예를 들어 예약 삭제가 성공하면 예약 도메인에 속한 Query를 갱신하는 방식입니다.</p>
<pre><code class="language-tsx">const queryClient = useQueryClient();

return useMutation({
  mutationFn: deleteReservation,

  onSuccess: () =&gt; {
    queryClient.invalidateQueries({
      queryKey: reservationQueryKeys.all,
    });
  },
});</code></pre>
<p>기기 상태 변경이라면 기기 도메인에 속한 Query를 무효화할 수 있습니다.</p>
<pre><code class="language-tsx">onSuccess: () =&gt; {
  queryClient.invalidateQueries({
    queryKey: machineQueryKeys.all,
  });
}</code></pre>
<p><code>staleTime</code> 동안에는 기존 데이터를 재사용하지만, 서버 데이터가 실제로 변경된 시점에는 캐시를 명시적으로 stale 상태로 전환했습니다.</p>
<p>이를 통해 요청 수를 줄이면서도 Mutation 이후에는 변경된 데이터를 즉시 다시 가져올 수 있었습니다.</p>
<hr>
<h1 id="5-동일-탐색-시나리오-기반-전후-비교">5. 동일 탐색 시나리오 기반 전후 비교</h1>
<h2 id="5-1-측정-조건">5-1. 측정 조건</h2>
<p>구조를 변경하기 전에 실제 관리자 화면에서 발생하는 API 요청 수를 확인했습니다.</p>
<p>Axios 인터셉터에 요청 카운터를 추가하고, 정해진 순서에 따라 화면을 탐색했습니다.</p>
<pre><code>대시보드
→ 기기 관리
→ 예약 관리
→ 사용자 관리
→ 고장 신고
→ 이전 화면 재방문</code></pre><p>측정은 동일한 관리자 계정과 동일한 화면 탐색 순서를 기준으로 진행했습니다.</p>
<pre><code>측정 기준

- 동일한 관리자 계정
- 동일한 화면 이동 순서
- Axios 인터셉터에서 API 요청 집계
- 5개 관리자 화면 순회 후 이전 화면 재방문
- 개선 전과 개선 후 동일한 시나리오 적용</code></pre><p>실제 게시 시에는 당시 측정 환경에 맞춰 아래 항목도 명시하는 편이 좋습니다.</p>
<pre><code>- 로컬 또는 배포 환경
- 브라우저 새로고침 여부
- 캐시가 없는 상태에서 시작했는지
- 인증 요청을 집계에 포함했는지</code></pre><h2 id="5-2-45회에서-7회">5-2. 45회에서 7회</h2>
<p>개선 전 동일한 탐색 시나리오에서는 총 45회의 API 요청이 발생했습니다.</p>
<p>V2에서 다음 구조를 적용한 뒤 같은 시나리오를 다시 수행했습니다.</p>
<ul>
<li>서버 상태를 TanStack Query로 관리</li>
<li>Query Key를 도메인 단위로 중앙화</li>
<li>데이터 변경 주기에 따라 <code>staleTime</code> 구분</li>
<li>비활성 캐시 유지를 위한 <code>gcTime</code> 설정</li>
<li>Mutation 이후 관련 Query만 무효화</li>
</ul>
<p>그 결과 API 요청은 7회로 감소했습니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>요청 수</th>
</tr>
</thead>
<tbody><tr>
<td>개선 전</td>
<td>45회</td>
</tr>
<tr>
<td>개선 후</td>
<td>7회</td>
</tr>
<tr>
<td>감소한 요청</td>
<td>38회</td>
</tr>
<tr>
<td>감소율</td>
<td>약 84%</td>
</tr>
</tbody></table>
<h2 id="5-3-요청-수-84-감소">5-3. 요청 수 84% 감소</h2>
<p>요청 수를 줄이기 위해 모든 데이터를 장시간 캐싱한 것은 아닙니다.</p>
<p>기기와 예약처럼 변화가 잦은 데이터에는 짧은 <code>staleTime</code>을 적용했습니다.</p>
<p>사용자와 관리자 본인 정보처럼 상대적으로 변화가 적은 데이터에는 더 긴 값을 적용했습니다.</p>
<p>서버 데이터가 실제로 변경된 경우에는 관련 Query를 명시적으로 무효화했습니다.</p>
<p>이를 통해 페이지 이동과 재방문 과정에서 발생하던 반복 요청을 줄이면서, Mutation 이후의 최신 데이터는 바로 반영할 수 있었습니다.</p>
<hr>
<h1 id="6-마무리">6. 마무리</h1>
<p>이번 개선 전에는 캐시를 API 요청을 줄이기 위한 저장 공간 정도로 이해하고 있었습니다.</p>
<p>하지만 실제 프로젝트에 적용하면서 중요한 것은 데이터를 오래 저장하는 것이 아니라, <strong>각 데이터를 언제까지 신뢰할 수 있는지 기준을 세우는 일</strong>이라는 점을 알게 됐습니다.</p>
<p>V1에서는 서버 데이터의 조회와 갱신 책임이 화면 컴포넌트와 이벤트 함수에 분산돼 있었습니다.</p>
<p>V2에서는 TanStack Query와 도메인별 Query Key를 적용해 조회, 재사용, 무효화 기준을 서버 상태 관리 구조 안으로 모았습니다.</p>
<p>또한 모든 데이터에 같은 캐시 설정을 적용하지 않고, 실제 변경 빈도와 화면에서 요구하는 최신성을 기준으로 <code>staleTime</code>을 구분했습니다.</p>
<p>서버 데이터가 변경된 경우에는 관련 Query를 무효화해 캐시가 최신 상태를 가리는 문제를 방지했습니다.</p>
<p>그 결과 동일한 관리자 화면 탐색 시나리오에서 API 요청을 <strong>45회에서 7회로 약 84% 줄이면서도</strong>, 운영에 필요한 데이터의 최신성을 유지할 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[전국 SW 연합 해커톤_대상 수상]]></title>
            <link>https://velog.io/@bom_0320/%EC%A0%84%EA%B5%AD-SW-%EC%97%B0%ED%95%A9-%ED%95%B4%EC%BB%A4%ED%86%A4%EB%8C%80%EC%83%81-%EC%88%98%EC%83%81</link>
            <guid>https://velog.io/@bom_0320/%EC%A0%84%EA%B5%AD-SW-%EC%97%B0%ED%95%A9-%ED%95%B4%EC%BB%A4%ED%86%A4%EB%8C%80%EC%83%81-%EC%88%98%EC%83%81</guid>
            <pubDate>Mon, 10 Nov 2025 11:11:45 GMT</pubDate>
            <description><![CDATA[<p>2025년 11월 5일부터 7일까지 2박 3일간 경주 사한셀렉트호텔에서 열린 ‘전국 SW 마이스터고 연합 해커톤’에 참가하였습니다. 본 행사는 과학기술정보통신부가 주최하고 정보통신기획평가원(IITP)과 전국 4개 SW마이스터고(대덕·대구·광주·부산)가 공동으로 주관하는 규모 있는 전국 단위 행사로, 매년 비슷한 시기에 열려 각 학교 대표 학생들이 한 자리에 모여 개발을 하는 자리인데요.
<img src="https://velog.velcdn.com/images/bom_0320/post/794fe250-4cce-416b-9b2a-ae4aaeacb44a/image.png" alt=""></p>
<p>올해 해커톤의 대주제는 “AI·SW, Connecting Value : AI·SW로 가치를 연결하라” 였는데요. 저는 광주소프트웨어 마이스터고를 대표에 참가하였습니다.  마찬가지로 다른 학교에서 선발된 학생들과 함께 4인 1팀으로 구성되어 협업을 진행하였습니다. </p>
<h2 id="기획-및-문제-정의">기획 및 문제 정의</h2>
<p>저희 팀은 이번 해커톤에서 ‘지역사회·문화’ 문화 분야를 선택하였는데요. </p>
<p>특히 다문화 가정 학부모님들이 학교와의 의사소통에서 겪는 언어적 장벽에 주목하였습니다.  현재 몇몇 학교에서는 가정통신문과 같은 주요 공지사항을 문자나 QR 코드 형태로 제공하고는 있으나, 문체가 공문서체, 한자어 중심의 문어체로 구성되어 있어 이해가 어렵다는 문제가 있었습니다. 또한 자동 번역 기능을 활용하더라도 <strong>의역·오역 문제</strong>가 자주 발생하고, 검수 절차가 없이 긴급 안내 시 오류가 발생할 가능성이 높다는 점도 확인하였습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/24bba71b-1bf1-410b-834a-d1e6563d7094/image.png" alt=""></p>
<p>기존 번역 앱(예: 알번역, 파파고 등)은 단순히 문장 구조나 의미를 단순히 치환하는 수준에 그쳐, 다문화 가정의 학부모가 한국어를 익히거나 지역 사회에 진출할 기회를 제공하지 못하며, 문맥을 이해하는 데에도 한계가 있었습니다.</p>
<p>그 결과, 다문화 학부모들은 학교생활 참여에 제약을 받고, 나아가 지역 사회나 복지기관과의 연결이 단절되는 문제를 겪고 있었습니다.</p>
<h2 id="솔루션">솔루션</h2>
<p>저희 팀은 이러한 한계를 극복하기 위해 ‘단순 번역이 아닌, 이해 가능한 언어로의 변환’이라는 새로운 방향을 제시하였습니다. </p>
<p>즉, 학부모가 단순히 문서를 읽는 데 그치지 않고, <strong>AI를 통해 문장을 이해하고, 직접 표현을 연습할 수 있는 상호작용형 플랫폼</strong>을 구축하였습니다. </p>
<p>이를 위해  <strong>국립국어원의 1단계 어휘 데이터</strong>를 기반으로, 공문서체로 작성된 문장을 초급 수준의 일상 언어로 변환하는 기능을 구현하였으며, 이를 통해 학부모가 문서를 쉽게 이해하면서도 동시에 한국어를 자연스럽게 학습할 수 있도록 유도하였습니다. </p>
<p>결과적으로 <strong>‘학습 → 이해 → 참여’로 이어지는 언어 습득의 선순환 구조</strong>를 실현하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/9acd6af6-631f-48ec-ad52-514baaf59dc4/image.png" alt="">
저희 서비스명 ClipCat은 ‘Clip(요약)’과 ‘Cat(도움을 주는 고양이)’ 의 합성어로, 단순히 번역 도구가 아닌 외국인 학부모의 언어 습득을 돕는 지능형 조력자를 의미합니다.</p>
<p>ClipCat은 기존 번역 중심 서비스와 달리, 이해되지 않는 문장을 드래그하면 AI 가 쉬운 한국어와 모국어 번역을 함께 제공하고, 이를 통해 학부모가 자신의 언어로 이해하며 동시에 한국어 표현을 학습할 수 있는 환경을 제공합니다. </p>
<p>물론, “그렇다면 그냥 모든 문장을 모국어로 번역해서 보면 되는 것 아니냐?” 라는 의문이 생길 수 있습니다. 하지만 저희 팀은 ‘즉각적인 편의’보다는 ‘지속적인 학습’을 원하는 학부모님들에게 초점을 맞추었습니다. 따라서 ClipCat은 단순히 번역 제공에 그치지 않고, 사용자가 한국어를 직접 읽고 이해해 나가며 자연스럽게 언어 습득 경험을 쌓을 수 있도록 설계하였습니다. </p>
<p>또한 저희는 실질적인 수요와 접근성을 고려하여 통계청 2022년 기준 다문화 가정 내 상위 5개의 언어(베트남어, 러시아어, 우즈베키스탄어, 캄보디아어, 몽골어)를 우선적으로 지원하였습니다. 이를 통해 서비스의 초기 효용성과 실제 사용성을 높이고 언어권별 사용자에게 실질적인 도움을 줄 수 있도록 설계하였습니다. </p>
<p>또한, 일부 학교에서 운영 중인 <strong>QR 번역 서비스는</strong> 학교 재량과 번역 인력의 한계로 인해 일관된 품질을 제공하지 못하는 문제가 있었습니다. 이에 ClipCatsms AI가 직접 문서를 <strong>분석·요약·번역</strong>하는 방식을 도입하여, 교사나 외부 번역가의 개입 없이도 정확한 결과를 즉시 제공함으로써 학교와 학부모 간의 정보 불균형을 최소화하였습니다. </p>
<h2 id="개발">개발</h2>
<p>이렇게 아이디어 구체화 이후, 본격적인 개발을 시작했습니다. </p>
<p>저는 프론트엔드 개발을 담당하였는데요.  Next.js와 Axios를 중심으로 백엔드와의 통신 구조를 구현하고,UI 구성과 상호작용 로직을 Figma 디자인을 기반으로 직접 퍼블리싱하였습니다. 특히 챗봇 인터페이스와 업로드 화면의 주요 기능을 설계하며 사용자가 직관적으로 이용할 수 있도록 컴포넌트를 구조화하였습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/98787725-4c67-481e-ad25-5c720f0e3794/image.png" alt="">
또한 웹페이지가 정적으로 보이지 않도록, ClipCat 캐릭터에 눈을 깜빡이는 애니메이션을 추가하여 자칫 단조로울 수 있는 화면에 생동감과 몰입감을 더하였습니다.  이 작은 움직임이 사용자에게 친근한 인상을 주어, AI 조력자라는 서비스의 정체성을 시각적으로 강화하는 효과를 주었다고 생각합니다. 특히 이러한 세심한 시각적 요소 덕분에,  “세부적인 완성도와 감각적인 연출이 돋보였다”는 평가를 받으며 디자인적 완성도 측면에서 높은 평가를 받을 수 있었습니다.</p>
<h2 id="협업-및-멘토-피드백">협업 및 멘토 피드백</h2>
<p>해커톤은 한정된 시간 속에서 기획, 개발, 디자인이 동시에 이뤄지기에 팀원간의 빠른 의사소통과 역할 분담이 무엇보다 중요했습니다. 저희 팀은 서로 다른 학교에서 처음 만났지만, 각자의 강점을 바탕으로 자연스럽게 협업 체계를 구축할 수 있었습니다.</p>
<p>개발이 예상보다 빠르게 진행되어 2일 차 오후에는 대부분의 핵심 기능이 완성되었고, 이후에는 멘토님들의 피드백을 바탕으로 프로젝트의 완성도와 방향성을 다듬는 데 집중하였습니다. 멘토님들이 각 팀을 돌아다니며 직접 조언을 해주셨는데, 저희 팀장이 직접 서비스를 소개하고 설명하는 동안, 저는 옆에서 멘토님들의 피드백을 빠짐없이 기록하며 즉시 반영할 부분을 정리했던 기억이 있습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/2c91a7e5-8fae-48a0-b271-b0f3c5d374b9/image.png" alt=""></p>
<p>모두가 공감할 수 있듯이, 그때는 단순히 좋은 조언이네라고 듣지만,  막상 실제로 적용해보다보면 잊거나 놓치기 쉬운 내용이 많습니다. 때문에 모든 피드백을 노션에 <strong>정리해 팀 전체가 바로 참고할 수 있도록 시각화</strong>했습니다.</p>
<p>이러한 태도 덕분에 저희 팀은 수정 속도가 빠르고 방향성 있는 개선이 가능했습니다. 특히 “기존 번역 앱과의 차별점을 명확히 하라”, “단순 번역이 아닌 AI 기반의 학습적 접근을 강조하라” 등의 멘토 피드백을 적극 반영하여 서비스의 핵심 가치를 더욱 구체화할 수 있었습니다.</p>
<p>이러한 과정은 단순히 기술적 보완을 넘어, 서비스의 철학과 차별점을 명확히 정립하는 전환점이 되었다고 생각하는데요. 짧은 시간이었지만, 서로의 전문성을 존중하고 멘토님의 피드백을 빠르게 실험하며 발전시킨 점이 결국 수상으로 이어질 수 있었던 저희 팀의 가장 큰 강점이었다고 생각합니다.</p>
<h2 id="결과-및-수상_1등">결과 및 수상_1등</h2>
<p>해커톤 마지막 날, 2박 3일간의 개발 결과물을 발표하는 시간이였습니다. 저희 팀 ClipCat은 단순한 번역을 넘어 “이해 가능한 언어로의 변환”과 “학습으로의 확장”이라는 새로운 접근 방식을 통해 사회적 가치와 기술적 완성도를 동시에 실현했다는 평가를 받았습니다. 그 결과, 심사위원들로부터 높은 호평을 얻을 수 있었는데요.</p>
<p>심사는 네이버클라우드, 니어네트웍스 등 5개 기업 관계자와 정보통신기획평가원(IITP) 심사위원단이 함께 진행하였으며, 대상 2팀, 최우수상 1팀, 우수상 5팀이 선정되었습니다.
<img src="https://velog.velcdn.com/images/bom_0320/post/b9cac143-ea3d-4705-a179-56d6983fe2ba/image.png" alt=""></p>
<p>중에서도 저희 팀 <strong>ClipCat은 과학기술정보통신부 장관상(대상)</strong> 을 수상하였습니다. 🎉</p>
<h2 id="마치며">마치며..</h2>
<p><img src="https://velog.velcdn.com/images/bom_0320/post/4b3c68b8-56e6-466e-8ed6-537a4bd3241b/image.png" alt=""></p>
<p>이번 해커톤은 저에게 정말 의미있는 경험이였는데요. 이전에 1학년때는 운좋게 좋은 팀을 만나 수상을 하거나, 디자인 감각 덕분에 수상을 할수 있었던 경험이 있습니다. 하지만 이러한 경험들 속에서 언제나 마음 한켠에는 “진짜 내 실력으로 인정받고 싶다”는 갈증이 있었습니다. 그래서인지 그동안의 수상들은 기쁘면서도 어딘가 공허하게 느껴졌던 것 같습니다.</p>
<p>하지만 이번에는 달랐습니다. 기획 단계부터 직접 참여하고, 코드 한 줄 한 줄을 제 손으로 짜면서 결과물을 완성했기에 그 어느 때보다 큰 보람을 느꼈습니다. 짧은 시간 안에 오류를 해결하고, 구조를 다듬으며 완성도를 높여가는 과정 속에서 “내가 정말 개발자로 성장하고 있구나”라는 확신이 들었습니다. </p>
<p>무엇보다 이번 우승은 나 혼자만의 결과가 아닌, 각자의 강점을 살리며 함께 성장한 팀원들 덕분에 이룬 성과였다고 생각합니다. 서로 처음 만난 사이였지만, 기획, 디자인,개발 어느 한쪽도 소홀하지 않고 같은 목표를 향해 달려갔기에 이러한 결과를 얻을 수 있었다고 생각합니다. </p>
<p>이 경험을 통해 진짜 성장은 결과가 아니라 과정 속의 변화에서 비롯된다는 걸 배웠습니다. 이번 해커톤에선 그동안 제가 만들어왔던 모든 개발과 기획 방향을 다시 돌아보게 되는 계기가 되었으니깐요.☺️ </p>
<p>앞으로 이 갈증을 성장의 원동력으로 삼아, 더 깊이 있는 개발 역량을 쌓고, 함께 일하고 싶은 믿은직한 프론트엔드 개발자로 성장해 나가고 싶습니다. </p>
<p>긴 글 읽어주셔서 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[postcss에서 @tailwindcss/postcss를 사용하는 이유 (Tailwind v4 기준)]]></title>
            <link>https://velog.io/@bom_0320/postcss.config.mjs%EC%97%90%EC%84%9C-tailwindcsspostcss%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0-Tailwind-v4-%EA%B8%B0%EC%A4%80</link>
            <guid>https://velog.io/@bom_0320/postcss.config.mjs%EC%97%90%EC%84%9C-tailwindcsspostcss%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0-Tailwind-v4-%EA%B8%B0%EC%A4%80</guid>
            <pubDate>Thu, 12 Jun 2025 09:26:35 GMT</pubDate>
            <description><![CDATA[<h2 id="issue">Issue</h2>
<p><code>npx create-next-app</code>로 프로젝트를 만들면서 Tailwind CSS를 선택했더니, <code>postcss.config.mjs</code>가 자동으로 생성되었고, 그 안에는 다음과 같은 설정이 있었음</p>
<pre><code class="language-js">const config = {
  plugins: [&quot;@tailwindcss/postcss&quot;],
};

export default config;</code></pre>
<p>그런데 기존에 공식 문서나 블로그에서 봤던 예제들은 대부분 이랬다:</p>
<pre><code class="language-js">const config = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

export default config;</code></pre>
<p>내가 봤던 예제와, 자동으로 생성된 설정의 코드가 달라 혼란이 생겼고, 
챗 gpt 도 처음엔 @tailwindcss/postcss 방식이 잘못됐다라는 식으로 설명을 해서 더 헷갈리기 시작했다.</p>
<p>하지만 CNA에서 자동으로 생성되는 설정이 문제 있는 방식이라면 이미 이슈가 터졌을 테고,
공식에서 사용하는 템플릿이 틀릴 리가 없지 않은가?</p>
<p>그래서 더 챗gpt에 의존하지 않고 구글링을 더 해보기로 했다.</p>
<h2 id="최신-tailwind-v4-및-nextjs의-변화">최신 Tailwind v4 및 Next.js의 변화</h2>
<h3 id="tailwind-css-v41-이상-공식-문서에서-명시한-방식">Tailwind CSS v4.1 이상 공식 문서에서 명시한 방식:</h3>
<pre><code class="language-js">export default {
    plugins: {
        &quot;tailwindcss/postcss&quot;: {}.
    }
}
</code></pre>
<p>Tailwind CSs v4부터는 새로운 공식 PostCSS 플러그인인 <code>@tailwindcss/postcss</code>를 사용하도록 설정 방식이 바뀌었다.</p>
<ul>
<li>기존(v3): <code>tailwindcss</code>,<code>autoprefixer</code>를 직접 설정</li>
<li>변경(v4): <code>@tailwindcss/postcss</code> 하나로 통합 (내부적으로 처리)</li>
</ul>
<blockquote>
<p>이 플러그인은 Tailwind 설정, 변환, 최적화 과정을 모두 담당한다.</p>
</blockquote>
<h2 id="nextjs의-최신-템플릿">Next.js의 최신 템플릿</h2>
<p>Next.js v15.2 이상에서 Tailwind를 포함해 생성한 프로젝트는 다음 설정을 자동으로 생성한다.</p>
<pre><code class="language-bash">npx create-next-app my-app --tailwind</code></pre>
<ul>
<li><code>postcss.config.mjs</code> 파일 생성</li>
<li>내부에는 <code>&quot;@tailwindcss/postcss&quot;</code> 플러그인이 문자열로 등록됨</li>
<li>이는 Tailwind v4.1 기준의 공식 설정이다</li>
</ul>
<p>하지만 Tailwind CSS 공식 문서에는 여전히 다음과 같은 예제가 많이 등장한다:</p>
<pre><code class="language-js">plugins: {
  tailwindcss: {},
  autoprefixer: {},
}</code></pre>
<p>하지만 이건 <strong>Tailwind CSS v3 이하</strong> 의 설정 방식이며, v4부터는 더 이상 권장되지 않는다.</p>
<h2 id="요약-비교">요약 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Tailwind v3 이하</th>
<th>Tailwind v4.0</th>
<th>Tailwind v4.1 이상</th>
</tr>
</thead>
<tbody><tr>
<td>PostCSS 설정 방식</td>
<td><code>tailwindcss: {}</code></td>
<td>가능하지만 권장 안 함</td>
<td>✅ <code>@tailwindcss/postcss</code></td>
</tr>
<tr>
<td>설정 방식</td>
<td>직접 명시</td>
<td>중간 과도기</td>
<td>공식 통합 방식</td>
</tr>
<tr>
<td>Next.js 템플릿</td>
<td>수동 설정</td>
<td>일부 혼재</td>
<td>자동으로 <code>@tailwindcss/postcss</code> 생성됨</td>
</tr>
</tbody></table>
<h2 id="내가-얻은-인사이트">내가 얻은 인사이트</h2>
<ul>
<li>Tailwind CSS는 <strong>v4.1부터 설정 구조가 확실히 바뀌었고</strong>, 이에 따라 문서도 공식적으로 업데이트되었다.</li>
<li>공식 문서만이 절대적인 기준이다. 오래된 블로그, GPT 답변, 커뮤니티 포스트는 참고하되 반드시 <strong>버전 기준 확인이 필수</strong>다.</li>
<li><code>create-next-app</code>으로 Tailwind를 선택한 경우, <code>@tailwindcss/postcss</code>가 자동으로 설정되는 건 <strong>정확하고 공식적인 동작</strong>이다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[모바일 vs 데스크탑에서의 Navigation 구현 방식 정리]]></title>
            <link>https://velog.io/@bom_0320/%EB%AA%A8%EB%B0%94%EC%9D%BC-vs-%EB%8D%B0%EC%8A%A4%ED%81%AC%ED%83%91%EC%97%90%EC%84%9C%EC%9D%98-Navigation-%EA%B5%AC%ED%98%84-%EB%B0%A9%EC%8B%9D-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@bom_0320/%EB%AA%A8%EB%B0%94%EC%9D%BC-vs-%EB%8D%B0%EC%8A%A4%ED%81%AC%ED%83%91%EC%97%90%EC%84%9C%EC%9D%98-Navigation-%EA%B5%AC%ED%98%84-%EB%B0%A9%EC%8B%9D-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 11 Jun 2025 08:18:17 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/bom_0320/post/812d431d-2183-4979-a96d-05719868f470/image.png" alt=""></p>
<h2 id="개요">개요</h2>
<p>React + Tailwind 기반의 <code>main-nav.tsx</code> 컴포넌트에서, 모바일과 데스크탑은 서로 다른 방식으로 Navigation UI 를 구현하고 있다. 
그리고 그 차이와 이유를 명확이 이해하고자 다음과 같은 포스터를 작성하였다.</p>
<h2 id="💻-데스크탑-nav-구현-방식">💻 데스크탑 nav 구현 방식</h2>
<h3 id="사용-구조">사용 구조</h3>
<pre><code class="language-tsx">{items?.length ? (
  &lt;nav className=&quot;hidden gap-6 md:flex&quot;&gt;
    {items.map((item) =&gt; (
      &lt;Link href={item.href}&gt;...&lt;/Link&gt;
    ))}
  &lt;/nav&gt;
) : null}</code></pre>
<h3 id="해석">해석</h3>
<ul>
<li><code>md:flex</code> 는 데스크탑(medium ≥ 768px) 이상일 때만 메뉴 보이게 함</li>
<li><code>hidden</code> : 기본은 숨김 (모바일에서는 안 보이도록)</li>
<li>메뉴 항목은 <code>&lt;Link&gt;</code> 컴포넌트로 직접 나열</li>
<li>별도 state 관리 없이 항상 보이는 수평 메뉴</li>
</ul>
<hr>
<h2 id="📱-모바일-nav-구현-방식">📱 모바일 nav 구현 방식</h2>
<h3 id="사용-구조-1">사용 구조</h3>
<pre><code class="language-tsx">&lt;button className=&quot;md:hidden&quot; onClick={...}&gt;
  {showMobileMenu ? &lt;Icons.close /&gt; : &lt;Icons.logo /&gt;}
  &lt;span&gt;Menu&lt;/span&gt;
&lt;/button&gt;

{showMobileMenu &amp;&amp; items &amp;&amp; (
  &lt;MobileNav items={items}&gt;{children}&lt;/MobileNav&gt;
)}</code></pre>
<h3 id="해석-1">해석</h3>
<ul>
<li><code>md: hidden</code> : 데스크탑에서는 버튼 안 보이고, 모바일에서만 보임</li>
<li>햄버거 버튼 클릭 시 <code>showMobileMenu</code> 상태를 토글</li>
<li><code>showMobileMenu === true</code> 일 때만 <code>&lt;MobileNav&gt;</code> 렌더링</li>
<li><code>&lt;MobileNav /&gt;</code> 는 전체 화면을 덮는 오버레이 메뉴</li>
</ul>
<h2 id="📊-비교-요약">📊 비교 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>💻 데스크탑</th>
<th>📱 모바일</th>
</tr>
</thead>
<tbody><tr>
<td>렌더링 방식</td>
<td><code>&lt;nav&gt;</code> + <code>&lt;Link&gt;</code></td>
<td><code>&lt;button&gt;</code> + <code>&lt;MobileNav&gt;</code></td>
</tr>
<tr>
<td>상태 관리</td>
<td>❌ 없음 (항상 보임)</td>
<td>✅ 있음 (<code>useState</code>)</td>
</tr>
<tr>
<td>표시 조건</td>
<td><code>md:flex</code> 이상</td>
<td><code>md:hidden</code> 이하</td>
</tr>
<tr>
<td>UI 형태</td>
<td>수평 메뉴바</td>
<td>오버레이(전체화면) 메뉴</td>
</tr>
<tr>
<td>사용자 조작</td>
<td>별도 조작 없음</td>
<td>햄버거 버튼 클릭 필요</td>
</tr>
</tbody></table>
<hr>
<h2 id="🎯-결론">🎯 결론</h2>
<blockquote>
<p>반응형 디자인에서 Navigation은 단순히 숨기고 보이는 걸 넘어서,</p>
<p><strong>기기 환경에 맞는 UX 구조 자체를 바꿔야 한다.</strong></p>
<p>데스크탑은 공간이 넓으므로 메뉴를 펼쳐두고,</p>
<p>모바일은 공간이 좁기 때문에 <strong>토글 기반의 오버레이 메뉴</strong>로 구현하는 것이 표준 패턴이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - Tailwind의 flex, hidden, md:flex 이해 & display의 본질]]></title>
            <link>https://velog.io/@bom_0320/TIL-Tailwind%EC%9D%98-flex-hidden-mdflex-%EC%9D%B4%ED%95%B4-display%EC%9D%98-%EB%B3%B8%EC%A7%88</link>
            <guid>https://velog.io/@bom_0320/TIL-Tailwind%EC%9D%98-flex-hidden-mdflex-%EC%9D%B4%ED%95%B4-display%EC%9D%98-%EB%B3%B8%EC%A7%88</guid>
            <pubDate>Wed, 11 Jun 2025 07:28:11 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-헷갈렸던-부분">오늘 헷갈렸던 부분</h2>
<p>처음엔 <code>flex</code> 를 단순히 가로 정렬하는 속성이라고 이해했었고, <code>md: flex</code>는 그저 반응형 정렬용이라고 생각음
그래서 nav 메뉴를 보여주기 위해 굳이 <code>md: flex</code>가 필요한 이유가 잘 이해되지 않았음</p>
<h2 id="핵심-정리">핵심 정리</h2>
<h3 id="1-display가-본질">1. <code>display</code>가 본질</h3>
<ul>
<li>HTML 요소가 <strong>보이냐 / 안 보이냐</strong>, <strong>어떻게 배치되느냐</strong>를 결정하는 핵심 속성이 <code>display</code></li>
<li><code>display: none</code> → 요소 자체가 화면에서 사라짐 (보이지 않음 + 공간도 차지하지 않음)</li>
<li><code>display: flex</code> → 요소가 보이고, 자식 요소들이 flexbox 방식으로 배치됨</li>
</ul>
<h3 id="2-flex는-정렬-방법이지-보이게-만드는-속성은-아님">2. <code>flex</code>는 &quot;정렬 방법&quot;이지, &quot;보이게 만드는 속성&quot;은 아님</h3>
<ul>
<li><code>flex</code>는 실제로는 <code>display: flex</code>를 의미하고,</li>
<li>자식 요소들을 <strong>가로 or 세로 방향</strong>으로 정렬하는 도구일 뿐임</li>
<li>단, 기본값이 <code>flex-direction: row</code>라서 보통 가로 정렬로 보임 → 그래서 흔히들 &quot;flex = 가로 정렬&quot;로 착각함</li>
</ul>
<h3 id="3-tailwind에서의-hidden-mdflex는-결국-display-제어">3. Tailwind에서의 <code>hidden</code>, <code>md:flex</code>는 결국 <code>display</code> 제어</h3>
<table>
<thead>
<tr>
<th>Tailwind 클래스</th>
<th>실제 CSS 효과</th>
</tr>
</thead>
<tbody><tr>
<td><code>hidden</code></td>
<td><code>display: none</code></td>
</tr>
<tr>
<td><code>flex</code></td>
<td><code>display: flex</code></td>
</tr>
<tr>
<td><code>md:flex</code></td>
<td>768px 이상에서 <code>display: flex</code></td>
</tr>
<tr>
<td><code>md:hidden</code></td>
<td>768px 이상에서 <code>display: none</code></td>
</tr>
</tbody></table>
<p>→ 즉, Tailwind는 <code>display</code> 속성을 <strong>조건부로 적용하는 래퍼일 뿐</strong>이다.</p>
<hr>
<h3 id="실전-적용-nav-메뉴-예시">실전 적용: <code>nav</code> 메뉴 예시</h3>
<pre><code class="language-tsx">&lt;nav className=&quot;hidden md:flex&quot;&gt;...&lt;/nav&gt;</code></pre>
<ul>
<li><p>모바일에서는 <code>display: none</code> → 메뉴가 숨겨짐</p>
</li>
<li><p>데스크탑에서는 <code>display: flex</code> → 메뉴가 보이고, 자식들이 가로로 정렬됨</p>
</li>
<li><p>메뉴의 <strong>보임 여부 자체가 flex 때문에 결정된 것처럼 보이지만</strong>,</p>
<p>  실제 핵심은 <code>display: none → display: flex</code>라는 <strong>display 상태의 전환</strong>이었다.</p>
</li>
</ul>
<hr>
<h3 id="오늘의-교훈">오늘의 교훈</h3>
<blockquote>
<p>Tailwind의 hidden, flex, md:flex 등은 전부 결국 display 속성을 조작하는 방식임</p>
<p>요소가 보이느냐 안 보이느냐는 <code>display</code>가 결정하며, <code>flex</code>는 정렬 도구일 뿐임</p>
<p>반응형 조건부 렌더링도 <code>display</code>의 on/off 개념으로 이해해보자</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL - Tailwind CSS v4에서 npx tailwindcss init -p 명령어가 작동하지 않는 이유와 해결 과정]]></title>
            <link>https://velog.io/@bom_0320/TIL-Tailwind-CSS-v4%EC%97%90%EC%84%9C-npx-tailwindcss-init-p-%EB%AA%85%EB%A0%B9%EC%96%B4%EA%B0%80-%EC%9E%91%EB%8F%99%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@bom_0320/TIL-Tailwind-CSS-v4%EC%97%90%EC%84%9C-npx-tailwindcss-init-p-%EB%AA%85%EB%A0%B9%EC%96%B4%EA%B0%80-%EC%9E%91%EB%8F%99%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Mon, 09 Jun 2025 10:58:26 GMT</pubDate>
            <description><![CDATA[<h3 id="🐛-문제-상황">🐛 문제 상황</h3>
<p>Tailwind를 CLI로 설치할 때 다음 명령어를 입력했다:</p>
<pre><code class="language-bash">
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
</code></pre>
<p>그런데 <code>npx tailwindcss init -p</code> 명령어에서 다음과 같은 에러가 발생함:</p>
<pre><code>
npm ERR! could not determine executable to run
</code></pre><p>또한, <code>node_modules/.bin/tailwindcss</code>가 존재하지 않음.</p>
<hr>
<h3 id="📌-원인-분석">📌 원인 분석</h3>
<p>이 문제는 <strong>Tailwind CSS v4부터 공식적으로 CLI 기반 init 명령어를 제거했기 때문</strong>이다.</p>
<h3 id="🚨-tailwind-css-v3-vs-v4-차이">🚨 Tailwind CSS v3 vs v4 차이</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Tailwind v3</th>
<th>Tailwind v4</th>
</tr>
</thead>
<tbody><tr>
<td><code>npx tailwindcss init</code></td>
<td>✅ 지원됨 (자동으로 설정 파일 생성)</td>
<td>❌ 제거됨 (수동 작성 필요)</td>
</tr>
<tr>
<td>설정 파일 생성</td>
<td>CLI로 자동 생성</td>
<td>사용자가 직접 파일 생성</td>
</tr>
<tr>
<td><code>autoprefixer</code> 필요</td>
<td>필요함</td>
<td>❌ 내장된 Lightning CSS가 처리함</td>
</tr>
<tr>
<td>content 설정 방식</td>
<td>직접 명시해야 함 (<code>tailwind.config.js</code>)</td>
<td>자동 감지 또는 명시 (선택 사항)</td>
</tr>
</tbody></table>
<hr>
<h3 id="🔧-해결-방법-tailwind-css-v4-기준">🔧 해결 방법 (Tailwind CSS v4 기준)</h3>
<h3 id="1-필요한-패키지-수동-설치">1. 필요한 패키지 수동 설치</h3>
<pre><code class="language-bash">
npm install -D tailwindcss postcss autoprefixer
</code></pre>
<blockquote>
<p>참고: 사실 v4에서는 autoprefixer, postcss는 없어도 동작은 가능. Vite처럼 특정 빌드 툴을 쓰는 경우엔 더 단순화됨.</p>
</blockquote>
<h3 id="2-설정-파일을-직접-작성">2. 설정 파일을 <strong>직접 작성</strong></h3>
<ul>
<li><code>tailwind.config.js</code> 예시:</li>
</ul>
<pre><code class="language-jsx">
/** @type {import(&#39;tailwindcss&#39;).Config} */
module.exports = {
  content: [
    &quot;./src/**/*.{html,js,ts,jsx,tsx}&quot;,
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
</code></pre>
<ul>
<li><code>postcss.config.js</code> 예시:</li>
</ul>
<pre><code class="language-jsx">
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}
</code></pre>
<h3 id="3-글로벌-css-파일-설정-globalscss-등">3. 글로벌 CSS 파일 설정 (<code>globals.css</code> 등)</h3>
<pre><code class="language-css">
@tailwind base;
@tailwind components;
@tailwind utilities;
</code></pre>
<hr>
<h3 id="💡-결론-및-배운-점">💡 결론 및 배운 점</h3>
<ul>
<li>Tailwind v4부터는 더 이상 <code>npx tailwindcss init</code> 명령어를 사용할 수 없다.</li>
<li>대신 <strong>설정 파일을 직접 작성하는 방식으로 변경</strong>되었으며, 이는 불편해 보이지만 더 유연한 커스터마이징을 위한 구조이다.</li>
<li>문서와 커뮤니티 블로그에서 보던 예전 설치법을 그대로 따라하면 위와 같은 오류가 생기므로, <strong>버전에 맞는 공식 문서를 참고하는 습관이 중요하다.</strong></li>
</ul>
<h3 id="💬-참고-링크">💬 참고 링크</h3>
<p><a href="https://tailwindcss.com/docs/installation/framework-guides/nextjs">https://tailwindcss.com/docs/installation/framework-guides/nextjs</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL: localStorage와 JSON.stringify vs. JSON.parse ]]></title>
            <link>https://velog.io/@bom_0320/TIL-localStorage%EC%99%80-JSON.stringify-vs.-JSON.parse</link>
            <guid>https://velog.io/@bom_0320/TIL-localStorage%EC%99%80-JSON.stringify-vs.-JSON.parse</guid>
            <pubDate>Tue, 27 May 2025 02:22:41 GMT</pubDate>
            <description><![CDATA[<p>브라우저 저장소인 localStorage의 개념과 JSON 변환 처리의 필요성 이해하기 위해서 다음과 같은 포스터를 작성했다.</p>
<h2 id="localstorage-란">localStorage 란?</h2>
<p><strong>localStorage</strong> 는 <strong>브라우저가 제공하는 클라이언트 측 저장소</strong>이다.</p>
<p>HTML5에서 도입된 <strong>Web Storage AP</strong>I 중 하나이며, key-value 형태의 데이터를 <strong>브라우저에 영구적으로 저장</strong>할 수 있다.</p>
<h3 id="💡-주요-특징">💡 주요 특징</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>지속성</td>
<td>브라우저를 꺼도, PC를 재부팅해도 데이터가 남아있음</td>
</tr>
<tr>
<td>저장 용량</td>
<td>보통 도메인당 약 5MB ~ 10MB 저장 가능 (쿠키보다 훨씬 큼)</td>
</tr>
<tr>
<td>접근 범위</td>
<td>같은 도메인에서만 접근 가능 (보안 보호)</td>
</tr>
<tr>
<td>저장 형식</td>
<td>반드시 <strong>문자열(string)</strong>만 저장 가능</td>
</tr>
<tr>
<td>사용 예시</td>
<td>자동 로그인 유지, 글 임시 저장, 테마 설정 등</td>
</tr>
</tbody></table>
<hr>
<h3 id="사용-예시">사용 예시</h3>
<pre><code class="language-tsx">// 저장
localStorage.setItem(&#39;nickname&#39;, &#39;김봄&#39;);

// 불러오기
const name = localStorage.getItem(&#39;nickname&#39;);

// 삭제
localStorage.removeItem(&#39;nickname&#39;)l

// 전체 삭제
localStorage.clear();</code></pre>
<h2 id="❓-그런데-객체배열은-왜-안-되는가">❓ 그런데 객체/배열은 왜 안 되는가?</h2>
<p>localStorage는 문자열만 저장 가능하므로, </p>
<p><strong>자바스크립트의 객체나 배열을 바로 저장할 수 없다.</strong></p>
<p>이럴 때 사용하는 것이 바로:</p>
<ul>
<li><code>JSON.stringify()</code>  : 객체 → 문자열</li>
<li><code>JSON.parse()</code>: 문자열 → 객체</li>
</ul>
<hr>
<h2 id="🔄-jsonstringify--jsonparse-흐름">🔄 JSON.stringify / JSON.parse 흐름</h2>
<h3 id="🔷-저장-시-객체-→-문자열">🔷 저장 시 (객체 → 문자열)</h3>
<pre><code class="language-tsx">const post = { title: &quot;안녕&quot;, content: &quot;첫 글이얌&quot; };

localStorage.setItem(&#39;posts&#39;, JSON.stringify([post]));
</code></pre>
<ul>
<li><code>[post]</code> 는 자바스크립트 배열</li>
<li><code>JSON.stringify()</code> 로 변환 시, 아래처럼 JSON 문자열로 바뀜:</li>
</ul>
<pre><code class="language-json">&quot;[{\&quot;title\&quot;:\&quot;안녕\&quot;,\&quot;content\&quot;:\&quot;첫 글이야\&quot;}]&quot;
</code></pre>
<hr>
<h3 id="🔶-꺼낼-때-문자열-→-객체">🔶 꺼낼 때 (문자열 → 객체)</h3>
<pre><code class="language-tsx">const saved = localStorage.getItem(&#39;posts&#39;);

if (saved) {
  const parsed = JSON.parse(saved);
  console.log(parsed[0].title);  // &quot;안녕&quot;
}</code></pre>
<ul>
<li><code>JSON.parse()</code> 는 문자열을 다시 자바스크립트 객체 배열로 복원</li>
<li>이후 배열처럼 다루기 가능</li>
</ul>
<hr>
<h2 id="📦-전체-흐름-정리">📦 전체 흐름 정리</h2>
<pre><code class="language-jsx">[ JS 객체/배열 ]
    ↓ JSON.stringify
&quot;문자열&quot; (localStorage에 저장됨)
    ↑ JSON.parse
[ JS 객체/배열 복원 ]
</code></pre>
<hr>
<h2 id="🔥-핵심-요약">🔥 핵심 요약</h2>
<table>
<thead>
<tr>
<th>질문</th>
<th>답변</th>
</tr>
</thead>
<tbody><tr>
<td>localStorage에 왜 stringify가 필요해?</td>
<td>문자열만 저장 가능하기 때문</td>
</tr>
<tr>
<td>왜 다시 parse 해야 해?</td>
<td>문자열로는 <code>.title</code> 등 프로퍼티 접근이 안 되기 때문</td>
</tr>
<tr>
<td>변환 형태는 어떻게 생김?</td>
<td>객체가 <code>&quot;{...}&quot;</code> 형태의 JSON 문자열로 바뀜</td>
</tr>
<tr>
<td>언제 써야 해?</td>
<td>localStorage, sessionStorage, 서버 통신 시 필수</td>
</tr>
</tbody></table>
<hr>
<h2 id="✅-실전에서-적용한-코드-예시-react-게시판-프로젝트">✅ 실전에서 적용한 코드 예시 (React 게시판 프로젝트)</h2>
<pre><code class="language-tsx">
// 저장
useEffect(() =&gt; {
  localStorage.setItem(&#39;posts&#39;, JSON.stringify(posts));
}, [posts]);

// 불러오기
useEffect(() =&gt; {
  const saved = localStorage.getItem(&#39;posts&#39;);
  if (saved) {
    setPosts(JSON.parse(saved));
  }
}, []);
</code></pre>
<blockquote>
<p>posts 상태가 바뀌면 자동으로 localStorage에 저장</p>
<p>페이지가 새로고침되면 localStorage에서 다시 불러와 복구됨</p>
</blockquote>
<hr>
<h2 id="💬-느낀점">💬 느낀점</h2>
<p>이번 학습을 통해 단순히 데이터를 저장하는 것만이 아니라,</p>
<p><strong>자바스크립트 객체와 문자열 간의 변환 로직이 왜 필요한지</strong>,</p>
<p>그리고 그 과정에서 어떤 데이터 형상이 오가는지 구체적으로 이해하게 되었다.</p>
<p>프론트엔드 개발에서 localStorage를 활용할 때는 무조건 <code>JSON.stringify</code> / <code>JSON.parse</code>가 함께 사용된다는 점을 명확히 인지하게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Drag & Drop에서 style을 다루는 방식 차이 (Draggable vs Droppable)]]></title>
            <link>https://velog.io/@bom_0320/React-Drag-Drop%EC%97%90%EC%84%9C-style%EC%9D%84-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B0%A9%EC%8B%9D-%EC%B0%A8%EC%9D%B4-Draggable-vs-Droppable</link>
            <guid>https://velog.io/@bom_0320/React-Drag-Drop%EC%97%90%EC%84%9C-style%EC%9D%84-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%B0%A9%EC%8B%9D-%EC%B0%A8%EC%9D%B4-Draggable-vs-Droppable</guid>
            <pubDate>Mon, 26 May 2025 05:15:29 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-문제-상황">🤔 문제 상황</h2>
<p>드래그 앤 드롭 리스트를 만들다가 궁금한 점이 생김:</p>
<ul>
<li><code>ul</code> 태그 (<code>Droppable</code>)에는 그냥 <code>style={{ padding: 20 }}</code> 이렇게 간단하게 스타일을 줘도 되는데,</li>
<li><code>li</code> 태그 (<code>Draggable</code>)에는 <code>...provided.draggableProps.style</code>처럼 <strong>복사해서 스타일을 덧붙여야</strong> 작동함</li>
</ul>
<p>왜 그런 차이가 생기는 걸까?</p>
<hr>
<h2 id="✅-핵심-개념-요약">✅ 핵심 개념 요약</h2>
<h3 id="🔹-draggable에는-라이브러리-내부에서-계산한-style이-있다">🔹 Draggable에는 라이브러리 내부에서 계산한 style이 있다!</h3>
<pre><code class="language-tsx">style={{
  ...provided.draggableProps.style,
  padding: &#39;8px&#39;,
}}
</code></pre>
<ul>
<li><code>provided.draggableProps.style</code>에는 드래그 중 <strong>위치 이동, 애니메이션, 트랜지션 등</strong>을 위한 내부 계산된 스타일이 담겨 있음</li>
<li>이 스타일을 유지하지 않으면 드래그 기능이 깨짐!</li>
<li>그래서 <strong>기존 스타일을 복사(...)하고, 내가 원하는 스타일을 덧붙여야 함</strong></li>
</ul>
<hr>
<h3 id="🔹-droppable에는-별도로-제공되는-style이-없다">🔹 Droppable에는 별도로 제공되는 style이 없다</h3>
<pre><code class="language-tsx">&lt;ul style={{ padding: 20 }}&gt;
</code></pre>
<ul>
<li><code>provided.droppableProps</code>는 드롭 이벤트를 위한 속성만 제공 (<code>data-*</code>, role 등)</li>
<li>별도로 계산된 style은 없음 → 내가 자유롭게 스타일 지정해도 기능에 영향 없음</li>
</ul>
<hr>
<h3 id="🔹-jsx에서-style-중괄호가-두-번-쓰이는-이유">🔹 JSX에서 <code>style={{}}</code> 중괄호가 두 번 쓰이는 이유</h3>
<ul>
<li>JSX에서는 JavaScript 표현식은 <code>{}</code>로 감싸야 하고,</li>
<li><code>style</code>은 JS 객체니까 <code>{{ ... }}</code> 형태가 됨</li>
</ul>
<p>📌 예:</p>
<pre><code class="language-tsx">&lt;div style={{ color: &#39;red&#39;, margin: &#39;10px&#39; }} /&gt;
</code></pre>
<hr>
<h2 id="📊-정리-표">📊 정리 표</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>Draggable</code>의 style</td>
<td>드래그 동작을 위해 내부 style 계산 포함됨 → 반드시 복사해서 사용</td>
<td><code>...provided.draggableProps.style</code></td>
</tr>
<tr>
<td><code>Droppable</code>의 style</td>
<td>라이브러리가 style을 계산해서 넘기지 않음 → 내가 직접 지정</td>
<td><code>style={{ padding: 20 }}</code></td>
</tr>
<tr>
<td><code>{{}}</code> 문법</td>
<td>JSX + JS 객체 표현식 조합</td>
<td><code>style={{ color: &#39;red&#39; }}</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="✅-한-줄-요약">✅ 한 줄 요약</h2>
<blockquote>
<p>Draggable은 라이브러리 내부 style을 유지해야 하므로 ...style로 복사해서 써야 하고,Droppable은 자유롭게 스타일을 지정해도 된다.
JSX의 {{}}는 JS 객체를 넘기기 위한 문법상 구조다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[@hello-pangea/dnd - provided 객체 정리 (Droppable vs Draggable)]]></title>
            <link>https://velog.io/@bom_0320/hello-pangeadnd-provided-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-Droppable-vs-Draggable</link>
            <guid>https://velog.io/@bom_0320/hello-pangeadnd-provided-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-Droppable-vs-Draggable</guid>
            <pubDate>Tue, 20 May 2025 09:03:54 GMT</pubDate>
            <description><![CDATA[<h2 id="✅-provided란">✅ <code>provided</code>란?</h2>
<blockquote>
<p>@hello-pangea/dnd 라이브러리에서</p>
<p><code>Droppable</code>, <code>Draggable</code> 컴포넌트가 드래그 기능을 동작시키기 위해</p>
<p>자식 함수(children)에 넘겨주는 <strong>필수 도구 세트 객체</strong></p>
</blockquote>
<hr>
<h2 id="🧭-왜-존재하는가">🧭 왜 존재하는가?</h2>
<p>React에선 DOM 요소를 직접 조작하지 않기 때문에,</p>
<p>라이브러리가 드래그 기능을 위해 꼭 필요한 값들 (<code>ref</code>, props 등)을</p>
<p>우리가 <strong>직접 붙일 수 있도록 제공해주는 방식</strong>이 바로 <code>provided</code>.</p>
<hr>
<h2 id="✅-droppable의-provided">✅ Droppable의 <code>provided</code></h2>
<pre><code class="language-tsx">&lt;Droppable droppableId=&quot;myList&quot;&gt;
  {(provided) =&gt; (
    &lt;ul
      ref={provided.innerRef}
      {...provided.droppableProps}
    &gt;
      {items.map(...)}
      {provided.placeholder}
    &lt;/ul&gt;
  )}
&lt;/Droppable&gt;
</code></pre>
<h3 id="🔹-droppableprovided-구조">🔹 DroppableProvided 구조</h3>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
<th>필수 여부</th>
</tr>
</thead>
<tbody><tr>
<td><code>innerRef</code></td>
<td>드롭 가능한 DOM 요소에 ref 연결</td>
<td>✅ 필수</td>
</tr>
<tr>
<td><code>droppableProps</code></td>
<td>드롭 기능을 위한 속성(data-*)들</td>
<td>✅ 필수</td>
</tr>
<tr>
<td><code>placeholder</code></td>
<td>드래그 중 비는 공간을 채우는 가짜 요소</td>
<td>✅ 필수</td>
</tr>
</tbody></table>
<h3 id="🔸-중요-포인트">🔸 중요 포인트</h3>
<ul>
<li><p><code>provided.placeholder</code>는 반드시 <code>&lt;ul&gt;</code> 안에 있어야 한다.</p>
<p>  → <code>&lt;ul&gt;</code> 바깥에 두면 React가 HTML 구조 에러로 판단함!</p>
</li>
</ul>
<hr>
<h2 id="✅-draggable의-provided">✅ Draggable의 <code>provided</code></h2>
<pre><code class="language-tsx">![](https://velog.velcdn.com/images/bom_0320/post/82e0c9f6-2702-4b21-9816-c970916259fc/image.png)
&lt;Draggable draggableId={item} index={index}&gt;
  {(provided) =&gt; (
    &lt;li
      ref={provided.innerRef}
      {...provided.draggableProps}
      {...provided.dragHandleProps}
      style={{
        ...provided.draggableProps.style,
        ...
      }}
    &gt;
      {item}
    &lt;/li&gt;
  )}
&lt;/Draggable&gt;
</code></pre>
<h3 id="🔹-draggableprovided-구조">🔹 DraggableProvided 구조</h3>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
<th>필수 여부</th>
</tr>
</thead>
<tbody><tr>
<td><code>innerRef</code></td>
<td>드래그 가능한 요소에 ref 연결</td>
<td>✅ 필수</td>
</tr>
<tr>
<td><code>draggableProps</code></td>
<td>위치 계산, 드래그 이동 등 기본 props</td>
<td>✅ 필수</td>
</tr>
<tr>
<td><code>dragHandleProps</code></td>
<td>사용자가 마우스로 잡는 “핸들” 역할</td>
<td>✅ 필수</td>
</tr>
</tbody></table>
<hr>
<h2 id="🧠-droppable-vs-draggable-비교">🧠 Droppable vs Draggable 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Droppable</th>
<th>Draggable</th>
</tr>
</thead>
<tbody><tr>
<td>역할</td>
<td>드롭 가능한 <strong>영역</strong></td>
<td>드래그 가능한 <strong>요소</strong></td>
</tr>
<tr>
<td>내부 속성</td>
<td><code>innerRef</code>, <code>droppableProps</code>, <code>placeholder</code></td>
<td><code>innerRef</code>, <code>draggableProps</code>, <code>dragHandleProps</code></td>
</tr>
<tr>
<td>ref 연결 대상</td>
<td><code>&lt;ul&gt;</code>, <code>&lt;div&gt;</code> 등 드롭 영역</td>
<td><code>&lt;li&gt;</code>, 카드, 요소 등 드래그 대상</td>
</tr>
<tr>
<td>공통점</td>
<td>모두 <strong>React에서 DOM을 직접 연결해야 하기 때문에 ref와 props가 필요</strong></td>
<td></td>
</tr>
</tbody></table>
<hr>
<h2 id="💡-헷갈렸던-점--배운-것">💡 헷갈렸던 점 &amp; 배운 것</h2>
<table>
<thead>
<tr>
<th>질문</th>
<th>정리된 개념</th>
</tr>
</thead>
<tbody><tr>
<td><code>provided</code>는 Droppable이랑 Draggable이 공통으로 쓰는 거야?</td>
<td>이름은 같지만 <strong>완전히 다른 타입과 역할을 가짐</strong></td>
</tr>
<tr>
<td><code>ref</code>, <code>props</code>를 왜 붙여야 해?</td>
<td>드래그 위치 계산과 이벤트 처리를 위해 <strong>실제 DOM에 연결해야 하기 때문</strong></td>
</tr>
<tr>
<td><code>provided.placeholder</code>는 꼭 <code>&lt;ul&gt;</code> 안에 있어야 해?</td>
<td>✅ 그래야 HTML 구조가 맞고 드래그 시 레이아웃도 깨지지 않음</td>
</tr>
<tr>
<td>JSX 안에서 왜 <code>{(provided) =&gt; (...)}</code>처럼 함수로 써야 해?</td>
<td>Droppable이 값을 넘겨주기 위해 <strong>children을 함수로 받기 때문 (함수형 children)</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="🎯-한-줄-요약">🎯 한 줄 요약</h2>
<blockquote>
<p>provided는 드래그 기능을 DOM에 정확히 연결하기 위해 라이브러리가 넘겨주는 “도구 세트”이고,
이걸 제대로 붙여야만 드래그 앤 드롭이 정상 작동한다.
Droppable과 Draggable은 각각 전혀 다른 provided 구조를 가진다!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[ref 가 뭐길래 DND에 꼭 필요할까?]]></title>
            <link>https://velog.io/@bom_0320/ref-%EA%B0%80-%EB%AD%90%EA%B8%B8%EB%9E%98-DND%EC%97%90-%EA%BC%AD-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@bom_0320/ref-%EA%B0%80-%EB%AD%90%EA%B8%B8%EB%9E%98-DND%EC%97%90-%EA%BC%AD-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Tue, 20 May 2025 08:07:02 GMT</pubDate>
            <description><![CDATA[<h2 id="ref란">ref란?</h2>
<blockquote>
<p>ref는 React가 HTML 요소(실제 DOM)를 직접 찾을 수 있게 해주는 도구다.</p>
</blockquote>
<hr>
<h2 id="🧭-정리">🧭 정리</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>평소 코드</td>
<td>JSX → 가상 DOM → 실제 DOM으로 자동 반영됨</td>
</tr>
<tr>
<td>ref 사용 시</td>
<td>React가 아닌 <strong>개발자가 직접 실제 DOM을 조작</strong>함</td>
</tr>
<tr>
<td>언제 ref를 씀?</td>
<td>드래그, 포커스, 스크롤, 사이즈 측정 등 <strong>가상 DOM으론 할 수 없는 작업</strong>을 할 때 사용</td>
</tr>
</tbody></table>
<hr>
<h3 id="⭐️-ref는-왜-필요한가">⭐️ ref는 왜 필요한가?</h3>
<blockquote>
<p>평소엔 React가 가상 DOM을 관리하지만,</p>
<p>정말 필요한 경우, <strong>내가 직접 진짜 DOM을 만질 수 있게 해주는 “예외 통로”</strong>가 바로 <code>ref</code>다.</p>
</blockquote>
<hr>
<h2 id="💡-실제-코드-예시">💡 실제 코드 예시</h2>
<pre><code class="language-tsx">import { useRef, useEffect } from &#39;react&#39;;

function MyComponent() {
  const myBoxRef = useRef(null);

  useEffect(() =&gt; {
    console.log(myBoxRef.current); // 실제 DOM 요소가 출력됨
    myBoxRef.current.style.backgroundColor = &#39;lightblue&#39;;
  }, []);

  return (
    &lt;div ref={myBoxRef}&gt;
      Hello
    &lt;/div&gt;
  );
}</code></pre>
<h3 id="✅-위-코드에서-벌어지는-일">✅ 위 코드에서 벌어지는 일</h3>
<ul>
<li><code>myBoxRef</code>는 <code>div</code>를 가리키는 <strong>ref 객체</strong></li>
<li><code>myBoxRef.current</code>는 실제 HTML <code>&lt;div&gt;</code> 요소</li>
<li>여기에 직접 스타일을 주거나 포커스를 줄 수 있음</li>
</ul>
<hr>
<h2 id="🧠-ref를-쓰는-대표적인-상황">🧠 ref를 쓰는 대표적인 상황</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>드래그 앤 드롭</td>
<td>요소의 위치와 크기를 직접 측정해야 할 때</td>
</tr>
<tr>
<td>포커스 이동</td>
<td>input 창에 자동으로 포커스 줄 때</td>
</tr>
<tr>
<td>스크롤 조작</td>
<td>특정 위치로 스크롤 시킬 때</td>
</tr>
<tr>
<td>애니메이션 적용</td>
<td>DOM에 직접 스타일 조작할 때</td>
</tr>
</tbody></table>
<hr>
<h2 id="🧲-drag-and-drop에서-왜-꼭-ref가-필요할까">🧲 Drag and Drop에서 왜 꼭 ref가 필요할까?</h2>
<p>드래그 앤 드롭은 요소의 위치를 직접 추적해야 해.</p>
<ul>
<li>예: &quot;지금 이 박스 위에 마우스를 놓고 있어?&quot;</li>
<li>이걸 알려면 실제 DOM의 <strong>위치 정보</strong>를 가져와야 해</li>
</ul>
<pre><code class="language-tsx">const rect = element.getBoundingClientRect();</code></pre>
<p>이 함수는 <strong>실제 HTML 요소</strong>의 화면 위치(px 단위)를 알려줌 → <strong>가상 DOM으로는 절대 불가능</strong></p>
<p>그래서 드래그 라이브러리들은 <code>&lt;li&gt;</code>, <code>&lt;ul&gt;</code> 같은 DOM 요소를 ref로 직접 가리켜서,</p>
<p>브라우저에게 위치 정보를 묻고 → 거기에 드롭해도 되는지 판단함.</p>
<hr>
<h2 id="❗️그렇다면-가상-dom만-쓰면-어떤-문제가-생길까">❗️그렇다면 가상 DOM만 쓰면 어떤 문제가 생길까?</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>드롭 위치 계산 불가</td>
<td>마우스가 어디에 있는지 알 수 없어</td>
</tr>
<tr>
<td>충돌 감지 불가</td>
<td>두 요소가 겹쳤는지 모름</td>
</tr>
<tr>
<td>화면 변화 감지 불가</td>
<td>스크롤, 창 크기 변화 등 반응할 수 없음</td>
</tr>
</tbody></table>
<hr>
<h2 id="✅-결론">✅ 결론</h2>
<blockquote>
<p>가상 DOM은 <strong>구조(설계도)</strong>만 알고,</p>
<p><strong>위치, 크기, 충돌</strong> 같은 <strong>화면 위의 정보</strong>는 모른다.</p>
<p>그래서 드래그 앤 드롭처럼 화면에서 <strong>정확한 위치를 계산해야 하는 기능</strong>은</p>
<p>반드시 <strong>실제 DOM을 ref로 직접 조작</strong>해야 한다.</p>
</blockquote>
<hr>
<h2 id="📦-핵심-요약">📦 핵심 요약</h2>
<table>
<thead>
<tr>
<th>용어</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>ref</code></td>
<td>실제 DOM을 React에서 가리키는 포인터</td>
</tr>
<tr>
<td><code>ref.current</code></td>
<td>진짜 HTML 요소를 가리킴</td>
</tr>
<tr>
<td><code>useRef()</code></td>
<td>ref를 만드는 Hook</td>
</tr>
<tr>
<td>드래그에서 역할</td>
<td>요소 위치를 계산하려면 반드시 필요함</td>
</tr>
</tbody></table>
<hr>
<h2 id="✅-정말-중요한-한-줄-요약">✅ 정말 중요한 한 줄 요약</h2>
<blockquote>
<p>ref는 &quot;React야, 이 DOM 여기 있어!&quot; 하고 알려주는 표시자야.</p>
<p>드래그 앤 드롭처럼 실제 위치, 크기 등을 계산해야 할 땐</p>
<p><strong>가상 DOM으론 부족하니까 → ref로 실제 DOM을 직접 가리켜야 해.</strong>
<img src="https://velog.velcdn.com/images/bom_0320/post/a3e46848-55c0-4c0b-a614-902348874f54/image.png" alt=""></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Zustand에 대하여]]></title>
            <link>https://velog.io/@bom_0320/Zustand%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@bom_0320/Zustand%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</guid>
            <pubDate>Wed, 14 May 2025 05:29:31 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/bom_0320/post/e47d91bc-65a2-4196-a0fa-479bd0479804/image.png" alt="">
Zustand는 독일어로 ‘상태’ 라는 뜻으로 React 생태계에서 사용하는 <strong>상태 관리 라이브러리</strong>이다. </p>
<p>현재 Redux가 압도적으로 많이 사용되고 있지만 문법이 더러운편이라 학습에 시간이 필요하다고 한다. Post Redux로서 사용자가 빠르게 늘고 있는 Zustand에 대해 알아보도록 하자</p>
<hr>
<h3 id="사용하는-이유">사용하는 이유</h3>
<p>기본적으로 React의 <strong>데이터 흐름</strong>은 State &amp; Props를 사용해 <strong>단방향으로 이루져야 한다</strong>는 원칙이 있다. </p>
<p>이러한 데이터 흐름은 단순하고 예측 가능하며 컴포넌트 간의 관계를 명확히 정의하기 때문에 유지보수성을 향상시킨다. </p>
<blockquote>
<p><strong>하지만..</strong>
데이터를 전달하는 과정에서 <strong>거쳐야 하는 컴포넌트가 너무 많은 상황</strong>이면 어떨까?</p>
</blockquote>
<h3 id="⚠️props-drilling-발생">⚠️Props Drilling 발생!!!!!</h3>
<pre><code class="language-tsx">1. 최상위 부모 컴포넌트에서 상태나 데이터를 가지고 있다.
2. 중간에 위치한 하위 컴포넌트가 해당 데이터에 접근할 필요가 있다.
3.  하지만 중간에 있는 다른 컴포넌트들은 그 데이터를 사용하지 않음에도 불구하고, 
   React 구조상 자식에게 전달하려면 무조건 props로 받아야 하므로, 
   그냥 “전달만 하는” 역할을 하게 된다. 
4. 결과적으로 데이터는 여러 컴포넌트를 통과하여 목적지에 도달한다. 
5. 이 과정을 “props drilling”이라고 함</code></pre>
<p>코드로 보자면..</p>
<pre><code class="language-tsx">// 👑 App (최상위)
function App() {
  const user = { name: &quot;봄김&quot; };
  return &lt;Parent user={user} /&gt;;
}

// 👩 Parent (데이터 안 씀) → 그냥 전달
function Parent({ user }: { user: { name: string } }) {
  return &lt;Child user={user} /&gt;;
}

// 👧 Child (데이터 안 씀) → 또 전달
function Child({ user }: { user: { name: string } }) {
  return &lt;TargetComponent user={user} /&gt;;
}

// 🎯 TargetComponent (여기서만 사용)
function TargetComponent({ user }: { user: { name: string } }) {
  return &lt;div&gt;{user.name}님 환영합니다!&lt;/div&gt;;
}
</code></pre>
<p><code>Parent</code>, <code>Child</code>는 <strong>user라는 데이터가 필요 없음에도 불구하고, 하위 컴포넌트에 전달하기 위헤 props를 계속 받아서 넘겨야함</strong></p>
<p>이게 바로 props drillling의 문제의 핵심이다.</p>
<hr>
<p><strong>😵 문제점 요약</strong></p>
<ul>
<li>중간 컴포넌트가 필요 없는 데이터를 계속 전달해야 함</li>
<li>컴포넌트 구조가 복잡해질수록 코드 유지보수가 어려워짐</li>
<li>구조가 바뀔 경우 관련된 컴포넌트 모두를 수정해야 함</li>
<li>불필요한 렌더링 유발 가능성 존재</li>
</ul>
<hr>
<h3 id="그래서-zustand">그래서 Zustand!</h3>
<blockquote>
<p>👉 <strong>Zustand는 이런 props drilling을 막기 위한 &quot;전역 상태 관리 라이브러리&quot;!</strong></p>
</blockquote>
<p>즉, 필요한 컴포넌트는 Zustand stord에서 직접 가져와서 사용하면 된다.</p>
<p>중간 단계 컴포넌트를 거칠 필요가 없는것!!!!!</p>
<hr>
<h3 id="✨-zustand로-바꾼다면">✨ Zustand로 바꾼다면?</h3>
<h3 id="📦-상태-저장소-정의">📦 상태 저장소 정의</h3>
<pre><code class="language-tsx">// store/userStore.ts
import { create } from &#39;zustand&#39;

export const useUserStore = create(() =&gt; ({
  user: { name: &#39;봄김&#39; },
}));</code></pre>
<h3 id="🧾-컴포넌트에서-바로-사용">🧾 컴포넌트에서 바로 사용</h3>
<pre><code class="language-tsx">function UserInfo() {
  const user = useUserStore((state) =&gt; state.user);
  return &lt;div&gt;{user.name}님 환영합니다!&lt;/div&gt;;
}</code></pre>
<p>➡️ 이제는 중간에 있는 <code>Parent</code>, <code>Child</code> 컴포넌트가 <strong>전혀 신경 쓸 필요 없음!</strong></p>
<hr>
<h2 id="📌-마무리-요약">📌 마무리 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>🔁 Props Drilling</td>
<td>필요 없는 컴포넌트들이 props를 전달만 하는 상황</td>
</tr>
<tr>
<td>🧠 Zustand</td>
<td>중간 단계를 건너뛰고, 필요한 곳에서 바로 상태를 가져올 수 있게 해줌</td>
</tr>
<tr>
<td>💡 효과</td>
<td>코드 간결, 구조 깔끔, 유지보수 쉬움</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[About_entities]]></title>
            <link>https://velog.io/@bom_0320/Aboutentities</link>
            <guid>https://velog.io/@bom_0320/Aboutentities</guid>
            <pubDate>Wed, 14 May 2025 00:58:00 GMT</pubDate>
            <description><![CDATA[<p>FSD 구조에서 <code>entities</code> 레이어는 <strong>비즈니스 개념 중심(User, Post 등)</strong> 의 코스를 담당한다. 
여기서 말하는 &quot;비즈니스 개념&quot;이란, 단순히 기능이 아니라 <strong>&quot;데이터 중심이냐 Vs. 기능 중심이냐&quot;</strong> 로 나눠서 이해해야 한다.</p>
<hr>
<h2 id="entities-폴더에는-데이터-중심-개념이-들어간다"><code>entities</code> 폴더에는 데이터 중심 개념이 들어간다.</h2>
<ul>
<li><code>User</code>, <code>Post</code>, <code>Comment</code>, <code>Product</code>, <code>Team</code>, <code>Ranking</code> 등</li>
<li>이들은 앱 내에서 <strong>&quot;핵심 객체(주체)&quot;</strong> 로 존재하며,</li>
<li><strong>관련된 모델, API, 상태</strong> 등을 포함한다.</li>
</ul>
<h2 id="features-폴더에는-기능-중심-개념이-들어간다"><code>features</code> 폴더에는 기능 중심 개념이 들어간다.</h2>
<ul>
<li>예: <code>login</code>, <code>signup</code>, <code>writeComment</code>, <code>likePost</code> 등</li>
<li>사용자의 <strong>행위(시나이로)</strong> 를 담당하며,</li>
<li>보통 <strong>UI와 로직</strong> 이 함께 들어있다.</li>
</ul>
<hr>
<h2 id="그렇다면-데이터-중심이란">그렇다면 &quot;데이터 중심&quot;이란?</h2>
<blockquote>
<p>현실 세계에 존재하는 개체를 앱 안에서 고유한 데이터 구조로 모델링한 것</p>
</blockquote>
<ul>
<li>앱에서 어떤 대상을 표현하거나 저장하려면 데이터를 기반으로 구조화해야 한다.</li>
<li>이런 구조는 다양한 화면인 기능에서 <strong>재사용 가능</strong>하다.</li>
</ul>
<hr>
<h3 id="🔍-예시로-이해하기">🔍 예시로 이해하기</h3>
<h3 id="🔷-user-→-데이터-중심-개념-✅-entities에-들어감">🔷 <code>User</code> → 데이터 중심 개념 (✅ <code>entities</code>에 들어감)</h3>
<pre><code class="language-tsx">{
  id: 1,
  name: &quot;봄김&quot;,
  email: &quot;bom@dev.com&quot;
}
</code></pre>
<ul>
<li>사용자라는 <strong>대상을 표현하는 객체</strong></li>
<li>여러 화면에서 보여지고, 상태로도 저장됨 → <strong>데이터 중심</strong></li>
</ul>
<hr>
<h3 id="🔷-signin-→-기능-중심-개념-✅-features에-들어감">🔷 <code>signin</code> → 기능 중심 개념 (✅ <code>features</code>에 들어감)</h3>
<ul>
<li><p>로그인을 한다는 건 <strong>행위</strong>이지, 어떤 <strong>대상을 표현</strong>하는 건 아님</p>
</li>
<li><p><code>POST /login</code> 요청을 보낼 뿐, 이 자체는 <strong>데이터로 저장되지 않음</strong></p>
<p>  → 따라서 <strong>기능 중심 개념</strong></p>
</li>
</ul>
<hr>
<h2 id="✅-개념-비교-정리">✅ 개념 비교 정리</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>중심</th>
<th>예시</th>
<th>소속</th>
</tr>
</thead>
<tbody><tr>
<td><code>User</code>, <code>Post</code>, <code>Product</code></td>
<td>✅ 데이터 중심</td>
<td>고유한 데이터가 있고 다양한 화면에 사용됨</td>
<td><code>entities/</code></td>
</tr>
<tr>
<td><code>Login</code>, <code>Signup</code>, <code>Like</code>, <code>WriteComment</code></td>
<td>❌ 기능 중심</td>
<td>유저의 특정 행위, 시나리오 중심</td>
<td><code>features/</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="💡-더-쉽게-기억하는-법">💡 더 쉽게 기억하는 법</h2>
<table>
<thead>
<tr>
<th>질문</th>
<th>Yes라면 <code>entities</code>, No라면 <code>features</code></th>
</tr>
</thead>
<tbody><tr>
<td>이건 &quot;무언가&quot;를 설명하는 데이터인가?</td>
<td>✔️ → <code>User</code>, <code>Post</code></td>
</tr>
<tr>
<td>이건 &quot;무언가를 하는 행위&quot;인가?</td>
<td>✔️ → <code>signin</code>, <code>commentWrite</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="🔄-기억용-요약-문장">🔄 기억용 요약 문장</h2>
<blockquote>
<p>&quot;데이터 중심이라는 건, 화면에 보여지고 저장되는 정보 자체를 중심으로 생각하는 거야.User는 어떤 대상(사람)이지만, signin은 행동이잖아?&quot;</p>
</blockquote>
<hr>
<h2 id="📌-추가로-알아두면-좋은-점">📌 추가로 알아두면 좋은 점</h2>
<h3 id="❓-entities-폴더에-signin-같은-기능이-들어가도-될까">❓ <code>entities</code> 폴더에 <code>signin</code> 같은 기능이 들어가도 될까?</h3>
<ul>
<li><p>FSD 원칙에 따르면,</p>
<ul>
<li><strong>데이터 중심 개념</strong>은 <code>entities/</code>,</li>
<li><strong>행동 중심 기능</strong>은 <code>features/</code>에 넣는 것이 가장 이상적이다.</li>
</ul>
</li>
<li><p>하지만 실제 프로젝트에서는 <strong>규모나 팀 스타일에 따라</strong></p>
<p>  <code>features/</code>를 따로 만들지 않고, <strong>작은 기능들을 <code>entities/</code> 안에 넣는 경우</strong>도 많다.</p>
</li>
<li><p>즉, <code>entities/signin</code>처럼 구성된 경우도 <strong>잘못된 건 아니며</strong>,</p>
<p>  <strong>작은 기능을 &quot;하나의 도메인처럼&quot; 다룬 유연한 구조</strong>라고 이해하면 된다.</p>
</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[🧼 Prettier란? 그리고 설정 방법]]></title>
            <link>https://velog.io/@bom_0320/Prettier%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%84%A4%EC%A0%95-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@bom_0320/Prettier%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%84%A4%EC%A0%95-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Tue, 13 May 2025 00:49:02 GMT</pubDate>
            <description><![CDATA[<h2 id="prettier란">Prettier란?</h2>
<blockquote>
<p><strong>코드 포맷터</strong>
내가 작성한 코드를 일정한 규칙에 맞게 자동으로 정리해주는 도구이다.</p>
</blockquote>
<h3 id="예시">예시</h3>
<pre><code class="language-arduino">// 포맷팅 전
function hello ( ){console.log(&quot;hi&quot;)}

// 포맷팅 후 (Prettier 적용 시)
function hello() {
  console.log(&quot;hi&quot;);
}</code></pre>
<hr>
<h2 id="🛠️-prettier의-장점">🛠️ Prettier의 장점</h2>
<ul>
<li>팀원 간 <strong>코드 스타일 통일</strong></li>
<li>저장할 때마다 자동 정리 → <strong>시간 절약</strong></li>
<li>리뷰 때 <strong>불필요한 스타일 논쟁 줄어듦</strong></li>
</ul>
<hr>
<h2 id="⚙️-prettier-설정-방법-2가지">⚙️ Prettier 설정 방법 (2가지)</h2>
<h3 id="①-prettierrc-파일로-설정">① <code>.prettierrc</code> 파일로 설정</h3>
<blockquote>
<p>설정 전용 파일을 루트 디렉토리에 만들어서 사용</p>
</blockquote>
<p>파일명 예시: <code>.prettierrc</code>, <code>.prettierrc.json</code>, <code>.prettierrc.yaml</code> 등</p>
<pre><code class="language-json">{
  &quot;semi&quot;: true,
  &quot;singleQuote&quot;: true,
  &quot;printWidth&quot;: 100
}</code></pre>
<p>→ 이 파일이 있으면 Prettier는 여기 설정을 따름</p>
<hr>
<h3 id="②-packagejson-안에-prettier-항목으로-설정">② <code>package.json</code> 안에 <code>&quot;prettier&quot;</code> 항목으로 설정</h3>
<blockquote>
<p>따로 파일 안 만들고, 프로젝트 설정 파일 안에서 설정</p>
</blockquote>
<pre><code class="language-json">
{
  &quot;name&quot;: &quot;my-project&quot;,
  ...
  &quot;prettier&quot;: {
    &quot;semi&quot;: true,
    &quot;singleQuote&quot;: true}
}
</code></pre>
<p>→ 이 경우에도 동일하게 작동함</p>
<hr>
<h2 id="✨-결론-요약">✨ 결론 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>.prettierrc</code> 파일 있음</td>
<td>Prettier는 이걸 <strong>우선 사용</strong></td>
</tr>
<tr>
<td><code>package.json</code> 설정</td>
<td>가능하지만 <code>.prettierrc</code>보다 <strong>우선순위 낮음</strong></td>
</tr>
<tr>
<td>둘 다 있음</td>
<td>헷갈릴 수 있으니 <strong>하나만 쓰는 걸 권장</strong></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[create-next-app 질문 선택 여부 확인 정리]]></title>
            <link>https://velog.io/@bom_0320/create-next-app-%EC%A7%88%EB%AC%B8-%EC%84%A0%ED%83%9D-%EC%97%AC%EB%B6%80-%ED%99%95%EC%9D%B8-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@bom_0320/create-next-app-%EC%A7%88%EB%AC%B8-%EC%84%A0%ED%83%9D-%EC%97%AC%EB%B6%80-%ED%99%95%EC%9D%B8-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Tue, 13 May 2025 00:27:28 GMT</pubDate>
            <description><![CDATA[<table>
<thead>
<tr>
<th>질문</th>
<th>확인 방법</th>
<th>확인 기준</th>
</tr>
</thead>
<tbody><tr>
<td>✔ <strong>Would you like to use TypeScript?</strong></td>
<td>프로젝트에 <code>tsconfig.json</code>이 있는지 확인</td>
<td>있으면 TypeScript 사용 (<code>.ts</code>, <code>.tsx</code> 파일도 같이 사용됨)</td>
</tr>
<tr>
<td>✔ <strong>Would you like to use ESLint?</strong></td>
<td><code>package.json</code>의 <code>devDependencies</code>에 <code>eslint</code>가 있는지 확인</td>
<td>있으면 ESLint 사용 중</td>
</tr>
<tr>
<td>✔ <strong>Would you like to use Tailwind CSS?</strong></td>
<td><code>package.json</code>에 <code>tailwindcss</code>, <code>postcss</code>, <code>autoprefixer</code>가 있는지 확인</td>
<td>이 3개가 있으면 Tailwind 사용 중</td>
</tr>
<tr>
<td>✔ <strong>Would you like your code inside a <code>src/</code> directory?</strong></td>
<td>프로젝트 루트에 <code>src/</code> 폴더가 있는지 확인</td>
<td>있으면 <code>src/</code> 구조 선택함</td>
</tr>
<tr>
<td>✔ <strong>Would you like to use App Router?</strong></td>
<td><code>src/</code> 또는 루트에 <code>app/</code> 폴더가 있는지 확인</td>
<td>있으면 App Router 사용 (없고 <code>pages/</code>만 있으면 전통 방식)</td>
</tr>
<tr>
<td>✔ <strong>Would you like to use Turbopack for <code>next dev</code>?</strong></td>
<td><code>npm run dev</code> 실행 시 터미널 로그 확인</td>
<td><code>✔ Using Turbopack</code>이 뜨면 사용, 아니면 Webpack 사용 중</td>
</tr>
<tr>
<td>✔ <strong>Would you like to customize the import alias?</strong></td>
<td><code>tsconfig.json</code>(또는 <code>jsconfig.json</code>) 확인</td>
<td><code>&quot;paths&quot;</code> 항목에 <code>&quot;@/*&quot;: [&quot;src/*&quot;]</code> 있으면 alias 설정한 것</td>
</tr>
<tr>
<td>✔ <strong>What import alias would you like configured?</strong></td>
<td>위 <code>&quot;paths&quot;</code> 설정을 보면 어떤 alias인지 알 수 있음</td>
<td>기본은 <code>&quot;@/*&quot;</code>이지만 직접 수정했을 수도 있음</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL – Next.js 프로젝트 생성 방식에 따른 차이점과 깨달음]]></title>
            <link>https://velog.io/@bom_0320/TIL-Next.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1-%EB%B0%A9%EC%8B%9D%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%B0%A8%EC%9D%B4%EC%A0%90%EA%B3%BC-%EA%B9%A8%EB%8B%AC%EC%9D%8C</link>
            <guid>https://velog.io/@bom_0320/TIL-Next.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1-%EB%B0%A9%EC%8B%9D%EC%97%90-%EB%94%B0%EB%A5%B8-%EC%B0%A8%EC%9D%B4%EC%A0%90%EA%B3%BC-%EA%B9%A8%EB%8B%AC%EC%9D%8C</guid>
            <pubDate>Mon, 12 May 2025 23:59:37 GMT</pubDate>
            <description><![CDATA[<h3 id="날짜-20250513">날짜: 2025.05.13</h3>
<h3 id="📌-주제-nextjs-프로젝트-생성--수동-설치-vs-create-next-app-자동-설정">📌 주제: Next.js 프로젝트 생성 – 수동 설치 vs create-next-app 자동 설정</h3>
<hr>
<h2 id="✅-1-내가-처음-사용한-방식--수동-설치">✅ 1. 내가 처음 사용한 방식 – 수동 설치</h2>
<p>처음에는 강의에서 알려준 대로 아래처럼 수동으로 Next.js 프로젝트를 구성했다:</p>
<pre><code class="language-bash">mkdir my-next-app
cd my-next-app
npm init -y
npm install react react-dom next
mkdir app
touch app/page.tsx
</code></pre>
<p>그리고 <code>package.json</code>을 아래처럼 수정했다:</p>
<pre><code class="language-json">&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;next dev&quot;,
  &quot;build&quot;: &quot;next build&quot;,
  &quot;start&quot;: &quot;next start&quot;
}
</code></pre>
<p>이 방식은 모든 구조와 설정을 직접 만져보면서 학습할 수 있다는 장점이 있었다.</p>
<p>하지만 실제 협업 프로젝트에 들어가면서 여러 <strong>치명적인 단점</strong>을 겪었다.</p>
<hr>
<h2 id="🧨-2-겪은-문제들">🧨 2. 겪은 문제들</h2>
<ul>
<li><p><code>.eslintrc.json</code>은 있는데 <code>eslint</code>가 설치되어 있지 않음</p>
<p>  → 설정만 있고 작동은 안 되는 상태</p>
</li>
<li><p><code>.prettierrc</code> 파일도 수동으로 만들었지만 관련 패키지가 없었음</p>
</li>
<li><p>TypeScript는 설치했지만 <code>tsconfig.json</code> 설정은 직접 만들어야 했음</p>
</li>
<li><p>팀원들마다 환경이 달라서 코드 스타일이 달라지고 충돌 발생</p>
</li>
<li><p>Tailwind CSS도 수동 설치해야 해서 공식 문서를 계속 참고해야 했음</p>
</li>
</ul>
<hr>
<h2 id="✅-3-협업에서는-create-next-app이-훨씬-낫다는-걸-깨달음">✅ 3. 협업에서는 create-next-app이 훨씬 낫다는 걸 깨달음</h2>
<p>나중에 <code>npx create-next-app@latest</code>를 써보면서 다음과 같은 장점을 느꼈다:</p>
<pre><code class="language-bash">npx create-next-app@latest .
</code></pre>
<blockquote>
<p>. 을 붙이면 현재 폴더에 세팅됨</p>
</blockquote>
<h3 id="생성-중-질문-예시--추천-답변">생성 중 질문 예시 &amp; 추천 답변:</h3>
<table>
<thead>
<tr>
<th>질문</th>
<th>추천 답변</th>
</tr>
</thead>
<tbody><tr>
<td>TypeScript 사용</td>
<td>Yes</td>
</tr>
<tr>
<td>ESLint 사용</td>
<td>Yes</td>
</tr>
<tr>
<td>Tailwind CSS 사용</td>
<td>Yes or No (원하는 대로)</td>
</tr>
<tr>
<td>src 폴더 사용할래?</td>
<td>Yes</td>
</tr>
<tr>
<td>App Router 사용할래?</td>
<td>Yes</td>
</tr>
<tr>
<td>Turbopack 쓸래?</td>
<td>No</td>
</tr>
<tr>
<td>import alias 설정할래?</td>
<td>Yes (<code>@/*</code>)</td>
</tr>
</tbody></table>
<p>이렇게만 선택하면 모든 설정이 자동으로 되어, 실수 없이 바로 개발에 들어갈 수 있다.</p>
<hr>
<h2 id="💡-4-도구들에-대한-이해">💡 4. 도구들에 대한 이해</h2>
<ul>
<li><strong>ESLint</strong>: 코드 문법 검사기. 실수를 방지하고 일관된 코드 스타일을 유지해준다.</li>
<li><strong>Prettier</strong>: 코드 포맷터. 줄 정리, 들여쓰기, 세미콜론 자동 정리 등.</li>
<li><strong>Tailwind CSS</strong>: CSS를 유틸리티 클래스 기반으로 빠르게 작성할 수 있게 해주는 프레임워크.</li>
<li><strong>TypeScript</strong>: 자바스크립트에 타입을 추가해 주는 언어. 코드 안정성과 자동완성 기능 향상.</li>
</ul>
<hr>
<h2 id="🌱-5-깨달은-점">🌱 5. 깨달은 점</h2>
<ul>
<li>수동 설치는 배우는 데는 좋지만, 협업이나 실무에서는 <strong>자동 설정(create-next-app)</strong>이 훨씬 안정적이다.</li>
<li><strong>필수 설정들을 빠뜨리면 작동이 안 되거나 버그가 생길 수 있다.</strong></li>
<li>create-next-app은 단순히 편한 게 아니라, <strong>&quot;실무 기준의 안정적인 개발 환경을 만들어주는 도구&quot;</strong>라는 걸 알게 됐다.</li>
<li>앞으로는 자동 설치로 시작한 후, 필요한 설정만 커스터마이징하는 방식으로 효율적으로 개발할 계획이다.</li>
</ul>
<hr>
<h2 id="📁-6-내가-만든-폴더-구조-예시">📁 6. 내가 만든 폴더 구조 예시</h2>
<pre><code class="language-bash">src/
├── entities/   # 핵심 도메인 모델
├── shared/     # 공통 유틸, 타입, 훅 등
├── views/      # 페이지 뷰
├── widgets/    # 재사용 가능한 UI 조각
</code></pre>
<hr>
<h2 id="🏁-마무리">🏁 마무리</h2>
<p>이 경험을 통해, 단순히 코드를 작성하는 것이 아니라 <strong>개발 환경을 어떻게 구성하느냐도 실력</strong>이라는 걸 깊이 깨달았다.</p>
<p>수동 설치도 분명 도움이 되었고, 그 덕분에 자동 설치의 강력함도 제대로 느낄 수 있었다.</p>
<hr>
<h2 id="📎-관련-학습-기록">📎 관련 학습 기록</h2>
<ul>
<li><p>처음에는 아래와 같이 수동으로 Next.js 프로젝트를 설정하는 방법을 학습했었다.</p>
<p>  👉 <a href="https://velog.io/@bom_0320/Next.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1-%EA%B3%BC%EC%A0%95-%EC%A0%95%EB%A6%AC">TIL: Next.js 수동 프로젝트 생성법</a></p>
</li>
<li><p>이번에 협업 경험을 통해 자동 생성 방식(create-next-app)의 강력함과 효율성을 깨닫게 되었고,</p>
<p>  앞으로는 이 두 가지를 모두 이해하고 상황에 맞게 선택할 수 있게 되었다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TIL: TypeScript에서의 세 가지 버전 충돌 문제 + CRA의 한계]]></title>
            <link>https://velog.io/@bom_0320/TIL-TypeScript%EC%97%90%EC%84%9C%EC%9D%98-%EC%84%B8-%EA%B0%80%EC%A7%80-%EB%B2%84%EC%A0%84-%EC%B6%A9%EB%8F%8C-%EB%AC%B8%EC%A0%9C-CRA%EC%9D%98-%ED%95%9C%EA%B3%84</link>
            <guid>https://velog.io/@bom_0320/TIL-TypeScript%EC%97%90%EC%84%9C%EC%9D%98-%EC%84%B8-%EA%B0%80%EC%A7%80-%EB%B2%84%EC%A0%84-%EC%B6%A9%EB%8F%8C-%EB%AC%B8%EC%A0%9C-CRA%EC%9D%98-%ED%95%9C%EA%B3%84</guid>
            <pubDate>Mon, 12 May 2025 08:30:41 GMT</pubDate>
            <description><![CDATA[<h3 id="오늘의-이슈">오늘의 이슈</h3>
<p><code>npm install --save-dev @types/react-query</code> 명령어 실행 시, 아래와 같은 에러가 발생함:</p>
<pre><code class="language-kotlin">npm error ERESOLVE could not resolve
...
Conflicting peer dependency: typescript@5.8.3
peerOptional typescript@&quot;^3.2.1 || ^4&quot; from react-scripts@5.0.1
</code></pre>
<hr>
<h2 id="원인-분석-세-가지-버전-충돌-중-하나">원인 분석: 세 가지 버전 충돌 중 하나</h2>
<p>TypeScript 프로젝트에서는 다음 세 가지 버전이 서로 영향을 주고 받는다:</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
<th>지금 내 상황과의 연관성</th>
</tr>
</thead>
<tbody><tr>
<td>① <strong>라이브러리 버전</strong></td>
<td>예: <code>react-scripts@5.0.1</code>프로젝트 내부의 빌드 및 실행 도구</td>
<td>CRA(Create React App)로 프로젝트를 시작했기 때문에 <code>react-scripts</code>가 자동 설치됨. 해당 버전은 TypeScript 5를 지원하지 않음</td>
</tr>
<tr>
<td>② <strong>타입 선언 버전 (@types)</strong></td>
<td>예: <code>@types/react-query</code>타입스크립트에서 사용할 수 있도록 타입 정보만 제공하는 라이브러리</td>
<td>설치하려 했지만 TypeScript와 <code>react-scripts</code>의 충돌로 인해 설치 실패</td>
</tr>
<tr>
<td>③ <strong>TypeScript 버전</strong></td>
<td>예: <code>typescript@5.8.3</code>내가 설치한 최신 TS 버전</td>
<td>최신 버전이지만 <code>react-scripts@5.0.1</code>에서 요구하는 버전보다 높아서 충돌 발생</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔥-에러-발생-구조-요약">🔥 에러 발생 구조 요약</h2>
<pre><code class="language-graphql">타입스크립트 버전이 너무 높음
→ react-scripts가 이걸 감당하지 못함
→ @types/react-query 같은 타입 선언 설치도 실패</code></pre>
<hr>
<h2 id="cra가-왜-이-문제를-만들었을까">CRA가 왜 이 문제를 만들었을까?</h2>
<h3 id="cra의-핵심-빌드-툴--react-scripts">CRA의 핵심 빌드 툴 = <code>react-scripts</code></h3>
<ul>
<li><p>CRA는 내부적으로 <code>react-scripts</code>라는 패키지를 사용해</p>
<p>  빌드, 테스트, 타입 검사 등을 자동으로 해준다.</p>
</li>
<li><p>그런데 현재 사용하는 <code>react-scripts@5.0.1</code>은</p>
<p>  <strong>TypeScript 5버전을 아직 지원하지 않는다.</strong></p>
</li>
<li><p><code>react-scripts</code>가 허용하는 TypeScript 버전 범위는 다음과 같다:</p>
<pre><code>  ^3.2.1 || ^4 (즉, TS 3 또는 TS 4.x까지만 가능)</code></pre></li>
</ul>
<hr>
<h2 id="😵💫-그래서-무슨-일이-벌어졌냐면">😵‍💫 그래서 무슨 일이 벌어졌냐면?</h2>
<p>너는 최신 버전인 <code>TypeScript 5.8</code>을 설치했는데,</p>
<p>CRA 내부의 <code>react-scripts</code>는 이렇게 말하는 거야:</p>
<pre><code class="language-arduino">react-scripts: &quot;나 TS 4까지만 쓸 수 있어!&quot;
TypeScript 5.8: &quot;난 최신 버전이야!&quot;</code></pre>
<p>→ <strong>둘이 버전이 안 맞아서 싸움 발생 → npm install 실패</strong></p>
<hr>
<h2 id="✅-해결-방법">✅ 해결 방법</h2>
<h3 id="🔧-방법-1-typescript-버전을-낮춰서-cra에-맞춘다-가장-쉬움">🔧 방법 1: TypeScript 버전을 낮춰서 CRA에 맞춘다 (가장 쉬움)</h3>
<pre><code class="language-bash">npm uninstall typescript
npm install --save-dev typescript@4.9.5
npm install --save-dev @types/react-query</code></pre>
<ul>
<li>이렇게 하면 <code>react-scripts@5.0.1</code>과 TypeScript 버전이 일치하므로 충돌 없이 설치 가능!</li>
</ul>
<hr>
<h3 id="🔧-방법-2-cra를-탈출하고-vite나-nextjs-같은-현대적인-도구로-전환한다">🔧 방법 2: CRA를 탈출하고 Vite나 Next.js 같은 현대적인 도구로 전환한다</h3>
<ul>
<li><strong>CRA는 2024년 이후로 유지보수가 사실상 중단</strong>된 상태이다.</li>
<li>많은 개발자들이 더 빠르고 가벼운 빌드 도구인 <code>Vite</code>, <code>Next.js</code>, <code>Parcel</code> 등으로 넘어가는 중이다.</li>
<li>최신 TypeScript 기능을 무리 없이 사용하고 싶다면 장기적으로는 전환을 고려하는 것이 좋다.</li>
</ul>
<hr>
<h2 id="느낀-점-및-교훈">느낀 점 및 교훈</h2>
<ul>
<li>TypeScript 프로젝트에서는 <strong>세 가지 버전의 호환성</strong>을 꼭 확인해야 한다:<ol>
<li>라이브러리 버전 (ex. <code>react-scripts</code>)</li>
<li>타입 선언 버전 (ex. <code>@types/react-query</code>)</li>
<li>타입스크립트 자체 버전 (ex. <code>typescript@5.8</code>)</li>
</ol>
</li>
<li>하나라도 어긋나면 “설치가 안 되거나”, “엉뚱한 곳에서 에러가 터지는” 문제가 생긴다.</li>
<li><strong>CRA는 더 이상 최신 스택을 따라가지 못하고 있으며</strong>, 새로운 프로젝트에서는 대체 도구(Vite, Next.js 등)를 사용하는 것이 더 좋다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[컴포넌트 Props의 타입 지정과 기본값 설정 (TypeScript + React)]]></title>
            <link>https://velog.io/@bom_0320/%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-Props%EC%9D%98-%ED%83%80%EC%9E%85-%EC%A7%80%EC%A0%95%EA%B3%BC-%EA%B8%B0%EB%B3%B8%EA%B0%92-%EC%84%A4%EC%A0%95-TypeScript-React</link>
            <guid>https://velog.io/@bom_0320/%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-Props%EC%9D%98-%ED%83%80%EC%9E%85-%EC%A7%80%EC%A0%95%EA%B3%BC-%EA%B8%B0%EB%B3%B8%EA%B0%92-%EC%84%A4%EC%A0%95-TypeScript-React</guid>
            <pubDate>Wed, 07 May 2025 12:06:25 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-1-props-타입-지정-방법">📌 1. Props 타입 지정 방법</h3>
<p>컴포넌트에 전달되는 props의 타입은 <code>interface</code>를 사용해 지정한다.</p>
<pre><code class="language-tsx">
interface TextProps {
  text: string;
  active?: boolean; // optional (선택적 props)
}
</code></pre>
<ul>
<li><code>text</code> → <strong>필수 props</strong></li>
<li><code>active?</code> → <strong>선택적 props</strong></li>
</ul>
<hr>
<h3 id="📌-2-선택적-props-vs-필수적-props">📌 2. 선택적 props vs 필수적 props</h3>
<ul>
<li><code>:</code>만 쓰면 필수</li>
<li><code>?:</code>를 쓰면 선택적</li>
</ul>
<pre><code class="language-tsx">
interface ExampleProps {
  requiredProp: string;       // 필수
  optionalProp?: number;      // 선택
}
</code></pre>
<hr>
<h3 id="📌-3-props에-기본값-설정">📌 3. props에 기본값 설정</h3>
<p>기본값은 <strong>함수 파라미터에 직접 설정</strong>한다:</p>
<pre><code class="language-tsx">
function Dummy({ text, active = false }: TextProps) {
  return &lt;h1&gt;{text}&lt;/h1&gt;;
}
</code></pre>
<p>이렇게 하면 <code>active</code>를 전달하지 않아도 자동으로 <code>false</code>가 된다.</p>
<hr>
<h3 id="📌-4-boolean-props는-true일-때-생략된-형태로-작성-가능">📌 4. Boolean props는 <code>true</code>일 때 생략된 형태로 작성 가능</h3>
<pre><code class="language-tsx">
&lt;Dummy text=&quot;hello&quot; active /&gt;
</code></pre>
<ul>
<li>위 코드는 <code>active={true}</code>와 완전히 동일하다.</li>
<li>이는 boolean 타입 props의 특징으로, <code>true</code>일 경우 속성 이름만 작성해도 된다.</li>
</ul>
<hr>
<h3 id="✅-실습-예시-요약">✅ 실습 예시 요약</h3>
<pre><code class="language-tsx">
interface TextProps {
  text: string;
  active?: boolean;
}

function Dummy({ text, active = false }: TextProps) {
  return &lt;h1&gt;{text}&lt;/h1&gt;;
}

// 사용 예시
&lt;Dummy text=&quot;hello&quot; active /&gt;           // ✅ active는 true
&lt;Dummy text=&quot;world&quot; /&gt;                 // ✅ active는 false (기본값)
</code></pre>
<hr>
<h3 id="💡-마무리-요약">💡 마무리 요약</h3>
<table>
<thead>
<tr>
<th>목적</th>
<th>문법</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>props 타입 지정</td>
<td><code>interface</code></td>
<td>컴포넌트에 전달되는 값의 타입 명시</td>
</tr>
<tr>
<td>선택적 props</td>
<td><code>propName?: Type</code></td>
<td>전달하지 않아도 되는 props</td>
</tr>
<tr>
<td>기본값 설정</td>
<td><code>= defaultValue</code></td>
<td>함수 파라미터에서 기본값 설정</td>
</tr>
<tr>
<td>Boolean props true 표현</td>
<td><code>&lt;Component prop /&gt;</code></td>
<td><code>prop={true}</code>와 동일</td>
</tr>
</tbody></table>
<hr>
<p>필요한 경우 <code>defaultProps</code> 방식도 있지만, 함수형 컴포넌트에선 <strong>파라미터 기본값 방식</strong>을 더 권장한다고 한다.</p>
]]></description>
        </item>
    </channel>
</rss>