<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>link_dropper.log</title>
        <link>https://velog.io/</link>
        <description>“이것저것 만들면서 배우는 중입니다”</description>
        <lastBuildDate>Mon, 13 Apr 2026 09:13:21 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>link_dropper.log</title>
            <url>https://velog.velcdn.com/images/link_dropper/profile/a725f65d-de17-4898-8584-501a6ceede73/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. link_dropper.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/link_dropper" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[990원짜리 사주 서비스에 7,000명이 결제했다 — 그래서 관상도 보기로 했다]]></title>
            <link>https://velog.io/@link_dropper/990%EC%9B%90%EC%A7%9C%EB%A6%AC-%EC%82%AC%EC%A3%BC-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-7000%EB%AA%85%EC%9D%B4-%EA%B2%B0%EC%A0%9C%ED%96%88%EB%8B%A4-%EA%B7%B8%EB%9E%98%EC%84%9C-%EA%B4%80%EC%83%81%EB%8F%84-%EB%B3%B4%EA%B8%B0%EB%A1%9C-%ED%96%88%EB%8B%A4</link>
            <guid>https://velog.io/@link_dropper/990%EC%9B%90%EC%A7%9C%EB%A6%AC-%EC%82%AC%EC%A3%BC-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-7000%EB%AA%85%EC%9D%B4-%EA%B2%B0%EC%A0%9C%ED%96%88%EB%8B%A4-%EA%B7%B8%EB%9E%98%EC%84%9C-%EA%B4%80%EC%83%81%EB%8F%84-%EB%B3%B4%EA%B8%B0%EB%A1%9C-%ED%96%88%EB%8B%A4</guid>
            <pubDate>Mon, 13 Apr 2026 09:13:21 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>안녕하세요, 1편에서 &#39;운명 분석 엔진&#39; 이야기를 들려드린 지 벌써 시간이 꽤 흘렀네요. 오랜만에 인사드립니다. 990원이라는 가격에 반신반의하며 올렸던 서비스에 감사하게도 7,000명이라는 유저분이 다녀가셨고, 그 지표들을 분석하며 다음 스텝을 준비하느라 꽤 바쁜 시간을 보냈습니다.</p>
</blockquote>
<hr>
<h2 id="0-근황-7000번의-데이터로-검증한-가설">0. 근황: 7,000번의 데이터로 검증한 가설</h2>
<p>1편을 작성할 당시 제 마음은 &quot;내가 만든 알고리즘이 시장에서 통할까?&quot;라는 호기심이 컸습니다. 990원이라는 가격은 수익보다는 &#39;최소한의 결제 허들&#39;을 넘기는지 확인하기 위한 장치였죠.</p>
<p>결과적으로 <strong>7,000명</strong>이 넘는 유저가 유료 결제를 했습니다. 별다른 마케팅 예산 없이 SEO와 커뮤니티 공유만으로 일궈낸 수치라 더 의미가 있었습니다. 이 과정을 통해 한 가지 확실한 비즈니스 인사이트를 얻었습니다.</p>
<p><strong>&quot;사람들은 자기 자신에 대한 이야기, 그리고 남에게 보여주고 싶은 콘텐츠에 기꺼이 지갑을 연다.&quot;</strong></p>
<hr>
<h2 id="1-사주-다음은-왜-관상인가">1. 사주 다음은 왜 &#39;관상&#39;인가?</h2>
<p>인생스포(사주)를 운영하며 유저들의 행동 패턴을 분석해 보니, 다음 스텝은 자연스럽게 &#39;이미지&#39;로 향했습니다. 관상은 사주와 같은 결의 도메인이지만 개발자로서 매력적인 UX 포인트가 몇 가지 더 있었습니다.</p>
<ul>
<li><strong>압도적으로 낮은 진입 장벽:</strong> 사주는 태어난 &#39;시(時)&#39;를 모르면 정확도가 떨어지지만, 관상은 셀카 한 장이면 끝납니다. 3초면 인풋이 완성되죠.</li>
<li><strong>공유의 트리거:</strong> 텍스트 위주의 사주 결과보다, &#39;내 얼굴&#39;과 &#39;캐릭터 이미지&#39;가 결합된 결과물이 SNS(Threads, X 등)에서 훨씬 더 강력한 전파력을 가집니다.</li>
</ul>
<p>그래서 만들었습니다. &#39;보이지 않는 운명&#39;에 이어 &#39;보이는 운명&#39;을 분석하는 서비스, <strong>관상스포</strong>입니다.</p>
<hr>
<h2 id="2-셀카-한-장에서-관상-데이터를-뽑아내는-과정">2. 셀카 한 장에서 관상 데이터를 뽑아내는 과정</h2>
<p>단순히 &quot;AI가 사진 보고 말해줍니다&quot;라고 하면 기술적으로 재미가 없죠. 관상의 신뢰도를 높이기 위해 설계한 <strong>데이터 추출 파이프라인</strong>을 소개합니다.</p>
<h3 id="①-사진에-얼굴이-있긴-한-거야--얼굴-감지">① 사진에 얼굴이 있긴 한 거야? — 얼굴 감지</h3>
<p>사용자가 사진을 올리면 가장 먼저 <strong>&quot;이 사진에 얼굴이 있는가&quot;</strong>를 판단해야 합니다. 풍경이나 음식 사진이 올라와 서버 비용이 새나가는 것을 막기 위해 <strong>온디바이스 ML(On-device ML)</strong>을 활용했습니다. 서버 왕복 없이 브라우저에서 즉시 얼굴 유무를 판단하고 업로드를 제어합니다.</p>
<h3 id="②-얼굴을-숫자로-바꾸기--관상학-지표-수치화">② 얼굴을 숫자로 바꾸기 — 관상학 지표 수치화</h3>
<p>AI에게 사진만 던지면 &quot;느낌적인 느낌&quot;으로만 대답하는 문제가 발생합니다. &quot;눈이 좀 큰 것 같네요&quot; 같은 모호한 표현을 피하기 위해, 얼굴 위에 수백 개의 랜드마크 좌표를 찍고 관상학 프레임으로 수치화했습니다.</p>
<ul>
<li><strong>오관(五官):</strong> 눈썹, 눈, 코, 입, 귀의 형태와 비율 측정.</li>
<li><strong>삼정(三停):</strong> 이마 / 눈<del>코 / 코</del>턱의 세로 비율 측정.</li>
</ul>
<p>여기서 핵심은 <strong>&#39;절대 길이&#39;가 아닌 &#39;비율&#39;</strong>을 사용하는 것입니다. 그래야 카메라와의 거리나 사진 크기에 상관없이 일관된 분석 결과가 나옵니다.</p>
<h3 id="③-ai에게-사진--수치를-함께-전달">③ AI에게 사진 + 수치를 함께 전달</h3>
<p>이렇게 추출된 수치 데이터는 AI(Gemini)에게 사진과 함께 전달됩니다. 수치가 <strong>&#39;앵커(Anchor)&#39;</strong> 역할을 해주기 때문에, AI는 훨씬 구체적이고 근거 있는 해석을 내놓을 수 있습니다.</p>
<blockquote>
<p><strong>AS-IS (사진만):</strong> &quot;눈이 큰 편이시네요.&quot;
<strong>TO-BE (사진+수치):</strong> &quot;눈꼬리가 위로 12도 가량 올라가 있어 날카로운 인상을 주지만, 눈의 세로 폭이 넓어 차가운 인상을 보완해 줍니다.&quot;</p>
</blockquote>
<hr>
<h2 id="3-기획의-핵심-공유를-부르는-소름-포인트">3. 기획의 핵심: 공유를 부르는 &#39;소름 포인트&#39;</h2>
<p>개발자로서 서비스의 성능만큼 고민한 것이 <strong>&#39;어떻게 하면 유저가 이 화면을 캡처하게 만들까?&#39;</strong>였습니다. 관상스포의 리포트 구조는 철저히 이 &#39;공유 트리거&#39;에 집중했습니다.</p>
<ul>
<li><strong>숫자의 구체성:</strong> &quot;성격이 밝네요&quot;보다 <strong>&quot;겉과 속의 괴리율 78%&quot;</strong>라는 숫자가 훨씬 강력합니다. 사람들은 정량화된 수치에 반응하고, 이를 근거로 친구들과 대화를 시작합니다.</li>
<li><strong>자극적인 키워드:</strong> &#39;조심해야 할 빌런 관상&#39;이나 &#39;찰떡 궁합 동물상&#39; 같은 섹션을 배치해, 혼자 보고 끝내는 것이 아니라 주변 지인들을 태그하거나 연인과 함께해보도록 유도했습니다.</li>
</ul>
<hr>
<h2 id="4-왜-하필-gemini-flash인가">4. 왜 하필 Gemini Flash인가?</h2>
<p>비즈니스 측면에서 가장 중요한 것은 <strong>Unit Economics(건당 경제성)</strong>였습니다. 990원짜리 서비스에서 모델 호출 비용이 수익의 절반을 차지하면 지속 불가능하니까요.</p>
<ul>
<li><strong>Cost-Efficiency:</strong> Gemini 1.5 Flash는 이미지 인지 능력이 뛰어나면서도 토큰당 비용이 압도적으로 저렴합니다. </li>
<li><strong>Native Multimodal:</strong> 별도의 Vision 모델을 거치지 않고 직접 이미지를 컨텍스트로 수용하기 때문에 지연 시간(Latency)을 획기적으로 줄일 수 있었습니다.</li>
<li><strong>Prompt Engineering:</strong> 수치가 포함된 프롬프트를 통해 &quot;점술가 같으면서도 현대적인&quot; 밸런스를 찾는 데 집중했습니다.</li>
</ul>
<hr>
<h2 id="5-마치며-사이드-프로젝트는-시리즈물이다">5. 마치며: 사이드 프로젝트는 &#39;시리즈물&#39;이다</h2>
<p>인생스포를 만들지 않았다면 관상스포는 세상에 나오지 못했을 겁니다. 7,000명의 유저 데이터와 피드백이 있었기에 &quot;사진 기반의 서비스가 더 확장성이 크겠다&quot;는 확신을 가질 수 있었죠.</p>
<p>결국 <strong>사이드 프로젝트는 마침표가 아니라 다음 프로젝트로 연결되는 징검다리</strong>라고 생각합니다. 앞선 프로젝트에서 확보한 유저들이 다음 서비스의 마중물이 되고, 두 서비스가 서로 크로스셀링(Cross-selling)되는 구조를 만드는 경험은 1인 개발자로서 매우 값진 자산이 되었습니다.</p>
<p>과연 7,000명의 사주 유저들이 얼굴도 보여줄까요? 지표가 나오면 또 공유해 보겠습니다.</p>
<hr>
<p><strong>관상스포 구경하기:</strong> <a href="https://life-spoiler.com/face-spoiler">https://life-spoiler.com/face-spoiler</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + Supabase로 '운명 분석 엔진' 만들고 990원에 푼 썰]]></title>
            <link>https://velog.io/@link_dropper/life-spoiler-launch</link>
            <guid>https://velog.io/@link_dropper/life-spoiler-launch</guid>
            <pubDate>Wed, 28 Jan 2026 08:26:39 GMT</pubDate>
            <description><![CDATA[<p>수많은 SI 프로젝트와 서비스를 런칭해봤지만, 이번엔 좀 독특한 도메인에 도전했습니다. 바로 <strong>&#39;점술(Fortune Telling)&#39;</strong> 입니다.</p>
<p>&quot;개발자가 무슨 미신이냐&quot; 하실 수 있겠지만, 로직을 파고들다 보니 이거... <strong>완벽한 알고리즘과 데이터베이스의 세계</strong>였습니다.</p>
<p>그래서 만들었습니다.
<strong><a href="https://life-spoiler.com">라이프 스포일러 (Life Spoiler)</a></strong></p>
<p>오늘은 이 서비스를 만들게 된 기술적 배경과 990원이라는 마이크로 BM(Business Model)을 설정한 이유를 회고 형식으로 공유합니다.</p>
<hr>
<h2 id="1-tech-stack--왜-nextjs--supabase-인가">1. Tech Stack : 왜 Next.js + Supabase 인가?</h2>
<p>빠른 MVP(Minimum Viable Product) 출시와 유지보수 편의성을 위해 &#39;국밥 조합&#39;을 선택했습니다.</p>
<ul>
<li><strong>Frontend: Next.js (App Router)</strong></li>
<li>SEO 최적화는 기본이고, 정적인 텍스트 결과가 많은 사주 서비스 특성상 SSR(Server Side Rendering)의 이점을 최대한 살렸습니다.</li>
<li>결과 페이지의 로딩 속도를 줄여 사용자 이탈을 막는 데 주력했습니다.</li>
</ul>
<ul>
<li><strong>Backend &amp; DB: Supabase</strong></li>
<li>사이드 프로젝트의 영원한 친구. Auth 처리부터 DB 관리까지 백엔드 리소스를 획기적으로 줄여줬습니다.</li>
<li>자미두수의 방대한 별자리 데이터와 해석 데이터를 RDBMS 형태로 구조화하여 쿼리 속도를 최적화했습니다.</li>
</ul>
<h2 id="2-도메인-로직--자미두수紫微斗數는-코딩이다">2. 도메인 로직 : 자미두수(紫微斗數)는 코딩이다</h2>
<p>보통 사주(명리학)를 많이 보지만, 개발자 관점에서는 <strong>자미두수</strong>가 훨씬 매력적입니다. 명리학이 &#39;기운&#39;과 &#39;조화&#39; 같은 추상적인 느낌이라면, 자미두수는 <strong>별(Star)들의 배치(Layout)와 상호작용(Event Logic)</strong> 으로 이루어져 있기 때문입니다.</p>
<ul>
<li><strong>Input:</strong> 생년월일시 (사용자 파라미터)</li>
<li><strong>Process:</strong> 100여 개의 별을 특정 궁(Position)에 배치하는 알고리즘 연산</li>
<li><strong>Output:</strong> 정확한 운명 데이터 도출</li>
</ul>
<p>마치 복잡한 조건문이 얽힌 레거시 코드를 리팩토링해서, <strong>&quot;너 이때 에러(사고) 터질 거야&quot;, &quot;이때 트래픽(재물) 폭주할 거야&quot;</strong> 라고 알려주는 모니터링 시스템과 비슷하다는 느낌을 받았습니다.</p>
<p>직접 제 데이터를 넣고 돌려봤을 때, 지난 20년의 굴곡이 정확히 매핑되는 걸 보고 소름이 돋아 배포를 결심했습니다.</p>
<h2 id="3-pricing--왜-하필-990원인가">3. Pricing : 왜 하필 990원인가?</h2>
<p>시중 점술 서비스는 비쌉니다. (보통 1~3만 원)
하지만 저는 개발자답게 생각하기로 했습니다.</p>
<blockquote>
<p>*&quot;어차피 로직은 내가 짰고, 들어가는 건 서버비와 API 비용뿐인데?&quot;*</p>
</blockquote>
<p>SaaS 모델처럼 접근 장벽을 극단적으로 낮추고 싶었습니다.
<strong>트래픽(Traffic)이 곧 깡패</strong>라는 생각으로, 누구나 부담 없이 자신의 운명을 &#39;디버깅&#39; 해볼 수 있는 가격.</p>
<ul>
<li><strong>평생 총운:</strong> <strong>990원</strong></li>
<li><strong>올해(2026) 신년 운세:</strong> <strong>990원</strong></li>
</ul>
<p>커피 한 잔 값도 안 됩니다. 개발자분들이라면 아시겠지만, 이 가격은 사실상 마진보다는 <strong>&quot;내 알고리즘이 얼마나 맞는지 테스트해보고 싶은 욕구&quot;</strong>가 더 컸습니다.</p>
<h2 id="4-마치며--인생도-디버깅이-필요하다">4. 마치며 : 인생도 디버깅이 필요하다</h2>
<p>우리는 코드를 짤 때 항상 예외 처리(Exception Handling)를 합니다.
그런데 정작 우리 인생에는 <code>try-catch</code> 문을 걸어두지 않고 살고 있진 않나요?</p>
<p><strong><a href="https://life-spoiler.com">라이프 스포일러</a></strong> 는 여러분 인생의 로그(Log)를 미리 보여주는 서비스입니다.
미리 알면 대비할 수 있고, 피해 갈 수 있습니다.</p>
<p>개발자 동료 여러분,
심심하실 때 재미 삼아 접속해서 여러분의 <strong>2026년 Config 파일</strong>을 한번 열어보세요.
생각보다 소름 돋게 맞을지도 모릅니다.</p>
<p><strong>👉 <a href="https://life-spoiler.com">서비스 바로가기</a></strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[“PR 올리기”가 개발의 절반을 잡아먹는다면: /pr + /apply-review로 커밋→PR→리뷰 반영 자동화하기]]></title>
            <link>https://velog.io/@link_dropper/pr-apply-review</link>
            <guid>https://velog.io/@link_dropper/pr-apply-review</guid>
            <pubDate>Tue, 13 Jan 2026 01:46:15 GMT</pubDate>
            <description><![CDATA[<h2 id="시리즈-안내">시리즈 안내</h2>
<table>
<thead>
<tr>
<th>순서</th>
<th>제목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1편</td>
<td>Claude Code Commands로 개발 워크플로우 자동화하기</td>
<td>전체 개요</td>
</tr>
<tr>
<td>2편</td>
<td>/spec - 명세서 기반 코드 구현 자동화</td>
<td>가장 핵심인 구현 자동화</td>
</tr>
<tr>
<td>3편</td>
<td>/create-jest - 테스트 코드 자동 생성</td>
<td>테스트 작성 자동화</td>
</tr>
<tr>
<td>4편</td>
<td>/w-context - AI를 위한 문서 작성</td>
<td>CONTEXT.md 개념과 활용</td>
</tr>
<tr>
<td><strong>5편</strong></td>
<td><strong>/pr + /apply-review - PR 워크플로우 자동화</strong></td>
<td><strong>PR 생성부터 리뷰 반영까지 (현재 글)</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>코드 다 짰어요. 이제 PR 올리면 끝…일 줄 알았죠.
근데 PR 단계에서 갑자기 일이 늘어납니다.</p>
<ul>
<li>“커밋 메시지 뭐라고 쓰지?”</li>
<li>“PR 설명에 뭘 써야 하지?”</li>
<li>“리뷰 달렸네… 또 수정해야 하나…”</li>
<li>“코멘트 5개 달렸는데 하나씩 보기 귀찮아…”</li>
</ul>
<p>가끔은 이런 생각도 들어요.</p>
<blockquote>
<p>내가 개발을 하고 있는 건지, PR 매니저를 하고 있는 건지…</p>
</blockquote>
<p>그래서 만든 게 <code>/pr</code>과 <code>/apply-review</code>입니다.</p>
<blockquote>
<p><strong>코드 작성 → PR 생성 → 리뷰 반영</strong>
이 전체 흐름을 <strong>명령어 두 개</strong>로 끝내버리는 자동화.</p>
</blockquote>
<hr>
<h2 id="pr이-하는-일-pr-올리는-과정을-통째로-줄이기"><code>/pr</code>이 하는 일: “PR 올리는 과정”을 통째로 줄이기</h2>
<p>실행은 한 줄입니다.</p>
<pre><code class="language-bash">/pr</code></pre>
<p>하지만 내부적으로는 아래를 순서대로 처리합니다.</p>
<ol>
<li><strong>변경 사항 분석</strong> — <code>git diff</code>로 뭐가 바뀌었는지 파악</li>
<li><strong>커밋 메시지 생성</strong> — 변경 내용을 요약한 메시지 작성</li>
<li><strong>커밋 실행</strong> — 스테이징 → 커밋</li>
<li><strong>브랜치 푸시</strong> — 원격 저장소로 push</li>
<li><strong>PR 생성</strong> — GitHub에 Pull Request 생성 (gh CLI 활용)</li>
</ol>
<p>즉, 개발자는 “코드만 수정”하고
나머지 PR 관련 반복 작업은 <code>/pr</code>이 처리합니다.</p>
<hr>
<h2 id="커밋-메시지-자동이지만-의미-있게-만들기">커밋 메시지: 자동이지만 ‘의미 있게’ 만들기</h2>
<p>자동 커밋 메시지라고 하면 걱정부터 들죠.</p>
<blockquote>
<p>“Update file”
“Fix bug”
이런 거 나오면 오히려 히스토리가 망한다…</p>
</blockquote>
<p>그래서 저는 커밋 메시지를 <strong>규칙으로 강제</strong>했습니다.</p>
<h3 id="1-conventional-commits-형식-고정">1) Conventional Commits 형식 고정</h3>
<pre><code class="language-text">&lt;type&gt;(&lt;scope&gt;): &lt;description&gt;

&lt;body&gt;</code></pre>
<p>예시:</p>
<pre><code class="language-text">feat(auth): 카카오 로그인 기능 추가

- 카카오 OAuth 연동
- 토큰 저장 및 갱신 로직 구현
- 로그인 화면 UI 추가</code></pre>
<p>이 형식의 장점은 딱 하나입니다.
<strong>커밋을 읽는 순간 변경의 성격이 보인다</strong>는 것.</p>
<h3 id="2-타입-자동-분류-featfixrefactor">2) 타입 자동 분류 (feat/fix/refactor…)</h3>
<p>Claude가 변경 내용을 보고 타입을 고릅니다.</p>
<table>
<thead>
<tr>
<th>타입</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>feat</td>
<td>새로운 기능</td>
</tr>
<tr>
<td>fix</td>
<td>버그 수정</td>
</tr>
<tr>
<td>refactor</td>
<td>리팩토링(기능 변경 없음)</td>
</tr>
<tr>
<td>style</td>
<td>포맷/스타일 변경</td>
</tr>
<tr>
<td>docs</td>
<td>문서 변경</td>
</tr>
<tr>
<td>test</td>
<td>테스트 추가/수정</td>
</tr>
<tr>
<td>chore</td>
<td>빌드/설정 등 기타</td>
</tr>
</tbody></table>
<p>타입이 일관되면 릴리즈 노트 생성이나 변경 추적도 훨씬 편해져요.</p>
<h3 id="3-설명은-한글로-팀-컨벤션-반영">3) 설명은 한글로 (팀 컨벤션 반영)</h3>
<p>저희 팀은 한글이 더 빨리 읽혀서, 한글로 고정했습니다.</p>
<pre><code class="language-text">feat(api): 재시도 로직 추가

- 네트워크 에러 시 최대 3회 재시도
- 지수 백오프 적용 (1초, 2초, 4초)
- 타임아웃 설정 추가</code></pre>
<p>이 부분은 팀 성향에 따라 영어로 바꿔도 되고, scope 규칙을 더 엄격히 해도 됩니다.</p>
<hr>
<h2 id="pr-설명-리뷰어가-코드부터-열지-않게-만드는-자동-생성">PR 설명: “리뷰어가 코드부터 열지 않게” 만드는 자동 생성</h2>
<p>PR 본문은 팀 생산성에서 생각보다 큰 비중을 차지합니다.
본문이 빈약하면 리뷰어가 <strong>코드부터 열어서 맥락을 추측</strong>해야 하거든요.</p>
<p><code>/pr</code>은 PR 본문도 템플릿 기반으로 생성합니다.</p>
<h3 id="기본-템플릿-구조">기본 템플릿 구조</h3>
<pre><code class="language-md">## Summary
- 변경 사항 요약 (1-3 bullet points)

## Test plan
- [ ] 테스트 항목 1
- [ ] 테스트 항목 2</code></pre>
<h3 id="실제-생성-예시">실제 생성 예시</h3>
<pre><code class="language-md">## Summary
- 카카오 로그인 기능 추가
- 기존 Apple/Google 로그인과 동일한 인터페이스로 통합
- 로그인 화면에 카카오 버튼 추가

## Test plan
- [ ] 카카오 계정으로 로그인 성공 확인
- [ ] 토큰 만료 시 자동 갱신 확인
- [ ] 로그아웃 후 재로그인 정상 동작 확인
- [ ] 기존 Apple/Google 로그인 영향 없음 확인</code></pre>
<p>이렇게 되면 리뷰어가 PR을 열자마자 “이 PR이 뭘 하는지”가 보이고,
테스트 플랜까지 있으니 질문이 줄어듭니다.</p>
<hr>
<h2 id="apply-review-리뷰-대응을-작업이-아니라-반영으로-만들기"><code>/apply-review</code>: 리뷰 대응을 “작업”이 아니라 “반영”으로 만들기</h2>
<p>PR 올리면 끝이 아니라, 이제 리뷰가 오죠.</p>
<pre><code class="language-text">- &quot;이 함수 이름 좀 더 명확하게 바꿔주세요&quot;
- &quot;에러 핸들링이 빠진 것 같아요&quot;
- &quot;타입을 더 구체적으로 해주세요&quot;
- &quot;테스트 케이스 추가해주세요&quot;</code></pre>
<p>이걸 하나씩 열고, 파일 찾고, 수정하고, 커밋하고, 푸시하고…
리뷰 반영 자체가 일입니다.</p>
<p>그래서 <code>/apply-review</code>는 한 줄로 시작합니다.</p>
<pre><code class="language-bash">/apply-review</code></pre>
<hr>
<h2 id="apply-review가-하는-일"><code>/apply-review</code>가 하는 일</h2>
<ol>
<li><strong>리뷰 코멘트 수집</strong> — 현재 브랜치의 PR에서 모든 코멘트를 가져옴</li>
<li><strong>코멘트 분석</strong> — 각 코멘트가 요구하는 변경을 분류</li>
<li><strong>코드 수정</strong> — 요청 내용대로 반영</li>
<li><strong>반영 커밋 생성</strong> — 리뷰 반영 커밋 작성 (요청자 정보 포함)</li>
<li><strong>푸시</strong> — 원격 브랜치에 재푸시</li>
</ol>
<p>즉, 리뷰어의 말이 “할 일 목록”으로 남는 게 아니라
<strong>코드 변경으로 바로 이어지게</strong> 하는 게 목표예요.</p>
<hr>
<h2 id="리뷰-반영-예시-코멘트가-코드로-변환되는-순간">리뷰 반영 예시: 코멘트가 코드로 변환되는 순간</h2>
<h3 id="before-리뷰-코멘트">Before: 리뷰 코멘트</h3>
<pre><code class="language-text">@reviewer: 이 함수 에러 핸들링이 없네요. try-catch로 감싸주세요.</code></pre>
<h3 id="after-코드-수정">After: 코드 수정</h3>
<pre><code class="language-ts">// Before
async function fetchUser(id: string) {
  const response = await fetch(`/users/${id}`);
  return response.json();
}

// After
async function fetchUser(id: string) {
  try {
    const response = await fetch(`/users/${id}`);
    if (!response.ok) {
      throw new ApiError(response.status, &quot;Failed to fetch user&quot;);
    }
    return response.json();
  } catch (error) {
    if (error instanceof ApiError) throw error;
    throw new ApiError(500, &quot;Network error&quot;);
  }
}</code></pre>
<h3 id="자동-생성된-커밋">자동 생성된 커밋</h3>
<pre><code class="language-text">fix(api): 에러 핸들링 추가

- fetchUser 함수에 try-catch 추가
- 응답 상태 코드 검증 추가
- 네트워크 에러 처리 추가

Requested by @reviewer</code></pre>
<p>여기서 제가 좋았던 포인트는 이거예요.</p>
<ul>
<li>“무슨 변경을 했는지”가 커밋 메시지에 남고</li>
<li>“누가 요청했는지”까지 기록되니
나중에 히스토리 추적이 쉬워집니다.</li>
</ul>
<hr>
<h2 id="리뷰-타입별로-다르게-처리합니다">리뷰 타입별로 ‘다르게’ 처리합니다</h2>
<p>리뷰는 전부 “고쳐주세요”가 아니죠.
그래서 <code>/apply-review</code>는 코멘트를 분류해서 행동을 달리합니다.</p>
<h3 id="1-코드-수정-요청">1) 코드 수정 요청</h3>
<pre><code class="language-text">&quot;변수명 더 명확하게 바꿔주세요&quot;
→ 코드 변경 + 커밋</code></pre>
<h3 id="2-질문">2) 질문</h3>
<pre><code class="language-text">&quot;이 로직이 왜 필요한가요?&quot;
→ 코드 주석 추가 또는 PR 코멘트로 설명</code></pre>
<h3 id="3-제안">3) 제안</h3>
<pre><code class="language-text">&quot;이렇게 분리하면 어떨까요?&quot;
→ 적용 여부 판단 후 반영하거나, 거절 사유를 남김</code></pre>
<h3 id="4-승인lgtm">4) 승인(LGTM)</h3>
<pre><code class="language-text">&quot;LGTM!&quot;
→ 액션 없음</code></pre>
<p>결과적으로 “리뷰를 처리하는 방식” 자체가 표준화돼서
리뷰 대응이 훨씬 덜 피곤해집니다.</p>
<hr>
<h2 id="워크플로우-전체-그림-5개-명령어의-완성형">워크플로우 전체 그림: 5개 명령어의 완성형</h2>
<p>이 시리즈에서 소개한 명령어들을 한 번에 연결하면 이렇게 됩니다.</p>
<pre><code class="language-bash">/spec user-notification     # 1) 명세 기반 구현
/create-jest                # 2) 테스트 자동 생성
/w-context                  # 3) 맥락 문서화

/pr                         # 4) 커밋 + PR 생성
/apply-review               # 5) 리뷰 반영 + 재푸시</code></pre>
<p>코드 작성부터 리뷰 반영까지, <strong>명령어 5개로 한 사이클이 완성</strong>됩니다.</p>
<hr>
<h2 id="왜-이-방식이-효과적이었나">왜 이 방식이 효과적이었나</h2>
<h3 id="1-pr-올리는-심리적-허들이-줄어듭니다">1) PR 올리는 심리적 허들이 줄어듭니다</h3>
<p>커밋 메시지/PR 본문 고민이 사라지면, PR을 더 자주 올리게 돼요.
PR이 작아지면 리뷰도 쉬워지고, 충돌도 줄어듭니다.</p>
<h3 id="2-커밋-히스토리-품질이-올라갑니다">2) 커밋 히스토리 품질이 올라갑니다</h3>
<p>수동으로 쓰면 귀찮아서 “fix”만 남발하게 되는데,
자동이더라도 규칙이 있으니 품질이 균일해집니다.</p>
<h3 id="3-리뷰-반영-속도가-빨라집니다">3) 리뷰 반영 속도가 빨라집니다</h3>
<p>리뷰가 달리면 미루게 되잖아요.
근데 <code>/apply-review</code>는 “미루기 어려울 정도로 가볍게” 처리됩니다.</p>
<h3 id="4-리뷰어-작성자-사이의-마찰이-줄어듭니다">4) 리뷰어-작성자 사이의 마찰이 줄어듭니다</h3>
<p>“제가 말한 건 그게 아닌데…” 같은 오해가 줄어드는 편이었어요.
특히 변경사항이 커밋 메시지로 요약되니 커뮤니케이션이 더 명확해집니다.</p>
<hr>
<h2 id="실제-명령어-파일-구조-핵심만">실제 명령어 파일 구조 (핵심만)</h2>
<h3 id="pr-명령어-예시"><code>/pr</code> 명령어 예시</h3>
<pre><code class="language-md"># pr.md

현재 변경사항을 커밋하고 PR을 생성합니다.

## 실행 절차
1. git diff로 변경 사항 확인
2. 변경 내용 분석하여 커밋 메시지 생성
3. 변경 파일 스테이징 및 커밋
4. 원격 브랜치에 푸시
5. gh pr create로 PR 생성

## 커밋 메시지 규칙
- Conventional Commits
- 타입: feat, fix, refactor, style, docs, test, chore
- 설명은 한글

## PR 템플릿
## Summary
- 변경 사항 요약 (1~3개)

## Test plan
- [ ] 테스트 항목들</code></pre>
<h3 id="apply-review-명령어-예시"><code>/apply-review</code> 명령어 예시</h3>
<pre><code class="language-md"># apply-review.md

현재 브랜치의 PR 리뷰를 분석하고 반영합니다.

## 실행 절차
1. 현재 브랜치의 PR 확인
2. gh api로 리뷰 코멘트 수집
3. 코멘트 분석 및 액션 결정
4. 코드 수정
5. 반영 커밋 생성 (리뷰어 정보 포함)
6. 푸시

## 리뷰 타입별 처리
- 수정 요청: 코드 변경 + 커밋
- 질문: 주석 추가 또는 답변
- 제안: 적용 여부 판단
- 승인: 액션 없음</code></pre>
<hr>
<h2 id="커스터마이징-포인트">커스터마이징 포인트</h2>
<p>프로젝트마다 팀 룰이 다르니까, 아래는 쉽게 바꿀 수 있게 열어두는 편이 좋아요.</p>
<h3 id="1-커밋-메시지-언어톤">1) 커밋 메시지 언어/톤</h3>
<pre><code class="language-md">- 영어로 작성
- 동사 원형으로 시작 (Add/Fix/Update/Remove)</code></pre>
<h3 id="2-pr-템플릿-확장">2) PR 템플릿 확장</h3>
<pre><code class="language-md">## Changes
- 상세 변경 내용

## Screenshots
&lt;!-- UI 변경 시 --&gt;

