<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yejiin_21.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 30 Jun 2026 12:39:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yejiin_21.log</title>
            <url>https://velog.velcdn.com/images/yejiin_21/profile/66cc8a8c-5f6c-48d0-a804-17501281f2da/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yejiin_21.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yejiin_21" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[QA에서 발견한 16건의 결함, 예외 처리 구조를 다시 설계한 이유]]></title>
            <link>https://velog.io/@yejiin_21/error-handler</link>
            <guid>https://velog.io/@yejiin_21/error-handler</guid>
            <pubDate>Tue, 30 Jun 2026 12:39:43 GMT</pubDate>
            <description><![CDATA[<p>인턴 기간 중 사내 업무 일지 기록과 동료 칭찬 서비스인 &#39;포레스트&#39;를 재구축하였다. 배포 전 QA를 진행하던 중, 엣지 케이스에서 동일한 유형의 장애가 반복적으로 발견되었다.</p>
<ul>
<li>어떤 화면은 API 실패 시 Alert만 표시된다.</li>
<li>어떤 화면은 데이터가 없는 것처럼 빈 화면만 보인다.</li>
<li>인증이 만료되어도 자동 갱신되지 않고 바로 로그인 화면으로 이동한다.</li>
<li>렌더링 예외가 발생하면 페이지 전체가 깨진다.</li>
</ul>
<p>처음에는 각각 독립적인 버그처럼 보였지만, 원인을 추척해 보니 모두 <strong>예외 처리 구조가 일관되지 않다</strong>는 하나의 문제로 연결되어 있었다.</p>
<p>이번 글에서 단순히 버그를 수정하는 것이 아니라, 프로젝트의 예외 처리 구조를 어떻게 다시 설계했는지 정리해보려고 한다.</p>
<h3 id="1-문제-상황">1. 문제 상황</h3>
<h4 id="1-interceptor가-존재하지만-실제로-사용되지-않음">1) Interceptor가 존재하지만 실제로 사용되지 않음</h4>
<p>가장 먼저 실제 API 호출 흐름을 확인했다.
프로젝트에서는 이미 토큰 자동 갱신과 공통 에러 처리를 위한 axiosInstance가 존재했다.</p>
<p>하지만 실제 호출 경로를 확인해 보니 검색 결과는 자기 자신뿐이었다.</p>
<pre><code>$ grep -rl &quot;axiosInstance&quot; src 
src/api/axiosInstance.js</code></pre><p>반면 실제 API 호출은 모두 ApiUtil.js에서 axios.post()를 직접 호출하고 있었다.</p>
<p>즉, axiosInstance.js 파일에 <strong>토큰 자동 갱신, 공통 에러 분류, 인터셉터 기반 예외 처리</strong> 모두 구현되어 있었지만 <strong>실제로는 단 한번도 실행되지 않는 코드</strong>였다.</p>
<h4 id="2-같은-에러도-화면마다-처리-방식이-다름">2) 같은 에러도 화면마다 처리 방식이 다름</h4>
<p>API 실패시 어떤 화면은 Alert를 띄우거나 데이터를 비운채 종료하거나 아예 catch 조차 하지 않았다.
예를 들어 마이페이지에서는</p>
<pre><code>const response = await meService(); 
setUserInfo(response.data); 
if (!userInfo) return null;</code></pre><p>API가 실패하면 userInfo는 끝까지 null인 상태로 남고, 사용자는 아무런 안내 없이 빈 화면만 보게 된다.</p>
<p>또 다른 화면은 </p>
<pre><code>if (notifications.length === 0) return null;</code></pre><p>정말 알림이 없는 것인지, 조회가 실패한 것인지조차 구분할 수 없었다.</p>
<h4 id="3-렌더링-예외는-페이지-전체-정애로-이어짐">3) 렌더링 예외는 페이지 전체 정애로 이어짐</h4>
<p>프로젝트에는 Error Boundary가 존재하지 않았다. 따라서 하나의 Feature에서 렌더링 예외가 발생하면 리액트 특성상 페이지 전체가 깨질 가능성이 있었다.</p>
<p>결국 문제는 개별 버그가 아니라 <strong>예외를 처리하는 기준 자체가 없었다는 것</strong>이었다.</p>
<h3 id="2-해결-방안">2. 해결 방안</h3>
<p>무작정 try/catch를 추가하는 방식으로는 문제를 해결할 수 없다고 판단했다.</p>
<p>먼저 예외 처리의 책임부터 다시 정의했다.</p>
<p>백엔드는 <strong>왜 실패했는지</strong>를 정의한다. 즉,errorCode와 message를 통해 비즈니스적인 실패 원인을 전달한다.</p>
<p>반면 프론트엔드는 그 실패를 받아 아래와 같은 상황을 결정해야 한다.</p>
<ul>
<li>사용자에게 어떤 메시지를 보여줄지</li>
<li>자동 복구를 시도할지</li>
<li>기능을 계속 사용할 수 있도록 할지</li>
<li>운영 환경에서 어떻게 추적할지</li>
</ul>
<p>이 기준을 바탕으로 4가지 관점으로 예외 처리 구조를 설계했다.</p>
<ul>
<li>예외 처리 기준 표준화</li>
<li>API 예외 처리 흐름 일원화</li>
<li>기능 단위 장애 격리</li>
<li>운영 환경에서 추적 가능한 구조</li>
</ul>
<h3 id="3-적용">3. 적용</h3>
<h4 id="1-에러-처리-기준-표준화">1) 에러 처리 기준 표준화</h4>
<p>먼저 ErrorUtils.classify()를 만들었다.</p>
<p>백엔드의 errorCode와 message는 그대로 유지하면서 프론트에서는 <strong>DOMAIN, SYSTEM, retryable</strong> 같은 공통 정보를 추가했다.</p>
<p>모든 API 에러는 최종적으로 다음과 같은 형태만 받으면 되도록 설정했다.</p>
<pre><code>{ category, code, status, message, retryable, backendErrorCode, raw }</code></pre><h4 id="2-api-통신-경로-통합">2) API 통신 경로 통합</h4>
<p>다음으로 실제 호출되지 않던 Interceptor를 서비스에 연결했다.</p>
<p>기존에는 아래와 같은 구조로 설정되었다.</p>
<pre><code>ApiUtils -&gt; axios.post()</code></pre><p>이를 아래와 같은 구조로 변경했다.</p>
<pre><code>ApiUtils -&gt; axiosInstance -&gt; Interceptor</code></pre><p>이 과정에서 <strong>토큰 자동 갱신, 공통 에러 처리, 인증 예외 처리</strong>가 실제 호출 경로에서 동작하기 시작했다. 또한 401 처리 책임도 하나로 통합하여 인증 예외가 여러 곳에서 중복 처리되지 않도록 개선했다.</p>
<h4 id="3-비동기-예외처리-개선">3) 비동기 예외처리 개선</h4>
<table>
<thead>
<tr>
<th align="center">Before</th>
<th align="center">After</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/yejiin_21/post/08b10585-34b0-4582-b9bd-249dc7bee000/image.png" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/yejiin_21/post/2f8b8787-1044-4839-bcd1-ce34023bc529/image.png" alt=""></td>
</tr>
<tr>
<td align="center">기존에는 API 실패와 데이터가 없는 상태가 같은 UI로 표현되는 경우가 많았다.</td>
<td align="center"></td>
</tr>
<tr>
<td align="center">이를 공통 InlineErrorFallback 컴포넌트로 변경하면서 <strong>실패한 경우, 데이터가 없는 경우</strong>를 명확히 구분하고, <strong>재시도가 가능한 에러</strong>는 즉시 다시 시도할 수 있는 UI도 함께 제공했다.</td>
<td align="center"></td>
</tr>
</tbody></table>
<h4 id="4-feature-단위-장애-격리">4) Feature 단위 장애 격리</h4>
<p>마지막으로 Error Boundary를 Feature 단위로 적용했다. </p>
<table>
<thead>
<tr>
<th align="center">Before</th>
<th align="center">After</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/yejiin_21/post/6d01df7b-2cf9-47cc-a8a4-0eea150ed818/image.png" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/yejiin_21/post/c2f33f4b-bbd0-4785-9998-861c0134f6b1/image.png" alt=""></td>
</tr>
<tr>
<td align="center"></td>
<td align="center"></td>
</tr>
<tr>
<td align="center">이전에는 ProfileCard 하나에서 렌더링 오류가 발생해도 페이지 전체가 영향을 받았다. Feature별 Error Boundary를 적용한 이후에는 해당 영역만 Fallback UI로 대체되고, 나머지 기능은 그대로 사용할 수 있도록 변경했다.</td>
<td align="center"></td>
</tr>
</tbody></table>
<p>예외가 발생해도 서비스 전체가 멈추지 않는 구조를 만든 것이다.</p>
<h4 id="5-에러-추적-구조-개선">5) 에러 추적 구조 개선</h4>
<p>예외 처리를 공통화하면서 <strong>에러를 해결하는 것뿐만 아니라, 원인을 빠르게 추적할 수 있는 구조</strong>도 함께 설계했다.</p>
<p>기존에는 각 서비스나 컴포넌트에서 console.error를 제각각 호출하고 있어, 어떤 API에서 어떤 유형의 에러가 발생했는지 파악하기 어려웠다.</p>
<p>이를 개선하기 위해 API 예외와 렌더링 예외의 추적 지점을 각각 하나로 통합했다.</p>
<ul>
<li><strong>API 예외</strong>는 Axios Interceptor에서 에러를 정규화한 뒤, 요청 URL과 함께 category, code, retryable 정보를 기록하도록 구성했다.</li>
<li><strong>렌더링 예외</strong>는 Feature Error Boundary에서 featureName과 componentStack을 함께 기록하여 어느 기능에서 예외가 발생했는지 확인할 수 있도록 했다.</li>
</ul>
<table>
<thead>
<tr>
<th align="center">API Error</th>
<th align="center">Render Error</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/yejiin_21/post/533723a5-40c1-4007-99a9-ce2b8c2211fc/image.png" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/yejiin_21/post/dd1b522f-4c16-4803-bf04-a668c5700e17/image.png" alt=""></td>
</tr>
</tbody></table>
<p>이후에는 API 오류는 어느 API에서 어떤 유형의 에러가 발생했는지, 렌더링 오류는 어느 기능에서 예외가 발생했는지를 동일한 방식으로 추적할 수 있게 되었다.</p>
<p>또한 추적 로직을 한 곳으로 모아두었기 때문에, 추후 Sentry와 같은 모니터링 서비스를 도입하더라도 해당 지점만 교체하면 동일한 구조를 그대로 활용할 수 있도록 확장성을 고려했다.</p>
<h3 id="4-검증-과정에서-발견한-또-다른-버그">4. 검증 과정에서 발견한 또 다른 버그</h3>
<p>구현 끝난 뒤 <strong>401 토큰 갱신</strong> 시나리오를 검증했다.</p>
<p>정상적으로는 아래와 같은 순서로 동작해야한다.</p>
<pre><code>/noti/list (401) -&gt; /users/refresh (200) -&gt; /noti/list 재시도 (200)</code></pre><p><img src="https://velog.velcdn.com/images/yejiin_21/post/b9ef89ec-28f8-48f2-967d-63211fca2fdf/image.png" alt=""></p>
<p>하지만 갱신까지 실패하는 시나리오를 테스트하던 중 오히려 새로운 버그를 발견했다.
<img src="https://velog.velcdn.com/images/yejiin_21/post/1e02c78e-b681-497a-86e1-7726ce8668d5/image.png" alt=""></p>
<p>AUTH_REQUIRED로 전달되어야 하는 인증 예외가 중간에 _SETUP_ERROR로 변경_되고 있었던 것이다.</p>
<p>원인을 추적해 보니 갱신 실패 시 전달되는 refreshError는 이미 동일한 Interceptor를 거쳐 정규화된 에러 객체였다.그런데 이를 다시 ErrorUtils.classify()에 전달하면서 response 정보가 없는 일반 객체로 판단되어 SETUP_ERROR로 재분류되고 있었다.</p>
<pre><code>} catch (refreshError) {
  // 리프레시 토큰 갱신 자체도 실패한 경우 (예: 리프레시 토큰 만료)
  // 리다이렉트 등 사용자 대응은 분류된 에러를 받는 쪽(ApiUtils)이 전담
  console.error(&quot;Token refresh failed:&quot;, refreshError);
  return Promise.reject(ErrorUtils.classify(refreshError));
}</code></pre><p>결국 문제는 <strong>이미 분류된 에러를 한 번 더 분류하고 있었다는 것</strong>이었다.</p>
<pre><code>} catch (refreshError) {
  // 리프레시 토큰 갱신 자체도 실패한 경우 (예: 리프레시 토큰 만료)
  // refreshError는 /users/refresh 호출이 같은 인터셉터를 거치며 이미 ErrorUtils로 분류된 값이므로 재분류하지 않음
  // 리다이렉트 등 사용자 대응은 분류된 에러를 받는 쪽(ApiUtils)이 전담
  console.error(&quot;Token refresh failed:&quot;, refreshError);
  return Promise.reject(refreshError);
}</code></pre><p>이를 해결하기 위해 갱신 실패 시에는 ErrorUtils.classify(refreshError)를 호출하지 않고, 이미 정규화된 refreshError를 그대로 전달하도록 수정했다. </p>
<p>수정 후 동일한 시나리오를 다시 검증한 결과, 인증 예외가 AUTH_REQUIRED 상태로 일관되게 유지되었고, 이후 로그인 페이지 이동까지 정상적으로 동작하는 것을 확인했다.</p>
<h3 id="4-결과">4. 결과</h3>
<p>이번 개선을 통해 프로젝트의 예외 처리 방식을 화면마다 다르게 구현하는 구조가 아닌, <strong>공통 에러 분류 체계, 일관된 API 예외 처리 흐름, 기능 단위 장애 격리, 재시도 가능한 복구 UI</strong>를 갖춘 예외 처리 아키텍처를 구축했다. 또한 401 인증 시나리오를 검증하는 과정에서 이중 분류 버그를 발견·수정하며 예외 처리 흐름의 안정성도 함께 확보했다.</p>
<p>배포 이후 1개월 동안 동일 유형의 장애는 재발하지 않았으며, 무엇보다 이번 경험을 통해 예외 처리는 단순히 try/catch를 추가하는 작업이 아니라, <strong>사용자 경험과 시스템 안정성을 함께 설계하는 일</strong>이라는 것을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[같은 코드, 다른 리뷰를 끝내기 위한 AI 리뷰 시스템 구축]]></title>
            <link>https://velog.io/@yejiin_21/AI-review-agent</link>
            <guid>https://velog.io/@yejiin_21/AI-review-agent</guid>
            <pubDate>Fri, 26 Jun 2026 05:44:22 GMT</pubDate>
            <description><![CDATA[<h3 id="1-문제-정의-같은-코드-다른-리뷰">1. 문제 정의: 같은 코드, 다른 리뷰</h3>
<p>프로젝트를 진행하다 보니 코드 자체의 복잡도보다 &#39;코드 리뷰 기준의 불일치&#39; 문제가 자주 언급되었다. 분명 같은 코드인데도, 어떤 리뷰어가 보느냐에 따라 피드백이 매번 달라지곤 했다.</p>
<ul>
<li>A 리뷰어는 &quot;비즈니스 로직과 UI가 잘 분리되어 구조가 깔끔하다&quot;고 평가한 반면,</li>
<li>B 리뷰어는 &quot;아키텍처 레이어가 모호해 유지보수 관점에서 위험하다&quot;고 수정을 요구했다.</li>
</ul>
<p>결국 문제는 피어 리뷰(Peer Review)의 대상인 코드 자체가 아니라, _<strong>리뷰어마다 판단하는 기준과 주관이 다르다는 점</strong>_이었다. 기준이 흔들리다보니 리뷰 프로세스 전체의 신뢰도와 일관성이 떨어지고 있었다.</p>
<h3 id="2-문제의-본질-문서와-실전의-차이">2. 문제의 본질: 문서와 실전의 차이</h3>
<p>팀 내부에 팀 컨벤션이나 룰이 없는 건 아니였다. 가이드라인이 명확히 존재하는데도 왜 자꾸 이런 문제가 발생하는걸까? 문제를 찬찬히 분석해보니 크게 3가지 원인이 있었다.</p>
<ul>
<li>글로만 존재하던 규칙: 컨벤션이 Wiki에만 기록되어 있다 보니, 실전 코드를 짤 때는 작업자마다 다르게 해석하거나 깜빡하는 경우도 많았다.</li>
<li>경험 기반의 주관적 판단: 정량적인 지표가 없다 보니, 리뷰어 개인의 경험과 선호도에 의존해 피드백을 남기게 되었다.</li>
<li>심각도의 불일치: 동일한 문제를 발견하더라도, 누군가는 반드시 고쳐야 할 이슈라고 보고 누군가는 개선 제안으로 가볍게 넘겼다.</li>
</ul>
<p>결국 판단 기준의 비일관성이 핵심이었다. 리뷰어의 주관에 따라 변하는 상황을 정리해야했다.</p>
<h3 id="3-해결-방안-ai를-리뷰-규칙의-실행-엔진으로-사용하기">3. 해결 방안: AI를 리뷰 규칙의 실행 엔진으로 사용하기</h3>
<p>이 문제를 해결하기 위해 단순히 &quot;AI에게 코드를 주고 알아서 리뷰해 달라고 하기&quot; 방식은 적합한 해결책이 아니라고 생각했다. 프롬프트가 모호하면 AI 역시 매번 다른 주관적인 답변을 주기 때문이다. 대신 질문의 방향을 조금 바꿔봤다.</p>
<blockquote>
<p><strong>우리 팀의 코드 리뷰 기준을 AI가 파싱하고 실행할 수 있는 명확한 규칙 시스템으로 구조화할 수 없을까?</strong></p>
</blockquote>
<p>AI에게 무작정 코드를 짜달라고 하거나 알아서 평가하라고 맡기는 게 아니라, 우리가 정해둔 엄격한 리뷰 규칙을 오차 없이 수행하는 <strong>규칙 실행 엔진</strong>의 역할을 주는 것이다. </p>
<h3 id="4-정량화를-위한-점수화-설계">4. 정량화를 위한 점수화 설계</h3>
<p>일관성을 유지하려면 리뷰 결과를 정량적인 수치로 변환해야한다. 단순히 감상평이 아닌, 명확한 감점 요인과 등급 체계를 설계했다.</p>
<p>[심각도 정의 및 판단 기준]</p>
<table>
<thead>
<tr>
<th>심각도</th>
<th align="left">의미</th>
<th align="center">영향도</th>
<th align="right">점수</th>
</tr>
</thead>
<tbody><tr>
<td>🚨 Critical</td>
<td align="left">구조적 붕괴 / 런타임 에러 유발</td>
<td align="center">시스템 안정성 치명적 영향</td>
<td align="right">-5</td>
</tr>
<tr>
<td>⚠️ Major</td>
<td align="left">설계 및 유지보수성 저해</td>
<td align="center">구조적 결함 가능성</td>
<td align="right">-3</td>
</tr>
<tr>
<td>🟠 Minor</td>
<td align="left">단순 코드 품질 및 컨벤션 미준수</td>
<td align="center">리팩토링 필요</td>
<td align="right">-1</td>
</tr>
<tr>
<td>🟢 Info</td>
<td align="left">더 나은 구조를 위한 제안</td>
<td align="center">기능적 영향 없음</td>
<td align="right">0</td>
</tr>
</tbody></table>
<p>리뷰어마다 &quot;이건 꼭 고쳐야 하나?&quot; 고민하거나 주관이 개입하지 않도록, 모든 탐지 조건을 <code>IF (안티패턴) ➔ THEN (심각도)</code> 구조로 격리하고 명확한 대응 가이드를 설정했다.</p>
<table>
<thead>
<tr>
<th align="left">심각도</th>
<th align="left">판단 조건 (IF)</th>
<th align="left">영향 및 대응 (THEN)</th>
<th align="center">감점 수치</th>
</tr>
</thead>
<tbody><tr>
<td align="left">🚨 <strong>Critical</strong></td>
<td align="left">• 아키텍처 레이어 붕괴<br>• 전역 상태 오용 (Side Effect 포함)<br>• silent failure (에러 삼킴) 발생 구조</td>
<td align="left">시스템 안정성에 치명적인 결함<br><strong>[Merge Block / 즉시 수정]</strong></td>
<td align="center"><code>-5</code></td>
</tr>
<tr>
<td align="left">⚠️ <strong>Major</strong></td>
<td align="left">• 도메인 로직 및 코어 UI 중복<br>• 타 Feature 도메인 자산 직접 참조<br>• Facade 패턴 우회 및 상태 전역화 남용</td>
<td align="left">설계 및 유지보수성을 크게 저해<br><strong>[수정 필수]</strong></td>
<td align="center"><code>-3</code></td>
</tr>
<tr>
<td align="left">🟠 <strong>Minor</strong></td>
<td align="left">• 네이밍 컨벤션 미준수<br>• 4단계 이상의 과도한 Props Drilling<br>• 무거운 연산부 메모이제이션 누락</td>
<td align="left">단순 코드 품질 및 가이드 미준수<br><strong>[리팩토링 권장]</strong></td>
<td align="center"><code>-1</code></td>
</tr>
<tr>
<td align="left">🟢 <strong>Info</strong></td>
<td align="left">• 단순 오탈자 의심<br>• 더 나은 가독성을 위한 대안 제안</td>
<td align="left">기능 및 구조적 영향 없음<br><strong>[단순 참고 / 선택 수용]</strong></td>
<td align="center"><code>0</code></td>
</tr>
</tbody></table>
<p>[점수 및 등급 산정 방식]</p>
<blockquote>
<p>$$Score = 100 - (5 \times N_{critical} + 3 \times N_{major} + 1 \times N_{minor})$$</p>
</blockquote>
<p>최종 산출된 점수에 따라 직관적인 <strong>Grade(등급)</strong>를 부여하며, 개발자와 리뷰어는 해당 PR의 전체적인 위험도와 머지 가능 여부를 한눈에 파악할 수 있다.</p>
<table>
<thead>
<tr>
<th align="left">Score 범위</th>
<th align="center">최종 등급 (Grade)</th>
<th align="left">리포트 상태 피드백</th>
<th align="left">패스 여부 (Action)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>90 ~ 100</strong></td>
<td align="center"><strong>S</strong></td>
<td align="left">🎉 완벽에 가까운 코드입니다.</td>
<td align="left"><strong>즉시 머지 가능 (Pass)</strong></td>
</tr>
<tr>
<td align="left"><strong>80 ~ 89</strong></td>
<td align="center"><strong>A</strong></td>
<td align="left">👍 전반적으로 훌륭하며 경미한 수정 권장 사항이 있습니다.</td>
<td align="left"><strong>확인 후 머지 가능</strong></td>
</tr>
<tr>
<td align="left"><strong>70 ~ 79</strong></td>
<td align="center"><strong>B</strong></td>
<td align="left">⚠️ 아키텍처나 구조적인 확인 및 리팩토링이 필요합니다.</td>
<td align="left"><strong>리뷰어 확인 후 진행</strong></td>
</tr>
<tr>
<td align="left"><strong>60 ~ 69</strong></td>
<td align="center"><strong>C</strong></td>
<td align="left">🚨 잠재적 결함 위험이 높아 재검토를 권장합니다.</td>
<td align="left"><strong>의무 리팩토링 대상</strong></td>
</tr>
<tr>
<td align="left"><strong>&lt; 60</strong></td>
<td align="center"><strong>D</strong></td>
<td align="left">❌ 치명적인 결함이 포함되어 머지할 수 없습니다.</td>
<td align="left"><strong>머지 불가 / 반려 (Fail)</strong></td>
</tr>
</tbody></table>
<h3 id="5-claude-skill-아키텍처-설계">5. Claude Skill 아키텍처 설계</h3>
<pre><code>├── 00_overview.md          # 전체 리뷰 프로세스 파이프라인 및 인터페이스 정의
├── 01_architecture.md      # 레이어드 아키텍처 정의 및 Import 제한 규칙
├── 02_state-management.md  # Zustand 상태 관리 및 Facade 패턴 준수 여부
├── 03_component-design.md  # 가독성, 컴포넌트 책임 분리, 중복 UI 탐지
├── 04_api-layer.md         # API 호출부, DTO 맵핑, 에러 핸들링 컨벤션
├── 05_quality-a11y.md      # 웹 접근성 및 성능 최적화(렌더링 성능) 기준
├── 06_scoring-system.md    # Severity 기반 정량 점수 계산 로직 및 등급 표
└── 07_output-format.md     # 최종 마크다운 리포트 포맷 규칙</code></pre><ul>
<li>설계 핵심 원칙
이 시스템의 핵심은 <strong>“리뷰 기준을 문장이 아니라 실행 가능한 규칙으로 만든 것”</strong>이다.</li>
</ul>
<p>모든 판단은 다음 구조를 따른다:</p>
<pre><code>IF &lt;조건&gt;
THEN &lt;심각도&gt;
BECAUSE &lt;이유&gt;</code></pre><ul>
<li><p>레이어 기반 관심사 분리: 위 검사 파일 순서대로 단계를 격리하여, 하위 레이어 규칙이 상위 레이어를 오염시키지 않도록 차단했다.</p>
</li>
<li><p>추상적 서술 배제: &quot;깔끔하게&quot;, &quot;가독성 좋게&quot; 같은 형용사는 제거하고 정량적으로 추적 가능한 규칙만 남겼다.</p>
</li>
</ul>
<blockquote>
<p>❌ 지양: &quot;컴포넌트 의존성을 최소화하세요.&quot;
✔ 지향: IF base layer(common) imports feature/store/api THEN Critical</p>
</blockquote>
<h3 id="6-실제-적용-화면">6. 실제 적용 화면</h3>
<p>코드를 제출했을 때 최종 등급 스코어보드와 함께 IF-THEN-BECAUSE 기반의 피드백이 정량적으로 구조화되어 반환된다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/yejiin_21/post/37f21562-3840-4beb-a825-ee58404fc9db/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/yejiin_21/post/cc2477b8-7909-4f11-b4b1-ae69096f4c2c/image.png" alt=""></th>
</tr>
</thead>
</table>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그인 보안, HTTPS(SSL)만으로 안전할까?(feat. 클라이언트 사이드 RSA 암호화 적용기)]]></title>
            <link>https://velog.io/@yejiin_21/RSA-Encryption</link>
            <guid>https://velog.io/@yejiin_21/RSA-Encryption</guid>
            <pubDate>Mon, 29 Dec 2025 08:00:43 GMT</pubDate>
            <description><![CDATA[<p>현재 인턴으로 근무하며 주간 발표 중 로그인 로직에 대해 이야기를 나누었다.</p>
<p>나는 &quot;클라이언트에서 사용자 입력값을 서버로 전송하면, 서버가 이를 해시값으로 변경해 DB에 저장합니다&quot;라고 설명했다. 그러자 매니저님께서 <strong>&quot;그렇게 되면 클라이언트에서 서버로 전송되는 중간에 패킷이 가로채일 경우, 사용자의 평문 비밀번호가 그대로 노출될 수 있겠네요.&quot;</strong>라는 피드백을 주셨다.</p>
<p>솔직히 처음에는 &quot;SSL(HTTPS)이 적용되어 있으면 암호화되니까 괜찮은 거 아닌가?&quot;라고 생각했다. 실제로 SSL은 중간자 공격(MTM)을 통해 패킷이 탈취되어도, 그 내용을 보호해 주는 것은 맞다.</p>
<p>하지만 매니저님의 피드백을 통해, 내가 간과하고 있던 보안 위협을 알게 되었다. </p>
<p>바로 SSL이 보호해 주지 못하는 영역, <strong>&#39;클라이언트 사이드&#39;에서의 평문 노출</strong> 문제였다. 만약 악성 브라우저 확장 프로그램이나 XSS(Cross-Site Scripting) 공격이 성공한다면, 데이터가 &#39;전송&#39;되기 직전에 평문 비밀번호가 그대로 탈취될 수 있었다.</p>
<p>그래서 이 문제를 해결하기 위해, 클라이언트 단에서부터 데이터를 직접 암호화하는 <strong>RSA 비대칭키 암호화</strong>를 도입하기로 결정했다.</p>
<hr>
<h2 id="비대칭키-암호화rsa-도입">비대칭키 암호화(RSA) 도입</h2>
<blockquote>
<p>RSA(Rivest-Shamir-Adleman)는 비대칭키 암호화 알고리즘으로, 공개 키로 암호화하고 개인 키로 복호화 하는 방식 </p>
</blockquote>
<p>공개 키는 자유롭게 배포할 수 있기 때문에 키 교환 과정에서의 보안 문제가 없다. 클라이언트는 서버의 공개 키로 데이터를 암호화해 전송하면, 서버만이 이를 복호화할 수 있다.</p>
<p>우선 비대칭키 암호화(RSA)를 통해 패스워드를 암호화하는 경우의 순서는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/972f3444-e1a9-4ea8-bc97-8f95e439876d/image.png" alt=""></p>
<ol>
<li>로그인 페이지 로드 시, 클라이언트가 공개키를 요청한다. </li>
<li>서버에서 공개키와 개인키를 생성 후, 클라이언트에게 공개키를 전송한다.</li>
<li>클라이언트에서 사용자가 입력한 패스워드를 공개키로 암호화한 후 서버에게 전송한다. </li>
<li>서버는 암호화된 패스워드를 개인키로 복호화한다. </li>
<li>이후 DB에 저장된 패스워드의 해시값과 비교(인증)한다.</li>
</ol>
<hr>
<h2 id="백엔드-구현-rsa-키-쌍-생성">백엔드 구현: RSA 키 쌍 생성</h2>
<p>프론트엔드에서 암호화를 수행하기 위해서는 먼저 서버에서 신뢰할 수 있는 키 쌍을 생성해야 한다. 백엔드 구현은 <a href="https://liferay.dev/b/client-side-password-encryption-">Liferay의 EncryptionUil</a> 로직을 참고하여 구현했다.</p>
<p>서버는 <strong>java.security.KeyPairGenerator</strong>를 통해 2048비트의 RSA 키 쌍을 생성한다. 여기서 중요한 점은 <strong>개인키는 서버 메모리에만 안전하게 보관하고, 공개키만 클라이언트에 노출</strong>한다.</p>
<pre><code>// 서버 사이드: RSA 키 쌍 생성 로직 (Liferay 기반)
public static KeyPair generateKeyPair() throws Exception {
    KeyPairGenerator generator = KeyPairGenerator.getInstance(&quot;RSA&quot;);
    generator.initialize(2048); // 2048비트 보안 수준 설정
    return generator.generateKeyPair();
}

// 공개키를 Base64 문자열로 변환하여 프론트엔드에 전달할 준비
public static String getPublicKeyBase64(PublicKey publicKey) {
    return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}</code></pre><hr>
<h2 id="프론트엔드-구현-암호화-프로토콜-설계">프론트엔드 구현: 암호화 프로토콜 설계</h2>
<p><strong>[공개키 로드 및 포맷팅]</strong></p>
<p>서버에서 보내주는 공개키는 순수 Base64 문자열이다. 하지만 JSEncrypt 라이브러리는 PEM 형식(헤더와 푸터가 포함된 형태)을 기대했기 때문에, 서버에서 받은 키를 프론트엔드 단에서 규격에 맞게 가공해야한다.</p>
<pre><code>// 프론트엔드: 서버에서 받은 Base64 키를 PEM 형식으로 변환하여 설정
const encryptPassword = (plainPassword, publicKeyBase64) =&gt; {
  const encryptor = new JSEncrypt();

  // PEM 형식 규격에 맞게 헤더/푸터 추가
  const pemKey = `-----BEGIN PUBLIC KEY-----\n${publicKeyBase64}\n-----END PUBLIC KEY-----`;
  encryptor.setPublicKey(pemKey);

  const encrypted = encryptor.encrypt(plainPassword);
  return encrypted;
};</code></pre><p>클라이언트 단에서 사용자 입력값을 난수 형태의 암호문으로 변환하는 데 성공했다.</p>
<hr>
<p><em><strong>암호화된 데이터를 서버로 전송하는 순간, 에러가 발생했다.</strong></em></p>
<pre><code>javax.crypto.BadPaddingException: Decryption error</code></pre><p>클라이언트 사이드에서는 암호문이 정상적으로 생성되었지만 서버 로그에는 위와 같은 에러가 나타났다.</p>
<p><strong>원인은 Padding(패딩) 방식의 불일치였다.</strong></p>
<p>RSA 암호화는 보안을 위해 평문에 무작위 데이터를 채워넣는데, 서버와 클라이언트의 기본 설정이 달랐던 것이다.</p>
<ul>
<li><strong>클라이언트(JSEncrypt)</strong>: 기본적으로 PKCS#1 v1.5 패딩을 사용</li>
<li><strong>서버(Java)</strong>: Cipher.getInstance(&quot;RSA&quot;)로 설정할 경우, 환경에 따라 기본 패딩 값이 달라져 클라이언트가 보낸 패딩 구조를 해석하지 못함</li>
</ul>
<p>이 문제를 해결하기 위해 백엔드 담당자분과 소통하여, 양측의 암호화 알고리즘 규격을 <strong>RSA/ECB/PKCS1Padding</strong>으로 통일하기로 했다.</p>
<p>이후로는 서버에서 성공적으로 복호화된 평문 데이터를 확인할 수 있었다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/db2ae9e3-2836-46b5-af3a-5184cd43cc02/image.png" alt=""></p>
<p>최종적으로 유저가 로그인 버튼을 누르면 평문 비밀번호는 즉시 암호문으로 변환되어 전송된다. 추가적으로 암호화 직후 평문이 담긴 변수는 즉시 비우도록 처리했으며, 네트워크 탭에서도 실제 비밀번호를 유추할 수 없는 난수만이 노출되는 것을 확인했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[처음엔 useEffect였지만, 지금은 React Query입니다]]></title>
            <link>https://velog.io/@yejiin_21/useEffect-to-React-Query</link>
            <guid>https://velog.io/@yejiin_21/useEffect-to-React-Query</guid>
            <pubDate>Thu, 07 Aug 2025 07:30:59 GMT</pubDate>
            <description><![CDATA[<h2 id="react-query-적용하기-전-나의-api-연동-방식">React Query 적용하기 전, 나의 API 연동 방식</h2>
<p>프론트엔드 개발을 시작한 지 1년 4개월 정도 되었다.
처음 API를 연동할 때는 가장 많이 사용된다는 이유로 <strong>axios</strong>를 선택했고, 서버에서 받아온 데이터를 <strong>useState, useEffect</strong>로 관리했다.</p>
<p>그 당시에는 리액트로 개발 전반적인 흐름을 이해하는 데 목적이 있었기 때문에 어떤 라이브러리가 더 적합할지, 데이터를 어떻게 효율적으로 다뤄야 할지 깊이 고민하지 않았다.</p>
<p>첫 프로젝트가 끝난 뒤, 다음 프로젝트부터는 <em>&#39;어떻게 하면 데이터를 더 잘 관리할 수 있을까?&#39;, &#39;어떤 라이브러리를 적용하면 좋을까?&#39;</em> 같은 고민을 자연스럽게 하게 됐고, 실제로 적용해보며 하나씩 배워갔다.</p>
<h2 id="개발-초기-api-연동-방식">개발 초기 API 연동 방식</h2>
<ul>
<li>서버 요청은 컴포넌트마다 <strong>useEffect</strong> 안에서 직접 처리</li>
<li>응답 데이터를 <strong>useState</strong>로 저장해 상태를 관리했고, 로딩/에러 상태도 매번 따로 구현</li>
<li>재요청이 필요한 경우도 <strong>별도 함수</strong>로 직접 작성해서 처리</li>
</ul>
<p>당시에는 일단 작동만 하면 된다고 생각했고, 코드의 중복이나 성능에 대한 고민은 많지 않았다.
그래서 프로젝트하는 동안 데이터 흐름이 복잡해지고 유지보수도 어렵다는 문제는 느꼈지만 당장은 기한 내에 완성하는 것에만 집중하였다.</p>
<h2 id="다음-프로젝트부터의-변화">다음 프로젝트부터의 변화</h2>
<p>현재 진행 중인 프로젝트에서는 <strong>React Query</strong>를 이용해 서버 데이터를 비동기적으로 관리하고 있다.
프로젝트를 시작하면서 팀원들과 컨벤션을 맞춰보던 중, <strong>React Query</strong>를 적용하면 더 효율적으로 데이터 관리할 수 있다라는 의견에 도입하게 되었다.</p>
<p>하지만 막상 쓰기 시작하고 보니, 왜 이 라이브러리를 써야 하는지, 어떤 점이 기존 방식보다 나은지에 대해서는 깊게 공부하지 않았다.</p>
<p>그래서 이번 포스팅에서는 내가 직접 경험했던 두 방식(API 연동과 상태 관리 방식)을 비교하면서,<em>** React Query를 왜 써야 하는지**</em> 정리해보려 한다.</p>
<hr>
<h2 id="기존-방식의-문제점">기존 방식의 문제점</h2>
<h3 id="1-중복-요청캐싱-미지원">1. 중복 요청&amp;캐싱 미지원</h3>
<pre><code>useEffect(() =&gt; {
  fetchAllDosageData(); // 컴포넌트가 마운트될 때 전체 데이터를 가져옴
}, [fetchAllDosageData]);</code></pre><p>useEffect를 사용해서 컴포넌트가 마운트될 때마다 API 요청이 발생하고 있다. 이 방식은 캐시를 사용하지 않기 때문에, <strong>같은 데이터를 사용하는 다른 컴포넌트에도 중복 요청</strong>이 발생할 수 있다.
이는 불필요한 네트워크 비용, UI 반응 저하, 서버 부하 증가로 이어질 수 있다.</p>
<h3 id="2-로딩에러-상태-처리의-반복과-복잡성">2. 로딩/에러 상태 처리의 반복과 복잡성</h3>
<pre><code>const [dosageData, setDosageData] = useState&lt;DosageData[]&gt;([]);

const fetchAllDosageData = useCallback(async () =&gt; {
  try {
    const response = await axios.get(
      &#39;https://www.everycare.site/api/v1/medicines/records/list&#39;,
      {
        headers: {
          &#39;Content-Type&#39;: &#39;application/json&#39;,
          Accept: &#39;application/json&#39;,
          Authorization: `Bearer ${token}`,
        },
        withCredentials: true,
      },
    );
    ...
    setDosageData(groupedData);
  } catch (error) {
    console.error(&#39;Failed to fetch dosage data:&#39;, error);
  }
}, [token]);</code></pre><p>axios 요청 후 결과를 useState로 수동 저장하고 있고 에러 핸들링도 try/catch로 수동 구현하고 있다. isLoading 상태도 따로 없기 때문에, 로딩 중 여부를 UI에 반영하려면 추가 작업이 필요하다. 또한 재요청(refetch)을 하려면 별도의 함수 호출도 추가 작업이 필요하다.</p>
<hr>
<h2 id="react-query-소개">React Query 소개</h2>
<p>위와 같은 문제점을 효율적으로 처리해주는 게 바로 <strong>React Query</strong>였다.</p>
<p>당시에는 간단히 공부하고 적용했지만, 이번 포스팅에서 이 라이브러리가 왜 필요한지, 어떤 점이 기존 방식보다 나은지 자세히 문서화해보겠다.</p>
<blockquote>
<p><strong>React Query</strong>는 React Component에서 서버의 상태를에서 <strong>서버의 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트</strong> 하는 작업을 도와주는 라이브러리다.
<strong>Hook</strong>을 사용하여 React Component 내부에서 자연스럽게 서버의 데이터를 사용할 수 있는 방법이다.</p>
</blockquote>
<p>React Query에 대한 개념은 이전에 작성한 글이 있어서, 이번 포스팅에서는 생략하고 직접 적용하면서 느낀 점과 기존 방식과의 비교에 초점을 맞춰 정리해보려고 한다.</p>
<p>개념이 궁금하다면 <a href="https://velog.io/@yejiin_21/React-Query">React Query로 데이터 흐름, 이렇게 쉬워도 되나요?</a> 참고해보면 도움이 될 것이다.</p>
<h3 id="react-query를-적용한-코드">React Query를 적용한 코드</h3>
<p><strong>이벤트 상세 조회 (GET)</strong></p>
<pre><code>const useEventDetail = () =&gt; {
  const { id } = useParams();

  const eventId = Number(id);

  const { data } = useQuery({
    queryKey: [&#39;eventDetail&#39;, eventId],
    queryFn: () =&gt; eventDetail({ eventId }),
  });

  return { data };
};</code></pre><p><strong>호스트 생성 요청 (POST)</strong></p>
<pre><code>export const useHostCreation = () =&gt; {
  return useMutation&lt;ApiResponse&lt;null&gt;, Error, HostCreationRequest&gt;({
    mutationFn: async (requestBody: HostCreationRequest) =&gt; {
      return await createHost(requestBody);
    },
  });
};</code></pre><h3 id="react-query를-써야-하는-이유">React Query를 써야 하는 이유</h3>
<h4 id="1-데이터를-상태가-아닌-캐시로-관리한다">1. 데이터를 &#39;상태&#39;가 아닌 &#39;캐시&#39;로 관리한다.</h4>
<p><strong>React Query</strong>는 캐싱 기반의 백엔드 상태 동기화 라이브러리이다. 
동일한 <strong>queryKey</strong>를 기준으로 요청을 캐싱하기 때문에 여러 컴포넌트에서 동일한 데이터를 사용하더라도 네트워크 요청이 한 번만 발생한다. 이는 성능 최적화에 유리하다.</p>
<h4 id="2-복잡한-상태-관리-없이도-로딩에러데이터-상태-등이-자동으로-관리된다">2. 복잡한 상태 관리 없이도 로딩/에러/데이터 상태 등이 자동으로 관리된다.</h4>
<p>isLoading, error, data 등 상태가 자동으로 제공돼서 매번 useState로 상태 선언하고, try/catch로 감쌀 필요가 없다. 이는 코드도 간결해지고 일관성도 유지된다.</p>
<h4 id="3-api-요청에-대한-재사용성과-확장성이-높다">3. API 요청에 대한 재사용성과 확장성이 높다.</h4>
<p>요청 로직이 <strong>queryFn</strong>으로 모듈화되면서 여러 컴포넌트에서 중복없이 재사용 가능하다. 나중에 stale-time, suspense, pagination 등 기능 확장도 쉬워진다.</p>
<hr>
<p><strong>React Query</strong>를 통해 단순히 &quot;잘 작동하는 코드&quot;가 아니라 <strong>&quot;더 효율적이고 안정적인 코드&quot;</strong>를 작성하는 방법을 배울 수 있었다.</p>
<p>처음엔 그저 기능이 좋아 보여서 적용했지만, &quot;왜 이 기술을 써야 하는가&quot;를 스스로 생각하고 선택하는 능력이 중요하다는 것을 느꼈다. 이것은 단순한 구현 능력보다도 <strong>기술 선택에 대한 판단력</strong>이고, 프론트엔드 개발자로서 꼭 필요한 역량 중 하나라고 생각한다.</p>
<p>이전에는 잘 몰랐지만, <strong>라이브러리 하나를 선택하는 기준과 근거를 갖는 것</strong>도 좋은 코드를 작성하는 것만큼 중요한 부분이라는 것을 깨달았다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[2편] WebP 전환, 성능 지표로 다시 뜯어보기]]></title>
            <link>https://velog.io/@yejiin_21/WebP-Performance-Analysis</link>
            <guid>https://velog.io/@yejiin_21/WebP-Performance-Analysis</guid>
            <pubDate>Tue, 01 Jul 2025 09:48:07 GMT</pubDate>
            <description><![CDATA[<p>이전 포스팅에서 이미지 형식을 기존의 JPEG, PNG에서 <strong>WebP</strong>로 전환하면서 눈에 띄는 이미지 최적화를 확인할 수 있었다. </p>
<p><a href="https://velog.io/@yejiin_21/JPEG-WebP-conversion">[1편] JPEG → WebP 전환 후 성능은 어떻게 달라졌을까?</a></p>
<p>하지만 단순히 <strong>“이미지 최적화를 구현했다”</strong>라는 결과만 보고 넘어가기보다는 기술적으로 깊이 있게 공부해보려고 한다.</p>
<hr>
<p>먼저 WebP 개념을 다시 한번 짚고가보자</p>
<p>WebP(Web Picture format)는 Google이 만든 최신 이미지 포맷이다. </p>
<p>JPEG처럼 손실 압축도 되고, PNG처럼 무손실 압축도 지원하며, 투명도(알파 채널), 애니메이션까지 모두 표현할 수 있는 <strong>올인원 포맷</strong>이다.</p>
<p>실제로 WebP는 다음과 같은 용량 절감 효과를 보여준다</p>
<ul>
<li><strong>무손실 WebP</strong>는 PNG보다 약 <strong>26% 더 작고</strong></li>
<li><strong>손실 WebP</strong>는 JPEG보다 <strong>25~34% 더 작으며</strong></li>
<li><strong>투명도가 포함된 손실 WebP</strong>는 PNG보다 <strong>최대 3배 더 작을 수 있다.</strong></li>
</ul>
<p>이러한 결과는 단순히 포맷을 바꿨기 때문만이 아니라, WebP의 압축 방식 자체가 기존 포맷들과 <strong>근본적으로 다르기 때문</strong>이다.</p>
<p>이제부터 기술적인 측면을 심층적으로 설명해보겠다.</p>
<hr>
<h3 id="q-왜-webp로-바꾸면-이미지-용량이-줄어들까">Q) 왜 WebP로 바꾸면 이미지 용량이 줄어들까?</h3>
<p>WebP는 Google의 동영상 기술인 <strong>VP8 코덱</strong>을 활용하여 이미지를 저장할 때 <strong>중복된 정보를 예측하고 차이만 저장하는 방식</strong>을 사용한다. 또한 무손실 압축은 <strong>이미 저장된 픽셀을 재사용하거나 색상 팔레트를 효율적으로 구성</strong>해 중복 데이터를 줄인다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/a9d96ee9-b450-4168-bcec-94b9523eeb8c/image.png" alt=""></p>
<p>기존에는 투명한 이미지를 저장할 때 PNG를 사용했지만, WebP는 <strong>손실 압축과 투명도(알파 채널)를 동시에 지원</strong>해 PNG보다 3배 이상 작게 만들 수 있다. 여기에 JPEG보다 <strong>더 효율적인 산술 인코딩</strong>을 사용하고, 불필요한 메타데이터(EXIF, 색상 프로필 등)를 제거할 수 있어 전체 용량이 더욱 줄어든다.</p>
<p>즉, WebP는 저장방식, 압축 기술, 메타데이터 처리까지 모든 면에서 <strong>더 똑똑하게 이미지 데이터를 다루기 때문에</strong> 같은 이미지를 더 작게 만들 수 있다.</p>
<h3 id="q-단순히-이미지-용량만-줄었는데-왜-lcp까지-줄어든-이유는-뭘까">Q) 단순히 이미지 용량만 줄었는데, 왜 LCP까지 줄어든 이유는 뭘까?</h3>
<p>LCP는 화면에서 가장 큰 콘텐츠 요소(주로 가장 큰 이미지나 텍스트)가 사용자 화면에 완전히 렌더링되는 시점까지의 시간을 측정한다. </p>
<p>이때 LCP에 가장 큰 영향을 주는 요소는 다음 두가지이다.</p>
<ol>
<li>네트워크 지연: 이미지를 받아오는 데 걸리는 시간</li>
<li>브라우저 렌더링: 이미지 디코딩 및 그리기 과정</li>
</ol>
<p>이전 포스팅에서 적었던 이미지 최적화 후 실제 수치 변화는 다음과 같았다.</p>
<ul>
<li><strong>이미지 용량</strong>: 787kB → 84.4kB (<strong>89% 감소</strong>)</li>
<li><strong>네트워크 요청 시간</strong>: 662ms → 112ms (<strong>83% 감소</strong>)</li>
<li><strong>LCP</strong>: 6110ms → 5070ms (약 <strong>17% 감소</strong>)</li>
<li><strong>Performance 점수</strong>: 56 → 75 (<strong>19점 상승</strong>)<strong>이미지 용량</strong>: 787kB → 84.4kB (<strong>89% 감소</strong>)</li>
<li><strong>네트워크 요청 시간</strong>: 662ms → 112ms (<strong>83% 감소</strong>)</li>
<li><strong>LCP</strong>: 6110ms → 5070ms (약 <strong>17% 감소</strong>)</li>
<li><strong>Performance 점수</strong>: 56 → 75 (<strong>19점 상승</strong>)</li>
</ul>
<p>이미지 최적화를 통해 용량이 <strong>89% 감소</strong>하면서 네트워크에서 이미지를 받아오는 시간 또한 <strong>83% 감소</strong>했다.</p>
<p>브라우저는 이미지 리소스를 빠르게 내려받은 덕분에, <strong>디코딩 → 렌더링 단계로 더 빨리 진입</strong>할 수 있었고, 결과적으로 <strong>LCP</strong>는 <strong>약 17% 줄어든 5070ms</strong>로 개선되었다.</p>
<p>이는 브라우저의 렌더링 타임라인에서 <strong>네트워크 지연이 렌더링 병목을 만드는 주요 원인</strong>이라는 것을 알 수 있다. 이미지 용량을 줄이면 단순히 “전송 속도”만 개선되는 게 아니라, <strong>렌더링 타이밍도 빨라져 LCP까지 개선</strong>되는 것이다.</p>
<h3 id="q-네트워크-상태가-안좋으면-최적화-효과는-어떻게-달라질까">Q) 네트워크 상태가 안좋으면 최적화 효과는 어떻게 달라질까?</h3>
<p>이미지 최적화를 통해 페이지 로딩 속도가 빨라진것을 확인할 수 있었다. </p>
<p>하지만 “네트워크 상태가 느릴 때는 최적화 효과가 어떻게 달라질까?”라는 궁금증이 생겼다.</p>
<p>그래서 Network 탭에 Throttling 기능을 활용해 3G 환경에서 실제로 측정해보았다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/8c0806d6-fc9e-4050-985c-a4b21d5e26e3/image.png" alt=""></p>
<ul>
<li><strong>이미지 용량</strong>: 787kB → 84.4kB (<strong>89% 감소</strong>)</li>
<li><strong>네트워크 요청 시간</strong>: 662ms → 794ms (<strong>20% 증가</strong>)</li>
<li><strong>LCP</strong>: 6110ms → 5480ms (약  <strong>10%감소</strong>)</li>
<li><strong>Performance 점수</strong>: 56 → 56 (<strong>변화 없음</strong>)</li>
</ul>
<hr>
<p>위 결과를 통해 네트워크 상태에 따라 결과가 다름을 확인할 수 있었다. 이러한 차이는 네트워크 속도가 느려짐에 따라 <strong>브라우저의 리소스 처리 타이밍이 지연되기 때문</strong>이다.</p>
<p>느린 네트워크에서는 단순한 성능 점수보다는 병목이 어디서 발생했는지를 확인하는 것이 중요하다. 이때 핵심적으로 생각해야할 성능 지표는 크게 두가지이다.</p>
<ol>
<li><strong>TTFB(Time to First Byte)</strong> - 서버로부터 첫 바이트가 도착하는 시간이다. 이는 서버 응답 속도, 캐싱 여부, CDN 적용 등에 영향 받는다.</li>
<li><strong>TTI(Time to Interactive)</strong> - 사용자가 페이지와 실제로 상호작용할 수 있게 되는 시간이다. 이는 JS 실행, 이미지 렌더링 등 리소스 처리 병목의 영향 받는다.</li>
</ol>
<p>결과적으로 <strong>이미지 최적화로 용량은 줄었지만</strong>, 느린 네트워크에서는 <strong>전송 지연(TTFB 증가)</strong>과 <strong>렌더링 처리 지연(TTI 증가)</strong>이 발생하기 때문에, <strong>LCP 개선폭이 줄고 Performance 점수 상승 효과가 상쇄</strong>되었다.</p>
<h3 id="q-webp를-쓰면-성능이-무조건-좋아질까">Q) WebP를 쓰면 성능이 무조건 좋아질까?</h3>
<p>WebP는 JPEG, PNG 대비 더 높은 압축률을 제공하지만, 성능이 무조건 좋아지는 건 아니다.</p>
<p>일부 <strong>iOS 구버전 Safari나 오래된 브라우저에선 WebP가 지원되지 않아 fallback 처리가 필요</strong>하고, WebP 디코딩이 브라우저에 따라 <strong>더 많은 CPU 자원을 소모</strong> 할 수도 있어 모바일 저사양 기기에서는 오히려 부하가 될 수 있다. 또한 WebP로 변환할 때 <strong>alt 태그 누락, 이미지 크기 비율 오류 등</strong>이 있으면 SEO나 접근성 측면에서도 문제가 생길 수 있다.</p>
<p>그러므로 무조건적인 포맷 전환보다는 <strong>환경에 따라 유연하게 대응하는 이미지 최적화 설계</strong>가 필요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[1편] JPEG → WebP 전환 후 성능은 어떻게 달라졌을까?]]></title>
            <link>https://velog.io/@yejiin_21/JPEG-WebP-conversion</link>
            <guid>https://velog.io/@yejiin_21/JPEG-WebP-conversion</guid>
            <pubDate>Tue, 01 Jul 2025 09:30:25 GMT</pubDate>
            <description><![CDATA[<p>최근에 진행한 <strong>같이가요</strong> 서비스는 호스트가 원하는 이벤트를 주최하고 여러 참가자들의 신청 받으며 관리 할 수 있는 서비스이다.</p>
<p>그 중 내 티켓 페이지는 사용자가 이벤트 참여를 위해 구매한 티켓들을 카드 형태로 보여주는 페이지이다. 한 티켓 당 호스트가 주최한 이벤트 배너 이미지를 보여주고 있다.</p>
<p>아래 페이지 접근 시, 티켓 목록 이미지가 늦게 로딩되며 첫 화면이 완전히 보이기까지 지연이 발생한다. 한 유저가 여러 티켓을 구매할 수록 구입한 티켓 정보가 많아지게 되는데, 이때 각 이미지 용량이 크면 이미지 로딩 속도가 느려지는 문제가 발생한다. </p>
<p>이로 인해 사용자 체감 성능 저하로 성능 개선이 필요하다고 생각했다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/ef41f69a-fc7d-4c85-ae77-293ad1389202/image.png" alt=""></p>
<p>성능 측정을 통해 어떤 부분을 최적화해야 하는지 판단할 수 있다. </p>
<p>크롬 브라우저에서 제공하는 웹 개발에 도움되는 다양한 툴이 있다.</p>
<ol>
<li>Network 패널: 현재 웹 페이지에서 발생하는 모든 네트워크 트래픽을 상세하게 볼 수 있다.</li>
<li>Performance 패널: 웹 페이지가 로드될 때 실행되는 모든 작업을 볼 수 있다.</li>
<li>Lighthouse 패널: 구글에서 만든 툴로, 웹 사이트의 성능을 측정하고 개선 방향을 제시해 주는 자동화 툴</li>
</ol>
<p>→ 이 중에서 <strong>Lighthouse</strong>를 통해 성능 측정을 한 뒤 개선해보려고 한다.</p>
<hr>
<h3 id="lighthouse란">Lighthouse란?</h3>
<p><strong>Lighthouse</strong>는 측정한 웹 페이지에서 <strong>다섯 가지 지표</strong>에 가중치를 적용해 평균값을 계산한 <strong>종합 성능 점수</strong>이다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/9e1708fd-addc-405f-8e14-fc5b22489b04/image.png" alt=""></p>
<p>이러한 지표를 <strong>웹 바이탈(Web Vitals)</strong>이라고 부른다.</p>
<ul>
<li><p><strong>First Contentful Paint(FCP)</strong>
  페이지가 로드될 때 브라우저가 DOM 콘텐츠의 <strong>첫 번째 부분</strong>을 렌더링하는 데 걸리는 시간</p>
</li>
<li><p><strong>Largest Contentful Paint(LCP)</strong>
  페이지가 로드될 때 화면 내에 있는 <strong>가장 큰 이미지나 텍스트 요소</strong>가 렌더링되기까지 걸리는 시간</p>
</li>
<li><p><strong>Speed Index(SI)</strong>
  페이지 로드 중에 콘텐츠가 <strong>시각적으로 표시되는 속도</strong>를 나타내는 지표</p>
</li>
<li><p><strong>Total Blocking Time(TBT)</strong>
  페이지가 클릭, 키보드 입력 등의 사용자 입력에 응답하지 않도록 <strong>차단된 시간</strong>을 종합한 지표</p>
</li>
<li><p><strong>Cummulative Layout Shift(CLS)</strong>
  페이지 로드 과정에서 발생하는 예기치 못한 <strong>레이아웃 이동</strong>을 측정한 지표</p>
</li>
</ul>
<hr>
<h3 id="lighthouse를-통한-성능-측정">Lighthouse를 통한 성능 측정</h3>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/yejiin_21/post/9a4611c2-8200-4c8f-99a4-bac78bfaf8c6/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/yejiin_21/post/08712e56-908b-4cbf-85dc-c103150810ea/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>- Performance: <strong>56점</strong></td>
<td></td>
</tr>
<tr>
<td>- 주요 성능 지표</td>
<td></td>
</tr>
<tr>
<td>- FCP (First Contentful Paint): <strong>4.9초</strong></td>
<td></td>
</tr>
<tr>
<td>- LCP (Largest Contentful Paint): <strong>6.4초</strong></td>
<td></td>
</tr>
<tr>
<td>- Speed Index: <strong>7.9초</strong></td>
<td></td>
</tr>
</tbody></table>
<h3 id="문제-원인-분석">문제 원인 분석</h3>
<p>Lighthouse의 진단 결과를 보면 가장 큰 문제는 “<strong>이미지”</strong>에 있었다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/bf716b56-27ee-4d8b-969b-eeaded651fcf/image.png" alt=""></p>
<p>브라우저가 가장 먼저 보여주려는 <strong>주요 콘텐츠(이미지)가 너무 크고, 너무 늦게, 너무 많은 자원</strong>과 함께 로딩되고 있었다는 결과를 볼 수 있다.</p>
<p>이벤트 배너 이미지가 성능에 얼마나 영향을 미치는지 알아보기 위해서는 LCP 수치를 확인하면 알 수 있다. 하나의 이미지를 불러오는데 무려 <strong>6110ms</strong> 시간이 소요되고 있었다.</p>
<p>하지만 Lighthouse가 절대적인 성능 측정의 지표는 아니기 때문에 좀 더 확실하게 성능 개선을 확인하기 위해 네트워크 탭도 확인해봤다.</p>
<p>네트워크 탭을 확인해보니 <strong>이미지 용량이 787kB이고, 해당 이미지를 불러오는데 드는 시간은 662ms로</strong>, 다른 요소들에 비해 너무 많은 시간이 소요되는 것을 확인할 수 있었다.</p>
<p><strong>[최적화 전 LCP(6110ms)]</strong></p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/bf189a36-f9f4-4660-8588-62854e0b9b87/image.png" alt=""></p>
<p><strong>[최적화 전 .jpeg 이미지 용량(787kB)]</strong></p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/5e816fcd-8ff3-4a53-89e4-29d5512966ab/image.png" alt=""></p>
<p>그래서 어떤 방법으로 성능을 개선할 수 있을지 고민해보았다.</p>
<p>이미지 최적화와 관련된 내용들을 찾아보던 중 구글에서 만든 <strong>WebP</strong>라는 이미지 포맷이 최적화에 많이 사용된다는 것을 알았다.</p>
<hr>
<h3 id="webp란">WebP란?</h3>
<p>구글에서 만든 <strong>웹에 최적화된 이미지 포맷</strong>이다.</p>
<p>WebP는 이미지 손상을 최소화하면서 png 또는 jpg와 비교했을 때 평균 40% 감소한 크기로 이미지를 압축할 수 있다. 현재 유저가 이미지를 업로드할때 png, jpg, jpeg 파일만 업로드 하도록 설정했는데 이를 webP로 바꾼다면 이미지를 불러올 때 시간이 훨씬 줄어들 수 있겠다고 생각했다.</p>
<p>유저가 이벤트 배너 이미지를 업로드하는 과정에서 <strong>이미지 형식을 webP로 바꿔서 업로드</strong> 되도록 하는 <code>convertImageToWebP</code>을 구현했다.</p>
<pre><code>const convertImageToWebP = (file: File): Promise&lt;File&gt; =&gt; {
  return new Promise((resolve, reject) =&gt; {
    const img = new Image();
    const reader = new FileReader();

    reader.onload = () =&gt; {
      if (typeof reader.result === &#39;string&#39;) {
        img.src = reader.result;
      }
    };

    img.onload = () =&gt; {
      const canvas = document.createElement(&#39;canvas&#39;);
      canvas.width = img.width;
      canvas.height = img.height;

      const ctx = canvas.getContext(&#39;2d&#39;);
      if (!ctx) return reject(new Error(&#39;Canvas context error&#39;));

      ctx.drawImage(img, 0, 0);

      canvas.toBlob(blob =&gt; {
        if (!blob) return reject(new Error(&#39;WebP 변환 실패&#39;));
        const webpFile = new File([blob], file.name.replace(/\.\w+$/, &#39;.webp&#39;), { type: &#39;image/webp&#39; });
        resolve(webpFile);
      }, &#39;image/webp&#39;);
    };

    img.onerror = reject;
    reader.onerror = reject;

    reader.readAsDataURL(file);
  });
};</code></pre><h3 id="1-유저가-업로드한-이미지-읽기">1. 유저가 업로드한 이미지 읽기</h3>
<p>유저가 올린 이미지를 <code>FileReader</code>를 이용해 브라우저가 이해할 수 있는 형태인 데이터 URL로 변환한다. 이 URL은 Image 객체에 넣어 브라우저에서 표시할 수 있도록 준비하는 과정이다.</p>
<h3 id="2-이미지를-캔버스에-그리기">2. 이미지를 캔버스에 그리기</h3>
<p>이미지가 로드되면 브라우저의  <code>&lt;canvas&gt;</code>요소를 생성하고, 그 안에 이미지를 그대로 그린다. 이 과정은 이미지를 가공하거나 포맷을 바꾸기 위해 그림판처럼 복사해놓는 역할을 한다,</p>
<h3 id="3-캔버스에-복사된-이미지를-webp로-변환">3. 캔버스에 복사된 이미지를 WebP로 변환</h3>
<p>캔버스에 그려진 이미지를 <code>toBlob()</code> 메서드를 사용해 WebP 포맷이 변환된다.</p>
<p>사진을 직접 첨부한 후 콘솔에서 업로드할 URL을 확인해보면 <code>.webp</code> 포맷이 변환된 것을 확인 할 수 있다!</p>
<blockquote>
<p>업로드할 URL: <a href="https://gotogetherbucket.s3.~~~.webp">https://gotogetherbucket.s3.~~~.webp</a></p>
</blockquote>
<p>성능 개선 후, LCP와 네트워크 탭을 확인해보니 확실히 성능 개선된 것을 확인할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/ddd73116-bb57-4fe3-9bff-a83d14ff4df9/image.png" alt=""></p>
<ul>
<li>Performance <strong>56점 → 75점</strong> 증가 <strong>(19점 증가)</strong></li>
<li>LCP <strong>6110ms → 5070ms</strong> 감소 <strong>(17% 감소)</strong></li>
<li>이미지 용량 <strong>787kB→ 84.4kB</strong> 감소 <strong>(89% 감소)</strong></li>
<li>네트워크 탭의 타임(이미지를 불러오는 시간) <strong>662ms → 112ms</strong> 감소 <strong>(83% 감소)</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/4b2c2109-29b0-456a-85cc-63fdfe5309f6/image.png" alt=""><img src="https://velog.velcdn.com/images/yejiin_21/post/1d81f659-2ca4-403d-8db4-ef15d005f11d/image.png" alt=""></p>
<p>다음 포스팅에서는 _<strong>WebP 최적화가 실제로 어떻게 성능에 영향을 주는지, 기술적인 지표와 개념을 중심</strong>_으로 더 깊이 있게 살펴볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[복잡한 모달 흐름을 간단하게! 전역 상태로 다루는 모달 관리법]]></title>
            <link>https://velog.io/@yejiin_21/%EB%B3%B5%EC%9E%A1%ED%95%9C-%EB%AA%A8%EB%8B%AC-%ED%9D%90%EB%A6%84%EC%9D%84-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C%EB%A1%9C-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%AA%A8%EB%8B%AC-%EA%B4%80%EB%A6%AC%EB%B2%95</link>
            <guid>https://velog.io/@yejiin_21/%EB%B3%B5%EC%9E%A1%ED%95%9C-%EB%AA%A8%EB%8B%AC-%ED%9D%90%EB%A6%84%EC%9D%84-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C%EB%A1%9C-%EB%8B%A4%EB%A3%A8%EB%8A%94-%EB%AA%A8%EB%8B%AC-%EA%B4%80%EB%A6%AC%EB%B2%95</guid>
            <pubDate>Thu, 20 Mar 2025 09:26:33 GMT</pubDate>
            <description><![CDATA[<p>이번 프로젝트에서 순차적으로 열리는 3개의 모달을 구현하는 과정에서, 모달 간 의존성이 생기는 문제가 발생했다. 기존에는 <strong>useState를 활용한 개별 상태 관리</strong>와 <strong>ModalPortal</strong>을 사용했지만, 다중 모달을 다루기에는 비효율적이었다. 
이번 글에서 <strong>다중 모달을 효율적으로 관리하는 방법</strong>을 소개하려고 한다.</p>
<hr>
<h2 id="기존-모달-구현-방식">기존 모달 구현 방식</h2>
<h3 id="1-usestate를-활용한-모달-상태-관리">1. useState를 활용한 모달 상태 관리</h3>
<pre><code class="language-jsx">const [loginModalOpen, setLoginModalOpen] = useState(false);
const [feedModalOpen, setFeedModalOpen] = useState(false);</code></pre>
<h3 id="2-조건부-렌더링과-modalportal을-통한-모달-렌더링">2. 조건부 렌더링과 ModalPortal을 통한 모달 렌더링</h3>
<pre><code class="language-jsx">{feedModalOpen &amp;&amp; selectedCard &amp;&amp; (
          &lt;ModalPortal&gt;
            &lt;FeedDetailCardModal card={selectedCard} onClose={() =&gt; setFeedModalOpen(false)} /&gt;
          &lt;/ModalPortal&gt;
        )}</code></pre>
<h2 id="기존-모달의-문제점">기존 모달의 문제점</h2>
<ol>
<li>각 모달의 상태를 별도로 관리해야 해서 모달이 많아질수록 <strong>상태 관리가 복잡</strong>해질 수 있다. 이는 <strong><code>props-drilling</code> 문제</strong>를 일으킬 수 있다.</li>
<li><code>ModalPortal</code>은 직접적인 상태 관리를 요구하지 않아 단일 모달에서는 괜찮지만, 여러 모달을 다루려면 각각의 모달에 대한 상태를 별도로 관리해야 하므로 다중 모달을 구현하기에는 <strong>상태 관리가 분산되고 복잡</strong>해질 수 있다.</li>
</ol>
<p>위 방법으로 다중 모달을 구현했을 때, 하나의 모달이 열리면 다른 모달이 닫히거나 상태가 변경되는 등의 <strong>의존성이</strong> 생겨 <strong>관리하기가 어려웠다.</strong> </p>
<blockquote>
<p><strong>상태 관리 복잡성, 모달 간 의존성, 관리 어려움</strong></p>
</blockquote>
<hr>
<p>이렇게 복잡한 모달 흐름을 효율적으로 관리하기 위해 3가지 핵심 요소를 고려했다.</p>
<h3 id="1-모달을-스택stack-구조로-관리"><strong>1. 모달을 스택(Stack) 구조로 관리</strong></h3>
<p>여러 개의 모달이 겹쳐지는 상황을 자연스럽게 처리하기 위해 <strong>배열(Array)을 활용한 스택 구조</strong>로 모달을 관리했다.</p>
<pre><code class="language-jsx">const [modalStack, setModalStack] = useState&lt;Modal[]&gt;([]);

  const push = (modal: Modal) =&gt; {
    setModalStack(prev =&gt; {
      const stack = [...prev, modal];
      console.log(&#39;Modal Pushed:&#39;, stack);
      return stack;
    });
  };

  const pop = () =&gt; {
    setModalStack(prev =&gt; {
      const stack = prev.slice(0, -1);
      console.log(&#39;Modal Popped:&#39;, stack);
      return stack;
    });
  };

  const clear = () =&gt; {
    setModalStack([]);
    console.log(&#39;Modal Stack Cleared&#39;);
  };</code></pre>
<p><code>modalStack</code>은 현재 활성화된 모달들의 배열이며, 가장 마지막에 추가된 모달이 최상위에 위치한다.</p>
<p><code>push</code> 함수는 새로운 모달을 배열 끝에 추가하고, <code>pop</code> 함수는 배열의 마지막 요소를 제거하는 방식으로 동작한다. 이를 통해 <strong><code>LIFO(Last In, First Out)</code></strong> 방식으로 모달을 자연스롭게 관리할 수 있다.</p>
<h3 id="2-context-api를-활용한-전역-상태-관리"><strong>2. Context API를 활용한 전역 상태 관리</strong></h3>
<p>전역적으로 모달 상태를 공유할 수 있도록 <strong><code>Context API</code></strong>를 이용하여 어디서든 모달을 쉽게 열고 닫을 수 있도록 구현했다.</p>
<pre><code class="language-jsx">const ModalContext = createContext&lt;ModalContextType | undefined&gt;(undefined);

const ModalProvider = ({ children }: { children: ReactNode }) =&gt; {
  const [modalStack, setModalStack] = useState&lt;Modal[]&gt;([]);
  const isModalOpen = modalStack.length &gt; 0;

  return (
    &lt;ModalContext.Provider value={{ modalStack, isModalOpen, push, pop, clear }}&gt;
      {children}
    &lt;/ModalContext.Provider&gt;
  );
};

const useModalStack = () =&gt; {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error(&#39;useModalStack must be used within a ModalProvider&#39;);
  }
  return context;
};</code></pre>
<p><code>ModalContext</code>를 생성하여 <strong>모달 상태와 조작 함수(push, pop, clear)를 전역적으로 관리</strong>할 수 있게 했고, <code>useModalStack</code> 훅을 통해 어디서든 <code>ModalContext</code>를 사용해 모달을 조작할 수 있도록 했다.</p>
<p><code>ModalProvider</code>가 <code>modalStack</code>의 상태를 유지하며, <code>isModalOpen</code>을 통해 <strong>현재 모달이 열려 있는지 여부</strong>를 쉽게 확인할 수 있도록 구현하였다.</p>
<h3 id="3-react-portal을-이용한-모달-렌더링"><strong>3. React Portal을 이용한 모달 렌더링</strong></h3>
<p>일반적으로 모달은 <code>div</code> 내부에서 렌더링하지만, <code>z-index</code> 문제나 레이아웃이 깨지는 문제를 방지하기 위해 <strong><code>React Portal</code>을 사용해 최상위 DOM에서 렌더링</strong>하였다.</p>
<pre><code class="language-jsx">function ModalPortal() {
  const { modalStack } = useModalStack();
  const node = document.getElementById(&#39;portal&#39;) as Element;

  return createPortal(
    &lt;div&gt;
      {modalStack.map((modal, index) =&gt; (
        &lt;div key={modal.key} className={`z-[${1000 + index}]`}&gt;
          &lt;modal.Component {...modal.componentProps} /&gt;
        &lt;/div&gt;
      ))}
    &lt;/div&gt;,
    node
  );
}

export default ModalPortal;</code></pre>
<p><code>createPortal</code>을 사용하여 <code>#portal</code> 요소(최상위 DOM)에 모달을 렌더링하면서 <strong>부모 요소의 스타일 영향을 받지 않고 독립적으로 렌더링</strong>할 수 있도록 했다.</p>
<hr>
<p>구현하면서 느낀점은 <strong>상태 관리의 중요성</strong>이었다. </p>
<p>처음에는 모달을 단순히 각 컴포넌트 내에서만 제어하면 충분할 거라 생각했지만, <strong>여러개 모달의 순서를 관리</strong>해야 하는 상황에서는 기존 방식으로는 한계가 있었다. 이를 위해 <strong>모달을 전역적으로 관리하는 구조</strong>가 필요하다고 생각했고, <strong>어떻게 구조화</strong>할 것인지에 초점을 두었다.</p>
<p>또한, 모달이 특정 컴포넌트 내부에 종속되면 <strong>UI 깨짐</strong>이나 <strong>제어의 어려움</strong>이 발생할 수 있다는 점도 경험했다. 전역적으로 관리한다고 해서 모든 문제가 해결되는 것이 아니라, <strong>어떤 방식으로 상태를 구성하고 조작할 것인지에 대한 명확한 기준</strong>을 설정하는 것이 중요했다. 단순한 UI 요소의 관리도 <strong>구조적인 사고</strong>와 <strong>설계</strong>가 필요하다고 생각했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Query로 데이터 흐름, 이렇게 쉬워도 되나요?]]></title>
            <link>https://velog.io/@yejiin_21/React-Query</link>
            <guid>https://velog.io/@yejiin_21/React-Query</guid>
            <pubDate>Sun, 12 Jan 2025 09:35:01 GMT</pubDate>
            <description><![CDATA[<p>리액트 어플리케이션의 경우 크게 3가지의 상태를 관리한다.</p>
<p>특정 컴포넌트 내부에서 사용하는 <strong>local state</strong>, 전역으로 관리하는 <strong>global state</strong>, 서버에서 받아온 데이터를 관리하는 <strong>server state</strong>가 있다.
React Query는 이 중 <strong>server state를 관리하는 최적화된 라이브러리</strong>이다.</p>
<p>이번 글에서는 React Query가 서버 상태를 어떻게 최적화하고 관리하는지에 대해 알아보려고 한다.</p>
<h2 id="react-query란">React Query란?</h2>
<p><img src="https://velog.velcdn.com/images/yejiin_21/post/b69d7ad2-453c-4868-ad6e-5aef0bae5837/image.png" alt=""></p>
<blockquote>
<p><strong>fetching, caching, 서버 데이터와의 동기화를 지원해주는 라이브러리</strong></p>
</blockquote>
<p>→ React 환경에서 서버의 상태를 불러오고, 캐싱하며, <strong>지속적으로 동기화</strong>하고 업데이트하는 작업을 도와주는 라이브러리</p>
<h2 id="react-query-왜-사용할까">React Query, 왜 사용할까?</h2>
<ul>
<li><strong>클라이언트 상태와 서버 상태의 분리</strong><ul>
<li>클라이언트에서 두 상태를 혼합하여 사용할 경우 상태 관리와 로직 작성이 복잡해지고 유지보수가 어려워질 수 있어서 React Query를 사용해서 <strong>서버 상태를 따로 관리</strong>할 수 있다.</li>
</ul>
</li>
<li><strong>최신 상태 유지</strong><ul>
<li>일정 시간마다 상태를 업데이트하거나 특정 동작 이후에 상태를 업데이트 할 수 있다.</li>
</ul>
</li>
<li><strong>캐싱, 중복 요청 방지</strong><ul>
<li>서버에 API 요청하여 받아온 결과를 캐싱하며 중복 요청을 최소화할 수 있다.</li>
</ul>
</li>
<li><strong>비동기 요청에 대한 상태 핸들링</strong><ul>
<li>비동기 API 요청에 대한 로딩 상태, 결과 값, 에러 상태와 같은 여러가지 상태를 확인하는 기능을 제공한다.</li>
</ul>
</li>
</ul>
<h2 id="react-query의-대표-기능">React Query의 대표 기능</h2>
<h3 id="usequery">useQuery</h3>
<p>useQuery Hook으로 수행되는 Query 요청은 <strong>GET</strong> 요청과 같이 서버에 저장되어 있는 <strong>상태</strong>를 불러와 사용한다.</p>
<pre><code class="language-jsx">const { data } = useQuery(
  queryKey, // 이 Query 요청에 대한 응답 데이터를 캐시할 때 사용할 Unique Key (required)
  queryFn, // 이 Query 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
  options, // useQuery에서 사용되는 Option 객체 (optional)
);</code></pre>
<ul>
<li><strong>queryKey</strong><ul>
<li><code>queryKey</code>는 쿼리를 식별하는 고유한 값으로, 배열 형태로 지정한다.</li>
<li><code>useQuery Hook</code>은 요청마다(API마다) 구분되는 <strong>Unique Key(aka. Query Key)</strong>가 필요하다. React Query는 이 <code>Unique Key</code>로 서버 상태(aka. API Response)로 로컬에 캐시하고 관리한다.</li>
<li>기본적으로 <code>queryFn</code>에서 사용하는 변수는, 변수가 변경될 때마다 자동으로 다시 가져올 수 있게 쿼리 키에 포함돼야 한다.<ul>
<li>변수와 상관없이 항상 하나의 쿼리로 처리하고 싶으면, <strong>ESLint exhaustive-deaps 규칙을 비활성화</strong> 하면 된다.</li>
</ul>
</li>
</ul>
</li>
<li><strong>queryFn</strong><ul>
<li><strong>queryFn</strong>은 데이터를 가져오는 비동기 함수</li>
<li>반드시 데이터를 반환하거나 오류를 던져야 한다. 던져진 오류는 반환되는 error 객체로 확인할 수 있다.</li>
</ul>
</li>
</ul>
<pre><code class="language-jsx">import { useQuery } from &#39;@tanstack/react-query&#39;

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData() {
  const { data, error } = useQuery&lt;ResponseValue&gt;({
    queryKey: [&#39;delay&#39;, wait],
    queryFn: async () =&gt; {
      const res = await fetch(`https://api.heropy.dev/v0/delay?t=${wait}`)
      const data = await res.json()
      if (!data.time) {
        throw new Error(&#39;문제가 발생했습니다!&#39;)
      }
      return data
    },
    staleTime: 1000 * 10, // 데이터 유효 기간(fresh -&gt; stale)
    retry: 1 // 요청 재시도 횟수
  })
  return (
    &lt;&gt;
      {data &amp;&amp; &lt;div&gt;{JSON.stringify(data)}&lt;/div&gt;}
      {error &amp;&amp; &lt;div&gt;{error.message}&lt;/div&gt;}
    &lt;/&gt;
  )
}</code></pre>
<aside>

<p><strong>코드 흐름</strong></p>
<ol>
<li><code>queryKey</code>로 요청 구분</li>
<li><code>queryFn</code>으로 데이터 요청</li>
<li>캐싱된 데이터가 있으면 반환, 없으면 새로 요청</li>
<li>변수 변경 시 <code>queryKey</code>를 기준으로 자동 재요청</li>
</ol>
</aside>

<h3 id="usemutation">useMutation</h3>
<p>useMutation Hook으로 수행되는 <code>Mutation</code> 요청은 <strong>POST,PUT,DELETE</strong> 요청과 같이 서버 데이터에 <strong>변경 요청</strong>을 처리할 때 사용한다.</p>
<p>서버와의 상호작용으로 상태를 변경하고, 실시간으로 UI를 업데이트하거나 에러를 처리하는데 유용하다.</p>
<pre><code class="language-jsx">const { mutate } = useMutation( // mutate: 요청을 트리거 하는 함수
  mutationFn, // 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
  options, // useMutation에서 사용되는 Option 객체 (optional)
// onSuccess: 요청 성공 콜백 함수, onError: 요청 실패 콜백 함수, onSettled: 성공 여부 상관없이 콜백 함수
);</code></pre>
<pre><code class="language-jsx">import React from &quot;react&quot;;
import { useMutation } from &quot;react-query&quot;;
import axios from &quot;axios&quot;;

const addTodo = async (newTodo) =&gt; {
  const response = await axios.post(&quot;/api/todos&quot;, newTodo);
  return response.data;
};

const TodoApp = () =&gt; {
  const { mutate, isLoading, isError, isSuccess } = useMutation(addTodo, {
    onSuccess: (data) =&gt; {
      console.log(&quot;Todo 추가 성공:&quot;, data);
      alert(&quot;새로운 Todo가 추가되었습니다!&quot;);
    },
    onError: (error) =&gt; {
      console.error(&quot;Todo 추가 실패:&quot;, error);
      alert(&quot;오류가 발생했습니다. 다시 시도해주세요.&quot;);
    },
  });

  const handleAddTodo = () =&gt; {
    const newTodo = { title: &quot;React Query 배우기&quot;, completed: false };
    mutate(newTodo);
  };

  return (
    &lt;div&gt;
      &lt;h1&gt;Todo List&lt;/h1&gt;
      &lt;button onClick={handleAddTodo} disabled={isLoading}&gt;
        {isLoading ? &quot;추가 중...&quot; : &quot;Todo 추가&quot;}
      &lt;/button&gt;
      {isSuccess &amp;&amp; &lt;p&gt;Todo가 성공적으로 추가되었습니다!&lt;/p&gt;}
      {isError &amp;&amp; &lt;p&gt;오류가 발생했습니다. 다시 시도해주세요.&lt;/p&gt;}
    &lt;/div&gt;
  );
};

export default TodoApp;</code></pre>
<h2 id="react-query로-서버-상태를-관리하기">React Query로 서버 상태를 관리하기</h2>
<p>클라이언트 상태와 서버 상태는 웹 애플리케이션에서 데이터를 관리할 때 핵심적으로 구분해야 할 개념이다. <code>React Query</code>는 <strong>서버 상태 관리를 간소화</strong>하는 데 중점을 둔 라이브러리이다. </p>
<h3 id="클라이언트-상태-vs-서버-상태">클라이언트 상태 vs 서버 상태</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th><strong>클라이언트 상태</strong></th>
<th><strong>서버 상태</strong></th>
</tr>
</thead>
<tbody><tr>
<td>정의</td>
<td>사용자의 브라우저 내에서 관리되는 데이터</td>
<td>서버에 저장되어 클라이언트 요청으로 가져오는 데이터</td>
</tr>
<tr>
<td>저장 위치</td>
<td>브라우저 메모리 (ex. useState, useReducer 등)</td>
<td>서버 (데이터베이스, API)</td>
</tr>
<tr>
<td>특징</td>
<td>애플리케이션 실행 중 유지되며, 초기화되기 쉬움</td>
<td>외부에서 관리되며, 항상 최신 상태로 동기화 필요</td>
</tr>
<tr>
<td>갱신 방식</td>
<td>로컬에서 즉시 갱신</td>
<td>서버로 요청을 보내 데이터를 가져오거나 갱싱</td>
</tr>
<tr>
<td>예시</td>
<td>모달 열림 여부, 입력 폼 상태, 페이지 로컬 필터 값</td>
<td>유저 정보, 상품 목록 주문 상태 등</td>
</tr>
<tr>
<td>관리의 복잡성</td>
<td>간단 (내부 로직으로 제어 가능)</td>
<td>복잡 (네트워크 요청, 에러 처리 등 필요)</td>
</tr>
</tbody></table>
<p><strong>[ React Query의 역할 ]</strong></p>
<p>React Query는 <strong>서버 상태를 가져오고, 캐싱하며, 필요할 때 자동으로 데이터를 갱신하는 작업</strong>을 처리해준다. 이를 통해 클라이언트는 데이터 요청 및 관리의 복잡성을 줄이고, 핵심 로직에만 집중할 수 있다.</p>
<h3 id="react-query를-활용한-서버-상태-관리-방법">React Query를 활용한 서버 상태 관리 방법</h3>
<ol>
<li><p><strong>Query Key</strong>로 데이터 상태를 명확히 정의</p>
<ul>
<li><p><strong>Query Key</strong>는 배열 형태로 구성</p>
</li>
<li><p>첫 번째 값은 데이터의 종류를 정의, 이후에는 동적으로 변경되는 변수들을 추가</p>
<pre><code class="language-jsx">const { data } = useQuery([&#39;users&#39;, userId], fetchUserData);</code></pre>
<p>Query Key를 정의함으로써, React Query는 <strong>서버에서 데이터를 가져오거나 캐시된 데이터를 반환할 때 이 키를 기준으로 상태 추적</strong></p>
</li>
</ul>
</li>
<li><p><strong>데이터 캐싱</strong>과 <strong>staletime</strong>의 활용</p>
<ul>
<li><p><strong>데이터 캐싱</strong>: 데이터를 가져온 후, 기본적으로 React Query는 해당 데이터를 메모리에 저장하고, 동일한 요청에 대해 다시 서버로 요청을 보내지 않아 불필요한 네트워크 요청 방지한다.</p>
</li>
<li><p><strong>staleTime</strong>: 데이터를 얼마나 “신선한” 상태로 유지할지 설정하는 옵션이다. <code>staleTime</code>을 늘리면 데이터를 오래 동안 fresh 상태로 유지할 수 있어 불필요한 요청을 줄이고, 사용자에게 빠른 응답을 제공할 수 있다.</p>
<pre><code class="language-jsx">const { data } = useQuery(
[&#39;posts&#39;],
fetchPosts,
{
  staleTime: 1000 * 60 * 5 // 5분 동안 데이터를 fresh 상태로 유지
}
);</code></pre>
</li>
</ul>
</li>
<li><p>네트워크 재연결, 요청 실패 등의 자동 갱신</p>
<ul>
<li><p><strong>자동 재연결</strong>: 네트워크 연결이 끊어진 경우, React Query는 자동으로 네트워크가 재연결되면 데이터를 다시 가져온다.</p>
</li>
<li><p><strong>요청 실패 처리</strong>: 요청이 실패할 경우, React Query는 자동으로 재시도하거나, 옵션에 따라 수동으로 재시도를 유도할 수 있다.</p>
<pre><code class="language-jsx">const { data, error } = useQuery(
&#39;posts&#39;,
fetchPosts,
{
  retry: 3, // 요청 실패 시 최대 3번까지 재시도
  refetchOnWindowFocus: true // 윈도우 포커스를 다시 얻을 때 자동으로 데이터 갱신
}
);</code></pre>
</li>
</ul>
</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>React Query를 알기 전, <code>fetch</code>나 <code>axios</code>로 데이터를 가져오고, 로딩 상태를 관리하며, 실패 시 재시도 로직, 에러 메세지를 표시하는 작업을 전부 직접 구현하며 서버 상태를 관리했다.  이 방식은 코드가 <strong>반복적이고, 상태관리가 복잡</strong>해지는 문제가 있었다. 또한 모든 상황을 일일이 다뤄야 해서 코드의 가독성이나 유지 보수가 어려웠다.</p>
<p>이런 문제를 해결하기 위해 <strong><code>React Query</code></strong>를 적용해보았고 이전에 모든 경우를 직접 설정해야 했지만, <strong>자동 캐싱과 데이터의 자동 갱신</strong>을 통해 데이터를 효율적으로 관리해 주었다. 이후 서버 상태 관리가 훨씬 간소화되고, 코드의 중복도 줄어드는 등 많은 장점이 있었다.</p>
<p>이전처럼 단순히 코드 구현에만 집중하는 것이 아니라, 하나의 API 연동하더라도 서버와 클라이언트 간의 데이터 흐름 등 고려해야하는 부분이 많다는 것을 느꼈다. 앞으로도 더 나은 코드 품질과 성능을 위해 다양한 도구와 패턴을 공부하며 적용할 것이다.</p>
<p><strong>Ref</strong>
<a href="https://tech.kakaopay.com/post/react-query-1/#react-query%EC%9D%98-query-%EC%9A%94%EC%B2%AD">카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유</a>
<a href="https://www.heropy.dev/p/HZaKIE">TanStack Query(React Query) 핵심 정리</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[FSD로 코드정리, 나도 이제 정리왕👑]]></title>
            <link>https://velog.io/@yejiin_21/FSD%EB%A1%9C-%EC%BD%94%EB%93%9C%EC%A0%95%EB%A6%AC-%EB%82%98%EB%8F%84-%EC%9D%B4%EC%A0%9C-%EC%A0%95%EB%A6%AC%EC%99%95</link>
            <guid>https://velog.io/@yejiin_21/FSD%EB%A1%9C-%EC%BD%94%EB%93%9C%EC%A0%95%EB%A6%AC-%EB%82%98%EB%8F%84-%EC%9D%B4%EC%A0%9C-%EC%A0%95%EB%A6%AC%EC%99%95</guid>
            <pubDate>Mon, 25 Nov 2024 07:43:37 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yejiin_21/post/e4881b41-e774-4252-9192-b5d7bcfe72dd/image.png" alt=""></p>
<h2 id="feature-sliced-designfsd-도입">Feature-Sliced Design(FSD) 도입</h2>
<p>이전 프로젝트에서는 src 디렉토리 내에 api, components, hook 등 역할별로 폴더구조를 사용했다. 이 폴더 구조로는 많은 단점이 있었다.</p>
<pre><code>// 이전 프로젝트의 폴더 구조  
📦src
┣ 📂api
┣ 📂assets
┣ 📂components
┣ 📂hooks
┣ 📂pages
┣ 📂routes
┣ 📂shared
┗ 📂util</code></pre><ol>
<li>폴더 내 파일 수가 증가할수록 해당 파일을 찾는 데 <strong>많은 시간 소요와 관리의 어려움</strong></li>
<li>역할별로 파일이 분리되어 있지만, 모듈 간 의존성이 복잡해지면서 코드의 <strong>가독성과 유지보수성 저하</strong></li>
</ol>
<p>이외에도 여러 문제를 해결하기 위해, 이번 프로젝트에서는 <strong>FSD(Feature-Sliced Design)</strong>구조를 적용해보려고 한다. 특히 이번 프로젝트는 이전보다 규모가 커졌기 때문에, 기능 단위로 구조를 나누는 FSD가 코드 관리와 유지보수에 더 효율적일거라 생각했다.</p>
<h2 id="🗂️-fsd-아키텍처란">🗂️ <strong>FSD 아키텍처란?</strong></h2>
<blockquote>
<p>Feature-Sliced Design(FSD)는 프론트엔드 애플리케이션의 구조를 잡는 아키텍처 방법론이다. 소프트웨어 시스템 기능 중심으로 나누어 모듈화하고, 각 기능을 독립적으로 관리하는 방식이다.</p>
</blockquote>
<p>FSD는 전체적으로 layer, slice, segment의 총 3depth로 이루어져있다.
<img src="https://velog.velcdn.com/images/yejiin_21/post/f363707e-f3d0-4509-a6ad-2f020194e036/image.png" alt=""></p>
<h3 id="layers">Layers</h3>
<p>layer는 최상위 depth로 기존에는 7개였으나 process layer는 현재 사용되지 않기에 6개로 구성하고 있다.</p>
<p>layer 프로젝트의 논리적 단위이며, 각 layer 특정 역할을 담당한다. 이는 다른 layer에 의존하거나 영향을 미칠 수 있다.</p>
<ul>
<li><code>app</code>: 프로젝트의 최상위 계층으로 앱을 실행하는 모든 것을 담당한다. Provider, Router, 전역 스타일 및 타입 등이 여기에 정의대며 app의 entry point 역할한다.</li>
<li><code>pages</code>: 개별 페이지의 구현을 담당한다.</li>
<li><code>widgets</code>: 재사용 가능한 UI 구성 요소를 포함한다. 쉽게 말해, UI 큰 틀은 같고 안의 내용만 바뀌는 것들이 포함한다.</li>
<li><code>shared</code>: 모든 계층에서 사용될 수 있는 유틸리티와 공통 컴포넌트들 포함<ul>
<li>slice가 존재하지 않고 바로 segments 파일 존재</li>
</ul>
</li>
<li><code>features</code>: 특정 기능을 구현하는 데 필요한 모든 것을 포함한다.</li>
<li><code>entities</code>: 도메인 모델과 관련된 모든 것</li>
</ul>
<h4 id="📌-기존의-components-폴더의-역할-분리">📌 기존의 components 폴더의 역할 분리</h4>
<p>기존에 components로 묶여있던 요소들은 <code>features</code>와  <code>entities</code>로 구분한다.</p>
<pre><code>&lt;Shared.Button
    onClick={forkFeature.api.fork}
    icon={shared.icon.fork}
    data={forkEntity.model.forkCount}
/&gt;</code></pre><ul>
<li><code>features</code>에서 버튼 동작(onClick 등) 정의</li>
<li>버튼이 한 페이지에서 말고 여러 페이지에서 재사용이 된다면 <code>shared</code></li>
<li><About> 데이터이므로 <code>entities/api</code> 로 불러옴</li>
</ul>
<hr>
<h3 id="layers레이어-간-의존성-규칙">Layers(레이어) 간 의존성 규칙</h3>
<ul>
<li><code>app</code> 컴포넌트에서는 모든 레이어를 import할 수 있지만 가급적 <code>pages</code>만 import 하다.</li>
<li><code>pages</code> 컴포넌트에서는 하위 4개 레이어(<code>widgets</code>, <code>features</code>, <code>entites</code>, <code>shared</code>)만 import 가능하다</li>
<li><code>features</code> 컴포넌트에서는 하위 2개 레이어(<code>entities</code>, <code>shared</code>)만 import 가능하다.</li>
</ul>
<p><em><strong>하위계층에서 상위계층을 import 할 수 없다.</strong></em></p>
<h3 id="📂-공개-api-index-파일의-활용">📂 공개 API: index 파일의 활용</h3>
<p>각 디렉토리 <code>index.js</code> 또는 <code>index.ts</code> 파일을 두어, 외부로 공유할 컴포넌트만 export한다.</p>
<pre><code>📦src
 ┣ 📂pages
 ┃ ┃ ┣ 📜home.tsx
 ┃ ┃ ┣ 📜profile.tsx
 ┃ ┃ ┣ 📜about.tsx
 ┃ ┃ ┗ 📜index.ts</code></pre><pre><code>// index.ts
export {about} from&#39;./about&#39;;</code></pre><h2 id="index-파일에서-export-하지-않으면-해당-파일은-외부에서-사용할-수-없다"><strong>index 파일에서 export 하지 않으면 해당 파일은 외부에서 사용할 수 없다.</strong></h2>
<h3 id="slice">Slice</h3>
<p>6개의 layer들은 slice라고 하는 하위 디렉토리가 있다.</p>
<p>slice의 주요 목표는 코드를 값 별로 그룹화 하는 것이다. slice는 layer처럼 특정하게 정해진 이름은 없어 목적에 맞게 구조를 나눈 뒤 이름을 붙이면 된다.</p>
<h3 id="segment">Segment</h3>
<p>각 슬라이스 내에서 기능을 더 세분화한 단위이다. 일반적으로는 <code>api</code>, <code>ui</code>, <code>model</code>로 구분된다. segment 또한 목적에 따라 segment에서 나뉘어서 구현된다.</p>
<ul>
<li><code>api</code>: 필요한 서버 요청 담당(fetch, axios등)</li>
<li><code>ui</code>: 화면 담당(컴포넌트)</li>
<li><code>model</code>: 비즈니스 로직(상태와 상호작용)</li>
<li><code>lib</code>: slice 내에서 사용되는 보조기능, util 함수의 성격</li>
<li><code>config</code>: 필요한 상수 값들을 담당</li>
</ul>
<hr>
<h2 id="fsd-폴더-구조-적용">FSD 폴더 구조 적용</h2>
<p>실제 프로젝트에서 이벤트 등록 페이지를 퍼블리싱을 위해 FSD를 적용한 폴더구조이다.</p>
<pre><code>📦src
 ┣ 📂app
 ┃ ┃ ┣ 📜store.ts // 애플리케이션의 전역 상태 관리
 ┃ ┃ ┗ 📜index.tsx
 ┃ ┃ ┣ 📂 routes
 ┃ ┃ ┃ ┣ 📜Routes.tsx // 라우터 설정과 네비게이션을 담당하는 파일
 ┃ ┃ ┃ ┣ 📜routes.ts // 앱의 라우트 경로들을 정의
 ┣ 📂pages
 ┃ ┃ ┣ 📂 eventCreation
 ┃ ┃ ┃ ┣ 📜FunnelPage.tsx
 ┣ 📂widgets // 재사용 가능한 페이지 수준 UI 컴포넌트 관리
 ┃ ┃ ┣ 📂 eventCreation
 ┃ ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┃ ┣ 📜HostSelectPage.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EventTitlePage.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EventDurationPage.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EventDetailsPage.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EventTypeLocationPage.tsx
 ┃ ┃ ┃ ┃ ┗ 📜EventTagsPage.tsx
 ┃ ┃ ┃ ┃ ┣ 📂config
 ┃ ┃ ┃ ┃ ┃ ┗ 📜funnel.ts
 ┣ 📂features // 특정 기능 단위의 구현을 담당
 ┃ ┃ ┣ 📂 manage-event
 ┃ ┃ ┃ ┣ 📂model
 ┃ ┃ ┃ ┃ ┣ 📜eventCreationStore.ts // 이벤트 생성 상태 관리
 ┃ ┃ ┃ ┃ ┗ 📜eventCreationModel.ts // 비즈니스 로직: 데이터 유효성 검사나 이벤트 생성 등 로직
 ┃ ┃ ┃ ┣ 📂api
 ┃ ┃ ┃ ┃ ┣ 📜createEventAPI.ts
 ┃ ┃ ┃ ┃ ┗ // 나중에 조회, 삭제 api 추가
 ┃ ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┃ ┣ 📜BannerUpload.tsx
 ┃ ┃ ┃ ┃ ┣ 📜LocationSearchInput.tsx
 ┃ ┃ ┃ ┃ ┣ 📜MapView.tsx
 ┃ ┃ ┃ ┃ ┣ 📜LocationList.tsx
 ┃ ┃ ┃ ┃ ┣ 📜LinkInputBox.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EventTypeBox.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EventDetail.tsx
 ┃ ┃ ┃ ┃ ┣ 📜CategoryDropdown.tsx
 ┃ ┃ ┃ ┃ ┗ 📜HashTagDropdown.tsx
 ┃ ┃ ┃ ┃ ┗ 📜FunnelUi.tsx
 ┣ 📂entities // 도메인 데이터 모델 및 관련 로직
 ┃ ┃ ┣ 📂config
 ┃ ┃ ┃ ┗ 📜event.ts
 ┣ 📂shared // 모든 계층에서 사용되는 공통 모듈, 유틸리티, 스타일
 ┃ ┃ ┣ 📂 ui
 ┃ ┃ ┃ ┣ 📜TextInput.tsx
 ┃ ┃ ┃ ┣ 📜Button.tsx
 ┃ ┃ ┃ ┗ 📜DatePicker.tsx
 ┃ ┃ ┣ 📂 lib
 ┃ ┃ ┃ ┗ 📜dateUtils.ts // 날짜를 처리하는 로직(날짜 계산, 날짜 포맷 변환 등)</code></pre><p>이번 프로젝트에서 FSD 폴더 구조를 설계하면서, <code>widgets</code>과 <code>shared</code>, <code>features</code>과 <code>entities</code> 폴더의 역할을 명확히 구분하는 것이 중요했다. 각 폴더의 목적과 구성 요소를 비교하면서, 어떻게 재사용성과 의존성을 고려했는지 작성해보겠다. </p>
<h3 id="1-widgets-폴더와-shared-폴더-비교">1. widgets 폴더와 shared 폴더 비교</h3>
<p><code>widgets</code> <code>shared</code> 폴더는 모두 재사용 가능한 UI 컴포넌트를 다루지만, 사용 범위와 목적에서 차이가 있다.</p>
<ul>
<li><code>widgets</code>: 페이지 수준의 UI 컴포넌트들을 포함한다. 즉, 특정 페이지에서만 사용되는 UI 구성 요소들을 모아둔 곳이다.<ul>
<li>이벤트 생성 페이지에서 사용되는 다양한 UI 컴포넌트들(예: <code>HostSelectPage.tsx</code>, <code>EventTitlePage.tsx</code>)를 widgets 파일에 넣었다. 이 파일들은 특화된 스타일과 로직을 갖고 있어서, 다른 페이지에서 재사용되지 않는다.</li>
</ul>
</li>
<li><code>shared</code>: 애플리케이션 전반에서 사용되는 컴포넌트들을 모아둔 곳이다.<ul>
<li><code>TextInput.tsx</code>, <code>Button.tsx</code>, <code>DatePicker.tsx</code>와 같은 컴포넌트들은 여러 페이지와 기능에서 재사용될 수 있기 때문에, 가장 하위 폴더인 <code>shared/ui</code> 폴더에 넣었다.</li>
</ul>
</li>
</ul>
<h3 id="2-features폴더와-entities폴더-비교">2. features폴더와 entities폴더 비교</h3>
<p><code>features</code>와 <code>entities</code> 폴더는 각각 비즈니스 로직과 도메인 모델을 관리한다. </p>
<ul>
<li><code>features</code>: 특정 기능 단위로 컴포넌트와 로직을 분리한 폴더이다.<ul>
<li>manage-event 폴더는 이벤트 생성뿐만 아니라 이벤트 조회, 삭제 등 관련 기능을 한 곳에서 관리할 수 있도록 포괄적인 의미를 갖고 있다. 이렇게 하면 이벤트와 관련된 여러 기능을 하나의 기능 단위로 묶어 관리하기에 좋다.</li>
</ul>
</li>
<li><code>entities</code>: 도메인 데이터 모델 및 관련 로직을 담고 있다.<ul>
<li>event.ts는 도메인 데이터를 정의하고, 이 데이터를 처리하는 로직을 포함하고 있다. 기능 단위의 구현보다는 데이터를 중심으로 구성된다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 프로젝트를 진행하다보니 대규모 프로젝트가 됐다… 기존의 폴더 구조로 진행했다면, 하나의 기능과 관련된 코드 파일을 찾는 데 많은 시간이 걸렸을 것이다. 하지만 기능별로 분류하는 FSD(Feature-Sliced Design)를 적용하면서, 파일을 효율적으로 관리하고 각 기능의 역할을 명확히 구분 할 수 있었다. </p>
<p>프로젝트의 특징에 맞는 폴더 구조를 적용하는 것의 중요함을 알게 되었다. 폴더 구조에 정해진 정답은 없지만, 프로젝트의 요구사항과 팀의 작업 방식에 따라 적절한 구조를 선택하는것이 중요하다.</p>
<p>앞으로 프로젝트를 진행할 때, 기존의 폴더 구조만 고수하는 것이 아닌 다양한 파일 구조를 시도해봐야겠다는 생각이 들었다.</p>
<p><strong>Ref</strong>
<a href="https://feature-sliced.design/kr/docs/get-started/overview">FSD 공식문서</a>
<a href="https://www.youtube.com/watch?v=64Fx5Y1gEOA">아직도 React 폴더 구조로 고민하고 계신가요? FSD 한 번 써보세요[제로초뉴스]</a></p>
]]></description>
        </item>
    </channel>
</rss>