<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dragonperson-kim.log</title>
        <link>https://velog.io/</link>
        <description>용인에 거주하는 개발자입니다. 사이드프로젝트의 진행사항, 오류해결, 배운것들을 적습니다</description>
        <lastBuildDate>Sun, 05 Apr 2026 05:56:28 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dragonperson-kim.log</title>
            <url>https://velog.velcdn.com/images/dragonperson_kim/profile/747e5ea7-150d-4ec7-b996-07c9787c5c60/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dragonperson-kim.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dragonperson_kim" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[리액트 네이티브 앱을 만들어보자(1)]]></title>
            <link>https://velog.io/@dragonperson_kim/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EC%95%B1%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%901</link>
            <guid>https://velog.io/@dragonperson_kim/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EC%95%B1%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%9E%901</guid>
            <pubDate>Sun, 05 Apr 2026 05:56:28 GMT</pubDate>
            <description><![CDATA[<h1 id="왜-이-프로젝트를-시작했는가">왜 이 프로젝트를 시작했는가</h1>
<p>이번 프로젝트는 거창한 아이디어에서 시작한 게 아니었다.
이유는 꽤 단순했다. React Native를 제대로 익혀보고 싶었다.</p>
<p>문서만 읽거나 예제를 따라치는 방식은 금방 한계가 왔다.
내 기준에서는 작은 기능 몇 개를 찍어보는 것보다, 하나의 제품을 처음부터 끝까지 만들어보는 게 훨씬 빨리 배우는 방법이었다.</p>
<p>그래서 이번에는 한 번 만들고 끝나는 연습용 프로젝트보다,
만든 뒤에도 계속 붙잡고 개선해나갈 수 있는 주제를 먼저 고르기로 했다.</p>
<hr>
<h2 id="아이디어는-어떻게-정했는가">아이디어는 어떻게 정했는가</h2>
<p>주제를 고를 때 기준은 세 가지였다.</p>
<ol>
<li>내가 실제로 자주 겪는 문제인가</li>
<li>다른 사람도 어느 정도 공감할 수 있는 문제인가</li>
<li>기능을 조금씩 확장해나가기 쉬운가</li>
</ol>
<p>이 기준으로 고민하다가 결국 잡은 주제가 <strong>“미루는 습관을 줄여주는 리마인드 앱”</strong> 이었다.</p>
<p>처음에는 흔하게 Todo 앱부터 떠올렸다.
그런데 생각해보면 문제는 늘 기록이 아니었다.
할 일을 적는 것 자체는 어렵지 않은데, 정작 힘든 건 <strong>그걸 시작하게 만드는 순간</strong>이었다.</p>
<p>그래서 이 앱도 단순히 할 일을 적고 체크하는 방향보다는,
<strong>행동을 유도하는 쪽</strong>으로 기능을 붙여보자는 생각이 들었다.</p>
<p>예를 들면 이런 식이다.</p>
<ul>
<li>일정 시간 동안 시작하지 않으면 푸시로 리마인드 보내기</li>
<li>오늘 계획이 비어 있으면 AI가 가볍게 시작 제안해주기</li>
<li>미룬 횟수, 실제 시작 시간, 완료율 같은 패턴 기록하기</li>
<li>집중 시간과 휴식 루틴을 자연스럽게 이어주기</li>
</ul>
<p>물론 만들다 보면 규모가 커질 수 있다는 부담도 있었다.
그래도 이번 프로젝트는 결과물 하나보다, <strong>그걸 만드는 과정에서 뭘 배우느냐</strong>가 더 중요했다.
그래서 애초에 작게 닫기보다는, 나중에 기능을 넓혀가기 쉬운 구조로 시작하는 쪽을 택했다.</p>
<hr>
<h2 id="기술-스택은-왜-이렇게-잡았는가">기술 스택은 왜 이렇게 잡았는가</h2>
<p>이번 프로젝트는 하나의 앱만 만드는 식이 아니라,
<strong><code>web-ui + api + mobile</code> 형태의 모노레포</strong>로 구성했다.</p>
<p>여기서 중요한 건 기술을 많이 써보는 데 있지 않았다.
오히려 <strong>개발 속도는 챙기고, 새로 익힐 부분은 따로 가져가기 위해</strong> 역할을 나눈 쪽에 가까웠다.</p>
<h3 id="1-web-ui-익숙한-조합으로-속도부터-챙기기">1) Web UI: 익숙한 조합으로 속도부터 챙기기</h3>
<ul>
<li>React 19 + TypeScript</li>
<li>Vite</li>
<li>TanStack Query</li>
<li>Zustand</li>
<li>React Router</li>
<li>Tailwind CSS + DaisyUI</li>
<li>dnd-kit</li>
<li>Tiptap</li>
</ul>
<p>웹은 기능 실험도 많고, UI를 계속 바꾸게 되는 영역이다.
이런 구간에서 새로운 스택까지 한꺼번에 들이밀면 전체 흐름이 쉽게 꼬인다.
그래서 웹은 의도적으로 <strong>익숙한 조합</strong>으로 가져갔다.</p>
<p>각 선택 이유도 꽤 분명했다.</p>
<ul>
<li><strong>React + TypeScript</strong>: 구조를 바꾸거나 리팩터링할 때 대응하기 편하다.</li>
<li><strong>Tailwind</strong>: 반복되는 UI를 빠르게 만들 수 있다.</li>
<li><strong>TanStack Query + Zustand</strong>: 서버 상태와 클라이언트 상태를 나눠서 관리하기 좋다.</li>
<li><strong>Vite</strong>: 초기 세팅과 반복 개발 속도가 빠르다.</li>
</ul>
<p>정리하면, 웹은 이번 프로젝트에서 새로운 걸 실험하는 공간이라기보다
<strong>빠르게 만들고 계속 수정할 수 있는 기반</strong>으로 두고 싶었다.</p>
<h3 id="2-api-graphql로-설계를-제대로-해보기">2) API: GraphQL로 설계를 제대로 해보기</h3>
<ul>
<li>Fastify</li>
<li>Apollo Server + GraphQL</li>
<li>Prisma</li>
<li>MongoDB</li>
<li>Zod</li>
</ul>
<p>기존에는 Next.js 기반으로 빠르게 붙이는 방식이 익숙했다.
그 방식도 장점이 분명했고 실제로 생산성도 좋았다.</p>
<p>그런데 이번에는 웹과 모바일을 분리해서 가져가는 만큼,
API도 그냥 붙이는 수준이 아니라 <strong>데이터 구조를 한 번 제대로 설계해보고 싶었다.</strong></p>
<p>그래서 GraphQL을 선택했다.</p>
<p>이유는 꽤 단순했다.</p>
<ul>
<li>화면마다 필요한 데이터 조합을 더 명확하게 가져갈 수 있고</li>
<li>스키마 중심으로 계약을 관리하기 편하고</li>
<li>모노레포 안에서 웹과 모바일이 같은 API 기준을 공유하기 좋았다</li>
</ul>
<p>물론 초반 속도만 놓고 보면 REST보다 더디게 느껴질 수 있다.
그래도 웹 쪽에서 익숙한 스택으로 속도를 확보한 만큼,
그 여유를 API 설계 쪽 학습에 써보는 게 이번 프로젝트 목적과 더 잘 맞았다.</p>
<h3 id="3-mobile-하이브리드로-react-native를-실전에서-익히기">3) Mobile: 하이브리드로 React Native를 실전에서 익히기</h3>
<ul>
<li>Expo + React Native</li>
<li>Expo Router</li>
<li>React Native WebView</li>
<li>Skia / Haptics 등 Expo 생태계</li>
</ul>
<p>모바일은 이번 프로젝트에서 <strong>직접 부딪혀보면서 React Native 감각을 익히는 영역</strong>이었다.</p>
<p>처음부터 완전 네이티브로 끝까지 가는 방법도 있겠지만,
그렇게 시작하면 초반 진입장벽이 생각보다 높고, 금방 지치기 쉽다.
그래서 이번에는 하이브리드 방식으로 접근했다.
<strong>빨리 만들어보면서 필요한 감각은 놓치지 않는 쪽</strong>이 더 현실적이라고 봤다.</p>
<p>각 선택도 그런 기준에서 나온 거였다.</p>
<ul>
<li><strong>WebView</strong>: 기존 웹 자산을 재활용해서 초기 구현 속도를 챙길 수 있다.</li>
<li><strong>RN 화면</strong>: 네이티브 인터랙션과 디바이스 동작을 직접 다뤄볼 수 있다.</li>
<li><strong>Expo 생태계</strong>: 복잡한 네이티브 설정 부담을 줄이고 개발 흐름을 유지하기 좋다.</li>
</ul>
<p>결국 모바일은
무리해서 처음부터 모든 걸 네이티브로 하려 하기보다,
<strong>배포 가능한 형태를 빠르게 만들면서 React Native를 몸으로 익혀보는 방식</strong>으로 가져갔다.</p>
<hr>
<h2 id="화면-개발은-바이브-코딩으로-풀었다">화면 개발은 “바이브 코딩”으로 풀었다</h2>
<p>이번 화면 작업에서는 Codex나 Claude 같은 에이전트를 꽤 적극적으로 붙여서 작업했다.
처음엔 반신반의했는데, 실제로 써보니 생각보다 훨씬 유용했다.
특히 <strong>개발 속도를 계속 끌고 가는 데</strong> 도움이 컸다.</p>
<p>코덱스에는 먼저 레포지토리를 읽게 한 다음, 대략 이런 식으로 작업을 맡겼다.</p>
<ul>
<li>먼저 화면 전체 흐름부터 잡기</li>
<li>헤더, 버튼, 카드 같은 공통 UI 먼저 분리하기</li>
<li>디테일한 스타일은 한 번에 끝내지 않고 여러 번 나눠 다듬기</li>
</ul>
<p>이 방식이 좋았던 건, 막혔을 때 처음부터 다시 짜지 않아도 된다는 점이었다.
이미 있는 코드 위에서 바로 수정하고, 비교하고, 다시 밀어붙일 수 있었다.
혼자서 처음부터 전부 새로 짜는 것보다 훨씬 리듬이 끊기지 않았다.</p>
<p>대신 분명하게 느낀 것도 있었다.
<strong>요청이 애매하면 결과도 애매하게 나온다.</strong></p>
<p>그래서 구현 디테일을 길게 늘어놓기보다는,
내가 원하는 방향을 먼저 분명하게 전달하려고 했다.</p>
<ul>
<li>“헤더는 정보량을 조금 줄이고 더 단정하게”</li>
<li>“핵심 액션은 첫 화면에서 바로 보이게”</li>
<li>“반복되는 부분은 공통 컴포넌트로 빼기”</li>
<li>“디자인이 애매하면 참고 이미지를 같이 보여주기”</li>
</ul>
<p>이렇게 해보니까 에이전트가 모든 걸 대신해주는 느낌이라기보다는,
<strong>내가 판단한 방향을 훨씬 빠르게 화면으로 옮겨주는 도구</strong>에 가까웠다.</p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/dbf181e6-8d7f-4a53-9594-d6690a85d174/image.gif" alt="결과물"></p>
<p>결과물도 꽤 인상적이었다.
무조건 완벽하게 나온다기보다는,
<strong>방향만 잘 잡아주면 생각보다 훨씬 빠르게 쓸 만한 형태를 만들어준다</strong>는 쪽이 더 정확했다.</p>
<p>물론 코드를 뜯어보면 손볼 부분도 많았다.
군데군데 덕지덕지 붙은 흔적도 있었고, 그대로 두면 아쉬운 부분도 분명했다.
그래도 내가 원하는 걸 빠르게 시도해보고, 바로 수정하면서 다음 단계로 넘어갈 수 있다는 점은 꽤 강력했다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 프로젝트는 단순히 앱 하나를 만드는 작업이라기보다,
<strong>어떻게 배우고, 어떻게 만들고, 어떤 도구를 어디까지 활용할지</strong>를 같이 익혀가는 과정에 가까웠다.</p>
<p>주제를 정할 때는 실제로 오래 붙잡고 갈 수 있는 문제를 골랐고,
기술 스택은 속도와 학습 포인트를 나눠서 가져갔다.
웹에서는 빠르게 만들 수 있는 기반을 챙기고,
API에서는 설계를 더 신경 써봤고,
모바일에서는 React Native를 실전으로 익혀보는 데 집중했다.</p>
<p>그리고 화면 작업에서는 에이전트 도구를 적극적으로 써보면서,
이런 도구가 개발을 대신해주는 존재라기보다
<strong>결정과 수정 속도를 올려주는 협업 도구</strong>에 가깝다는 것도 체감했다.</p>
<p>처음엔 React Native를 공부해보자는 마음으로 시작한 프로젝트였지만,
돌아보면 그보다 더 많은 걸 배웠다.
하이브리드 구조를 어떻게 가져갈지, 학습을 제품 개발 안에 어떻게 녹일지,
그리고 AI 도구를 실제 작업 흐름 안에서 어떻게 써야 하는지까지 같이 확인할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GPT와 함께 사이드프로젝트 보강하기 - 성능개선]]></title>
            <link>https://velog.io/@dragonperson_kim/GPT%EC%99%80-%ED%95%A8%EA%BB%98-%EC%82%AC%EC%9D%B4%EB%93%9C%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B3%B4%EA%B0%95%ED%95%98%EA%B8%B0-%EC%84%B1%EB%8A%A5%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@dragonperson_kim/GPT%EC%99%80-%ED%95%A8%EA%BB%98-%EC%82%AC%EC%9D%B4%EB%93%9C%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B3%B4%EA%B0%95%ED%95%98%EA%B8%B0-%EC%84%B1%EB%8A%A5%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Sat, 21 Mar 2026 01:20:29 GMT</pubDate>
            <description><![CDATA[<h1 id="이제-성능-최적화-할-차례">이제 성능 최적화 할 차례</h1>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/4033a11a-d763-4d94-ab52-86f5e8d1ad87/image.gif" alt="">
<del>정글동선 만큼 전체적인 흐름의 최적화도 중요하다..</del></p>
<p>지난번에 GPT한테 블로그 보강 ToDo를 정리해달라고 했고,<br>SEO 작업을 먼저 마무리한 뒤 이번엔 성능 개선 단계로 넘어왔다.</p>
<p>이번 목표는 명확했다.<br>성능 최적화를 통해, <strong>배포된 웹페이지가 이전보다 더 빠르게 뜨도록 만드는 것</strong></p>
<hr>
<h2 id="gpt가-준-최적화-리스트">GPT가 준 최적화 리스트</h2>
<ul>
<li>이미지 최적화</li>
<li>Provider 범위 최소화</li>
<li>dynamic import 기반 코드 스플리팅</li>
</ul>
<p>최적화가 중요하다는 건 원래 알고 있었다.<br>그래서 API 호출 줄이기, <code>memo</code>/<code>useCallback</code> 같은 기본적인 최적화는 이미 적용해둔 상태였고,<br>그때까진 “이 정도면 최적화는 꽤 해둔 편 아닌가?”라고 생각했다.</p>
<p>근데 이번에 성능을 본격적으로 점검하면서 느낀 건,<br>그건 어디까지나 <strong>컴포넌트 단위/코드 단위 최적화</strong>였고,<br>페이지 진입부터 렌더링까지 이어지는 <strong>전체 흐름 최적화</strong>는 거의 못 보고 있었다는 점이었다.</p>
<p>예를 들면,</p>
<ul>
<li>초기 진입에서 꼭 필요하지 않은 코드가 같이 로드되는 문제</li>
<li>전역 provider 범위가 넓어서 모든 페이지가 공통 비용을 먹는 문제</li>
<li>화면에서 안 쓰는 데이터까지 API로 받아오는 문제</li>
<li>페이지 전용 컴포넌트가 공용 번들에 섞여 들어가는 문제</li>
</ul>
<p>이런 건 <code>useCallback</code> 하나 추가한다고 해결되는 문제가 아니었다.<br>코드 한 줄 단위의 최적화보다, 로딩 경로 자체를 어떻게 설계했는지가 더 크게 영향을 줬다.</p>
<p>그래서 이번엔 관점을 조금 바꿨다.<br>“함수 재생성 줄이기” 같은 미시적인 최적화보다,<br><strong>어떤 코드가 언제 로드되고, 어떤 데이터가 언제 필요한지</strong>를 먼저 보는 쪽으로.</p>
<p>결국 최적화는 작은 코드 테크닉만으로 끝나는 게 아니라,<br>페이지 구조, provider 범위, 데이터 흐름까지 함께 봐야 하고,<br>실제 사용자 체감은 그쪽에서 더 크게 갈린다는 걸 이번에 확실히 느꼈다.</p>
<hr>
<h1 id="무엇을-최적화-했는가">무엇을 최적화 했는가?</h1>
<h2 id="이미지-최적화">이미지 최적화</h2>
<p>next에서 제공해주는 Image컴포넌트를 쓰면 손쉽게 최적화를 할수있다.</p>
<blockquote>
<p>next/image는 일반 img보다 이미지 로딩을 자동으로 최적화해주는 Next.js 내장 컴포넌트
핵심은 “필요한 이미지부터, 필요한 크기로, 필요한 시점에” 로드하게 만드는 것.</p>
</blockquote>
<p>주로 사용한 기능은 아래와 같다.</p>
<ul>
<li><p>priority
해당 이미지를 “초기 렌더 핵심 리소스”로 취급해서 미리 적극적으로 로드
LCP 후보(첫 화면 대표 이미지)에는 유리할 수 있음
근데 남발하면 초기 네트워크 경쟁이 커져서 오히려 느려짐</p>
</li>
<li><p>loading=&quot;lazy&quot;
뷰포트 근처에 왔을 때 로드
첫 화면에 당장 안 보이는 이미지면 초기 로딩 부담을 줄임</p>
</li>
<li><p>decoding=&quot;async&quot;
이미지 디코딩을 렌더링 메인 흐름을 덜 막는 방식으로 처리
화면 표시를 덜 방해하게 도움</p>
</li>
</ul>
<p>next/image의 priority를 무분별하게 쓰지 않고, 비핵심 이미지는 lazy/async로 전환해서 초기 렌더 구간의 네트워크 경쟁과 디코딩 부담을 줄였다.</p>
<hr>
<h2 id="provider-범위-최소화--구조-정리">Provider 범위 최소화 + 구조 정리</h2>
<p>이번 최적화에서 가장 구조적으로 의미 있었던 부분은 <code>provider</code> 정리였다.<br>예전에는 “전역에서 한 번에 감싸는 방식”에 가까워서 관리가 편하긴 했지만,<br>책임 경계가 흐려지고 페이지가 늘어날수록 비용과 복잡도가 같이 커지는 구조였다.</p>
<p>그래서 이번에는 <code>provider</code>를 기능 기준으로 다시 쪼개서,<br><strong>“어디서 무엇을 책임지는지”</strong>가 보이도록 정리했다.</p>
<h3 id="ui-책임-분리-uiprovider--popuphost">UI 책임 분리 (UIProvider / PopupHost)</h3>
<p>기존에는 팝업 처리 로직이 한 덩어리로 엮여 있어서<br>모달/토스트 동작 흐름을 추적하기가 어려웠다.</p>
<p>이번에 다음처럼 역할을 분리했다.</p>
<ul>
<li>UIProvider: UI 제어 함수(openModal/openToast) 제공</li>
<li>ModalHost: 모달 렌더링 전담</li>
<li>ToastHost: 토스트 렌더링 전담</li>
<li>PopupContainer: host 조합/마운트 지점 관리</li>
</ul>
<p>이렇게 분리하면서,<br>“상태를 누가 만들고 / 누가 보여주고 / 누가 정리하는지” 경계가 명확해졌다.</p>
<hr>
<h3 id="모달-라이프사이클-정리-열기응답정리">모달 라이프사이클 정리 (열기/응답/정리)</h3>
<p>모달은 단순히 띄우는 것보다,<br>응답(resolve)과 cleanup 타이밍이 더 중요했다.</p>
<p>이번에 모달 관리 책임을 다음처럼 정리했다.</p>
<ul>
<li>모달 결과값(resolve) 처리 경로 통일</li>
<li>route 변경 시 열려 있던 모달 정리(cleanup) 분리</li>
<li>write 모달 같은 비동기 흐름에서도 토스트/모달 충돌 줄이기</li>
</ul>
<p>결과적으로 “모달이 닫혔는데 상태가 남는 문제” 같은<br>경계성 버그 가능성을 낮췄다.</p>
<hr>
<h3 id="session-provider-범위-축소">Session Provider 범위 축소</h3>
<pre><code class="language-ts">//몽땅 떄려박고 사용하던방식

export function MyProvider({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  return (
    &lt;ReduxProvider store={store()}&gt;
      &lt;ThemeProvider attribute=&quot;class&quot;&gt;
        &lt;QueryClientProvider client={queryClient}&gt;
          &lt;UIProvider&gt;
            &lt;ReactQueryStreamedHydration&gt;{children}&lt;/ReactQueryStreamedHydration&gt;
            &lt;Suspense fallback={null}&gt;
              &lt;PopupContainer /&gt;
            &lt;/Suspense&gt;
            {process.env.NODE_ENV === &quot;development&quot; ? (
              &lt;ReactQueryDevtools initialIsOpen={false} buttonPosition=&quot;bottom-right&quot; /&gt;
            ) : null}
          &lt;/UIProvider&gt;
        &lt;/QueryClientProvider&gt;
      &lt;/ThemeProvider&gt;
    &lt;/ReduxProvider&gt;
  );
}</code></pre>
<pre><code class="language-ts">// components/providers/sessionBoundary.tsx (After 신규 추가)
//따로 빼서 인가가 필요한 페이지에서만 사용
&quot;use client&quot;;

import { SessionProvider } from &quot;next-auth/react&quot;;

export default function SessionBoundary({
  children,
}: {
  children: React.ReactNode;
}) {
  return &lt;SessionProvider&gt;{children}&lt;/SessionProvider&gt;;
}
/</code></pre>
<p>기존에는 세션 provider를 넓은 범위에서 공통 적용하던 구조였고,<br>이번에는 필요한 라우트 중심으로 범위를 좁히는 방식으로 조정했다.
지금은 프로젝트가 작지아서 효과를 체감할 순 없지만 플젝이 좀더 커지면 좀더 효과적이라는 생각과 이런 방식으로도 정리 할수 있구나라는 생각이 들었다.</p>
<hr>
<h3 id="결과적으로-달라진-점">결과적으로 달라진 점</h3>
<p>이 작업은 단순히 provider 개수를 줄인 게 아니라,<br>책임을 세분화해서 구조를 읽기 쉬운 형태로 바꾼 게 핵심이었다.</p>
<p>정리 이후 기준으로 보면,</p>
<ul>
<li>모달/토스트/UI 컨텍스트 책임이 분리되어 추적이 쉬워졌고</li>
<li>route 전환 시 정리 타이밍이 명확해졌고</li>
<li>인증 스코프가 페이지 단위로 구분되면서 유지보수 난이도가 내려갔다</li>
</ul>
<p>이번 provider 정리는 “성능 최적화 + 구조 가독성 + 유지보수성”을 같이 가져간 작업이었다.</p>
<hr>
<h2 id="페이지-코드-스플리팅">페이지 코드 스플리팅</h2>
<h3 id="스플리팅이란-뭘까">스플리팅이란 뭘까?</h3>
<hr>
<h3 id="write">write</h3>
<p>write 페이지는 초기 진입 부담이 큰 편이라 우선순위로 분리했다.<br>dynamic import로 무거운 영역을 초기 렌더 경로에서 빼고, 필요한 시점에만 로드되도록 조정했다.</p>
<p>추가로 로딩 UI도 같이 손봤다.<br>처음에는 스켈레톤을 넣었지만 write 특성상 오히려 흐름이 끊겨 보여서,<br>최종적으로는 “로딩이 끝나면 opacity animation을 사용해 화면이 자연스럽게 나타나는 방식”으로 바꿨다.<br>즉, 단순히 분할만 한 게 아니라 렌더링 전환 방식까지 같이 정리한 셈이다.</p>
<p>실측 결과:</p>
<ul>
<li>/write First Load JS: 167kB → 151kB</li>
<li>약 9.6% 감소</li>
</ul>
<hr>
<h3 id="comments">comments</h3>
<p>comments는 페이지 전용 컴포넌트를 페이지 스코프로 이동하고,<br>부가 기능(captcha 등)은 필요한 시점에만 로드되도록 분리했다.</p>
<p>결과:</p>
<ul>
<li>초기 로드 기준 번들 부담이 대략 5~9% 수준으로 줄어드는 방향으로 확인됨</li>
<li>공통 번들 오염이 줄어 진입 흐름이 단순해짐</li>
</ul>
<hr>
<h3 id="profile">profile</h3>
<p>profile도 전용 코드 스코프를 분리해서<br>전역 번들에 불필요하게 실리던 의존성을 줄였다.</p>
<p>결과:</p>
<ul>
<li>초기 로드 기준 번들 부담이 대략 5~9% 수준으로 줄어드는 방향으로 확인됨</li>
<li>profile 관련 코드가 필요한 시점에 로드되도록 정리됨</li>
</ul>
<hr>
<h3 id="post-commonpost-컬렉션-영역">post (commonPost 컬렉션 영역)</h3>
<p>post 상세는 코드 분할뿐 아니라 데이터 응답도 같이 다이어트했다.<br>컬렉션 목록에서 화면에 실제로 쓰는 값 중심으로 응답을 정리해서<br>불필요한 데이터 전송량을 줄였다.</p>
<p>결과:</p>
<ul>
<li>페이지 전환/영역 표시 시 체감 지연 완화</li>
<li>초기 로드 기준으로도 5~9% 내외 개선 구간으로 확인</li>
</ul>
<hr>
<h3 id="lighthouse-결과">Lighthouse 결과</h3>
<p>배포 환경 기준 before/after를 여러 번 비교했을 때,<br><strong>Performance 점수는 평균 +4점</strong> 상승했다.<br>(예: 78 → 82)</p>
<p>Lighthouse는 측정 환경에 따라 편차가 있어서<br>단일 측정값보다 평균 비교 기준으로 판단했다.</p>
<hr>
<h1 id="정리">정리</h1>
<p>이번 작업은 “새 기능 추가”보다<br><strong>초기 로드 비용 줄이기 + 전역 구조 경량화</strong>에 집중한 작업이었다.</p>
<p>핵심 결과를 요약하면:</p>
<ul>
<li>write 번들 감소: <strong>167kB → 151kB (약 9.6% 감소)</strong></li>
<li>Lighthouse 평균: <strong>+4점</strong></li>
<li>provider/캐시/응답 구조 정리로 체감 로딩 안정성 개선</li>
</ul>
<p>확실히 렌더링 속도가 조금 더 빨라졌고, 오랜만에 집 청소를 한 것처럼 상쾌한 느낌도 든다.
개발도 재밌지만, 이렇게 세세하게 구조를 정리해보는 과정도 꽤 재밌는 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React 예제로 다시 보는 기본기]]></title>
            <link>https://velog.io/@dragonperson_kim/React-%EC%98%88%EC%A0%9C%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EB%8A%94-%EA%B8%B0%EB%B3%B8%EA%B8%B0</link>
            <guid>https://velog.io/@dragonperson_kim/React-%EC%98%88%EC%A0%9C%EB%A1%9C-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EB%8A%94-%EA%B8%B0%EB%B3%B8%EA%B8%B0</guid>
            <pubDate>Tue, 17 Mar 2026 08:09:06 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p><code>React</code>와 <code>Next.js</code>로 사이드 프로젝트를 여러 번 만들었다.
기능을 구현하고, 페이지를 만들고, 필요한 라이브러리를 붙이는 데에는 이제 어느 정도 익숙해졌다.</p>
<p>그런데 익숙하게 쓴다는 것과 정확히 이해하고 있다는 것은 다른 문제였다.
예를 들어 <strong>state가 왜 이렇게 반영되는</strong>지, <strong>memo는 언제 의미가 있는지</strong>,** key는 왜 중요한지** 같은 질문을 받으면 어렴풋이 설명은 할 수 있어도, 명확하게 말하긴 애매하다는 생각이 들었다.</p>
<p>그래서 이번 글은 React를 새로 배우는 글이라기보다,
이미 쓰고 있는 개념들을 예제를 통해 다시 확인해보는 글에 가깝다.
자주 쓰지만 막상 제대로 설명하려면 헷갈리는 기본기들을 하나씩 다시 살펴보려고 한다.</p>
<h1 id="리액트-이것이-궁금했었다">리액트, 이것이 궁금했었다</h1>
<p>React를 쓰다 보면 state를 바꾸면 화면이 바뀌고, 리스트를 렌더링할 때는 <code>key</code>를 넣고,
필요하면 <code>memo</code>나 <code>useCallback</code>으로 최적화를 한다.
그런데 막상 한 번 멈춰서 생각해보면, React는 이런 일을 도대체 어떻게 처리하고 있는 걸까?</p>
<p>React는 변경이 생길 때마다 화면 전체를 무작정 다시 그리는 대신,
컴포넌트를 다시 실행해 새로운 UI를 계산하고, 이전 결과와 비교한 뒤
실제로 달라진 부분만 DOM에 반영한다.</p>
<p>즉, React는 “다시 계산”과 “실제 반영”을 분리해서 다루고,
이 구조 덕분에 선언적인 방식으로 UI를 작성하면서도 비교적 효율적으로 화면을 업데이트할 수 있다.</p>
<p>이번 글에서는 간단한 예제를 통해 이런 동작을 하나씩 확인해보려고 한다.</p>
<h1 id="예제로-살펴보기">예제로 살펴보기</h1>
<h2 id="1-usestate">1. useState</h2>
<h3 id="개념">개념</h3>
<p><code>useState</code>는 단순히 값을 저장하는 도구처럼 보이지만, 실제로는 그보다 조금 다르게 이해하는 편이 좋다.</p>
<p>중요한 건 <code>useState</code>가 <strong>현재 렌더에서 사용할 상태값을 제공하고</strong>,  
setter 함수는 그 값을 즉시 바꾸는 것이 아니라 <strong>다음 렌더링을 예약한다</strong>는 점이다.</p>
<p>즉, <code>count</code> 같은 state 값은 “지금 이 순간 바로 바뀌는 변수”라기보다,<br><strong>이번 렌더에서 React가 보여주기로 한 값</strong>에 가깝다.</p>
<p>이 관점이 중요한 이유는 <code>useState</code>를 쓰면서 자주 헷갈리는 지점들이 대부분 여기서 시작되기 때문이다.<br>왜 같은 <code>setState</code>를 여러 번 호출했는데 기대한 값이 나오지 않는지,<br>왜 비동기 함수 안에서는 예전 값을 보고 있는 것처럼 보이는지 같은 문제들이 대표적이다.</p>
<p>결국 <code>useState</code>를 이해할 때 핵심은<br><strong>state는 즉시 변경되는 값이 아니라, 렌더마다 고정된 스냅샷처럼 동작한다</strong>는 점이다.</p>
<h3 id="예제-같은-setstate인데-왜-결과가-다를까">예제: 같은 setState인데 왜 결과가 다를까</h3>
<pre><code class="language-js">const wrongIncrease = () =&gt; {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
};

const correctIncrease = () =&gt; {
  setCount((prev) =&gt; prev + 1);
  setCount((prev) =&gt; prev + 1);
  setCount((prev) =&gt; prev + 1);</code></pre>
<p>wrongIncrease를 실행하면 <code>count</code>가 3 증가할 것 같지만, 실제로는 기대와 다르게 동작한다.<br>반면 <code>correctIncrease</code>는 의도한 대로 3이 증가한다.</p>
<p>그 이유는 현재 렌더에서의 <code>count</code> 값은 하나로 고정되어 있기 때문이다.<br>즉, <code>setCount(count + 1)</code>를 세 번 호출해도 각각이 서로 다른 값을 기준으로 계산하는 것이 아니라,<br>같은 <code>count</code> 값을 기준으로 세 번 요청하는 것에 가깝다.</p>
<p>반면 함수형 업데이트는 이전 상태를 순서대로 전달받아 계산한다.<br>그래서 여러 번 호출해도 앞선 업데이트 결과를 이어서 반영할 수 있다.</p>
<blockquote>
<p>state는 즉시 바뀌는 변수가 아니라, 렌더마다 고정된 값을 기준으로 읽힌다.</p>
</blockquote>
<h3 id="예제-왜-비동기-안에서는-예전-state를-보게-될까">예제: 왜 비동기 안에서는 예전 state를 보게 될까</h3>
<pre><code class="language-js">//이 이벤트를 붙인 버튼을 클릭하고 setCount로 값을 변경했을때 count는 어떻게 보일까?
const onHandleClick  = () =&gt; {
  setTimeout(() =&gt; {
    console.log(count);
  }, 1000);
};</code></pre>
<p>이 코드는 1초 뒤에 <code>count</code>를 출력하는 것처럼 보인다.
하지만 타이머가 실행되기 전에 state가 바뀌더라도, 항상 최신 <code>count</code>가 출력되는 것은 아니다.</p>
<p>그 이유는 <code>setTimeout</code> 안의 콜백이 만들어질 당시의 <code>count</code> 값을 기준으로 동작하기 때문이다.
즉, 나중에 실행되는 코드라고 해서 항상 최신 state를 참조하는 것은 아니다.</p>
<p>이 예제를 통해 알 수 있는 것은, 비동기 로직 안에서의 state는
“실행되는 시점”보다 “함수가 만들어진 시점” 의 영향을 크게 받는다는 점이다.</p>
<blockquote>
<p>state는 항상 최신값을 읽는 것이 아니라, 함수가 만들어진 시점의 값을 기준으로 동작할 수 있다.</p>
</blockquote>
<h2 id="2-usecallback">2. useCallback</h2>
<h3 id="개념-1">개념</h3>
<p><code>useCallback</code>은 함수를 실행하는 Hook이 아니라,<br><strong>렌더링 사이에서 함수 참조를 유지하는 Hook</strong>이다.</p>
<p>컴포넌트는 렌더링될 때마다 다시 실행되므로,<br>내부에서 선언한 함수도 매번 새로 만들어진다.</p>
<p>보통은 문제없지만,<br><code>memo</code>로 감싼 자식에게 함수를 props로 넘기거나<br><code>useEffect</code> 의존성에 함수를 넣는 경우에는<br><strong>함수가 새로 만들어졌다는 사실 자체</strong>가 불필요한 동작을 만들 수 있다.</p>
<p>즉, <code>useCallback</code>의 핵심은<br>함수 내용을 바꾸는 것이 아니라<br><strong>같은 함수 참조를 재사용하게 만드는 것</strong>이다.</p>
<h3 id="예제-왜-자식이-다시-렌더링될까">예제: 왜 자식이 다시 렌더링될까</h3>
<pre><code class="language-jsx">const Child = memo(function Child({ onClick }) {
  console.log(&quot;Child render&quot;);
  return &lt;button onClick={onClick}&gt;클릭&lt;/button&gt;;
});

export default function Parent() {
  const [theme, setTheme] = useState(false);

  const handleClick = useCallback(() =&gt; {
    console.log(&quot;click&quot;);
  }, []);

  return (
    &lt;&gt;
      &lt;button onClick={() =&gt; setTheme((prev) =&gt; !prev)}&gt;theme toggle&lt;/button&gt;
      &lt;Child onClick={handleClick} /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p><code>Child</code>는 <code>memo</code>로 감싸져 있어도,<br>부모가 다시 렌더링될 때마다 함수가 새로 만들어지면<br>props가 바뀐 것으로 판단해 다시 렌더링될 수 있다.</p>
<p>이때 <code>useCallback</code>을 쓰면<br>의존성이 바뀌지 않는 동안 같은 함수 참조를 유지할 수 있다.</p>
<blockquote>
<p><code>useCallback</code>은 자식 렌더링을 막는 도구라기보다, props 비교가 의미 있게 동작하도록 만드는 도구에 가깝다.</p>
</blockquote>
<h3 id="예제-usecallback을-써도-예전-값을-볼-수-있는-이유">예제: useCallback을 써도 예전 값을 볼 수 있는 이유</h3>
<pre><code class="language-jsx">const handleClick = useCallback(() =&gt; {
  console.log(count);
}, []);</code></pre>
<p>이 코드는 함수 참조는 유지되지만,<br>처음 렌더에서 만들어진 <code>count</code> 값을 계속 바라보게 된다.</p>
<p>즉, <code>useCallback</code>은 <strong>함수를 고정</strong>할 뿐<br>그 안의 값을 자동으로 최신으로 바꿔주지는 않는다.</p>
<pre><code class="language-jsx">const handleClick = useCallback(() =&gt; {
  console.log(count);
}, [count]);</code></pre>
<p>이렇게 해야 <code>count</code>가 바뀔 때 최신 값을 기준으로 새 함수가 만들어진다.</p>
<blockquote>
<p><code>useCallback</code>은 참조를 고정할 뿐, 내부 값까지 최신으로 보장하지는 않는다.</p>
</blockquote>
<p><code>useCallback</code>은 함수를 저장하는 Hook이 아니라,<br><strong>함수 참조를 안정적으로 유지하는 Hook</strong>이다.</p>
<p>그래서 모든 함수에 쓰는 것이 아니라,<br><strong>참조가 바뀌는 것이 실제 문제를 만드는 상황에서만</strong> 의미가 있다.</p>
<h2 id="3-reactmemo">3. React.memo</h2>
<h3 id="개념-2">개념</h3>
<p><code>React.memo</code>는 컴포넌트의 props가 이전과 같다면<br><strong>부모가 다시 렌더링되어도 자식의 렌더링을 건너뛸 수 있게 해주는 기능</strong>이다.</p>
<p>React에서는 부모가 다시 렌더링되면 자식 컴포넌트도 함께 다시 실행되는 것이 기본 동작이다.<br>이때 자식이 받는 props가 이전과 동일하고, 다시 렌더링할 이유가 없다면<br><code>React.memo</code>를 통해 그 렌더링을 생략할 수 있다.</p>
<p>다만 중요한 점은 <code>React.memo</code>가 <strong>모든 렌더링을 막아주는 것이 아니라</strong>,  
어디까지나 <strong>props 비교 기준으로 불필요한 렌더링을 줄이는 도구</strong>라는 점이다.</p>
<p>즉, <code>React.memo</code>의 핵심은<br>컴포넌트를 “고정”하는 것이 아니라<br><strong>같은 props라면 다시 실행하지 않도록 최적화하는 것</strong>에 가깝다.</p>
<h3 id="예제-부모가-다시-렌더링되면-자식도-항상-다시-실행될까">예제: 부모가 다시 렌더링되면 자식도 항상 다시 실행될까</h3>
<pre><code class="language-jsx">import { memo, useState } from &quot;react&quot;;

const Child = memo(function Child({ value }) {
  console.log(&quot;Child render&quot;);
  return &lt;div&gt;value: {value}&lt;/div&gt;;
});

export default function Parent() {
  const [count, setCount] = useState(0);
  const [theme, setTheme] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setCount((prev) =&gt; prev + 1)}&gt;count 증가&lt;/button&gt;
      &lt;button onClick={() =&gt; setTheme((prev) =&gt; !prev)}&gt;theme 변경&lt;/button&gt;

      &lt;Child value={count} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>이 예제에서 <code>Parent</code>가 다시 렌더링되는 상황은 두 가지다.</p>
<ul>
<li><code>count</code>가 바뀔 때</li>
<li><code>theme</code>가 바뀔 때</li>
</ul>
<p>여기서 <code>Child</code>는 <code>value</code>라는 props를 받고 있고,<br><code>React.memo</code>로 감싸져 있다.</p>
<p>따라서 <code>count</code>가 바뀌면 <code>value</code>도 함께 바뀌므로 <code>Child</code>는 다시 렌더링된다.<br>반면 <code>theme</code>만 바뀌고 <code>value</code>가 그대로라면, <code>Child</code>는 다시 렌더링되지 않는다.</p>
<p>즉, <code>React.memo</code>는 부모 렌더링 자체를 막는 것이 아니라,<br><strong>자식이 받는 props가 같을 때 자식 렌더링을 생략하게 해주는 것</strong>이다.</p>
<blockquote>
<p><code>React.memo</code>는 “부모가 렌더링되면 자식도 무조건 다시 렌더링된다”는 기본 흐름 위에서, props가 같을 경우 그 자식 렌더링을 건너뛸 수 있게 해준다.</p>
</blockquote>
<h3 id="예제-그런데-함수-props를-넘기면-왜-다시-렌더링될까">예제: 그런데 함수 props를 넘기면 왜 다시 렌더링될까</h3>
<pre><code class="language-jsx">import { memo, useState } from &quot;react&quot;;

const Child = memo(function Child({ onClick }) {
  console.log(&quot;Child render&quot;);
  return &lt;button onClick={onClick}&gt;클릭&lt;/button&gt;;
});

export default function Parent() {
  const [theme, setTheme] = useState(false);

  const handleClick = () =&gt; {
    console.log(&quot;click&quot;);
  };

  return (
    &lt;&gt;
      &lt;button onClick={() =&gt; setTheme((prev) =&gt; !prev)}&gt;theme toggle&lt;/button&gt;
      &lt;Child onClick={handleClick} /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>이 경우 <code>Child</code>는 <code>memo</code>로 감싸져 있어도,<br>부모가 다시 렌더링될 때마다 다시 렌더링될 수 있다.</p>
<p>그 이유는 <code>handleClick</code> 함수가 매 렌더링마다 새로 만들어지기 때문이다.<br>즉, 코드 모양은 같아 보여도 React 입장에서는<br>이전 props와 이번 props가 같은 값이 아니라 <strong>새로운 함수 참조</strong>로 보이게 된다.</p>
<p>그래서 <code>React.memo</code>는 함수 props 앞에서는 기대만큼 동작하지 않을 수 있고,<br>이때 <code>useCallback</code>이 함께 등장한다.</p>
<pre><code class="language-jsx">const handleClick = useCallback(() =&gt; {
  console.log(&quot;click&quot;);
}, []);</code></pre>
<p>이렇게 하면 의존성이 바뀌지 않는 동안 같은 함수 참조를 유지할 수 있으므로,<br><code>React.memo</code>의 props 비교도 비로소 의미 있게 동작하게 된다.</p>
<blockquote>
<p><code>React.memo</code>는 props가 같을 때만 동작한다.<br>그리고 함수 props의 “같음”을 유지하고 싶을 때 <code>useCallback</code>이 필요해진다.</p>
</blockquote>
<p><code>React.memo</code>는 렌더링을 막는 마법 같은 기능이 아니라,<br><strong>같은 props에 대해 같은 결과를 그리는 컴포넌트의 불필요한 재실행을 줄이는 도구</strong>다.</p>
<p>그래서 모든 컴포넌트에 무조건 붙이기보다,<br>부모 렌더링이 자주 일어나고 props 변화가 제한적인 컴포넌트에서 더 의미가 있다.</p>
<p>그리고 함수 props를 함께 다루는 순간<br><code>React.memo</code>만으로는 부족할 수 있으며,<br>그때 <code>useCallback</code>이 왜 필요한지도 자연스럽게 연결된다.</p>
<h2 id="4-key">4. key</h2>
<h3 id="개념-3">개념</h3>
<p><code>key</code>는 단순히 경고를 없애기 위한 값이 아니라,<br><strong>React가 요소를 어떤 기준으로 구분할지 알려주는 식별자</strong>다.</p>
<p>React는 이전 렌더와 다음 렌더를 비교할 때<br><code>key</code>를 보고 <strong>같은 컴포넌트인지, 새로운 컴포넌트인지</strong> 판단한다.</p>
<p>단순하게 <code>key</code>가 같으면 같은 대상으로 보고 state를 유지할 수 있고, <code>key</code>가 다르면 다른 대상으로 보고 새로 만든다</p>
<p>즉 <code>key</code>는 React에게 “이 요소가 이전 것과 같은 대상인지”를 알려주는 기준이다.</p>
<h3 id="예제-1-key가-바뀌면-왜-state가-초기화될까">예제 1: key가 바뀌면 왜 state가 초기화될까</h3>
<pre><code class="language-jsx">function ProfileForm({ user }) {
  const [input, setInput] = useState(&quot;&quot;);

  return &lt;input value={input} onChange={(e) =&gt; setInput(e.target.value)} /&gt;;
}

&lt;ProfileForm key={user} user={user} /&gt;</code></pre>
<p>이 경우 <code>user</code>가 바뀌면 <code>key</code>도 바뀐다.<br>그러면 React는 기존 컴포넌트를 재사용하지 않고 <strong>새 컴포넌트로 다시 만든다.</strong><br>그래서 내부 state도 초기화된다.</p>
<blockquote>
<p><code>key</code>가 바뀌면 React는 업데이트가 아니라 새 컴포넌트로 취급한다.</p>
</blockquote>
<h3 id="예제-2-왜-index를-key로-쓰면-문제가-될-수-있을까">예제 2: 왜 index를 key로 쓰면 문제가 될 수 있을까</h3>
<pre><code class="language-jsx">{items.map((item, index) =&gt; (
  &lt;input key={index} defaultValue={item.text} /&gt;
))}</code></pre>
<p><code>index</code>는 순서를 나타내는 값이라서,<br>중간 삽입이나 정렬 변경이 생기면 같은 항목이어도 <code>key</code>가 달라질 수 있고,<br>반대로 다른 항목인데도 같은 자리로 잘못 대응될 수 있다.</p>
<p>그 결과 입력값, 체크 상태 같은 것이 엉뚱하게 남을 수 있다.</p>
<p>그래서 가능하면 이렇게 <strong>데이터 자체를 대표하는 값</strong>을 써야 한다.</p>
<pre><code class="language-jsx">{items.map((item) =&gt; (
  &lt;input key={item.id} defaultValue={item.text} /&gt;
))}</code></pre>
<blockquote>
<p><code>key</code>는 순서가 아니라, 각 항목의 정체성을 나타내는 값이어야 한다.</p>
</blockquote>
<p><code>key</code>는 리스트 문법이 아니라<br><strong>React가 컴포넌트의 정체성을 판단하는 기준</strong>이다.</p>
<p>그래서 <code>key</code>를 어떻게 주느냐에 따라<br>state가 유지될 수도 있고, 초기화될 수도 있다.</p>
<h1 id="마무리">마무리</h1>
<p>이번에 정리한 예제들은 단순한 개념 설명이라기보다,
내가 사이드 프로젝트를 진행하면서 실제로 여러 번 부딪혔던 문제들에 가깝다.</p>
<p><code>state</code>가 예상과 다르게 동작해 화면이 꼬인 적도 있었고,
<code>memo</code>나 <code>useCallback</code>을 적절히 쓰지 않아 불필요한 렌더링으로 화면이 무거워진 적도 있었다.
<code>key</code> 역시 단순히 리스트에 넣는 값 정도로 생각했었는데,
드래그 앤 드롭처럼 요소의 순서와 위치가 자주 바뀌는 상황에서는
생각보다 훨씬 중요한 기준이라는 걸 직접 겪으며 알게 됐다.</p>
<p>평소에는 익숙하게 쓰던 개념들이었지만, 이렇게 예제를 통해 하나씩 다시 정리해보니
“그냥 쓰는 것”과 “이해하고 쓰는 것”의 차이가 조금은 선명해진 느낌이다.</p>
<p>앞으로도 React를 사용할 때 새로운 기능만 빠르게 붙이기보다,
이런 기본 동작들을 함께 떠올리면서 더 안정적으로 코드를 작성해보려고 한다.</p>
<p>*<em>내가만든 예제코드 *</em>
<a href="https://github.com/MyeongwuKim/ReactExample">https://github.com/MyeongwuKim/ReactExample</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GPT와 함께 사이드프로젝트 보강하기 - SEO]]></title>
            <link>https://velog.io/@dragonperson_kim/GPT%EC%99%80-%ED%95%A8%EA%BB%98-%EC%82%AC%EC%9D%B4%EB%93%9C%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B3%B4%EA%B0%95%ED%95%98%EA%B8%B0-1</link>
            <guid>https://velog.io/@dragonperson_kim/GPT%EC%99%80-%ED%95%A8%EA%BB%98-%EC%82%AC%EC%9D%B4%EB%93%9C%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B3%B4%EA%B0%95%ED%95%98%EA%B8%B0-1</guid>
            <pubDate>Tue, 10 Mar 2026 01:29:48 GMT</pubDate>
            <description><![CDATA[<h1 id="이제-뭐하지">이제 뭐하지?</h1>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/26e7635d-e30c-4b97-bed2-91b9bc0b085d/image.jpg" alt=""></p>
<blockquote>
<p>사이드 프로젝트 끝났는데 이제 진짜 뭐하지.. 진짜 할게없다..</p>
</blockquote>
<p>사이드프로젝트 블로그를 어느 정도 완성하고 나니까, 새로운 일을 벌리는것보단, 
이제는 <strong>“여기서 뭘 더 보강하고 싶은데..”</strong> 라는 생각이 들었다.</p>
<p>혼자 정해보려다가 반쯤 장난으로 GPT한테 한번 물어봤는데, 생각보다 리스트를 꽤 잘 뽑아줘서 놀랐다. 그냥 가볍게 물어본 거였는데 <strong>기능 추가, SEO, 성능 개선</strong> 쪽으로 나눠서 제법 그럴듯한 ToDo를 정리해줬다.</p>
<p>특히 막연하게 “뭔가 더 해야 할 것 같은데...” 정도로만 생각하고 있던 부분들이, 실제로 손댈 수 있는 작업 목록으로 바뀌니까 프로젝트를 다시 보는 느낌도 조금 달라졌다.</p>
<h2 id="gpt가-정리해준-블로그-보강-todo">GPT가 정리해준 블로그 보강 ToDo</h2>
<p>GPT가 뽑아준 Todo는 이렇다.</p>
<h3 id="seo">SEO</h3>
<ul>
<li>Open Graph / Twitter 메타데이터 적용</li>
<li>canonical 설정</li>
<li>post 상세 페이지 <code>article</code> 타입 적용 검토</li>
<li>robots / sitemap 최종 점검</li>
<li>구조화 데이터(JSON-LD)</li>
<li>not-found / 404 메타 처리</li>
</ul>
<h3 id="성능-개선">성능 개선</h3>
<ul>
<li>이미지 최적화</li>
<li>폰트 최적화</li>
<li>불필요한 클라이언트 컴포넌트 줄이기</li>
<li>Provider 범위 최소화</li>
<li>revalidate / 캐싱 전략 정리</li>
<li>dynamic import 등 코드 스플리팅</li>
</ul>
<p><strong>꽤나 그럴싸하게 뽑아줬다 ㅋㅋ;</strong></p>
<h2 id="하나도-신경-쓰지-않았던-seo">하나도 신경 쓰지 않았던 SEO</h2>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/1cc83494-4168-415d-a9d0-7d388f63e93c/image.png" alt=""></p>
<p><del>GPT에게 물어보기전엔 구글 검색 노출이 알아서 되는줄 알았다.</del></p>
<p>멍청하게도 블로그를 만들 때 SEO는 거의 신경 쓰지 않았다.
당시에는 일단 페이지가 잘 나오고, 글 목록이 보이고, 상세 페이지가 정상적으로 동작하면 그걸로 충분하다고 생각했다. 웹페이지는 그냥 잘 만들어서 문제없이 보이기만 하면 되는 줄 알았다.</p>
<p>그러다 GPT에게 블로그를 더 보강하려면 어떤 작업을 하면 좋을지 물어보는 과정에서, SEO가 생각보다 중요한 영역이라는 걸 알게 됐다. 검색엔진 최적화라는 말은 막연히 어렵게 느껴졌지만, 실제로는 <code>title</code>, <code>description</code>, <code>Open Graph</code>처럼 기본적이지만 중요한 설정들이 많았다.</p>
<p>특히 블로그처럼 글이 중심이 되는 프로젝트는 검색 유입도 중요하고, 링크를 공유했을 때 어떤 제목과 설명, 이미지가 보이는지도 꽤 중요했다.
그전까지는 그저 페이지를 만드는 데만 집중했다면, 이번에는 페이지를 더 제대로 보여주기 위한 작업도 필요하다는 걸 느꼈다.</p>
<p>무엇보다 나는 배포만 하면 검색엔진에 자동으로 잘 노출되는 줄 알았다.
하지만 실제로는 검색엔진이 페이지를 이해할 수 있도록 메타데이터를 정리해야 했고, 구글에 내 사이트가 더 잘 수집될 수 있도록 색인이나 사이트맵 같은 부분도 함께 챙겨야 한다는 걸 뒤늦게 알게 됐다.</p>
<p>익숙한 분야는 아니었지만, 그래서 이번 보강 작업에서는 <strong>SEO를 먼저 정리해보기로</strong> 했다.</p>
<hr>
<h1 id="무엇을-수정했는가">무엇을 수정했는가?</h1>
<h2 id="구글-색인-등록">구글 색인 등록</h2>
<p><code>Google Search Console</code>을 통해 색인 등록 작업을 진행했다.
위에 내용처럼 배포만 하면 검색 결과에도 자연스럽게 노출되는 줄 알았지만, 실제로는 사이트를 등록하고 사이트맵을 제출하는 등 검색엔진이 페이지를 수집할 수 있도록 따로 설정해줘야 했다.</p>
<p>이 작업을 하면서 블로그는 단순히 화면을 잘 만드는 것에서 끝나는 게 아니라, 검색엔진이 이해할 수 있도록 구조와 정보를 함께 정리해야 한다는 점을 알게 됐다.
SEO를 막연하게 어렵게만 생각했는데, 직접 적용해보니 기본적인 설정과 색인 등록만으로도 블로그를 훨씬 더 제대로 운영하는 느낌이 들었다. 
나름 재미있었다 ㅋㅋ;</p>
<h2 id="메타데이터">메타데이터</h2>
<p>기존에도 일부 메타데이터는 적용되어 있었지만, 이번에는 홈 화면과 포스트 상세 페이지를 기준으로 조금 더 보강하는 방향으로 정리했다.</p>
<p>우선 <strong>홈 화면</strong>에는 <code>Open Graph</code>와 <code>Twitter</code> 메타데이터를 추가했다.<br>링크를 공유했을 때 제목, 설명, 이미지가 조금 더 의도한 형태로 보이도록 하기 위한 작업이었다.</p>
<p>또한 공유 시 사용할 <code>OG 이미지</code> 도 따로 제작해 연결했다.<br>기존에는 이런 부분까지는 전혀 신경 쓰지 않았는데, 막상 적용해보니 블로그의 인상이나 완성도에 생각보다 큰 영향을 주는 요소라는 걸 체감했다.</p>
<p>*<em>포스트 상세 페이지 *</em> 쪽도 <code>generateMetadata</code>를 기준으로 메타데이터를 다시 살펴봤다.<br>글 제목과 설명, 썸네일이 각 포스트에 맞게 반영되도록 정리했고, 이후에는 <code>canonical</code>, <code>article</code> 타입 같은 메타데이터도 조금 더 보강할 수 있도록 방향을 잡아두었다.</p>
<h1 id="이렇게-함으로써-기대하는-점">이렇게 함으로써 기대하는 점</h1>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/d79d67ea-a12a-4e46-aeab-17467b5d7e56/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/3df90e87-8982-4e69-8e40-e6687909dba9/image.png" alt=""></p>
<p><strong>(Open Graph 메타데이터 결과물, 이제 구글에도 노출이 잘된다! 별것 아니지만 괜스레 뿌듯함..)</strong></p>
<p>가장 큰 변화는 블로그가 외부에서 보여지는 방식이 조금 더 정돈된다는 점이다.<br>이전에는 페이지가 잘 렌더링되고 글이 잘 보이는 것에만 집중했다면, 이제는 검색엔진이나 링크 공유 환경에서도 페이지가 조금 더 의도한 방식대로 보일 수 있게 됐다.</p>
<p>특히 <code>Open Graph</code> <code>메타데이터</code>와 <code>OG 이미지</code>를 적용하면서, 링크를 공유했을 때 제목과 설명, 이미지가 함께 정리되어 보인다는 점이 인상적이었다. 블로그처럼 글이 중심이 되는 프로젝트에서는 이런 요소도 생각보다 중요하다고 느꼈다.</p>
<p>검색엔진 최적화 자체를 단번에 체감하기는 어렵겠지만, 적어도 
title, description, Open Graph처럼 기본이 되는 요소들을 하나씩 정리해두는 건 이후를 위해서도 의미 있는 작업이라고 생각했다.</p>
<p>무엇보다 좋았던 점은, 새로운 기능을 하나 더 만드는 것과는 또 다른 방식으로 프로젝트를 다듬고 있다는 느낌을 받았다는 점이다.<br><strong>만드는 단계에서 끝나는 것이 아니라, 완성한 뒤에도 보완하고 정리하는 과정이 결국 프로젝트의 완성도를 높여준다는 걸 이번 작업을 통해 다시 느꼈다.</strong></p>
<h1 id="오늘-작업을-마무리하며-느낀점">오늘 작업을 마무리하며 느낀점</h1>
<p><strong>GPT 넌 정말 최고야</strong></p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/c4861037-27e8-4dbd-8cfb-4ee0bb9f2a50/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 블로그 프로젝트를 돌아보며 (2)]]></title>
            <link>https://velog.io/@dragonperson_kim/Next.js-%EB%B8%94%EB%A1%9C%EA%B7%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%A9%B0-2</link>
            <guid>https://velog.io/@dragonperson_kim/Next.js-%EB%B8%94%EB%A1%9C%EA%B7%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EB%8F%8C%EC%95%84%EB%B3%B4%EB%A9%B0-2</guid>
            <pubDate>Sun, 08 Mar 2026 09:37:28 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가며">들어가며</h1>
<p>1편에서 다루고 싶었던 내용을 적다 보니 생각보다 글이 길어져, 이어서 2편을 작성하게 되었다.
이번 편 역시 같은 흐름으로, 블로그를 개발하면서 사용했던 기술과 직접 해결했던 문제들을 중심으로 정리해보려고 한다.</p>
<hr>
<h2 id="어떻게-이미지를-업로드할-것인가">어떻게 이미지를 업로드할 것인가?</h2>
<p>Cloudflare Images는 단순한 객체 스토리지가 아니라, 업로드 요청에 대해 <strong>일회성 업로드 URL</strong>을 발급해주는 구조를 가지고 있다.</p>
<p>그래서 이미지를 서버로 먼저 보낸 뒤 다시 Cloudflare로 전달하는 방식이 아니라, 클라이언트가 Cloudflare에 <strong>직접 업로드</strong>를 수행하는 구조로 만들었다.</p>
<p>이 방식의 장점은 분명했다.</p>
<ul>
<li>서버가 이미지 파일을 직접 다루지 않아 <strong>리소스 부담이 적다</strong></li>
<li>업로드 속도가 빠르다</li>
<li>확장성 측면에서도 유리하다</li>
</ul>
<p>즉, Next.js는 업로드 URL을 발급하는 역할만 담당하고, 실제 이미지 데이터의 흐름은 Cloudflare에 위임하는 방식이다.</p>
<h3 id="1-업로드-url-요청하기">1. 업로드 URL 요청하기</h3>
<p>클라이언트가 이 API를 호출하면 Cloudflare에 Direct Upload 요청을 보내고, Cloudflare는 업로드 URL과 함께 이미지 업로드 토큰을 발급해준다.
이후 클라이언트는 해당 URL로 파일을 전송하고, Cloudflare가 이미지를 직접 저장하게 된다.</p>
<pre><code class="language-js">//api/upload/route.ts
export const POST = async (req: NextRequest) =&gt; {
  try {
    const fetchUrl = `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT}/images/v1/direct_upload`;
    const response = await (
      await fetch(fetchUrl, {
        method: req.method,
        headers: {
          &quot;Content-Type&quot;: &quot;application/json&quot;,
          Authorization: `Bearer ${process.env.CF_TOKEN}`,
        },
      })
    ).json();
    return NextResponse.json({
      ok: true,
      ...response.result,
    });
  } catch {
    return NextResponse.json({
      ok: false,
      error: &quot;이미지 서버와 통신중 오류가 발생하였습니다.&quot;,
    });
  }
};</code></pre>
<h3 id="2-발급받은-url로-직접-업로드하기">2. 발급받은 URL로 직접 업로드하기</h3>
<p>이미지 업로드 과정은 다음과 같은 흐름으로 구성했다.</p>
<ul>
<li>먼저 <code>URL.createObjectURL(file)</code>로 blob URL을 생성해 사용자가 즉시 이미지를 미리 볼 수 있도록 한다.</li>
<li>그와 동시에 백그라운드에서 Cloudflare 업로드 요청을 보낸다.</li>
<li>업로드가 완료되면 응답으로 받은 <code>imageId</code>를 기준으로 에디터 내부의 임시 이미지를 실제 이미지 URL로 치환한다.</li>
<li>이 <code>imageId</code>는 이후 관리 목적으로 DB에도 함께 저장한다.</li>
</ul>
<p>아래는 해당 기능을 구현한 코드 일부이다.</p>
<pre><code class="language-js">// 이미지 업로드 이벤트
const onUploadImgEvt = useCallback(async (file: File) =&gt; {
  if (!editorView) return;
  editorView?.focus();

  // 📌 1단계: blob URL로 임시 이미지 표시
  const imgURL = URL.createObjectURL(file);
  const placeholder = `![업로드중](${imgURL})`;
  editorView.dispatch(editorView.state.update(editorView.state.replaceSelection(placeholder)));

  try {
    // 📌 2단계: 업로드 URL 발급 요청
    const { uploadURL } = await (await fetch(`/api/upload`, { method: &quot;POST&quot; })).json();

    const form = new FormData();
    form.append(&quot;file&quot;, file, `${process.env.NODE_ENV}_simpleblog_${timeStamp()}`);

    // 📌 3단계: Cloudflare에 직접 업로드
    const {
      result: { id },
    } = await (await fetch(uploadURL, { method: &quot;POST&quot;, body: form })).json();

    // 📌 4단계: Cloudflare URL로 치환
    const deliveryURL = getDeliveryDomain(id, &quot;public&quot;);
    const cursor = new SearchCursor(editorView.state.doc, placeholder);
    cursor.next();
    editorView.dispatch({
      changes: { from: cursor.value.from, to: cursor.value.to, insert: `![](${deliveryURL})` },
    });

    // DB 저장
    imageMutate.mutate({ imageId: id });

  } catch {
    openToast(true, &quot;이미지 업로드중 실패하였습니다&quot;, 1);
  }
}, [editorView]);</code></pre>
<h3 id="3-이미지-정리">3. 이미지 정리?</h3>
<p>이미지를 업로드한 뒤 글을 삭제하면, 이미지 파일은 저장소에 그대로 남게 된다.
이런 파일들을 방치하면 계속 쌓이기만 하고 비용만 발생하는, 이른바 고아 이미지(Orphan Image)가 생기게 된다.</p>
<p>운영을 고려하면 이 문제는 반드시 자동으로 관리할 필요가 있었다.</p>
<p>그래서 이미지와 게시글의 관계를 Prisma에서 다음과 같이 설정했다.</p>
<ul>
<li>Post가 삭제될 경우
→ 연결된 Image 레코드도 자동 삭제 (<code>onDelete: Cascade</code>)</li>
<li>Image가 삭제될 경우
→ Cloudflare Image 삭제 API도 함께 호출</li>
</ul>
<p>하지만 여기서 또 다른 문제가 있었다.</p>
<p>이미지와 게시글의 관계를 DB 차원에서 잘 관리하더라도, 실제 이미지를 삭제하는 작업은 <strong>Cloudflare API 요청</strong>으로 수행된다.
그렇다면 해당 요청이 실패하면 어떻게 될까?</p>
<ul>
<li>임시글을 삭제했는데 API 에러가 발생한 경우</li>
<li>에디터에서 이미지를 교체했는데 이전 이미지 삭제가 누락된 경우</li>
<li>예기치 못한 네트워크 장애로 API 호출이 실패한 경우</li>
</ul>
<p>이런 경우 DB에서는 이미지 정보가 사라졌더라도, Cloudflare 저장소에는 파일이 그대로 남게 된다.
즉, 또 다른 형태의 고아 이미지가 생기는 셈이다.</p>
<p>다행히도 Vercel의 <strong>Cron Job</strong>을 활용하면, 이런 고아 이미지를 주기적으로 정리할 수 있었다.</p>
<p>정리 흐름은 다음과 같다.</p>
<ol>
<li>DB에서 <strong>사용 중인 모든 imageId 목록을 수집</strong></li>
<li>Cloudflare 저장소에 있는 이미지 목록과 비교</li>
<li>일정 시간 이상 DB에 존재하지 않는 imageId를 탐색</li>
<li>해당 이미지를 Cloudflare API로 삭제 요청</li>
</ol>
<p>즉, 정리 스케줄러를 통해 미처 지워지지 못한 고아 이미지도 시간이 지나면 자동으로 정리되도록 만들었다.</p>
<hr>
<h1 id="인증과-인가는-어떻게">인증과 인가는 어떻게?</h1>
<h3 id="1-인증">1. 인증</h3>
<p>처음에는 <strong>NextAuth</strong>의 <strong>Credentials Provider</strong>를 사용해 DB에 사용자 정보를 저장하고, 직접 로그인 기능을 구현하는 방식도 고려했다.</p>
<p>하지만 회원 정보와 비밀번호를 직접 관리해야 한다는 점이 생각보다 부담으로 느껴졌다. 개인 프로젝트에서는 인증 시스템 자체를 깊게 구현하는 것보다 핵심 기능 개발에 더 집중하는 편이 낫다고 판단했고, 그 결과 <strong>카카오 OAuth</strong> 로그인을 선택하게 됐다.</p>
<p>비밀번호 저장과 검증 로직을 직접 다루지 않아도 된다는 점에서 훨씬 간편했고, 이미 검증된 인증 흐름을 활용할 수 있다는 점에서도 만족스러웠다.
결과적으로 개인 프로젝트에서는 인증 기능을 직접 구현하는 것보다, 검증된 방식을 활용해 빠르게 적용하는 쪽이 더 잘 맞는 선택이었다.</p>
<p>추가로 <code>signIn</code> 콜백을 활용해 특정 계정만 로그인할 수 있도록 제한하면서, 개인 프로젝트에 맞는 최소한의 접근 제어도 함께 적용했다.</p>
<pre><code class="language-js">//next-option
callbacks: {
  jwt: ({ token, user }) =&gt; {
    return { ...token, ...user };
  },
  signIn: async (params) =&gt; {
    const { email } = params.user;

    if (isDemo) {
      // 데모 모드에서는 누구나 허용
      return true;
    }
    if (email === &quot;내 카카오톡 아이디&quot;) {
      return true;
    }

    return false;
  },
},</code></pre>
<h3 id="2-인가">2. 인가</h3>
<p>인가는 로그인한 사용자가 어디까지 접근할 수 있는지를 구분하는 방식으로 구현했다.</p>
<p>블로그 특성상 방문자와 로그인한 사용자가 완전히 같은 화면을 볼 필요는 없었고, 특히 수정이나 삭제 같은 기능은 관리자만 사용할 수 있어야 했다.
이를 위해 <strong>NextAuth</strong>에서 제공하는 <code>useSession</code>을 활용해 현재 로그인 상태를 확인하고, 세션 정보가 존재할 때만 특정 UI가 노출되도록 처리했다.</p>
<p>방문자는 게시글을 조회하는 기능만 사용할 수 있고, 로그인한 경우에는 수정 및 삭제 같은 관리 기능까지 접근할 수 있도록 분기했다.
복잡한 권한 체계를 두기보다는, 개인 프로젝트에 맞게 인증 여부를 기준으로 필요한 기능만 구분하는 방식이 더 적절하다고 판단했다.</p>
<pre><code class="language-js">//예시
const { data: session } = useSession();

{session &amp;&amp; (
  &lt;div&gt;
    &lt;button&gt;수정&lt;/button&gt;
    &lt;button&gt;삭제&lt;/button&gt;
  &lt;/div&gt;
)}</code></pre>
<h3 id="3-api-보호">3. API 보호</h3>
<p>인가 처리를 단순히 화면 분기에만 맡기지는 않았다.
수정이나 삭제 버튼을 숨기더라도, 사용자가 직접 API를 호출하는 방식으로 요청을 보낼 수 있기 때문이다.</p>
<p>그래서 <strong>Next.js</strong>에서 제공하는 <strong>middleware</strong>를 활용해 인증이 필요한 페이지와 API 요청을 함께 보호했다. <code>getToken</code>을 통해 현재 요청에 토큰이 존재하는지 확인하고, 토큰이 없는 경우에는 보호된 페이지 접근을 로그인 페이지로 리다이렉트하도록 처리했다.</p>
<p>또한 인증되지 않은 상태에서 들어오는 <code>POST</code>, <code>DELETE</code> 요청은 일부 예외 API를 제외하고 차단해, 데이터 변경이 발생하는 요청 자체를 제한했다.</p>
<p>이를 통해 단순히 화면에서 기능을 숨기는 수준을 넘어, 실제 요청 단계에서도 인증되지 않은 접근을 막을 수 있었다.
개인 프로젝트에서는 모든 요청에 복잡한 권한 체계를 두기보다, 꼭 필요한 페이지와 API만 선별적으로 보호하는 방식이 더 적절하다고 느꼈다.</p>
<pre><code class="language-js">//middleware.ts
import { getToken } from &quot;next-auth/jwt&quot;;
import { NextRequest, NextResponse } from &quot;next/server&quot;;

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = await getToken({ req: request, secret: process.env.SECRET });

  const protectedPages = [&quot;/write&quot;, &quot;/setting&quot;, &quot;/temp&quot;];

  if (protectedPages.some((page) =&gt; pathname.startsWith(page)) &amp;&amp; !token) {
    return NextResponse.redirect(new URL(&quot;/auth/signin&quot;, request.url));
  }

  if (
    !token &amp;&amp;
    (request.method === &quot;POST&quot; || request.method === &quot;DELETE&quot;) &amp;&amp;
    !pathname.startsWith(&quot;/api/auth&quot;) &amp;&amp;
    !(request.method === &quot;POST&quot; &amp;&amp; pathname.startsWith(&quot;/api/comments&quot;)) &amp;&amp;
    !(
      request.method === &quot;POST&quot; &amp;&amp;
      pathname.startsWith(&quot;/api/post/&quot;) &amp;&amp;
      pathname.endsWith(&quot;/view&quot;)
    )
  ) {
    return NextResponse.json(
      { error: &quot;API 접근 권한이 없습니다.&quot; },
      { status: 401 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    &quot;/api/:path*&quot;,
    &quot;/((?!_next/static|_next/image|favicon.ico|auth/signin).*)&quot;,
  ],
};</code></pre>
<hr>
<h1 id="변경된-테일윈드">변경된 테일윈드?</h1>
<h3 id="1-v3에서-v4-달라진-점">1. v3에서 v4 달라진 점</h3>
<p>처음에 언급했던 것처럼 스타일링에는 <code>Tailwind CSS</code>를 사용했다.
다만 작업하면서 조금 헷갈렸던 부분은 v3에서 v4로 넘어오며 설정 방식이 꽤 많이 달라졌다는 점이었다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>Tailwind CSS v3</th>
<th>Tailwind CSS v4</th>
</tr>
</thead>
<tbody><tr>
<td>설정 중심</td>
<td><code>tailwind.config.js</code> 중심으로 설정</td>
<td>CSS 파일에서 <code>@theme</code> 등을 활용하는 CSS-first 방식이 중심</td>
</tr>
<tr>
<td>content 관리</td>
<td><code>content</code> 배열을 직접 작성해야 했음</td>
<td>템플릿 파일을 자동 감지하는 방식이 기본</td>
</tr>
<tr>
<td>테마 확장</td>
<td>config의 <code>theme.extend</code> 중심</td>
<td>CSS 안에서 <code>@theme</code>로 디자인 토큰을 정의</td>
</tr>
<tr>
<td>커스텀 유틸리티/확장</td>
<td>주로 config, plugin 기반</td>
<td><code>@theme</code>, <code>@utility</code>, <code>@variant</code> 같은 CSS 지시어 중심, 필요하면 <code>@config</code>, <code>@plugin</code>으로 기존 방식도 일부 연동 가능</td>
</tr>
<tr>
<td>CLI</td>
<td><code>tailwindcss</code> 패키지의 CLI 사용</td>
<td><code>@tailwindcss/cli</code> 패키지로 분리</td>
</tr>
</tbody></table>
<h3 id="2-신경-쓴-점">2. 신경 쓴 점</h3>
<p>이전 프로젝트에서는 테마를 명확하게 정의하지 않은 채 스타일을 그때그때 작성하는 경우가 많았다.
그 과정에서 자주 사용하던 값조차 다시 찾게 되거나, 비슷한 색상과 스타일이 조금씩 다르게 반복되는 일도 있었다. 결과적으로 전체적인 UI 일관성도 아쉬움이 남았다.</p>
<p>이번 프로젝트에서는 이런 문제를 줄이기 위해 <code>@theme</code>를 중심으로 폰트, 컬러, 브레이크포인트 등을 미리 정리해두고 사용하는 방식으로 바꿨다.
특히 <strong>라이트 모드</strong>와 <strong>다크 모드</strong>에서 반복적으로 사용되는 값들을 공통 변수로 관리하면서, 스타일을 조금 더 체계적으로 다룰 수 있도록 신경 썼다.</p>
<p>완벽하다고 보기는 어렵지만, 이전보다 훨씬 명확한 기준 안에서 스타일을 작성할 수 있었다는 점에서 의미가 있었다.</p>
<pre><code class="language-css">@theme {
  --font-sans: var(--font-pretendard), system-ui, sans-serif;
  --color-background1: #fff;
  --color-text1: #212529;
  --breakpoint-sm: 768px;
}

.dark {
  --color-background1: #1e1e1e;
  --color-text1: #ececec;
}</code></pre>
<hr>
<h1 id="배운것과-까다로웠던-것">배운것과 까다로웠던 것</h1>
<blockquote>
<p>[!NOTE]
이번 프로젝트를 진행하면서 페이지 라우팅 구조나 렌더링 방식도 고민이 많았지만, 가장 시간을 많이 쓴 부분은 여러 화면에서 공통으로 사용하는 Provider와 공통 컴포넌트를 설계하는 일이었다.</p>
</blockquote>
<p>이전 프로젝트에서는 필요한 기능을 그때그때 붙이는 식으로 작업하는 경우가 많았다.
하지만 이번에는 페이지 수가 늘어나고 여러 화면에서 반복적으로 사용하는 기능도 많아지면서, 처음부터 구조를 조금 더 신경 써야겠다는 생각이 들었다.
특히 공통적으로 사용되는 UI와 데이터 처리 로직을 어떻게 정리할지가 꽤 중요한 고민이었다.</p>
<h3 id="1-공통적으로-사용되는-기능을-어떻게-분리할지">1. 공통적으로 사용되는 기능을 어떻게 분리할지</h3>
<p>프로젝트를 만들면서 가장 먼저 든 생각은, 여러 화면에서 반복적으로 사용되는 공통 UI와 데이터 처리 패턴이 생각보다 많다는 점이었다.
페이지마다 비슷한 구조를 각각 구현하기보다, 자주 쓰이는 기능은 미리 분리해두는 편이 이후 유지보수나 확장에 더 유리하다.</p>
<p>자주 쓰이는 패턴 예시는 다음과 같았다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>역할</th>
<th>분리한 이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>providers/uiProvider.tsx</code></td>
<td>토스트, 모달 같은 전역 UI 관리</td>
<td>어느 화면에서든 같은 방식으로 호출할 수 있도록</td>
</tr>
<tr>
<td><code>InfiniteScroll/infiniteScrollProvider.tsx</code></td>
<td>게시글, 댓글, 임시글 등의 리스트를 공통 방식으로 처리하는 컴포넌트</td>
<td>반복되는 무한 스크롤과 fetch, 로딩, 에러 처리 흐름을 공통화하기 위해</td>
</tr>
</tbody></table>
<p>이렇게 나누어두니 페이지 컴포넌트는 화면 자체에 더 집중할 수 있었고, 공통 기능은 별도의 레이어에서 일관되게 관리할 수 있어 전체 구조도 훨씬 명확해졌다.</p>
<h3 id="2-가장-어려웠던-부분은-범용적으로-동작하는-타입-설계였다">2. 가장 어려웠던 부분은 범용적으로 동작하는 타입 설계였다</h3>
<p><code>타입스크립트</code>는 타입을 좁혀 실수를 방지해주지만, 그만큼 까다로운 부분도 있다.
공통 구조를 만드는 과정에서 가장 어려웠던 부분은 단순히 기능을 동작하게 만드는 것이 아니라, <strong>여러 화면에서 문제없이 재사용할 수 있도록 타입을 맞추는 작업</strong>이었다.</p>
<p>예를 들어 모달은 종류마다 필요한 props가 달랐고, 무한 스크롤도 게시글, 댓글, 임시글, 컬렉션처럼 서로 다른 데이터를 공통된 방식으로 처리해야 했다.</p>
<pre><code class="language-ts">// 인피니트 스크롤 컴포넌트 타입 예시
type DataType = &quot;post&quot; | &quot;temp&quot; | &quot;comments&quot; | &quot;relatedPosts&quot; | &quot;collections&quot;;</code></pre>
<p>이처럼 데이터 종류를 나누고, 각 타입에 맞는 fetcher와 렌더링 방식을 연결하는 과정이 생각보다 까다로웠다.
한 화면에서만 쓰는 코드였다면 훨씬 단순하게 만들 수 있었겠지만, 공통 구조는 어디서든 안정적으로 동작해야 했기 때문에 초반 설계에 시간을 많이 쓸 수밖에 없었다.</p>
<blockquote>
<p>구현보다 설계가 더 어려웠던 이유는
“지금 한 번만 동작하면 되는 코드”가 아니라
“앞으로도 계속 재사용될 코드”를 만들어야 했기 때문이다.</p>
</blockquote>
<h3 id="3-전역-ui는-편하게-쓰는-대신-내부-구조는-더-신경-써야-했다">3. 전역 UI는 편하게 쓰는 대신 내부 구조는 더 신경 써야 했다</h3>
<p><code>UIProvider</code>와 <code>PopupContainer</code>를 중심으로 한 전역 UI 구조는 토스트와 모달을 어디서든 공통된 방식으로 사용할 수 있도록 하기 위해 만든 구조였다.
사용하는 쪽에서는 비교적 간단하게 호출할 수 있지만, 내부에서는 모달 종류별 분기, Promise 기반 제어, 라우트 변경 시 정리 같은 부분까지 고려해야 했다.</p>
<p>즉, 바깥에서 보기에는 편한 구조일수록 안쪽 설계는 오히려 더 꼼꼼해야 한다는 점을 많이 느꼈다.</p>
<pre><code class="language-ts">openModal&lt;T extends ModalType&gt;(
  type: T,
  props: ModalPropsMap[T]
): Promise&lt;unknown&gt;</code></pre>
<p>이런 식으로 타입을 연결해두지 않으면, 나중에 모달 종류가 늘어날수록 사용하는 쪽도 불안정해질 수 있었다.
처음에는 꽤 번거로웠지만, 한 번 구조를 잡고 나니 이후에는 공통된 방식으로 계속 확장할 수 있다는 점이 확실한 장점이었다.</p>
<h3 id="4-처리-구조를-공통화해두니-이후-작업이-훨씬-편했다">4. 처리 구조를 공통화해두니 이후 작업이 훨씬 편했다</h3>
<p>게시글 목록, 관련 글, 댓글, 임시글처럼 비슷한 패턴이 반복될 가능성이 높았기 때문에, 이를 하나의 <strong>공통 인피니트 스크롤 컴포넌트</strong>로 정리해두는 편이 더 낫다고 판단했다.</p>
<p>그 결과 이후에는 새로운 리스트 화면이 생겨도 기존 구조 안에서 타입과 렌더링, 페칭만 추가하면 되었고, 비슷한 로직을 매번 새로 작성하는 부담도 줄일 수 있었다.</p>
<blockquote>
<p>결과적으로 초반에는 설계와 타입 정리에 시간이 꽤 많이 들었지만,
프로젝트를 업데이트할수록 그 선택이 훨씬 효율적이었다고 느꼈다.
실제로 새로운 기능을 붙이거나 화면을 확장할 때 훨씬 빠르게 대응할 수 있었기 때문이다.</p>
</blockquote>
<h3 id="5-provider를-역할에-맞게-나눠-사용하니-편리했다">5. Provider를 역할에 맞게 나눠 사용하니 편리했다.</h3>
<p>이번 프로젝트에서는 Provider를 전역용과 페이지 내부용으로 나눠 사용했다.
토스트나 모달처럼 여러 화면에서 공통으로 쓰는 기능은 전역 Provider로, write 페이지처럼 내부 상태와 데이터 처리가 많은 부분은 별도 Provider로 분리했다.</p>
<p>이렇게 나누니 상태의 범위가 명확해졌고, 공통 기능과 페이지 전용 로직도 덜 섞이게 됐다.
특히 관련 상태와 함수를 한곳에 모아둘 수 있어서, 구현과 수정이 훨씬 수월했다.</p>
<h3 id="6-api를-다루면서-느낀것이-많다">6. API를 다루면서 느낀것이 많다</h3>
<p>이번 프로젝트에서는 API를 직접 다루는 과정도 꽤 까다로웠다.
프론트엔드에 비해 API 설계는 익숙하지 않았기 때문에, 데이터 형식이나 에러 응답 형식을 맞추는 부분에서 시행착오가 많았다.</p>
<p>특히 화면에서는 응답 형태를 기준으로 데이터를 보여주고 오류를 처리해야 하기 때문에, API마다 반환 형식이 다르면 사용하는 쪽도 함께 복잡해질 수밖에 없었다.
이번 작업을 하면서 성공 응답과 에러 응답을 어느 정도 공통된 형태로 맞춰두는 것이 생각보다 중요하다는 점을 배웠다.</p>
<p>다음에 비슷한 풀스택 사이드 프로젝트를 하게 된다면, <strong>기능 구현에 앞서 API 규격부터 조금 더 명확하게 설계하는 쪽으로 진행하고 싶다.</strong></p>
<hr>
<h1 id="마무리하며">마무리하며</h1>
<p>이번 포스팅은 프로젝트를 다시 돌아보는 기록에 가까웠다.
만들면서 바로 정리한 것이 아니다 보니, 중간중간 빠뜨린 부분도 있었고 기억이 흐릿한 부분도 있었다.</p>
<p>그래도 글을 쓰면서 내가 어떤 부분을 고민했고, 어디에 시간을 많이 쏟았는지를 다시 확인할 수 있었다.
막연하게 지나갔던 작업들도 정리해보니 분명한 이유와 흐름이 있었고, 그 과정에서 새롭게 알게 된 점도 적지 않았다.</p>
<p>완벽하게 정리된 글은 아닐 수 있지만, 이번 포스팅을 통해 프로젝트를 한 번 더 돌아보고 스스로를 점검해볼 수 있었다는 점에서 충분히 의미가 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[그리디(Greedy) 알고리즘]]></title>
            <link>https://velog.io/@dragonperson_kim/%EA%B7%B8%EB%A6%AC%EB%94%94</link>
            <guid>https://velog.io/@dragonperson_kim/%EA%B7%B8%EB%A6%AC%EB%94%94</guid>
            <pubDate>Tue, 03 Mar 2026 01:29:55 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가기전에">들어가기전에</h1>
<p>알고리즘 문제를 풀다 보면 처음엔 DP나 이분탐색처럼 뭔가 거창한 로직이 필요할 것 같았는데, 의외로 <strong>지금 당장 가장 좋은 선택을 하는 것만으로 풀리는 문제</strong>들도 꽤 많았다.
이런 유형이 바로 <code>그리디(Greedy)</code> 이다.</p>
<p>처음에는 나도 그리디를 보면 좀 애매했다.
이게 진짜 맞는 선택인가? 지금 최선이 나중에도 최선이 맞나?
이런 생각 때문에 오히려 어렵게 느껴졌다.</p>
<p>이번 글에서는 코딩 테스트에서 자주 등장하는 <strong>그리디 패턴이 어떤 느낌인지</strong>, 그리고 어떤 문제에서 떠올려야 하는지를 예시와 함께 정리해보려고 한다.</p>
<h1 id="그리디란">그리디란?</h1>
<p>정말 단순하다. 그리디는 상남자처럼 매 순간 <strong>가장 좋아 보이는 선택</strong>을 하는 방식이다.</p>
<p>즉,</p>
<blockquote>
<p>지금 당장 이득이 가장 큰 선택을 하고
그 선택을 계속 이어가며 최종 답을 만드는 방식</p>
</blockquote>
<p>핵심은 <strong>매 선택이 미래까지 포함해서도 문제를 깨지 않아야 한다는 점</strong>이다.
그래서 모든 문제를 그리디로 풀 수 있는 건 아니고,
<strong>지금의 최선이 전체 최선으로 이어지는 구조</strong> 가 있어야 한다.</p>
<h1 id="왜-그리디가-어려운가">왜 그리디가 어려운가?</h1>
<p>그리디는 구현 자체는 단순한 경우가 많다.
근데 어려운 건 구현이 아니라 <strong>이 문제를 그리디로 봐도 되는지 판단하는 것</strong>이다.</p>
<p>예를 들어,</p>
<ul>
<li>당장 가장 커 보이는 값을 고른다</li>
<li>당장 가장 빨리 끝나는 걸 고른다</li>
<li>당장 가장 작은 비용을 선택</li>
</ul>
<p>이런 선택이 항상 정답이 되는 건 아니다.</p>
<p>그래서 그리디 문제는 보통
<strong>왜 이 선택이 맞는가?</strong> 를 한 번 납득해야 한다.</p>
<h1 id="예제로-살펴보기">예제로 살펴보기</h1>
<p>이번에도 LeetCode 예제로 정리해보려고 한다.
그리디는 문제를 많이 볼수록 아 이런느낌인가? 하고 감이 오는 것 같다.</p>
<h2 id="assign-cookies-easy">Assign Cookies (Easy)</h2>
<p>문제) 아이들의 만족도와 쿠키 크기가 주어질 때, 최대 몇 명의 아이를 만족시킬 수 있는지 구하라.</p>
<p>예를 들어
아이의 만족도: <code>[1,2,3]</code>
쿠키 크기: <code>[1,1]</code></p>
<p>라면, 만족도 1인 아이만 만족시킬 수 있으니 정답은 <code>1</code> 이다.</p>
<pre><code class="language-js">// 가장 작은 아이부터, 줄 수 있는 가장 작은 쿠키를 준다.
var findContentChildren = function(g, s) {
    g.sort((a, b) =&gt; a - b);
    s.sort((a, b) =&gt; a - b);

    let i = 0; // child
    let j = 0; // cookie

    while (i &lt; g.length &amp;&amp; j &lt; s.length) {
        if (s[j] &gt;= g[i]) {
            i++;
        }
        j++;
    }

    return i;
};</code></pre>
<p>이 문제는 그리디의 대표적인 느낌이 잘 보인다.
가장 만족도가 작은 아이부터 처리하고, 그 아이를 만족시킬 수 있는 <strong>가장 작은 쿠키</strong>를 주는 게 핵심이다.</p>
<p>처음엔 큰 쿠키를 먼저 쓰면 안 되나? 싶었는데,
그러면 작은 아이한테 큰 쿠키를 낭비할 수 있어서 오히려 손해였다.</p>
<p>즉, <strong>작은 요구는 작은 자원으로 해결하는 게 이득</strong> 이다.</p>
<hr>
<h2 id="best-time-to-buy-and-sell-stock-ii-medium">Best Time to Buy and Sell Stock II (Medium)</h2>
<p>문제) 주식 가격이 주어질 때, 여러 번 거래할 수 있다면 얻을 수 있는 최대 이익을 구하라.</p>
<pre><code class="language-js">// 오를 때마다 그 차익을 모두 더하면 된다.
var maxProfit = function(prices) {
    let profit = 0;

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

    return profit;
};</code></pre>
<p>이 문제도 처음 보면
언제 사고 언제 팔아야 최대지? 하면서 어렵게 생각하게 된다.</p>
<p>근데 잘 보면
<code>1 -&gt; 3 -&gt; 5</code> 처럼 계속 오르는 구간은
결국 <code>5 - 1</code> 이익과
<code>(3 - 1) + (5 - 3)</code> 이익이 같다.</p>
<p>그래서 <strong>오르는 구간의 이익을 전부 더해도 최종 최대 이익과 같다.</strong></p>
<p>이 문제를 풀면서 느낀 건,
그리디는 가끔 <strong>문제를 쪼개서 생각하면 갑자기 쉬워진다</strong>는 점이다.
한 번의 완벽한 거래를 찾으려 하지 말고,
그냥 <strong>지금 오르면 먹는다</strong> 라고 보면 된다.</p>
<hr>
<h2 id="jump-game-medium">Jump Game (Medium)</h2>
<p>문제) 배열의 각 원소는 현재 위치에서 점프할 수 있는 최대 거리일 때, 마지막 인덱스까지 도달 가능한지 판단하라.</p>
<pre><code class="language-js">// 지금까지 도달 가능한 가장 먼 위치만 관리
var canJump = function(nums) {
    let farthest = 0;

    for (let i = 0; i &lt; nums.length; i++) {
        if (i &gt; farthest) return false;
        farthest = Math.max(farthest, i + nums[i]);
    }

    return true;
};</code></pre>
<p>이 문제는 처음에 나도 DFS나 DP 느낌으로 생각할 수 있을 것 같았는데,
막상 핵심은 단순했다.</p>
<p><strong>현재 위치까지 올 수 있다면, 여기서 갈 수 있는 가장 먼 위치를 계속 갱신하면 된다.</strong></p>
<p>즉,</p>
<ul>
<li>지금 인덱스가 이미 도달 불가능한 위치면 실패</li>
<li>아니면 <code>i + nums[i]</code> 로 갈 수 있는 범위를 계속 늘린다</li>
</ul>
<p>이 문제를 보고 나서 그리디의 느낌이 조금 더 명확해졌다.
모든 경로를 다 볼 필요 없이,
<strong>지금까지 만들 수 있는 최선의 범위</strong> 만 추적하면 되는 경우가 있다.</p>
<h1 id="그리디-문제를-언제-떠올려야-하는가">그리디 문제를 언제 떠올려야 하는가?</h1>
<p>다음과 같은 느낌이 보이면 그리디를 의심해볼 수 있다.</p>
<ul>
<li>최대한 많은 개수</li>
<li>최소 횟수 / 최소 비용</li>
<li>지금 선택이 이후 선택에 영향을 주는 구조</li>
<li>정렬 후 앞에서부터 처리</li>
<li>현재 상태에서 가장 이득인 선택</li>
</ul>
<p>특히 이런 문제들이 많다.</p>
<ul>
<li>회의실 배정</li>
<li>구간 선택</li>
<li>주유소 / 점프 / 배정 문제</li>
<li>동전 문제(일부 조건에서만)</li>
<li>정렬 후 순서대로 결정하는 문제</li>
</ul>
<p>그리디 문제를 많이 풀다 보면
<strong>정렬 + 순차 처리</strong> 조합이 진짜 자주 나온다는 걸 느끼게 된다.</p>
<h1 id="그리디의-핵심은">그리디의 핵심은?</h1>
<p>내가 느낀 그리디의 핵심은 이거였다.</p>
<blockquote>
<p>지금 당장 최선처럼 보이는 선택을 해도, 나중에 후회하지 않는가?</p>
</blockquote>
<p>이걸 확인할 수 있으면 그리디고,
그걸 확신할 수 없으면 보통 DP나 다른 접근을 봐야 한다.</p>
<p>그래서 그리디는 막연히 대충 좋아 보이는 거 고르는 것 이 아니라,
<strong>근거 있게 포기하고, 근거 있게 선택하는 방식</strong> 에 가깝다.</p>
<h1 id="마무리">마무리</h1>
<p>그리디는 처음엔 쉬워 보이는데 오히려 그래서 더 헷갈렸다.
구현은 짧은데, 왜 이게 정답이지? 를 설명하려고 하면 막히는 경우가 많았다.</p>
<p>특히 나 같은 경우는 문제를 보면 괜히 복잡하게 생각하는 습관이 있어서,
그리디 문제도 처음엔 DP처럼 보려고 했던 적이 많았다.
근데 몇 문제 풀어보니 <strong>정렬하고, 지금 가능한 최선의 선택을 쌓아간다</strong>
이 흐름이 보이기 시작했다.</p>
<p>아직도 낯선 문제를 보면 바로 그리디라고 확신하긴 어렵지만,
적어도 이제는 이거 혹시 정렬하고 앞에서부터 처리하는 문제 아닌가?
정도는 먼저 떠올려보게 된 것 같다.</p>
<p>역시 알고리즘은 많이 틀려보고,
왜 틀렸는지 정리하면서 익숙해지는 게 제일 중요한 것 같다.
많이 풀어봤어도 아직도 많이 어렵다 ㅋㅋ;</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스택(Stack) 알고리즘]]></title>
            <link>https://velog.io/@dragonperson_kim/%EC%8A%A4%ED%83%9D%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@dragonperson_kim/%EC%8A%A4%ED%83%9D%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Sun, 15 Feb 2026 03:07:13 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가기전에">들어가기전에</h1>
<p>알고리즘 문제를 풀다 보면 <code>스택(Stack)</code> 을 사용하는 문제를 정말 자주 접하게 된다.
처음에는 “괄호 검사할 때 쓰는 자료구조” 정도로만 생각했는데, 문제를 많이 풀어보니 스택은 훨씬 더 다양한 방식으로 등장했다.
이번 글에서는 코딩 테스트에서 자주 등장하는 스택 패턴을 실제 문제 예시와 함께 정리해보려고 한다.</p>
<h1 id="스택이란">스택이란?</h1>
<h3 id="1-기본-개념">1. 기본 개념</h3>
<p>스택은 <strong>LIFO (Last In, First Out)</strong> 구조이다.</p>
<ul>
<li>push → 맨 위에 추가</li>
<li>pop → 맨 위에서 제거</li>
<li>top → 맨 위 값 확인</li>
</ul>
<p>가장 최근에 들어온 값부터 처리한다는 점이 핵심이다.</p>
<h3 id="2-왜-스택을-쓰는가">2. 왜 스택을 쓰는가?</h3>
<p>스택은 단순히 “저장하는 용도”가 아니라,</p>
<p><strong>이전 값들과 현재 값을 빠르게 비교하고 정리하는 도구</strong> 로 사용된다.</p>
<p>특히 이런 상황에서 자주 쓰인다.</p>
<ul>
<li>현재 값이 이전 값보다 크거나 작은 경우</li>
<li>어떤 조건이 깨질 때까지 이전 값들을 정리해야 하는 경우</li>
<li>다시 돌아올 수 있는 값인지 판단해야 하는 경우</li>
</ul>
<h1 id="예제로-살펴보기">예제로 살펴보기</h1>
<p>프로그래머스도 좋지만 LeetCode가 굉장히 문제은행? 같은 느낌으로 난이도별로 나열이 잘 되어있었기에 LeetCode 예제로 스택을 살펴보려고한다.</p>
<h2 id="valid-parentheseseasy">Valid Parentheses(Easy)</h2>
<p>문제) 괄호 문자열이 올바른지 판단하라. ()[]{} → true (] → false</p>
<pre><code class="language-js">// 가장 최근에 열린 괄호부터 닫혀야 한다 → LIFO 구조
var isValid = function(s) {
    let stack = [];
    let map = {
        &#39;)&#39;: &#39;(&#39;,
        &#39;}&#39;: &#39;{&#39;,
        &#39;]&#39;: &#39;[&#39;
    };

    for (let char of s) {
        if (char === &#39;(&#39; || char === &#39;{&#39; || char === &#39;[&#39;) {
            stack.push(char);
        } else {
            if (stack.length === 0) return false;
            let top = stack.pop();
            if (top !== map[char]) return false;
        }
    }

    return stack.length === 0;
};
</code></pre>
<p>이 문제는 진짜 쉬웠다.</p>
<h2 id="daily-temperaturesmidium">Daily Temperatures(Midium)</h2>
<p>문제) 각 날짜마다 더 따뜻해지는 날까지 며칠 걸리는지 구하라.</p>
<p>입력: [73,74,75,71,69,72,76,73]
출력: [1,1,4,2,1,1,0,0]</p>
<pre><code class="language-js">//각 인덱스는 push 1번, pop 1번 → O(N)
//“다음 큰 값” 유형의 대표 문제
var dailyTemperatures = function(temperatures) {
    let stack = [];
    let answer = new Array(temperatures.length).fill(0);

    for (let i = 0; i &lt; temperatures.length; i++) {

        while (
            stack.length &amp;&amp;
            temperatures[stack[stack.length - 1]] &lt; temperatures[i]
        ) {
            let prevIndex = stack.pop();
            answer[prevIndex] = i - prevIndex;
        }

        stack.push(i);
    }

    return answer;
};
</code></pre>
<p>아.. 이문제는 푸는데 생각보다 꽤 걸렸다.
멍청하게도 Index를 Stack에 저장해서 쓴다는 아이디어를 생각하지 못했다. 
30분넘게 끙끙거리다 GPT한테 힌트를 얻으니 금방 풀렸던 문제였다.</p>
<h2 id="remove-duplicate-lettersmidium">Remove Duplicate Letters(Midium)</h2>
<p>문제) 중복 문자를 제거하면서 사전순으로 가장 작은 문자열 만들기.</p>
<pre><code class="language-js">//더 작은 문자가 나오면 이전 문자를 제거할지 판단
//단, 그 문자가 나중에 다시 등장해야 제거 가능