## Checklist
- [ ] 테스트 통과
- [ ] 문서 업데이트(해당 시)</code></pre>
<h3 id="3-브랜치-네이밍-자동화">3) 브랜치 네이밍 자동화</h3>
<pre><code class="language-md">- feat/: 새 기능
- fix/: 버그 수정
- refactor/: 리팩토링
예: feat/kakao-login</code></pre>
<h3 id="4-리뷰-반영-후-자동-코멘트">4) 리뷰 반영 후 자동 코멘트</h3>
<pre><code class="language-md">&quot;리뷰 내용을 반영했습니다. 확인 부탁드립니다!&quot;</code></pre>
<hr>
<h2 id="주의사항">주의사항</h2>
<h3 id="1-민감한-변경은-자동화만-믿지-않기">1) 민감한 변경은 자동화만 믿지 않기</h3>
<p>보안/결제/개인정보 같은 영역은 자동 커밋 전에 꼭 사람이 확인하는 게 안전합니다.</p>
<h3 id="2-리뷰가-너무-함축적이면-오해-가능">2) 리뷰가 너무 함축적이면 오해 가능</h3>
<pre><code class="language-text">&quot;이거 좀…&quot;
&quot;여기 그렇게 하면 안 되는데&quot;</code></pre>
<p>이런 코멘트는 AI도 사람도 해석이 어렵습니다.
리뷰어가 “요청 형태”로 써주면 자동화가 훨씬 잘 굴러가요.</p>
<h3 id="3-충돌은-수동-해결이-필요할-수-있음">3) 충돌은 수동 해결이 필요할 수 있음</h3>
<p>머지 충돌은 자동 처리의 한계가 있는 영역이라, 충돌 해결 후 다시 실행하는 흐름이 필요합니다.</p>
<h3 id="4-ci-실패는-별도-대응">4) CI 실패는 별도 대응</h3>
<p>PR 생성 후 CI가 깨지는 건 <code>/apply-review</code>로 자동 해결되지 않을 수 있어요.
CI 로그 기반 수정은 별도의 작업으로 보는 게 좋습니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p><code>/pr</code>과 <code>/apply-review</code>의 핵심은 한 문장입니다.</p>
<blockquote>
<p>리뷰 “과정”이 아니라, <strong>리뷰의 결과(품질 개선)</strong>에 집중하게 만드는 것.</p>
</blockquote>
<ul>
<li>커밋 메시지/PR 본문 작성 같은 반복 작업은 자동화하고</li>
<li>리뷰 코멘트는 코드로 빠르게 반영해서 사이클을 줄이고</li>
<li>개발자는 더 중요한 판단(설계/품질)에 에너지를 쓰게 됩니다</li>
</ul>
<p>이 시리즈에서 소개한 명령어를 다시 정리하면:</p>
<table>
<thead>
<tr>
<th>명령어</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>/spec</code></td>
<td>명세서 기반 코드 구현</td>
</tr>
<tr>
<td><code>/create-jest</code></td>
<td>테스트 코드 자동 생성</td>
</tr>
<tr>
<td><code>/w-context</code></td>
<td>AI를 위한 맥락 문서화</td>
</tr>
<tr>
<td><code>/pr</code></td>
<td>커밋 + PR 생성 자동화</td>
</tr>
<tr>
<td><code>/apply-review</code></td>
<td>리뷰 반영 자동화</td>
</tr>
</tbody></table>
<p>물론 완벽한 자동화는 없어요.
아키텍처 결정이나 논쟁이 필요한 리뷰는 여전히 사람이 해야 합니다.</p>
<p>하지만 <strong>기계적인 반복 작업을 AI에게 맡기고</strong>,
우리는 더 가치 있는 판단에 집중할 수 있게 됐습니다.</p>
<p>이게 제가 생각하는 “AI와 같이 일하는 개발 워크플로우”의 형태였습니다.</p>
<hr>
<h2 id="참고-link-dropper-프로젝트">참고: Link Dropper 프로젝트</h2>
<p>이 글에서 소개한 명령어들은 제가 만들고 있는 <strong>Link Dropper</strong> 프로젝트에서 실제로 사용하고 있습니다.</p>
<ul>
<li><a href="https://apps.apple.com/us/app/link-dropper/id6755904161">App Store에서 다운로드하기</a></li>
<li><a href="https://link-dropper.com">웹에서 사용하러 가기</a></li>
<li><a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 익스텐션 설치하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI가 “코드 읽기”를 넘어서 “이해”하게 만드는 방법: `/w-context`로 CONTEXT.md 자동 생성하기]]></title>
            <link>https://velog.io/@link_dropper/w-context</link>
            <guid>https://velog.io/@link_dropper/w-context</guid>
            <pubDate>Fri, 09 Jan 2026 02:22:42 GMT</pubDate>
            <description><![CDATA[<h2 id="시리즈-안내">시리즈 안내</h2>
<table>
<thead>
<tr>
<th>순서</th>
<th>제목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1편</td>
<td>Claude Code Commands로 개발 워크플로우 자동화하기</td>
<td>전체 개요</td>
</tr>
<tr>
<td>2편</td>
<td>/spec - 명세서 기반 코드 구현 자동화</td>
<td>가장 핵심인 구현 자동화</td>
</tr>
<tr>
<td>3편</td>
<td>/create-jest - 테스트 코드 자동 생성</td>
<td>테스트 작성 자동화</td>
</tr>
<tr>
<td><strong>4편</strong></td>
<td><strong>/w-context - AI를 위한 문서 작성</strong></td>
<td><strong>CONTEXT.md 개념과 활용 (현재 글)</strong></td>
</tr>
<tr>
<td>5편</td>
<td>/pr + /apply-review - PR 워크플로우 자동화</td>
<td>PR 생성부터 리뷰 반영까지</td>
</tr>
</tbody></table>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>Claude Code를 쓰다 보면 이런 상황, 은근 자주 마주칩니다.</p>
<ul>
<li>“이 폴더 구조 왜 이렇게 돼 있어?” → Claude: (모든 파일을 읽으며 추론 시작…)</li>
<li>“여기에 기능 추가해줘” → Claude: (구현은 했는데 기존 패턴이랑 미묘하게 다름)</li>
<li>“이 모듈 어떻게 쓰는 거야?” → Claude: (코드 분석은 하는데 ‘설계 의도’는 알 수 없음)</li>
</ul>
<p>AI는 코드를 <strong>읽을</strong> 수는 있어요.
그런데 코드를 <strong>이해</strong>하려면 결국 맥락이 필요합니다.</p>
<p>그래서 만든 게 <code>/w-context</code>입니다.</p>
<blockquote>
<p><strong>AI가 읽을 수 있는 README</strong>를 자동으로 만들어주는 명령어.
코드 주석 대신, <strong>설계 의도와 사용 패턴</strong>을 한 곳에 모아두는 문서화 방식.</p>
</blockquote>
<hr>
<h2 id="contextmd가-뭔가요">CONTEXT.md가 뭔가요?</h2>
<p>한 문장으로 하면 이렇습니다.</p>
<blockquote>
<p><strong>AI를 위한 모듈 설명서</strong>입니다.</p>
</blockquote>
<p>README.md가 “사람”에게 설치/사용/기여 방법을 알려주는 문서라면,
CONTEXT.md는 “AI가 이 폴더를 처음 볼 때 반드시 알아야 할 것”에 집중합니다.</p>
<h3 id="readme-vs-context">README vs CONTEXT</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>README.md</th>
<th>CONTEXT.md</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>개발자(사람)</td>
<td>AI 어시스턴트</td>
</tr>
<tr>
<td>목적</td>
<td>설치/사용법/운영</td>
<td>설계 의도/맥락/패턴 전달</td>
</tr>
<tr>
<td>위치</td>
<td>보통 루트</td>
<td>모듈(폴더)별</td>
</tr>
<tr>
<td>핵심 질문</td>
<td>어떻게 쓰지? (How)</td>
<td>왜 이렇게 만들었지? (Why)</td>
</tr>
</tbody></table>
<p>여기서 핵심은 딱 하나예요.</p>
<blockquote>
<p>AI가 흔히 놓치는 건 구현력이 아니라, <strong>“왜 이렇게 했는지”</strong>입니다.</p>
</blockquote>
<hr>
<h2 id="w-context가-하는-일"><code>/w-context</code>가 하는 일</h2>
<p>실행은 한 줄입니다.</p>
<pre><code class="language-bash">/w-context</code></pre>
<p>그러면 Claude가 <strong>현재 작업 중인 폴더</strong>를 기준으로 CONTEXT.md를 생성(또는 업데이트)합니다.</p>
<ol>
<li><strong>폴더 파악</strong> — 지금 작업하는 모듈이 무엇인지 확인</li>
<li><strong>파일 분석</strong> — 코드 구조/역할/패턴을 읽어냄</li>
<li><strong>기존 문서 확인</strong> — CONTEXT.md가 있으면 업데이트, 없으면 생성</li>
<li><strong>CONTEXT.md 작성</strong> — 팀 규칙에 맞춰 문서화</li>
</ol>
<p>중요한 건 이게 “한 번 만들고 끝”이 아니라는 점이에요.</p>
<blockquote>
<p>코드가 바뀌면 <code>/w-context</code>를 다시 실행해서
<strong>문서도 같이 최신화</strong>합니다.</p>
</blockquote>
<p>문서가 최신 상태로 유지되면, AI가 같은 모듈을 수정할 때 “처음 보는 프로젝트”처럼 헤매지 않게 됩니다.</p>
<hr>
<h2 id="contextmd에는-무엇을-넣나요">CONTEXT.md에는 무엇을 넣나요?</h2>
<p>저는 CONTEXT.md를 <strong>5개 섹션</strong>으로 고정했습니다.
이 정도면 AI가 맥락을 잡기에 충분하면서, 과하게 길어지지도 않더라고요.</p>
<hr>
<h3 id="1-이-모듈이-하는-일-한-문장으로-끝">1) 이 모듈이 하는 일 (한 문장으로 끝)</h3>
<pre><code class="language-md">## 이 모듈이 하는 일

HTTP 요청을 표준화된 방식으로 처리하고, 인증/재시도/에러 핸들링을 자동화합니다.</code></pre>
<p>이 한 줄이 있으면 AI는 첫 줄에서 이렇게 판단합니다.</p>
<blockquote>
<p>“아, 이 폴더는 API 클라이언트 레이어구나.”</p>
</blockquote>
<p>이게 없으면 Claude는 “파일 다 읽고 추론”부터 시작해요.
작업 속도가 달라질 수밖에 없습니다.</p>
<hr>
<h3 id="2-파일-구조와-역할-어디를-고쳐야-하는지-바로-보이게">2) 파일 구조와 역할 (어디를 고쳐야 하는지 바로 보이게)</h3>
<pre><code class="language-md">## 파일 구조와 역할

- `client.ts`: 핵심 HTTP 클라이언트, fetch 래핑, 재시도 로직
- `types.ts`: 요청/응답 타입 정의
- `interceptors.ts`: 요청/응답 인터셉터
- `index.ts`: public API 내보내기</code></pre>
<p>이 섹션이 있으면 “수정 지점”을 찾는 시간이 줄어요.</p>
<ul>
<li>재시도 로직 추가? → <code>client.ts</code></li>
<li>에러 타입 추가? → <code>types.ts</code></li>
<li>public export 변경? → <code>index.ts</code></li>
</ul>
<p>AI도 사람이랑 똑같이 “어디부터 볼지”가 중요합니다.</p>
<hr>
<h3 id="3-핵심-설계-결정-가장-중요한-파트-why">3) 핵심 설계 결정 (가장 중요한 파트: Why)</h3>
<p>이게 CONTEXT.md의 진짜 목적입니다.
AI가 <strong>기존 방향을 존중</strong>하게 만드는 장치예요.</p>
<pre><code class="language-md">## 핵심 설계 결정

1. **fetch 직접 래핑**
   - axios 대신 네이티브 fetch 사용
   - 번들 사이즈 최소화
   - React Native/Web에서 동일하게 동작

2. **재시도는 네트워크 에러만**
   - 4xx는 재시도하지 않음
   - 비즈니스 에러는 재시도해도 결과가 같음
   - 불필요한 서버 부하 방지

3. **토큰 자동 주입**
   - 모든 요청에 인증 헤더 자동 추가
   - 호출부에서 토큰 관리 코드 제거
   - 토큰 갱신 로직 중앙화</code></pre>
<p>이런 내용이 없으면 AI는 보통 “자기가 생각하는 베스트 프랙티스”로 구현하려고 합니다.
그게 항상 틀린 건 아니지만, <strong>프로젝트의 의도와 다를 수 있어요.</strong></p>
<hr>
<h3 id="4-사용-패턴-ai가-복붙할-수-있는-예시">4) 사용 패턴 (AI가 ‘복붙’할 수 있는 예시)</h3>
<pre><code class="language-md">## 사용 패턴

```ts
import { apiClient } from &quot;@/libs/api&quot;;

// GET
const users = await apiClient.get&lt;User[]&gt;(&quot;/users&quot;);

// POST
const newUser = await apiClient.post&lt;User&gt;(&quot;/users&quot;, {
  body: { name: &quot;John&quot;, email: &quot;john@example.com&quot; }
});

// 에러 처리
try {
  await apiClient.get(&quot;/protected&quot;);
} catch (error) {
  if (error instanceof ApiError) {
    // 401이면 로그아웃 처리
  }
}</code></pre>
<pre><code>
AI는 “예시 코드”를 굉장히 잘 따릅니다.  
즉, 사용 패턴 섹션은 **미래의 코드 스타일을 유도하는 템플릿**이에요.

---

### 5) 확장 시 고려사항 (미리 알려주면 삽질이 줄어든다)

```md
## 확장 시 고려사항

- 새 HTTP 메서드 추가 시 `client.ts`의 request 메서드 확장
- 인터셉터 추가 시 실행 순서 주의 (요청은 정순, 응답은 역순)
- 에러 타입 추가 시 `types.ts`의 ApiError 클래스 확장
- 캐싱 도입 시 React Query와의 역할 분리 고려</code></pre><p>이게 없으면 AI가 기능 추가하면서 “좋아 보이는 방향”으로 확장하는데,
그게 나중에 구조를 망가뜨리는 경우가 있어요.</p>
<p>확장 포인트를 미리 적어두면, AI가 <strong>안전한 레일</strong> 위에서 움직입니다.</p>
<hr>
<h2 id="실제-생성-결과-예시-libsapi에서-실행하면">실제 생성 결과 예시 (libs/api/에서 실행하면)</h2>
<p><code>libs/api/</code> 폴더에서 <code>/w-context</code>를 실행하면 대략 이런 문서가 생깁니다.</p>
<pre><code class="language-md"># API 클라이언트 컨텍스트

이 문서는 AI가 api 모듈의 설계 의도와 사용 방법을 빠르게 파악할 수 있도록 작성되었습니다.

## 이 모듈이 하는 일
HTTP 요청을 표준화된 방식으로 처리하고, 인증 토큰 관리와 에러 핸들링을 자동화합니다.

## 파일 구조와 역할
- `client.ts`: fetch 래핑, 재시도 로직
- `types.ts`: 요청/응답 타입, 에러 클래스
- `auth/`: 인증 관련 API
- `user/`: 사용자 관련 API
- `index.ts`: public API export

## 핵심 설계 결정
- axios 대신 fetch 사용
- 네트워크 에러만 재시도 (지수 백오프)
- ApiError로 에러 표준화

## 사용 패턴
(예시 코드...)

## 확장 시 고려사항
- 도메인별 폴더로 분리 (`links/`, `folders/`)
- 캐싱은 React Query에서 처리</code></pre>
<p>이 문서 하나만 있으면 AI는 “추측”이 아니라 “이해”를 기반으로 움직이게 됩니다.</p>
<hr>
<h2 id="기존-contextmd가-있으면-덮어쓰지-않고-동기화합니다">기존 CONTEXT.md가 있으면? 덮어쓰지 않고 “동기화”합니다</h2>
<p>문서 자동 생성의 최대 단점은 이거죠.</p>
<blockquote>
<p>“내가 손으로 다듬은 내용이 날아갈까 봐 무섭다”</p>
</blockquote>
<p>그래서 <code>/w-context</code>는 “갈아엎기”가 아니라 “동기화”로 설계했습니다.</p>
<ol>
<li>기존 CONTEXT.md 구조는 <strong>유지</strong></li>
<li>코드 변경사항 분석</li>
<li>새 파일/기능은 <strong>추가 반영</strong></li>
<li>삭제된 요소는 <strong>문서에서 제거</strong></li>
<li>설계 결정/사용 패턴이 바뀌면 <strong>해당 섹션만 업데이트</strong></li>
</ol>
<p>즉, 문서를 <strong>코드와 같은 생명주기</strong>로 관리합니다.</p>
<hr>
<h2 id="왜-코드-주석-대신-contextmd인가">왜 코드 주석 대신 CONTEXT.md인가?</h2>
<p>주석으로도 설명할 수는 있어요.
그런데 저는 CONTEXT.md가 “AI 시대에는” 더 실용적이라고 느꼈습니다.</p>
<h3 id="주석의-한계">주석의 한계</h3>
<pre><code class="language-ts">// 재시도 로직
// - 네트워크 에러만 재시도
// - 최대 3회
// - 지수 백오프 적용
async function fetchWithRetry() {}</code></pre>
<ul>
<li>파일 곳곳에 흩어져 있음</li>
<li>전체 설계 그림이 한눈에 안 보임</li>
<li>AI가 맥락 파악하려면 여러 파일을 다 읽어야 함</li>
</ul>
<h3 id="contextmd의-장점">CONTEXT.md의 장점</h3>
<ul>
<li><strong>한 곳에 모인다</strong> → 맥락을 빠르게 로딩</li>
<li><strong>Why 중심</strong> → 설계 의도를 명시</li>
<li><strong>AI 친화적</strong> → 한 번에 읽고 이해 가능</li>
<li><strong>유지보수 쉬움</strong> → 코드와 분리, 업데이트도 명령어로</li>
</ul>
<p>한마디로, 주석은 “로컬 설명”, CONTEXT는 “모듈의 지도”입니다.</p>
<hr>
<h2 id="저는-언제-w-context를-실행하나요">저는 언제 <code>/w-context</code>를 실행하나요?</h2>
<p>저는 아래 타이밍을 루틴으로 만들었습니다.</p>
<h3 id="1-새-모듈을-만들었을-때">1) 새 모듈을 만들었을 때</h3>
<pre><code class="language-bash">mkdir libs/cache
# 코드 작성...
/w-context</code></pre>
<h3 id="2-기존-모듈을-크게-수정했을-때">2) 기존 모듈을 크게 수정했을 때</h3>
<pre><code class="language-bash"># libs/api 구조 변경
/w-context</code></pre>
<h3 id="3-spec으로-구현을-끝낸-직후">3) <code>/spec</code>으로 구현을 끝낸 직후</h3>
<pre><code class="language-bash">/spec user-profile
/w-context</code></pre>
<p>구현 직후가 설계 의도가 가장 선명할 때라 문서 품질이 좋아요.</p>
<h3 id="4-pr-올리기-직전">4) PR 올리기 직전</h3>
<pre><code class="language-bash">/w-context
/pr</code></pre>
<p>PR에서 “문서 최신인가?”를 사람이 체크하지 않게 만들기.</p>
<hr>
<h2 id="contextmd는-어디에-생기나요">CONTEXT.md는 어디에 생기나요?</h2>
<p>현재 작업 중인 폴더 내부에 <code>CONTEXT.md</code>로 생성됩니다.</p>
<pre><code class="language-text">libs/
├── api/
│   ├── client.ts
│   ├── types.ts
│   └── CONTEXT.md
├── logger/
│   └── CONTEXT.md
└── utils/
    └── CONTEXT.md</code></pre>
<p>모듈별로 하나씩 있으면, AI가 특정 폴더를 작업할 때 <strong>그 폴더의 맥락만 빠르게</strong> 읽고 들어올 수 있어요.
(프로젝트 전체 README 한 장으로는 절대 못 하는 일입니다.)</p>
<hr>
<h2 id="커스터마이징-포인트">커스터마이징 포인트</h2>
<p>프로젝트 성격에 따라 CONTEXT.md 템플릿을 바꿀 수 있어요.</p>
<h3 id="1-필수-섹션-추가">1) 필수 섹션 추가</h3>
<pre><code class="language-md">## 필수 포함 내용
1. 이 모듈이 하는 일
2. 파일 구조와 역할
3. 핵심 설계 결정
4. 사용 패턴
5. 확장 시 고려사항
6. 성능 고려사항 (선택)
7. 보안 관련 사항 (선택)</code></pre>
<h3 id="2-작성-언어">2) 작성 언어</h3>
<p>영어 프로젝트면 작성 언어만 바꾸면 됩니다.</p>
<pre><code class="language-md">- Use English for all sections
- Keep it concise and technical</code></pre>
<h3 id="3-생성-위치-변경">3) 생성 위치 변경</h3>
<p>모듈 안이 아니라, docs로 모아도 됩니다.</p>
<pre><code class="language-md">- `docs/context/{module}.context.md`에 생성</code></pre>
<hr>
<h2 id="spec-create-jest와-붙이면-개발-3종-세트가-됩니다"><code>/spec</code>, <code>/create-jest</code>와 붙이면 “개발 3종 세트”가 됩니다</h2>
<pre><code class="language-bash">/spec user-notification
/create-jest
/w-context</code></pre>
<p>이렇게 하면</p>
<ul>
<li>구현은 명세서 기반으로 일관되게</li>
<li>테스트는 자동으로 따라오고</li>
<li>맥락은 문서로 남아서 다음 작업 품질이 올라갑니다</li>
</ul>
<p>즉, 한 번 만든 모듈이 시간이 지나도 “설계 의도”를 잃지 않게 돼요.</p>
<hr>
<h2 id="왜-이-방식이-효과적이었나">왜 이 방식이 효과적이었나</h2>
<ol>
<li><p><strong>AI의 맥락 부족 문제 해결</strong>
코드만 보면 AI는 추측할 수밖에 없는데, CONTEXT.md가 있으면 확신을 갖고 작업합니다.</p>
</li>
<li><p><strong>온보딩 시간 단축</strong>
새 팀원, 새 AI 세션 모두 “CONTEXT부터 읽고 시작”이 가능해집니다.</p>
</li>
<li><p><strong>일관된 코드 스타일 유지</strong>
설계 결정과 사용 패턴이 문서화되어 있으니, 사람이든 AI든 같은 방향으로 코드를 씁니다.</p>
</li>
<li><p><strong>미래의 나를 위한 보험</strong>
3개월 뒤 “왜 이렇게 했지?”라는 질문에 CONTEXT.md가 답해줍니다.</p>
</li>
</ol>
<hr>
<h2 id="주의사항">주의사항</h2>
<h3 id="1-모든-폴더에-만들-필요는-없습니다">1) 모든 폴더에 만들 필요는 없습니다</h3>
<p>단순 UI 컴포넌트까지 CONTEXT.md를 만들면 오히려 노이즈가 됩니다.
<strong>복잡한 로직/규칙이 있는 모듈</strong>에만 집중하는 게 좋아요.</p>
<h3 id="2-코드와-문서의-동기화가-핵심입니다">2) 코드와 문서의 동기화가 핵심입니다</h3>
<p>문서가 코드와 달라지면 혼란을 줍니다.
큰 변경 후에는 <code>/w-context</code>로 업데이트하는 습관이 중요해요.</p>
<h3 id="3-민감-정보는-절대-쓰지-마세요">3) 민감 정보는 절대 쓰지 마세요</h3>
<p>API 키, 내부 보안 규칙, 인증 우회 힌트 같은 건 문서에 남기면 안 됩니다.
“설계 의도”와 “비밀 정보”는 다릅니다.</p>
<hr>
<h2 id="실제-명령어-파일은-이렇게-생겼습니다-핵심만">실제 명령어 파일은 이렇게 생겼습니다 (핵심만)</h2>
<pre><code class="language-md"># w-context.md

현재 작업 중인 폴더에 CONTEXT.md 파일을 생성하거나 업데이트합니다.

## 목적
AI가 해당 모듈의 맥락을 빠르게 파악할 수 있도록 자연어 문서를 작성합니다.

## 실행 방법
1. 현재 폴더 확인
2. 폴더 내 파일 분석
3. CONTEXT.md 존재 여부 확인
   - 없으면 생성
   - 있으면 변경사항 동기화

## CONTEXT.md 작성 규칙
### 필수 포함
1. 이 모듈이 하는 일
2. 파일 구조와 역할
3. 핵심 설계 결정
4. 사용 패턴
5. 확장 시 고려사항

### 작성 원칙
- 코드를 읽지 않아도 이해 가능해야 함
- 구현 디테일보다 의도/맥락 중심
- 핵심만 간결하게</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p><code>/w-context</code>의 핵심은 이거였습니다.</p>
<blockquote>
<p>AI가 코드를 “읽는” 수준에서 멈추지 않고,
<strong>“이해하고 같은 방향으로 수정”</strong>하도록 돕는 것.</p>
</blockquote>
<p>처음엔 “문서 또 써야 해?” 싶을 수 있어요.
근데 <code>/w-context</code>는 한 줄이고, 효과는 꽤 큽니다.</p>
<p>다음 5편에서는 <code>/pr</code>과 <code>/apply-review</code>로
<strong>PR 생성부터 리뷰 반영까지</strong> 워크플로우를 자동화한 이야기를 정리해볼게요.</p>
<hr>
<h2 id="참고-link-dropper-프로젝트">참고: Link Dropper 프로젝트</h2>
<p>이 글에서 소개한 명령어들은 제가 만들고 있는 <strong>Link Dropper</strong> 프로젝트에서 실제로 사용하고 있습니다.</p>
<ul>
<li><a href="https://apps.apple.com/us/app/link-dropper/id6755904161">App Store에서 다운로드하기</a></li>
<li><a href="https://link-dropper.com">웹에서 사용하러 가기</a></li>
<li><a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 익스텐션 설치하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[“테스트는 나중에…”를 끝내는 버튼 하나: /create-jest로 변경 감지→테스트 생성→실행까지 자동화하기]]></title>
            <link>https://velog.io/@link_dropper/jest-automation</link>
            <guid>https://velog.io/@link_dropper/jest-automation</guid>
            <pubDate>Tue, 30 Dec 2025 08:37:11 GMT</pubDate>
            <description><![CDATA[<h2 id="시리즈-안내">시리즈 안내</h2>
<table>
<thead>
<tr>
<th>순서</th>
<th>제목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1편</td>
<td>Claude Code Commands로 개발 워크플로우 자동화하기</td>
<td>전체 개요</td>
</tr>
<tr>
<td>2편</td>
<td>/spec - 명세서 기반 코드 구현 자동화</td>
<td>가장 핵심인 구현 자동화</td>
</tr>
<tr>
<td><strong>3편</strong></td>
<td><strong>/create-jest - 테스트 코드 자동 생성</strong></td>
<td><strong>테스트 작성 자동화 (현재 글)</strong></td>
</tr>
<tr>
<td>4편</td>
<td>/w-context - AI를 위한 문서 작성</td>
<td>CONTEXT.md 개념과 활용</td>
</tr>
<tr>
<td>5편</td>
<td>/pr + /apply-review - PR 워크플로우 자동화</td>
<td>PR 생성부터 리뷰 반영까지</td>
</tr>
</tbody></table>
<hr>
<h2 id="들어가며">들어가며</h2>
<p>테스트 코드, 좋은 건 알아요.
근데 현실은 대개 이렇죠.</p>
<ul>
<li>“이거 테스트는… 나중에 쓰자” (안 씀)</li>
<li>“급하니까 일단 배포하고…” (영원히 안 씀)</li>
<li>“기존 테스트 깨졌네? 일단 스킵…” (테스트의 죽음)</li>
</ul>
<p>저도 똑같았습니다. 테스트가 필요 없는 게 아니라, <strong>테스트를 쓰는 데 드는 에너지</strong>가 문제였어요.</p>
<p>그래서 만든 게 <code>/create-jest</code>입니다.</p>
<blockquote>
<p>“코드 바꾸면 → 테스트가 따라온다”
라는 규칙을 <strong>고민이 아니라 시스템으로 강제</strong>해버린 명령어.</p>
</blockquote>
<hr>
<h2 id="create-jest가-하는-일-진짜로는-테스트-파이프라인"><code>/create-jest</code>가 하는 일 (진짜로는 “테스트 파이프라인”)</h2>
<p>실행은 한 줄입니다.</p>
<pre><code class="language-bash">/create-jest</code></pre>
<p>하지만 내부적으로는 아래를 순서대로 처리해요.</p>
<ol>
<li><strong>변경된 파일 감지</strong> — <code>git diff</code>로 무엇이 바뀌었는지 확인</li>
<li><strong>테스트 대상 필터링</strong> — “테스트할 파일”만 규칙으로 추려냄</li>
<li><strong>테스트 생성/업데이트</strong> — 있으면 수정, 없으면 새로 생성</li>
<li><strong>테스트 실행</strong> — 작성한 테스트가 실제로 통과하는지 확인</li>
</ol>
<p>즉, “테스트 파일 만들어드립니다”가 아니라</p>
<blockquote>
<p>코드 수정 → <code>/create-jest</code> → <strong>테스트까지 끝</strong></p>
</blockquote>
<p>이 흐름을 만드는 게 목표였습니다.</p>
<hr>
<h2 id="핵심-설계-자동화의-성패는-무엇을-테스트할지가-결정한다">핵심 설계: 자동화의 성패는 “무엇을 테스트할지”가 결정한다</h2>
<p>테스트 자동화를 시도할 때 제일 먼저 부딪히는 문제가 이거예요.</p>
<ul>
<li>다 테스트하려면 유지보수가 지옥</li>
<li>너무 덜 테스트하면 의미가 없음</li>
</ul>
<p>그래서 저는 “테스트할 것 / 안 할 것”을 <strong>명확히 분리</strong>했습니다.</p>
<h3 id="✅-테스트-대상-효율-좋은-구역만">✅ 테스트 대상 (효율 좋은 구역만)</h3>
<pre><code class="language-markdown">- `libs/**/*.ts` - 공통 라이브러리
- `hooks/**/*.ts` - 커스텀 훅
- `utils/**/*.ts` - 유틸리티 함수
- `services/**/*.ts` - 서비스 레이어</code></pre>
<p>이 영역들은 보통 <strong>순수 로직</strong>이 많고, 테스트 비용 대비 효과가 커요.
즉 “자동 생성”과 궁합이 좋습니다.</p>
<h3 id="❌-테스트-제외-여긴-다른-전략이-낫다">❌ 테스트 제외 (여긴 다른 전략이 낫다)</h3>
<pre><code class="language-markdown">- `app/**/*` - 앱 라우터 (E2E로 커버)
- `components/**/*` - React 컴포넌트 (별도 컴포넌트 테스트)
- `**/*.d.ts` - 타입 정의 파일
- `**/index.ts` - re-export만 하는 파일
- `**/__tests__/**` - 테스트 파일 자체
- `*.config.*` - 설정 파일</code></pre>
<p>UI/라우팅은 단위 테스트로 무리해서 커버하기보다, <strong>E2E나 컴포넌트 테스트 전략</strong>이 더 적합하다고 판단했어요.
그래서 <code>/create-jest</code>는 “로직 구역”만 확실히 쌓아주는 역할을 맡습니다.</p>
<hr>
<h2 id="테스트-파일-위치네이밍-흩어지지-않게-한-규칙으로">테스트 파일 위치/네이밍: 흩어지지 않게 “한 규칙”으로</h2>
<p>테스트가 자동으로 생기기 시작하면, 다음 문제가 옵니다.</p>
<blockquote>
<p>“어디에 생겼더라?”
“왜 어떤 건 여기 있고 어떤 건 저기 있어?”</p>
</blockquote>
<p>그래서 <strong>무조건 같은 규칙</strong>으로 생성하도록 했어요.</p>
<h3 id="위치-동일-경로의-__tests__-폴더">위치: 동일 경로의 <code>__tests__</code> 폴더</h3>
<pre><code class="language-text">libs/fetch/
├── core.ts
├── types.ts
├── index.ts
└── __tests__/
    └── fetch.test.ts</code></pre>
<p>소스랑 같은 위치에 있으니 찾기 쉽고, 모듈 단위로 묶여서 관리도 편합니다.</p>
<h3 id="네이밍-모듈-크기-기준으로-실용적으로">네이밍: “모듈 크기” 기준으로 실용적으로</h3>
<table>
<thead>
<tr>
<th>모듈 크기</th>
<th align="right">파일 수</th>
<th>네이밍</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>작음</td>
<td align="right">1~3개</td>
<td><code>{모듈명}.test.ts</code></td>
<td><code>fetch.test.ts</code></td>
</tr>
<tr>
<td>큼</td>
<td align="right">4개 이상</td>
<td><code>{파일명}.test.ts</code></td>
<td><code>session.test.ts</code></td>
</tr>
</tbody></table>
<p>작은 모듈은 통합 테스트 파일 하나가 관리가 쉽고,
큰 모듈은 파일 단위로 쪼개야 유지보수가 됩니다.</p>
<hr>
<h2 id="테스트-작성-규칙-팀-컨벤션을-명령어에-박아두기">테스트 작성 규칙: “팀 컨벤션”을 명령어에 박아두기</h2>
<p>여기가 <code>/create-jest</code>의 진짜 핵심이에요.</p>
<p>테스트를 못 쓰는 이유는 보통 “시간”보다도
<strong>매번 선택해야 하는 것들이 너무 많아서</strong>예요.</p>
<ul>
<li>describe 어떻게 나누지?</li>
<li>케이스는 뭐부터 쓰지?</li>
<li>모킹은 어디까지 하지?</li>
<li>테스트명은 영어로? 한글로?</li>
</ul>
<p>그래서 저는 그 선택을 <strong>명령어가 대신 하도록</strong> 만들었습니다.</p>
<hr>
<h2 id="1-한글-테스트명-실패-로그가-곧-문서가-된다">1) 한글 테스트명 (실패 로그가 곧 문서가 된다)</h2>
<pre><code class="language-ts">it(&quot;사용자를 생성한다&quot;, () =&gt; {});
it(&quot;유효하지 않은 이메일이면 에러를 던진다&quot;, () =&gt; {});</code></pre>
<p>한글 테스트명의 장점은 단순해요.</p>
<ul>
<li>실패했을 때 로그만 봐도 “뭐가 깨졌는지” 바로 이해됨</li>
<li>팀원이 테스트 파일을 읽을 때도 맥락이 빨리 잡힘</li>
</ul>
<p>테스트는 “작성자”보다 “미래의 유지보수자”를 위한 거라서, 저는 한글이 더 실용적이었습니다.</p>
<hr>
<h2 id="2-aaa-패턴-강제-테스트-구조를-자동으로-통일">2) AAA 패턴 강제 (테스트 구조를 자동으로 통일)</h2>
<pre><code class="language-ts">it(&quot;정상 케이스를 처리한다&quot;, () =&gt; {
  // Arrange
  const input = { name: &quot;홍길동&quot;, email: &quot;hong@test.com&quot; };

  // Act
  const result = createUser(input);

  // Assert
  expect(result).toEqual({ id: expect.any(String), ...input });
});</code></pre>
<p>AAA(Arrange-Act-Assert)를 강제하면 좋은 점:</p>
<ul>
<li>테스트가 길어져도 읽기 쉬움</li>
<li>“준비/실행/검증”이 섞이지 않아서 수정하기 쉬움</li>
<li>팀원이 테스트 스타일로 싸울 일이 사라짐</li>
</ul>
<hr>
<h2 id="3-describe로-기능-단위-그룹화-실패-위치를-즉시-좁힌다">3) describe로 기능 단위 그룹화 (실패 위치를 즉시 좁힌다)</h2>
<pre><code class="language-ts">describe(&quot;UserService&quot;, () =&gt; {
  describe(&quot;createUser&quot;, () =&gt; {
    it(&quot;정상 케이스를 처리한다&quot;, () =&gt; {});
    it(&quot;중복 이메일이면 에러를 던진다&quot;, () =&gt; {});
  });

  describe(&quot;updateUser&quot;, () =&gt; {
    it(&quot;존재하는 사용자를 업데이트한다&quot;, () =&gt; {});
    it(&quot;존재하지 않는 사용자면 에러를 던진다&quot;, () =&gt; {});
  });
});</code></pre>
<p>이 구조는 테스트가 많아질수록 효과가 커집니다.
특히 CI에서 실패했을 때 “어느 기능이 문제인지”가 바로 찍혀요.</p>
<hr>
<h2 id="테스트-케이스-우선순위-중요한-것부터-자동으로-채운다">테스트 케이스 우선순위: 중요한 것부터 자동으로 채운다</h2>
<p>Claude가 테스트를 생성할 때 저는 우선순위를 고정했습니다.</p>
<ol>
<li><strong>Happy Path</strong> — 정상 입력/정상 결과</li>
<li><strong>Edge Cases</strong> — 경계값, 빈 값, null/undefined</li>
<li><strong>Error Cases</strong> — 예외 상황, 에러 핸들링</li>
<li><strong>Integration (필요시)</strong> — 모듈 간 상호작용</li>
</ol>
<p>이렇게 하면 최소한</p>
<ul>
<li>“정상 동작”은 보장하고</li>
<li>“자주 터지는 경계/에러”도 같이 잡습니다</li>
</ul>
<p>자동 생성 테스트가 “쓸모없는 테스트”가 되지 않게 만드는 장치예요.</p>
<hr>
<h2 id="모킹-규칙-과하게-하지-않기-테스트가-깨지는-진짜-이유">모킹 규칙: 과하게 하지 않기 (테스트가 깨지는 진짜 이유)</h2>
<p>테스트가 깨지기 쉬운 가장 흔한 원인이 <strong>과도한 모킹</strong>이더라고요.
내부 구현을 잔뜩 mock 해버리면, 코드가 조금만 바뀌어도 테스트가 와르르 깨집니다.</p>
<p>그래서 <code>/create-jest</code>는 원칙이 단순합니다.</p>
<ul>
<li><strong>외부 의존성만 모킹한다</strong> (네트워크, 파일 시스템, 외부 API)</li>
<li>내부 구현은 <strong>가능한 실제로 실행</strong>한다</li>
</ul>
<pre><code class="language-ts">// ✅ Good: 외부 의존성만 모킹
const mockFetch = jest.fn();
global.fetch = mockFetch;

// ❌ Bad: 내부 구현을 과하게 모킹
jest.mock(&quot;./internal-helper&quot;);</code></pre>
<p>이렇게 하면 테스트가 “구현 디테일”에 덜 묶여서 유지보수가 쉬워집니다.</p>
<hr>
<h2 id="기존-테스트가-있으면-갈아엎지-않고-업데이트만">기존 테스트가 있으면? “갈아엎지 않고” 업데이트만</h2>
<p>자동화의 또 다른 함정은 이거예요.</p>
<blockquote>
<p>“테스트 새로 만들어줬는데… 기존 테스트 스타일이랑 완전 다르네?”</p>
</blockquote>
<p>그래서 변경된 파일에 테스트가 이미 있으면 <code>/create-jest</code>는 이렇게 행동합니다.</p>
<ol>
<li>기존 테스트 구조 유지</li>
<li>변경된 기능에 대한 테스트만 수정/추가</li>
<li>삭제된 기능의 테스트 제거</li>
<li>새 기능의 테스트 추가</li>
<li>전체 테스트가 통과하는지 확인</li>
</ol>
<p>즉, <strong>전체를 다시 쓰지 않고</strong> 변경분만 안전하게 다룹니다.
(이게 안 되면 팀 프로젝트에서 바로 반발 나옵니다…)</p>
<hr>
<h2 id="실제-사용-예시-재시도-로직-추가했더니-테스트도-같이-자랐다">실제 사용 예시: “재시도 로직 추가했더니 테스트도 같이 자랐다”</h2>
<p>예를 들어 <code>libs/api/client.ts</code>에 재시도 로직을 추가했다고 해볼게요.</p>
<pre><code class="language-bash"># 1) 코드 수정 후 변경 파일 확인
git diff --name-only
# libs/api/client.ts

# 2) 테스트 생성/업데이트
/create-jest</code></pre>
<p>그러면 결과는 이런 식으로 요약됩니다.</p>
<pre><code class="language-text">## 테스트 작성 완료

### 업데이트된 테스트
- libs/api/__tests__/client.test.ts

### 테스트 결과
✓ 12 tests passed
✗ 0 tests failed

### 주요 테스트 케이스
- apiFetch: GET/POST, 재시도, 에러 처리 등
- createApiClient: 인스턴스 생성, baseURL 설정 등</code></pre>
<p>중요한 건 “파일 하나 늘어났네요”가 아니라,
<strong>변경한 로직(재시도)이 테스트 케이스로 바로 박제</strong>된다는 점입니다.</p>
<hr>
<h2 id="spec과-같이-쓰면-명세→구현→테스트가-두-줄로-끝난다"><code>/spec</code>과 같이 쓰면 “명세→구현→테스트”가 두 줄로 끝난다</h2>
<p>2편에서 다룬 <code>/spec</code>과 붙이면 흐름이 깔끔해져요.</p>
<pre><code class="language-bash">/spec user-auth
/create-jest</code></pre>
<ul>
<li><code>/spec</code>: 명세서 기반 구현 + 검증</li>
<li><code>/create-jest</code>: 변경 기반 테스트 생성 + 실행</li>
</ul>
<p>이렇게 하면 “테스트를 쓰는 사람”이 아니라,
<strong>테스트가 기본으로 따라오는 구조</strong>가 됩니다.</p>
<hr>
<h2 id="왜-이-방식이-효과적이었나">왜 이 방식이 효과적이었나</h2>
<h3 id="1-테스트-쓸까라는-고민이-사라짐">1) “테스트 쓸까?”라는 고민이 사라짐</h3>
<p>테스트가 선택이 아니라 루틴이 됩니다.
루틴이 되면 지속됩니다.</p>
<h3 id="2-테스트-스타일이-통일됨">2) 테스트 스타일이 통일됨</h3>
<p>한글 테스트명, AAA, describe 구조…
팀원마다 다르게 쓰던 테스트가 “명령어 규칙”으로 정렬됩니다.</p>
<h3 id="3-리뷰어가-편해짐">3) 리뷰어가 편해짐</h3>
<p>PR에 테스트가 항상 포함되니,
“테스트는요?”라는 질문 자체가 줄어듭니다.</p>
<h3 id="4-회귀-테스트가-자동으로-쌓임">4) 회귀 테스트가 자동으로 쌓임</h3>
<p>수정할 때마다 테스트가 업데이트되니까,
어느 순간부터는 “테스트가 나를 지켜주는 순간”이 오더라고요.</p>
<hr>
<h2 id="실제-명령어-파일은-이렇게-생겼습니다-핵심만">실제 명령어 파일은 이렇게 생겼습니다 (핵심만)</h2>
<p><code>/create-jest</code> 명령어는 결국 <strong>규칙을 마크다운으로 적어둔 것</strong>입니다.</p>
<pre><code class="language-markdown"># create-jest.md

변경된 파일에 대한 테스트 코드를 생성하거나 업데이트합니다.

## 실행 절차
1. git diff로 변경된 파일 목록 확인
2. 테스트 대상 파일 필터링
3. 기존 테스트 여부에 따라 생성 또는 업데이트
4. 테스트 실행하여 통과 확인

## 테스트 대상 규칙
### 포함
- libs/**/*.ts
- hooks/**/*.ts
- utils/**/*.ts
- services/**/*.ts

### 제외
- app/**/*
- components/**/*
- **/*.d.ts
- **/index.ts
- **/__tests__/**
- *.config.*

## 테스트 작성 규칙
1. 한글 테스트명
2. describe 구조화
3. AAA 패턴
4. 모킹은 외부 의존성만</code></pre>
<p>이렇게 “팀 룰”을 문서로 남기면, Claude는 그걸 매번 일관되게 따라줍니다.</p>
<hr>
<h2 id="커스터마이징-포인트-프로젝트마다-여기만-바꾸면-됨">커스터마이징 포인트 (프로젝트마다 여기만 바꾸면 됨)</h2>
<h3 id="1-테스트-대상-경로">1) 테스트 대상 경로</h3>
<pre><code class="language-markdown">- src/domain/**/*.ts
- src/application/**/*.ts</code></pre>
<h3 id="2-테스트-파일-위치">2) 테스트 파일 위치</h3>
<ul>
<li>소스 파일 옆에 <code>*.test.ts</code> 생성
또는</li>
<li>루트 <code>tests/</code>에 동일 폴더 구조로 생성</li>
</ul>
<h3 id="3-프레임워크-변경">3) 프레임워크 변경</h3>
<ul>
<li>Jest 대신 Vitest</li>
<li>Testing Library 규칙 추가</li>
</ul>
<hr>
<h2 id="주의사항-자동-생성-테스트를-과신하지-않기">주의사항 (자동 생성 테스트를 “과신”하지 않기)</h2>
<ul>
<li><strong>100% 커버리지가 목표가 아닙니다.</strong>
자동 생성은 “기본 안전망”을 깔아주는 역할이에요.</li>
<li>생성된 테스트는 <strong>한 번은 눈으로 검토</strong>하세요.
가끔 표면적인 테스트(예: 단순 snapshot 느낌)가 나올 수 있습니다.</li>
<li>테스트가 쌓이면 실행 시간이 늘어요.
CI에서 병렬 실행/캐싱 같은 운영 최적화도 고려해볼 만합니다.</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p><code>/create-jest</code>의 핵심은 한 문장입니다.</p>
<blockquote>
<p>테스트 작성을 “의지”에서 “시스템”으로 옮기는 것.</p>
</blockquote>
<ul>
<li>“어떻게 테스트하지?” 고민을 없애고</li>
<li>팀 컨벤션에 맞는 테스트를 자동으로 만들고</li>
<li>코드 변경과 테스트가 <strong>항상 같이 다니게</strong> 만들었습니다</li>
</ul>
<p>완벽하진 않아요.
하지만 <strong>테스트 0개</strong>보다
<strong>자동 생성된 테스트 10개 + 중요한 곳 수동 보완 2개</strong>가 훨씬 낫잖아요.</p>
<p>다음 4편에서는 <code>/w-context</code>를 다룹니다.
AI가 코드를 더 잘 이해하도록 <strong>CONTEXT.md를 자동 생성</strong>하는 흐름이에요.</p>
<hr>
<h2 id="참고-link-dropper-프로젝트">참고: Link Dropper 프로젝트</h2>
<p>이 글에서 소개한 명령어들은 제가 만들고 있는 <strong>Link Dropper</strong> 프로젝트에서 실제로 사용하고 있습니다.</p>
<ul>
<li><a href="https://apps.apple.com/us/app/link-dropper/id6755904161">App Store에서 다운로드하기</a></li>
<li><a href="https://link-dropper.com">웹에서 사용하러 가기</a></li>
<li><a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 익스텐션 설치하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[“AI랑 핑퐁하다 하루 끝” 이제 그만: `/spec` 하나로 명세서→구현→검증까지 끝내기]]></title>
            <link>https://velog.io/@link_dropper/claude-commands-2</link>
            <guid>https://velog.io/@link_dropper/claude-commands-2</guid>
            <pubDate>Tue, 23 Dec 2025 04:47:38 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@link_dropper/claude-commands-1">지난 글</a>에서 Claude Code의 Custom Commands로 <strong>구현부터 PR까지</strong> 자동화한 이야기를 했어요.
오늘은 그중에서도 제 워크플로우의 심장 같은 존재, <strong><code>/spec</code> 명령어</strong>를 깊게 파헤쳐보려고 합니다.</p>
<p>솔직히 AI 코딩 도구를 쓰면서 이런 답답함, 한 번쯤 느끼셨을 거예요.</p>
<ul>
<li>“코드는 만들어주는데… 왜 이렇게 만들었는지 모르겠어”</li>
<li>“요청한 것만 딱 만들고 끝. 나머지는 결국 내가 마무리해야 해”</li>
<li>“날마다 결과물이 들쭉날쭉해서, 다시 손대느라 시간이 더 든다”</li>
</ul>
<p><code>/spec</code>은 이 문제를 <strong>‘명세서 기반 개발’</strong>로 정면 돌파합니다.</p>
<blockquote>
<p>AI에게 “구현해줘”라고 말하는 대신,
<strong>결정을 명세서에 미리 담고</strong> <code>/spec</code>으로 실행한다.</p>
</blockquote>
<p>이 한 줄이 생각보다 큰 차이를 만들더라고요.</p>
<hr>
<h2 id="spec이-뭔가요"><code>/spec</code>이 뭔가요?</h2>
<p>한 줄 요약은 이렇게 할게요.</p>
<blockquote>
<p>명세서를 주면, Claude가 <strong>분석→계획→구현→검증→보고</strong>까지 “개발 프로세스”를 그대로 수행하는 명령어</p>
</blockquote>
<pre><code class="language-bash">/spec user-auth  # specs/user-auth.md를 읽고 구현</code></pre>
<p>그리고 여기서 가장 중요한 키워드가 하나 있습니다.</p>
<h3 id="질문-없이"><strong>“질문 없이”</strong></h3>
<p>보통 AI에게 기능 구현을 부탁하면 이런 흐름이 반복되죠.</p>
<pre><code class="language-text">AI: &quot;인증은 JWT로 할까요? 세션으로 할까요?&quot;
나: &quot;JWT요&quot;
AI: &quot;토큰 저장은 어디에 할까요?&quot;
나: &quot;AsyncStorage요&quot;
AI: &quot;에러 처리는 어떻게 할까요?&quot;
나: (이미 지침)</code></pre>
<p><code>/spec</code>은 이 핑퐁을 줄이는 게 아니라, <strong>애초에 필요 없게</strong> 만들어요.</p>
<ul>
<li>선택지를 AI가 묻기 전에</li>
<li>사람이 답변하느라 흐름이 끊기기 전에</li>
<li><strong>명세서에 결정을 박아두는 방식</strong>입니다.</li>
</ul>
<hr>
<h2 id="spec이-동작하는-핵심-원칙-4가지"><code>/spec</code>이 동작하는 핵심 원칙 4가지</h2>
<p><code>/spec</code>은 “코드 생성”이라기보다, <strong>코드를 만들기 위한 규칙 세트</strong>에 가까워요.</p>
<h3 id="1-명세서만으로-끝나야-한다">1) 명세서만으로 끝나야 한다</h3>
<p>명세서가 충분히 상세하면 Claude는 <strong>추가 질문 없이</strong> 구현에 들어갑니다.
즉 명세서는 단순 문서가 아니라 <strong>실행 가능한 문서</strong>가 됩니다.</p>
<h3 id="2-품질을-기본값으로-둔다">2) 품질을 기본값으로 둔다</h3>
<p>“일단 돌아가는 코드”가 아니라</p>
<ul>
<li>가독성</li>
<li>성능</li>
<li>확장성</li>
<li>보안</li>
</ul>
<p>까지 기본값으로 깔고 들어가요. 목표는 <strong>프로덕션에 올려도 부끄럽지 않은 코드</strong>입니다.</p>
<h3 id="3-우선순위-기반으로-구현한다-🔴→🟡→🟢">3) 우선순위 기반으로 구현한다 (🔴→🟡→🟢)</h3>
<p>필수부터 먼저 완성하고, 시간이 남으면 권장/선택 기능으로 넘어갑니다.
이 구조 덕분에 “시간이 부족해서 다 못 했어요”여도 <strong>핵심 기능은 살아있어요.</strong></p>
<h3 id="4-한-명령어는-한-가지-일만">4) 한 명령어는 한 가지 일만</h3>
<p>테스트는 <code>/create-jest</code>, 문서는 <code>/w-context</code>로 분리했어요.
<code>/spec</code>은 <strong>구현과 검증에만 집중</strong>합니다.</p>
<hr>
<h2 id="spec-실행-흐름-phase-0-→-phase-6"><code>/spec</code> 실행 흐름: Phase 0 → Phase 6</h2>
<p><code>/spec</code>이 “그냥 생성”이 아니라 “개발 프로세스”인 이유가 여기 있어요.
총 7단계를 <strong>항상 같은 순서로</strong> 밟습니다.</p>
<pre><code class="language-text">Phase 0: 명세서 로드
Phase 1: 명세서 분석 및 이해
Phase 2: 구현 전 검토 (질문 필요 시)
Phase 3: 구현 계획 수립
Phase 4: 코드 구현
Phase 5: 검증
Phase 6: 완료 보고</code></pre>
<p>각 단계를 짧게(하지만 핵심은 빠짐없이) 살펴볼게요.</p>
<hr>
<h2 id="phase-0-명세서-로드">Phase 0: 명세서 로드</h2>
<pre><code class="language-bash">/spec              # 인자 없이 실행하면 목록 표시
/spec user-auth    # specs/user-auth.md 로드</code></pre>
<ul>
<li>인자가 없으면 사용 가능한 명세서 목록을 보여주고</li>
<li>인자가 있으면 해당 파일을 불러옵니다.</li>
</ul>
<p>파일이 없으면 이렇게 깔끔하게 실패합니다.</p>
<pre><code class="language-text">❌ specs/user-auth.md 파일을 찾을 수 없습니다.
사용 가능한 명세서: [목록]</code></pre>
<hr>
<h2 id="phase-1-명세서-분석-및-이해">Phase 1: 명세서 분석 및 이해</h2>
<p>이 단계가 <strong>품질을 결정</strong>합니다.
Claude가 바로 코딩부터 하지 않고, 명세서에서 필요한 정보를 구조적으로 뽑아내요.</p>
<table>
<thead>
<tr>
<th>섹션</th>
<th>뽑아내는 정보</th>
</tr>
</thead>
<tbody><tr>
<td>개요</td>
<td>기능 목적, 해결하려는 문제</td>
</tr>
<tr>
<td>사용자 시나리오</td>
<td>플로우/유스케이스</td>
</tr>
<tr>
<td>기능 요구사항</td>
<td>🔴 필수 → 🟡 권장 → 🟢 선택 정렬</td>
</tr>
<tr>
<td>구현 위치</td>
<td>생성/수정할 파일 경로</td>
</tr>
<tr>
<td>기존 코드 활용</td>
<td>참고할 패턴/모듈</td>
</tr>
<tr>
<td>타입 정의</td>
<td>인터페이스/타입</td>
</tr>
<tr>
<td>API 설계</td>
<td>엔드포인트, 요청/응답</td>
</tr>
<tr>
<td>에러 처리</td>
<td>에러 코드/메시지/처리</td>
</tr>
</tbody></table>
<p>그리고 여기서 중요한 한 가지.</p>
<h3 id="contextmd를-같이-읽습니다"><strong>CONTEXT.md를 같이 읽습니다</strong></h3>
<p>기존 코드의 설계 의도와 사용 패턴을 놓치면 “프로젝트랑 따로 노는 코드”가 나오거든요.</p>
<pre><code class="language-bash"># 예: libs/fetch 모듈을 활용한다면
cat libs/fetch/CONTEXT.md</code></pre>
<p>이 과정을 넣어두니, 결과물이 “그럴듯한 예제 코드”가 아니라 <strong>우리 코드베이스의 일부처럼</strong> 나오기 시작했습니다.</p>
<hr>
<h2 id="phase-2-구현-전-검토-질문이-정말-필요한지-판단">Phase 2: 구현 전 검토 (질문이 정말 필요한지 판단)</h2>
<p><code>/spec</code>이 “질문이 없다”는 건 <strong>무조건 묻지 않는다</strong>가 아니라,
<strong>정말 필요한 경우에만</strong> 묻도록 설계했다는 뜻입니다.</p>
<h3 id="질문하는-경우">질문하는 경우</h3>
<ul>
<li>구현 위치가 불명확하거나 선택지가 여럿인 경우</li>
<li>기존 코드와 충돌 가능성이 큰 경우</li>
<li>보안/성능에 큰 트레이드오프가 있는 경우</li>
</ul>
<h3 id="질문-없이-진행하는-경우">질문 없이 진행하는 경우</h3>
<ul>
<li>명세서가 충분히 구체적인 경우</li>
<li>컨벤션(CLAUDE.md)으로 답이 이미 정해지는 경우</li>
<li>업계 표준 베스트 프랙티스가 명확한 경우</li>
</ul>
<p>여기서 얻은 인사이트는 딱 하나였어요.</p>
<blockquote>
<p>질문이 많이 나온다면 <code>/spec</code>이 문제라기보다, <strong>명세서가 덜 익은 것</strong>이다.</p>
</blockquote>
<hr>
<h2 id="phase-3-구현-계획-수립-todo-리스트-자동-생성">Phase 3: 구현 계획 수립 (Todo 리스트 자동 생성)</h2>
<p>명세서 요구사항을 바탕으로 Todo를 뽑고, 우선순위를 고정합니다.</p>
<pre><code class="language-text">1. 🔴 필수 요구사항 (모두 완료해야 함)
2. 🟡 권장 요구사항 (시간이 허락하면)
3. 🟢 선택 요구사항 (추가 개선)</code></pre>
<p>그리고 보통 이런 흐름으로 구현 순서를 잡아요.</p>
<pre><code class="language-text">1. 타입 정의
2. 에러/결과 모델
3. 핵심 로직
4. 유틸리티
5. API 라우트
6. UI 컴포넌트
7. 페이지 통합
8. export 정리</code></pre>
<p>타입부터 잡아두면 이후 구현이 안정적으로 굴러가서, 결과적으로 수정량이 줄었습니다.</p>
<hr>
<h2 id="phase-4-코드-구현-하지만-아무렇게나는-금지">Phase 4: 코드 구현 (하지만 “아무렇게나”는 금지)</h2>
<p>여기서는 명세서 + 컨벤션을 근거로 구현합니다.
원칙은 딱 5가지로 고정했어요.</p>
<ul>
<li><strong>컨벤션 준수</strong> (네이밍, import 순서, 코드 스타일)</li>
<li><strong>성능 고려</strong> (불필요 연산/렌더 최소화)</li>
<li><strong>가독성 고려</strong> (단일 책임, 명확한 이름, 필요한 곳만 주석)</li>
<li><strong>확장성 고려</strong> (인터페이스 기반, 하드코딩 지양)</li>
<li><strong>보안 고려</strong> (입력 검증, 민감 정보 노출 방지)</li>
</ul>
<p>이게 좋아서라기보다, <strong>원칙이 고정돼 있으니 결과가 안정적</strong>입니다.</p>
<hr>
<h2 id="phase-5-검증-여기서-진짜-자동화가-갈립니다">Phase 5: 검증 (여기서 “진짜 자동화”가 갈립니다)</h2>
<p>코드만 만들고 끝내면 AI 도구가 아니라 “코드 생성기”죠.
<code>/spec</code>은 검증을 기본 프로세스에 넣었습니다.</p>
<pre><code class="language-bash">pnpm build   # 타입 체크
pnpm lint    # 린트 체크
pnpm test -- &lt;관련 테스트 경로&gt;  # 관련 테스트 (필요 시)</code></pre>
<p>그리고 중요한 포인트.</p>
<blockquote>
<p>에러가 나면 “에러 났어요”로 끝나는 게 아니라, <strong>수정 후 재시도</strong>합니다.</p>
</blockquote>
<p>이 부분 때문에 “결과물의 신뢰도”가 확 올라갔어요.</p>
<hr>
<h2 id="phase-6-완료-보고-사람이-바로-판단-가능하게">Phase 6: 완료 보고 (사람이 바로 판단 가능하게)</h2>
<p>마지막에 구조화된 보고서를 출력합니다. 예시는 이런 느낌이에요.</p>
<pre><code class="language-markdown">## ✅ 구현 완료: 사용자 인증

### 요약
&gt; 소셜 로그인을 통한 사용자 인증 시스템

### 구현된 요구사항
- [x] 🔴 Apple 로그인
- [x] 🔴 Google 로그인
- [x] 🟡 자동 로그인

### 생성된 파일
| 파일 | 설명 |
|------|------|
| `libs/auth/types.ts` | 타입 정의 |
| `libs/auth/core.ts` | 핵심 로직 |

### 검증 결과
| 항목 | 결과 |
|------|------|
| 타입 체크 | ✅ 통과 |
| 린트 | ✅ 통과 |

### 사용 예시
```typescript
import { signInWithApple } from &quot;@/libs/auth&quot;;

const user = await signInWithApple();</code></pre>
<h3 id="다음-단계">다음 단계</h3>
<pre><code class="language-bash">/create-jest
/w-context</code></pre>
<pre><code>
덕분에 “뭐가 됐고, 뭐가 남았고, 다음은 뭘 하면 되는지”가 한눈에 들어옵니다.

---

## 좋은 명세서 작성법: 질문을 0에 가깝게 만드는 체크리스트

`/spec` 품질은 결국 **명세서 품질**에 달려있습니다.  
제가 가장 효과를 봤던 팁들만 모아볼게요.

### 1) 구현 위치를 무조건 적는다
```markdown
## 구현 위치

### 생성할 파일
- `libs/auth/types.ts`
- `libs/auth/core.ts`
- `libs/auth/index.ts`

### 수정할 파일
- `stores/authStore.ts`</code></pre><p>경로가 없으면 100% “어디에 만들까요?”가 나옵니다.</p>
<h3 id="2-타입을-명세서에-박아두기">2) 타입을 명세서에 박아두기</h3>
<pre><code class="language-markdown">## 타입 정의

```typescript
interface AuthUser {
  id: string;
  email: string;
  name: string;
  provider: &#39;apple&#39; | &#39;google&#39; | &#39;kakao&#39;;
}</code></pre>
<pre><code>
타입이 있으면 결과물이 흔들리지 않아요. (그리고 나중에 타입 수정 지옥이 줄어듭니다.)

### 3) 에러 케이스는 표로 정리
```markdown
## 에러 처리

| 상황 | 코드 | 사용자 메시지 | 처리 |
|---|---|---|---|
| 네트워크 끊김 | NETWORK_ERROR | 인터넷 연결을 확인해주세요 | 재시도 |
| 토큰 만료 | TOKEN_EXPIRED | 다시 로그인해주세요 | 로그인 이동 |</code></pre><p>표로 쓰면 “빼먹는 에러”가 확 줄어듭니다.</p>
<h3 id="4-우선순위는-🔴🟡🟢로-고정">4) 우선순위는 🔴🟡🟢로 고정</h3>
<p>AI가 “어느 것부터?”를 고민할 필요가 없게 만드는 장치입니다.</p>
<h3 id="5-기존-코드-활용-섹션을-꼭-둔다">5) “기존 코드 활용” 섹션을 꼭 둔다</h3>
<pre><code class="language-markdown">## 기존 코드 활용

- `libs/api/client.ts` - API 호출 패턴 참고
- `stores/authStore.ts` - 상태 관리 패턴 참고</code></pre>
<p>이게 있으면 결과물이 “우리 코드”에 훨씬 자연스럽게 붙습니다.</p>
<hr>
<h2 id="실제-명세서-예시-일부">실제 명세서 예시 (일부)</h2>
<p>제가 실제로 링크 드라퍼에서 사용한 명세서 일부입니다.</p>
<pre><code class="language-markdown"># 공유 익스텐션 메타데이터 크롤링

## 개요
iOS Share Extension에서 공유된 URL의 메타데이터를 크롤링하여
링크 정보를 자동으로 채우는 기능

## 기능 요구사항
### 🔴 필수
- Open Graph 메타데이터 추출
- 제목/설명/이미지 URL 파싱
- 타임아웃 및 에러 처리

### 🟡 권장
- 이미지 없을 시 파비콘 폴백
- 캐싱으로 중복 요청 방지

## 구현 위치
### 생성할 파일
- `libs/crawler/types.ts`
- `libs/crawler/parser.ts`
- `libs/crawler/index.ts`

## 타입 정의
```typescript
interface LinkMetadata {
  title: string | null;
  description: string | null;
  imageUrl: string | null;
  faviconUrl: string | null;
}</code></pre>
<h2 id="에러-처리">에러 처리</h2>
<table>
<thead>
<tr>
<th>에러 상황</th>
<th>처리 방법</th>
</tr>
</thead>
<tbody><tr>
<td>네트워크 타임아웃</td>
<td>5초 후 빈 메타데이터 반환</td>
</tr>
<tr>
<td>파싱 실패</td>
<td>URL만 저장, 메타데이터는 null</td>
</tr>
</tbody></table>
<pre><code>
이 정도만 적어도 `/spec`이 **질문 없이 구현**을 끝내는 편이었습니다.

---

## `/spec`이 바꾼 내 개발 방식

### Before: 대화형 개발(=핑퐁 지옥)
```text
나: &quot;인증 기능 만들어줘&quot;
AI: &quot;어떤 방식으로?&quot;
나: &quot;소셜 로그인&quot;
AI: &quot;어떤 제공자?&quot;
나: &quot;Apple, Google&quot;
AI: &quot;토큰 저장은?&quot;
나: (이때부터 집중력 붕괴)</code></pre><h3 id="after-명세서-기반-개발실행">After: 명세서 기반 개발(=실행)</h3>
<pre><code class="language-text">나: &quot;/spec user-auth&quot;
AI: 분석 → 계획 → 구현 → 검증 → 보고
나: (검증 통과한 코드 받음)</code></pre>
<p>시간이 줄어든 것도 좋았지만, 더 큰 변화는 이거였어요.</p>
<blockquote>
<p>명세서가 같으면 결과도 비슷하다.
즉, <strong>코드 품질이 ‘컨디션’이 아니라 ‘프로세스’에 의해 결정</strong>된다.</p>
</blockquote>
<hr>
<h2 id="마치며">마치며</h2>
<p><code>/spec</code>은 단순히 “코드를 잘 뽑는 명령어”가 아니라,</p>
<ul>
<li>명세서를 <strong>실행 가능한 문서</strong>로 만들고</li>
<li>개발 프로세스(분석→계획→구현→검증)를 <strong>강제</strong>하고</li>
<li>결과물의 품질을 <strong>안정화</strong>하는 장치였습니다.</li>
</ul>
<p>명세서를 쓰는 시간이 들긴 해요.
그런데 그 시간은 대부분 “AI와 핑퐁하며 잃는 시간”을 통째로 대체하더라고요.</p>
<p>다음 글(3편)에서는 <code>/create-jest</code>를 다룹니다.
<code>/spec</code>으로 만든 코드에 <strong>테스트를 자동으로 붙이는 흐름</strong>이 어떻게 굴러가는지 공유해볼게요.</p>
<hr>
<h2 id="시리즈-안내">시리즈 안내</h2>
<table>
<thead>
<tr>
<th>순서</th>
<th>제목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>1편</td>
<td><a href="./claude-commands-1.md">Claude Code Commands로 개발 워크플로우 자동화하기</a></td>
<td>✅ 완료</td>
</tr>
<tr>
<td><strong>2편</strong></td>
<td><strong>/spec - 명세서 기반 코드 구현 자동화</strong></td>
<td>✅ 현재 글</td>
</tr>
<tr>
<td>3편</td>
<td>/create-jest - 테스트 코드 자동 생성</td>
<td>예정</td>
</tr>
<tr>
<td>4편</td>
<td>/w-context - AI를 위한 문서 작성</td>
<td>예정</td>
</tr>
<tr>
<td>5편</td>
<td>/pr + /apply-review - PR 워크플로우 자동화</td>
<td>예정</td>
</tr>
</tbody></table>
<hr>
<h2 id="링크-드라퍼-정식-출시">링크 드라퍼, 정식 출시!</h2>
<blockquote>
<p>링크 드라퍼는 단순 저장 툴이 아니라, “다시 꺼내보게 만드는” 링크 관리 도구를 지향합니다.</p>
</blockquote>
<ul>
<li>빠르고 간편한 링크 저장: iOS/Android 앱, 웹, 크롬 익스텐션</li>
<li>폴더로 깔끔하게 정리: 읽을 거리, 레퍼런스, 쇼핑 후보까지</li>
<li>폴더 공유: 같이 보는 자료는 폴더 단위로 한 번에</li>
<li>크롬 익스텐션 원클릭 저장: 보고 있는 페이지를 바로 저장</li>
</ul>
<p><a href="https://apps.apple.com/us/app/link-dropper/id6755904161">링크 드라퍼 앱 다운로드 (iOS)</a>
<a href="https://link-dropper.com">링크 드라퍼 웹에서 사용하러 가기</a>
<a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 익스텐션 설치하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[“그거 또 해야 해?”를 없애는 법: Claude Code Commands로 개발 워크플로우 자동화하기]]></title>
            <link>https://velog.io/@link_dropper/claude-commands-1</link>
            <guid>https://velog.io/@link_dropper/claude-commands-1</guid>
            <pubDate>Fri, 19 Dec 2025 05:09:18 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>개발하면서 진짜 자주 드는 생각이 있어요.</p>
<ul>
<li>“테스트… 또 써야 하지?”</li>
<li>“PR 본문… 또 정리해야 하지?”</li>
<li>“리뷰 코멘트… 또 반영하고 답글 달아야 하지?”</li>
</ul>
<p>문제는 이 작업들이 <strong>매번 조금씩 다르지만, 패턴은 거의 동일</strong>하다는 겁니다.
그래서 저는 Claude Code의 <strong>Custom Commands</strong> 기능을 이용해, 반복되는 흐름을 <strong>명령어 5개로 고정</strong>해버렸습니다.</p>
<p>이 글에서는 “어떤 명령어를 어떻게 묶어서” 개발 워크플로우 전체를 자동화했는지, 그리고 왜 이 방식이 생각보다 강력했는지를 <strong>벨로그 스타일로</strong> 편하게 공유해볼게요.</p>
<hr>
<h2 id="claude-code의-custom-commands란">Claude Code의 Custom Commands란?</h2>
<p>Claude Code에서는 <code>.claude/commands/</code> 폴더에 <strong>마크다운 파일</strong>을 만들어두면, 터미널에서 <code>/명령어</code> 형태로 실행할 수 있어요.</p>
<pre><code class="language-text">.claude/
└── commands/
    ├── spec.md          # /spec으로 실행
    ├── create-jest.md   # /create-jest로 실행
    ├── w-context.md     # /w-context로 실행
    ├── pr.md            # /pr로 실행
    └── apply-review.md  # /apply-review로 실행</code></pre>
<p>포인트는 이겁니다.</p>
<blockquote>
<p>“스크립트를 짜는 게 아니라, <strong>작업 지시서를 마크다운으로 적는다</strong>.”</p>
</blockquote>
<p>즉, 마크다운 안에 자연어로
“이 상황에서는 이렇게 분석하고, 이런 규칙을 따라, 이런 결과를 만들어줘”
라고 적어두면 Claude가 그걸 <strong>그대로 실행 가능한 워크플로우</strong>로 돌려줍니다.</p>
<hr>
<h2 id="내가-자동화한-개발-워크플로우-한-장-요약">내가 자동화한 개발 워크플로우 (한 장 요약)</h2>
<p>제가 만든 명령어 5개는 개발 사이클을 <strong>처음부터 끝까지</strong> 커버합니다.</p>
<pre><code class="language-text">기능 명세서 작성
      ↓
  ① /spec         →  명세서 기반 코드 구현
      ↓
  ② /create-jest  →  테스트 코드 자동 생성
      ↓
  ③ /w-context    →  AI용 문서(CONTEXT.md) 작성
      ↓
  ④ /pr           →  커밋 &amp; PR 생성
      ↓
  ⑤ /apply-review →  코드 리뷰 자동 반영</code></pre>
<p>여기서 중요한 건, 이 흐름이 “AI가 똑똑해서 알아서”가 아니라
<strong>명령어마다 역할과 규칙을 명확히 분리</strong>해두었기 때문에 안정적으로 돌아간다는 점이에요.</p>
<p>그럼 각각을 짧게 살펴볼게요.</p>
<hr>
<h2 id="1-spec--명세서-기반-코드-구현">1) <code>/spec</code> — 명세서 기반 코드 구현</h2>
<p><strong>한 줄 요약:</strong> 명세서를 주면, 구현을 “절차적으로” 끝냅니다.</p>
<pre><code class="language-bash">/spec user-auth  # specs/user-auth.md를 읽고 구현</code></pre>
<p><code>specs/</code> 폴더에 기능 명세서를 마크다운으로 작성해두면, Claude가 아래처럼 <strong>단계적으로 구현</strong>합니다.</p>
<ul>
<li>Phase 0: 명세서 로드</li>
<li>Phase 1: 명세서 분석 및 이해</li>
<li>Phase 2: 구현 전 검토 (질문 필요 시)</li>
<li>Phase 3: 구현 계획 수립</li>
<li>Phase 4: 코드 구현</li>
<li>Phase 5: 검증 (타입 체크, 린트)</li>
</ul>
<p>제가 느낀 체감 포인트는 두 가지였어요.</p>
<ul>
<li><strong>“바로 코딩”이 아니라 “이해→계획→구현→검증”이 강제됨</strong></li>
<li>결과가 코드만 던지고 끝이 아니라, <strong>생성 파일/검증 결과/사용 예시까지 보고서처럼</strong> 나옴</li>
</ul>
<p>즉, 단순 생성기가 아니라 “개발 프로세스”를 명령어에 박아둔 느낌입니다.</p>
<hr>
<h2 id="2-create-jest--테스트-코드-자동-생성">2) <code>/create-jest</code> — 테스트 코드 자동 생성</h2>
<p><strong>한 줄 요약:</strong> <code>git diff</code> 기준으로 바뀐 파일을 잡고 테스트를 만들어줍니다.</p>
<pre><code class="language-bash">/create-jest  # git diff 기반으로 테스트 대상 파일 자동 감지</code></pre>
<p>특히 마음에 들었던 건 “테스트 대상 선정”을 규칙으로 박아둔 부분이에요.</p>
<ul>
<li><code>libs/</code>, <code>hooks/</code>, <code>utils/</code>, <code>services/</code> 등 테스트 대상 폴더를 정의</li>
<li><code>__tests__/</code>에 테스트 파일 생성</li>
<li>테스트명은 한글로 (<code>it(&quot;사용자를 생성한다&quot;, ...)</code>)</li>
<li>AAA 패턴(Arrange-Act-Assert) 적용</li>
</ul>
<p>이렇게 해두면, 테스트를 “쓸지 말지 고민”하는 단계가 줄어듭니다.
그냥 변경되면 → 테스트가 생깁니다.</p>
<hr>
<h2 id="3-w-context--ai를-위한-문서contextmd-작성">3) <code>/w-context</code> — AI를 위한 문서(CONTEXT.md) 작성</h2>
<p><strong>한 줄 요약:</strong> 주석 대신, “모듈의 설계 의도”를 문서로 남깁니다.</p>
<pre><code class="language-bash">/w-context  # 현재 폴더에 CONTEXT.md 생성</code></pre>
<p>이건 생각보다 효과가 컸어요.
코드는 시간이 지나면 “왜 이렇게 했지?”가 남는데, 그걸 보통 주석이나 위키로 해결하려다 실패하잖아요.</p>
<p><code>/w-context</code>는 아예 <strong>AI가 다음 수정 때 읽을 문서</strong>를 만들어줍니다.</p>
<ul>
<li>이 모듈이 하는 일 (한 문장)</li>
<li>파일 구조와 역할</li>
<li>핵심 설계 결정</li>
<li>사용 패턴 예시</li>
<li>확장 시 고려사항</li>
</ul>
<p>결과적으로 다음 작업에서 Claude가 <strong>맥락을 먼저 잡고</strong> 들어오니까,
수정 품질이 확 좋아지더라고요.</p>
<hr>
<h2 id="4-pr--커밋--pr-생성">4) <code>/pr</code> — 커밋 &amp; PR 생성</h2>
<p><strong>한 줄 요약:</strong> 변경사항을 분석해서 “커밋하고 PR까지” 올려줍니다.</p>
<pre><code class="language-bash">/pr  # 자동으로 브랜치 생성, 커밋, PR 생성</code></pre>
<p>PR은 사실 코딩보다도 피로도가 큰 작업이죠.</p>
<ul>
<li>브랜치 이름 자동 생성 (<code>feature/</code>, <code>fix/</code>, <code>hotfix/</code>)</li>
<li>변경사항을 논리 단위로 나눠 커밋</li>
<li>PR 제목/본문 자동 작성</li>
<li><code>gh</code> CLI로 GitHub 연동</li>
</ul>
<p>커밋 메시지 포맷도 프로젝트 규칙으로 고정했습니다.</p>
<pre><code class="language-text">[카테고리] 작업 제목