var removeDuplicateLetters = function(s) {
    let stack = [];
    let seen = new Set();
    let lastIndex = {};

    // 각 문자의 마지막 등장 위치 저장
    for (let i = 0; i &lt; s.length; i++) {
        lastIndex[s[i]] = i;
    }

    for (let i = 0; i &lt; s.length; i++) {
        let char = s[i];

        if (seen.has(char)) continue;

        while (
            stack.length &amp;&amp;
            stack[stack.length - 1] &gt; char &amp;&amp;
            lastIndex[stack[stack.length - 1]] &gt; i
        ) {
            seen.delete(stack.pop());
        }

        stack.push(char);
        seen.add(char);
    }

    return stack.join(&#39;&#39;);
};</code></pre>
<p>처음에 풀 땐 stack.indexOf(char)로 이미 들어간 문자인지 확인하고,
substr로 잘라내는 방식을 반복했다.
이렇게 하니 정답은 통과했지만 런타임이 좋지 않았다.
GPT에게 물어보니 Set이나 Map을 활용해 인덱스를 O(1)로 체크하면 성능을 개선할 수 있다고 했고, 실제로 33ms 정도 나오던 코드가 11ms 수준으로 줄어들었다.</p>
<h1 id="스택-문제를-언제-떠올려야-하는가">스택 문제를 언제 떠올려야 하는가?</h1>
<p>다음과 같은 문장이 보이면 스택을 의심해볼 수 있다:</p>
<ul>
<li>다음에 더 큰 값</li>
<li>이전 값과 비교”</li>
<li>조건이 깨질 때까지 제거”</li>
<li>사전순으로 가장 작은 결과”</li>
<li>중복 제거”</li>
</ul>
<p>특히 “다음 큰 값”, “이전 작은 값” 이런 표현이 나오면 거의 스택 문제다.
스택 문제의 핵심은 단순하다.
<strong>지금 상태를 더 좋게 만들기 위해, 이전 상태를 정리한다</strong>
그래서 대부분 O(N)으로 해결된다.</p>
<h1 id="마무리">마무리</h1>
<p>스택이 가장 쉽고 자신있었던 파트였는데 살짝 문제를 꼬아버리면 어렵게 느껴졌다.
특히 <strong>인덱스를 저장해서 사용하는 방법</strong> 이런 아이디어 요소들이 부족했던 것 같다.
아직 완벽하진 않지만, 이런 패턴들을 하나씩 정리해가면서 점점 더 빠르게 문제를 인식할 수 있게 되는 것 같다. 
앞으로도 자주 틀리고, 또 정리하면서 익숙해져보려고 한다.
솔직히 <strong>한글이 제일 어렵다.. 책 좀 읽자</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Umami 도입기]]></title>
            <link>https://velog.io/@dragonperson_kim/Umami-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
            <guid>https://velog.io/@dragonperson_kim/Umami-%EB%8F%84%EC%9E%85%EA%B8%B0</guid>
            <pubDate>Mon, 02 Feb 2026 09:33:09 GMT</pubDate>
            <description><![CDATA[<h1 id="umami란">Umami란?</h1>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/83cb57e5-4e14-426a-92dd-43a980ce8e06/image.png" alt=""></p>
<p><strong>Umami</strong>는 오픈소스로 제공되는 웹 분석(Analytics) 도구다. 웹사이트에 방문자 수, 페이지 조회 수, 유입 경로 같은 기본적인 트래픽 데이터를 수집해서 대시보드 형태로 보여준다.</p>
<p>상용 분석 서비스들과 달리 구조가 단순하고 가벼워서, 복잡한 마케팅 분석보다는 “내 사이트에 사람이 얼마나 오는지” 같은 기본 지표를 확인하는 데 잘 어울린다. 또한 개인정보 수집을 최소화하는 방향으로 설계되어 있어서, 개인 프로젝트나 작은 사이트에 부담 없이 붙이기 좋다는 점도 특징이다</p>
<hr>
<h1 id="why-umami">Why Umami?</h1>
<p>내 블로그에 DB 테이블을 따로 만들어 방문자 수를 집계할 수도 있었지만, 숫자만 쌓이는 방식보다는 직관적으로 얼마나 사람들이 들어오는지 한눈에 보고 싶었다.</p>
<p>여러 분석 도구들이 있었지만, 내가 중요하게 본 기준은 딱 두 가지였다.
무료일 것, 그리고 설정이 복잡하지 않을 것.</p>
<p>처음에는 <strong>Google Analytics</strong>도 함께 고려했다. 워낙 널리 쓰이는 서비스라 기능도 강력하고 참고할 자료도 많아서 좋아 보였다.</p>
<p>하지만 막상 적용하려고 보니 설정 과정이 생각보다 복잡하게 느껴졌다. 계정 구조나 속성 설정, 추적 코드 방식도 여러 단계로 나뉘어 있어서 가볍게 시작하기엔 조금 부담이 있었다. “일단 방문자 수만 확인하고 싶다”는 내 목적에 비해 기능이 너무 많다는 느낌도 들었다.</p>
<p>그래서 방향을 바꿔, 더 단순하고 바로 사용할 수 있는 도구를 찾게 됐고, 그때 선택한 게 <strong>Umami</strong>였다.</p>
<hr>
<h1 id="umami의-효능">Umami의 효능..</h1>
<p><strong>Umami</strong>는 기능이 많은 분석 도구는 아니지만, 개인 프로젝트에서 쓰기엔 딱 필요한 것들만 갖추고 있다.</p>
<h2 id="주요-기능">주요 기능</h2>
<h3 id="기본-트래픽-분석">기본 트래픽 분석</h3>
<ul>
<li><strong>페이지뷰(Pageviews)</strong> — 각 페이지의 조회 수 통계  </li>
<li><strong>유니크 방문자(Visitors)</strong> — 중복 제거된 방문 수 확인  </li>
<li><strong>이탈률(Bounce Rate)</strong>  </li>
<li><strong>평균 세션 시간(Session Duration)</strong>  </li>
<li><strong>유입 경로(Referrers)</strong> — 어디서 트래픽이 들어왔는지 확인  </li>
<li><strong>기기 / 브라우저 / 운영체제 / 국가</strong> 등 방문 환경 정보 확인 가능  </li>
</ul>
<hr>
<h2 id="커스텀-이벤트-추적-events">커스텀 이벤트 추적 (Events)</h2>
<p>Umami는 기본 pageview 외에도 <strong>사용자 정의 이벤트</strong>를 기록할 수 있다.</p>
<h3 id="html-속성-기반-이벤트">HTML 속성 기반 이벤트</h3>
<pre><code class="language-html">&lt;button data-umami-event=&quot;signup_click&quot;&gt;Sign up&lt;/button&gt;</code></pre>
<h3 id="javascript-기반-이벤트">JavaScript 기반 이벤트</h3>
<pre><code class="language-js">if (window.umami) {
  window.umami.track(&#39;post_view&#39;, {
    postId
  });
}</code></pre>
<p>커스텀 이벤트는 대시보드에서 이벤트별로 집계/조회 가능하며,<br>이벤트 속성(Properties)도 함께 확인할 수 있다.</p>
<hr>
<h2 id="자동-추적-vs-수동-추적">자동 추적 vs 수동 추적</h2>
<ul>
<li>기본 설정에서는 페이지 방문 시 <strong>자동으로 pageview가 수집됨</strong></li>
<li><code>data-auto-track=&quot;false&quot;</code> 옵션을 사용하면 자동 수집을 끄고<br><strong>원하는 시점에만 이벤트를 보내는 수동 트래킹 방식</strong> 사용 가능</li>
</ul>
<hr>
<h2 id="실시간--인터페이스">실시간 &amp; 인터페이스</h2>
<ul>
<li><strong>실시간 방문 현황(Realtime)</strong> 확인 가능  </li>
<li>복잡하지 않은 <strong>직관적인 대시보드 UI</strong> 제공  </li>
</ul>
<hr>
<h2 id="기타-특징">기타 특징</h2>
<ul>
<li><strong>경량 추적 스크립트</strong> — 사이트 성능에 부담이 적음  </li>
<li><strong>다중 사이트 지원</strong> — 하나의 Umami 인스턴스로 여러 사이트 분석 가능  </li>
<li><strong>프라이버시 친화적 설계</strong> — 개인정보 최소 수집, 쿠키 없이 동작  </li>
</ul>
<hr>
<h1 id="사용법">사용법</h1>
<p><a href="https://cloud.umami.is/login">https://cloud.umami.is/login</a> (Umami 사이트)
사용법은 무지무지 간단하다. 영알못인 나도 그냥 슉슉샥샥?으로 끝내버릴수 있었다.</p>
<ol>
<li><p>위에 링크를 타고 들어가서 Sign Up 을 하고 이메일 인증을 통해 계정을 만들어준다</p>
</li>
<li><p>그리고 이런식으로 단순하게 <strong>Add Website</strong> 버튼을 클릭하여 간단하게 폼을 입력해주고 save버튼을 클릭하여 사이트를 추가하면 된다 
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/423bd61b-7309-43ec-8acc-ce1685cb7d81/image.png" alt=""></p>
</li>
<li><p>만들어진 아이템을 클릭하여 우측상단에 Edit버튼을 클릭해준다. 그리고 WebSite ID를 복사해준다</p>
</li>
<li><p>아래 코드처럼 사용하고자하는 프로젝트에 넣어준다 (아래는 Next.js환경)</p>
</li>
</ol>
<pre><code class="language-js">//next.js
&lt;Script
 src=&quot;https://cloud.umami.is/script.js&quot;
      data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
      strategy=&quot;afterInteractive&quot; /&gt;</code></pre>
<ol start="5">
<li>끝 이제 마음껏 확인해보면 된다</li>
</ol>
<hr>
<h1 id="결과물">결과물</h1>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/32a1c2f7-b3fe-42cc-86c6-ea51d8d47491/image.png" alt="">
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/6c51c148-7c4b-4354-b978-95d65e6597ba/image.png" alt=""></p>
<p>위에 이미지처럼 통계를 확인할수 있다.</p>
<p>기본 스크립트만 사용할 경우, 사용자가 어떤 페이지를 방문하든 모두 자동으로 페이지뷰로 집계된다. 전체적인 방문자 수를 보는것도 좋지만 <strong>“어떤 글이 실제로 조회됐는지”</strong>를 알고 싶었다.</p>
<p>그래서 <strong>Umami</strong>의 Events 기능을 활용해 글 조회 시점에만 직접 이벤트를 보내도록 처리했다. 글 페이지가 렌더링된 뒤에 아래와 같이 커스텀 이벤트를 호출하는 방식이다.</p>
<pre><code class="language-js">if (window.umami) {
  window.umami.track(&quot;post_view&quot;, {
    postId,
    slug: postData?.data.slug,
  });
}
</code></pre>
<p>이렇게 하면 단순한 페이지 진입이 아니라, 실제로 특정 게시글이 열렸을 때만 위에 두번째 이미지처럼 post_view 이벤트가 기록된다.
또한 postId와 slug 같은 정보를 함께 넘겨서, 어떤 글이 얼마나 조회됐는지까지 구분해서 확인할 수 있도록 했다.</p>
<p>덕분에 단순 트래픽 수치가 아니라, 콘텐츠 단위의 조회 데이터를 볼 수 있게 되었고 어떤 글이 관심을 받는지도 파악할 수 있었다.</p>
<hr>
<h1 id="마무리">마무리</h1>
<p>처음에는 다른 블로그들처럼 그냥 따라 붙여본 느낌이었는데, 막상 적용하고 나니 생각보다 자주 들여다보게 된다. 숫자가 많고 적고를 떠나서, 누군가 실제로 글을 읽고 갔다는 흔적이 남는다는 게 은근한 동기부여가 됐다. 작은 기능이지만 프로젝트에 생동감을 더해준 요소였던 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[useDeferredValue 와 useTransition]]></title>
            <link>https://velog.io/@dragonperson_kim/useDeferredValue-%EC%99%80-useTransition</link>
            <guid>https://velog.io/@dragonperson_kim/useDeferredValue-%EC%99%80-useTransition</guid>
            <pubDate>Thu, 15 Jan 2026 02:16:22 GMT</pubDate>
            <description><![CDATA[<h1 id="자동완성-입력에서-생긴-문제">자동완성 입력에서 생긴 문제</h1>
<p>사이드 프로젝트로 블로그를 만들던중에 태그 자동완성 기능이 있었는데, 태그 자동완성을 붙이다보니 타이핑할 때마다 쿼리가 너무 많이 날라가 문제가 생겼다. </p>
<h2 id="1-기존-문제">1. 기존 문제</h2>
<p>컴포넌트인 TagInput은 컨트롤드 인풋이고, 입력이 바뀔 때마다 useQuery가 실행된다.</p>
<pre><code class="language-tsx">// 태그입력 자동완성 쿼리
const { data: tagData, isLoading } = useQuery&lt;
  QueryResponse&lt;{ body: string }[]&gt;
&gt;({
  queryKey: [&quot;tag&quot;, input],
  queryFn: async () =&gt; {
    const q = input.trim();
    if (q.length &lt; 2) return { data: [] };

    const res = await fetch(
      `/api/tag/search?q=${encodeURIComponent(q)}`
    );
    return res.json();
  },
  enabled: input.trim().length &gt;= 2,
});</code></pre>
<p><strong>문제점</strong></p>
<ul>
<li>input은 매 키 입력마다 즉시 변경</li>
<li>useQuery는 queryKey가 바뀌면 바로 실행</li>
<li>결과적으로 타이핑 속도 = API 호출 속도, 비효율적</li>
</ul>
<h2 id="2-usedeferredvalue-사용하여-해결">2. useDeferredValue 사용하여 해결</h2>
<pre><code class="language-tsx">//useDeferredValue 훅 사용
const deferredInput = useDeferredValue(input);

const { data: tagData, isLoading } = useQuery&lt;
  QueryResponse&lt;{ body: string }[]&gt;
&gt;({
  queryKey: [&quot;tag&quot;, deferredInput],
  queryFn: async () =&gt; {
    const q = deferredInput.trim();
    if (q.length &lt; 2) return { data: [] };

    const res = await fetch(
      `/api/tag/search?q=${encodeURIComponent(q)}`
    );
    return res.json();
  },
  enabled: deferredInput.trim().length &gt;= 2,
});</code></pre>
<p><strong>달라진 점</strong></p>
<ul>
<li>input은 그대로 즉시 업데이트</li>
<li>쿼리는 조금 늦은 값 기준으로만 실행</li>
<li>중간 입력 단계들이 자연스럽게 스킵됨 </li>
</ul>
<p>결과적으로 “입력 UX”와 “네트워크 요청”이 분리 되었다.</p>
<h1 id="usedeferredvalue">useDeferredValue?</h1>
<p>위에 코드처럼 useDeferredValue를 사용하여 해결하였는데, 어떻게 해결이 된것일까?</p>
<p><code>useDeferredValue</code>는 값을 즉시 바꾸는 훅이 아니고, 이미 변경된 값을 조금 늦게 소비할 수 있게 해주는 훅에 가깝다.
사용할땐 값을 래핑해서 사용한다.</p>
<pre><code class="language-js">const deferredInput = useDeferredValue(input);</code></pre>
<p>이 코드는 input을 지연시키는 게 아니라, input의 <strong>느린 버전(slower copy)</strong>을 하나 더 만들어 준다.</p>
<p>그래서 동작 방식은 이렇다. input은 타이핑과 동시에 바로 업데이트되고 deferredInput은 React가 판단한 여유 있는 시점에 따라온다. 이 덕분에 입력 UX는 그대로 유지하면서 무거운 렌더링이나 네트워크 요청만 늦출 수 있다.</p>
<p>자동완성 예제에서는 input은 즉시 화면에 반영하고, 쿼리는 <code>deferredInput</code> 기준으로만 실행하도록 분리하면서 타이핑 속도와 API 호출을 같은 타이밍으로 처리하지 않게 만들었다.</p>
<p>즉, <code>useDeferredValue</code>는 <strong>“이 값은 지금 당장 쓰지 않아도 된다”</strong>고 React에게 알려주는 역할을 한다.</p>
<h1 id="usetransition">useTransition?</h1>
<p><code>useDeferredValue</code>로 문제는 어느 정도 해결됐지만, React 18 관련 글들을 찾아보다 보니 비슷한 역할을 하는 훅이 하나 더 눈에 들어왔다.
바로 <code>useTransition</code>이다.</p>
<h2 id="1usetransition은-어떤-훅인가">1.useTransition은 어떤 훅인가?</h2>
<p><code>useTransition</code> 사용법</p>
<pre><code class="language-js">const [isPending, startTransition] = useTransition();</code></pre>
<p>그리고 상태를 감싸서 사용한다.</p>
<pre><code class="language-ts">startTransition(() =&gt; {
  setState(nextState);
});</code></pre>
<p>이 훅의 핵심은 <strong>이 상태 업데이트는 우선순위가 낮으니 당장 처리 하지마</strong> 이다
즉, <code>useDeferredValue</code>처럼 값을 하나 더 만들어서 쓰는 게 아니라,
업데이트 자체를 React에게 <strong>“천천히 처리해도 된다”</strong>고 알려주는 방식이다</p>
<pre><code class="language-js">const [input, setInput] = useState(&quot;&quot;);
const [keyword, setKeyword] = useState(&quot;&quot;);

const [isPending, startTransition] = useTransition();

&lt;input
  value={input}
  onChange={(e) =&gt; {
    const value = e.target.value;

    // 입력값은 즉시 반영
    setInput(value);

    // 자동완성 기준 값은 낮은 우선순위로 업데이트
    startTransition(() =&gt; {
      setKeyword(value);
    });
  }}
/&gt;
</code></pre>
<p>쿼리는 키워드로 실행</p>
<pre><code class="language-js">useQuery({
  queryKey: [&quot;tag&quot;, keyword],
  queryFn: () =&gt; fetchTag(keyword),
  enabled: keyword.trim().length &gt;= 2,
});
</code></pre>
<p>정리하자면</p>
<ul>
<li>input은 타이핑 즉시 반응</li>
<li>자동완성 기준 값은 천천히 따라옴</li>
<li>쿼리는 keyword가 바뀔 때만 실행</li>
</ul>
<h1 id="usedeferredvalue-vs-usetransition-비교">useDeferredValue vs useTransition 비교</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>useDeferredValue</th>
<th>useTransition</th>
</tr>
</thead>
<tbody><tr>
<td>접근 방식</td>
<td>값 소비를 지연</td>
<td>상태 업데이트를 지연</td>
</tr>
<tr>
<td>코드 위치</td>
<td>render 단계</td>
<td>이벤트 핸들러</td>
</tr>
<tr>
<td>제어권</td>
<td>React에게 맡김</td>
<td>개발자가 명시</td>
</tr>
<tr>
<td>상태 구조 변경</td>
<td>거의 없음</td>
<td>분리 필요</td>
</tr>
</tbody></table>
<p>결론 둘 다 “지연”은 맞지만,</p>
<ul>
<li><code>useDeferredValue</code>
값은 이미 바뀌었고, 그 값을 쓰는 렌더링만 늦춘다.</li>
<li><code>useTransition</code>
상태를 바꾸는 업데이트 자체를 낮은 우선순위로 늦춘다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React RSC 보안 이슈 정리와 개인적인 대응]]></title>
            <link>https://velog.io/@dragonperson_kim/React-RSC-%EB%B3%B4%EC%95%88-%EC%9D%B4%EC%8A%88-%EC%A0%95%EB%A6%AC%EC%99%80-%EA%B0%9C%EC%9D%B8%EC%A0%81%EC%9D%B8-%EB%8C%80%EC%9D%91</link>
            <guid>https://velog.io/@dragonperson_kim/React-RSC-%EB%B3%B4%EC%95%88-%EC%9D%B4%EC%8A%88-%EC%A0%95%EB%A6%AC%EC%99%80-%EA%B0%9C%EC%9D%B8%EC%A0%81%EC%9D%B8-%EB%8C%80%EC%9D%91</guid>
            <pubDate>Sun, 21 Dec 2025 02:49:51 GMT</pubDate>
            <description><![CDATA[<h1 id="vercel-경고-메시지에서-시작된-이야기">Vercel 경고 메시지에서 시작된 이야기</h1>
<p>최근 <strong>Vercel</strong>에 배포해 둔 웹사이트를 업데이트하던 중,
대시보드에서 다음과 같은 경고 메시지를 확인했다.</p>
<blockquote>
<p>Error: Vulnerable version of Next.js detected, please update immediately
Learn More: <a href="https://vercel.link/CVE-2025-66478">https://vercel.link/CVE-2025-66478</a></p>
</blockquote>
<p>이 메시지는 단순한 업데이트 권장 안내가 아니라, 
특정 보안 취약점(CVE)과 직접적으로 연결된 경고였다.</p>
<p>Vercel은 해당 프로젝트에서 사용 중인 <strong>Next.js</strong> 버전에 보안 문제가 존재함을 감지했고,
이를 즉시 업데이트해야 하는 <strong>Critical 수준의 이슈</strong>로 분류했다.</p>
<hr>
<h1 id="무슨-일이-있었던-걸까">무슨 일이 있었던 걸까?</h1>
<h2 id="문제가-된-대상">문제가 된 대상</h2>
<p>이번 경고는 <strong>CVE-2025-66478</strong>로 식별되는<br>Next.js 관련 보안 취약점과 연관되어 있다.</p>
<p>공식 안내에 따르면,<br>이 취약점은 <strong>Next.js의 App Router 환경</strong>에서 동작하는 기능과 관련되어 있으며,<br>일부 경우 <strong>Server Components(RSC)</strong> 와 연관된 동작이 영향을 받을 수 있는 것으로 알려졌다.</p>
<p>Server Components는 Next.js App Router의 핵심 기능으로,</p>
<ul>
<li>서버에서만 실행되는 코드 작성</li>
<li>데이터베이스 접근</li>
<li>내부 API 호출</li>
<li>서버 환경에서만 접근 가능한 값 처리</li>
</ul>
<p>등을 가능하게 하여, <strong>서버 전용 로직이 클라이언트로 전달되지 않도록 설계된 구조</strong>를 가진다.</p>
<hr>
<h2 id="cve-2025-66478-요약-확인된-사실-기준">CVE-2025-66478 요약 (확인된 사실 기준)</h2>
<ul>
<li><strong>취약점 식별자</strong>: CVE-2025-66478  </li>
<li><strong>위험도</strong>: Critical (Vercel 기준)  </li>
<li><strong>영향 대상</strong>: 특정 버전의 Next.js  </li>
<li><strong>조치 방법</strong>: 보안 패치가 포함된 버전으로 업데이트 필요  </li>
</ul>
<p>세부적인 공격 조건이나 내부 동작 방식은<br>공식 보안 공지를 통해 요약 형태로 안내되었으며,<br>Vercel은 해당 취약점이 <strong>실제 배포 환경에서 문제가 될 수 있다고 판단</strong>해  
대시보드 경고를 표시했다.</p>
<p>이번 Vercel 경고 메시지는 단순한 권고 수준의 알림이 아니라,</p>
<ul>
<li>CVE가 공식적으로 할당된 보안 취약점이며</li>
<li>Vercel 기준 <strong>Critical</strong>로 분류되었고</li>
<li>영향을 받는 Next.js 버전을 사용하는 경우</li>
<li><strong>즉시 업데이트가 권장되는 이슈</strong></li>
</ul>
<p>임을 알리는 안내였다.
따라서 해당 경고를 확인했다면, 추가 분석 여부와 관계없이<br><strong>보안 패치가 포함된 Next.js 버전으로 업데이트하는 것이 필수적</strong>이다.</p>
<hr>
<h1 id="react-공식-발표로-확인된-사실-정리">React 공식 발표로 확인된 사실 정리</h1>
<p>React 팀은 공식 블로그를 통해<br><strong>React Server Components(RSC)와 관련된 치명적인 보안 취약점</strong>이 발견되었음을 공개했다.</p>
<p>공식 공지:<br><a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components</a></p>
<p>해당 취약점은 <strong>CVE-2025-55182</strong>로 등록되었으며,<br>보안 등급은 <strong>CVSS 10.0 (Critical)</strong> 으로 평가되었다.</p>
<p>React 팀은 이 이슈에 대해   <strong>즉각적인 조치가 필요한 보안 취약점</strong>임을 명확히 밝혔다.</p>
<hr>
<h2 id="react는-어떻게-문제를-인식했는가">React는 어떻게 문제를 인식했는가</h2>
<p>React 공식 설명에 따르면,<br>문제는 <strong>React Server Components 요청을 처리하는 과정</strong>에서 확인되었다.</p>
<p>React Server Components는 서버에서 컴포넌트를 실행한 뒤,<br>그 결과를 직렬화된 형태로 클라이언트에 전달한다.</p>
<p>React 팀은 이 과정에서 다음과 같은 위험 가능성을 확인했다고 설명한다.</p>
<ul>
<li>클라이언트로부터 전달되는 Server Components 요청 데이터가</li>
<li>악의적으로 조작될 경우</li>
<li>서버에서 처리되는 실행 흐름에 영향을 줄 수 있는 가능성</li>
</ul>
<p><strong>서버가 처리하는 일부 입력 데이터가 충분히 검증되지 않은 상태로 역직렬화되고 있었다는 점</strong>이 이번 취약점의 핵심 원인으로 지목되었다.</p>
<p>React는 공식 공지를 통해 이와 같은 입력 처리 방식이 <strong>서버 실행 환경에서는 허용되어서는 안 되는 보안 위험</strong>이라고 설명했다.</p>
<hr>
<h2 id="react가-공식적으로-밝힌-위험성">React가 공식적으로 밝힌 위험성</h2>
<p>React 팀은 이 취약점으로 인해<br>다음과 같은 보안 문제가 발생할 수 있음을 공식적으로 언급했다.</p>
<ul>
<li>인증 없이 서버로 조작된 요청을 전송할 수 있음</li>
<li>React Server Components 처리 로직이 이를 정상 요청으로 처리할 수 있음</li>
<li>서버 내부에서 의도하지 않은 코드 경로가 실행될 가능성 존재</li>
<li>그 결과 <strong>Remote Code Execution(RCE)</strong> 로 이어질 수 있음</li>
</ul>
<p>React는 이 취약점이 다음과 같은 특징을 가진다고 명시했다.</p>
<ul>
<li>개발자의 설정 실수나 커스텀 코드가 없어도 발생할 수 있음</li>
<li><strong>React Server Components를 서버에서 사용하는 경우</strong> 영향을 받을 수 있음</li>
</ul>
<p>특정 프레임워크나 특정 구현 방식의 문제가 아니라,<br><strong>Server Components를 서버에서 실행하는 구조 자체가 가지는 보안 취약점</strong>이라는 점을 공식적으로 인정한 것이다.</p>
<hr>
<h2 id="react-팀은-이-문제를-어떻게-해결했는가">React 팀은 이 문제를 어떻게 해결했는가</h2>
<p>React 팀은 이 문제를 단순한 핫픽스로 처리하지 않고,<br><strong>Server Components 처리 방식 자체를 보수적으로 수정하는 방향</strong>으로 대응했다.
공식 공지에서 언급된 주요 대응 방향은 다음과 같다.</p>
<h3 id="1-server-components-요청-검증-강화">1. Server Components 요청 검증 강화</h3>
<ul>
<li>서버에서 처리되는 Server Components 요청 payload에 대해
<strong>더 엄격한 구조 검증을 수행</strong></li>
<li>예상되지 않은 형태의 데이터가 포함된 요청은 처리하지 않도록 변경</li>
<li>클라이언트로부터 전달되는 데이터를 기본적으로 신뢰하지 않는 방식으로 수정</li>
</ul>
<h3 id="2-위험한-역직렬화-및-실행-경로-제한">2. 위험한 역직렬화 및 실행 경로 제한</h3>
<ul>
<li>역직렬화 과정에서
서버 내부 객체나 실행 가능한 참조에 접근할 수 있었던 경로를 제한</li>
<li>서버에서 실행 가능한 코드 경로를 명시적으로 축소</li>
<li>외부 입력이 서버 실행 흐름에 영향을 줄 수 있는 가능성을 차단</li>
</ul>
<h3 id="3-보안-릴리즈-배포-및-업데이트-권고">3. 보안 릴리즈 배포 및 업데이트 권고</h3>
<p>React 팀은 위 수정 사항을 포함한<br><strong>보안 릴리즈(Security Release)</strong> 를 배포하고,</p>
<ul>
<li>React Server Components를 사용하는 모든 프로젝트에 대해</li>
<li><strong>즉각적인 버전 업데이트를 강하게 권고</strong>했다.</li>
</ul>
<p>또한, 
초기 패치 이후에도 연관된 추가 취약점(서비스 거부, 정보 노출 가능성 등)이 확인되었으며,<br>이에 대해서도 React 팀은 공식적으로 추가 보안 패치를 배포했다.</p>
<hr>
<h2 id="요약">요약</h2>
<p>React 공식 발표를 기준으로 정리하면 다음과 같다.</p>
<ul>
<li>이번 이슈는 React Server Components와 관련된 <strong>공식 보안 취약점</strong>이다</li>
<li>서버 컴포넌트 요청 처리 과정에서 <strong>입력 검증이 충분하지 않았던 구조적 문제</strong>가 원인이었다</li>
<li>이로 인해 인증 없이 <strong>Remote Code Execution(RCE)</strong> 이 발생할 가능성이 존재했다</li>
<li>React 팀은 처리 모델 자체를 수정하는 방식으로 대응했다</li>
<li>현재는 보안 패치가 반영된 버전으로의 업데이트가 필수적인 상태다</li>
</ul>
<p>이번 사례는 React Server Components가 제공하는 편의성과 함께<br><strong>서버 보안에 대한 책임 역시 개발자와 프레임워크가 함께 가져가야 한다는 점</strong>을  
명확하게 보여준 사건이라고 볼 수 있다.</p>
<hr>
<h1 id="그래서-나는">그래서 나는?</h1>
<p>이번 이슈를 확인한 뒤,<br>가장 먼저 한 일은 <strong>Vercel에서 권고한 대로 React와 Next.js를 최신 버전으로 업데이트</strong>하는 것이었다.</p>
<p>내가 운영 중이던 프로젝트는 외부 입력을 직접 처리하는 서버 로직이 거의 없었고
인증, 결제와 같은 민감한 기능도 포함되어 있지 않았기 때문에</p>
<p>실제 취약점이 악용될 가능성은 높지 않은 상태였다.<br>업데이트 이후에도 로그나 동작에서 특이사항은 발견되지 않았다.</p>
<hr>
<h2 id="이번-이슈를-통해-느낀-점">이번 이슈를 통해 느낀 점</h2>
<p>이번 React 보안 이슈를 통해 분명하게 느낀 점은 하나였다.</p>
<blockquote>
<p><strong>Server Components는 UI 코드가 아니라<br>서버 코드라는 인식이 필요하다는 것</strong></p>
</blockquote>
<p>React Server Components는 분명 강력하다.<br>서버에서 바로 데이터를 다룰 수 있고,<br>클라이언트 번들 크기를 줄일 수 있다는 점은 큰 장점이다.</p>
<p>하지만 이번 사례를 통해 깨달은 것은:</p>
<ul>
<li>서버에서 실행되는 로직은 결국 <strong>백엔드 코드와 동일한 보안 기준</strong>이 필요하다는 점</li>
<li>프레임워크가 제공하는 편의성에만 의존하면<br><strong>입력 처리나 실행 경로에 대한 경계가 느슨해질 수 있다는 점</strong></li>
<li>특히 민감한 로직일수록<br>Server Components 안에 무심코 넣는 것은 위험할 수 있다는 점이었다</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 이슈는 “React에 보안 문제가 있었다”로 끝낼 일이 아니라,
Server Components라는 새로운 실행 모델이개발자에게 어떤 책임을 요구하는지를 다시 생각하게 만든 사건이었다.</p>
<p>앞으로도 Server Components를 사용할 생각이지만,<br><strong>민감한 로직일수록 더 보수적으로 설계하고, 프레임워크의 안전함을 당연하게 가정하지는 않으려 한다.</strong></p>
<p>이번 React 보안 이슈는 그 기준을 다시 세우게 만든 계기였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[슬라이딩 윈도우(Sliding Window) 알고리즘]]></title>
            <link>https://velog.io/@dragonperson_kim/%EC%8A%AC%EB%9D%BC%EC%9D%B4%EB%94%A9-%EC%9C%88%EB%8F%84%EC%9A%B0%EB%9E%80</link>
            <guid>https://velog.io/@dragonperson_kim/%EC%8A%AC%EB%9D%BC%EC%9D%B4%EB%94%A9-%EC%9C%88%EB%8F%84%EC%9A%B0%EB%9E%80</guid>
            <pubDate>Fri, 12 Dec 2025 01:09:59 GMT</pubDate>
            <description><![CDATA[<h1 id="시작하기전에">시작하기전에</h1>
<p>알고리즘 문제를 풀다 보면 <strong>슬라이딩 윈도우(Sliding Window</strong>) 라는 표현을 정말 자주 접하게 된다.
특히 연속된 구간을 다루는 문제에서 압도적으로 자주 등장하는 핵심 테크닉이며, 실제 코딩 테스트에서도 빈출되는 알고리즘이다.</p>
<p>이번 글에서는 슬라이딩 윈도우의 기본 개념부터, 어떤 상황에서 활용되는지, 그리고 대표적인 문제 패턴까지 코드 예시와 함께 정리해보려고 한다.</p>
<h1 id="슬라이딩-윈도우란">슬라이딩 윈도우란?</h1>
<h2 id="1-개념">1. 개념</h2>
<p>슬라이딩 윈도우(Sliding Window)는 말 그대로** 창문(window)을** <strong>좌우로 밀면서(slide)</strong> 탐색하는 기법이다.</p>
<p>보통 아래 두 가지 경우에 사용된다</p>
<ul>
<li><p>부분 배열의 길이가 고정된 경우 (Fixed Window)</p>
</li>
<li><p>조건을 만족하는 가장 긴(혹은 가장 짧은) 구간을 찾아야 하는 경우 (Dynamic Window)</p>
</li>
</ul>
<p>핵심 아이디어는 간단하다:</p>
<p>전체 배열을 매번 새로 계산하지 않고,
윈도우의 양 끝을 조절하며 “증가된 부분만 더하고, 빠진 부분만 빼서” 효율적으로 계산한다.</p>
<p>이 방식으로 많은 문제를 O(N) 으로 해결할 수 있다.</p>
<h2 id="2-왜-슬라이딩-윈도우를-쓰는가">2. 왜 슬라이딩 윈도우를 쓰는가?</h2>
<ul>
<li><p>나이브 방식
각 구간을 매번 일일이 계산 → O(N²)</p>
</li>
<li><p>슬라이딩 윈도우
앞/뒤 포인터만 이동 → O(N) 중복된 계산을 줄임
즉, <strong>구간 문제의 시간 복잡도를 한 단계 확 줄여주는 기술</strong>이다.</p>
</li>
</ul>
<h1 id="예제로-살펴보기">예제로 살펴보기</h1>
<h2 id="1-고정-길이-윈도우-fixed-window">1. 고정 길이 윈도우 (Fixed Window)</h2>
<p>문제) “길이가 k인 subarray 중 가장 큰 합을 구하라.”</p>
<p>윈도우 크기가 고정되어 있으므로
오른쪽 값 추가,왼쪽 값 제거 만 반복하면 된다.</p>
<pre><code class="language-js">function maxSum(nums, k) {
  // 1. 먼저 길이가 k인 초기 윈도우의 합을 만든다.
  let sum = 0;
  for (let i = 0; i &lt; k; i++) sum += nums[i];

  // 초기 윈도우의 합이 현재까지의 최대값
  let answer = sum;

  // 2. 윈도우를 오른쪽으로 한 칸씩 밀면서(update) 합을 갱신한다.
  for (let i = k; i &lt; nums.length; i++) {
    sum += nums[i];        // 새로 들어온 값 추가
    sum -= nums[i - k];    // 왼쪽에서 빠져나가는 값 제거

    // 각 단계에서 최대 합을 갱신
    answer = Math.max(answer, sum);
  }

  // 3. 길이 k인 모든 연속 구간 중 최댓값 반환
  return answer;
}

</code></pre>
<h2 id="2-가변-길이-윈도우-dynamic-window">2. 가변 길이 윈도우 (Dynamic Window)</h2>
<p>문제) “합이 target 이하가 되는 가장 긴 subarray 길이를 구하라.”</p>
<p>윈도우 길이가 조건에 따라 늘었다 줄었다 하며 탐색한다.</p>
<pre><code class="language-js">
function longestSubarray(nums, target) {
  let left = 0;      // 윈도우의 시작 포인터
  let sum = 0;       // 현재 윈도우의 합
  let answer = 0;    // 조건을 만족하는 가장 긴 구간 길이

  // right 포인터를 오른쪽으로 확장하며 윈도우를 키운다.
  for (let right = 0; right &lt; nums.length; right++) {
    sum += nums[right];  // 새로 포함된 값 더하기

    // 현재 합이 target을 초과하면 조건을 만족할 때까지 left를 이동(윈도우 축소)
    while (sum &gt; target) {
      sum -= nums[left];  // 빠져나가는 값 제거
      left++;             // 윈도우 시작점 이동
    }

    // 여기 도달했을 때 sum &lt;= target을 만족하는 상태
    // 현재 윈도우 크기(right - left + 1)를 기준으로 최대 길이 갱신
    answer = Math.max(answer, right - left + 1);
  }

  return answer;
}

</code></pre>
<h2 id="3-빈도-기반-슬라이딩-윈도우">3. 빈도 기반 슬라이딩 윈도우</h2>
<p>문제) “문자열 s에서 모든 문자가 최소 k번 등장하는 가장 긴 구간”</p>
<p>문자열 문제에서 자주 등장한다. 이 경우에는 윈도우 내부의 frequency map , count
distinct number 등을 관리하며 이동한다.</p>
<pre><code class="language-js">
function countValidSubstrings(s, k) {
  let left = 0;          // 윈도우 시작 포인터
  let count = 0;         // 조건을 만족하는 substring 개수
  let freq = new Map();  // 윈도우 내부에서 문자 등장 횟수를 저장하는 Map

  // right 포인터를 확장하며 윈도우를 키움
  for (let right = 0; right &lt; s.length; right++) {
    // 새로 들어온 문자 빈도 증가
    freq.set(s[right], (freq.get(s[right]) || 0) + 1);

    // 윈도우 내에서 서로 다른 문자 수가 k를 초과하면 조건 만족할 때까지 축소
    while (freq.size &gt; k) {
      freq.set(s[left], freq.get(s[left]) - 1);  // 빠지는 문자 count 감소

      // 0이 되면 Map에서 제거 → distinct 문자 수 감소
      if (freq.get(s[left]) === 0) freq.delete(s[left]);

      left++;  // 윈도우 축소
    }

    // 현재 right 기준으로 가능한 모든 유효한 substring 개수를 더함
    // (left ~ right 구간 전체가 조건을 만족하기 때문)
    count += right - left + 1;
  }

  return count;
}

</code></pre>
<h1 id="슬라이딩-윈도우를-언제-떠올려야-하는가">슬라이딩 윈도우를 언제 떠올려야 하는가?</h1>
<p>다음 문구가 보이면 슬라이딩 윈도우 문제일 확률이 높다
(특히 연속이면서 ‘효율적으로’ 계산해야 한다 → 바로 이 기법이 떠올라야 함)</p>
<ul>
<li><p>“연속된 구간”</p>
</li>
<li><p>“부분 배열(subarray)”</p>
</li>
<li><p>“길이가 l ~ r”</p>
</li>
<li><p>“최대/최소 합”</p>
</li>
<li><p>“조건을 만족하는 가장 긴 문자열”</p>
</li>
<li><p>“0과 1의 개수 제한”</p>
</li>
</ul>
<blockquote>
</blockquote>
<p>슬라이딩 윈도우의 본질은
<strong>“중복 계산을 피하고, 진짜로 변하는 부분만 업데이트한다”</strong>
라는 발상이다.</p>
<h1 id="마무리">마무리</h1>
<p><strong>슬라이딩 윈도우</strong>는 실제 코딩 테스트에서도 나 역시 여러 번 마주쳤던 알고리즘이다. 솔직히 많이 틀리기도 했고, 그래서 LeetCode 문제들을 풀면서 개념을 다시 차근차근 정리해보게 되었다.</p>
<p>아직 알고리즘은 어렵고 답답하게 느껴질 때가 많지만, 꾸준히 풀다 보면 분명 나아질 거라 믿는다. 그 과정의 일부로 이렇게 정리하며 포스팅하는 습관도 계속 이어가 보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 블로그 프로젝트를 돌아보며 (1)]]></title>
            <link>https://velog.io/@dragonperson_kim/%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%9D%B8-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%A0%9C%EC%9E%91-%ED%9A%8C%EA%B3%A0%EB%A1%9D1</link>
            <guid>https://velog.io/@dragonperson_kim/%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%9D%B8-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%A0%9C%EC%9E%91-%ED%9A%8C%EA%B3%A0%EB%A1%9D1</guid>
            <pubDate>Mon, 08 Dec 2025 05:03:06 GMT</pubDate>
            <description><![CDATA[<h1 id="시작하기까지의-이야기">시작하기까지의 이야기</h1>
<h2 id="1-이유">1. 이유</h2>
<p>처음에는 블로그 자체에 큰 관심이 없었고, 회사 일과 사이드 프로젝트를 함께 진행하는 것도 솔직히 꽤 귀찮게 느껴졌다.
그러다 이직 준비를 하면서 여러 채용 공고를 살펴보니, 잘 정리된 기술 블로그가 생각보다 큰 가산점으로 작용한다는 걸 깨닫게 되었고 그때부터 본격적으로 블로그를 시작해보기로 했다.</p>
<p>처음에는 검증된 플랫폼인 Velog에 글을 쓰는 것도 충분히 괜찮은 선택이라고 생각했다.
하지만 <strong>React</strong>와 <strong>Next.js</strong>를 제대로 공부해보고 싶은 마음이 있었고, 어차피 시작할 거라면 내가 직접 사용하는 블로그를 하나 만들어보는 것도 의미 있을 것 같아 결국 개인 블로그를 직접 개발하게 되었다.</p>
<h2 id="2-why-next-react">2. Why Next, React??</h2>
<p>사실 Next.js랑 React를 선택한 건 엄청 깊은 이유가 있어서라기보단,
예전에 이 둘로 사이드 프로젝트를 두 번 정도 만들어본 적이 있어서였다.
완전히 새로운 기술을 파기엔 귀찮고, 그렇다고 아무 의미 없이 Velog에만 글을 쓰기엔 좀 아쉬웠다.
그래서 “익숙한 거 한 번 더 잘 만들어볼까?” 하는 느낌으로 자연스럽게 손이 갔다.</p>
<p>마침 <strong>React Query</strong>나 <strong>TypeScript</strong>도 다시 감을 잡고 싶었고,
최근에 App Router나 서버 컴포넌트 구조도 많이 바뀌어서
“이번엔 제대로 써보자”라는 생각도 있었다.</p>
<p>Next는 라우팅부터 렌더링 방식까지 알아서 챙겨주는 게 많아서 편했고,
React는 어차피 프론트엔드를 하면 계속 마주칠 친구라
다시 한 번 제대로 다뤄보는 게 나쁘지 않겠다는 마음이었다.</p>
<p>결론은
<strong>익숙해서 좋았고, 다시 배우기에도 좋았고, 실제로 만들어두면 써먹을 데가 많아서</strong>
자연스럽게 React + Next.js 조합으로 가게 되었다.</p>
<h1 id="어떻게-만들것인가">어떻게 만들것인가?</h1>
<p>시작할 때 마음만큼은 링에 오르기 전의 복서처럼 뜨거웠다.
그런데 막상 퇴근하고 컴퓨터 앞에 앉으니 갑자기 갑갑해지기 시작했다.
그 흔한 <code>npx create-next-app@latest</code> 한 줄 치는 것도 괜히 귀찮아지고,
본격적으로 코드를 쓰려니 의욕이 묘하게 사라졌다.</p>
<p>그래서 처음부터 코딩을 밀어붙이기보다는,
일단 참고할 만한 블로그 디자인을 찾아보는 쪽으로 방향을 틀었다.
“어떤 느낌으로 만들지” 감을 잡는 게 먼저일 것 같았다.</p>
<h2 id="1-uiux">1. UI/UX</h2>
<h3 id="참고-할만한-화면-리스트">참고 할만한 화면 리스트</h3>
<ul>
<li><p>티스토리
→ 구글링할 때 가장 자주 보던 블로그 플랫폼이고, <a href="https://inpa.tistory.com/">Inpa님 블로그</a>도 예전부터 참고해왔어서 “이 느낌으로 갈까?”라는 생각이 들었다.
근데 내가 원하는 무난하면서 깔끔한 디자인과는 조금 다른 느낌이라 최종적으로는 보류했다.</p>
</li>
<li><p>내가 창작해서 해보기
→ 예전에 Next 13 버전으로 블로그를 만든 적이 있었는데, 그때는 여기저기서 디자인을 가져와 섞어보고 내 스타일대로 꾸며보겠다고 욕심을 부리다가 결국 완전히 난잡해져서 망해버린 경험이 있다.
그 기억이 떠오르다 보니 이번에는 과감하게 “내가 꾸미기”는 보류하기로 했다.</p>
</li>
<li><p><em>차라리 검증된 디자인을 그대로 참고해서 만드는 게 훨씬 안전하다. (그냥 카피하는게 게 최고…)*</em></p>
</li>
<li><p>velog
→ 사실 디자인은 거의 Velog로 확정해두고 있었다.
하루 정도 이것저것 찾아보며 비교해봤지만, 결국 다시 Velog로 마음이 기울었다.
다크모드를 기본으로 지원하고, 전체적으로 깔끔하고 심플한 분위기가 딱 내 취향이었다.
물론 전부 그대로 가져오는 건 재미도 없고 의미도 없으니,</p>
</li>
<li><p><em>Velog 느낌을 기본으로 하되 필요한 부분만 적당히 가져오는*</em> 정도로 방향을 잡았다.</p>
</li>
</ul>
<h3 id="완성된-화면">완성된 화면</h3>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/b5ca98ce-8448-43de-84fd-9f1f356e7a0a/image.png" alt="">
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/3b315efa-edf3-4d24-a19d-f7f0bcaebb06/image.png" alt="">
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/83a1fc47-e890-480f-b901-6178743d572d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/ab3fb351-857c-4b53-a09c-2b6678546e9a/image.png" alt="">
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/3f5bfbbf-286e-4cbb-b069-4fb26e1bad4a/image.png" alt=""></p>
<p>적당히 참고하려 했지만, 결과적으로는 꽤 많이 비슷해졌다.
Velog 메인 화면을 모티브로 <strong>게시글 리스트 페이지</strong>를 구현했고,
<strong>프로필 카드와 설정 화면</strong>은 Velog의 프로필 UI를 참고해 제작했다.
<strong>Post 상세 페이지</strong>는 전체적인 구성은 유지하되,
일기장 형식으로 사용할 예정이라 댓글 기능은 제외했고
대신 별도로 방명록 페이지를 만들었다.</p>
<h2 id="2-skill">2. SKill</h2>
<p>UI/UX는 방향은 정리했다. 그렇다면 이제 이걸 무슨 기술로, 어떤 구조로 만들지 선택할 차례였다. 앞서 간단히 언급했듯이 프론트엔드는** React + Next.js** 조합으로 진행하기로 했는데, 여기서 한 번 더 차근차근 정리해보려고 한다.</p>
<h3 id="프론트엔드">프론트엔드</h3>
<ul>
<li><p>Next.js (App Router, Server Components)
→ SSR/SSG/ISR을 유연하게 활용할 수 있어 SEO와 초기 렌더링에서 이점이 있다.</p>
</li>
<li><p>React
→ 컴포넌트 기반 UI 구성에 익숙하고, 재사용성과 확장성이 뛰어나다.</p>
</li>
<li><p>React Query
→ 서버 상태 관리에 최적화되어 있고, 캐싱/Prefetch 등으로 UX를 크게 개선해 준다.</p>
</li>
<li><p>TypeScript
→ 타입 안정성을 통해 런타임 오류를 줄이고, 유지보수성을 확보할 수 있다.</p>
</li>
<li><p>Tailwind.css
→ 빠른 스타일링과 일관된 디자인 시스템 구축에 유리해, 컴포넌트 단위 개발 생산성을 크게 높여주었다.</p>
</li>
</ul>
<h3 id="백엔드--인프라">백엔드 &amp; 인프라</h3>
<p>프론트엔드 방향을 정했다면, 이제 이걸 어디에 어떻게 저장하고, 어디서 어떻게 띄울지도 고민해야 했다.<br>새로운 기술로 하는것도 괜찮지만 빠른 배포를 위해 자주 사용했던 <strong>Prisma + MongoDB + Vercel</strong> 조합으로 가기로 했다.</p>
<ul>
<li><p>Prisma
→ JS/TS 환경에서 쓰기 좋은 ORM이라, 스키마 기반으로 데이터 구조를 정의하기 편했다.<br>타입 지원도 잘 되어 있어서, TypeScript와 함께 쓸 때 개발 경험이 꽤 좋았다.</p>
</li>
<li><p>MongoDB Atlas
→ 블로그 정도 규모에서는 복잡한 트랜잭션이 많이 필요하지 않아서,<br>문서 기반으로 빠르게 개발할 수 있는 MongoDB를 선택했다.<br>Atlas를 쓰면 인프라를 직접 관리할 필요 없이 바로 클라우드에서 연결해 쓸 수 있다는 점도 컸다. <del>(그리고 무료플랜이 있다.)</del></p>
</li>
<li><p>Vercel
→ Next.js와 가장 궁합이 잘 맞는 배포 플랫폼이라, 별 고민 없이 선택했다.<br>Git에 푸시하면 자동으로 빌드/배포가 돌아가고,<br>프리뷰 환경도 쉽게 만들어져서 사이드 프로젝트에 특히 잘 맞았다.</p>
</li>
</ul>
<h1 id="데이터는-어떻게">데이터는 어떻게?</h1>
<p>UI도 정했고, 어떤 기술을 사용할지도 결정했다.
이제 남은 건 무엇을 저장하고 어떻게 다룰 것인지였다.</p>
<p>블로그의 핵심 데이터는 당연히 <strong>글(Post)</strong> 이다.
그리고 글과 함께 <strong>이미지(Image)</strong> 를 관리해야 했고,
글을 <strong>태그(Tag)</strong> 로 분류할 수 있도록 구조를 잡을 필요가 있었다.</p>
<p>또한 이 블로그는 개인 블로그이기 때문에
게시글 작성자 정보는 <strong>단일 프로필(Profile)</strong> 로 관리하고,
방문자들이 댓글을 남길 방명록(Comment) 기능을 따로 두기로 했다.</p>
<p>결국 이 네 가지 모델을 중심으로 전체 데이터 구조를 설계하게 되었다.</p>
<h3 id="1-post--글-데이터">1. Post — 글 데이터</h3>
<p>블로그의 핵심.
제목/내용 외에도 썸네일, 미리보기, 임시저장 여부, slug URL 등을 포함했다.</p>
<pre><code class="language-js">model Post {
  id        String   @id @default(auto()) @map(&quot;_id&quot;) @db.ObjectId
  title     String
  content   String
  preview   String?
  thumbnail String?
  slug      String   @unique
  tagIds    String[] @db.ObjectId
  tag       Tag[]    @relation(fields: [tagIds], references: [id])
  isTemp    Boolean  @default(false)
  images    Image[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}</code></pre>
<ul>
<li>slug를 unique로 설정해 SEO 및 라우팅 최적화</li>
<li>isTemp 필드로 임시저장 기능 지원 (발행하지 않은 상태)</li>
<li>태그와 N:M 관계</li>
</ul>
<h3 id="2-image--게시글-이미지">2. Image — 게시글 이미지</h3>
<p>이미지 관리 로직을 유연하게 가져가기 위해
<code>Post</code> 모델에서 분리해 <code>Image</code> 모델로 따로 관리했다.
외부 이미지 저장소 연동을 고려했고,
글 삭제 시 이미지 정리 흐름을 명확히 할 필요가 있었다.
또한 썸네일과 일반 이미지를 구분해 다룰 수 있도록
별도 엔티티 구성으로 확장성을 확보했다.</p>
<pre><code class="language-js">model Image {
  id        String   @id @default(auto()) @map(&quot;_id&quot;) @db.ObjectId
  imageId   String   @unique //클라우드 플레어 이미지 Id
  postId    String?  @db.ObjectId
  post      Post?    @relation(fields: [postId], references: [id], onDelete: Cascade)
  isThumb   Boolean  @default(false) //썸네일용 플래그
  createdAt DateTime @default(now())
}</code></pre>
<ul>
<li><code>onDelete: Cascade</code><strong>를 통해 글 삭제 시 이미지도 자동 정리</strong></li>
</ul>
<h3 id="3-tag--탐색--분류를-위한-구조">3. Tag — 탐색 &amp; 분류를 위한 구조</h3>
<p>태그는 게시글의 탐색성을 높여주는 요소라
별도의 엔티티로 두고 Post와 N:M 관계로 구성했다.</p>
<pre><code class="language-js">model Tag {
  id        String   @id @default(auto()) @map(&quot;_id&quot;) @db.ObjectId
  body      String   @unique
  isTemp    Boolean  @default(false)
  posts     Post[]   @relation(fields: [postIds], references: [id])
  postIds   String[] @db.ObjectId
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
</code></pre>
<ul>
<li>아직 발행되지 않은 임시 태그는 isTemp 로 구분
→ 임시 글 단계의 태그는 실제로 사용될지 불확실하다.
그런데 이를 바로 저장해버리면 태그 목록 조회 시
사용되지 않는 태그까지 함께 불러오는 문제가 생긴다.
이를 방지하기 위해 <code>isTemp</code> 플래그를 도입해
임시 태그를 분리 관리하도록 했다.</li>
</ul>
<h3 id="4-profile--블로거-프로필">4. Profile — 블로거 프로필</h3>
<p>혼자만 쓰는 구조라 복잡하게 모델링 하지 않았고
SNS 링크나 자기소개를 담아 포트폴리오 역할도 고려했다</p>
<pre><code class="language-js">model Profile {
  id         String  @id @default(auto()) @map(&quot;_id&quot;) @db.ObjectId
  title      String
  content    String
  introduce  String
  profileImg String?
  github     String?
  notion     String?
  instagram  String?
}</code></pre>
<h3 id="5-comment--댓글-대신-방명록">5. Comment — 댓글 대신 방명록</h3>
<p>블로그 콘셉트가 일기장이기 때문에
게시글 자체의 댓글은 제거하고,
대신 별도 페이지에서 방명록을 작성하도록 했다.</p>
<pre><code class="language-js">model Comment {
  id        String   @id @default(auto()) @map(&quot;_id&quot;) @db.ObjectId
  name      String
  content   String
  isMe      Boolean  @default(false)
  createdAt DateTime @default(now())
}</code></pre>
<ul>
<li>isMe 플래그를 통한 작성자 방문자 구문</li>
</ul>
<h3 id="6-모델링-요약">6. 모델링 요약</h3>
<blockquote>
<p> Post (N) ─── (N) Tag
 Post (1) ─── (N) Image
 Comment ─── Independent
 Profile ─── Independent</p>
</blockquote>
<h1 id="라우팅-구조는-어떻게">라우팅 구조는 어떻게?</h1>
<p>데이터 모델까지 준비되었으니,
이제 이 데이터를 어떤 페이지에서 어떻게 보여줄지 고민할 단계였다.</p>
<p>Next.js를 사용하면서 가장 먼저 부딪힌 고민은
바로 <strong>렌더링(SSR · SSG · ISR)</strong> 선택이었다.
페이지 성격에 따라 최적의 방식을 찾기 위해,
코드를 여러 번 고치고 되돌리며 시행착오를 겪게 되었다.</p>
<h2 id="1-구조">1. 구조</h2>
<pre><code class="language-js">app/
 ├─ api/                     // Route Handlers (서버 API)
 ├─ auth/                    // 인증 관련 페이지 (요구 시 사용)
 ├─ comments/                // 방명록 페이지(댓글 기능)
 ├─ hooks/                   // 클라이언트 커스텀 훅
 ├─ lib/                     // 서버 유틸 및 DB 접근 로직
 ├─ post/                    // 게시글 상세 페이지
 │   └─ [slug]/
 │       └─ page.tsx
 ├─ profile/                 // 소개 페이지
 │   └─ page.tsx
 ├─ setting/                 // 설정 페이지
 │   └─ page.tsx
 ├─ temp/                    // 임시글 관련 라우트
 ├─ write/                   // 글 작성 페이지
 │   └─ page.tsx
 ├─ fonts.ts                 // 폰트 설정
 ├─ globals.css              // 전역 스타일
 ├─ layout.tsx               // 공통 레이아웃
 ├─ not-found.tsx            // 404 페이지
 └─ page.tsx                 // 메인 페이지 (게시글 리스트)
</code></pre>
<table>
<thead>
<tr>
<th>경로</th>
<th>역할</th>
<th>렌더링 방식</th>
<th><strong>특징</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>/</code></td>
<td>게시글 리스트</td>
<td><strong>Client + Tag 기반 캐시 revalidate</strong></td>
<td>prefetch 없음, 글 작성 후 서버에서 태그 새로고침</td>
</tr>
<tr>
<td><code>/post/[slug]</code></td>
<td>게시글 상세</td>
<td><strong>SSR</strong></td>
<td>SEO 최적화</td>
</tr>
<tr>
<td><code>/write</code></td>
<td>글 작성</td>
<td><strong>CSR</strong></td>
<td>입력/상호작용 중심</td>
</tr>
<tr>
<td><code>/setting</code></td>
<td>설정</td>
<td><strong>SSR + Hydration</strong></td>
<td>force-dynamic + prefetch로 초기 성능 안정</td>
</tr>
<tr>
<td><code>/profile</code></td>
<td>소개 페이지</td>
<td><strong>ISR</strong></td>
<td>변경 빈도 낮음, 정적 캐싱 유리</td>
</tr>
<tr>
<td><code>/comments</code></td>
<td>방명록</td>
<td><strong>SSR + staleTime 30s</strong></td>
<td>최신성 + 성능 균형 유지</td>
</tr>
<tr>
<td><code>/temp</code></td>
<td>임시글 목록</td>
<td><strong>SSR + staleTime 0</strong></td>
<td>항상 최신 상태 유지 (invalidate 중심)</td>
</tr>
</tbody></table>
<h2 id="2-각-구조의-고민과-해결">2. 각 구조의 고민과 해결</h2>
<h3 id="1---게시글-리스트">1. / — 게시글 리스트</h3>
<p>메인 페이지를 SSR로 구현했을 때는
글 작성/삭제 후 새로고침하면
<strong>SSR에서 내려온 옛날 데이터가 캐시를 덮어버리는 문제</strong>가 있었다.
그리고 메인 페이지는 트래픽이 많을 수 있는 영역이라
매 요청마다 서버 렌더링과 DB 쿼리를 수행하는 것도 비효율적이었다.</p>
<p>그래서 SSR을 제거하고,
<strong>클라이언트 캐싱 기반 + 서버</strong> <code>revalidateTag(&quot;posts&quot;)</code> 조합으로 전환했다.
이 구조를 통해 비용을 줄이면서도,
글이 변경되는 시점에만 정확하게 최신 데이터가 반영되도록 만들 수 있었다.</p>
<h3 id="2-postslug--게시글-상세">2. /post/[slug] — 게시글 상세</h3>
<p>상세 페이지는 <strong>slug 기반 라우팅</strong>을 선택했다.<br>Velog에서도 사용되는 방식으로,</p>
<pre><code class="language-text">/post/안녕-하세요
/post/react-query-정복하기</code></pre>
<p>처럼 <strong>제목을 URL에 녹여내는 패턴</strong>이다.</p>
<p>이 방식은 단순히 보기 좋기만 한 것이 아니라,<br>다음과 같은 실질적인 장점이 있었다.</p>
<ul>
<li><p>SEO 최적화
→ URL 자체가 콘텐츠 키워드를 포함
검색 엔진이 페이지를 <strong>독립 문서</strong>로 잘 인식</p>
</li>
<li><p>사용자 경험 향상 (UX)
→ 주소만 봐도 어떤 글인지 직관적으로 파악
공유 및 링크 접근에 유리</p>
</li>
<li><p>고유 식별자 역할
→ DB에서 <code>slug</code>는 <strong>unique</strong>
별도의 ID 없이 직접적인 접근 가능</p>
</li>
</ul>
<p>상세 페이지는 검색 유입이 중요한 페이지이기 때문에,
SEO에 유리한 <strong>SSR 방식으로 렌더링</strong>하도록 결정했다.
slug 기반으로 데이터를 서버에서 바로 가져올 수 있어
정확한 콘텐츠를 안정적으로 제공할 수 있다는 점도 장점이었다.</p>
<h3 id="3-setting--설정-페이지">3. /setting — 설정 페이지</h3>
<p>초기에는 SSR(prefetch + Hydration)로 구현했지만,<br>invalidate와 함께 사용하니 새로고침 시 <strong>UI 깜빡임</strong>이 발생했고<br><code>force-dynamic</code> 때문에 <strong>매번 서버 요청이 반복</strong>되는 비효율이 있었다.</p>
<p>설정 페이지는 <strong>SEO가 필요 없는 관리자 전용 페이지</strong>이기 때문에<br>SSR을 제거하고 <strong>CSR + React Query invalidate</strong> 방식으로 전환했다.</p>
<h3 id="4-comments--방명록댓글">4. /comments — 방명록(댓글)</h3>
<p>방명록은 사용자 간 실시간 상호작용이 중요한 기능이 아니기 때문에
최신 데이터를 빠르게 반영할 필요성이 크지 않았다.</p>
<p>그래서 <strong>staleTime을 30초</strong>로 설정하여,
짧은 시간 내에서 불필요한 refetch를 방지하고
간단한 캐싱으로 성능을 안정적으로 유지하도록 했다.</p>
<p>소통 기능이라기보다
그냥 간단히 메시지를 남기는 용도에 가깝기 때문에
“적당한 최신성 + 효율적인 동작”을 선택했다.</p>
<h3 id="5-posttemp--임시글-리스트">5. /post/temp — 임시글 리스트</h3>
<p>이 화면 또한 SSR을 통해 staletime을 10분? 정도 줬으나
임시글은 작성 중 수시로 바뀌기 때문에
<strong>stale time을 0으로 두어</strong> 것보다 항상 서버 기준으로 최신 데이터 조회가 안정적이었다.</p>
<p>또한 /write 페이지에서
임시 저장할 때마다 setQueryData로 temp 리스트를 직접 업데이트해야 해서
케이스가 많아지는 것도 번거로웠다.</p>
<hr>
<p>나름 두 달 가까이 야금야금 시간을 갈아 넣었던 사이드 프로젝트였는데,
글로 정리해보니 의외로 단순하게만 보인다.
그래도 이렇게 하나하나 짚어보다 보니
당시에 머리 싸매고 고민했던 기억들이 다시 떠오르면서
<strong>“아, 그래도 꽤 열심히 했네”</strong> 싶었다.</p>
<p>아직 다 쓰지도 못했는데 내용이 생각보다 너무 길어져서
이쯤에서 한 번 끊고, 나머지는 Part 2에서 이어가려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Jest를 활용해 테스트해보기]]></title>
            <link>https://velog.io/@dragonperson_kim/Jest%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dragonperson_kim/Jest%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 14 Nov 2025 05:34:52 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가기전에">들어가기전에..</h1>
<p>지금까지 여러 사이드 프로젝트를 진행했지만, Jest는 한 번도 제대로 사용해본 적이 없다. 처음에는 Jest 자체를 잘 몰랐고, 또 프로젝트가 어느 정도 완성된 상태에서 테스트 환경을 뒤늦게 도입하기가 너무 번거롭게 느껴졌기 때문이다. 하지만 채용 공고나 다양한 오픈소스 프로젝트를 보다 보면 Jest는 사실상 기본 도구처럼 자리 잡고 있다는 생각이 든다. 그래서 이번 포스팅을 통해 Jest를 체계적으로 학습해보려고 한다.</p>
<h1 id="jest란">Jest란?</h1>
<p><code>Jest</code>는 자바스크립트 테스트 프레임워크로, React를 비롯해 대부분의 JavaScript/TypeScript 프로젝트에서 사용 가능한 범용 테스트 도구다. 메타(구 페이스북)에서 개발했으며, 설정이 간단하고 사용하기 쉬운 API를 제공한다는 점에서 프론트엔드 생태계의 사실상 표준으로 자리 잡고 있다.</p>
<h3 id="1-jest의-주요-특징">1. Jest의 주요 특징</h3>
<ul>
<li><p>Zero-Config 지향
별도의 복잡한 설정 없이 바로 테스트를 작성하고 실행할 수 있다.</p>
</li>
<li><p>빠른 실행 속도
테스트를 병렬로 실행하여 큰 프로젝트에서도 효율적이다.</p>
</li>
<li><p>강력한 Mocking 지원
네트워크 요청, 타이머, 모듈 등을 간단하게 mock 처리할 수 있어 독립적인 테스트 작성이 가능하다.</p>
</li>
<li><p>스냅샷 테스트 제공
UI 변경 여부를 자동으로 감지해 예상치 못한 변화를 빠르게 확인할 수 있다.</p>
</li>
<li><p>TypeScript 지원
ts-jest 같은 도구를 통해 TypeScript 환경에서도 자연스럽게 사용할 수 있다.</p>
</li>
</ul>
<h3 id="2-한-줄로-정리하면">2. 한 줄로 정리하면?</h3>
<p>Jest는 코드가 예상대로 동작하는지 검증하고, 프로젝트의 품질을 유지해주는 테스트 도구다.</p>
<h1 id="jest-세팅하기">Jest 세팅하기</h1>
<p>TypeScript + React 환경 기준으로 Jest를 세팅하는 과정을 정리해본다.</p>
<h3 id="1-필요한-패키지-설치하기">1. 필요한 패키지 설치하기</h3>
<p>먼저 jest와 타입스크립트 환경에서 동작하기 위한 도구들을 설치한다.</p>
<pre><code class="language-js">npm install --save-dev jest ts-jest @types/jest</code></pre>
<p>React 컴포넌트 테스트도 할 예정이라면 아래도 함께 설치한다</p>
<pre><code class="language-js">npm install --save-dev @testing-library/react @testing-library/jest-dom</code></pre>
<h3 id="2-jest-설정-파일-생성하기">2. Jest 설정 파일 생성하기</h3>
<p>아래의 명령어로 자동으로 설정 파일을 생성할 수도 있다.</p>
<pre><code class="language-js">
npx ts-jest config:init</code></pre>
<p>그러면 아래와 유사한 <strong>jest.config.js</strong> 파일이 생성된다</p>
<pre><code class="language-js">/** @type {import(&#39;ts-jest&#39;).JestConfigWithTsJest} */
module.exports = {
  preset: &quot;ts-jest&quot;,
  testEnvironment: &quot;jsdom&quot;,
  moduleNameMapper: {
    &quot;^@/(.*)$&quot;: &quot;&lt;rootDir&gt;/$1&quot;,
  },
  setupFilesAfterEnv: [&quot;&lt;rootDir&gt;/jest.setup.ts&quot;],
};
</code></pre>
<p>설명:</p>
<ul>
<li><p>preset: TypeScript 파일을 변환하기 위해 ts-jest 사용</p>
</li>
<li><p>testEnvironment: React 테스트를 위한 jsdom 환경</p>
</li>
<li><p>moduleNameMapper: 절대경로(@/components/...)를 Jest에서도 인식하도록</p>
</li>
<li><p>setupFilesAfterEnv: 테스트 실행 전에 공통 설정 로드</p>
</li>
</ul>
<p>React UI를 테스트 하기위해선 setup파일을 아래와 같이 설정한다</p>
<pre><code class="language-js">//setupTests.ts
import { server } from &quot;./mocks/server&quot;;
import &quot;whatwg-fetch&quot;; 
import &quot;@testing-library/jest-dom&quot;; // React UI 테스트 

// 모든 테스트 시작 전에 서버를 켜기
beforeAll(() =&gt; server.listen());

// 각 테스트 끝난 뒤 핸들러 리셋 (다른 테스트에 영향 방지)
afterEach(() =&gt; server.resetHandlers());

// 모든 테스트 끝나면 서버 닫기
afterAll(() =&gt; server.close());
</code></pre>
<p><code>import &quot;@testing-library/jest-dom&quot;</code>
이걸 통해 다음과 같은 matcher를 사용할 수 있다:</p>
<ul>
<li>toBeInTheDocument()</li>
<li>toHaveTextContent()</li>
<li>toBeVisible()</li>
</ul>
<h1 id="간단한-테스트-작성해보기">간단한 테스트 작성해보기</h1>
<h3 id="1-함수-테스트">1. 함수 테스트</h3>
<p>아래와 같이 간단한 덧셈함수를 작성한다</p>
<pre><code class="language-js">//test/sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
</code></pre>
<p>그리고 테스트 케이스를 작성후 <code>npx jest test/sum</code> 을 해보면</p>
<pre><code class="language-js">//test/sum.test.ts
import { sum } from &quot;./sum&quot;;

describe(&quot;덧셈 테스트&quot;, () =&gt; {
  test(&quot;1 + 2 = 3 이어야 한다&quot;, () =&gt; {
    expect(sum(1, 2)).toBe(3);
  });

  test(&quot;3 + 3 = 6 이어야 한다&quot;, () =&gt; {
    expect(sum(3, 3)).toBe(6);
  });
});
</code></pre>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/e1759398-bf94-4e43-8286-0cb37b3aed59/image.png" alt=""></p>
<p>이렇게 성공적으로 테스트가 완료된걸 확인할수 있다.</p>
<h3 id="2-리액트-컴포넌트-테스트">2. 리액트 컴포넌트 테스트</h3>
<p>이번엔 리액트 컴포넌트 UI를 테스트해보자</p>
<pre><code class="language-js">// src/components/hello.tsx
export default function Hello() {
  return &lt;div&gt;Hello World&lt;/div&gt;;
}
</code></pre>
<p>똑같이 테스트 케이스를 작성, <code>npx jest components/hello</code> 실행</p>
<pre><code class="language-js">// src/components/Hello.test.tsx
import { render, screen } from &quot;@testing-library/react&quot;;
import Hello from &quot;./hello&quot;;

test(&quot;Hello 컴포넌트가 정상 렌더링된다&quot;, () =&gt; {
  render(&lt;Hello /&gt;);
  expect(screen.getByText(&quot;Hello World&quot;)).toBeInTheDocument();
});
</code></pre>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/5aa99336-28ef-42fb-972d-4d5de8d7d89c/image.png" alt=""></p>
<p>컴포넌트 UI 테스트도 성공적으로 되는걸 확인할수있다.</p>
<h1 id="api를-끼얹어서-테스트-해보기msw">API를 끼얹어서 테스트 해보기(MSW)</h1>
<p>단순히 컴포넌트 UI만 테스트하면 뭔가 실용성도 떨어지고 재미도 없다.
그래서 실제 서비스처럼 API가 개입된 흐름을 테스트해보고 싶어지는데,
문제는 테스트 환경에서는 진짜 서버를 부를 수 없다는 점이다.</p>
<p>이럴 때 필요한 게 바로 <strong>MSW(Mock Service Worker)</strong>다.
<code>MSW</code>는 네트워크 요청을 가짜로 만들어주는 도구로,
백엔드가 없어도 fetch나 axios 요청에 <strong>원하는 응답</strong>을 돌려줄 수 있다.</p>
<p>API가 끼어있는 컴포넌트 테스트를 현실적으로 만들려면 MSW가 필수다.</p>
<h3 id="1-mswv1-설치">1. MSW(v1) 설치</h3>
<p>MSW는 반드시 v1을 사용해야 Jest와 안정적으로 호환된다.</p>
<p>설치 시 명시적으로 v1 설치:</p>
<pre><code class="language-js">npm i -D msw@1</code></pre>
<h3 id="2-핸들러-정의하기-srcmockshandlersts">2. 핸들러 정의하기 (src/mocks/handlers.ts)</h3>
<p>API 요청을 어떻게 가짜 응답할지 작성하는 곳이다.
원하면 여러 개의 API를 여기에서 등록할 수 있다.</p>
<pre><code class="language-js">import { rest } from &quot;msw&quot;;

export const handlers = [
  rest.get(&quot;https://example.com/api/user&quot;, (req, res, ctx) =&gt; {
    return res(
      ctx.status(200),
      ctx.json({
        name: &quot;김철수&quot;,
      })
    );
  }),
];
</code></pre>
<h3 id="3-서버-객체-생성-srcmocksserverts">3. 서버 객체 생성 (src/mocks/server.ts)</h3>
<p>테스트 시작 시 서버 켜고, 테스트 종료 후 서버 끄기.</p>
<pre><code class="language-js">import { server } from &quot;./mocks/server&quot;;
import &quot;whatwg-fetch&quot;; // fetch polyfill
import &quot;@testing-library/jest-dom&quot;;

// 테스트 시작 전: 서버 ON
beforeAll(() =&gt; server.listen());

// 각 테스트 후: 핸들러 리셋
afterEach(() =&gt; server.resetHandlers());

// 전체 테스트 끝나면 서버 OFF
afterAll(() =&gt; server.close());
</code></pre>
<p>여기까지 작성하면 기본적인 설정은 끝이다
테스트 케이스를 작성해보자</p>
<h3 id="4-컴포넌트--api">4. 컴포넌트 + API</h3>
<p>위에서 작성했던것과 마찬가지로 컴포넌트를 만들어준다.</p>
<pre><code class="language-js">//components/primaryButton.tsx
import React, { useState } from &quot;react&quot;;

export function PrimaryButton() {
  const [user, setUser] = useState&lt;{ name: string } | null&gt;(null);

  async function handleClick() {
    const res = await fetch(&quot;https://example.com/api/user&quot;);
    const data = await res.json();
    setUser(data);
  }

  return (
    &lt;div&gt;
      &lt;button onClick={handleClick}&gt;유저 불러오기&lt;/button&gt;
      {user &amp;&amp; &lt;p data-testid=&quot;username&quot;&gt;{user.name}&lt;/p&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<p>테스트 케이스 작성, <code>npx jest components/primaryButton</code> 실행</p>
<pre><code class="language-js">import { render, screen } from &quot;@testing-library/react&quot;;
import userEvent from &quot;@testing-library/user-event&quot;;
import { PrimaryButton } from &quot;./primaryButton&quot;;

test(&quot;버튼 클릭 시 유저 이름이 표시된다&quot;, async () =&gt; {
  render(&lt;PrimaryButton /&gt;);

  // 버튼 클릭
  await userEvent.click(screen.getByText(&quot;유저 불러오기&quot;));

  // Mock 응답 결과가 렌더링되는지 확인
  const username = await screen.findByTestId(&quot;username&quot;);
  expect(username).toHaveTextContent(/^김철수$/);
});
</code></pre>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/34f4bd0a-b8f7-47ed-b6ab-5c9b5cce2267/image.png" alt=""></p>
<p>성공적으로 pass가 되는걸 확인할수 있다.</p>
<p>버튼을 클릭하면 실제 API 주소로 fetch를 보내지만, 테스트 환경에서는 이 요청이 인터넷으로 나가지 않는다. 대신 <code>MSW</code> 가 네트워크 요청을 가로채서 우리가 정의한 가짜 응답을 대신 돌려준다. 테스트 환경에서는 Service Worker(혹은 Node 환경용 인터셉터)가 fetch 호출을 중간에서 받아주고, 매칭되는 핸들러가 있으면 그 응답을 반환한다.</p>
<blockquote>
<p>즉, 컴포넌트는 진짜 API를 호출한다고 생각하지만
실제로는 <code>MSW</code>가 해당 URL을 intercept → mock response를 반환하는 구조다.</p>
</blockquote>
<p>이 원리 덕분에 서버 없이도 <strong>“API 포함 UI 흐름”</strong>을 완전히 재현하며 테스트할 수 있다.</p>
<h1 id="마무리하며">마무리하며..</h1>
<p><code>Jest</code>를 활용한 기본적인 테스트 케이스 작성부터 <code>MSW</code> 를 이용한 <code>API mocking</code>까지 간단히 살펴보았다. 아직은 겉핥기식으로 경험해본 수준이지만, 이 정도 흐름만 이해해도 앞으로 더 복잡한 컴포넌트나 비동기 로직을 테스트하는 데 큰 도움이 될 것이다.
이제 실제 프로젝트에 적용해보면서 테스트 커버리지도 넓히고, 다양한 상황을 시뮬레이션하는 더 견고한 테스트 환경을 만들어볼 계획이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[크롬익스텐션 만들어보기(2) - Tailwind Converter - 2]]></title>
            <link>https://velog.io/@dragonperson_kim/%ED%81%AC%EB%A1%AC%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B02-Tailwind-Converter-2</link>
            <guid>https://velog.io/@dragonperson_kim/%ED%81%AC%EB%A1%AC%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B02-Tailwind-Converter-2</guid>
            <pubDate>Sun, 02 Nov 2025 00:12:23 GMT</pubDate>
            <description><![CDATA[<h1 id="인트로">인트로</h1>
<p>전편에선 컨버터를 만들기 위한 세팅작업을 하다가 끝이났다. 크롬익스텐션 환경이 처음이기에 생각보다 오래걸렸고 예상치 못한 문제들이 많았다. UI적으로 짜잘한 수정도 있었지만(드래그,팝업 위치 업데이트 등) 이번글에선 엘리먼트의 css를 테일윈드로 변환하는 과정을 글로 정리하려고 한다</p>
<h1 id="과정">과정</h1>
<h2 id="1-어떻게-변환-시킬것인가">1. 어떻게 변환 시킬것인가?</h2>
<p>깊이 고민하진 않았다. 어차피 GPT(라고 쓰고 바이브코딩이라 부른다)에게 물어보면 되겠지 싶었다. 그런데 막상 물어보니, 생각보다 간단한 문제는 아니었다.</p>
<p><strong>생각했던 과정</strong></p>
<ul>
<li>getComputedStyle을 통해 엘리먼트의 css를 가져온다</li>
<li>GPT가 테일윈드로 1:1변환 시키는방법을 알아서 찾는다(?)</li>
<li>변환시킨 문자열을 편하게 볼수있게 UI를 꾸며준다</li>
<li>완성(??)</li>
</ul>
<p>단순하게 “DOM에서 스타일만 읽어와서 Tailwind로 변환하면 되잖아?”였지만,<br>막상 구현해보니 곳곳에서 예상치 못한 문제들이 튀어나왔다.  </p>
<h2 id="2-문제와-해결">2. 문제와 해결</h2>
<h3 id="문제1">문제1</h3>
<p><strong>getComputedStyle이 너무많다..</strong></p>
<p>getComputedStyle은 엘리먼트에 적용된 모든 스타일을 반환한다.
즉, 상속된 값, 브라우저 기본 스타일, 의미 없는 0px 값까지 전부 나온다.
처음엔 이걸 그대로 변환했는데 결과가 끔찍했다.
의미없는 css의 양이 너무 많았기에 핵심만 남기고 나머지는 전부 버려야 했다.</p>
<pre><code class="language-js">block w-[auto] h-[auto] color-[rgb(0_0_0)] margin-[0px] padding-[0px] border-[none] ...</code></pre>
<p><strong>해결방법</strong></p>
<p><code>&lt;div&gt;</code>, <code>&lt;button&gt;</code> 등 동일 태그의 기본값(getComputedStyle(document.createElement(tag)))과 비교
실제로 “차이 나는” 속성만 남기고, allowedProps 배열로 필요한 속성만 필터링하였다.</p>
<pre><code class="language-js">const allowedProps = [
  &quot;display&quot;, &quot;color&quot;, &quot;background-color&quot;,
  &quot;width&quot;, &quot;height&quot;,
  &quot;margin-top&quot;, &quot;margin-right&quot;, &quot;margin-bottom&quot;, &quot;margin-left&quot;,
  &quot;padding-top&quot;, &quot;padding-right&quot;, &quot;padding-bottom&quot;, &quot;padding-left&quot;,
  &quot;font-size&quot;, &quot;font-weight&quot;,
  &quot;box-shadow&quot;, &quot;border-radius&quot;, &quot;border-width&quot;, &quot;border-color&quot;
];
</code></pre>
<h3 id="문제2">문제2</h3>
<p><strong>Tailwind 변환의 까다로움</strong></p>
<p>getComputedStyle로 가져온 CSS를 그대로 Tailwind로 바꾸는 건 생각보다 간단하지 않았다.
우선 브라우저가 반환하는 스타일 값은 너무 다양하고,
Tailwind의 설정값(theme.spacing, fontSize, borderRadius 등)과 완벽히 일치하지 않기 때문이다. 실제 테일윈드처럼 깔끔하게 변환시킬순 없었고 어느정도는 타협해서 변환시키기로 했다.</p>
<p><strong>해결방법</strong></p>
<p>우선 Tailwind 설정을 전부 <strong>JSON 형태로 덤프(tailwind.json)</strong> 떠서
하나의 매핑 기준 데이터셋으로 만들었다.
이 파일에는 <code>&quot;4px&quot; → &quot;p-1&quot;</code>, <code>&quot;1rem&quot; → &quot;text-base&quot;</code> 같은 식으로
Tailwind 클래스와 실제 CSS 값이 모두 담겨 있다.</p>
<p>변환 과정은 단순했다.
우선 각 CSS 속성값을 숫자로 변환해서 tailwind.json 안에서 가장 가까운 값(근사값) 을 찾고,
일치하는 키가 있으면 그걸 Tailwind 클래스로 매핑했다.
예를 들어 <code>font-size: 15.9px → text-base</code>, <code>margin-top: 32px → mt-8</code> 식으로 변환된다.
근사값조차 없을 경우엔 <code>[prop:value]</code> 형태로 fallback 처리했다.</p>
<p>하지만 색상(color, background-color) 계열은 예외였다.
CSS에는 <code>rgb()</code>, <code>hsl()</code>, <code>oklab()</code>, <code>color(display-p3 ...)</code> 같은 다양한 포맷이 섞여 있어서
Tailwind의 팔레트(<code>red-500</code>, <code>blue-600</code>)과 정확히 맞출 수가 없었다.
그래서 색상은 아예 매칭을 포기하고,
그냥 <code>[background-color:#e90000]</code>, <code>[color:rgb(233_0_0)]</code> 같은 브라켓 형식으로 그대로 변환했다.</p>
<p>결과만 정리하자면,</p>
<blockquote>
<p>색상은 완벽 매칭 불가능 → [prop:value] 형식으로 처리
나머지 속성은 Tailwind JSON 기준으로 근사 매칭 → 없으면 [prop:value] fallback</p>
</blockquote>
<p>많은 노가다가 들어가고 완벽하진 않지만 만족스럽게 해결이 되었다.</p>
<h2 id="3-결과물">3. 결과물</h2>
<p>Tailwind 변환 로직은 모두 완성됐다.
이제 남은 건 단순히 그 결과를 UI로 보여주는 일뿐이었다.
변환에 사용된 함수들은 따로 useConverter 훅으로 분리해 관리했고,
<strong>content 스크립트</strong>에서 사용자가 변환된 결과를 바로 확인할 수 있도록 렌더링했다.</p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/c34b03b5-bbff-445a-9842-8d6a547c7568/image.png" alt=""></p>
<p>상단에는 변환된 Tailwind 문자열 전체를 한눈에 볼 수 있도록 표시하고,
원하는 경우 쉽게 복사할 수 있는 기능을 추가했다.
하단에는 변환된 클래스들을 <code>Typography</code>, <code>Color</code>, <code>Box Model</code>, <code>Layout</code>, <code>Effects</code>, <code>Other</code> 
여섯 가지 카테고리로 구분해, 각 항목을 버튼 컴포넌트 형태로 렌더링했다.
각 버튼을 클릭하면 해당 클래스를 즉시 클립보드로 복사할 수 있도록 구현했다.</p>
<h1 id="마치며">마치며</h1>
<p>만드는 과정은 꽤 재미있었다.
비록 완벽하진 않지만, 처음부터 그걸 목표로 한 건 아니었다.
<strong>“완벽함보다는 도전”</strong> — 그 마음으로 시작했고,
지금은 일단 시도했다는 사실만으로도 충분히 의미가 있었다.
앞으로는 조금씩 다듬고, 천천히 리팩토링해가면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[크롬익스텐션 만들어보기(2) - Tailwind Converter - 1]]></title>
            <link>https://velog.io/@dragonperson_kim/%ED%81%AC%EB%A1%AC%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B02-Tailwind-Converter-1</link>
            <guid>https://velog.io/@dragonperson_kim/%ED%81%AC%EB%A1%AC%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B02-Tailwind-Converter-1</guid>
            <pubDate>Mon, 27 Oct 2025 05:45:52 GMT</pubDate>
            <description><![CDATA[<h1 id="인트로">인트로</h1>
<p>크롬익스텐션 만들어보기(1) 챕터를 시작하기전에 프로젝트를 이미 어느정도 구성해둔 상태였다. 1~2일 마다 진행과정과 어려움을 쓰려고했는데 현생에 치이고 너무 게으른 관계로 게속 미루다 미루다.. 이제서야 내용을 정리하려한다.</p>
<h1 id="과정">과정</h1>
<p>챕터1에서 작성한 내용처럼 엘리먼트의 css를 완벽하진 않지만 TailwindCss로 변환하는것을 목표로 시작했다. 엄청 간단할거라고 생각하고 무작정 진행했는데 생각보다 쉽지많은 않았고 vite 설정에서 많이 꼬이기도 했다.</p>
<h2 id="1-엘리먼트-찾기">1. 엘리먼트 찾기</h2>
<p>생각보다 단순한 아이디어에서 시작됐다.
개발자라면 익숙한 크롬 개발자도구(F12) 의 커서 모양 아이콘(검사할 페이지 요소 선택) 기능 — 마우스로 페이지의 요소를 직접 클릭해 구조를 확인할 수 있는 그 기능에서 착안했다.
이 원리를 응용해, 익스텐션 내부에서도 비슷하게 요소를 클릭해 확인할 수 있는 기능을 구현했다.</p>
<pre><code class="language-js">//content/inspector.ts
import { logger } from &quot;../hooks/useUtils&quot;;

let overlay: HTMLDivElement | null = null;
let label: HTMLDivElement | null = null;
let blocker: HTMLDivElement | null = null;

export function removeInspectorInfo() {
   overlay?.remove();
   overlay = null;
   label?.remove();
   label = null;
}

export function initInspector(setTarget: (el: HTMLElement) =&gt; void) {
   logger(&quot;initInspector (main doc)&quot;);

   if (blocker) return;

   function stopInspector() {
      blocker?.remove();
      blocker = null;

      document.removeEventListener(&quot;mousemove&quot;, handleMove, true);
      document.removeEventListener(&quot;click&quot;, handleClick, true);
      document.removeEventListener(&quot;keydown&quot;, handleKeyDown, true);
      logger(&quot;[inspector] stopped&quot;);
   }

   function handleKeyDown(e: KeyboardEvent) {
      if (e.key === &quot;Escape&quot;) stopInspector();
   }

   function handleClick(e: MouseEvent) {
      e.preventDefault();
      e.stopPropagation();

      if (overlay) overlay.style.backgroundColor = &quot;transparent&quot;;
      const el = document
         .elementsFromPoint(e.clientX, e.clientY)
         .find((n) =&gt; n !== blocker &amp;&amp; n !== overlay &amp;&amp; n !== label) as HTMLElement | undefined;

      if (el) {
         setTarget(el);
      }

      stopInspector();
   }

   function handleMove(e: MouseEvent) {
      const el = document
         .elementsFromPoint(e.clientX, e.clientY)
         .find((n) =&gt; n !== blocker &amp;&amp; n !== overlay &amp;&amp; n !== label) as HTMLElement | undefined;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      showOverlay(rect, el);
   }
   function showOverlay(rect: DOMRect, el: HTMLElement) {
      if (!overlay) {
         overlay = document.createElement(&quot;div&quot;);
         Object.assign(overlay.style, {
            position: &quot;absolute&quot;,
            zIndex: &quot;999999&quot;,
            pointerEvents: &quot;none&quot;,
            border: &quot;2px solid magenta&quot;,
            background: &quot;rgba(255,0,255,0.1)&quot;,
         });
         document.body.appendChild(overlay);
      }
      Object.assign(overlay.style, {
         top: `${rect.top + window.scrollY}px`,
         left: `${rect.left + window.scrollX}px`,
         width: `${rect.width}px`,
         height: `${rect.height}px`,
      });

      if (!label) {
         label = document.createElement(&quot;div&quot;);
         Object.assign(label.style, {
            position: &quot;absolute&quot;,
            zIndex: &quot;1000000&quot;,
            fontFamily: &quot;monospace&quot;,
            fontSize: &quot;14px&quot;,
            color: &quot;#fff&quot;,
            background: &quot;rgba(0,0,0,0.8)&quot;,
            padding: &quot;2px 5px&quot;,
            borderRadius: &quot;3px&quot;,
            whiteSpace: &quot;nowrap&quot;, // 긴 텍스트 한 줄로
         });
         document.body.appendChild(label);
      }

      const tag = el.tagName.toLowerCase();
      const siblings = el.parentElement
         ? Array.from(el.parentElement.children).filter((child) =&gt; child.tagName === el.tagName)
         : [];
      const index = siblings.indexOf(el);
      const id = el.id ? `#${el.id}` : `#${index}`;
      const classes = el.classList.length ? &quot;.&quot; + Array.from(el.classList).join(&quot;.&quot;) : &quot;&quot;;

      label.textContent = `${tag}${id}${classes}`;

      // ✅ label 높이 계산해서 그만큼 위로 올림
      const labelHeight = label.offsetHeight || 20;
      const top = rect.top + window.scrollY - labelHeight - 8; // 8px 여백

      Object.assign(label.style, {
         top: `${Math.max(top, 0)}px`, // 화면 위로 벗어나지 않게 보정
         left: `${rect.left + window.scrollX}px`,
      });
   }

   blocker = document.createElement(&quot;div&quot;);
   Object.assign(blocker.style, {
      position: &quot;fixed&quot;,
      top: &quot;0&quot;,
      left: &quot;0&quot;,
      width: &quot;100vw&quot;,
      height: &quot;100vh&quot;,
      background: &quot;transparent&quot;,
      cursor: &quot;crosshair&quot;,
      zIndex: &quot;999998&quot;,
   });
   document.body.appendChild(blocker);

   document.addEventListener(&quot;mousemove&quot;, handleMove, true);
   document.addEventListener(&quot;click&quot;, handleClick, true);
   document.addEventListener(&quot;keydown&quot;, handleKeyDown, true);

   logger(&quot;[inspector] started&quot;);
}
</code></pre>
<p>위 코드처럼 단순히 인스펙터가 시작됐을때 오버레이,라벨 등 엘리먼트를 만들어 사용하고 있는 웹페이지에 append하면 끝이였다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/3de3c537-eda6-4636-8507-3714bf156bda/image.png" alt="">
보라색 영역은 선택한 엘리먼트의 영역이고 그 위의 라벨은 태그이름을 나열하였다.(사실 개발자도구의 인스펙터와 거의 똑같다)</p>
<h2 id="2-converter-팝업-띄우기">2. Converter 팝업 띄우기</h2>
<p>생각보다 빨리 여기서 난처한 상황이 나왔는데 어느때와 같이 UI를 테일윈드로 진행하려했으나 적용이 되질 않았다. 왜지? 분명히 크롬익스텐션에 등록해서 사용했던 팝업은 적용이 됐었는데..?</p>
<h3 id="content-script에서-tailwind가-적용되지-않았던-이유">Content Script에서 Tailwind가 적용되지 않았던 이유</h3>
<p>처음에는 Tailwind를 이용해 content script 쪽 UI를 만들고 싶었다.
하지만 빌드 후 실제 확장 프로그램을 실행해보니,
외부 웹페이지에 삽입되는 content script에서는 Tailwind가 전혀 적용되지 않았다.</p>
<p>처음엔 단순히 CSS 경로 문제라고 생각했지만,
실제 원인은 content script의 실행 환경 때문이었다.</p>
<p>Chrome 확장 프로그램의 popup이나 options 페이지는
chrome-extension:// 내부에서 실행되기 때문에
Tailwind CSS가 일반적인 방식(import &quot;./global.css&quot;)으로 잘 작동한다.
하지만 content script는 외부 웹페이지의 DOM에 삽입되는 코드라서
확장 내부의 CSS 리소스를 직접 불러올 수 없다.
결과적으로 Tailwind 스타일이 적용되지 않았다.</p>
<h3 id="vite-설정을-통한-해결">Vite 설정을 통한 해결</h3>
<p>이 문제를 해결하기 위해 Vite의 ?inline 옵션을 사용했다.
Tailwind로 빌드된 CSS를 별도의 파일로 링크하는 대신,
빌드 시점에 CSS 내용을 문자열로 변환하여 JS 번들에 포함시키는 방식이다.</p>
<p>먼저, vite.config.ts에서 다음과 같이 빌드 구조를 설정했다.</p>
<pre><code class="language-js">//vite.config.js
 root: &quot;src&quot;,
      publicDir: &quot;../public&quot;,
      build: {
         outDir: &quot;../dist&quot;,
         emptyOutDir: true,
         rollupOptions: {
            input: {
               popup: resolve(__dirname, &quot;src/popup/index.html&quot;),
               options: resolve(__dirname, &quot;src/options/index.html&quot;),
               background: resolve(__dirname, &quot;src/background.ts&quot;),
            },
            output: {
               entryFileNames: (chunkInfo) =&gt; {
                  if (chunkInfo.name === &quot;background&quot;) return &quot;background.js&quot;;
                  if (chunkInfo.name === &quot;popup&quot;) return &quot;popup/popup.js&quot;;
                  if (chunkInfo.name === &quot;options&quot;) return &quot;options/options.js&quot;;
                  return &quot;assets/[name]-[hash].js&quot;;
               },
               chunkFileNames: &quot;assets/[name]-[hash].js&quot;,
               assetFileNames: &quot;assets/[name]-[hash][extname]&quot;,
            },
         },
      }
</code></pre>
<p>이 설정을 통해 Tailwind가 처리된 CSS(global.css)가
빌드 시 자동으로 dist/assets/ 폴더에 생성된다.</p>
<p>그리고 content script에서 다음처럼 import 했다:</p>
<pre><code class="language-js">//content/index.js
import tailwindCss from &quot;../global.css?inline&quot;;

const style = document.createElement(&quot;style&quot;);
style.textContent = tailwindCss;
document.head.appendChild(style);</code></pre>
<p>그리고 확인해보면?</p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/58444adc-4269-47cf-b538-282c0fe2fff5/image.png" alt="">
이렇게 깔끔하게 적용이 된걸 확인할수 있다.</p>
<h3 id="또다른-문제">또다른 문제</h3>
<blockquote>
<p>“한 문제를 해결했다고 안심할 때쯤, 또 다른 문제가 그 틈을 비집고 들어온다.”</p>
</blockquote>
<p>그렇다. 나름 잘되어가고있다고 자신만만할 즈음 또 다른 문제가 나왔다..
어느때처럼 쇼핑을 하던와중 웹사이트가 이상하다는걸 느꼈다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/2a79fec6-5a84-42a0-b324-0729a628aec6/image.png" alt=""></p>
<p>응? 아이콘 위치가 이상한데? 익스텐션을 삭제해볼까? (제발..)</p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/26ee00bd-2eae-49fa-ac93-52b437f39d8a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/489218c5-dc13-4b46-99b0-ee0db4fb1268/image.png" alt=""></p>
<p>왜 또다른 문제가 나오는것이냐.. <del>나 너무 힘들어..</del></p>
<pre><code class="language-js">//tailwind.config.js
module.exports = {
  prefix: &quot;ex-tw-&quot;,
  corePlugins: { preflight: false },
};</code></pre>
<p>GPT를 통해 해결책을 들었으나, 이렇게 설정한다고 결과는 달라지지 않았다.
그럼 어떻게 해야할까?</p>
<h3 id="또-다시-해결">또 다시 해결</h3>
<p>방법은 생각보다 간단했다.
웹페이지에 직접 Tailwind를 삽입할 필요가 없었다.
대신 <strong>iframe</strong> 을 하나 만들어 완전히 격리된 환경에서 사용하는 방식으로 해결할 수 있었다.</p>
<pre><code class="language-js">//content/index.js
function createInspectorIframe() {
   const iframe = document.createElement(&quot;iframe&quot;);
   iframe.id = &quot;tw-inspector-iframe&quot;;

   Object.assign(iframe.style, {
      position: &quot;fixed&quot;,
      inset: &quot;0&quot;,
      zIndex: &quot;2147483647&quot;,
      border: &quot;none&quot;,
      width: &quot;100vw&quot;,
      height: &quot;100vh&quot;,
      display: &quot;none&quot;,
      pointerEvents: &quot;none&quot;,
   });
   document.body.appendChild(iframe);

   const iframeDoc = iframe.contentDocument!;
   iframeDoc.open();
   iframeDoc.write(&quot;&lt;!DOCTYPE html&gt;&lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;/body&gt;&lt;/html&gt;&quot;);
   iframeDoc.close();

   // Tailwind 주입
   const styleEl = iframeDoc.createElement(&quot;style&quot;);
   styleEl.textContent = tailwindCss;
   iframeDoc.head.appendChild(styleEl);

   injectFonts(iframeDoc);

   const resetEl = iframeDoc.createElement(&quot;style&quot;);
   resetEl.textContent = `
    html, body {
      margin: 0; padding: 0;
      background: transparent;
      overflow: hidden;
      width: 100%; height: 100%;
    }
    * { box-sizing: border-box; }
  `;
   iframeDoc.head.appendChild(resetEl);

   const mountEl = iframeDoc.createElement(&quot;div&quot;);
   iframeDoc.body.appendChild(mountEl);

   return { iframe, iframeDoc, mountEl };
}
</code></pre>
<h2 id="끝으로">끝으로..</h2>
<p>쉐도우 DOM이니 뭐니 여러 가지 방법을 시도해봤지만, 결국 이 방식이 가장 깔끔했다.
빠르게 후딱 끝내려던 토이 프로젝트였는데, 생각보다 진전이 더뎠다.
<del>(찍먹하려다 실패)</del>
예기치 못한 문제들이 계속 발생했고,
이렇게 글로 정리해보니 짧아 보이지만 실제로는 꽤 많은 시간이 걸렸다.
그래도 결국 해결됐으니 해피엔딩!
이제 다음 챕터에서는 Converter 완성 과정을 정리해보려 한다. ㄱㄱ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[크롬익스텐션 만들어보기(1) - 프로젝트 세팅]]></title>
            <link>https://velog.io/@dragonperson_kim/%ED%81%AC%EB%A1%AC%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B01-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%B8%ED%8C%85-3wet1x7c</link>
            <guid>https://velog.io/@dragonperson_kim/%ED%81%AC%EB%A1%AC%EC%9D%B5%EC%8A%A4%ED%85%90%EC%85%98-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B01-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%B8%ED%8C%85-3wet1x7c</guid>
            <pubDate>Wed, 22 Oct 2025 10:23:26 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가며">들어가며</h1>
<h3 id="1-동기">1. 동기</h3>
<ul>
<li>사이드 프로젝트를 여러 개 진행하다 보니 항상 <strong>Tailwind CSS</strong>를 사용하게 되었다. 
많이 쓰다 보니 손에 익기도 했고 더 잘 활용하고 싶어서 프로젝트마다 도입했는데,
문제는 CSS 자체보다 <strong>UI 작업</strong>이었다. </li>
<li>디자인 지식이 전무하다 보니 다른 사이트 UI를 참고하게 되었고, 
그 과정에서 매번 <strong>개발자 도구 → 요소 선택 → CSS 확인</strong> 절차를 반복하는 게 너무 번거로웠다.  </li>
<li>이 불편함을 해결하기 위해, <strong>크롬 확장 프로그램</strong>을 직접 만들어보기로 했다.<h3 id="2-목표">2. 목표</h3>
</li>
<li>앞으로도 계속 Tailwind CSS를 사용할 것 같아, 나 자신이 편하게 쓰고 다른 사람도 활용할 수 있도록 크롬 익스텐션 출시를 목표로 개발하기로 했다.</li>
<li>일단 자그마한 기능만 개발해서 출시하고 계속해서 기능을 추가하는것으로 목적을 두었다.<h3 id="3-다루는-범위">3. 다루는 범위</h3>
</li>
<li>Vite + React 환경 설정  </li>
<li>manifest.json 기본 구성  </li>
<li>간단한 기능을 만들어 크롬에 등록하고 동작 여부 확인 </li>
</ul>
<h1 id="프로젝트-구조와-설명">프로젝트 구조와 설명</h1>
<p>tailwind-extension
├── eslint.config.js
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
│   ├── icon.png
│   └── manifest.json
├── src
│   ├── background.ts
│   ├── content
│   │   ├── index.tsx
│   │   └── inspector.ts
│   ├── index.css
│   ├── options
│   │   ├── Options.tsx
│   │   ├── index.html
│   │   └── index.tsx
│   ├── popup
│   │   ├── Popup.tsx
│   │   ├── index.html
│   │   └── index.tsx
│   └── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── vite.content.config.ts</p>
<h2 id="기본-파일-구조와-역할">기본 파일 구조와 역할</h2>
<p>간단한 기능을 만들고 직접 확인해보려면<br>먼저 프로젝트의 파일 구조를 이해하는 것이 중요하다.  </p>
<p>아래는 기본적인 크롬 익스텐션 구조이며,<br>각 폴더와 파일이 어떤 역할을 하는지 간단히 살펴보자.</p>
<hr>
<h3 id="manifestjson">manifest.json</h3>
<p>확장의 모든 정보를 담은 <strong>설정 파일</strong>.  
name, version, permissions, entry scripts 등 확장의 동작에 필요한 정보를 정의한다.</p>
<hr>
<h3 id="viteconfigts">vite.config.ts</h3>
<p>Vite를 이용한 <strong>번들러 설정 파일</strong>.  
크롬 익스텐션은 여러 진입점(popup, content, background 등)을 가지므로<br><strong>multi-entry 빌드 설정</strong>이 필요하다.</p>
<hr>
<h3 id="background">background</h3>
<p>항상 실행되는 <strong>서비스 워커(Service Worker)</strong> 폴더.<br>알림, 메시지 통신, 이벤트 처리 등 백그라운드 로직을 담당한다.</p>
<hr>
<h3 id="content">content</h3>
<p>사용자가 보고 있는 <strong>웹페이지에 직접 삽입되는 코드</strong>.  
DOM 조작이나 UI 변경 등 <strong>화면 상의 인터랙션 처리</strong>를 담당한다.  </p>
<blockquote>
<p>말 그대로 “내용물”이며, 사용자의 화면에 무언가를 보여주고 싶을 때 이 부분을 수정한다.</p>
</blockquote>
<hr>
<h3 id="popup">popup</h3>
<p>브라우저 상단 <strong>확장 아이콘을 클릭했을 때 나타나는 팝업 UI</strong>.  </p>
<blockquote>
<p>크롬 우측 상단에 있는 익스텐션 아이콘을 클릭했을 때 열리는 작은 창을 구성한다.</p>
</blockquote>
<hr>
<h3 id="options">options</h3>
<p><strong>확장 프로그램의 설정 페이지</strong>.  
사용자가 직접 환경을 바꾸거나 옵션을 저장할 수 있는 페이지를 담당한다.</p>
<hr>
<h2 id="빌드-및-결과물-확인">빌드 및 결과물 확인</h2>
<p>이제 기본적인 설정이 모두 끝났다면,<br><strong>npm run build</strong> 명령어로 빌드를 진행해보자.</p>
<p>dist/
├── assets/
│   ├── index-C8iq0p5x.js
│   └── index-EhWt2fYy.css
├── background.js
├── content.css
├── content.js
├── icon.png
├── manifest.json
├── options/
│   ├── index.html
│   └── options.js
└── popup/
    ├── index.html
    └── popup.js</p>
<p>이런 식으로 <strong>vite.config.js</strong> 의 설정을 기반으로<br>익스텐션에 등록할 <strong>빌드 파일들</strong>이 자동으로 생성된다.</p>
<p>하지만 여기서 <strong>가장 중요한 점</strong>은,<br>빌드 후에 <strong>manifest.json 설정이 제대로 되어 있는지 꼭 확인해야 한다</strong>는 것이다.</p>
<p>크롬은 익스텐션을 로드할 때<br>manifest.json을 기준으로 각 스크립트와 리소스를 불러오기 때문에,<br>이 설정이 잘못되어 있으면 <strong>익스텐션이 정상적으로 동작하지 않는다.</strong></p>
<blockquote>
<p>(직접 겪어봤는데… manifest 설정이 꼬이면<br>아무 반응이 없는 익스텐션 아이콘만 덩그러니 남는다 )</p>
</blockquote>
<p>js
// manifest.json 설정예시
{
   &quot;manifest_version&quot;: 3,
   &quot;name&quot;: &quot;Tailwind Chrome Extension&quot;,
   &quot;version&quot;: &quot;1.0.0&quot;,
   &quot;description&quot;: &quot;TailwindCSS 기반 React 크롬 확장 프로그램&quot;,
   &quot;action&quot;: {
      &quot;default_popup&quot;: &quot;popup/index.html&quot;
   },
   &quot;options_page&quot;: &quot;options/index.html&quot;,
   &quot;background&quot;: {
      &quot;service_worker&quot;: &quot;background.js&quot;,
      &quot;type&quot;: &quot;module&quot;
   },
   &quot;content_scripts&quot;: [
      {
         &quot;matches&quot;: [&quot;http://<em>/</em>&quot;, &quot;https://<em>/</em>&quot;],
         &quot;js&quot;: [&quot;content.js&quot;],
         &quot;css&quot;: [&quot;content.css&quot;]
      }
   ],
   &quot;permissions&quot;: [&quot;contextMenus&quot;, &quot;storage&quot;, &quot;activeTab&quot;, &quot;scripting&quot;],
   &quot;icons&quot;: {
      &quot;16&quot;: &quot;icon.png&quot;,
      &quot;48&quot;: &quot;icon.png&quot;,
      &quot;128&quot;: &quot;icon.png&quot;
   }
}</p>
<h1 id="크롬-익스텐션-등록하기">크롬 익스텐션 등록하기</h1>
<p>번들러 설정과 manifest.json 구성이 모두 끝났다면,<br>이제 내가 만든 크롬 익스텐션을 직접 확인해보자.</p>
<hr>
<h3 id="1-크롬-확장-페이지로-이동">1. 크롬 확장 페이지로 이동</h3>
<p>주소창에 chrome://extensions 입력을 한다</p>
<hr>
<h3 id="2-dist-폴더-등록하기">2. dist 폴더 등록하기</h3>
<p>상단의 <strong>‘압축해제된 확장 프로그램 로드’</strong> 버튼을 클릭하고,<br>빌드 결과물로 생성된 <strong>dist 폴더</strong>를 선택하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/dragonperson_kim/post/c25cc2e6-4d00-4c4d-9579-4d394df58c75/image.png" alt=""></p>
<p>이렇게 하면 우리가 익숙하게 알고 있는 형태로<br>확장 프로그램이 크롬에 등록된다 </p>
<hr>
<h3 id="참고">참고</h3>
<p>여기서는 과정을 간단히 요약했지만, 자세한 내용은 <a href="https://developer.chrome.com/docs/extensions?hl=ko">Chrome 공식 문서</a>에서 더 친절하게 확인할 수 있다.</p>
<hr>
<h3 id="✅-정리">✅ 정리</h3>
<p>이번글에선</p>
<ul>
<li><strong>설정</strong>  </li>
<li><strong>빌드</strong>  </li>
<li><strong>크롬 등록</strong></li>
</ul>
<p>까지의 기본적인 흐름을 다루었다.  </p>
<p>다음 챕터에서는 <strong>Tailwind 변환 로직</strong>과  
이를 <strong>사용자에게 시각적으로 보여주는 UI 구성</strong>을 중심으로 포스팅을 할 예정이다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js ISR 도입기]]></title>
            <link>https://velog.io/@dragonperson_kim/%EC%84%9C%EB%B2%84-%EC%BA%90%EC%8B%B1%EA%B3%BC-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BA%90%EC%8B%B1</link>
            <guid>https://velog.io/@dragonperson_kim/%EC%84%9C%EB%B2%84-%EC%BA%90%EC%8B%B1%EA%B3%BC-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BA%90%EC%8B%B1</guid>
            <pubDate>Sat, 06 Sep 2025 03:00:23 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">Intro</h1>
<p>React Query와 Next.js를 기반으로 블로그 프로젝트를 진행하며 이제 마무리 단계에 들어섰다.<br>최근에는 SEO 최적화 과정에서 <strong>조금 더 효율적으로 데이터 패칭을 활용할 수 있는 방법</strong>을 고민하다가, ISR(Incremental Static Regeneration)을 도입하기로 했다.  </p>
<blockquote>
<p>ISR은 정적 페이지를 생성한 뒤, 설정한 주기마다 자동으로 새 데이터로 갱신해주는 기능이다. (GPT)</p>
</blockquote>
<p>그래서 <strong>자주 변경되지 않는 페이지를 대상으로 ISR을 적용</strong>해보기로 했다.  </p>
<hr>
<h1 id="troubleshooting">TroubleShooting</h1>
<h3 id="잘-모르고-사용했던-부분">잘 모르고 사용했던 부분</h3>
<p>처음엔 이렇게 코드를 작성했다:  </p>
<pre><code class="language-js">export async function fetchProfile() {
  const url = `/api/profile`;

  const res = await fetch(url, {
    next: { revalidate: 600 },
  });

  return res.json();
}</code></pre>
<p>페이지는 SSR로 해두었고 fetch에서 revalidate: 600을 주면,
해당 페이지에서 쓰이는 React Query가 invalidate를 해도 600초 동안은 동일한 데이터를 주는 줄 알았다. </p>
<p>하지만 실제로는 그렇지 않았고 ISR 이해를 잘 못했던것 같다.</p>
<h3 id="알게된-점">알게된 점</h3>
<blockquote>
<ul>
<li>revalidate 옵션은 Next.js 서버에서 실행될 때만 캐싱이 적용된다.</li>
</ul>
</blockquote>
<ul>
<li>클라이언트에서 fetch를 호출하면 단순 브라우저 fetch → 항상 네트워크 요청.</li>
<li>따라서 React Query에서 invalidate를 호출하면 무조건 API를 다시 치고 최신 DB 데이터를 가져오게 된다.</li>
</ul>
<p>즉, 클라이언트 캐시(React Query)와 서버 캐시(ISR)는 별개라는 걸 알게 되었다.</p>
<p>ISR은 어떻게 적용할까? 실제로 ISR을 적용하려면 두 가지 방법이 있다.</p>
<pre><code class="language-js">// ✅ 페이지 단위 ISR
export const revalidate = 300;

export default async function Profile() {
  const profile = await prisma.profile.findFirst({});
  return &lt;ProfileItem profile={profile} /&gt;;
}
</code></pre>
<pre><code class="language-js">// ✅ 페이지 단위 ISR
export default async function Profile() {
  const res = await fetch(`${process.env.NEXTAUTH_URL}/api/profile`, {
    next: { revalidate: 300 },
  });
  const profile = await res.json();
  return &lt;ProfileItem profile={profile} /&gt;;
}</code></pre>
<table>
<thead>
<tr>
<th>구분</th>
<th>페이지 ISR (<code>export const revalidate = 300</code>)</th>
<th>fetch ISR (<code>fetch(..., { next: { revalidate: 300 } })</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>캐싱 단위</td>
<td>페이지 전체 (HTML)</td>
<td>특정 fetch 요청 결과 (데이터)</td>
</tr>
<tr>
<td>SEO 영향</td>
<td>캐시된 HTML 그대로 제공 → 크롤러도 캐시된 내용 봄</td>
<td>HTML은 매번 새로 그림 → 크롤러는 항상 최신 HTML 받음</td>
</tr>
<tr>
<td>갱신 방식</td>
<td>300초마다 새 HTML 생성</td>
<td>300초마다 fetch 데이터만 새로 요청</td>
</tr>
<tr>
<td>렌더링 시점</td>
<td>페이지 자체가 캐시에서 서빙됨</td>
<td>페이지는 SSR처럼 실행되지만 fetch 결과는 캐시에서 가져옴</td>
</tr>
<tr>
<td>활용 예시</td>
<td>블로그 글 상세, 태그 페이지처럼 SEO 중요한 곳</td>
<td>외부 API, DB 쿼리처럼 부하 줄이고 싶은 데이터 호출</td>
</tr>
</tbody></table>
<h3 id="직접-사용해보기">직접 사용해보기</h3>
<p><strong>npm run build</strong> 후 결과를 보면,
export const revalidate = 300을 적용했을 때 5m 이라는 문구가 표시되며 페이지가 5분 주기로 캐싱되는 걸 확인할 수 있었다.
그리고 muate 하고 새로고침을 눌렀을때 화면이 변하지 않는다는것을 두눈으로 직접 확인할수 있었다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/467c37a6-e743-45c7-9668-c6efdbda98c5/image.png" alt="">
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/b4a007b1-6f1c-45cc-a597-f16e0061636a/image.gif" alt=""></p>
<p>그렇다면 화면에는 어떻게 업데이트된 내용을 반영할 수 있을까?
방법은 의외로 간단했다. <strong>next/cache</strong>에서 제공하는 기능을 활용하면 된다.
API 내부에서 revalidatePath(&quot;/profile&quot;)를 호출하면, 마치 <strong>React Query</strong>의 <strong>InvalidateQueries</strong>처럼 캐시가 무효화되고 새로운 데이터로 갱신되는 것을 확인할 수 있었다.</p>
<blockquote>
<p>Next.js에서 제공하는 <strong><code>revalidatePath</code></strong>는 서버 캐시에 저장된 페이지를 강제로 무효화(재검증)하는 함수다.<br>ISR을 적용한 페이지는 보통 <code>revalidate</code>로 설정한 주기마다 새로운 데이터를 가져오지만, <code>revalidatePath</code>를 사용하면 <strong>주기와 상관없이 즉시 최신 데이터로 갱신</strong>할 수 있다. 
예를 들어, <code>/profile</code> 페이지가 <code>export const revalidate = 300;</code> 으로 설정되어 있다면  기본적으로는 300초(5분)마다 새로운 데이터를 패칭한다.
하지만 사용자가 프로필을 수정하는 API를 호출할 때, 
다음과 같이 <code>revalidatePath</code> 를 실행하면   300초를 기다리지 않아도 즉시 새로운 데이터로 갱신된다.</p>
</blockquote>
<h1 id="conclusion">Conclusion</h1>
<p>돌아보니 너무 복잡하게 생각했던 것 같다. 이번 과정을 통해 <strong>React Query가 정말 잘 만든 라이브러리</strong>라는 걸 다시 한번 느꼈다. 웬만한 상황은 클라이언트 단에서 충분히 해결할 수 있기 때문이다.  </p>
<p>또한 캐싱을 다룰 때는 <strong>클라이언트 레이어와 서버 레이어의 의미를 명확히 구분</strong>하고, 상황에 맞게 사용하는 것이 중요하다는 것도 깨달았다.  </p>
<p>마지막으로 ISR을 적절히 활용하면서 <strong>Lighthouse 점수도 80점대까지 끌어올릴 수 있었다</strong>는 점에서, 성능 최적화 측면에서도 큰 효과를 얻을 수 있었다.  </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React 리렌더링 memo와 useCallback]]></title>
            <link>https://velog.io/@dragonperson_kim/React%EC%9D%98-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%B0%A9%EC%A7%80</link>
            <guid>https://velog.io/@dragonperson_kim/React%EC%9D%98-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%B0%A9%EC%A7%80</guid>
            <pubDate>Mon, 25 Aug 2025 10:10:59 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">Intro</h1>
<p>사이드 프로젝트 진행하다가 발생한 문제는 아니고 문득 GPT에게 이것저것 질문을 하던중 <strong>usememo, usecallback, React.memo</strong>를 이용한 렌더링 최적화 주제가 나왔었다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/807e58cb-60df-4723-82dd-22608f943d43/image.gif" alt="">
아차 싶었다..
내가 데이터를 받아서 컴포넌트를 뿌려줄때 해당 컴포넌트에 그런 처리를 해줬던가? 
사이드 프로젝트에서 제일 많이 쓰이는 컴포넌트를 확인해봤다.</p>
<pre><code class="language-ts">const TagItem = (props: AnyTagItemProps) =&gt; {
  return (
    &lt;&gt;
      &lt;Badge
        theme={{
          root: {
            color: {
              gray: &quot;bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600&quot;,
              cyan: &quot;bg-cyan-100 text-cyan-800 hover:bg-cyan-200 dark:bg-cyan-200 dark:text-cyan-900 dark:hover:bg-cyan-300&quot;,
            },
          },
        }}
        color={
          props.mode == &quot;check&quot; ? (props.isChecked ? &quot;cyan&quot; : &quot;gray&quot;) : &quot;cyan&quot;
        }
        size=&quot;md&quot;
      &gt;
        &lt;button className=&quot;cursor-pointer&quot; onClick={props.clickEvt}&gt;
          {props.text}
        &lt;/button&gt;
      &lt;/Badge&gt;
    &lt;/&gt;
  );
};

export default TagItem;</code></pre>
<p>그런거 안되어있네.. 
React Developer Tools 을 사용하여 확인해보자.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/9c96d44f-98c7-48a3-b977-343b73bbf71c/image.png" alt="">
TagItem 컴포넌트는 PostFilter의 자식으로 사용 중인데, 부모에서 내려주는 checked props 값이 바뀔 때마다 모든 TagItem이 함께 리렌더링되는 문제가 있었다.
그럼 어떻게 해야 할까?</p>
<h1 id="troubleshooting">TroubleShooting</h1>
<p>처음에 말했던 내용처럼 React.memo를 사용하여 TagItem을</p>
<pre><code class="language-ts">export default memo(TagItem, (prev, next) =&gt; {
  if (prev.mode !== next.mode) return false;
  if (prev.mode === &quot;check&quot; &amp;&amp; next.mode === &quot;check&quot;) {
    if (prev.isChecked !== next.isChecked) return false;
  }
  if (prev.text !== next.text) return false;
  if (prev.id !== next.id) return false;
  if (prev.body !== next.body) return false; // body 비교 추가
  if (prev.clickEvt !== next.clickEvt) return false;
  if (prev.clickValueType !== next.clickValueType) return false;
  return true;
});</code></pre>
<p>이렇게 감싸주었고 clickEvt 함수 prop이라 부모 컴포넌트에서 해당 함수를 useCallback 처리를 해서 전달해주었다. 그랬더니 짜잔
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/005807e4-178c-442c-a45b-3c449effa25a/image.png" alt="">
TagItem을 라디오 버튼처럼 쓰고 있었는데, 생각해보니 체크가 바뀔 때 필요한 건 새로 선택된 애랑 기존에 선택 해제되는 애, 딱 두 개뿐이었다. 굳이 전부 리렌더링될 필요가 없었고, 체크된 거랑 해제된 거만 바꿔주면 충분했고 위 이미저처럼 만족스럽게 바뀌었다.</p>
<p>그럼 memo는 뭐길래 컴퍼넌트를 감싸주면 리렌더링이 방지되는 것일까? GPT에게 물어보자.</p>
<blockquote>
<p>React.memo는 컴포넌트가 받은 props를 얕게(shallow) 비교해서 값이 안 바뀌면 이전 렌더링 결과를 그대로 재사용한다. 그래서 실제로 바뀐 props가 있을 때만 리렌더링이 일어난다. </p>
</blockquote>
<p>그럼 useCallback은 왜쓰는걸까?</p>
<blockquote>
<p>부모가 리렌더링되면, 그 안에서 새로 정의된 함수는 매번 새로운 참조(주소)를 갖게되고
이 함수를 자식 컴포넌트에 props로 넘기면, 자식 입장에서는 &quot;props가 바뀌었다&quot;고 판단해서 불필요하게 리렌더링될 수 있음.
useCallback으로 감싸면 함수 참조를 메모이제이션해서, deps가 변하지 않는 이상 같은 함수 객체를 계속 쓰게 된다.</p>
</blockquote>
<h1 id="conclusion">Conclusion</h1>
<p>부끄럽지만 내 사이드 프로젝트에서는 <strong>React.memo, useCallback, useMemo</strong>를 제대로 활용하지 못했었다. 예전에 설명과 예제를 흘려보듯 본 적은 있었지만 크게 와닿지는 않았었다. 그런데 이번 프로젝트에서 직접 적용해보면서, 이게 얼마나 중요한 부분이고 또 리액트에서 리렌더링 최적화를 얼마나 세심하게 신경 써야 하는지 뼈저리게 느끼게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[useInfiniteQuery 오류 처리]]></title>
            <link>https://velog.io/@dragonperson_kim/Temp-Title</link>
            <guid>https://velog.io/@dragonperson_kim/Temp-Title</guid>
            <pubDate>Tue, 19 Aug 2025 11:26:41 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">Intro</h1>
<p>지난번에 이어 사이드 프로젝트를 진행하던 중 한 가지 놓치고 있던 부분이 있었다.
바로 useInfiniteQuery를 사용해 무한 스크롤을 구현할 때, API로부터 데이터를 받아오지 못했을 경우 어떻게 처리해야 하는가라는 점이다.</p>
<p>무한 스크롤은 기본적으로 사용자가 스크롤을 내릴 때마다 새로운 페이지를 불러와 이어 붙이는 방식이다. 그런데 네트워크 오류나 서버 문제로 인해 다음 데이터를 가져오지 못한다면, UI가 멈추거나 빈 화면이 보이는 경험을 주어서는 안 된다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/ec4fb8fd-fdb9-4905-8ac4-ceb4fe9cdd98/image.png" alt="">
(GPT에게 물어보니 그렇다한다 ㅋㅋ;)</p>
<p>일단 피드를 불러오는 API응답값을 이렇게 바꿨다</p>
<pre><code class="language-js">  if(pageNumber) throw new Error(&#39;error!!&#39;)
  return NextResponse.json({
      ok: true,
      data: postData,
      totalCount,
    });
  } catch (e: any) {
    return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
  } finally {
  }</code></pre>
<p>그리고 상황을 얼추 맞추기위해서 다음 데이터를 불러올때 throw를 주었다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/1c262661-86b4-4e48-93cd-fcd939a1836a/image.png" alt="">
위처럼 데이터를 2번 요청했을때 에러가 났다는걸 볼수 있었다. 이제 클릭하면 실패한 부분을 다시 fetch 하겠지? 
<strong>엥 ? 클릭했는데 아무런 반응이 없는것이였다..</strong> 이게 뭐지 싶어 동작하는 부분의 코드를 찍어봤는데
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/77d94759-9d9f-4710-a3dc-7d489e308538/image.png" alt="">
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/bd7ff7be-0267-41b8-8a3a-482edc55ef7a/image.png" alt="">
lastPage를 체크해주는 코드에서 ok가 false라서 undefined가 나고 막혀서 더이상 fetch를 진행하지 않는것이였다. 그럼 어떡하지? GPT한테 물어봤는데 nextFetch만 하면된단다(...)</p>
<h1 id="troubleshooting">TroubleShooting</h1>
<p>GPT한테 답을 얻을수 없어서 이걸 refetch를 해야하나? 그렇게 하긴 싫은데..라고 생각하며 끙끙 거리고있을때 lastPage를 어떻게 하면 다시 그대로 fetch할수 있지 않을까 하고 GPT를 달달 볶던중 키워드를 얻고 해당 정보를 얻을수 있었다.
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/465e0364-72c3-4743-b6c8-682916234fb4/image.png" alt="">
그렇다면 setQueryData를 사용해 실패한 데이터를 지운다면 lastPage가 바뀌지않을까?
그래서 버튼의 이벤트를 fetchNextPage 대신 이런식으로
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/f36b9b61-5e0f-4bf6-97a6-3b1d1b7170ce/image.png" alt="">
이렇게 바꿔주었고 두근거리는 마음으로 버튼을 다시 클릭하자
<img src="https://velog.velcdn.com/images/dragonperson_kim/post/99aec51a-646c-4e41-8f96-50890fabfc06/image.png" alt="">
이런식으로 실패한 page=1이 재요청 되는걸 확인할수 있었다.</p>
<h1 id="conclusion">Conclusion</h1>
<p>간단하게만 생각했는데 막상 해보니 생각보다 까다로웠다.
API로부터 데이터를 받아 화면에 뿌려주는 것까지는 비교적 단순하고 예상도 가능했지만, 내가 직접 API를 짜는 데 서툴기도 하고, 상황에 따라 발생하는 오류를 어떻게 처리해 사용자와 개발자 모두에게 자연스럽게 보여줄지가 정말 어렵게 느껴졌다. 지금 시도하고 있는 방식이 정답인지는 잘 모르겠지만, 일단 해야 할 일이 많으니 다음 단계로 나아가 보려고 한다.</p>
]]></description>
        </item>
    </channel>
</rss>