작업 설명 (복잡한 변경일 경우에만)</code></pre>
<p>이렇게 해두면 PR 품질이 <strong>개인 컨디션에 따라 흔들리지 않습니다.</strong>
“늘 일정한 수준”으로 나오는 게 팀에서는 진짜 큰 장점이에요.</p>
<hr>
<h2 id="5-apply-review--코드-리뷰-자동-반영">5) <code>/apply-review</code> — 코드 리뷰 자동 반영</h2>
<p><strong>한 줄 요약:</strong> 리뷰 코멘트를 읽고 “반영/거절/답글”까지 처리합니다.</p>
<pre><code class="language-bash">/apply-review  # 현재 브랜치의 PR 리뷰를 가져와서 처리</code></pre>
<p>제가 이 명령어에서 제일 중요하게 둔 건 “무조건 반영”이 아니라, <strong>판단 기준을 넣는 것</strong>이었습니다.</p>
<h3 id="적용하는-경우">적용하는 경우</h3>
<ul>
<li>버그 지적, 보안 이슈, 성능 문제</li>
<li>코드 컨벤션 위반, 가독성 개선</li>
</ul>
<h3 id="거절하는-경우">거절하는 경우</h3>
<ul>
<li>단순 취향 차이</li>
<li>과도한 추상화 요청</li>
<li>PR 범위를 벗어난 리팩토링 요청</li>
</ul>
<p>그리고 반영 후엔 PR에 자동으로 답글도 남깁니다.</p>
<ul>
<li>“반영했습니다”</li>
<li>혹은 “이번 PR 범위를 넘어선 변경이라 다음 작업으로 분리하겠습니다” 같은 거절 사유</li>
</ul>
<p>리뷰 대응에서 에너지가 많이 빠지는데, 이걸 “규칙화” 해두니까 팀 커뮤니케이션도 훨씬 매끄러워졌어요.</p>
<hr>
<h2 id="왜-이-방식이-효과적이었나">왜 이 방식이 효과적이었나</h2>
<h3 id="1-자연어로-정의되는-팀의-규칙">1) 자연어로 정의되는 “팀의 규칙”</h3>
<p>스크립트는 유지보수 비용이 커요.
그런데 마크다운 지시서는 <strong>읽고 고치기 쉬워서</strong> 팀 규칙을 담기에 좋습니다.</p>
<h3 id="2-프로젝트-맞춤형-커스터마이징이-쉬움">2) 프로젝트 맞춤형 커스터마이징이 쉬움</h3>
<ul>
<li>커밋 메시지 규칙</li>
<li>폴더 구조</li>
<li>테스트 스타일(한글 테스트명, AAA 패턴)</li>
</ul>
<p>이런 것들이 “우리 팀 방식”인데, 명령어로 고정해두면 신규 인원 온 날부터 바로 맞춰집니다.</p>
<h3 id="3-개선-루프가-빠름">3) 개선 루프가 빠름</h3>
<p>명령어가 마음에 안 들면?</p>
<ul>
<li><code>.md</code> 파일 열고</li>
<li>문장 몇 줄 고치고</li>
<li>다시 실행</li>
</ul>
<p>피드백 반영 속도가 빨라서 “우리 팀에 맞는 자동화”로 점점 진화합니다.</p>
<h3 id="4-공유가-쉽다">4) 공유가 쉽다</h3>
<p><code>.claude/commands/</code>를 Git에 올리면 끝입니다.
온보딩 문서에 “이거 하세요” 적는 것보다, <strong>명령어로 강제하는 게</strong> 더 강력하더라고요.</p>
<hr>
<h2 id="시리즈-안내">시리즈 안내</h2>
<p>이 글은 전체 개요이고, 다음 글부터는 각 명령어를 더 깊게 파볼 예정입니다.</p>
<table>
<thead>
<tr>
<th>순서</th>
<th>제목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1편</strong></td>
<td><strong>Claude Code Commands로 개발 워크플로우 자동화하기</strong></td>
<td>전체 개요 (현재 글)</td>
</tr>
<tr>
<td>2편</td>
<td>/spec - 명세서 기반 코드 구현 자동화</td>
<td>가장 핵심인 구현 자동화</td>
</tr>
<tr>
<td>3편</td>
<td>/create-jest - 테스트 코드 자동 생성</td>
<td>테스트 작성 자동화</td>
</tr>
<tr>
<td>4편</td>
<td>/w-context - AI를 위한 문서 작성</td>
<td>CONTEXT.md 개념과 활용</td>
</tr>
<tr>
<td>5편</td>
<td>/pr + /apply-review - PR 워크플로우 자동화</td>
<td>PR 생성부터 리뷰 반영까지</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>Custom Commands는 “AI 기능”이라기보다, 제게는 <strong>개발 프로세스를 마크다운으로 코드화하는 도구</strong>처럼 느껴졌습니다.</p>
<ul>
<li>반복 작업을 줄이는 수준을 넘어서</li>
<li>팀의 컨벤션과 판단 기준을 명령어로 고정하고</li>
<li>결과 품질을 일정하게 만들 수 있었거든요.</li>
</ul>
<p>다음 글에서는 가장 핵심인 <code>/spec</code>를 깊게 다뤄보겠습니다.
명세서를 어떻게 써야 Claude가 덜 헤매는지, 그리고 실제로 어떤 흐름으로 구현이 진행되는지 예시까지 포함해서 정리해볼게요.</p>
<hr>
<h2 id="10-🧪-링크-드라퍼-정식-출시">10. 🧪 링크 드라퍼, 정식 출시!</h2>
<blockquote>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있어요.</p>
</blockquote>
<p>🔗 빠르고 간편한 링크 저장
iOS/Android 앱, 웹, 크롬 익스텐션 어디서든 바로 저장
🧠 폴더별로 깔끔하게 정리
읽을 거리, 레퍼런스, 쇼핑 후보까지 주제별 정리
🌐 폴더를 친구에게 공유
같이 보는 자료는 폴더 단위로 링크 한 번에 공유
⚡ 크롬 익스텐션 원클릭 저장
지금 보고 있는 페이지를 버튼 한 번으로 저장</p>
<p>👉 <a href="https://apps.apple.com/us/app/link-dropper/id6755904161">링크 드라퍼 앱 다운로드 (iOS)</a>
👉 <a href="https://link-dropper.com">링크 드라퍼 웹에서 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 익스텐션 설치하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Expo 앱에서 OTA와 강제 업데이트까지 한 번에 관리하기]]></title>
            <link>https://velog.io/@link_dropper/expo-OTA-push</link>
            <guid>https://velog.io/@link_dropper/expo-OTA-push</guid>
            <pubDate>Mon, 15 Dec 2025 07:43:01 GMT</pubDate>
            <description><![CDATA[<h2 id="1-앱을-운영하면-결국-부딪히는-두-가지-욕망">1. 앱을 운영하면 결국 부딪히는 두 가지 욕망</h2>
<p>앱을 조금만 운영해 보면 금방 이런 생각이 듭니다.</p>
<ol>
<li><strong>“아… 이 버그 오늘 안에라도 고치고 싶은데, 스토어 심사는 너무 느리다.”</strong></li>
<li><strong>“이 구버전은 진짜 더 이상 쓰면 안 되는데… 강제로라도 막고 싶다.”</strong></li>
</ol>
<p>Expo를 쓴다면 이 두 가지는 이렇게 나뉩니다.</p>
<ul>
<li><strong>OTA(Over-The-Air) 업데이트</strong>
→ EAS Update로 JS 번들을 즉시 교체</li>
<li><strong>네이티브(스토어) 업데이트</strong>
→ App Store / Play Store 심사를 거치는 일반적인 업데이트</li>
</ul>
<p>Link Dropper 앱에서는 이 두 가지를 동시에 가져가는 구조를 만들었습니다.</p>
<ul>
<li>JS 코드, UI, 로직은 <strong>EAS Update(OTA)</strong>로 빠르게 수정</li>
<li>꼭 막아야 하는 구버전은 <strong>서버에서 “최소 지원 버전”을 내려서 강제 업데이트</strong></li>
</ul>
<p>이 글에서는 그 구조를 <strong>처음부터 끝까지 한 번에</strong> 정리해 봅니다.</p>
<hr>
<h2 id="2-ota-vs-네이티브-업데이트-경계-어디까지">2. OTA vs 네이티브 업데이트, 경계 어디까지?</h2>
<p>먼저, 어떤 변경을 어디서 처리해야 할지 기준을 세워야 합니다.</p>
<h3 id="2-1-ota-업데이트-eas-update">2-1. OTA 업데이트 (EAS Update)</h3>
<ul>
<li><p><strong>변경 가능 범위</strong></p>
<ul>
<li>JavaScript 코드</li>
<li>React 컴포넌트</li>
<li>이미지 등 번들에 포함된 에셋</li>
</ul>
</li>
<li><p><strong>배포 속도</strong></p>
<ul>
<li>거의 즉시 (스토어 심사 없음)</li>
</ul>
</li>
<li><p><strong>언제 쓰는가</strong></p>
<ul>
<li>버그 수정</li>
<li>UI/UX 개선</li>
<li>비즈니스 로직 수정</li>
</ul>
</li>
</ul>
<h3 id="2-2-네이티브-업데이트-스토어-배포">2-2. 네이티브 업데이트 (스토어 배포)</h3>
<ul>
<li><p><strong>변경 가능 범위</strong></p>
<ul>
<li>네이티브 코드 (Swift, Kotlin, 네이티브 모듈)</li>
<li>Expo SDK 버전 업그레이드</li>
<li>권한 추가/변경 (예: 푸시, 위치, 카메라)</li>
</ul>
</li>
<li><p><strong>배포 속도</strong></p>
<ul>
<li>심사 + 전파 시간: 1~7일 정도</li>
</ul>
</li>
<li><p><strong>언제 쓰는가</strong></p>
<ul>
<li>새로운 네이티브 기능 도입</li>
<li>SDK 버전 올리기</li>
<li>빌드 세팅 변경</li>
</ul>
</li>
</ul>
<blockquote>
<p><strong>정리하면:</strong>
OTA로 가능한 건 <strong>일단 OTA로 빠르게</strong>.
네이티브/SDK/권한이 엮이면 <strong>스토어로 정식 배포</strong>.</p>
</blockquote>
<hr>
<h2 id="3-필수-업데이트는-왜-언제-필요한가">3. “필수 업데이트”는 왜, 언제 필요한가</h2>
<p>OTA만으로는 해결할 수 없는 상황이 분명 존재합니다.</p>
<ul>
<li>서버 API가 크게 바뀌어서
→ <strong>이전 버전이 더 이상 정상 동작하지 않을 때</strong></li>
<li>심각한 보안 취약점이 발견되어
→ <strong>특정 버전 이상만 쓰게 강제해야 할 때</strong></li>
<li>네이티브 기능이 필수인데
→ <strong>해당 기능이 없는 옛 빌드를 막고 싶을 때</strong></li>
</ul>
<p>이럴 땐 결국 이렇게 해야 합니다.</p>
<ol>
<li>스토어에 새로운 버전을 올리고</li>
<li>서버에서 “최소 지원 버전”을 올리고</li>
<li>그보다 낮은 버전의 앱은
→ <strong>강제 업데이트 알럿만 띄우고, 정상 사용은 막는다</strong></li>
</ol>
<p>이 글에서 설명하는 구조가 바로 이 부분을 담당합니다.</p>
<hr>
<h2 id="4-전체-흐름-한-번에-보기">4. 전체 흐름 한 번에 보기</h2>
<p>먼저, 앱 시작 시 전체 흐름은 이렇게 설계했습니다.</p>
<pre><code class="language-text">[앱 시작]
      │
      ▼
1. 서버에서 최소 지원 버전 조회
   (GET /app-config/version)
      │
      ▼
2. 현재 앱 버전 &lt; 최소 버전 ?
      │
  ┌───┴──────────────┐
 YES                  NO
  │                   │
  ▼                   ▼
[강제 업데이트 알럿]   3. OTA 업데이트 확인
(스토어로 이동)          (Updates.checkForUpdateAsync)
                      │
                      ▼
              [업데이트 있음? → 다음 실행 시 적용]</code></pre>
<p>즉, <strong>“필수 업데이트 체크”를 먼저</strong> 하고, 통과하면 그 다음에 <strong>OTA 업데이트를 확인</strong>합니다.</p>
<hr>
<h2 id="5-eas-update-기본-세팅">5. EAS Update 기본 세팅</h2>
<h3 id="5-1-appjson--appconfig-설정">5-1. app.json / app.config 설정</h3>
<pre><code class="language-json">{
  &quot;expo&quot;: {
    &quot;runtimeVersion&quot;: {
      &quot;policy&quot;: &quot;appVersion&quot;
    },
    &quot;updates&quot;: {
      &quot;url&quot;: &quot;https://u.expo.dev/[프로젝트 ID]&quot;
    }
  }
}</code></pre>
<ul>
<li><code>runtimeVersion.policy: &quot;appVersion&quot;</code>
→ “앱 버전(예: 1.2.0)이 같은 앱끼리만 같은 OTA 업데이트를 받는다”는 정책
→ 네이티브가 바뀌면 <strong>앱 버전을 올려야</strong> 한다고 이해하면 됩니다.</li>
</ul>
<h3 id="5-2-easjson-설정">5-2. eas.json 설정</h3>
<pre><code class="language-json">{
  &quot;cli&quot;: {
    &quot;appVersionSource&quot;: &quot;remote&quot;
  },
  &quot;build&quot;: {
    &quot;production&quot;: {
      &quot;autoIncrement&quot;: true,
      &quot;channel&quot;: &quot;production&quot;
    }
  }
}</code></pre>
<ul>
<li><code>channel</code>: OTA 업데이트를 적용할 <strong>채널 이름</strong></li>
<li><code>autoIncrement</code>: 빌드 번호 자동 증가</li>
</ul>
<hr>
<h2 id="6-서버에서-최소-지원-버전-관리하기">6. 서버에서 “최소 지원 버전” 관리하기</h2>
<p>필수 업데이트를 위해서는 서버가 “이 버전 미만은 막아라”를 알고 있어야 합니다.</p>
<h3 id="6-1-응답-타입-설계">6-1. 응답 타입 설계</h3>
<pre><code class="language-ts">// GET /app-config/version

interface ServerVersionResponse {
  ios: string;      // iOS 최소 버전 (예: &quot;1.2.0&quot;)
  android: string;  // Android 최소 버전 (예: &quot;1.1.0&quot;)
  updatedAt: string;
}</code></pre>
<h3 id="6-2-예시-응답-json">6-2. 예시 응답 JSON</h3>
<pre><code class="language-json">{
  &quot;ios&quot;: &quot;1.2.0&quot;,
  &quot;android&quot;: &quot;1.1.0&quot;,
  &quot;updatedAt&quot;: &quot;2024-12-15T10:00:00Z&quot;
}</code></pre>
<ul>
<li>플랫폼별로 따로 두면
→ iOS/Android 배포 속도가 달라도 유연하게 대응할 수 있습니다.</li>
</ul>
<hr>
<h2 id="7-버전-비교-유틸리티">7. 버전 비교 유틸리티</h2>
<p>버전 비교는 결국 <code>1.2.3</code> 형식의 문자열을 숫자로 쪼개서 비교하는 방식으로 구현했습니다.</p>
<pre><code class="language-ts">// libs/utils/version/index.ts

export const isVersionSufficient = (current: string, minimum: string): boolean =&gt; {
  const currentParts = current.split(&#39;.&#39;).map(Number);
  const minParts = minimum.split(&#39;.&#39;).map(Number);

  for (let i = 0; i &lt; 3; i++) {
    const curr = currentParts[i] || 0;
    const min = minParts[i] || 0;

    if (curr &gt; min) return true;
    if (curr &lt; min) return false;
  }

  return true; // 같으면 충분
};

export const isValidVersion = (version: string): boolean =&gt; {
  const versionRegex = /^\d+\.\d+\.\d+$/;
  return versionRegex.test(version);
};</code></pre>
<ul>
<li><code>isVersionSufficient</code>
→ <code>current &gt;= minimum</code> 이면 <code>true</code></li>
<li><code>isValidVersion</code>
→ <code>x.y.z</code> 패턴인지 간단히 검증</li>
</ul>
<hr>
<h2 id="8-updatemanager-업데이트-체크의-허브">8. UpdateManager: 업데이트 체크의 허브</h2>
<p>이제 실제로 <strong>“앱 시작 시 한 번만 부르면 되는 함수”</strong>를 만드는 단계입니다.</p>
<h3 id="8-1-타입-정의">8-1. 타입 정의</h3>
<pre><code class="language-ts">// types/config.ts

export type UpdateStatus =
  | &#39;UP_TO_DATE&#39;
  | &#39;NATIVE_UPDATE_REQUIRED&#39;
  | &#39;OTA_UPDATE_AVAILABLE&#39;
  | &#39;ERROR&#39;;

export interface UpdateCheckResult {
  status: UpdateStatus;
  currentVersion?: string;
  minimumVersion?: string;
  error?: Error;
}</code></pre>
<h3 id="8-2-현재-버전--최소-버전-가져오기">8-2. 현재 버전 &amp; 최소 버전 가져오기</h3>
<pre><code class="language-ts">import * as Application from &#39;expo-application&#39;;
import { Platform } from &#39;react-native&#39;;

const getCurrentVersion = (): string =&gt; {
  return Application.nativeApplicationVersion || &#39;0.0.0&#39;;
};

const selectMinimumVersion = (config: ServerVersionResponse): string | undefined =&gt; {
  return Platform.select({
    ios: config.ios,
    android: config.android,
  });
};</code></pre>
<h3 id="8-3-네이티브스토어-업데이트-필요-여부-체크">8-3. 네이티브(스토어) 업데이트 필요 여부 체크</h3>
<pre><code class="language-ts">const checkNativeUpdate = async (): Promise&lt;UpdateCheckResult&gt; =&gt; {
  try {
    const config = await fetchMinimumVersion(); // 서버 API 호출
    const minVersion = selectMinimumVersion(config);
    const currentVersion = getCurrentVersion();

    if (!minVersion || !isValidVersion(minVersion)) {
      // 서버가 이상한 값을 주면 일단 통과 (Fail-Open)
      return { status: &#39;UP_TO_DATE&#39;, currentVersion };
    }

    if (!isVersionSufficient(currentVersion, minVersion)) {
      return {
        status: &#39;NATIVE_UPDATE_REQUIRED&#39;,
        currentVersion,
        minimumVersion: minVersion,
      };
    }

    return { status: &#39;UP_TO_DATE&#39;, currentVersion, minimumVersion: minVersion };
  } catch (error) {
    // 네트워크 오류 시에도 앱은 사용 가능하게 둔다 (Fail-Open)
    return {
      status: &#39;ERROR&#39;,
      error: error instanceof Error ? error : new Error(&#39;Unknown error&#39;),
    };
  }
};</code></pre>
<hr>
<h2 id="9-fail-open-정책-서버-장애에도-앱은-살아-있어야-한다">9. Fail-Open 정책: 서버 장애에도 앱은 살아 있어야 한다</h2>
<p>업데이트 체크 중에 에러가 나면 어떻게 할까요?</p>
<h3 id="9-1-두-가지-선택지">9-1. 두 가지 선택지</h3>
<ul>
<li><p><strong>Fail-Close</strong></p>
<ul>
<li>“버전 체크에 실패했으니, 위험하니 앱을 막자”</li>
<li>보안/규제 산업에서는 타당한 선택</li>
</ul>
</li>
<li><p><strong>Fail-Open</strong></p>
<ul>
<li>“버전 체크에 실패했지만, 일단 앱은 쓰게 하자”</li>
<li>UX와 가용성에 더 무게를 둔 선택</li>
</ul>
</li>
</ul>
<p>Link Dropper에서는 <strong>Fail-Open</strong>을 선택했습니다.</p>
<pre><code class="language-ts">const checkAppUpdates = async (): Promise&lt;void&gt; =&gt; {
  const nativeResult = await checkNativeUpdate();

  if (nativeResult.status === &#39;NATIVE_UPDATE_REQUIRED&#39;) {
    showForceUpdateAlert();
    return;
  }

  // 에러가 나도 앱은 계속 사용 가능
  if (nativeResult.status === &#39;ERROR&#39;) {
    console.warn(&#39;Native version check failed, but continuing (Fail-Open)&#39;);
  }

  // OTA 업데이트 체크
  await checkAndApplyOTAUpdate();
};</code></pre>
<p><strong>이유는 간단합니다.</strong></p>
<ul>
<li>서버가 잠깐 터졌다고 전체 앱이 “열리지 않는 서비스”가 되는 건
→ 작은 팀에게 치명적</li>
<li>“업데이트 강제”보다 더 큰 악영향은
→ “앱 자체가 안 열리는 경험”</li>
</ul>
<p>보안 등급이 높은 서비스라면 반대 선택을 할 수도 있습니다.
중요한 건 <strong>정책을 의식적으로 선택</strong>하는 것.</p>
<hr>
<h2 id="10-ota-업데이트-체크--적용">10. OTA 업데이트 체크 &amp; 적용</h2>
<p>이제 OTA 쪽입니다.</p>
<pre><code class="language-ts">import * as Updates from &#39;expo-updates&#39;;

const checkAndApplyOTAUpdate = async (): Promise&lt;UpdateCheckResult&gt; =&gt; {
  try {
    const update = await Updates.checkForUpdateAsync();

    if (!update.isAvailable) {
      return { status: &#39;UP_TO_DATE&#39; };
    }

    // 백그라운드에서 다운로드
    await Updates.fetchUpdateAsync();

    // 기본 전략: 다음 앱 실행 시 적용
    return { status: &#39;OTA_UPDATE_AVAILABLE&#39; };
  } catch (error) {
    return {
      status: &#39;ERROR&#39;,
      error: error instanceof Error ? error : new Error(&#39;Unknown error&#39;),
    };
  }
};</code></pre>
<p>만약 “지금 당장 바로 적용하고 싶다”면:</p>
<pre><code class="language-ts">// 즉시 재시작 (사용자 경험에 주의)
await Updates.reloadAsync();</code></pre>
<ul>
<li>스플래시 화면이 다시 뜨고 앱이 새로 부팅되는 느낌이 나기 때문에
→ 사용자에게 한 번은 안내해 주는 편이 좋습니다.</li>
</ul>
<hr>
<h2 id="11-강제-업데이트-알럿-ux">11. 강제 업데이트 알럿 UX</h2>
<p>이제 <strong>스토어 업데이트가 ‘필수’일 때</strong> 띄우는 알럿입니다.</p>
<pre><code class="language-ts">import { Alert, AppState, Linking } from &#39;react-native&#39;;

const STORE_URL = &#39;https://appstore.com/your-app&#39;; // 실제 스토어 URL

const showForceUpdateAlert = (): void =&gt; {
  Alert.alert(
    &#39;업데이트 필요&#39;,
    &#39;새로운 버전이 출시되었습니다. 계속 사용하려면 앱을 업데이트해 주세요.&#39;,
    [
      {
        text: &#39;업데이트&#39;,
        onPress: () =&gt; {
          // 스토어로 이동
          Linking.openURL(STORE_URL);

          // 스토어 다녀와서도 여전히 구버전이면 다시 알럿
          const subscription = AppState.addEventListener(&#39;change&#39;, async (nextState) =&gt; {
            if (nextState === &#39;active&#39;) {
              subscription.remove();
              const result = await checkNativeUpdate();
              if (result.status === &#39;NATIVE_UPDATE_REQUIRED&#39;) {
                showForceUpdateAlert();
              }
            }
          });
        },
      },
    ],
    { cancelable: false }, // 뒤로가기/바깥 터치로 닫기 불가
  );
};</code></pre>
<p>포인트는 세 가지입니다.</p>
<ol>
<li><strong>취소 버튼이 없다</strong>
→ 이번 세션에서는 반드시 업데이트를 유도</li>
<li><strong>스토어 → 다시 돌아온 뒤에도 재검사</strong>
→ 진짜로 업데이트가 되었는지 확인</li>
<li><strong>최소 버전은 서버에서 관리</strong>
→ 비즈니스/CS 상황에 따라 언제든 조정 가능</li>
</ol>
<hr>
<h2 id="12-앱-시작-시-한-번만-호출하면-끝">12. 앱 시작 시 한 번만 호출하면 끝</h2>
<p>이제 이 모든 걸 앱 시작 루틴에 한 줄로 넣습니다.</p>
<pre><code class="language-ts">// app/_layout.tsx (또는 App.tsx)

import { useEffect } from &#39;react&#39;;
import { checkAppUpdates } from &#39;@/services/UpdateManager&#39;;

export default function RootLayout() {
  useEffect(() =&gt; {
    const initialize = async () =&gt; {
      await initI18n();

      kakaoAuth.initialize();
      googleAuth.initialize();
      checkAuthStatus();

      // ✅ 여기서 한 번만 호출
      checkAppUpdates();

      setIsInitialized(true);
    };

    initialize();
  }, []);

  // ...
}</code></pre>
<p>이렇게 하면:</p>
<ol>
<li>앱이 켜질 때</li>
<li>서버에서 최소 버전을 받아와 강제 업데이트 여부 판단</li>
<li>통과하면 OTA 업데이트까지 확인</li>
</ol>
<p>까지 한 번에 처리됩니다.</p>
<hr>
<h2 id="13-실제-운영-시나리오별-대응-방법">13. 실제 운영 시나리오별 대응 방법</h2>
<p>이제 운영하면서 자주 만나는 상황별로 어떻게 대응하는지 정리해 봅니다.</p>
<h3 id="13-1-js-코드만-바꿔도-되는-긴급-버그">13-1. JS 코드만 바꿔도 되는 “긴급 버그”</h3>
<pre><code class="language-bash"># 코드 수정 후
eas update --channel production --message &quot;긴급 버그 수정&quot;</code></pre>
<ul>
<li>스토어 심사 없이 바로 배포</li>
<li>사용자는 앱을 재시작하는 순간 최신 로직으로 동작</li>
</ul>
<h3 id="13-2-네이티브-라이브러리-추가--sdk-업그레이드">13-2. 네이티브 라이브러리 추가 / SDK 업그레이드</h3>
<pre><code class="language-bash"># 1. app.json에서 앱 버전 올리기
# 2. 새 빌드 생성
eas build --platform all --profile production

# 3. 스토어 제출
eas submit --platform all

# 4. 필요한 시점에 서버의 최소 버전 업데이트</code></pre>
<ul>
<li>“이전 버전은 절대 쓰면 안 된다” 수준이 아닐 땐
→ 최소 버전 업데이트 시점은 조금 여유롭게 잡을 수도 있습니다.</li>
</ul>
<h3 id="13-3-보안-이슈로-특정-버전-강제-차단">13-3. 보안 이슈로 특정 버전 강제 차단</h3>
<ol>
<li>새 버전 빌드 &amp; 스토어 배포</li>
<li>스토어 전파가 어느 정도 완료되면</li>
<li>서버 DB에서 <code>ios</code> / <code>android</code> 최소 버전을 새 버전으로 업데이트</li>
<li>이후 구버전 사용자는 <strong>앱 실행 시 알럿만 보고 막히게</strong> 됨</li>
</ol>
<hr>
<h2 id="14-한-장으로-정리">14. 한 장으로 정리</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>어떻게 해결할까?</th>
</tr>
</thead>
<tbody><tr>
<td>JS/UI/로직만 수정하면 되는 경우</td>
<td><code>eas update</code> (OTA)</td>
</tr>
<tr>
<td>네이티브 코드/SDK/권한 변경</td>
<td>스토어 배포 (새 빌드 + 심사)</td>
</tr>
<tr>
<td>구버전 더 이상 허용하면 안 됨</td>
<td>서버에서 “최소 지원 버전” 올리기</td>
</tr>
<tr>
<td>서버/API 장애가 날 수 있음</td>
<td>Fail-Open 정책으로 앱은 계속 열리게 두기</td>
</tr>
</tbody></table>
<p>Expo의 EAS Update와 서버 기반 버전 관리를 조합하면,</p>
<ul>
<li><strong>배포 속도</strong>와</li>
<li><strong>버전 통제</strong>를
둘 다 어느 정도 만족시키는 구조를 만들 수 있습니다.</li>
</ul>
<hr>
<h2 id="🧪-링크-드라퍼-정식-출시">🧪 링크 드라퍼, 정식 출시!</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
<strong>정리하고, 다시 꺼내보게 만드는 링크 관리 도구</strong>를 지향하고 있어요.</p>
<ul>
<li>🔗 <strong>빠르고 간편한 링크 저장</strong>
iOS/Android 앱, 웹, 크롬 익스텐션 어디서든 바로 저장</li>
<li>🧠 <strong>폴더별로 깔끔하게 정리</strong>
읽을 거리, 레퍼런스, 쇼핑 후보까지 주제별 정리</li>
<li>🌐 <strong>폴더를 친구에게 공유</strong>
같이 보는 자료는 폴더 단위로 링크 한 번에 공유</li>
<li>⚡ <strong>크롬 익스텐션 원클릭 저장</strong>
지금 보고 있는 페이지를 버튼 한 번으로 저장</li>
</ul>
<p>👉 <a href="https://apps.apple.com/us/app/link-dropper/id6755904161"><strong>링크 드라퍼 앱 다운로드</strong> (iOS)</a>
👉 <a href="https://link-dropper.com"><strong>링크 드라퍼 웹에서 사용하러 가기</strong></a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1"><strong>크롬 웹스토어에서 익스텐션 설치하기</strong></a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[📱 Expo로 iOS Share Extension 구현기]]></title>
            <link>https://velog.io/@link_dropper/expo-share-extension</link>
            <guid>https://velog.io/@link_dropper/expo-share-extension</guid>
            <pubDate>Thu, 11 Dec 2025 04:38:58 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이 글은 “링크 저장 앱”을 만들면서 <strong>iOS Share Extension + Android 공유 인텐트</strong>를 붙인 개발기입니다.
Expo 쓰면서 “이거 되는 게 맞나…?” 했던 분들 대상으로 씁니다.</p>
</blockquote>
<hr>
<h2 id="0-문제의식-왜-굳이-share-extension까지">0. 문제의식: 왜 굳이 Share Extension까지?</h2>
<p>링크 저장 앱을 만들다 보면 이런 UX가 꼭 필요해집니다.</p>
<ol>
<li>Safari / Chrome에서 글을 읽다가</li>
<li>🔘 공유 버튼 → 공유 시트 열고</li>
<li>우리 앱 아이콘을 누르면</li>
<li><strong>바로 ‘링크 저장’ 화면</strong>이 뜨고, 앱 전환 없이 저장 끝 ✅</li>
</ol>
<p>iOS에서는 이걸 <strong>Share Extension</strong>, Android에서는 <strong>Share Intent</strong>로 구현합니다.</p>
<p>이 글에서는 <strong>Expo(Managed + EAS Build)</strong> 환경에서</p>
<ul>
<li>iOS: <code>expo-share-extension</code>으로 <strong>커스텀 Share UI</strong> 만들고</li>
<li>Android: <code>expo-share-intent</code>로 <strong>공유 인텐트 처리</strong></li>
</ul>
<p>까지 실제로 동작하는 구조를 예시 코드로 정리합니다.</p>
<hr>
<h2 id="1-share-extension-한-줄-정의">1. Share Extension 한 줄 정의</h2>
<blockquote>
<p><strong>“다른 앱에서 공유한 데이터를 받아, 작은 별도 프로세스로 처리하는 iOS 확장 앱”</strong></p>
</blockquote>
<p>특징만 요약하면:</p>
<ol>
<li><p><strong>독립 프로세스</strong></p>
<ul>
<li>메인 앱과 완전히 분리된 Target / 프로세스로 돌아감</li>
</ul>
</li>
<li><p><strong>리소스 제한</strong></p>
<ul>
<li>메모리·시간 제한이 있어서, 무거운 작업(대량 이미지 분석 등)은 매우 비추천</li>
</ul>
</li>
<li><p><strong>공유 스토리지 필요</strong></p>
<ul>
<li><p>메인 앱과 로그인 상태/데이터를 공유하려면</p>
<ul>
<li>App Group / Keychain Access Group 같은 iOS 메커니즘을 써야 함</li>
</ul>
</li>
</ul>
</li>
</ol>
<hr>
<h2 id="2-어떤-라이브러리를-쓸-것인가">2. 어떤 라이브러리를 쓸 것인가</h2>
<p>Expo에서 “다른 앱 → 우리 앱” 공유를 받으려면, 기본 <code>expo-sharing</code>으로는 <strong>안 됩니다</strong>.
공식 문서에도 “다른 앱에서 우리 앱으로 공유 받는 건 지원 안 함”이라고 박혀 있습니다.</p>
<p>그래서 커뮤니티 라이브러리를 씁니다.</p>
<h3 id="2-1-사용한-라이브러리">2-1. 사용한 라이브러리</h3>
<pre><code class="language-jsonc">// package.json (예시)
{
  &quot;dependencies&quot;: {
    &quot;expo-share-extension&quot;: &quot;^5.0.0&quot;,
    &quot;expo-share-intent&quot;: &quot;^5.1.0&quot;
  }
}</code></pre>
<blockquote>
<p>버전은 예시입니다. 실제 프로젝트에서는 <strong>Expo SDK 버전에 맞는 버전 매트릭스</strong>를 꼭 확인하세요.</p>
</blockquote>
<h3 id="2-2-역할-분담">2-2. 역할 분담</h3>
<table>
<thead>
<tr>
<th>라이브러리</th>
<th>플랫폼</th>
<th>UI 방식</th>
<th>UX</th>
</tr>
</thead>
<tbody><tr>
<td><strong>expo-share-extension</strong></td>
<td>iOS 전용</td>
<td>✅ 별도 React Native UI</td>
<td>공유 시트 안에서 바로 처리</td>
</tr>
<tr>
<td><strong>expo-share-intent</strong></td>
<td>iOS + Android (하지만 우리는 Android에만)</td>
<td>❌ 별도 UI 없음, 메인 앱으로 딥링크</td>
<td>앱이 열리고 나서 처리 ([GitHub][4])</td>
</tr>
</tbody></table>
<p>이번 구조는 이렇게 가져갑니다.</p>
<ul>
<li><p><strong>iOS</strong></p>
<ul>
<li><code>expo-share-extension</code>으로 <strong>커스텀 Share Extension</strong></li>
</ul>
</li>
<li><p><strong>Android</strong></p>
<ul>
<li><code>expo-share-intent</code>로 <strong>공유 인텐트 → 메인 앱 라우팅</strong></li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-appjson--appconfig-설정">3. app.json / app.config 설정</h2>
<h3 id="3-1-기본-expo-설정-예시">3-1. 기본 Expo 설정 (예시)</h3>
<pre><code class="language-jsonc">// app.json (예시용)
{
  &quot;expo&quot;: {
    &quot;name&quot;: &quot;MyLinkBox&quot;,
    &quot;slug&quot;: &quot;my-link-box&quot;,
    &quot;scheme&quot;: &quot;mylinkbox&quot;,
    &quot;ios&quot;: {
      &quot;bundleIdentifier&quot;: &quot;com.mycompany.mylinkbox&quot;,
      &quot;entitlements&quot;: {
        &quot;com.apple.security.application-groups&quot;: [
          &quot;group.com.mycompany.mylinkbox&quot;
        ],
        &quot;keychain-access-groups&quot;: [
          &quot;ABCDE12345.*&quot;
        ]
      }
    },
    &quot;android&quot;: {
      &quot;package&quot;: &quot;com.mycompany.mylinkbox&quot;,
      &quot;intentFilters&quot;: [
        {
          &quot;action&quot;: &quot;android.intent.action.SEND&quot;,
          &quot;category&quot;: [&quot;android.intent.category.DEFAULT&quot;],
          &quot;data&quot;: { &quot;mimeType&quot;: &quot;text/plain&quot; }
        }
      ]
    },
    &quot;plugins&quot;: [
      [
        &quot;expo-share-extension&quot;,
        {
          &quot;activationRules&quot;: [
            { &quot;type&quot;: &quot;url&quot;, &quot;max&quot;: 1 },
            { &quot;type&quot;: &quot;text&quot; }
          ],
          &quot;backgroundColor&quot;: {
            &quot;red&quot;: 255,
            &quot;green&quot;: 255,
            &quot;blue&quot;: 255,
            &quot;alpha&quot;: 1
          },
          &quot;appGroupIdentifier&quot;: &quot;group.com.mycompany.mylinkbox&quot;
        }
      ],
      [
        &quot;expo-share-intent&quot;,
        {
          // iOS는 커스텀 Share Extension을 쓸 거라서 비활성화
          &quot;disableIOS&quot;: true,
          &quot;androidIntentFilters&quot;: [&quot;text/*&quot;],
          &quot;androidMultiIntentFilters&quot;: []
        }
      ]
    ]
  }
}</code></pre>
<h3 id="3-2-핵심-옵션-해설">3-2. 핵심 옵션 해설</h3>
<h4 id="🔸-activationrules-expo-share-extension">🔸 <code>activationRules</code> (expo-share-extension)</h4>
<p>iOS의 <code>NSExtensionActivationRules</code>를 추상화해 둔 옵션입니다. </p>
<pre><code class="language-jsonc">&quot;activationRules&quot;: [
  { &quot;type&quot;: &quot;url&quot;, &quot;max&quot;: 1 },
  { &quot;type&quot;: &quot;text&quot; }
]</code></pre>
<ul>
<li><p><code>type: &quot;url&quot;</code></p>
<ul>
<li>Safari에서 페이지 공유 시, URL이 있을 때 활성화</li>
</ul>
</li>
<li><p><code>type: &quot;text&quot;</code></p>
<ul>
<li>선택한 텍스트 공유에도 반응 (텍스트 안에 URL이 섞인 케이스까지 케어하고 싶을 때 유용)</li>
</ul>
</li>
<li><p><code>max</code></p>
<ul>
<li>동시에 받을 수 있는 항목 개수 (지정 안 하면 기본값 1)</li>
</ul>
</li>
</ul>
<h4 id="🔸-appgroupidentifier">🔸 <code>appGroupIdentifier</code></h4>
<pre><code class="language-jsonc">&quot;appGroupIdentifier&quot;: &quot;group.com.mycompany.mylinkbox&quot;</code></pre>
<ul>
<li><strong>메인 앱 / Share Extension이 같이 쓰는 App Group ID</strong></li>
<li>이 그룹 안의 <strong>공유 컨테이너에 파일·데이터를 같이 저장</strong>할 수 있습니다.</li>
</ul>
<h4 id="🔸-entitlementskeychain-access-groups">🔸 <code>entitlements.keychain-access-groups</code></h4>
<pre><code class="language-jsonc">&quot;keychain-access-groups&quot;: [
  &quot;ABCDE12345.*&quot;
]</code></pre>
<ul>
<li>iOS Keychain 공유를 위한 그룹</li>
<li>실제로는 <code>ABCDE12345.group.com.mycompany.mylinkbox</code> 처럼 <strong>팀 ID + 그룹명</strong> 형태를 더 엄밀히 쓰기도 합니다.</li>
<li>글에서는 예시라 <code>ABCDE12345.*</code> 처럼 단순화했습니다.</li>
</ul>
<h4 id="🔸-expo-share-intent의-disableios">🔸 <code>expo-share-intent</code>의 <code>disableIOS</code></h4>
<p><code>expo-share-intent</code>는 기본적으로 iOS/Android 모두에 확장을 추가하지만,
우리는 iOS에서 <code>expo-share-extension</code>을 따로 쓰므로 <strong>iOS 쪽은 꺼 줍니다.</strong></p>
<hr>
<h2 id="4-ios-share-extension-ui-만들기">4. iOS Share Extension UI 만들기</h2>
<h3 id="4-1-엔트리-포인트-indexsharejs">4-1. 엔트리 포인트: <code>index.share.js</code></h3>
<pre><code class="language-ts">// index.share.js
import { AppRegistry } from &#39;react-native&#39;;
import ShareExtension from &#39;./ShareExtension&#39;;

// 👇 이름은 반드시 &quot;shareExtension&quot;
AppRegistry.registerComponent(&#39;shareExtension&#39;, () =&gt; ShareExtension);</code></pre>
<blockquote>
<p><code>AppRegistry.registerComponent</code>의 첫 번째 인자가 <strong>반드시 <code>&quot;shareExtension&quot;</code></strong> 이어야 합니다.</p>
</blockquote>
<h3 id="4-2-shareextension-컴포넌트-예시-버전">4-2. ShareExtension 컴포넌트 (예시 버전)</h3>
<pre><code class="language-tsx">// ShareExtension.tsx (예시)
import React, { useEffect, useState } from &#39;react&#39;;
import { InitialProps, close } from &#39;expo-share-extension&#39;;
import {
  View,
  Text,
  Button,
  ActivityIndicator,
  StyleSheet,
  TouchableOpacity,
} from &#39;react-native&#39;;

// ───────────────────────
// 1. 예시용 유틸 / API
// ───────────────────────
async function getAuthTokenFromSecureStore(): Promise&lt;string | null&gt; {
  // 실제 앱에서는 expo-secure-store + accessGroup 사용
  return &#39;dummy-token&#39;;
}

type Folder = { id: string; name: string };

async function fetchFolderList(token: string): Promise&lt;Folder[]&gt; {
  console.log(&#39;fetch folder with token&#39;, token);
  return [
    { id: &#39;inbox&#39;, name: &#39;📥 인박스&#39; },
    { id: &#39;read-later&#39;, name: &#39;나중에 읽기&#39; },
  ];
}

function extractUrlFromSharedContent(text?: string | null): string | null {
  if (!text) return null;
  const match = text.match(/https?:\/\/\S+/);
  return match ? match[0] : null;
}

function isValidUrl(url: string | null): boolean {
  if (!url) return false;
  try {
    new URL(url);
    return true;
  } catch {
    return false;
  }
}

async function saveLinkToServer(params: {
  url: string;
  folderId: string | null;
  token: string;
}) {
  console.log(&#39;save link&#39;, params);
  // 실제 앱에서는 여기서 API 호출
}

// ───────────────────────
// 2. ShareExtension UI
// ───────────────────────
type Props = InitialProps;

export default function ShareExtension(props: Props) {
  const { url, text } = props;

  const [token, setToken] = useState&lt;string | null&gt;(null);
  const [folders, setFolders] = useState&lt;Folder[]&gt;([]);
  const [selectedFolder, setSelectedFolder] = useState&lt;Folder | null&gt;(null);
  const [saving, setSaving] = useState(false);
  const [saved, setSaved] = useState(false);

  const sharedUrl = extractUrlFromSharedContent(url || text);

  useEffect(() =&gt; {
    (async () =&gt; {
      const t = await getAuthTokenFromSecureStore();
      setToken(t);
      if (!t) return;

      const list = await fetchFolderList(t);
      setFolders(list);
      setSelectedFolder(list[0] ?? null);
    })();
  }, []);

  const handleSave = async () =&gt; {
    if (!token) return;
    if (!isValidUrl(sharedUrl)) return;

    try {
      setSaving(true);
      await saveLinkToServer({
        url: sharedUrl!,
        folderId: selectedFolder?.id ?? null,
        token,
      });
      setSaved(true);

      setTimeout(() =&gt; {
        close(); // 1.5초 후 Share Extension 닫기
      }, 1500);
    } finally {
      setSaving(false);
    }
  };

  if (!token) {
    return (
      &lt;View style={styles.container}&gt;
        &lt;Text style={styles.title}&gt;로그인이 필요합니다&lt;/Text&gt;
        &lt;Text style={styles.subtitle}&gt;
          앱에서 먼저 로그인한 뒤 다시 공유해주세요.
        &lt;/Text&gt;
        &lt;Button title=&quot;닫기&quot; onPress={close} /&gt;
      &lt;/View&gt;
    );
  }

  return (
    &lt;View style={styles.container}&gt;
      &lt;Text style={styles.title}&gt;링크 저장하기&lt;/Text&gt;
      &lt;Text style={styles.subtitle}&gt;
        {isValidUrl(sharedUrl) ? sharedUrl : &#39;유효한 URL이 없습니다.&#39;}
      &lt;/Text&gt;

      {/* 폴더 선택 (예시) */}
      &lt;View style={{ marginVertical: 12 }}&gt;
        {folders.map((folder) =&gt; (
          &lt;TouchableOpacity
            key={folder.id}
            style={[
              styles.folderItem,
              folder.id === selectedFolder?.id &amp;&amp; styles.folderItemSelected,
            ]}
            onPress={() =&gt; setSelectedFolder(folder)}
          &gt;
            &lt;Text&gt;{folder.name}&lt;/Text&gt;
          &lt;/TouchableOpacity&gt;
        ))}
      &lt;/View&gt;

      {saving ? (
        &lt;ActivityIndicator /&gt;
      ) : saved ? (
        &lt;Text&gt;저장 완료! 🎉&lt;/Text&gt;
      ) : (
        &lt;Button
          title=&quot;저장하기&quot;
          onPress={handleSave}
          disabled={!isValidUrl(sharedUrl)}
        /&gt;
      )}
    &lt;/View&gt;
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: &#39;#fff&#39;,
    justifyContent: &#39;flex-start&#39;,
  },
  title: {
    fontSize: 18,
    fontWeight: &#39;600&#39;,
  },
  subtitle: {
    marginTop: 8,
    color: &#39;#555&#39;,
  },
  folderItem: {
    paddingVertical: 8,
    paddingHorizontal: 12,
    borderRadius: 8,
    borderWidth: 1,
    borderColor: &#39;#ddd&#39;,
    marginBottom: 8,
  },
  folderItemSelected: {
    borderColor: &#39;#4f46e5&#39;,
    backgroundColor: &#39;#eef2ff&#39;,
  },
});</code></pre>
<h3 id="4-3-ux-핵심-포인트">4-3. UX 핵심 포인트</h3>
<ol>
<li><p><strong>토큰 없이 들어온 경우</strong></p>
<ul>
<li>“로그인 필요” 메시지 + 닫기 버튼만 보여줌</li>
</ul>
</li>
<li><p><strong>URL 추출</strong></p>
<ul>
<li><code>props.url</code>이 없고 텍스트만 올 수도 있으니 <code>text</code>에서도 정규식으로 URL 추출</li>
</ul>
</li>
<li><p><strong>저장 후 자동 닫기</strong></p>
<ul>
<li>저장 완료 → 짧게 피드백 → <code>close()</code> 호출로 Share Extension 종료</li>
</ul>
</li>
</ol>
<hr>
<h2 id="5-인증-토큰-공유-securestore--keychain-access-group">5. 인증 토큰 공유: SecureStore + Keychain Access Group</h2>
<p>실제 서비스에서는 “Extension에서도 인증된 사용자로 API를 호출”해야 합니다.
이걸 해결하기 위해 <strong>iOS Keychain 공유</strong>를 씁니다.</p>
<h3 id="5-1-예시-코드">5-1. 예시 코드</h3>
<pre><code class="language-ts">// secureStore.ts (예시)
import * as SecureStore from &#39;expo-secure-store&#39;;

const TOKEN_KEY = &#39;MY_APP_AUTH_TOKEN&#39;;
// 실제 프로젝트에서는 팀 ID가 포함된 값 사용 권장
const ACCESS_GROUP = &#39;ABCDE12345.*&#39;;

export async function saveAuthToken(token: string) {
  await SecureStore.setItemAsync(TOKEN_KEY, token, {
    accessGroup: ACCESS_GROUP,
  });
}

export async function getAuthToken() {
  try {
    const token = await SecureStore.getItemAsync(TOKEN_KEY, {
      accessGroup: ACCESS_GROUP,
    });
    return token;
  } catch (e) {
    console.error(&#39;failed to get token&#39;, e);
    return null;
  }
}</code></pre>
<blockquote>
<p>⚠️ <strong>주의</strong></p>
<ul>
<li>실제 앱에선 <code>keychain-access-groups</code> entitlement와 <code>accessGroup</code> 값이 서로 일관되게 설정되어야 합니다.</li>
<li>팀 ID 접두(<code>ABCDE12345.</code>)를 어떻게 붙일지는 프로젝트마다 다를 수 있어서, 여기선 <strong>의도만 보이는 예시</strong>로 적었습니다.</li>
</ul>
</blockquote>
<hr>
<h2 id="6-android-intent-filter--expo-share-intent">6. Android: Intent Filter + <code>expo-share-intent</code></h2>
<p>Android는 Share Extension이 따로 없고, <strong>Intent Filter를 매니페스트(app.json)로 설정</strong>한 뒤, <code>expo-share-intent</code> 훅으로 데이터를 받습니다.</p>
<h3 id="6-1-android-intentfilters-appjson-예시">6-1. Android intentFilters (app.json 예시)</h3>
<pre><code class="language-jsonc">&quot;android&quot;: {
  &quot;intentFilters&quot;: [
    {
      &quot;action&quot;: &quot;android.intent.action.SEND&quot;,
      &quot;category&quot;: [&quot;android.intent.category.DEFAULT&quot;],
      &quot;data&quot;: {
        &quot;mimeType&quot;: &quot;text/plain&quot;
      }
    }
  ]
}</code></pre>
<h3 id="6-2-공유-데이터-처리-expo-router-예시">6-2. 공유 데이터 처리 (Expo Router 예시)</h3>
<pre><code class="language-tsx">// app/_layout.tsx (혹은 App.tsx)
import { useEffect } from &#39;react&#39;;
import { Platform } from &#39;react-native&#39;;
import { useRouter } from &#39;expo-router&#39;;
import { useShareIntent } from &#39;expo-share-intent&#39;;

export default function RootLayout() {
  const router = useRouter();
  const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();

  useEffect(() =&gt; {
    if (Platform.OS !== &#39;android&#39;) return;
    if (!hasShareIntent || !shareIntent) return;

    const url = shareIntent.webUrl ?? shareIntent.text;
    if (!url) return;

    // 공유된 URL을 저장 화면으로 라우팅
    router.push({
      pathname: &#39;/save&#39;,
      params: { url },
    });

    resetShareIntent();
  }, [hasShareIntent, shareIntent]);

  return (
    // ... 실제 앱 네비게이션/레이아웃
    null
  );
}</code></pre>
<blockquote>
<p><code>expo-share-intent</code>는 <strong>네이티브 모듈</strong>이라 Expo Go에서는 동작하지 않고,
반드시 <strong>prebuild + dev client / EAS Build</strong> 환경에서 테스트해야 합니다.</p>
</blockquote>
<hr>
<h2 id="7-네이티브-코드-개념만-살짝-보기-swift-예시">7. 네이티브 코드 개념만 살짝 보기 (Swift 예시)</h2>
<p><code>expo-share-extension</code>을 쓰면 iOS 타겟/Swift 코드는 플러그인이 알아서 생성해 줍니다.
관심 있는 부분만 추상화해서 보면 대략 이런 구조입니다. </p>
<pre><code class="language-swift">// ShareExtensionViewController.swift (개념 예시)
class ShareExtensionViewController: UIViewController {

  override func viewDidLoad() {
    super.viewDidLoad()
    loadReactNativeContent()
  }

  private func loadReactNativeContent() {
    getShareData { [weak self] sharedData in
      guard let self = self else { return }

      let rootView = reactNativeFactory!.rootViewFactory.view(
        withModuleName: &quot;shareExtension&quot;, // index.share.js에서 등록한 이름
        initialProperties: sharedData
      )

      self.view.addSubview(rootView)
      rootView.frame = self.view.bounds
    }
  }

  private func getShareData(completion: @escaping ([String: Any]?) -&gt; Void) {
    guard let items = extensionContext?.inputItems as? [NSExtensionItem] else {
      completion(nil)
      return
    }

    var result: [String: Any] = [:]

    // URL / 텍스트 추출 (단순화 예시)
    for item in items {
      for provider in item.attachments ?? [] {
        if provider.hasItemConformingToTypeIdentifier(&quot;public.url&quot;) {
          provider.loadItem(forTypeIdentifier: &quot;public.url&quot;, options: nil) { value, _ in
            if let url = value as? URL {
              result[&quot;url&quot;] = url.absoluteString
            }
            completion(result)
          }
        } else if provider.hasItemConformingToTypeIdentifier(&quot;public.text&quot;) {
          provider.loadItem(forTypeIdentifier: &quot;public.text&quot;, options: nil) { value, _ in
            if let text = value as? String {
              result[&quot;text&quot;] = text
            }
            completion(result)
          }
        }
      }
    }
  }

  func close() {
    extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
  }
}</code></pre>
<p>역할은 단순합니다.</p>
<ol>
<li><code>extensionContext</code>에서 공유된 데이터 파싱</li>
<li>React Native <code>shareExtension</code> 모듈을 띄우며 <code>initialProperties</code>로 전달</li>
<li>JS에서 <code>close()</code> 호출 시 네이티브에서 <code>completeRequest</code>로 Extension 종료</li>
</ol>
<hr>
<h2 id="8-eas-build에서-app-extension-인식시키기">8. EAS Build에서 App Extension 인식시키기</h2>
<p>Expo 공식 문서에 따르면, <strong>EAS Build가 app extension 타겟을 제대로 인식하게 하려면</strong>
<code>extra.eas.build.experimental.ios.appExtensions</code> 설정을 권장합니다.</p>
<pre><code class="language-jsonc">{
  &quot;expo&quot;: {
    &quot;extra&quot;: {
      &quot;eas&quot;: {
        &quot;build&quot;: {
          &quot;experimental&quot;: {
            &quot;ios&quot;: {
              &quot;appExtensions&quot;: [
                {
                  &quot;targetName&quot;: &quot;MyShareExtension&quot;,
                  &quot;bundleIdentifier&quot;: &quot;com.mycompany.mylinkbox.ShareExtension&quot;,
                  &quot;entitlements&quot;: {
                    &quot;com.apple.security.application-groups&quot;: [
                      &quot;group.com.mycompany.mylinkbox&quot;
                    ]
                  }
                }
              ]
            }
          }
        }
      }
    }
  }
}</code></pre>
<ul>
<li><p>이 설정이 있으면 EAS가</p>
<ul>
<li>Share Extension용 Provisioning Profile까지 같이 생성해 줘서</li>
<li>“iOS 빌드만 돌리면 되게” 도와줍니다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="9-내용-검증--주의할-점-요약">9. 내용 검증 &amp; 주의할 점 요약</h2>
<p>질문 주셨던 “틀린 내용이 있는지” 기준으로 정리하면:</p>
<ol>
<li><p><strong>expo-share-extension 사용 방식</strong></p>
<ul>
<li><code>activationRules</code>, <code>appGroupIdentifier</code>, <code>index.share.js</code>에서 <code>&quot;shareExtension&quot;</code> 등록 등은
공식 README와 일치합니다.</li>
</ul>
</li>
<li><p><strong>expo-share-intent 사용 방식</strong></p>
<ul>
<li><code>useShareIntent</code> 훅 사용, <code>disableIOS</code>, <code>androidIntentFilters</code> 설정 모두
공식 README와 패턴이 동일합니다.</li>
</ul>
</li>
<li><p><strong>“App Group을 통한 데이터 공유”</strong>라는 설명</p>
<ul>
<li><p>iOS에서 메인 앱/Share Extension 사이 데이터 공유는</p>
<ul>
<li>App Group (파일/컨테이너 공유) +</li>
<li>Keychain Access Group (SecureStore 등 키체인 공유)
조합으로 구현하는 패턴이 맞습니다.</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>EAS Build + appExtensions 설정</strong></p>
<ul>
<li><code>extra.eas.build.experimental.ios.appExtensions</code>를 통해
EAS가 미리 extension 정보를 알고 서명/프로비저닝을 처리해 준다는 설명도
최신 Expo 문서와 일치합니다.</li>
</ul>
</li>
<li><p><strong>토큰 공유 코드에 대한 한 가지 주의</strong></p>
<ul>
<li>예시에서는 이해를 위해 <code>accessGroup: &#39;ABCDE12345.*&#39;</code>처럼 단순화했지만,</li>
<li>실제 앱에서는 <strong>Apple Team ID + 그룹명</strong>이 포함된 정확한 Keychain 그룹 값을 써야 할 수 있습니다.</li>
<li>이 부분은 각자 Apple 계정/기존 앱 구조에 따라 달라서, 글에서는 “패턴만” 보여주는 수준으로 두었습니다.</li>
</ul>
</li>
</ol>
<h2 id="10-🧪-링크-드라퍼-정식-출시">10. 🧪 링크 드라퍼, 정식 출시!</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있어요.</p>
<p>🔗 빠르고 간편한 링크 저장
iOS/Android 앱, 웹, 크롬 익스텐션 어디서든 바로 저장
🧠 폴더별로 깔끔하게 정리
읽을 거리, 레퍼런스, 쇼핑 후보까지 주제별 정리
🌐 폴더를 친구에게 공유
같이 보는 자료는 폴더 단위로 링크 한 번에 공유
⚡ 크롬 익스텐션 원클릭 저장
지금 보고 있는 페이지를 버튼 한 번으로 저장</p>
<p>👉 <a href="https://apps.apple.com/us/app/link-dropper/id6755904161">링크 드라퍼 앱 다운로드 (iOS / Android)</a>
👉 <a href="https://link-dropper.com">링크 드라퍼 웹에서 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 익스텐션 설치하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Pinterest 스타일 레이아웃, React에서 어떻게 구현할까?]]></title>
            <link>https://velog.io/@link_dropper/Pinterest-style-layout</link>
            <guid>https://velog.io/@link_dropper/Pinterest-style-layout</guid>
            <pubDate>Thu, 20 Nov 2025 01:34:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>링크 드라퍼(Link Dropper)의 탐색 페이지를 개발하면서, 다양한 높이의 콘텐츠를 자연스럽게 정렬하는 <strong>Masonry 레이아웃</strong>과 <strong>무한 스크롤</strong> 기능을 구현하게 되었습니다. 단순히 보기 좋은 UI를 넘어서 <strong>사용자 경험을 해치지 않고 성능까지 고려한 구조</strong>를 만드는 것이 핵심 과제였습니다.</p>
</blockquote>
<p>이 글에서는 CSS만으로 구현했던 초기 시도부터 Flexbox 기반의 명시적 레이아웃으로의 전환, 그리고 무한 스크롤과 스켈레톤 로딩까지, 실제 구현 과정과 트러블슈팅 경험을 모두 공유합니다.</p>
<hr>
<h2 id="프로젝트-요구사항-정리">프로젝트 요구사항 정리</h2>
<p>탐색 기능은 아래와 같은 요구를 충족해야 했습니다:</p>
<ol>
<li>Masonry 레이아웃 (다양한 높이의 링크 카드 배치)</li>
<li>무한 스크롤 (스크롤 시 자동으로 다음 페이지 로드)</li>
<li>반응형 지원 (모바일: 1열, 태블릿: 2열, 데스크톱: 3~4열)</li>
<li>로딩 시 기존 콘텐츠 위치 유지</li>
<li>스켈레톤 UI와 다크모드 지원</li>
</ol>
<hr>
<h2 id="기술-스택-선택">기술 스택 선택</h2>
<h3 id="tanstack-query-react-query">TanStack Query (React Query)</h3>
<p>무한 스크롤과 서버 상태 관리를 위해 <code>useInfiniteQuery</code> 훅을 사용했습니다.</p>
<p><strong>선택 이유:</strong></p>
<ul>
<li>직관적인 페이지네이션 (<code>getNextPageParam</code>)</li>
<li>중복 요청 방지</li>
<li>캐싱 및 백그라운드 데이터 업데이트</li>
</ul>
<h3 id="intersection-observer-api">Intersection Observer API</h3>
<p><code>scroll</code> 이벤트 대신 Intersection Observer를 선택해 뷰포트 진입을 감지했습니다.</p>
<p><strong>선택 이유:</strong></p>
<ul>
<li>메인 스레드 부담이 적음</li>
<li>트리거 시점(threshold) 세밀 조정 가능</li>
</ul>
<hr>
<h2 id="시도-1-css-column-기반-masonry-레이아웃">시도 1: CSS Column 기반 Masonry 레이아웃</h2>
<p>간단한 코드로 구현 가능한 <code>column-count</code> 속성을 먼저 사용했습니다.</p>
<pre><code class="language-css">.ExploreLinkList {
  column-count: 4;
  column-gap: 1.5rem;
}

.card {
  break-inside: avoid;
  margin-bottom: 1rem;
}</code></pre>
<p><strong>문제 발생:</strong>
무한 스크롤로 새 아이템이 로드되면 <strong>기존 카드들이 다른 컬럼으로 재배치</strong>되는 현상이 발생했습니다. 이는 사용자 경험을 해치는 큰 단점이었습니다.</p>
<hr>
<h2 id="시도-2-flexbox-기반-명시적-masonry-레이아웃">시도 2: Flexbox 기반 명시적 Masonry 레이아웃</h2>
<p>이 문제를 해결하기 위해 Flexbox를 사용한 <strong>명시적 컬럼 분배</strong> 방식으로 전환했습니다.</p>
<h3 id="컬럼-분배-로직-round-robin-방식">컬럼 분배 로직 (Round-robin 방식)</h3>
<pre><code class="language-typescript">export const useMasonryLayout = &lt;T extends { id: number }&gt;(
  items: T[],
  columnCount: number
): T[][] =&gt;
  useMemo(() =&gt; {
    const columns: T[][] = Array.from({ length: columnCount }, () =&gt; []);

    items.forEach((item, index) =&gt; {
      const columnIndex = index % columnCount;
      columns[columnIndex].push(item);
    });

    return columns;
  }, [items, columnCount]);</code></pre>
<h3 id="반응형-대응">반응형 대응</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const updateColumnCount = () =&gt; {
    if (window.innerWidth &lt;= 480) setColumnCount(1);
    else if (window.innerWidth &lt;= 768) setColumnCount(2);
    else if (window.innerWidth &lt;= 1024) setColumnCount(3);
    else setColumnCount(4);
  };

  updateColumnCount();
  window.addEventListener(&quot;resize&quot;, updateColumnCount);

  return () =&gt; window.removeEventListener(&quot;resize&quot;, updateColumnCount);
}, []);</code></pre>
<h3 id="flexbox-레이아웃">Flexbox 레이아웃</h3>
<pre><code class="language-css">.ExploreLinkList {
  display: flex;
  gap: 1.5rem;
  padding: 1.5rem;
  max-width: 73rem;
  margin: 0 auto;
  align-items: flex-start;
}

.column {
  display: flex;
  flex-direction: column;
  flex: 1;
  gap: 1rem;
  min-width: 0;
}</code></pre>
<hr>
<h2 id="무한-스크롤-구현">무한 스크롤 구현</h2>
<h3 id="intersection-observer-훅">Intersection Observer 훅</h3>
<pre><code class="language-typescript">const observer = new IntersectionObserver(
  (entries) =&gt; {
    if (entries[0].isIntersecting) fetchNextPage();
  },
  { threshold: 0.1 }
);</code></pre>
<p><code>hasNextPage</code>, <code>isFetchingNextPage</code> 조건에 따라 옵저버 등록 여부를 조절합니다.</p>
<hr>
<h2 id="스켈레톤-로딩-전략">스켈레톤 로딩 전략</h2>
<h3 id="초기-로딩-실제-컬럼-구조-반영">초기 로딩: 실제 컬럼 구조 반영</h3>
<p>컬럼 수에 맞게 스켈레톤 아이템 분배.</p>
<pre><code class="language-tsx">const loadingColumns = Array.from({ length: columnCount }, (_, i) =&gt; i);
const itemsPerColumn = Math.ceil(12 / columnCount);</code></pre>
<h3 id="추가-로딩-간단한-가로-스켈레톤-4개">추가 로딩: 간단한 가로 스켈레톤 4개</h3>
<p>별도의 레이아웃 변화 없이 사용자에게 로딩 상태를 전달합니다.</p>
<hr>
<h2 id="성능-최적화">성능 최적화</h2>
<h3 id="usememo로-재연산-최소화">useMemo로 재연산 최소화</h3>
<ul>
<li>평탄화된 링크 배열</li>
<li>컬럼 분배 결과</li>
</ul>
<pre><code class="language-typescript">const allLinks = useMemo(() =&gt; data?.pages.flatMap(p =&gt; p.links) ?? [], [data]);</code></pre>
<h3 id="이벤트-위임">이벤트 위임</h3>
<p>카드 클릭 이벤트를 부모 요소에서 처리하여 리스너를 단 하나로 유지.</p>
<h3 id="react-query-캐싱-전략">React Query 캐싱 전략</h3>
<pre><code class="language-typescript">staleTime: 5 * 60 * 1000</code></pre>
<p>탐색 페이지 재방문 시 캐시 데이터를 그대로 사용해 UX 개선.</p>
<hr>
<h2 id="개선-아이디어">개선 아이디어</h2>
<h3 id="1-스마트한-컬럼-배치">1. 스마트한 컬럼 배치</h3>
<p>각 컬럼의 총 높이를 추적해 가장 짧은 컬럼에 배치.</p>
<pre><code class="language-typescript">const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));</code></pre>
<h3 id="2-가상-스크롤-도입">2. 가상 스크롤 도입</h3>
<p>렌더링 성능 개선을 위해 <code>react-window</code> 또는 <code>react-virtual</code> 사용 고려.</p>
<h3 id="3-낙관적-업데이트">3. 낙관적 업데이트</h3>
<p>서버 응답 없이 먼저 UI 반영 후, 실패 시 롤백.</p>
<hr>
<h2 id="마무리하며">마무리하며</h2>
<p>이번 구현을 통해 얻은 가장 큰 인사이트는 다음과 같습니다:</p>
<ul>
<li><strong>CSS Column은 동적 콘텐츠에 불리</strong>하다</li>
<li><strong>명시적인 JavaScript 제어</strong>가 예측 가능한 레이아웃을 만든다</li>
<li><strong>라이브러리 선택이 복잡도를 줄여준다</strong></li>
<li><strong>퍼포먼스 최적화는 필수</strong></li>
</ul>
<hr>
<h2 id="적용-가능한-사례">적용 가능한 사례</h2>
<ul>
<li>Pinterest/Unsplash 스타일 이미지 갤러리</li>
<li>무한 스크롤 기반 블로그/뉴스 피드</li>
<li>상품 목록, 게시판 등</li>
</ul>
<hr>
<h2 id="🧪-링크-드라퍼-정식-출시">🧪 링크 드라퍼, 정식 출시!</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<hr>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[링크 자동 요약 & 자동 태깅 개발기]]></title>
            <link>https://velog.io/@link_dropper/link-auto-summary-and-tags</link>
            <guid>https://velog.io/@link_dropper/link-auto-summary-and-tags</guid>
            <pubDate>Thu, 13 Nov 2025 03:12:03 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“링크만 잔뜩 쌓이는 북마크를, 한 번에 읽을 수 있는 노션 스타일 요약 문서로 바꿔보자.”</p>
</blockquote>
<p>링크 드라퍼에 <strong>웹 링크 자동 요약 + 자동 태깅 기능</strong>을 추가하면서 겪었던 기술적 선택과 시행착오를 정리해 보았습니다.
단순히 제목/설명 메타태그를 긁어오는 수준이 아니라, <strong>실제 본문을 분석해서 구조화된 마크다운 문서와 상위 카테고리 태그</strong>까지 뽑아내는 것이 목표였습니다.</p>
<blockquote>
<p>참고: 현재는 <strong>영상 위주 콘텐츠</strong>나 <strong>본문이 거의 없는 페이지</strong>는 요약이 실패하는 한계가 있습니다. 이 부분은 빠르게 개선 중입니다.</p>
</blockquote>
<hr>
<h2 id="1-무엇을-해결하고-싶었나">1. 무엇을 해결하고 싶었나</h2>
<p>북마크 서비스나 “나중에 읽기” 도구를 써보면, 결국 이렇게 되기 쉽습니다.</p>
<ul>
<li>제목만 잔뜩 쌓이고</li>
<li>나중에 보면 왜 저장했는지 기억 안 나고</li>
<li>다시 읽기엔 시간이 부족한 링크 더미…</li>
</ul>
<p>그래서 링크 드라퍼에서는 <strong>“링크를 저장하면, 바로 요약과 태그까지 자동으로 만들어주는 기능”</strong>을 만들기로 했습니다.</p>
<ul>
<li>링크를 저장하면</li>
<li>서버에서 HTML을 크롤링 → 본문 추출 → LLM으로 요약/태깅</li>
<li>결과를 <strong>노션 스타일 마크다운 문서 + 상위 카테고리 태그</strong>로 저장</li>
</ul>
<p>사용자는 “링크 목록”이 아니라 “<strong>요약된 문서 컬렉션</strong>”을 보게 되는 경험을 목표로 했습니다.</p>
<hr>
<h2 id="2-전체-아키텍처-한-번에-보기">2. 전체 아키텍처 한 번에 보기</h2>
<h3 id="기술-스택">기술 스택</h3>
<ul>
<li><strong>Backend</strong>: FastAPI</li>
<li><strong>LLM</strong>: Upstage Solar Pro</li>
<li><strong>HTML 파싱/조작</strong>: BeautifulSoup4, lxml</li>
</ul>
<h3 id="처리-플로우">처리 플로우</h3>
<pre><code class="language-text">1. 사용자가 URL 저장
   ↓
2. 웹 크롤링으로 HTML 수집
   ↓
3. HTML에서 본문 텍스트 추출
   ↓
4. 노이즈 제거 및 텍스트 정제
   ↓
5. LLM에 요약 + 태그 요청
   ↓
6. 구조화된 마크다운(summary) + 태그(tags) 반환
   ↓
7. DB 저장 및 링크 드라퍼에서 노션 스타일로 표시</code></pre>
<h3 id="api-설계-텍스트-직접-요약용">API 설계 (텍스트 직접 요약용)</h3>
<pre><code class="language-python"># 텍스트 직접 입력 방식
POST /summarize-text
{
  &quot;text&quot;: &quot;요약할 본문 텍스트...&quot;
}

# 응답 형식
{
  &quot;summary&quot;: &quot;## 제목\n\n### 섹션1\n내용...&quot;,
  &quot;tags&quot;: [&quot;개발&quot;, &quot;AI&quot;]
}</code></pre>
<p>실제 서비스에서는 URL을 받아서 내부에서 HTML → 텍스트 추출 후, 이 <code>/summarize-text</code> 흐름으로 연결해 사용하고 있습니다.</p>
<hr>
<h2 id="3-본문-추출-노이즈-제거가-품질의-절반">3. 본문 추출: 노이즈 제거가 품질의 절반</h2>
<p>“아무리 좋은 LLM도, 쓰레기 입력에는 쓰레기 출력(Garbage In, Garbage Out)”
그래서 제일 먼저 신경 쓴 건 <strong>본문만 깔끔하게 추출하는 것</strong>이었습니다.</p>
<h3 id="31-노이즈-제거-xpath--스팸-키워드">3.1 노이즈 제거: XPath + 스팸 키워드</h3>
<p>HTML에는 우리가 원하지 않는 것들이 너무 많이 섞여 있습니다.</p>
<ul>
<li>댓글</li>
<li>사이드바</li>
<li>푸터</li>
<li>광고</li>
<li>스팸 링크 블록 등</li>
</ul>
<p>lxml의 XPath를 활용해서, 이런 영역을 <strong>사전에 날려버리는</strong> 전략을 썼습니다.</p>
<pre><code class="language-python"># XPath 기반 노이즈 블록 제거
NOISE_XPATHS = [
    &quot;//*[contains(@id,&#39;comment&#39;)]&quot;,
    &quot;//*[contains(@class,&#39;sidebar&#39;)]&quot;,
    &quot;//*[contains(@id,&#39;footer&#39;)]&quot;,
    &quot;//*[contains(@class,&#39;ad&#39;)]&quot;,
]

# 스팸 키워드 필터링
SPAM_KEYWORDS = [
    &quot;casino&quot;, &quot;betting&quot;, &quot;카지노&quot;, &quot;토토&quot;, &quot;대출&quot;
]</code></pre>
<p>구현 포인트는 대략 이런 느낌입니다.</p>
<ul>
<li>lxml으로 DOM을 파싱하고, <code>NOISE_XPATHS</code>에 해당하는 노드를 통째로 제거</li>
<li>특정 스팸 키워드를 다수 포함한 블록은 과감히 필터링</li>
<li>다중 링크만 잔뜩 들어 있는 라인도 스팸 후보로 간주</li>
</ul>
<p>이렇게 <strong>본문 후보를 최대한 깨끗하게 만든 뒤</strong>에, Trafilatura로 넘깁니다.</p>
<h3 id="32-trafilatura로-본문-추출">3.2 Trafilatura로 본문 추출</h3>
<p>BeautifulSoup만으로는 “어디까지가 본문인지” 판단하기 어렵습니다.
그래서 <strong>머신러닝 기반 본문 추출 라이브러리인 Trafilatura</strong>를 사용했습니다.</p>
<pre><code class="language-python">def parse_content(html: str) -&gt; Optional[str]:
    &quot;&quot;&quot;Trafilatura를 활용한 본문 추출&quot;&quot;&quot;
    text = trafilatura.extract(
        html,
        include_comments=False,  # 댓글 제외
        include_tables=True,     # 표 포함
        no_fallback=False        # 폴백 활성화
    )

    # 최소 100자 이상일 때만 유효
    if text and len(text.strip()) &gt; 100:
        return text.strip()

    return None</code></pre>
<p>선택 이유는 다음과 같습니다.</p>
<ul>
<li>BeautifulSoup는 “태그 파서”일 뿐, 본문/비본문 구분에 특화되어 있지 않음</li>
<li>Trafilatura는 다양한 사이트 구조에 대해 경험적으로 꽤 높은 정확도를 보여줌</li>
<li>옵션으로 <strong>댓글 제외, 테이블 포함</strong> 등을 섬세하게 조정할 수 있음</li>
</ul>
<p><code>len(text) &gt; 100</code> 같은 간단한 기준이지만, 실제로 <strong>너무 짧은 텍스트는 요약의 의미가 없는 경우</strong>가 많아서 유효성 체크에 꽤 도움이 됐습니다.</p>
<hr>
<h2 id="4-llm-요약-서비스-upstage-solar-pro">4. LLM 요약 서비스: Upstage Solar Pro</h2>
<p>본문 텍스트가 준비되면, 이제 LLM에 넘겨서 <strong>“노션 스타일 요약 문서 + 태그”</strong>를 만들어야 합니다.</p>
<h3 id="41-서비스-구조">4.1 서비스 구조</h3>
<pre><code class="language-python">class SummarizationService:
    def __init__(self):
        self.client = OpenAI(
            api_key=UPSTAGE_API_KEY,
            base_url=&quot;https://api.upstage.ai/v1/solar&quot;
        )

    def summarize(self, text: str) -&gt; dict:
        response = self.client.chat.completions.create(
            model=&quot;solar-pro&quot;,
            messages=[
                {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: SYSTEM_PROMPT},
                {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_prompt}
            ],
            temperature=0.1,      # 일관성 중시
            max_tokens=8192,      # 충분한 응답 길이
        )

        result = parse_llm_json(response.choices[0].message.content)
        return result</code></pre>
<p>설계 선택 포인트:</p>
<ul>
<li><strong>Temperature 0.1</strong>
창의적인 문장보다는, <strong>일관되고 재현 가능한 요약</strong>이 더 중요하다고 판단했습니다.</li>
<li><strong>max_tokens 8192</strong>
긴 글도 통으로 넣고 싶어서 토큰을 넉넉하게 잡았습니다.
(초기에는 4096으로 했다가, 긴 문서가 뒷부분에서 잘리는 문제가 있었고, 이후 8192로 올렸습니다.)</li>
</ul>
<h3 id="42-json-파싱-지옥-탈출기">4.2 JSON 파싱 지옥 탈출기</h3>
<p>LLM에게 “JSON으로만 답해줘”라고 아무리 말해도, 현실은 이렇게 옵니다.</p>
<pre><code class="language-text">1. ```json
   { ... }</code></pre>
<ol start="2">
<li>{ ... }</li>
<li>설명 텍스트... { ... } 설명 텍스트...</li>
<li>심지어 후행 쉼표(trailing comma)까지…</li>
</ol>
<pre><code>
그래서 응답 파싱 로직을 꽤 튼튼하게 만들 필요가 있었습니다.

```python
def extract_json_string(raw_text: str) -&gt; str:
    # 1순위: 마크다운 코드 블록 내 JSON 추출
    code_block_match = re.search(
        r&quot;```(?:json)?\s*(\{[\s\S]*?\})\s*```&quot;,
        raw_text
    )
    if code_block_match:
        return code_block_match.group(1)

    # 2순위: 문자열 시작부터 JSON
    if raw_text.lstrip().startswith(&quot;{&quot;):
        return raw_text

    # 3순위: 텍스트 중간에 포함된 JSON 객체
    json_match = re.search(r&quot;\{[\s\S]*\}&quot;, raw_text)
    if json_match:
        return json_match.group(0)

    raise ValueError(&quot;JSON 응답을 찾을 수 없습니다&quot;)</code></pre><p>여기에 더해:</p>
<ul>
<li>후행 쉼표(trailing comma) 제거</li>
<li><code>json.loads</code> 실패 시 로그 남기고 재시도/실패 처리</li>
</ul>
<p>등을 유틸 함수(<code>utils/llm_json.py</code>)로 분리해서, 다른 LLM 연동에도 재사용할 수 있게 만들었습니다.</p>
<hr>
<h2 id="5-프롬프트-엔지니어링-추측하지-마를-가르치기">5. 프롬프트 엔지니어링: “추측하지 마”를 가르치기</h2>
<p>가장 시간이 많이 들어간 부분은 <strong>프롬프트 엔지니어링</strong>이었습니다.
원하는 건 “간단한 요약”이 아니라:</p>
<ul>
<li>원문에 <strong>없는 내용은 절대 넣지 않고</strong></li>
<li>수치/날짜 등은 <strong>정확히 보존</strong>하면서</li>
<li><strong>노션 페이지처럼 구조화된 마크다운</strong>을 뱉는 것이었습니다.</li>
</ul>
<h3 id="51-시스템-프롬프트-엄격-원칙">5.1 시스템 프롬프트: 엄격 원칙</h3>
<pre><code class="language-python">SYSTEM_PROMPT = &quot;&quot;&quot;당신은 정확한 요약 및 상위 분류 전문가입니다.

[엄격 원칙]
1) 제공된 본문만 사용하고, 배경지식/추측/외부 사실을 추가하지 마세요.
2) 수치·날짜·고유명사·단위 등 사실 요소는 원문 그대로 보존하세요.
3) 정보가 불명확하거나 부족하면 그 사실을 간단히 언급하고 추론하지 마세요.
4) 콘텐츠가 아닌 잡음(메뉴/광고/저작권 문구)은 무시하세요.
5) 어조는 중립적·비평가적이며 과장이나 축소 없이 기술하세요.
&quot;&quot;&quot;</code></pre>
<p>이런 원칙이 없으면 LLM은 쉽게:</p>
<ul>
<li>배경지식을 섞어서 <strong>“그럴듯한 이야기”</strong>를 만들어내거나</li>
<li>숫자/날짜를 자기 멋대로 바꾸는 할루시네이션을 일으키거나</li>
<li>“~ 것으로 보인다” 같은 <strong>애매한 추론 문장</strong>을 추가합니다.</li>
</ul>
<p>원문을 기반으로 한 <strong>정확한 요약</strong>이 목표이기 때문에, 이 부분을 계속 강조했습니다.</p>
<h3 id="52-마크다운-출력-구조-강제">5.2 마크다운 출력 구조 강제</h3>
<p>요약 결과는 Link Dropper에서 <strong>노션 페이지처럼 바로 읽히는 문서</strong>여야 합니다.
그래서 아예 마크다운 구조를 프롬프트에 못 박았습니다.</p>
<pre><code class="language-text">[마크다운 문서 작성 규칙]
- 이모지 사용 절대 금지
- 노션 페이지처럼 제목과 섹션으로 구조화된 완성된 문서
- 반드시 다음 구조를 따르세요:
  * ## [본문의 핵심 주제] - 문서 제목 (H2)
  * ### [섹션명] - 주요 섹션 구분 (H3, 2-3개 섹션 권장)
  * 각 섹션은 2-3개의 완전한 문장으로 구성
  * **굵은 글씨**: 중요 키워드, 수치, 날짜 강조
- 전체 길이: 일반적으로 1,200자 이내</code></pre>
<p>예상하는 결과물은 이런 느낌입니다.</p>
<pre><code class="language-markdown">## AI 에이전트의 미래와 도전과제

### 기술 발전 현황
최근 **LangChain**과 **AutoGPT** 같은 프레임워크가 등장하면서
AI 에이전트 개발이 대중화되고 있습니다. **2024년 1분기** 기준으로
관련 오픈소스 프로젝트가 **300% 증가**했습니다.

### 남은 과제들
그러나 환각(hallucination) 문제와 비용 증가가 상용화의 걸림돌로
작용하고 있습니다. 특히 **GPT-4**를 사용할 경우 토큰당 비용이
이전 모델 대비 **10배** 높아졌습니다.</code></pre>
<p>이렇게 구조를 강제해 두면, 링크 드라퍼 쪽에서는 별도의 후처리 없이 바로 보여줄 수 있습니다.</p>
<h3 id="53-카테고리-태그-시스템">5.3 카테고리 태그 시스템</h3>
<p>태그는 자유롭게 적게 두면 <strong>너무 세분화</strong>되어서 관리가 안 되기 때문에, <strong>상위 카테고리 집합</strong>을 만들어 두었습니다.</p>
<pre><code class="language-python">ALLOWED_TAG_CATEGORIES = {
    &quot;기술/개발&quot;: [&quot;개발&quot;, &quot;AI&quot;, &quot;클라우드&quot;, &quot;데이터&quot;, &quot;보안&quot;],
    &quot;비즈니스&quot;: [&quot;경제&quot;, &quot;금융&quot;, &quot;스타트업&quot;, &quot;마케팅&quot;],
    &quot;사회/문화&quot;: [&quot;사회&quot;, &quot;정치&quot;, &quot;교육&quot;, &quot;문화&quot;],
    &quot;과학&quot;: [&quot;과학&quot;, &quot;의료&quot;, &quot;환경&quot;],
    &quot;기타&quot;: [&quot;스포츠&quot;, &quot;엔터테인먼트&quot;, &quot;게임&quot;]
}

[태그 선택 규칙]
- 가능한 한 상위(넓은) 범주를 고르세요
- 태그 수는 2-4개를 넘지 마세요</code></pre>
<p>의도한 효과:</p>
<ul>
<li>태그가 <strong>지나치게 쪼개지지 않도록</strong> 제어</li>
<li>검색/필터링에 의미 있는 상위 카테고리 수준 유지</li>
<li>LLM이 태그를 마구 찍어내지 않도록 제한</li>
</ul>
<h3 id="54-체크리스트로-자가-검증-유도">5.4 체크리스트로 자가 검증 유도</h3>
<p>프롬프트 끝에는 LLM이 <strong>스스로 출력물을 점검</strong>해 보도록 체크리스트를 추가했습니다.</p>
<pre><code class="language-text">[출력 점검 체크리스트]
- [ ] &quot;summary&quot;는 ## 제목으로 시작하는 노션 스타일 문서인가?
- [ ] ### 섹션 제목이 2-3개 포함되어 있는가?
- [ ] 이모지가 전혀 사용되지 않았는가?
- [ ] 핵심 수치/이름/날짜가 **굵게** 강조되었는가?
- [ ] 본문에 없는 주장·해석이 없는가?
- [ ] &quot;tags&quot;는 2-4개이며 허용 집합에서만 선택했는가?</code></pre>
<p>체감상:</p>
<ul>
<li>이모지 사용 금지</li>
<li>섹션 개수/구조 준수</li>
</ul>
<p>같은 부분에서 <strong>프롬프트 준수율이 30~40% 정도는 올라간 느낌</strong>이었습니다.</p>
<hr>
<h2 id="6-데이터-검증--에러-처리">6. 데이터 검증 &amp; 에러 처리</h2>
<p>LLM 응답을 그대로 신뢰할 수는 없기 때문에, <strong>검증 레이어</strong>를 여러 단계로 두었습니다.</p>
<h3 id="61-요청-검증-pydantic">6.1 요청 검증: Pydantic</h3>
<p>너무 짧은 텍스트는 요약해도 의미가 없기 때문에, 요약 API 요청 자체를 검증했습니다.</p>
<pre><code class="language-python">class SummarizeTextRequest(BaseModel):
    text: str

    @field_validator(&#39;text&#39;)
    @classmethod
    def validate_text(cls, v: str) -&gt; str:
        if not v or not v.strip():
            raise ValueError(&quot;텍스트는 비어있을 수 없습니다&quot;)

        word_count = len(v.split())
        if word_count &lt; 10:
            raise ValueError(
                f&quot;텍스트가 너무 짧습니다. &quot;
                f&quot;최소 10단어 이상 필요합니다. (현재: {word_count}단어)&quot;
            )

        return v</code></pre>
<p>이 단계에서 걸러지면, 굳이 LLM 호출 비용을 쓰지 않아도 됩니다.</p>
<h3 id="62-응답-검증-필수-필드-체크">6.2 응답 검증: 필수 필드 체크</h3>
<p>LLM 응답이 JSON이라 해도, <strong>필수 필드가 빠져 있을 수</strong> 있습니다.</p>
<pre><code class="language-python">def summarize(self, text: str) -&gt; dict:
    result = parse_llm_json(content)

    # 필수 필드 검증
    if &quot;summary&quot; not in result or &quot;tags&quot; not in result:
        raise ValueError(&quot;응답에 필수 필드가 없습니다&quot;)

    # summary 검증
    if not result[&quot;summary&quot;]:
        raise ValueError(&quot;summary가 비어있습니다&quot;)

    # tags 검증
    if not isinstance(result[&quot;tags&quot;], list) or len(result[&quot;tags&quot;]) == 0:
        raise ValueError(&quot;tags는 비어있지 않은 리스트여야 합니다&quot;)

    return result</code></pre>
<p>이렇게 검증 레이어를 여러 개 두면:</p>
<ul>
<li>디버깅 포인트가 명확해지고</li>
<li>“요약이 안 됐다”는 이슈가 왔을 때, <strong>어느 단계에서 실패했는지</strong> 빠르게 확인할 수 있습니다.</li>
</ul>
<hr>
<h2 id="7-1차-구현-→-2차-리팩토링-뭐가-달라졌나">7. 1차 구현 → 2차 리팩토링: 뭐가 달라졌나</h2>
<h3 id="71-초기-문제들">7.1 초기 문제들</h3>
<p>처음 구현했을 때는 다음과 같은 문제들이 있었습니다.</p>
<ul>
<li><code>max_tokens=4096</code>으로 인해 <strong>긴 문서 요약이 중간에서 잘림</strong></li>
<li>프롬프트가 너무 자유로워서 <strong>출력 형식이 제각각</strong></li>
<li>LLM이 JSON을 깨끗하게 안 보내서 <strong>파싱 실패 케이스 다수</strong></li>
</ul>
<h3 id="72-개선-작업-커밋-로그-느낌으로">7.2 개선 작업 (커밋 로그 느낌으로)</h3>
<pre><code class="language-text">[설정] 요약 기능을 위한 상수 및 설정 파일 추가
[유틸리티] 텍스트 정제 및 노이즈 제거 유틸리티 추가
[서비스] LLM 기반 텍스트 요약 서비스 추가
[Refactor] LLM JSON 파싱 유틸 도입
[Refactor] 요약 프롬프트 상수 구조화
[요약] maxToken 놀리기 (4096 → 8192)</code></pre>
<p>주요 변경 사항은 네 가지였습니다.</p>
<ol>
<li><p><strong>프롬프트 상수 분리</strong></p>
<ul>
<li>하드코딩된 프롬프트 → <code>constants/summarize.py</code>로 분리</li>
</ul>
</li>
<li><p><strong>JSON 파싱 유틸 모듈화</strong></p>
<ul>
<li>응답 파싱 로직 → <code>utils/llm_json.py</code>로 분리, 재사용 가능하게</li>
</ul>
</li>
<li><p><strong>maxTokens 증가 (4096 → 8192)</strong></p>
<ul>
<li>긴 문서에서도 요약이 중간에서 끊기지 않도록</li>
</ul>
</li>
<li><p><strong>텍스트 정제 강화</strong></p>
<ul>
<li>노이즈 제거, 최소 길이 체크 등 입력 품질 높이기</li>
</ul>
</li>
</ol>
<h3 id="73-성능-변화">7.3 성능 변화</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>요약 형식 준수율</td>
<td>~60%</td>
<td>~90%</td>
</tr>
<tr>
<td>JSON 파싱 성공률</td>
<td>~75%</td>
<td>~98%</td>
</tr>
<tr>
<td>평균 응답 시간</td>
<td>3–4초</td>
<td>3–4초 (동일)</td>
</tr>
<tr>
<td>긴 문서 처리</td>
<td>잘림 다수</td>
<td>안정적</td>
</tr>
</tbody></table>
<p>수치 자체는 체감 기반이지만, 실제로 운영하면서 느끼는 <strong>에러 빈도와 수동 수정 빈도</strong>는 확실히 줄어든 편입니다.</p>
<hr>
<h2 id="8-현재-한계와-앞으로의-계획">8. 현재 한계와 앞으로의 계획</h2>
<h3 id="81-현재-한계">8.1 현재 한계</h3>
<ol>
<li><p><strong>영상 위주 콘텐츠 요약 미지원</strong></p>
<ul>
<li>유튜브처럼 <strong>영상만 있고, 본문 텍스트가 거의 없는 페이지</strong>는 현재 요약에 실패합니다.</li>
<li>자막/트랜스크립트 기반 요약은 별도의 파이프라인이 필요해서, 추후 작업으로 분리해두었습니다.</li>
</ul>
</li>
<li><p><strong>본문이 없는 랜딩 페이지</strong></p>
<ul>
<li>이미지/레이아웃 위주의 랜딩 페이지도 마찬가지로, 텍스트 추출 자체가 어려워서 요약이 실패합니다.</li>
</ul>
</li>
</ol>
<p>이 부분은 <strong>사용자에게도 명시적으로 안내</strong>하고, 빠른 시일 내에 개선할 예정입니다.</p>
<h3 id="82-앞으로-하고-싶은-것들">8.2 앞으로 하고 싶은 것들</h3>
<ol>
<li><p><strong>스트리밍 응답 지원</strong></p>
<ul>
<li>현재는 서버에서 요약이 완전히 끝날 때까지 기다렸다가 한 번에 반환합니다.</li>
<li>추후에는 <strong>스트리밍 형태로 문서가 채워지는 UX</strong>를 검토하고 있습니다.</li>
</ul>
</li>
<li><p><strong>캐싱 전략</strong></p>
<ul>
<li>동일한 URL을 여러 번 요약하는 것은 낭비이기 때문에,
Redis 같은 캐시를 두고 <strong>중복 요약을 피하는 전략</strong>을 도입할 계획입니다.</li>
</ul>
</li>
<li><p><strong>다국어 지원 강화</strong></p>
<ul>
<li>지금은 한국어/영어 중심으로 동작하지만,</li>
<li>언어별로 프롬프트를 최적화해서 <strong>다국어 페이지에 더 잘 대응</strong>하고 싶습니다.</li>
</ul>
</li>
<li><p><strong>요약 품질 평가 메트릭 도입</strong></p>
<ul>
<li>현재는 사람 눈으로만 “좋다/별로다”를 판단하고 있습니다.</li>
<li>가능하면 ROUGE, BERTScore 같은 지표를 참고해 <strong>정량적인 지표</strong>를 도입해 보는 것이 목표입니다.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="9-마치며">9. 마치며</h2>
<p>겉으로 보기에는 “링크 요약 기능 하나 추가했다”처럼 보이지만, 실제 구현은:</p>
<ul>
<li>웹 크롤링</li>
<li>본문 추출</li>
<li>텍스트 정제</li>
<li>LLM 프롬프트 설계</li>
<li>JSON 파싱</li>
<li>데이터 검증 &amp; 에러 처리</li>
</ul>
<p>까지 여러 레이어가 겹쳐 있는 <strong>꽤 긴 파이프라인</strong>이었습니다.</p>
<p>특히 느낀 점은:</p>
<ul>
<li><strong>프롬프트 엔지니어링은 한 번에 끝나지 않는다</strong>
→ 실제 사용 데이터를 보면서 계속 다듬어야 합니다.</li>
<li><strong>LLM 응답은 항상 예외 케이스가 있다</strong>
→ 파싱과 검증 로직을 충분히 탄탄하게 짜두는 게 중요합니다.</li>
<li><strong>노이즈 제거가 요약 품질의 50%</strong>
→ 좋은 모델을 쓰는 것만큼, 좋은 입력을 만들어 주는 것이 중요합니다.</li>
</ul>
<p>지금은 링크 드라퍼에서 <strong>링크를 저장하면 자동으로 요약 문서와 태그가 붙는 경험</strong>을 제공하고 있고,
앞으로는 영상/비텍스트 페이지까지 커버하도록 계속 확장해 나갈 예정입니다.</p>
<h2 id="🧪-링크-드라퍼-정식-출시">🧪 링크 드라퍼, 정식 출시!</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[느린 API, 어디서 막혔나]]></title>
            <link>https://velog.io/@link_dropper/api-refactoring-1</link>
            <guid>https://velog.io/@link_dropper/api-refactoring-1</guid>
            <pubDate>Fri, 24 Oct 2025 15:23:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<ul>
<li><strong>DB 단계 최적화</strong>: 불필요 인덱스/과도한 CASCADE 정리 → 쓰기 성능 및 무결성 개선. </li>
</ul>
</blockquote>
<ul>
<li><strong>쿼리 구조 개선</strong>: N+1 제거(폴더별 미읽은 수 집계), 다건 UPDATE를 <strong>CASE 단일 쿼리</strong>로 통합.  </li>
<li><strong>동시성 활용</strong>: 순차 처리 → <code>Promise.all</code> 기반 병렬 처리로 대량 작업 가속. </li>
<li><strong>결과</strong>: 응답 시간 50%+ 단축, DB 부하 70%↓, 코드 403줄 순감. </li>
</ul>
<hr>
<h2 id="왜-이-글을-읽어야-할까">왜 이 글을 읽어야 할까?</h2>
<p>운영 환경에서 <strong>API 응답이 튀고, 피크 타임에 DB가 뜨거워지는</strong> 문제는 대부분 “쿼리 구조·인덱스·동시성” 3축에서 원인이 드러납니다. 본 리팩터링은 이 세 축을 단계적으로 다뤄 <strong>측정 가능</strong>한 성과를 만들었습니다. (응답시간/부하/코드 줄 수) </p>
<hr>
<h2 id="1-스키마·인덱스-정비-쓰기-성능과-무결성을-동시에">1) 스키마·인덱스 정비: 쓰기 성능과 무결성을 동시에</h2>
<p>운영 중 적체가 보이는 프로젝트의 공통점은 <strong>인덱스 과다</strong>와 <strong>과격한 CASCADE</strong>입니다.
이번 작업에서는 인덱스를 최소화하고, 외래키의 삭제 전이를 보수적으로 조정해 <strong>INSERT/UPDATE 성능을 회복</strong>하고 <strong>무결성을 보강</strong>했습니다. </p>
<h3 id="예시-코드-단순화된-엔티티">예시 코드 (단순화된 엔티티)</h3>
<pre><code class="language-ts">// Before: 인덱스/연쇄 옵션이 과함 (예시)
@Entity(&#39;resource&#39;)
@Index([&#39;ownerId&#39;, &#39;isArchived&#39;])
export class Resource {
  @Column({ default: false }) isArchived: boolean;
  @ManyToOne(() =&gt; Folder, { onDelete: &#39;CASCADE&#39; }) folder: Folder | null;
}

// After: 필요한 관계만, 보수적 전이 (예시)
@Entity(&#39;resource&#39;)
export class Resource {
  @Column({ default: false }) isArchived: boolean;
  @ManyToOne(() =&gt; Folder) folder: Folder | null; // onDelete 생략 → 애플리케이션 레벨로 제어
}</code></pre>
<hr>
<h2 id="2-n1-제거-폴더별-미읽은-개수를-한-방에">2) N+1 제거: “폴더별 미읽은 개수”를 한 방에</h2>
<p>기존엔 폴더 수만큼 <code>COUNT</code>가 반복되는 N+1 쿼리였고, 이를 <strong>IN + GROUP BY</strong> 단일 쿼리로 교체했습니다.  </p>
<h3 id="예시-코드-집계-api">예시 코드 (집계 API)</h3>
<pre><code class="language-ts">// Before: 폴더마다 개별 COUNT (예시)
const withUnread = await Promise.all(
  folders.map(async (f) =&gt; ({ ...f, unread: await countUnread(f.id) }))
);

// After: 한 번에 묶어서 조회 (예시)
const ids = folders.map(f =&gt; f.id);
const rows = await db.query(/* sql */ `
  SELECT folder_id, COUNT(*) AS unread
  FROM link
  WHERE is_trashed = false
    AND last_use_at IS NULL
    AND folder_id = ANY($1)
  GROUP BY folder_id
`, [ids]);

const unreadMap = new Map(rows.map(r =&gt; [Number(r.folder_id), Number(r.unread)]));
const withUnreadFast = folders.map(f =&gt; ({ ...f, unread: unreadMap.get(f.id) ?? 0 }));</code></pre>
<blockquote>
<p>폴더 10개 기준 <strong>11쿼리 → 2쿼리</strong>로 축소(82%↓). </p>
</blockquote>
<hr>
<h2 id="3-대량-update-최적화-case-문으로-원자적-일괄-반영">3) 대량 UPDATE 최적화: CASE 문으로 원자적 일괄 반영</h2>
<p>아이템 정렬처럼 “행마다 다른 값”을 갱신해야 한다면, 반복 UPDATE 대신 <strong>CASE-based 단일 UPDATE</strong>로 전환합니다. 트랜잭션 비용이 줄고, <strong>원자성</strong>이 보장됩니다. </p>
<h3 id="예시-코드-정렬-순서-일괄-반영">예시 코드 (정렬 순서 일괄 반영)</h3>
<pre><code class="language-ts">// Before: 항목마다 UPDATE (예시)
for (const it of items) {
  await db.update(&#39;sort&#39;, { sort_order: it.order }, { item_id: it.id });
}

// After: CASE 한 번으로 (예시)
const ids = items.map(x =&gt; x.id);
const cases = items.map(x =&gt; `WHEN item_id = ${x.id} THEN ${x.order}`).join(&#39; &#39;);
await db.query(`
  UPDATE sort
  SET sort_order = CASE ${cases} ELSE sort_order END
  WHERE item_id = ANY($1)
`, [ids]);</code></pre>
<blockquote>
<p>실측 예시: 5개 항목 정렬 시 <strong>10쿼리 → 1쿼리(90%↓)</strong>. </p>
</blockquote>
<hr>
<h2 id="4-순차-→-병렬-대량-이동·복사-작업-가속">4) 순차 → 병렬: 대량 이동·복사 작업 가속</h2>
<p>목록 이동/복사 등 I/O가 많은 작업은 <strong>타입별 분리 + <code>Promise.all</code></strong>로 병렬화하여 전체 처리 시간을 줄였습니다.  </p>
<h3 id="예시-코드-이동-작업">예시 코드 (이동 작업)</h3>
<pre><code class="language-ts">// Before: 순차 처리 (예시)
for (const it of items) await moveOne(it);

// After: 타입별 병렬 처리 (예시)
const linkItems = items.filter(i =&gt; i.type === &#39;link&#39;);
const folderItems = items.filter(i =&gt; i.type === &#39;folder&#39;);

await Promise.all([
  Promise.all(linkItems.map(moveOneLink)),
  Promise.all(folderItems.map(moveOneFolder)),
]);</code></pre>
<blockquote>
<p>10개 항목(링크5/폴더5) 이동 시, 쿼리 수와 경로가 단축되며 <strong>처리 시간 체감</strong>이 발생. </p>
</blockquote>
<hr>
<h2 id="5-응용-publicationitem-서비스-단순화">5) 응용: Publication/Item 서비스 단순화</h2>
<p>복잡한 재귀를 반복/분할로 치환하고, 알림 등 부가 책임을 분리했습니다.
정렬은 <strong>머지 정렬 커스텀 코드 → 내장 <code>Array.prototype.sort</code></strong>로 단순화해 가독성과 성능을 동시에 얻었습니다.  </p>
<h3 id="예시-코드-정렬-단순화">예시 코드 (정렬 단순화)</h3>
<pre><code class="language-ts">// Before: 커스텀 병합 정렬 (예시)
function mergeSortByOrder(listA, listB) { /* ...길고 복잡... */ }

// After: 내장 sort로 충분 (예시)
const sorted = [...links, ...folders].sort((a, b) =&gt; a.order - b.order);</code></pre>
<hr>
<h2 id="6-유지·데이터-모델-개선-soft-deleteunique-명시">6) 유지·데이터 모델 개선: Soft Delete/Unique 명시</h2>
<p>운영 로그성 데이터엔 <strong>Soft Delete</strong>가 유용합니다. 또한 URL 등 고유키는 <strong>Unique 제약</strong>을 스키마에 명확히 두어 중복을 차단합니다.  </p>
<h3 id="예시-코드">예시 코드</h3>
<pre><code class="language-ts">// Soft Delete (예시)
@Entity(&#39;maintenance&#39;)
class Maintenance {
  @DeleteDateColumn() deletedAt: Date;
}

// Unique 제약 (예시)
@Entity(&#39;og_metadata&#39;)
@Unique([&#39;url&#39;])
class OgMeta {
  @Column() url: string;
}</code></pre>
<hr>
<h2 id="성과-운영-지표-기준">성과 (운영 지표 기준)</h2>
<ul>
<li><strong>응답 시간</strong>: 50%+ 단축</li>
<li><strong>DB 부하</strong>: 70% 감소</li>
<li><strong>코드베이스</strong>: 403줄 순감(677 삭제, 274 추가)</li>
<li><strong>확장성/안정성</strong>: 대량 처리 시 일관된 성능 유지, 트랜잭션/원자성 보장
근거: 프로젝트 정리 문서의 결론/지표 요약.  </li>
</ul>
<hr>
<h2 id="체크리스트-우리-팀에-바로-적용하려면">체크리스트: 우리 팀에 바로 적용하려면</h2>
<ol>
<li><strong>스키마</strong>: 과도한 인덱스/연쇄 옵션 제거, 유니크/널 제약을 모델 의도로 맞춤. </li>
<li><strong>쿼리</strong>: N+1 의심 구간을 IN+GROUP BY로 통합, 다건 UPDATE는 CASE 단일화.  </li>
<li><strong>동시성</strong>: CPU/IO 바운드별로 병렬화 설계(<code>Promise.all</code>/배치 크기 제한). </li>
<li><strong>코드 품질</strong>: 커스텀 정렬·재귀 로직을 내장 함수/반복으로 단순화. </li>
<li><strong>삭제 정책</strong>: 로그·이력엔 Soft Delete, 하드 삭제는 최소화. </li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 리팩터링은 <strong>DB 설계 → 쿼리 전략 → 동시성 → 도메인 로직</strong>을 한 흐름으로 정돈해, <strong>측정 가능한 성과</strong>를 냈다는 점이 핵심입니다. 같은 문제를 겪는 팀이라면 위 체크리스트부터 적용해 보세요. 성능은 결국 <strong>구조</strong>에서 나옵니다. (요약 근거) </p>
<blockquote>
<p>참고: 본 글의 기술 근거는 사용자가 제공한 리팩터링 분석 파일을 기반으로 하며, 예시 코드는 운영 코드와 무관하게 이해를 돕기 위해 별도로 구성되었습니다. </p>
</blockquote>
<hr>
<h2 id="🧪-링크-드라퍼-정식-출시">🧪 링크 드라퍼, 정식 출시!</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<hr>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@dnd-kit + Zustand로 완성한 DOCK 기능: 자연스러운 드래그 앤 드롭과 낙관적 UI의 조화]]></title>
            <link>https://velog.io/@link_dropper/dock</link>
            <guid>https://velog.io/@link_dropper/dock</guid>
            <pubDate>Fri, 26 Sep 2025 10:11:17 GMT</pubDate>
            <description><![CDATA[<h2 id="✨-들어가며">✨ 들어가며</h2>
<p>링크 드라퍼(Link Dropper) 서비스에 <strong>DOCK 기능</strong>을 추가하면서, 단순한 링크 고정보다 중요한 것은 <strong>사용자 경험(UX)</strong> 이었습니다.
링크를 고정하는 동작이 ‘자연스럽고 예측 가능’하게 이루어지도록 하기 위해선, 시각적 피드백, 즉각적인 반응, 실패 대비 처리 등 다양한 요소가 필요했죠.</p>
<p>이번 글에서는 <code>@dnd-kit</code>과 <code>Zustand</code>를 이용해 <strong>DnD 인터랙션을 어떻게 설계하고</strong>, <strong>낙관적 UI를 어떤 방식으로 구현했는지</strong>를 예시 코드와 함께 공유합니다.</p>
<hr>
<h2 id="🎯-기능-설계의-핵심-포인트">🎯 기능 설계의 핵심 포인트</h2>
<h3 id="문제-정의">문제 정의</h3>
<ul>
<li>고정된 링크 영역(DOCK)과 최근 본 링크 목록 간의 이동이 필요하다.</li>
<li>사용자가 링크를 드래그할 때 자연스럽게 느껴져야 하며, UX가 끊기면 안 된다.</li>
<li>순서 변경, 고정 해제 등 다양한 액션을 드래그로 구현해야 한다.</li>
</ul>
<h3 id="주요-과제">주요 과제</h3>
<table>
<thead>
<tr>
<th>과제</th>
<th>해결 전략</th>
</tr>
</thead>
<tbody><tr>
<td>드래그 도중의 시각 피드백</td>
<td><code>DragOverlay</code> 활용</td>
</tr>
<tr>
<td>드래그가 실수로 발생하는 문제</td>
<td>최소 거리 제약 설정</td>
</tr>
<tr>
<td>API 응답 지연에 의한 UX 끊김</td>
<td>낙관적 UI로 선반영 후 실패 시 롤백</td>
</tr>
<tr>
<td>상태의 신뢰성 유지</td>
<td><code>snapshot</code> 기반 복구 로직 설계</td>
</tr>
</tbody></table>
<hr>
<h2 id="🧩-dnd-컨텍스트-설계">🧩 DnD 컨텍스트 설계</h2>
<p>DnD 컨텍스트를 따로 Provider로 만들고, 이 안에서 draggable 요소들과 drop zone들을 관리합니다.
여기서 중요한 것은 <code>item.id</code>의 <strong>명명 규칙</strong>이에요.
<code>recent-{id}</code> vs <code>dock-{id}</code> 패턴으로 id를 정하면, 드래그 중 어디서 왔고 어디로 가는지를 쉽게 판단할 수 있습니다.</p>
<pre><code class="language-tsx">const items = useMemo(() =&gt; [
  ...recentLinks.map(link =&gt; ({ id: `recent-${link.id}` })),
  ...dockItems.map(item =&gt; ({ id: `dock-${item.id}` })),
  { id: &quot;dock-drop-zone&quot; },
], [recentLinks, dockItems]);</code></pre>
<p>👉 이 구조는 이후 <code>onDragEnd</code>에서 drop 위치를 판별하는 기준이 되며, if 조건문이 훨씬 명확해집니다.</p>
<hr>
<h2 id="⚙️-드래그-동작-중의-시각-피드백-dragoverlay">⚙️ 드래그 동작 중의 시각 피드백: DragOverlay</h2>
<p>드래그 중인 요소를 사용자에게 보여주는 시각적 피드백은 <strong>필수적 UX 요소</strong>입니다.</p>
<pre><code class="language-tsx">&lt;DragOverlay&gt;
  {activeItem ? (
    &lt;div className=&quot;drag-overlay&quot;&gt;
      &lt;LinkThumbnail link={activeItem.link} /&gt;
    &lt;/div&gt;
  ) : null}
&lt;/DragOverlay&gt;</code></pre>
<ul>
<li>사용자는 지금 어떤 링크를 드래그 중인지 인식할 수 있음</li>
<li>오버레이는 별도의 z-index로 띄워서 다른 UI와 겹치지 않게 처리</li>
</ul>
<p>이 구성은 단순하지만 드래그가 &quot;진짜 되는 것처럼 보이게 만드는 데&quot; 큰 역할을 합니다.</p>
<hr>
<h2 id="🔄-상태-관리-왜-zustand인가">🔄 상태 관리: 왜 Zustand인가?</h2>
<p>기존의 React state 혹은 Redux가 아닌 Zustand를 사용한 이유는 다음과 같습니다:</p>
<ul>
<li>간결한 코드로 전역 상태를 관리 가능</li>
<li>snapshot 같은 일시적 저장 구조를 쉽게 구현할 수 있음</li>
<li>React Context 없이도 작동하며, DnD처럼 상태 변화가 자주 발생하는 구조에 적합</li>
</ul>
<h3 id="상태-구조-설계">상태 구조 설계</h3>
<pre><code class="language-ts">interface DockState {
  dockItems: DockItem[];
  snapshot: DockItem[];
  setDockItems: (items: DockItem[]) =&gt; void;
  snapshotCurrent: () =&gt; void;
}</code></pre>
<ul>
<li><code>snapshot</code>: 실패 시 복구할 수 있도록 이전 상태를 임시 저장</li>
<li><code>setDockItems</code>: 상태 갱신</li>
<li><code>snapshotCurrent</code>: 낙관적 UI 이전에 꼭 호출해야 하는 액션</li>
</ul>
<hr>
<h2 id="🚀-낙관적-ui--빠른-피드백을-위한-선택">🚀 낙관적 UI — 빠른 피드백을 위한 선택</h2>
<p>드래그 후 링크를 DOCK으로 옮기는 순간, UI가 반응이 없다면 사용자 입장에서는 <strong>버그처럼 느껴질 수 있습니다</strong>.
이를 해결하기 위해 &quot;먼저 UI를 갱신하고, 나중에 서버에 반영하는&quot; <strong>낙관적 UI 패턴</strong>을 도입했습니다.</p>
<pre><code class="language-ts">async function handleDropToDock(link: Link, insertAfterId: number | null) {
  snapshotCurrent(); // 현재 상태 백업

  // 1. UI 선반영
  const reordered = computeNewOrder(dockItems, link, insertAfterId);
  setDockItems(reordered);

  try {
    // 2. 서버에 반영
    await api.addToDock(link.id);
    await api.updateDockOrder(reordered);
  } catch {
    // 3. 실패 시 롤백
    setDockItems(snapshot);
  }
}</code></pre>
<p>낙관적 UI를 구현할 땐 항상 롤백 경로를 열어두는 게 중요합니다.
여기서 <code>snapshot</code>이 큰 역할을 하죠.</p>
<hr>
<h2 id="🧠-드래그-종료-처리-ondragend">🧠 드래그 종료 처리: onDragEnd</h2>
<p>드래그 종료 이벤트에는 <strong>모든 분기처리 로직이 집약</strong>되어 있습니다.
DnD 흐름을 정리하면 다음과 같습니다:</p>
<pre><code class="language-tsx">function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event;
  if (!over) return;

  const activeId = active.id;
  const overId = over.id;

  if (activeId.startsWith(&quot;recent-&quot;) &amp;&amp; overId === &quot;dock-drop-zone&quot;) {
    const linkId = activeId.replace(&quot;recent-&quot;, &quot;&quot;);
    onDropToDock(findLinkById(linkId), null);
    return;
  }

  if (activeId.startsWith(&quot;dock-&quot;) &amp;&amp; overId.startsWith(&quot;dock-&quot;)) {
    onReorderDockItems(/* ... */);
  }
}</code></pre>
<ul>
<li>ID 패턴을 이용해 출발지와 도착지를 명확히 파악</li>
<li>서로 다른 행동을 조건 분기로 구분 (추가 vs 정렬 변경)</li>
</ul>
<hr>
<h2 id="🧪-결과-기능-이상의-경험">🧪 결과: 기능 이상의 경험</h2>
<h3 id="✅-사용성-개선-포인트">✅ 사용성 개선 포인트</h3>
<ul>
<li>즉각적인 피드백: 드래그만 하면 바로 반응</li>
<li>자연스러운 애니메이션과 UI 흐름</li>
<li>API 실패 대응까지 고려된 안정성</li>
</ul>
<hr>
<h2 id="🏁-마무리하며">🏁 마무리하며</h2>
<p>이번 DOCK 기능 구현은 작아 보이지만 깊은 고민이 담긴 작업이었습니다.
<code>@dnd-kit</code>의 유연함과 <code>Zustand</code>의 간결함, 그리고 낙관적 UI의 UX적 강점을 조화롭게 적용한 좋은 경험이었고,
이런 방식이 여러분의 프로젝트에도 영감을 줄 수 있기를 바랍니다.</p>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com/">링크 드라퍼 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클릭 폭탄에서 해방되기: 이벤트 위임으로 코드 효율 + 성능 두 마리 토끼 잡기]]></title>
            <link>https://velog.io/@link_dropper/Event-Delegation</link>
            <guid>https://velog.io/@link_dropper/Event-Delegation</guid>
            <pubDate>Fri, 19 Sep 2025 05:51:22 GMT</pubDate>
            <description><![CDATA[<p>웹/프론트엔드 개발을 하다 보면, 특히 UI가 복잡해지거나 요소가 동적으로 생성되면서 <strong>이벤트 핸들러 수가 폭발적으로 늘어나는 문제</strong>에 직면하곤 합니다.
링크, 폴더, 리스트, 버튼 등 반복적 요소에 하나하나 이벤트를 붙이는 방식은 단기적으로는 작지만, 장기적으로 보면 유지보수·성능·버그 관점에서 골치 아픈 원인이 됩니다.</p>
<blockquote>
<p>이 글에서는 이벤트 위임(Event Delegation)의 개념, 왜 쓰는지, 언제 쓰면 좋고 언제 피해야 하는지, 실제 코드 예제와 함께 <strong>링크/폴더 구조</strong> 같은 상황에서 적용할 수 있는 구체적인 해결 방안을 다룹니다.</p>
</blockquote>
<hr>
<h2 id="📌-이벤트-위임이란-무엇인가">📌 이벤트 위임이란 무엇인가?</h2>
<p>이벤트 위임(Event Delegation)은 다음 개념들의 조합을 활용합니다:</p>
<ol>
<li><p><strong>이벤트 버블링 (Event Bubbling)</strong>
자식 요소에서 발생한 이벤트가 DOM 트리 상위로 전파(flows up)되는 특성입니다.</p>
</li>
<li><p><strong>상위 요소에 이벤트 리스너를 한 번만 등록</strong>
여러 하위 요소(each child)에 개별적으로 <code>onClick</code>/<code>onDoubleClick</code> 등을 붙이는 대신, 상위(wrapper 또는 컨테이너) 요소에 이벤트 리스너를 걸어두고, 이벤트가 상위까지 올라왔을 때 어느 자식에서 일어났는지 평가하여 처리합니다.</p>
</li>
<li><p><strong>대상(target) 판별 로직</strong>
<code>event.target</code>, <code>event.target.closest(selector)</code>, <code>matches()</code>, <code>dataset</code> 속성 등을 활용해 클릭된 요소가 어떤 타입(예: 링크 vs 폴더 vs 특정 버튼)인지 구분합니다.</p>
</li>
</ol>
<hr>
<h2 id="⚠️-반복된-방식의-문제점-왜-이벤트-위임이-필요했나">⚠️ 반복된 방식의 문제점: 왜 이벤트 위임이 필요했나</h2>
<p>링크(link)와 폴더(folder)에 각각 <code>onClick</code>, <code>onDoubleClick</code> 등을 직접 붙이는 기존 방식이 가진 구체적인 단점들은 다음과 같습니다:</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>문제 상세</th>
</tr>
</thead>
<tbody><tr>
<td><strong>핸들러 중복 및 메모리 낭비</strong></td>
<td>동일한 함수/로직이 여러 요소에 중복되어 붙기 때문에, 메모리 사용량 증가. 특히 리스트 항목이 많고 동적으로 추가/제거 되는 경우 부담 커짐.</td>
</tr>
<tr>
<td><strong>동적 요소 관리의 번거로움</strong></td>
<td>스크립트로 요소가 추가되면 또 이벤트를 붙여야 함. 실수로 누락되면 해당 새 요소는 작동 안 함.</td>
</tr>
<tr>
<td><strong>가독성 저하 / 코드 산만화</strong></td>
<td>JSX나 HTML/템플릿에 이벤트 속성이 잔뜩 생김. 이벤트 로직이 요소마다 흩어져 있어 전체 흐름 파악이 어려움.</td>
</tr>
<tr>
<td><strong>성능 저하 가능성</strong></td>
<td>브라우저가 많은 이벤트 리스너를 다루느라 렌더링이나 이벤트 처리 시 비용이 높아짐. 특히 모바일 기기나 저사양 환경에서 체감 가능.</td>
</tr>
</tbody></table>
<p>링크+폴더 예제에서는 “링크 클릭” / “폴더 더블클릭” 등의 동작이 반복 요소마다 있고, 어떤 경우엔 폴더가 동적으로 추가되기도 할 것임. 이런 구조에서는 위임이 유리합니다.</p>
<hr>
<h2 id="🛠-위임-전략-적용하기-링크·폴더-구조-예시">🛠 위임 전략 적용하기: 링크·폴더 구조 예시</h2>
<p>아래는 링크/폴더 구조에 이벤트 위임을 적용하는 실전 예제입니다.</p>
<h3 id="구조-가정">구조 가정</h3>
<ul>
<li>여러 개의 아이템(item)이 있고, 각각 아이템은 “링크” 혹은 “폴더” 타입</li>
<li>유저가 링크 클릭 또는 폴더 더블클릭을 할 때 별도 동작 수행</li>
<li>폴더 더블클릭은 ‘더블클릭’ 이벤트로, 링크는 단일 클릭</li>
</ul>
<h3 id="html--jsx-구조">HTML / JSX 구조</h3>
<pre><code class="language-jsx">&lt;ul id=&quot;item-list&quot;&gt;
  {items.map(item =&gt; (
    &lt;li key={item.id}
        data-type={item.type}    // &quot;link&quot; 또는 &quot;folder&quot;
        data-id={item.id}&gt;
      {item.type === &quot;link&quot;
        ? &lt;a href={item.url} className=&quot;item-link&quot;&gt; {item.name} &lt;/a&gt;
        : &lt;span className=&quot;item-folder&quot;&gt; {item.name} &lt;/span&gt;}
    &lt;/li&gt;
  ))}
&lt;/ul&gt;</code></pre>
<h3 id="이벤트-위임-코드-클릭--더블클릭-분리">이벤트 위임 코드 (클릭 + 더블클릭 분리)</h3>
<pre><code class="language-js">const itemList = document.getElementById(&#39;item-list&#39;);

// 단일 클릭 처리
itemList.addEventListener(&#39;click&#39;, function(event) {
  const li = event.target.closest(&#39;li&#39;);
  if (!li) return;

  const type = li.dataset.type;
  const id   = li.dataset.id;

  if (type === &#39;link&#39;) {
    // 링크 클릭 동작
    console.log(`링크 클릭됨: ${id}`);
    // 여기서는 기본 동작(navigation) 허용하거나
    // event.preventDefault() 등 제어 가능
  }
  // 폴더 클릭 시 (폴더 단축 클릭) 다른 동작이 필요하다면 여기에
});

// 더블클릭 처리 (폴더 전용)
itemList.addEventListener(&#39;dblclick&#39;, function(event) {
  const li = event.target.closest(&#39;li&#39;);
  if (!li) return;

  const type = li.dataset.type;
  const id   = li.dataset.id;

  if (type === &#39;folder&#39;) {
    console.log(`폴더 더블클릭됨: ${id}`);
    // 폴더 더블클릭 처리
  }
});</code></pre>
<h3 id="추가-고려-이벤트-간-충돌-처리">추가 고려: 이벤트 간 충돌 처리</h3>
<ul>
<li><p>단일 클릭과 더블클릭이 같은 요소에서 모두 가능할 때, 일반적으로 클릭 이벤트가 먼저 실행되고 더블클릭이 이어지는 구조여서 클릭 동작과 더블클릭 동작이 서로 간섭할 수 있음.</p>
</li>
<li><p>해결책:</p>
<ol>
<li><p><strong>타이머 기반 분기</strong>
더블클릭이 아닌 단일 클릭으로 보고 일정 시간 이후(예: 300ms) 클릭 로직 실행.</p>
</li>
<li><p><strong>이벤트 취소</strong>
더블클릭 이벤트가 발생했을 때 단일 클릭 이벤트의 실행을 막거나 무시하도록 로직 구성.</p>
</li>
</ol>
</li>
</ul>
<p>예시 (간단한 타이머 방식):</p>
<pre><code class="language-js">let clickTimer = null;
const clickDelay = 300;

itemList.addEventListener(&#39;click&#39;, function(event) {
  const li = event.target.closest(&#39;li&#39;);
  if (!li) return;

  const type = li.dataset.type;
  const id   = li.dataset.id;

  // 링크 단일 클릭만 처리
  if (type === &#39;link&#39;) {
    // 링크는 단일 클릭만 필요하다면 바로 처리
    handleLinkClick(id);
  } else if (type === &#39;folder&#39;) {
    // 폴더는 단일 클릭 vs 더블클릭 분기
    clearTimeout(clickTimer);
    clickTimer = setTimeout(() =&gt; {
      handleFolderSingleClick(id);
    }, clickDelay);
  }
});

itemList.addEventListener(&#39;dblclick&#39;, function(event) {
  const li = event.target.closest(&#39;li&#39;);
  if (!li) return;

  const type = li.dataset.type;
  const id   = li.dataset.id;

  if (type === &#39;folder&#39;) {
    clearTimeout(clickTimer);
    handleFolderDoubleClick(id);
  }
});</code></pre>
<hr>
<h2 id="⚙-이벤트-위임의-장·단점--실무적-고려사항">⚙ 이벤트 위임의 장·단점 &amp; 실무적 고려사항</h2>
<h3 id="✅-장점">✅ 장점</h3>
<ul>
<li><p><strong>핸들러 수 감소 → 메모리 &amp; 이벤트 리스너 관리 비용 절감</strong>
반복되는 하위 요소가 많을수록 이득이 커짐. 동적 요소 생성에도 추가 처리 필요 없음.</p>
</li>
<li><p><strong>코드 일관성 / 유지보수성 향상</strong>
이벤트 로직이 한 곳에 집중됨. 수정/추가/버그 수정 시 변경 지점을 최소화 가능.</p>
</li>
<li><p><strong>성능 개선</strong>
많은 이벤트 리스너가 걸려 있는 경우, 브라우저 이벤트 처리 및 가비지 컬렉션 부담 감소.</p>
</li>
<li><p><strong>확장성</strong>
요소 타입이 추가되더라도, 상위 로직 쪽에서 타입만 판별하여 처리하면 됨.</p>
</li>
</ul>
<h3 id="❌-단점--주의할-점">❌ 단점 / 주의할 점</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>이벤트 버블링이 안 되는 경우</strong></td>
<td><code>stopPropagation()</code>이 사용되었거나, Shadow DOM 내 특정 이벤트, 혹은 캡슐화된 컴포넌트 등에서는 이벤트가 상위로 전달되지 않을 수 있음.</td>
</tr>
<tr>
<td><strong>이벤트 타입에 따른 충돌</strong></td>
<td>클릭/더블클릭/드래그/마우스 무브 등의 이벤트가 복합될 경우, 단일 이벤트와 더블클릭 간 타이밍 문제.</td>
</tr>
<tr>
<td><strong>우선순위 / 제어권 문제</strong></td>
<td>특정 자식 요소에만 이벤트가 작동해야 할 경우(예: 보안적으로 중요한 버튼, 접근성 고려), 위임이 오히려 복잡도를 높일 수 있음.</td>
</tr>
<tr>
<td><strong>이벤트 타깃 판별의 복잡성</strong></td>
<td><code>event.target</code>이 자식 요소 안쪽의 태그일 경우, <code>closest()</code>나 커스텀 식별자(데이터 속성, 클래스) 설정 필요. 잘못 사용하면 의도치 않은 요소가 이벤트를 갖게 됨.</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔍-언제-위임-vs-직접-등록이-더-좋은가">🔍 언제 위임 vs 직접 등록이 더 좋은가?</h2>
<p>아래 상황들을 비교하며 판단하면 됩니다:</p>
<table>
<thead>
<tr>
<th>조건</th>
<th>위임이 적합한 경우</th>
<th>직접 등록이 나은 경우</th>
</tr>
</thead>
<tbody><tr>
<td>반복적 요소 수</td>
<td>수십 ~ 수백 개 이상 반복 요소 있음</td>
<td>아주 소수이거나 단 하나뿐인 요소</td>
</tr>
<tr>
<td>동적 생성 여부</td>
<td>새로운 요소가 자주 추가/삭제됨</td>
<td>마크업이 고정되어 있고 자주 변하지 않음</td>
</tr>
<tr>
<td>이벤트 종류</td>
<td>클릭, 더블클릭, 키보드 이벤트 등 버블링 가능한 이벤트</td>
<td><code>focus</code>, <code>blur</code>, <code>submit</code>(폼), <code>scroll</code>, <code>touchmove</code> 등 버블링 제한 혹은 민감한 제어 필요 이벤트</td>
</tr>
<tr>
<td>예상 충돌 가능성</td>
<td>자식 간 이벤트 우선순위 충돌이 적거나 쉽게 구분 가능</td>
<td>복잡한 UI 로직, 많은 상호작용, 제어 권한 필요 시 직접 붙이는 것이 명확함</td>
</tr>
<tr>
<td>성능 요구 수준</td>
<td>저사양 환경(모바일, 오래된 브라우저), 대량 데이터 렌더링 필요</td>
<td>단순 페이지, 요소 적음, 최적화보다 명확성이 더 중요한 경우</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔧-실무-적용-시-체크리스트">🔧 실무 적용 시 체크리스트</h2>
<ul>
<li>DOM 구조를 설계할 때 <strong>식별자(identifier)</strong> 설정: 클래스명, <code>data-</code> 속성, 태그명 등을 미리 정리해두면 이벤트 타깃 판별이 쉬움</li>
<li><code>closest()</code> 와 <code>matches()</code> 사용법 숙지</li>
<li>더블클릭 vs 클릭 분기 필요 시 타이밍, 이벤트 취소(logic) 고려</li>
<li><code>stopPropagation()</code>, <code>stopImmediatePropagation()</code> 등이 이벤트 흐름을 끊는 곳이 없는지 점검</li>
<li>Shadow DOM, 웹 컴포넌트 사용 시 이벤트 전파 특성 이해</li>
<li>개발자 도구(Chrome DevTools 등)의 Performance / Memory 탭을 활용해 핸들러 수, 이벤트 처리 지연 등의 지표 측정</li>
</ul>
<hr>
<h2 id="✅-결론">✅ 결론</h2>
<ul>
<li>이벤트 위임은 반복적이고 동적인 UI 구성에서 <strong>성능, 유지보수, 확장성</strong>을 개선해 주는 매우 유용한 패턴입니다.</li>
<li>하지만 모든 경우에 만능은 아니므로, 위에서 언급한 고려사항들을 기반으로 “언제 위임할 것인가, 언제 직접 이벤트를 붙일 것인가”를 판단하는 것이 중요합니다.</li>
<li>특히 링크 + 폴더 구조처럼 반복되는 항목이 많고 사용자 인터랙션 종류가 제한적이라면, 지금 바로 이벤트 위임으로 리팩터링 해보시는 걸 추천드립니다.</li>
</ul>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 사용하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🤖 MCP와 A2A: 에이전트들이 세상을 연결하는 법]]></title>
            <link>https://velog.io/@link_dropper/mcp-a2a</link>
            <guid>https://velog.io/@link_dropper/mcp-a2a</guid>
            <pubDate>Thu, 24 Jul 2025 16:04:45 GMT</pubDate>
            <description><![CDATA[<h2 id="🧩-배경-왜-mcp와-a2a를-알아야-할까">🧩 배경: 왜 MCP와 A2A를 알아야 할까?</h2>
<p>대규모 언어 모델(LLM)은 단순한 텍스트 생성기를 넘어 <strong>“에이전트”</strong>로 진화하고 있습니다.
이제 AI는 외부 시스템과 연동하고, 다른 AI와 협업하며 <strong>복잡한 작업을 자동으로 처리</strong>할 수 있어야 합니다.</p>
<p>그렇다면 이 복잡한 연동과 협업은 <strong>어떻게 표준화</strong>되어 있을까요?</p>
<p>👉 해답은 바로 두 가지 핵심 프로토콜, <strong>MCP (Model Context Protocol)</strong>와 <strong>A2A (Agent-to-Agent Protocol)</strong>에 있습니다.</p>
<hr>
<h2 id="🛠-mcp-도구를-호출하는-llm의-새로운-습관">🛠 MCP: 도구를 호출하는 LLM의 새로운 습관</h2>
<h3 id="✅-mcp란">✅ MCP란?</h3>
<p>MCP는 LLM이 외부 도구(API, DB, 서비스 등)를 호출할 수 있도록 도와주는 <strong>도구 호출용 프로토콜</strong>입니다.
기존의 프롬프트 기반 도구 설명 방식에서 벗어나, <strong>기계가 해석 가능한 JSON 형식으로 도구를 등록하고 호출</strong>할 수 있게 합니다.</p>
<blockquote>
<p>마치 LLM에게 “이 도구는 이렇게 쓰는 거야”라고 가르쳐주는 인터페이스입니다.</p>
</blockquote>
<h3 id="🔍-작동-방식-요약">🔍 작동 방식 요약</h3>
<ol>
<li><code>tools/describe</code>: 사용 가능한 도구 목록과 파라미터 구조를 조회</li>
<li><code>tools/call</code>: 도구 호출 및 결과 수신</li>
<li>호출 형식: JSON-RPC 2.0 기반</li>
</ol>
<h3 id="📦-호출-예시">📦 호출 예시</h3>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;1&quot;,
  &quot;method&quot;: &quot;tools/call&quot;,
  &quot;params&quot;: {
    &quot;name&quot;: &quot;getWeather&quot;,
    &quot;arguments&quot;: {
      &quot;location&quot;: &quot;Seoul&quot;
    }
  }
}</code></pre>
<h3 id="✅-장점-요약">✅ 장점 요약</h3>
<ul>
<li>프롬프트 반복 없이 도구 호출 가능</li>
<li>LLM이 상황에 따라 자동 도구 선택</li>
<li>복잡한 도구 체계도 쉽게 확장 가능</li>
</ul>
<hr>
<h2 id="🤝-a2a-에이전트가-에이전트를-부르는-법">🤝 A2A: 에이전트가 에이전트를 부르는 법</h2>
<h3 id="✅-a2a란">✅ A2A란?</h3>
<p>A2A는 하나의 에이전트가 다른 에이전트에게 작업(Task)을 위임하고 결과를 기다릴 수 있게 해주는 <strong>에이전트 간 협업 프로토콜</strong>입니다.</p>
<blockquote>
<p>예: 법률 분석 에이전트 → 번역 에이전트 → 요약 에이전트로 이어지는 다단계 작업</p>
</blockquote>
<h3 id="🔍-주요-구성-요소">🔍 주요 구성 요소</h3>
<ul>
<li><strong>Agent Card</strong>: 에이전트 기능 정의 (API 경로, 입력값, 인증 등 포함)</li>
<li><strong>Task 객체</strong>: 작업 요청 및 상태 관리 (submitted → working → completed)</li>
<li><strong>Transport</strong>: JSON-RPC over HTTP, SSE 또는 Webhook 기반</li>
</ul>
<h3 id="📦-예시">📦 예시</h3>
<pre><code class="language-json">{
  &quot;task_id&quot;: &quot;translate_contract&quot;,
  &quot;from&quot;: &quot;agent://legal_reader&quot;,
  &quot;to&quot;: &quot;agent://translator&quot;,
  &quot;input&quot;: {
    &quot;text&quot;: &quot;이 계약서는...&quot;
  }
}</code></pre>
<h3 id="✅-a2a의-장점">✅ A2A의 장점</h3>
<ul>
<li>에이전트의 역할을 능력 단위로 분리 가능</li>
<li>상태 기반 비동기 작업 관리 가능</li>
<li>병렬, 순차 작업 조합이 자유로움</li>
<li>Agent Directory를 통한 동적 연결/탐색 가능</li>
</ul>
<hr>
<h2 id="🔗-a2a와-mcp는-어떻게-함께-쓰일까">🔗 A2A와 MCP는 어떻게 함께 쓰일까?</h2>
<p>MCP와 A2A는 역할이 다르지만, 실제 환경에서는 <strong>서로 보완적으로 사용됩니다.</strong></p>
<h3 id="예시-시나리오">예시 시나리오</h3>
<ol>
<li>사용자가 “배송 상태 확인” 요청</li>
<li>메인 에이전트는 <code>배송 전용 에이전트</code>에게 A2A로 위임</li>
<li>배송 에이전트는 MCP를 통해 <code>배송 API</code> 호출</li>
<li>결과는 A2A를 통해 메인 에이전트 → 사용자에게 전달</li>
</ol>
<hr>
<h2 id="📊-mcp-vs-a2a-비교">📊 MCP vs A2A 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>MCP</th>
<th>A2A</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>도구(API, DB 등)</td>
<td>다른 에이전트</td>
</tr>
<tr>
<td>목적</td>
<td>LLM ↔ 도구 연결</td>
<td>에이전트 ↔ 에이전트 연결</td>
</tr>
<tr>
<td>방식</td>
<td>JSON-RPC 2.0, 도구 서술 기반</td>
<td>Task 중심, 상태 관리 기반</td>
</tr>
<tr>
<td>활용</td>
<td>외부 API 실행</td>
<td>복잡한 워크플로우 조립</td>
</tr>
<tr>
<td>예</td>
<td>검색 API, DB 쿼리, 계산기</td>
<td>번역 → 리뷰 → QA</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔀-참고로-알아두면-좋은-용어-acp">🔀 참고로 알아두면 좋은 용어: ACP</h2>
<ul>
<li><strong>ACP (Agent Communication Protocol)</strong>은 메시징 중심 구조</li>
<li>A2A는 작업(Task) 위임 중심</li>
<li>MCP는 도구와 직접 상호작용을 위한 인터페이스</li>
</ul>
<p>→ 세 가지는 목적과 역할이 서로 다르며, 필요에 따라 함께 사용됩니다</p>
<hr>
<h2 id="🧠-마무리-에이전트-시대의-필수-프로토콜">🧠 마무리: 에이전트 시대의 필수 프로토콜</h2>
<p>MCP와 A2A는 단순한 기술이 아닙니다.
AI가 <strong>현실의 문제를 해결하기 위해 도구를 쓰고, 다른 AI와 협업</strong>할 수 있도록 만드는 <strong>핵심 인프라입니다.</strong></p>
<blockquote>
<p>앞으로 LLM 기반 시스템을 개발한다면, <strong>MCP와 A2A는 반드시 이해하고 설계에 반영해야 할 요소</strong>입니다.</p>
</blockquote>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 베타 체험하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
<hr>
<p>궁금하신 점이나 실제 구현에 대한 내용이 있다면 댓글로 남겨주세요! 🙌</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js와 React Query의 완벽한 만남: Hydrate로 서버 데이터를 클라이언트에 매끄럽게 전달하기]]></title>
            <link>https://velog.io/@link_dropper/next.js-React-Query-Hydrate</link>
            <guid>https://velog.io/@link_dropper/next.js-React-Query-Hydrate</guid>
            <pubDate>Thu, 19 Jun 2025 02:29:30 GMT</pubDate>
            <description><![CDATA[<p>Next.js는 SSR과 SSG를 지원하는 강력한 프레임워크이며, React Query는 API 호출 및 캐싱을 자동으로 처리해주는 매우 강력한 상태 관리 라이브러리입니다. 이 둘의 조합은 매우 강력하지만, 서버에서 가져온 데이터를 클라이언트로 자연스럽게 넘기기 위한 고민은 여전히 존재합니다.</p>
<p>React Query의 <code>Hydrate</code> 컴포넌트를 활용하면 이 문제를 깔끔하게 해결할 수 있습니다. 이번 글에서는 Hydrate의 개념, 동작 원리, 그리고 실제로 어떻게 Next.js 프로젝트에 적용하는지를 상세하게 설명합니다.</p>
<hr>
<h2 id="🤔-왜-hydrate가-필요할까">🤔 왜 Hydrate가 필요할까?</h2>
<p>서버 사이드 렌더링을 사용하는 경우, 서버에서 데이터를 미리 받아와 화면을 렌더링합니다. 그러나 클라이언트는 이 상태를 알지 못하므로, React Query는 동일한 데이터를 클라이언트에서 다시 요청하게 됩니다. 이로 인해 불필요한 중복 요청과 느린 사용자 경험이 발생할 수 있습니다.</p>
<p><code>Hydrate</code>를 사용하면 서버에서 가져온 데이터를 클라이언트 React Query의 <code>QueryClient</code> 상태에 미리 주입할 수 있으므로, 다음과 같은 이점이 있습니다:</p>
<ul>
<li>✅ <strong>중복 요청 방지</strong>: 클라이언트에서 불필요하게 API를 다시 호출하지 않음</li>
<li>⚡ <strong>빠른 첫 렌더링</strong>: 사용자에게 더 빠른 페이지를 제공</li>
<li>🔄 <strong>일관된 상태 유지</strong>: SSR과 CSR 간의 상태 불일치 문제 해결</li>
</ul>
<hr>
<h2 id="🔍-hydrate의-동작-원리">🔍 Hydrate의 동작 원리</h2>
<p>Hydrate는 서버에서 React Query의 상태(QueryClient 내부 상태)를 <code>dehydrate()</code> 함수를 통해 JSON 직렬화 가능한 형태로 변환한 뒤, 클라이언트에서 <code>Hydrate</code> 컴포넌트를 통해 이를 <code>QueryClient</code>에 복원하는 방식으로 동작합니다.</p>
<p>이 과정을 요약하면 다음과 같습니다:</p>
<ol>
<li><strong>서버에서 QueryClient로 데이터를 prefetch</strong></li>
<li><strong><code>dehydrate</code> 함수로 직렬화</strong></li>
<li><strong>Next.js의 <code>getServerSideProps</code>나 <code>getStaticProps</code>를 통해 <code>props</code>로 전달</strong></li>
<li><strong>클라이언트에서 <code>Hydrate</code> 컴포넌트로 역직렬화 후 <code>QueryClient</code>에 주입</strong></li>
</ol>
<p>이렇게 주입된 초기 캐시는 클라이언트에서 <code>useQuery</code> 훅으로 접근 시 바로 사용되며, 같은 <code>queryKey</code>와 동일한 fetch 함수를 사용하는 경우 새로운 요청 없이 즉시 캐시 데이터를 보여줍니다.</p>
<hr>
<h2 id="🛠-예제-코드로-살펴보는-hydrate-활용법">🛠 예제 코드로 살펴보는 Hydrate 활용법</h2>
<h3 id="1-서버에서-queryclient-생성-및-데이터-prefetch">1. 서버에서 QueryClient 생성 및 데이터 prefetch</h3>
<pre><code class="language-tsx">// pages/index.tsx
import { dehydrate, QueryClient, useQuery } from &#39;@tanstack/react-query&#39;;
import { GetServerSideProps } from &#39;next&#39;;

const fetchData = async () =&gt; {
  const res = await fetch(&#39;https://api.example.com/data&#39;);
  return res.json();
};

export const getServerSideProps: GetServerSideProps = async () =&gt; {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery([&#39;data&#39;], fetchData);

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

export default function Home() {
  const { data, isLoading } = useQuery([&#39;data&#39;], fetchData);
  return &lt;div&gt;{isLoading ? &#39;Loading...&#39; : JSON.stringify(data)}&lt;/div&gt;;
}</code></pre>
<h3 id="2-_apptsx에서-hydrate로-상태-복원">2. <code>_app.tsx</code>에서 Hydrate로 상태 복원</h3>
<pre><code class="language-tsx">// pages/_app.tsx
import { Hydrate, QueryClient, QueryClientProvider } from &#39;@tanstack/react-query&#39;;
import { useState } from &#39;react&#39;;
import type { AppProps } from &#39;next/app&#39;;

export default function MyApp({ Component, pageProps }: AppProps) {
  const [queryClient] = useState(() =&gt; new QueryClient());

  return (
    &lt;QueryClientProvider client={queryClient}&gt;
      &lt;Hydrate state={pageProps.dehydratedState}&gt;
        &lt;Component {...pageProps} /&gt;
      &lt;/Hydrate&gt;
    &lt;/QueryClientProvider&gt;
  );
}</code></pre>
<hr>
<h2 id="⚠️-hydrate-사용-시-주의할-점">⚠️ Hydrate 사용 시 주의할 점</h2>
<ul>
<li><code>Hydrate</code>는 반드시 <code>QueryClientProvider</code> 내부에 위치해야 합니다.</li>
<li><code>dehydrate</code>로 직렬화된 데이터는 JSON으로 직렬화 가능한 값이어야 합니다.</li>
<li><code>queryKey</code>와 <code>fetch 함수의 구현</code>이 서버와 클라이언트에서 동일해야 캐시가 재활용됩니다.</li>
</ul>
<hr>
<h2 id="💡-응용-팁과-실전-활용">💡 응용 팁과 실전 활용</h2>
<ul>
<li><strong>정적 페이지 (SSG)에서도 활용 가능</strong>: <code>getStaticProps</code>와 함께 사용하여 정적 페이지도 빠르게 구성 가능</li>
<li><strong>다국어 지원 시 유용</strong>: 초기 번역 데이터를 서버에서 주입하면 클라이언트에서 로딩 없이 UI 구성 가능</li>
<li><strong>로그인 사용자 정보 프리패칭</strong>: 로그인한 사용자의 프로필 정보를 미리 캐시에 넣어둘 수 있음</li>
<li><strong><code>persistQueryClient</code>와의 차이</strong>: <code>Hydrate</code>는 SSR 데이터 복원에 사용되며, <code>persistQueryClient</code>는 로컬 스토리지 등을 통한 캐시 지속에 활용됨</li>
</ul>
<hr>
<h2 id="🧩-마무리-hydrate는-선택이-아닌-필수">🧩 마무리: Hydrate는 선택이 아닌 필수!</h2>
<p>React Query와 Next.js의 조합에서 <code>Hydrate</code>는 단순한 옵션이 아니라 꼭 활용해야 할 기능입니다. 서버에서 가져온 데이터를 클라이언트와 자연스럽게 이어주어 중복 요청을 방지하고, UX를 극대화하며, 성능까지 향상시켜주는 고마운 존재입니다.</p>
<p>Next.js 프로젝트에서 SSR을 적용하고 있다면, <code>Hydrate</code>를 반드시 고려해 보세요. 서버 상태 관리의 끝판왕으로, 더 빠르고 안정적인 웹 애플리케이션을 만들 수 있습니다.</p>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 베타 체험하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe?pli=1">크롬 웹스토어에서 설치하기</a></p>
<h2 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h2>
<p>서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[💡 아이디어만 있으면 돼? AI가 코딩해주는 '바이브 코딩'의 충격적인 현실!]]></title>
            <link>https://velog.io/@link_dropper/vibe-coding-service</link>
            <guid>https://velog.io/@link_dropper/vibe-coding-service</guid>
            <pubDate>Tue, 03 Jun 2025 15:28:57 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>안녕하세요, Link Dropper 팀의 개발자이자 늘 새로운 기술을 탐구하는 블로거입니다. 오늘은 제가 최근 흥미를 느끼고 있는 개념인 <strong>&#39;바이브 코딩(Vibe Coding)&#39;</strong>에 대해 이야기해보려 합니다. 코딩 없이 개발이라니, 정말 가능할까요? 이 글을 끝까지 읽으시면 그 궁금증이 해소될 겁니다!</p>
</blockquote>
<hr>
<h2 id="1-바이브-코딩이란">1. 바이브 코딩이란?</h2>
<h3 id="1-1-바이브-코딩-대체-무엇인가요">1-1. 바이브 코딩, 대체 무엇인가요?</h3>
<p><strong>바이브 코딩</strong>은 <strong>AI의 도움을 받아 최소한의 코드 작성 또는 아예 코드 작성 없이 아이디어의 &#39;바이브(Vibe)&#39;, 즉 느낌과 흐름을 즉시 구현해내는 개발 방식</strong>을 의미합니다. 기존의 개발 방식이 상세한 설계와 복잡한 코딩 과정을 거쳤다면, 바이브 코딩은 마치 디자이너가 스케치하듯 빠르게 아이디어를 구현하고, AI가 그 빈틈을 채워주며 완성도를 높이는 형태라고 할 수 있습니다.</p>
<p>이는 기존의 &#39;노코드(No-code)&#39; 또는 &#39;로우코드(Low-code)&#39; 플랫폼과는 조금 다릅니다. 노코드/로우코드가 미리 정의된 컴포넌트나 드래그 앤 드롭 방식을 통해 개발한다면, 바이브 코딩은 AI와의 상호작용을 통해 아이디어의 추상적인 부분까지 구체적인 결과물로 만들어내는 것에 가깝습니다. 단순히 기능 블록을 조립하는 것을 넘어, &quot;이런 느낌의 웹사이트를 만들고 싶어&quot;, &quot;사용자가 이런 경험을 하게 하고 싶어&quot;와 같은 추상적인 지시어를 AI가 해석하여 코드를 생성하거나 UI/UX를 제안하는 등 훨씬 더 유연하고 창의적인 결과물을 만들어낼 수 있습니다.</p>
<h4 id="1-2-왜-바이브-코딩을-배워야-할까요-비개발자-vs-개발자">1-2: 왜 바이브 코딩을 배워야 할까요? (비개발자 vs. 개발자)</h4>
<p>그렇다면 왜 우리는 바이브 코딩에 주목해야 할까요? 비개발자와 개발자 모두에게 엄청난 기회를 제공하기 때문입니다.</p>
<p><strong>👩‍💻 비개발자 관점: 내 아이디어를 코딩 없이 현실로!</strong></p>
<p>많은 비개발자분들이 번뜩이는 아이디어를 가지고도 &#39;코딩&#39;이라는 장벽 앞에서 좌절하곤 합니다. 외주를 주거나 개발자를 찾는 과정은 비용과 시간, 그리고 커뮤니케이션의 어려움이 따르죠. 하지만 바이브 코딩은 이러한 문제를 단숨에 해결해 줍니다. 복잡한 프로그래밍 언어를 배우지 않아도, 내가 생각하는 기능과 디자인의 &#39;느낌&#39;만으로도 MVP(Minimum Viable Product, 최소 기능 제품)를 빠르게 만들어 볼 수 있습니다.</p>
<p>예를 들어, &quot;사용자가 글을 쓰고, 좋아요를 누를 수 있는 간단한 커뮤니티 웹사이트를 만들어줘&quot;라고 지시하면, AI가 기본적인 구조와 필요한 코드를 생성해 주는 식입니다. 아이디어 구상부터 서비스 론칭까지의 시간을 획기적으로 단축시켜, 시장의 변화에 발 빠르게 대응하고 끊임없이 아이디어를 테스트해 볼 수 있는 강력한 무기가 됩니다. 비개발자도 이제 &#39;창작자&#39;이자 &#39;서비스 기획자&#39;로서 자신의 아이디어를 직접 구현할 수 있는 시대가 온 것입니다.</p>
<p><strong>👨‍💻 개발자 관점: 지루한 반복 작업은 AI에게! 핵심 로직에 집중하고, 디자인 스트레스는 그만!</strong></p>
<p>&quot;개발자인데 왜 코딩 없이 개발하는 방법을 배워야 해?&quot;라고 생각할 수도 있습니다. 하지만 바이브 코딩은 개발자에게도 혁신적인 도구입니다.</p>
<p>가장 큰 장점은 **&#39;디자인 및 프론트엔드 개발에 대한 부담 감소&#39;**입니다. 많은 개발자가 백엔드 로직이나 핵심 기능 구현에는 강하지만, 프론트엔드의 세부적인 디자인이나 CSS 작업에 많은 시간을 할애하며 스트레스를 받곤 합니다. 바이브 코딩을 활용하면, AI가 디자인 시스템이나 레이아웃 구성을 제안하고 기본적인 UI 컴포넌트들을 생성해 주기 때문에, 개발자는 번거로운 디자인 작업을 크게 신경 쓸 필요 없이 <strong>서비스의 핵심 로직과 복잡한 기능 구현에만 집중</strong>할 수 있습니다.</p>
<p>또한, 반복적이고 boilerplate(보일러플레이트) 코드를 작성하는 시간을 절약하고, 초기 프로토타이핑을 훨씬 빠르게 진행할 수 있습니다. 이는 개발 생산성을 극대화하고, 더욱 창의적이고 도전적인 개발 프로젝트에 몰두할 수 있는 여유를 제공합니다. 마치 강력한 조수가 옆에서 늘 대기하고 있는 것과 같죠.</p>
<hr>
<h2 id="2-바이브-코딩으로-만든-서비스-소개-상상-그-이상의-구현">2. 바이브 코딩으로 만든 서비스 소개: 상상 그 이상의 구현!</h2>
<p>바이브 코딩의 강력함을 증명하기 위해 제가 직접 만들어본 서비스들을 소개해 드립니다. 짧은 시간에 이렇게 다양한 기능들을 구현할 수 있다는 사실에 저 스스로도 놀랐습니다.</p>
<hr>
<h3 id="🚀-서비스-1-초간단-투두리스트-1시간-만에-뚝딱">🚀 서비스 1: 초간단 투두리스트 (1시간 만에 뚝딱!)</h3>
<hr>
<p>처음으로 바이브 코딩의 힘을 시험해 본 프로젝트는 바로 <strong>투두리스트</strong>였습니다. 간단한 기능이지만, 백엔드까지 직접 구축하며 풀스택 개발 프로세스를 경험하고 싶었습니다.</p>
<ul>
<li><strong>소요시간:</strong> 단 1시간!</li>
<li><strong>주요 특징:</strong><ul>
<li>간단한 투두리스트 기능 구현</li>
<li><strong>Firebase</strong>를 이용한 백엔드 구성으로 데이터 저장 및 관리</li>
<li><strong>AI가 스스로 &#39;개발자 도구&#39;를 만들다!</strong> : 개발 과정에서 몇 가지 오류가 발생했는데, AI에게 &quot;오류를 고쳐달라&quot;고 요청했더니, AI가 스스로 개발자 도구를 만들어 주었습니다. 이 도구에는 Firebase 연결 테스트, 환경 진단, 샘플 데이터 생성 등 디버깅에 필요한 핵심 기능들이 포함되어 있어 정말 신기했습니다. AI의 문제 해결 능력과 창의성에 감탄할 수밖에 없었습니다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/link_dropper/post/f7967986-dcd3-4e49-a389-bfc47b138114/image.png" alt=""></p>
<hr>
<h3 id="🚀-서비스-2-ai-아티클-커뮤니티-4시간-만에-완성">🚀 서비스 2: AI 아티클 커뮤니티 (4시간 만에 완성!)</h3>
<hr>
<p>다음으로는 조금 더 복잡한 기능을 가진 <strong>AI 아티클 커뮤니티</strong>를 만들어 보았습니다. 사용자 간의 상호작용이 필요한 서비스였는데, 바이브 코딩 덕분에 빠른 시간 안에 구현할 수 있었습니다.</p>
<ul>
<li><strong>소요시간:</strong> 4시간</li>
<li><strong>주요 특징:</strong><ul>
<li><strong>AI 아티클 생성 에디터:</strong> 사용자가 AI에 대한 아티클을 직접 작성하고 게시할 수 있는 에디터 기능 구현.</li>
<li><strong>좋아요 및 댓글 기능:</strong> 게시물에 대한 좋아요와 댓글을 달 수 있어 사용자 간의 소통 활성화.</li>
<li><strong>1대1 채팅 기능:</strong> 사용자 간에 개별적으로 대화할 수 있는 채팅 기능까지 구현했습니다.</li>
</ul>
</li>
<li><strong>비고:</strong> 복잡한 기능들임에도 불구하고 AI와의 협업을 통해 빠르게 핵심 기능을 구현할 수 있었습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/link_dropper/post/267c62a3-2b14-41ae-9d05-cc42db464e60/image.png" alt="">
<img src="https://velog.velcdn.com/images/link_dropper/post/af13b176-80ed-4ba2-80fe-9197108baf35/image.png" alt="">
<img src="https://velog.velcdn.com/images/link_dropper/post/fcbe1c4e-6a6d-4de0-ad8a-e03eae4c49ae/image.png" alt=""></p>
<hr>
<h3 id="🚀-서비스-3-프롬프트-저장소-8시간의-대장정-풀스택-완성">🚀 서비스 3: 프롬프트 저장소 (8시간의 대장정, 풀스택 완성!)</h3>
<hr>
<p>마지막으로 도전한 프로젝트는 가장 규모가 컸던 <strong>프롬프트 저장소</strong>였습니다. AI 시대에 프롬프트의 중요성이 커지는 만큼, 프롬프트 작성이 어렵거나 개인 프롬프트를 관리하고 싶은 사용자들에게 프롬프트 피드백을 주고 성능 테스트를 도와주는 서비스를 만들고 싶었습니다.</p>
<ul>
<li><strong>소요시간:</strong> 8시간 (풀스택 서비스임에도 놀라운 속도!)</li>
<li><strong>주요 특징:</strong><ul>
<li><strong>매력적인 랜딩 페이지:</strong> 서비스의 목적과 기능을 직관적으로 보여주는 랜딩 페이지 구축.</li>
<li><strong>프롬프트 작성 Form:</strong> 사용자가 다양한 카테고리의 프롬프트를 쉽게 작성하고 업로드할 수 있는 폼 제공.</li>
<li><strong>조회수 기록, 좋아요, Fork 기능:</strong> 인기 있는 프롬프트를 식별하고, 사용자가 다른 프롬프트를 자신에게 맞게 수정하고 저장할 수 있는 Fork 기능 구현.</li>
<li><strong>프롬프트 버전 관리:</strong> 프롬프트의 수정 이력을 추적하고, 이전 버전으로 되돌릴 수 있는 기능.</li>
<li><strong>프롬프트 분석 대시보드:</strong> 총 조회수, 총 좋아요 수 등 프롬프트의 성과를 차트 형태로 시각화하여 제공.</li>
<li><strong>프롬프트 커뮤니티:</strong> 사용자들이 프롬프트에 대한 의견을 나누고 정보를 공유하는 공간.</li>
<li><strong>프롬프트 엔지니어 토론장:</strong> 전문적인 프롬프트 엔지니어들이 심도 있는 토론을 펼칠 수 있는 장 마련.</li>
<li><strong>프롬프트 피드백</strong>: AI가 프롬프트를 피드백해주고 더 좋은 프롬프트를 제안. (구현 예정)</li>
<li><strong>프롬프트 성능 테스트</strong>: AI가 프롬프트의 성능을 테스트 (구현 예정)</li>
</ul>
</li>
<li><strong>비고:</strong> 이 프로젝트를 통해 바이브 코딩이 단순히 간단한 웹 페이지를 넘어, 복잡한 데이터 관리, 사용자 상호작용, 분석 대시보드까지 포함하는 <strong>종합적인 서비스 구현</strong>에도 탁월하다는 것을 깨달았습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/link_dropper/post/3e873730-439c-4b17-99a9-68e62d1c4c9e/image.png" alt="">
<img src="https://velog.velcdn.com/images/link_dropper/post/46614d26-86e1-419d-8526-e3f1c6bd13b4/image.png" alt="">
<img src="https://velog.velcdn.com/images/link_dropper/post/ea03cb3f-ce21-46af-a05e-0142b7fa2f6c/image.png" alt="">
<img src="https://velog.velcdn.com/images/link_dropper/post/6d74d0c1-c5a0-4068-9fcc-d331239cb890/image.png" alt=""></p>
<hr>
<h2 id="🌟-바이브-코딩-개발의-미래를-그리다">🌟 바이브 코딩, 개발의 미래를 그리다</h2>
<hr>
<p>바이브 코딩은 개발의 패러다임을 바꿀 강력한 도구라고 확신합니다. 이제 아이디어를 떠올리고, 그 아이디어를 즉시 구현해보는 것이 더 이상 &#39;개발자&#39;만의 특권이 아닙니다. 비개발자는 자신의 아이디어를 빠르게 검증하고 시장에 선보일 수 있으며, 개발자는 반복적인 작업에서 벗어나 더욱 창의적이고 핵심적인 문제 해결에 집중할 수 있게 됩니다.</p>
<p>물론, 바이브 코딩이 모든 것을 해결해 주지는 않을 겁니다. AI의 한계, 복잡한 커스터마이징의 어려움 등 넘어야 할 산은 분명히 있습니다. 하지만 중요한 것은, AI와의 협업을 통해 <strong>개발의 진입 장벽이 낮아지고, 아이디어의 구현 속도가 기하급수적으로 빨라진다</strong>는 사실입니다.</p>
<p>저는 앞으로도 바이브 코딩을 활용하여 더 다양한 서비스를 만들고, 그 경험과 노하우를 공유할 예정입니다. 여러분도 바이브 코딩으로 상상 속의 아이디어를 현실로 만들어보는 것은 어떨까요?</p>
<hr>
<blockquote>
<p>혹시 여러분만의 &#39;바이브 코딩&#39; 경험이나 아이디어가 있다면 댓글로 공유해 주세요! 만약 이 글의 반응이 좋다면, 다음 글에서는 제가 바이브 코딩을 활용한 구체적인 방법이나 꿀팁들을 소개해 드리겠습니다.</p>
</blockquote>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 베타 체험하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe">크롬 웹스토어에서 설치하기</a></p>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<ul>
<li>서비스 업데이트</li>
<li>기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!</li>
</ul>
<p>👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PR 올리면 AI가 혼내줍니다.. 직접 만든 이야기..]]></title>
            <link>https://velog.io/@link_dropper/pr-review-ai</link>
            <guid>https://velog.io/@link_dropper/pr-review-ai</guid>
            <pubDate>Wed, 28 May 2025 18:46:05 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/link_dropper/post/c325f686-1383-48f8-868c-7282cd6b23b9/image.png" alt=""></p>
<h2 id="시작하며">시작하며..!</h2>
<p>이전에 코드 리뷰라는 주제로 <a href="https://velog.io/@link_dropper/code-review-1">포스팅</a>을 한 적이 있었는데요,
저희는 실제로 두 명의 개발자가 서로 작성한 코드에 대해 리뷰를 주고받고 있습니다.</p>
<p>단순히 오류를 지적하는 것이 아니라, 작성자가 놓친 부분을 발견하거나 더 나은 코딩 방식을 제안하기 위한 목적이에요.
이를 통해 코드의 품질을 높이고, 서로의 성장까지 도모하기 위함이죠.</p>
<p>하지만 두 명만으로는 관점이 제한적일 수밖에 없고, 때로는 중요한 부분을 놓칠 수도 있습니다.
그래서 &quot;AI가 우리가 놓친 부분까지 자동으로 리뷰해준다면 어떨까?&quot; 라는 아이디어가 떠올랐습니다.</p>
<p>이번 포스팅에서는 직접 구현한, <strong>GitHub PR에 자동으로 AI가 리뷰 코멘트</strong>를 남기는 기능의 전체적인 흐름과 구현 과정을 간단히 소개해드릴게요!</p>
<hr>
<h2 id="🧩-어떤-기능인가요">🧩 어떤 기능인가요?</h2>
<p>간단히 말하면, 누군가 GitHub에 Pull Request를 올리면, AI가 코드의 변경사항을 읽고 자동으로 리뷰 코멘트를 작성해서 남겨주는 기능입니다.</p>
<p>&quot;어떤 부분이 개선될 수 있는지&quot;나, &quot;이건 왜 이렇게 했을까?&quot; 같은 리뷰가 자동으로 달립니다.
(실제로 어떤 종류의 리뷰를 작성할 지는, AI에게 어떻게 프롬프팅 하냐에 따라서 다르게 설정할 수 있어요!)</p>
<p>그럼 이 기능이 실제로 어떻게 동작하는지 하나씩 살펴볼게요.</p>
<hr>
<h2 id="🔁-전체-흐름-한눈에-보기">🔁 전체 흐름 한눈에 보기</h2>
<ol>
<li><p>누군가 특정 repo에 PR을 올린다</p>
</li>
<li><p>GitHub이 내 서버에 webhook 요청을 보낸다</p>
</li>
<li><p>PR 정보 확인 (내 서버)</p>
</li>
<li><p>변경된 코드 내용 가져오기 (내 서버)</p>
</li>
<li><p>AI로 코드 리뷰 생성 (내 서버)</p>
</li>
<li><p>다시 GitHub에 코멘트를 남긴다 🎯 (내 서버에서 -&gt; github api를 통해)</p>
</li>
</ol>
<h3 id="🪜-각-단계별로-조금만-더-자세히-알아볼게요">🪜 각 단계별로 조금만 더 자세히 알아볼게요!</h3>
<p><strong>1. GitHub Webhook 이벤트 받기</strong></p>
<p>GitHub에는 웹훅(webhook)이라는 기능이 있어요. 레포지토리에서 어떤 일이 발생했을 때 지정된 URL로 HTTP 요청을 보내주는 기능이죠.</p>
<p>(이 웹훅은 github에서 아래 과정에 따라 추가 가능해요!)</p>
<pre><code>** 원하는 레포지토리 &gt; Settings &gt; Webhooks &gt; Add webhook **</code></pre><p>PR이 생성되거나 업데이트되면
GitHub은 저희가 만든 서버의 특정 엔드포인트로 POST 요청을 보냅니다</p>
<p>요청에는 PR 번호, 레포지토리 정보, 파일 목록 등 다양한 정보가 포함돼요</p>
<p><strong>2. 요청의 진짜 여부 확인하기 🔐</strong></p>
<p>요청이 진짜 GitHub에서 온 건지 확인하는 게 중요해요.
그래서 HMAC-SHA256 방식으로 서명(signature)을 검증합니다.</p>
<p>GitHub는 요청과 함께 X-Hub-Signature-256 헤더를 보내줍니다</p>
<p>우리는 같은 방식으로 서명을 계산해서 비교합니다
일치하지 않으면 요청을 거부해요.</p>
<p><strong>3. PR 관련 이벤트만 처리</strong></p>
<p>GitHub에서 보내는 이벤트는 정말 다양해요. 그 중 우리는 PR 관련 이벤트만 관심이 있습니다.</p>
<p>PR이 &quot;열리거나(opened)&quot;, &quot;업데이트(synchronize)&quot;되었을 때만 처리합니다</p>
<p>그 외 이벤트는 무시하고 진행하지 않아요!</p>
<p><strong>4. PR 정보 뽑기</strong></p>
<p>이제 PR 번호와 레포지토리 정보를 꺼내야겠죠?</p>
<p>PR 번호 (pr_number)
레포지토리 이름 (repo_full_name)</p>
<p>이 정보는 나중에 GitHub API 호출에 꼭 필요합니다.</p>
<p><strong>5. 리뷰 체인 실행하기 ⚙️</strong></p>
<p>우선 모든 리뷰 과정을 담당하는 클래스를 하나 만들었어요. <strong>PRReviewChain</strong></p>
<p>review_chain = PRReviewChain(pr_number, repo_full_name)
review_chain.run()</p>
<p>이 클래스에 pr번호와, 레포지토리 이름을 전달하는 것으로 내부에 있는 다양한 과정들이 시작돼요!</p>
<p><strong>6. GitHub API로 PR 정보 가져오기</strong></p>
<p>GitHub의 <a href="https://docs.github.com/ko/rest?apiVersion=2022-11-28">공식 API</a>를 사용해서 PR에 어떤 변경이 있었는지 확인합니다.</p>
<p>어떤 파일이 수정됐는지 (get_pull_request_files)
전체 diff 내용은 어떤지 (get_pull_request_diff)
이때 필요한 인증은 GitHub Access Token을 사용해 처리합니다.</p>
<p><strong>7. LangChain + OpenAI로 리뷰 생성 🧠</strong></p>
<p>이제, AI가 리뷰를 생성합니다!</p>
<p>AI로 리뷰를 생성할 때, <strong>LangChain</strong>이라는 프레임워크를 사용했어요.
(LangChain은 GPT 같은 언어 모델을 다양한 도구와 쉽게 연결해주는 프레임워크에요.
단순히 프롬프트만 보내는 게 아니라, 외부 API 연결, 응답 가공, 히스토리 관리 등등 수많은 API들이 포함되어 있어요!)</p>
<p>아무튼, AI에게 저희가 사전에 설정한 프롬프트를 전달하게 돼요!
프롬프트에는 저희가 사용하는 코딩 컨벤션이나, 어떤 부분을 중점적으로 리뷰해야하는지 등 다양한 정보들을 같이 포함했어요!</p>
<p><strong>8. GitHub PR에 코멘트 달기</strong></p>
<p>이렇게 AI가 생성한 리뷰를 GitHub에 다시 올릴 차례!</p>
<p>전체 리뷰 → PR에 단일 코멘트로 추가
파일별 리뷰 → 각 파일 라인에 코멘트로 추가</p>
<p>이 작업도 GitHub API를 통해 처리됩니다.</p>
<p>이렇게 최종적으로, 실제 GitHub의 Pull-request에는 AI가 남긴 리뷰가 등록이 돼요!</p>
<hr>
<h2 id="🎨-그림-요약">🎨 그림 요약</h2>
<p>간단하게는 github과 제 서버, 그리고 LLM이 아래와 같은 느낌으로 상호작용하고 있어요!
<img src="https://velog.velcdn.com/images/taero30/post/3d51f83e-03cb-47a6-a4f3-495f73201586/image.png" alt=""></p>
<hr>
<h2 id="🙌-마무리하며-프롬프트가-핵심이다">🙌 마무리하며 (프롬프트가 핵심이다!)</h2>
<p>지금까지 소개해 드렸던 전체적인 흐름도 중요하지만, 사실 이 프로젝트의 <strong>가장 중요한 요소는 ‘프롬프트’</strong>에요.</p>
<p>어떤 질문을 어떻게 구성해서 AI에게 던질지에 따라, 리뷰의 품질이 완전히 달라지거든요.
예를 들어, “이 코드를 개선해줘”가 아니라, “이 코드에서 보안상 문제가 있는 부분을 알려줘”라고 하면 전혀 다른 리뷰가 나오게 돼요!</p>
<p>그래서 프롬프트를 어떻게 구성하느냐에 따라 리뷰 스타일, 깊이, 방향성을 원하는 대로 조정할 수 있어요.
앞으로 다양한 방식으로 프롬프트를 수정하며, 리뷰의 품질을 저희가 원하는 방향으로 개선해 볼 예정이에요!</p>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.</p>
<p>• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장</p>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 베타 체험하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe">크롬 웹스토어에서 설치하기</a></p>
<hr>
<h2 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h2>
<ul>
<li>서비스 업데이트</li>
<li>기능 꿀팁</li>
</ul>
<p>카카오톡 채널을 통해 빠르게 받아보세요!</p>
<p>👉 <a href="http://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🧠 Next.js에서 반드시 알아야 할 4가지 캐싱 전략]]></title>
            <link>https://velog.io/@link_dropper/nextjs-caching</link>
            <guid>https://velog.io/@link_dropper/nextjs-caching</guid>
            <pubDate>Fri, 23 May 2025 08:28:33 GMT</pubDate>
            <description><![CDATA[<h2 id="🏗-캐시-전략을-알아야-하는-이유">🏗 캐시 전략을 알아야 하는 이유</h2>
<p>Next.js는 <code>app router</code> 기반 프로젝트에서 기본적으로 fetch와 page rendering을 SSR(Server Side Rendering) 또는 SSG(Static Site Generation) 방식으로 처리합니다. 여기서 <strong>캐시 전략을 어떻게 쓰느냐에 따라 페이지의 성능, 사용자 경험, SEO가 좌우됩니다.</strong></p>
<hr>
<h2 id="🧩-캐싱-전략-①-request-cache-요청-캐시">🧩 캐싱 전략 ① Request Cache (요청 캐시)</h2>
<blockquote>
<p>동일한 fetch 요청은 한 번만 처리하고, 이후에는 결과를 재사용합니다.</p>
</blockquote>
<h3 id="✅-특징">✅ 특징</h3>
<ul>
<li>같은 fetch 요청이 여러 번 있어도, 한 번만 네트워크 요청이 발생</li>
<li>메모리 캐시 기반 (서버에서만 작동)</li>
<li><strong>서버리스 환경에서는 인스턴스 간 캐시 공유 안 됨</strong></li>
</ul>
<h3 id="✅-예시-코드">✅ 예시 코드</h3>
<pre><code class="language-tsx">const A = async () =&gt; await fetch(&#39;/api/workspace&#39;);
const B = async () =&gt; await fetch(&#39;/api/workspace&#39;); 
// 같은 요청, 한 번만 요청됨</code></pre>
<h3 id="❗️주의사항">❗️주의사항</h3>
<ul>
<li>URL, 쿼리, 헤더 등이 <strong>모두 일치</strong>해야 캐싱됩니다.</li>
<li>과도한 캐시는 메모리 문제를 유발할 수 있음</li>
</ul>
<h3 id="🔧-캐시-무효화-방법">🔧 캐시 무효화 방법</h3>
<pre><code class="language-tsx">await fetch(&#39;/api/data&#39;, {
  next: { cache: &#39;no-store&#39; }
});</code></pre>
<hr>
<h2 id="🧱-캐싱-전략-②-build-time-cache-빌드-타임-캐시">🧱 캐싱 전략 ② Build-time Cache (빌드 타임 캐시)</h2>
<blockquote>
<p><code>next build</code> 시점에 fetch를 실행하고, 정적으로 캐싱합니다.</p>
</blockquote>
<h3 id="✅-특징-1">✅ 특징</h3>
<ul>
<li>빌드 시 데이터가 고정</li>
<li>실제 배포 환경에서는 <code>auto</code> 캐시 정책이지만, 정적 fetch는 <code>force-cache</code>처럼 동작</li>
</ul>
<h3 id="😨-문제가-생기는-시나리오">😨 문제가 생기는 시나리오</h3>
<pre><code class="language-tsx">const TodoListPage = async () =&gt; {
  const todos = await getTodoList();
  return (
    &lt;ul&gt;
      {todos.map((todo, i) =&gt; &lt;li key={i}&gt;{todo}&lt;/li&gt;)}
    &lt;/ul&gt;
  );
}

const CreateTodoPage = () =&gt; {
  const onSaveTodo = async () =&gt; {
    await saveTodo();
    router.push(&#39;/todo-list&#39;);
  }

  return &lt;button onClick={onSaveTodo}&gt;생성&lt;/button&gt;;
}</code></pre>
<h3 id="🛠-해결-방법">🛠 해결 방법</h3>
<ol>
<li><code>no-store</code> 사용 (항상 최신 데이터)</li>
<li><code>revalidateTag</code>로 특정 캐시 무효화</li>
</ol>
<pre><code class="language-tsx">await fetch(&#39;/api/todos&#39;, {
  next: { tags: [&#39;todos&#39;] }
});

revalidateTag(&#39;todos&#39;);</code></pre>
<hr>
<h2 id="📦-캐싱-전략-③-page-cache-페이지-캐시">📦 캐싱 전략 ③ Page Cache (페이지 캐시)</h2>
<blockquote>
<p>유저가 방문한 페이지를 캐싱하여 다음 요청 시 빠르게 응답합니다.</p>
</blockquote>
<h3 id="✅-특징-2">✅ 특징</h3>
<ul>
<li>요청 시 SSR 결과를 저장</li>
<li>이후 같은 요청이 오면 캐시로 응답</li>
<li>ISR 기반으로 작동함</li>
</ul>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>Build-time Cache</th>
<th>Page Cache</th>
</tr>
</thead>
<tbody><tr>
<td>생성 시점</td>
<td>빌드 시</td>
<td>요청 시</td>
</tr>
<tr>
<td>갱신 시점</td>
<td>ISR 또는 재배포</td>
<td>조건에 따라 무효화 가능</td>
</tr>
<tr>
<td>사용 사례</td>
<td>마케팅 페이지</td>
<td>로그인 후 대시보드</td>
</tr>
</tbody></table>
<hr>
<h2 id="🚀-캐싱-전략-④-prefetch-cache-사전-로딩-캐시">🚀 캐싱 전략 ④ Prefetch Cache (사전 로딩 캐시)</h2>
<blockquote>
<p>유저가 아직 클릭하지 않은 링크에 대해 백그라운드에서 미리 데이터를 가져옵니다.</p>
</blockquote>
<h3 id="✅-작동-원리">✅ 작동 원리</h3>
<ul>
<li><code>&lt;Link&gt;</code>는 <strong>정적 경로에 대해 자동 prefetch</strong></li>
<li>Intersection Observer로 뷰포트 내에서만 작동</li>
<li>브라우저 캐시 활용</li>
</ul>
<pre><code class="language-tsx">&lt;Link href=&quot;/about&quot;&gt;About&lt;/Link&gt;
&lt;Link href=&quot;/about&quot; prefetch={false}&gt;About&lt;/Link&gt;</code></pre>
<hr>
<h2 id="💡-성능을-위해-캐시-전략을-더-잘-쓰는-팁">💡 성능을 위해 캐시 전략을 더 잘 쓰는 팁</h2>
<h3 id="1-fetch는-props-전달-대신-하위-컴포넌트에서-다시-호출">1. fetch는 props 전달 대신, 하위 컴포넌트에서 다시 호출</h3>
<pre><code class="language-tsx">const Parent = () =&gt; &lt;Child /&gt;
const Child = async () =&gt; {
  const data = await fetchData();
}</code></pre>
<hr>
<h3 id="2-client-component는-꼭-필요한-경우만-사용">2. Client Component는 꼭 필요한 경우만 사용</h3>
<pre><code class="language-tsx">const Page = () =&gt; &lt;ModalButton /&gt;</code></pre>
<hr>
<h3 id="3-서버에서-fetch-후-클라이언트에-전달">3. 서버에서 fetch 후 클라이언트에 전달</h3>
<pre><code class="language-tsx">const Server = async () =&gt; {
  const data = await fetchData();
  return &lt;Client data={data} /&gt;
}</code></pre>
<hr>
<h2 id="📚-참고-자료">📚 참고 자료</h2>
<ul>
<li>공식 문서: <a href="https://nextjs.org/docs/app/building-your-application/data-fetching/caching">Next.js Data Fetching and Caching</a></li>
</ul>
<hr>
<h2 id="🗂-words">🗂 Words</h2>
<table>
<thead>
<tr>
<th>단어</th>
<th>뜻</th>
<th>발음</th>
</tr>
</thead>
<tbody><tr>
<td>Cache</td>
<td>저장된 데이터</td>
<td>[kæʃ]</td>
</tr>
<tr>
<td>Prefetch</td>
<td>사전 로딩</td>
<td>[priːˈfetʃ]</td>
</tr>
<tr>
<td>Revalidate</td>
<td>재검증하다</td>
<td>[ˌriːˈvælɪdeɪt]</td>
</tr>
<tr>
<td>Tag</td>
<td>태그 (분류 단위)</td>
<td>[tæɡ]</td>
</tr>
<tr>
<td>Observer</td>
<td>관찰자</td>
<td>[əbˈzɜːrvər]</td>
</tr>
</tbody></table>
<hr>
<h2 id="📢-함께-보면-좋은-소식">📢 함께 보면 좋은 소식</h2>
<h3 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h3>
<blockquote>
<p>링크 드라퍼는 링크를 저장하고,
정리하고,
다시 꺼내보게 만들어주는 서비스입니다.</p>
</blockquote>
<ul>
<li>📎 폴더별 링크 정리</li>
<li>✨ OG 이미지 자동 추출</li>
<li>🧠 공유 URL 생성 및 게시 기능</li>
<li>⚡ 크롬 익스텐션 원클릭 저장</li>
</ul>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 베타 체험하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe">크롬 웹스토어에서 설치하기</a></p>
<hr>
<h3 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h3>
<ul>
<li>서비스 업데이트</li>
<li>기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!</li>
</ul>
<p>👉 <a href="https://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🧩 코드리뷰, 정말 꼭 해야 할까?]]></title>
            <link>https://velog.io/@link_dropper/code-review-1</link>
            <guid>https://velog.io/@link_dropper/code-review-1</guid>
            <pubDate>Tue, 20 May 2025 02:05:58 GMT</pubDate>
            <description><![CDATA[<h2 id="✍️-아직-코드리뷰-문화가-없는-팀이라면">✍️ 아직 코드리뷰 문화가 없는 팀이라면?</h2>
<p>스타트업, 사이드 프로젝트, 혹은 초기 단계의 조직에서는
<strong>코드리뷰를 하지 않아도 당장 눈에 띄는 문제는 없어 보입니다.</strong></p>
<ul>
<li>“리팩터링은 나중에 하지 뭐”</li>
<li>“어차피 혼자 짠 건데 뭐하러 리뷰해”</li>
<li>“시간 없는데 코드 보느라 시간을 낭비할 순 없어”</li>
</ul>
<p>하지만 코드는 &#39;현재&#39;만을 위한 것이 아닙니다.
<strong>2주 후, 2달 후, 또는 다른 사람이 봤을 때</strong>
잘 읽히고, 안전하고, 유지보수 가능한 코드여야 합니다.</p>
<p><strong>코드리뷰는 바로 그 연결고리 역할을 합니다.</strong></p>
<hr>
<h2 id="✅-코드리뷰가-가져다주는-진짜-효과">✅ 코드리뷰가 가져다주는 진짜 효과</h2>
<h3 id="1-❗-버그를-미리-발견할-수-있습니다">1. ❗ 버그를 미리 발견할 수 있습니다</h3>
<p>코드리뷰는 실시간 테스트 도구입니다.
의도하지 않은 side effect나 누락된 예외 처리를 빠르게 포착할 수 있습니다.</p>
<h3 id="2-📘-코드-스타일과-구조가-통일됩니다">2. 📘 코드 스타일과 구조가 통일됩니다</h3>
<p>비슷한 기능을 전혀 다른 방식으로 구현하면 기술 부채로 이어집니다.
리뷰를 통해 컨벤션을 지키고 통일성을 유지할 수 있습니다.</p>
<h3 id="3-🧠-비즈니스-로직-이해도와-팀-전파력이-올라갑니다">3. 🧠 비즈니스 로직 이해도와 팀 전파력이 올라갑니다</h3>
<p>“이 기능 왜 이렇게 만들었지?”를 코드 안에서 유추하는 것이 아니라,
리뷰 과정을 통해 명시적이고 문맥적으로 전달할 수 있습니다.</p>
<h3 id="4-🙌-학습의-기회이자-성장의-루틴이-됩니다">4. 🙌 학습의 기회이자 성장의 루틴이 됩니다</h3>
<p>리뷰는 단순 피드백이 아니라 지식 교류입니다.
주니어는 시니어의 관점을, 시니어는 주니어의 시도를 배울 수 있습니다.</p>
<blockquote>
<p>은근히 많이 듣는 이야기:
“리뷰하다가 내가 더 배웠어요.”</p>
</blockquote>
<h3 id="5-🚀-리뷰는-곧-제품-품질입니다">5. 🚀 리뷰는 곧 제품 품질입니다</h3>
<p>성공적인 코드리뷰 문화는 곧 <strong>서비스 안정성</strong>으로 이어집니다.</p>
<hr>
<h2 id="❌-그렇다면-코드리뷰의-어려움은">❌ 그렇다면, 코드리뷰의 어려움은?</h2>
<ul>
<li>리뷰하는 데 시간이 걸립니다</li>
<li>코드 문맥을 모르면 피드백이 어렵습니다</li>
<li>감정 섞인 표현은 의도치 않게 상처가 될 수 있습니다</li>
</ul>
<p>하지만 이건 도구와 문화로 극복할 수 있습니다:</p>
<ul>
<li>리뷰 기준을 명확히 하고</li>
<li>PR 템플릿을 도입하고</li>
<li>피드백의 목적이 &#39;사람&#39;이 아니라 &#39;코드&#39;라는 점을 잊지 않는 것</li>
</ul>
<hr>
<h2 id="🤝-리뷰를-지적이-아닌-제안으로-바꾸는-법">🤝 리뷰를 ‘지적’이 아닌 ‘제안’으로 바꾸는 법</h2>
<p>리뷰어의 말 한 마디가 조직의 분위기를 바꿉니다.</p>
<p>❌ “이건 잘못됐어요.”
✅ “혹시 이런 방식은 어떨까요?”</p>
<p>❌ “이거 왜 이렇게 짰나요?”
✅ “이렇게 구현하신 이유가 궁금합니다!”</p>
<p>❌ “이 함수는 너무 복잡해요.”
✅ “한 번 분리해보면 가독성이 더 좋아질 수 있을 것 같아요!”</p>
<hr>
<h2 id="😌-리뷰를-받는-입장에서-기분-나쁘지-않게-하려면">😌 리뷰를 받는 입장에서 기분 나쁘지 않게 하려면?</h2>
<ul>
<li>🙏 “질문드릴게요” → 상대에 대한 존중 표현</li>
<li>🙇‍♂️ “제가 잘 몰라서 여쭤봅니다” → 대화의 시작을 부드럽게</li>
<li>🤝 “이런 방향도 고려해보면 좋을 것 같아요” → 피드백보다는 협업 제안</li>
</ul>
<hr>
<h2 id="📚-코드리뷰를-배움으로-바꾸는-팀-문화-만들기">📚 코드리뷰를 &#39;배움&#39;으로 바꾸는 팀 문화 만들기</h2>
<ul>
<li>코멘트가 마음에 안 들어도 일단 ‘왜 그렇게 말했을까?’ 생각하기</li>
<li>리뷰어도 반대로 리뷰받아보기 → ‘양방향 문화’ 조성</li>
<li>리뷰 요청 시: <strong>“특히 어떤 부분이 궁금한지”</strong> 적어두기</li>
<li>리뷰 후: <strong>칭찬도 같이 남기기!</strong> (예: 네이밍 좋네요, 리팩터링 방향 좋습니다 등)</li>
</ul>
<hr>
<h2 id="🏷️-pn-룰-가이드">🏷️ Pn 룰 가이드</h2>
<p>**Pn 룰(Priority Notation)**은 리뷰 코멘트를 <strong>우선순위 기반</strong>으로 분류하여, 리뷰어와 개발자 간의 커뮤니케이션을 더 명확하게 해주는 방법입니다.</p>
<table>
<thead>
<tr>
<th><strong>등급</strong></th>
<th><strong>의미</strong></th>
<th><strong>행동 지침</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>P1</strong></td>
<td>꼭 반영해주세요</td>
<td>🔴 <code>Request changes</code> — 서비스에 중대한 영향을 주는 오류 가능성. 반드시 반영하거나 리뷰어를 설득해야 합니다.</td>
</tr>
<tr>
<td><strong>P2</strong></td>
<td>적극적으로 고려해주세요</td>
<td>🔴 <code>Request changes</code> — 필수는 아니지만 중요. 토론과 합의가 권장됩니다.</td>
</tr>
<tr>
<td><strong>P3</strong></td>
<td>웬만하면 반영해주세요</td>
<td>🟡 <code>Comment</code> — 수용 or 반영하지 못하는 이유를 설명하거나 추후 반영 계획을 명시하세요.</td>
</tr>
<tr>
<td><strong>P4</strong></td>
<td>반영해도 좋고 넘어가도 괜찮습니다</td>
<td>🟢 <code>Approve</code> — 무시해도 괜찮지만, 고민해볼 가치는 있습니다.</td>
</tr>
<tr>
<td><strong>P5</strong></td>
<td>그냥 사소한 의견입니다</td>
<td>🟢 <code>Approve</code> — 반영해도 좋고 무시해도 괜찮습니다.</td>
</tr>
</tbody></table>
<p>💬 예시 코멘트</p>
<ul>
<li><strong>P1</strong>: <code>useEffect</code> 안에 비동기 함수가 직접 들어가 있어서 에러가 발생할 수 있어요. 반드시 수정해주세요. (P1)</li>
<li><strong>P2</strong>: 이 API 호출은 debounce 처리하는 게 UX 측면에서 좋을 것 같아요. (P2)</li>
<li><strong>P3</strong>: 변수명을 조금 더 명확하게 해도 좋을 것 같아요. 예: <code>result</code> → <code>userScore</code> (P3)</li>
<li><strong>P4</strong>: 코드 스타일은 팀 내 규칙에 따라 정리하셔도 괜찮습니다. (P4)</li>
<li><strong>P5</strong>: 이 부분 개인적으로 너무 좋네요. 👍 (P5)</li>
</ul>
<hr>
<h3 id="✍️-코드리뷰를-요청하는-사람은-어떻게-해야-할까">✍️ 코드리뷰를 요청하는 사람은 어떻게 해야 할까?</h3>
<h4 id="✅-커밋을-논리적으로-잘게-나누기">✅ 커밋을 논리적으로 잘게 나누기</h4>
<ul>
<li>기능 단위로 나누기: <code>feat: 로그인 폼 레이아웃 추가</code>, <code>feat: 로그인 요청 API 연동</code></li>
<li>수정 단위로 나누기: <code>fix: 로그인 상태관리 오류 수정</code></li>
</ul>
<h4 id="✅-커밋-메시지를-명확하게-작성하기">✅ 커밋 메시지를 명확하게 작성하기</h4>
<ul>
<li>❌ 불명확한 메시지: <code>fix</code>, <code>update</code></li>
<li>✅ 명확한 메시지: <code>fix: 로그인 API 요청 시 중복 호출 방지</code></li>
</ul>
<h4 id="✅-pr-설명을-꼼꼼하게-작성하기">✅ PR 설명을 꼼꼼하게 작성하기</h4>
<pre><code class="language-md">## 작업 개요
- 로그인 기능 중 상태 관리 구조를 개선했습니다.

## 주요 변경 사항
- Zustand → Context API 마이그레이션
- API 요청 로직을 커스텀 훅으로 분리

## 리뷰 받고 싶은 부분
- 상태 구조가 적절한지
- 요청 훅 분리 방식에 개선 여지가 있는지</code></pre>
<hr>
<h2 id="🧪-링크-드라퍼-지금-베타-테스트-중입니다">🧪 링크 드라퍼, 지금 베타 테스트 중입니다</h2>
<p>링크 드라퍼는 링크를 저장하고,
정리하고,
다시 꺼내보게 만들어주는 서비스입니다.</p>
<ul>
<li>📎 폴더별 링크 정리</li>
<li>✨ OG 이미지 자동 추출</li>
<li>🧠 공유 URL 생성 및 게시 기능</li>
<li>⚡ 크롬 익스텐션 원클릭 저장</li>
</ul>
<p>👉 <a href="https://link-dropper.com">링크 드라퍼 베타 체험하러 가기</a>
👉 <a href="https://chromewebstore.google.com/detail/link-dropper/fpapogjaaknahejimbikfpkkdjdnfgoe">크롬 웹스토어에서 설치하기</a></p>
<hr>
<h2 id="💬-카카오톡-채널-추가하고-소식-받기">💬 카카오톡 채널 추가하고 소식 받기</h2>
<ul>
<li>서비스 업데이트</li>
<li>기능 꿀팁</li>
</ul>
<p>카카오톡 채널을 통해 빠르게 받아보세요!</p>
<p>👉 <a href="http://pf.kakao.com/_ZNCBn">카카오톡 채널 추가하기</a></p>
]]></description>
        </item>
    </channel>
</rss>