<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jeong_eeeun.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 30 Apr 2026 07:18:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jeong_eeeun.log</title>
            <url>https://images.velog.io/images/jeong_eeeun/profile/1f32e785-cb55-4299-a881-25fa9f5a663f/social.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jeong_eeeun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jeong_eeeun" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[AI가 실패할 때마다 돈이 두 배로 나갔다]]></title>
            <link>https://velog.io/@jeong_eeeun/AI%EA%B0%80-%EC%8B%A4%ED%8C%A8%ED%95%A0-%EB%95%8C%EB%A7%88%EB%8B%A4-%EB%8F%88%EC%9D%B4-%EB%91%90-%EB%B0%B0%EB%A1%9C-%EB%82%98%EA%B0%94%EB%8B%A4</link>
            <guid>https://velog.io/@jeong_eeeun/AI%EA%B0%80-%EC%8B%A4%ED%8C%A8%ED%95%A0-%EB%95%8C%EB%A7%88%EB%8B%A4-%EB%8F%88%EC%9D%B4-%EB%91%90-%EB%B0%B0%EB%A1%9C-%EB%82%98%EA%B0%94%EB%8B%A4</guid>
            <pubDate>Thu, 30 Apr 2026 07:18:07 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>출퇴근 중에 떠오르는 개발 아이디어를 즉시 구현까지 이어지게 하고 싶었다. Ghost Dev라는 프로젝트를 만들어서, 아이디어가 생기면 티켓을 생성하고 Claude API가 자동으로 기능을 구현해주는 흐름을 만들었다.</p>
<p>그런데 실행할 때마다 돈이 나간다. 토큰을 줄이는 게 최우선 과제가 됐다.</p>
<hr>
<h2 id="문제-분석-input-토큰이-왜-이렇게-많지">문제 분석: input 토큰이 왜 이렇게 많지?</h2>
<p>Claude API의 토큰은 input과 output으로 나뉜다. 모니터링을 해보니 특히 <strong>input 토큰</strong>이 많이 차지하고 있었다.
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/103f472e-4aac-464a-b64a-298af42a0bb3/image.png" alt=""></p>
<p>원인은 두 가지였다.</p>
<h3 id="1-누적되는-대화-히스토리">1. 누적되는 대화 히스토리</h3>
<p>Vercel AI SDK에서 하나의 LLM 호출 사이클을 <strong>step</strong>이라고 한다.</p>
<pre><code>LLM 호출 → 응답 생성 → 툴 호출 → 툴 결과 수신 → LLM 재호출</code></pre><p>AI는 이전 작업을 기억하지 못하기 때문에, 매 step마다 이전의 모든 메시지(tool call + tool result 포함)를 다음 input에 담아서 넘겨줘야 한다. step이 쌓일수록 input 토큰이 선형적으로 늘어나는 구조다.</p>
<h3 id="2-파일-읽기-결과의-누적">2. 파일 읽기 결과의 누적</h3>
<p>디렉토리 조회나 파일 읽기 결과가 길 경우, 이것도 전부 히스토리에 쌓인 채로 다음 step에 전달된다.</p>
<hr>
<h2 id="해결-방법-1-병렬-파일-읽기">해결 방법 1: 병렬 파일 읽기</h2>
<p>파일을 순차적으로 읽는 대신, 병렬로 읽어서 결과를 합치는 방식을 생각했다.</p>
<p><strong>한계:</strong> A 파일을 읽어야 B 파일의 경로를 알 수 있는 경우엔 병렬화가 불가능하다. import 관계나 설정 파일 참조처럼 의존성이 있는 경우가 생각보다 많았다. 병렬화는 의존성이 없는 파일에만 적용할 수 있어서, 근본적인 해결책이 되지 못했다.</p>
<hr>
<h2 id="해결-방법-2-이전-메시지-압축">해결 방법 2: 이전 메시지 압축</h2>
<p>step이 쌓이면 오래된 메시지를 압축해서 다음 step으로 넘기는 방식이다.</p>
<p>처음엔 <em>압축하면 품질이 떨어지지 않을까?</em> 걱정했는데, 여러 실험 결과 <strong>step이 10개를 넘어간 시점부터 압축을 적용하면 품질 영향이 최소화</strong>된다는 걸 확인했다.</p>
<p>압축에는 Sonnet 대신 <strong>Haiku 모델</strong>을 사용해서 메시지를 요약하도록 했다. 이렇게 하면 요약 자체에 드는 비용도 낮출 수 있다.</p>
<hr>
<h2 id="또-다른-문제-실패하면-처음부터-다시">또 다른 문제: 실패하면 처음부터 다시?</h2>
<p>위 방법으로 실험하던 중 새로운 문제를 발견했다. 특정 상황에서 티켓 실행이 중간에 실패하는 경우가 있었는데, 재실행하면 <strong>처음부터 다시 시작</strong>되는 것이었다.</p>
<p>예를 들어 100만 토큰을 쓴 뒤 실패했다면, 재실행 시 다시 100만 토큰을 소비하게 된다. 토큰은 곧 돈이다.</p>
<hr>
<h2 id="해결-방법-3-checkpoint--임시-브랜치">해결 방법 3: Checkpoint + 임시 브랜치</h2>
<p>두 가지를 조합해서 해결했다.</p>
<h3 id="checkpoint-저장">Checkpoint 저장</h3>
<p>각 step이 끝날 때마다 현재 메시지 상태를 DB에 저장한다. 재실행 시 처음부터 시작하지 않고, 마지막 checkpoint부터 이어서 진행할 수 있다.</p>
<h3 id="임시-브랜치-활용">임시 브랜치 활용</h3>
<p>파일을 수정하다가 에러가 발생할 수도 있다. 수정된 파일 상태도 보존해야 재개가 가능하기 때문에, ticketId 기반의 임시 브랜치를 만들어서 변경 사항을 커밋해둔다. 재실행 시 해당 브랜치가 존재하면 그 브랜치로 이동해 작업을 이어간다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>해결책</th>
</tr>
</thead>
<tbody><tr>
<td>step마다 히스토리 누적</td>
<td>Haiku로 오래된 메시지 압축</td>
</tr>
<tr>
<td>파일 읽기 비용</td>
<td>의존성 없는 파일은 병렬 읽기</td>
</tr>
<tr>
<td>실패 시 처음부터 재시작</td>
<td>DB checkpoint + 임시 브랜치 저장</td>
</tr>
</tbody></table>
<p>정확히 같은 조건의 비교는 아니지만, 비슷한 난이도의 작업에서 이 정도 차이가 난다면 충분히 의미 있는 결과라고 본다.</p>
<h3 id="before--after">before / after</h3>
<p><img src="https://velog.velcdn.com/images/jeong_eeeun/post/4b9d2015-586c-4eeb-9f18-4680f4c0c7e5/image.png" alt="">↑ input 토큰 19만개
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/b5b85c9a-299d-411d-a8a5-bd3a315423ea/image.png" alt="">↑ input 토큰 3만개</p>
<p>결국 토큰 최적화는 단순히 &quot;덜 보내기&quot;가 아니라, <strong>언제 압축하고 언제 재개할지</strong>를 설계하는 문제였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Trouble shooting] Error: We are cleaning up async info that was not on the parent Suspense boundary.]]></title>
            <link>https://velog.io/@jeong_eeeun/Trouble-shooting-Error-We-are-cleaning-up-async-info-that-was-not-on-the-parent-Suspense-boundary</link>
            <guid>https://velog.io/@jeong_eeeun/Trouble-shooting-Error-We-are-cleaning-up-async-info-that-was-not-on-the-parent-Suspense-boundary</guid>
            <pubDate>Mon, 27 Apr 2026 08:00:21 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>React instrumentation encountered an error: Error: We are cleaning up async info that was not on the parent Suspense boundary. This is a bug in React.</p>
</blockquote>
<p>난생 처음보는 에러를 발견했다. 
서비스가 다운되는 에러도 아니여서 큰 지장은 없었지만 콘솔로그에 에러가 찍히는게 불편했다.
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/a3c2d58b-d82b-43ba-82e6-bc0a7a1f6d97/image.png" alt=""></p>
<h3 id="원인">원인</h3>
<p>알고보니 리액트 서버 컴포넌트를 사용했을때 발생하는 문제였다. 
해당 프로젝트에서 Next.js를 사용하고 있어 모든 컴포넌트가 서버 컴포넌트로 동작하고 있었다. 
그 중 데이터 패칭을 하는 page.tsx는 컴포넌트 앞에 async를 붙여 비동기로 처리하도록 했다.</p>
<p>이렇게 비동기로 처리했을때 문제점이 있었는데 </p>
<ol>
<li>데이터 패칭을 위해 async를 하다 보니 데이터를 다 받아오기 전까지 React는 렌더링을 일시중단한다. </li>
<li>렌더링이 일시 중지 되면 리액트에선 fallback UI를 찾기 위해 상위 트리를 올라가며 찾는다. </li>
<li>이때 Suspense로 해당 컴포넌트를 감싸지 않아 루트까지 갔을때 적절한 fallback UI를 찾지 못한다면, 리액트 내부 엔진에서 충돌이 발생하여 에러가 발생하게 된다. </li>
</ol>
<h3 id="해결-방안">해결 방안</h3>
<p>그러면 Suspense로 해당 컴포넌트를 감싸주면 된다. 
Next.js 같은 경우는 두가지 방법이 있다. </p>
<ol>
<li>loading.tsx 파일을 생성하여 Next.js 내부적으로 page를 <strong>Suspense</strong>로 감싸도록 한다. </li>
<li>만약 loading.tsx을 만들지 않았다고 하면 layout.tsx에서 children을 <strong>Suspense</strong>로 감싸도록 한다. </li>
</ol>
<h3 id="결과">결과</h3>
<p>콘솔에 찍히던 보기 불편한 에러가 사라졌다. 
그리고 리액트 서버 컴포넌트가 내부적으로 비동기 상태일때의 동작을 일부 알게 되었다. 
또한 loading.tsx를 설정하면서 데이터가 로드되는 동안 정의한 로딩 UI가 정상적으로 노출되어 사용자 경험을 향상 시켰다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI를 활용한 Velog 썸네일 자동 생성기 제작기]]></title>
            <link>https://velog.io/@jeong_eeeun/AI%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-Velog-%EC%8D%B8%EB%84%A4%EC%9D%BC-%EC%9E%90%EB%8F%99-%EC%83%9D%EC%84%B1%EA%B8%B0-%EC%A0%9C%EC%9E%91%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_eeeun/AI%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-Velog-%EC%8D%B8%EB%84%A4%EC%9D%BC-%EC%9E%90%EB%8F%99-%EC%83%9D%EC%84%B1%EA%B8%B0-%EC%A0%9C%EC%9E%91%EA%B8%B0</guid>
            <pubDate>Sun, 19 Apr 2026 15:07:28 GMT</pubDate>
            <description><![CDATA[<h1 id="1-개요-왜-만들게-되었는가">1. 개요: 왜 만들게 되었는가?</h1>
<p>요즘 Velog에 꾸준히 게시글을 올리려고 노력 중이다. 하지만 그동안 썸네일 없이 글을 올리다 보니, 나중에 목록을 확인할 때 해당 글이 어떤 내용을 담고 있는지 시각적으로 한눈에 들어오지 않았다. </p>
<p>AI시대이니 이를 활용해 게시글의 내용을 요약하고 썸네일까지 자동으로 생성해 주는 도구가 있다면 편리하겠다는 생각이 들었다. 그래서 크롬 확장 프로그램 형태로 직접 제작해 보았다.</p>
<h2 id="2-사용-방법">2. 사용 방법</h2>
<p>현재는 개인적인 용도로만 사용하기 위해 크롬 웹 스토어에 출시하지 않고, 로컬에서 확장 프로그램을 직접 로드하여 사용 중이다. 사용자의 API Key를 다루는 만큼 보안상의 이유로 스토어 등록은 지양했다.</p>
<p>확장 프로그램을 등록한 뒤, 글을 작성하는 중간에 썸네일 생성기를 실행한다. 비용 부담을 줄이기 위해 무료 모델인 <strong>Gemini AI API Key</strong>를 사용하도록 구성했다.
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/bdff6952-8205-45b2-8447-560f4236c51e/image.png" alt="">
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/70b8217f-32e3-4714-8431-c0502f52eb7a/image.png" alt=""></p>
<p>사용자가 직접 API Key를 등록하고 &#39;요약하기&#39; 버튼을 누르면 본문 내용을 바탕으로 요약 및 썸네일 생성이 진행된다.
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/cfd40503-9b11-4c83-8d1f-aa7f908ed8b5/image.png" alt=""></p>
<hr>
<h2 id="3-보안-및-데이터-저장-방식">3. 보안 및 데이터 저장 방식</h2>
<p>API Key 저장에는 <code>chrome.storage.local</code>(Chrome 확장 프로그램 전용 로컬 스토리지)을 활용했다. 브라우저의 일반적인 <code>localStorage</code>와 사용법이 유사하며, 다음과 같은 방식으로 데이터를 가져온다.</p>
<pre><code class="language-jsx">chrome.storage.local.get(&#39;geminiApiKey&#39;, ({ geminiApiKey }) =&gt; { 
  // API Key를 활용하여 Gemini API 호출
  console.log(&#39;Key retrieved&#39;);
});</code></pre>
<h3 id="보안성-평가">보안성 평가</h3>
<p>현재 방식은 다음과 같은 <strong>장점</strong>이 있다.</p>
<ul>
<li>소스 코드 내에 API Key가 하드코딩되지 않는다.</li>
<li><code>type=&quot;password&quot;</code> 필드를 사용하여 입력 시 노출을 방지한다.</li>
<li>웹 페이지에서 접근할 수 없으며 오직 해당 확장 프로그램만 접근 가능하다.</li>
</ul>
<p>반면, 다음과 같은 <strong>취약점</strong>도 존재한다.</p>
<ul>
<li><code>chrome.storage.local</code>은 암호화되지 않은 평문으로 저장된다.</li>
<li>누군가 내 컴퓨터에 직접 접근하여 파일 시스템을 뒤지면 키 추출이 가능하다.</li>
<li>DevTools의 Application 탭에서 평문으로 확인이 가능하다.</li>
</ul>
<p><strong>결론적으로</strong>, 개인 사용 목적이라면 현재의 방식도 일반적인 관행 수준에서는 충분히 안전하다. 다만 보안성을 더 높이려면 브라우저 종료 시 데이터가 삭제되는 <code>chrome.storage.session</code>을 사용하거나, 매번 입력을 받는 방식을 고려해 볼 수 있다.</p>
<hr>
<h2 id="4-보완한-점-작성-중인-글-추출하기">4. 보완한 점: 작성 중인 글 추출하기</h2>
<p>기존에는 이미 발행된 게시글의 <code>og</code> 메타태그를 활용해 제목과 설명을 가져왔다. 하지만 썸네일은 글을 올리기 직전, 즉 <strong>작성 단계</strong>에서 필요한 경우가 많다. 작성 중에는 메타태그가 존재하지 않으므로 기존 방식으로는 내용을 읽어올 수 없었다.</p>
<p>Velog 에디터의 DOM 구조를 분석한 결과, <code>CodeMirror-code</code> 클래스 하위의 <code>pre</code> 태그들이 본문 내용을 담고 있음을 확인했다. 이를 활용해 실시간으로 텍스트를 수집하고 요약 정보를 뽑아내도록 로직을 개선했다.</p>
<pre><code class="language-jsx">const cmCodeEl = document.querySelectorAll(&#39;.CodeMirror-code&#39;);
const lines = Array.from(cmCodeEl.querySelectorAll(&#39;pre&#39;))
  .map((pre) =&gt; pre.innerText)
  .join(&#39;\n&#39;);
data.bodyText = lines.replace(/\s+/g, &#39; &#39;).trim();</code></pre>
<p>이 방식을 통해 글을 작성하는 도중에도 현재까지 작성된 내용을 바탕으로 최적의 썸네일을 생성할 수 있게 되었다.</p>
<hr>
<h2 id="5-마치며">5. 마치며</h2>
<p>게시글 업데이트 이후뿐만 아니라, 글을 작성하는 시점에도 AI를 통해 썸네일을 생성할 수 있게 되어 작업 효율이 크게 좋아졌다.</p>
<p>단순히 도구를 만드는 것에 그치지 않고, 개발자로서 겪는 사소한 불편함을 직접 코드로 해결하며 보안과 DOM 분석 등 기술적인 고민을 해볼 수 있었던 유익한 프로젝트였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude code skill: tiki-taka로 AI 개발 워크플로우 개선하기 ]]></title>
            <link>https://velog.io/@jeong_eeeun/Claude-code-skill-tiki-taka%EB%A1%9C-AI-%EA%B0%9C%EB%B0%9C-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_eeeun/Claude-code-skill-tiki-taka%EB%A1%9C-AI-%EA%B0%9C%EB%B0%9C-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 14 Apr 2026 06:07:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_eeeun/post/0e06ba53-72ac-4981-8bc8-fc82a019a764/image.png" alt=""></p>
<h2 id="ai가-코드를-작성할-때의-문제점">AI가 코드를 작성할 때의 문제점</h2>
<p>Claude와 같은 AI가 코드 작업을 할 때, 몇 가지 문제가 반복됐다.</p>
<p>설계 없이 바로 구현에 들어가면 아키텍처 문제나 엣지 케이스를 놓치기 쉽고, 설계 결함은 구현 중간에 발견될수록 수정 비용이 기하급수적으로 늘어났다. 
기분 탓인지 모르겠지만, 한 AI만 가지고 진행하면 blind spot이 남는다는 것도 문제였다. 거기다 복잡한 설계 결정과 단순한 코드 작성을 전부 고성능 모델로 처리하면 비용이 너무 높았다.</p>
<p>이 문제를 해결하기 위해 pin-plate 프로젝트에서 두 가지 커스텀 Claude Code 스킬을 개발했다: <strong>tiki-taka</strong>와 <strong>opus-sonnet</strong>.
일단 이 게시글에선 tiki-taka에 대한 설명만 할 예정이다.</p>
<hr>
<h2 id="tiki-taka-설계-리뷰-자동화">Tiki-taka: 설계 리뷰 자동화</h2>
<h3 id="왜-만들었나">왜 만들었나</h3>
<p>복잡한 기능을 개발할 때, AI가 혼자 설계·구현하면 아키텍처 문제를 놓치는 경우가 많았다. 코드를 다 짜고 나서 발견하면 또 토큰 소모를 해서 수정해야하고 작성된 코드가 병목이 될 수도 있다고 생각했다. <strong>설계 단계에서 미리 잡자</strong>는 게 목적이다.</p>
<p>특히 이런 상황에서 효과적이다:</p>
<ul>
<li>변경해야 할 파일이 <strong>6개 이상</strong>인 작업</li>
<li><strong>새로운 기능</strong> 추가 (기존 코드 탐사가 필요한 경우)</li>
<li><strong>아키텍처 변경</strong> (여러 계층에 영향이 가는 경우)</li>
<li><strong>복잡한 상태 관리</strong> (여러 스토어·쿼리가 얽혀 있는 경우)</li>
</ul>
<hr>
<h3 id="동작-방식">동작 방식</h3>
<p>tiki-taka는 <strong>설계 → 리뷰 → 구현</strong> 3단계로 진행된다.</p>
<p>실행되면 <code>.claude/plans/tiki-taka</code> 폴더 안에 <code>${날짜}-${설계건}</code> 폴더가 생성된다. Phase 1에서 <code>plan.md</code>가 만들어지고, 이를 기반으로 Phase 2로 진입하여 Codex가 계획서를 검토한다. 
검토 결과는 <code>codex-1.md</code>로 저장되고, 이를 바탕으로 Claude가 다시 검증·반박하고 <code>claude-1.md</code>로 저장된다. 양측 모두 LGTM이 나오면 바로 구현으로 들어간다.</p>
<p><strong>Phase 1: 설계 초안</strong></p>
<p>Claude가 계획서를 작성한다. 변경·생성할 파일 목록, 각 파일의 작업 범위(인터페이스, 타입, 함수 시그니처), 예상되는 엣지 케이스와 에러 처리, 성능·보안 고려사항을 담는다.</p>
<p><strong>Phase 2: 티키타카 리뷰 루프</strong></p>
<p>Codex는 계획서를 객관적으로 검토하고, pin-plate 프로젝트 컨벤션(Server Components, 훅 순서, 네이밍 등) 준수 여부, 누락된 엣지 케이스와 타입 안전성 문제를 짚는다. 그리고 <code>LGTM</code> 또는 <code>NEEDS_CHANGES</code>로 결론 낸다.</p>
<p>Claude는 Codex의 의견을 분석해서, 타당한 지적이면 계획서를 수정하고, 불필요하거나 틀린 지적이면 근거를 들어 반박한다. 마찬가지로 <code>LGTM</code> 또는 <code>NEEDS_CHANGES</code>로 응답한다.</p>
<p>양측 모두 <code>LGTM</code>이 나올 때까지 반복한다. 최대 3라운드. (Pro 요금제를 쓰고 Codex는 무료라, 최대한 적게 돌게 제한을 뒀다. 요금제 높은걸 쓰면 10번정도 돌게 해도 될 것 같다.)</p>
<p><strong>Phase 3: 구현</strong></p>
<p>양측 합의가 끝나면, 검증된 설계를 기반으로 코드를 작성한다.</p>
<hr>
<h3 id="적용-기준">적용 기준</h3>
<p>단, 모든 작업에 다 돌리진 않는다. 아래에 해당하면 tiki-taka를 쓰지 않는다:</p>
<ul>
<li>변경 파일 <strong>5개 이하</strong>로 완결되는 작업</li>
<li><strong>버그 수정</strong> (원인 파악 완료, 범위 한정된 경우)</li>
<li><strong>단순 스타일 변경</strong> (CSS, 디자인 토큰, Vanilla Extract)</li>
<li><strong>문서 수정</strong> (README, 주석, 마크다운)</li>
<li><strong>명백히 단순한 작업</strong> (변수명 변경, 한 줄 수정 등)</li>
</ul>
<p>처음엔 제약 없이 돌렸는데, 간단한 작업에서도 Codex가 리뷰하는 상황이 생겼다. 불필요한 토큰이 낭비되고, 1분이면 될 작업이 10분으로 늘어났다. 그래서 위 조건을 기준으로 제약을 걸었다.</p>
<hr>
<h3 id="뭐가-좋은가">뭐가 좋은가</h3>
<p>코드를 짜기 전에 문제를 잡으니까 리팩터링 비용이 줄어들고, Claude와 Codex 두 관점이 교차하면서 한쪽이 놓칠 수 있는 사각지대가 줄어든다. 설계가 미리 정해진 상태에서 구현하면, &quot;뭘 만들지&quot;를 고민하지 않고 &quot;어떻게 만들지&quot;에만 집중할 수 있다는 것도 좋았다.</p>
<p>물론 내가 정답은 아니다. 일단 나는 1인 개발로 기획, 디자인, 개발을 하고 있기 때문에 여러 AI와 함께 검증을 하는게 안정적으로 프로젝트를 관리할 수 있다고 판단했다. 
계속 사용하다보면 더 좋은 방법이 나타나지 않을까 생각 든다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 에이전트 레이트 리밋(Rate Limit) 트러블슈팅]]></title>
            <link>https://velog.io/@jeong_eeeun/AI-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%EB%A0%88%EC%9D%B4%ED%8A%B8-%EB%A6%AC%EB%B0%8BRate-Limit-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Anthropic-API-%EB%8C%80%EC%9D%91%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_eeeun/AI-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%EB%A0%88%EC%9D%B4%ED%8A%B8-%EB%A6%AC%EB%B0%8BRate-Limit-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Anthropic-API-%EB%8C%80%EC%9D%91%EA%B8%B0</guid>
            <pubDate>Sun, 29 Mar 2026 15:31:29 GMT</pubDate>
            <description><![CDATA[<p>최근 개발 중인 AI 에이전트 &#39;GhostDev&#39;가 실행 도중 예기치 않게 종료되는 현상이 발생했습니다. 로그를 확인하니 다음과 같은 에러 한 줄만 남기고 프로세스가 종료되어 있었습니다.</p>
<p><code>RetryError: Failed after 3 attempts.</code></p>
<p>단순한 일시적 오류인 줄 알았으나, 반복되는 실패를 통해 근본적인 원인을 파악하고 해결한 과정을 정리해 보았습니다.</p>
<hr>
<h2 id="1-문제-원인-파악-토큰-누적과-429-에러">1. 문제 원인 파악: 토큰 누적과 429 에러</h2>
<p>GhostDev는 Jira 티켓을 분석해 코드를 작성하는 에이전트로, <code>generateText</code>를 통해 Claude API를 호출하며 여러 스텝(tool call)을 수행합니다. 문제는 <strong>스텝이 진행될수록 대화 히스토리가 누적된다</strong>는 점이었습니다.</p>
<ul>
<li><strong>원인</strong>: 히스토리가 길어질수록 매 요청의 Input Token이 급격히 증가합니다.</li>
<li><strong>결과</strong>: Anthropic의 분당 토큰 한도(TPM)를 초과하여 <code>429 Too Many Requests</code>가 발생했습니다.</li>
<li><strong>한계</strong>: AI SDK 내부의 기본 retry(3회)가 동일한 시간 윈도우 내에서 발생하여 모두 실패하고 <code>RetryError</code>를 던졌습니다. 이를 처리하는 별도의 로직이 없어 프로세스가 그대로 종료된 것이었습니다.</li>
</ul>
<hr>
<h2 id="2-해결-방안">2. 해결 방안</h2>
<h3 id="①-스텝-간-지연-시간delay-도입">① 스텝 간 지연 시간(Delay) 도입</h3>
<p>가장 먼저 토큰 소모 속도를 낮추기 위해 <code>onStepFinish</code> 단계에 지연 시간을 추가했습니다. 레이트 리밋 윈도우가 보통 1분 단위임을 고려해, 안전하게 30초의 대기 시간을 두었습니다.</p>
<pre><code class="language-typescript">onStepFinish: async (step) =&gt; {
  for (const toolCall of step.toolCalls ?? []) {
    await logger.toolCall(toolCall.toolName, toolCall.args);
}
  // 레이트 리밋 방지를 위한 30초 대기
  await new Promise((resolve) =&gt; setTimeout(resolve, 30_000));
}</code></pre>
<h3 id="②-외부-retry-로직-및-지연-시간-정교화">② 외부 Retry 로직 및 지연 시간 정교화</h3>
<p>지연 시간을 넣어도 스텝이 매우 많아지면 결국 한도를 넘을 수 있습니다. 따라서 <code>RetryError</code> 발생 시 프로세스를 종료하는 대신, 윈도우가 초기화될 때까지 충분히 기다렸다가 다시 시도하는 외부 로직(Outer Retry)을 구현했습니다.</p>
<p>이때 대기 시간은 API 응답 헤더의 <code>retry-after</code> 값을 파싱하여 동적으로 설정했습니다.</p>
<pre><code class="language-typescript">import { RetryError } from &#39;ai&#39;;

const MAX_OUTER_RETRIES = 3;

function getRetryAfterMs(err: unknown): number {
  const lastError = (err as any)?.lastError;
  const retryAfter = lastError?.responseHeaders?.[&#39;retry-after&#39;];

  if (retryAfter) {
    // 헤더 값에 5초의 버퍼를 추가하여 안전하게 대기
    return (parseInt(retryAfter, 10) + 5) * 1000;
  }
  return 65_000; // 헤더가 없을 경우의 Fallback (1분 5초)
}

// 실행 로직 내 적용 예시
for (let attempt = 1; attempt &lt;= MAX_OUTER_RETRIES; attempt++) {
  try {
    result = await generateText({ /* ... 기존 인자들 */ });
    break; 
  } catch (err) {
    if (RetryError.isInstance(err) &amp;&amp; attempt &lt; MAX_OUTER_RETRIES) {
      const waitMs = getRetryAfterMs(err);
      await logger.info(`Rate limit 도달 (시도 ${attempt}/${MAX_OUTER_RETRIES}). ${waitMs / 1000}초 후 재시도합니다.`);
      await new Promise((r) =&gt; setTimeout(r, waitMs));
    } else {
      throw err; // 재시도 횟수 초과 시 에러 상위로 전파
    }
  }
}</code></pre>
<h3 id="③-프롬프트-캐싱을-통한-토큰-절약">③ 프롬프트 캐싱을 통한 토큰 절약</h3>
<p>근본적으로 전송하는 토큰 양 자체를 줄이기 위해 Anthropic의 <strong>프롬프트 캐싱(Prompt Caching)</strong> 기능을 적용했습니다. 매번 동일하게 전송되는 시스템 프롬프트를 캐싱하여 레이트 리밋에 여유를 확보했습니다.</p>
<pre><code class="language-typescript">import { CoreMessage } from &#39;ai&#39;;

const messages: CoreMessage[] = [
  {
    role: &#39;system&#39;,
    content: buildSystemPrompt({ repoPath: process.cwd(), targetWorkspace }),
    experimental_providerMetadata: {
      anthropic: { cacheControl: { type: &#39;ephemeral&#39; } },
    },
  },
  {
    role: &#39;user&#39;,
    content: buildTicketPrompt({ title, description, baseBranch, branchPrefix }),
  },
];

const result = await generateText({ 
  model: anthropic(&#39;claude-3-5-sonnet-latest&#39;), 
  messages, 
  tools, 
  maxSteps: 50 
});</code></pre>
<h2 id="3-배포-이슈와-교훈-버전-관리의-중요성">3. 배포 이슈와 교훈: 버전 관리의 중요성</h2>
<p>코드 수정 후 배포 과정에서 <code>403 Forbidden</code> 에러가 발생하며 NPM 패키지 발행이 막히는 현상이 있었습니다. 확인 결과 <code>package.json</code>의 버전 범프(Version Bump)를 누락하여 동일한 버전을 재배포하려 했던 것이 원인이었습니다. </p>
<p>이 작은 실수로 인해 한동안 운영 환경에서는 레이트 리밋 대응이 없는 구버전 에이전트가 계속 동작하고 있었습니다. 이번 경험을 통해 에이전트의 핵심 로직 변경 시 <strong>버전 업데이트를 필수 체크리스트</strong>에 포함하도록 프로세스를 개선했습니다.</p>
<h2 id="마치며">마치며</h2>
<p>AI 에이전트는 실행 스텝이 길어질수록 비용과 실패 확률이 기하급수적으로 상승합니다. 이번 트러블슈팅을 통해 <strong>속도 제어(Rate Limiting)</strong>와 <strong>토큰 최적화</strong>는 사후 대처가 아닌, 초기 설계 단계에서부터 필수적으로 고려해야 할 요소임을 다시금 확인했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Troubleshooting] 웹뷰(WebView) 환경에서 위치 정보(Geolocation)가 작동하지 않는 이유와 해결법]]></title>
            <link>https://velog.io/@jeong_eeeun/Troubleshooting-%EC%9B%B9%EB%B7%B0WebView-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%9C%84%EC%B9%98-%EC%A0%95%EB%B3%B4Geolocation%EA%B0%80-%EC%9E%91%EB%8F%99%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95</link>
            <guid>https://velog.io/@jeong_eeeun/Troubleshooting-%EC%9B%B9%EB%B7%B0WebView-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%9C%84%EC%B9%98-%EC%A0%95%EB%B3%B4Geolocation%EA%B0%80-%EC%9E%91%EB%8F%99%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95</guid>
            <pubDate>Mon, 16 Feb 2026 07:44:10 GMT</pubDate>
            <description><![CDATA[<p>최근 개인 프로젝트인 <strong>&#39;Pin-Plate(맛집 기록 서비스)&#39;</strong>를 개발하며 웹으로 만든 기능을 모바일 앱 내 웹뷰로 이식하는 작업을 진행했습니다.</p>
<p>분명 웹 브라우저에서는 장소 검색 기능이 아주 잘 작동했는데, 유독 모바일 앱(안드로이드 웹뷰) 환경에서만 검색 결과가 빈 화면으로 나오는 현상을 발견했습니다. 원인은 <strong>보안 정책으로 인한 위치 정보(Geolocation) 요청 차단</strong>이었고, 이를 <strong>Native Bridge</strong>로 해결한 과정을 공유합니다.</p>
<hr>
<h2 id="1-문제-상황-웹은-되는데-앱은-왜-안-될까">1. 문제 상황: &quot;웹은 되는데, 앱은 왜 안 될까?&quot;</h2>
<p>장소 검색 로직은 현재 사용자의 위경도 좌표를 받아와 Query String에 담아 API를 호출하는 방식입니다.</p>
<ul>
<li><strong>웹(PC):</strong> <code>http://localhost:3000</code> 접속 시 위치 정보가 정상적으로 수집됨.</li>
<li><strong>앱(Mobile):</strong> 웹뷰를 통해 개발 서버 IP(<code>http://192.168.x.x:3000</code>)로 접속 시 위치 정보를 가져오지 못해 검색 결과가 0건으로 표시됨.</li>
</ul>
<hr>
<h2 id="2-원인-분석-보안-컨텍스트secure-context의-제한">2. 원인 분석: 보안 컨텍스트(Secure Context)의 제한</h2>
<p>W3C 명세와 브라우저 보안 정책에 따르면, Geolocation API는 개인정보 보호를 위해 <strong>Secure Context(보안 컨텍스트)</strong>에서만 작동합니다.</p>
<h3 id="왜-localhost만-허용될까">왜 localhost만 허용될까?</h3>
<p>웹 표준에서는 <strong>HTTPS</strong> 환경을 안전하다고 판단합니다. 다만, 개발 편의를 위해 <code>http://localhost</code>나 <code>http://127.0.0.1</code>은 예외적으로 안전한 환경(Secure Context)으로 간주하여 위치 정보 요청을 허용합니다.</p>
<h3 id="모바일-ip-접속의-한계">모바일 IP 접속의 한계</h3>
<p>모바일 기기에서 PC 서버 IP로 접속하는 것은 브라우저 입장에서 <strong>&#39;안전하지 않은 원격 접속&#39;</strong>입니다. <code>localhost</code> 예외 조항에 해당하지 않으므로, HTTPS가 적용되지 않은 상태에서는 위치 정보 API 호출이 원천 차단됩니다.</p>
<blockquote>
<p>참고: iOS 웹뷰는 시뮬레이터나 특정 조건에서 localhost 접속 시 관대할 수 있지만, 안드로이드 웹뷰는 HTTP 환경에서의 민감한 권한 요청에 훨씬 엄격합니다.</p>
</blockquote>
<hr>
<h2 id="3-첫-시도-https-적용">3. 첫 시도: HTTPS 적용</h2>
<p>가장 먼저 Next.js의 <code>--experimental-https</code> 옵션을 사용하여 로컬 서버를 HTTPS로 구동해 보았습니다. 하지만 두 가지 문제로 인해 기각했습니다.</p>
<ol>
<li><strong>SSL Trust 에러:</strong> 사설 인증서(Self-signed certificate)를 사용하다 보니 모바일 기기에서 &quot;신뢰할 수 없는 인증 기관&quot;이라며 접속을 차단(<code>SSL error: The certificate authority is not trusted</code>)했습니다.</li>
<li><strong>설정의 복잡도:</strong> 안드로이드 <code>onReceivedSslError</code>를 통해 에러를 무시할 수 있지만, 이는 보안상 매우 취약하며 실제 배포 환경과는 거리가 먼 설정이라 판단했습니다.</li>
</ol>
<hr>
<h2 id="4-최종-해결책-native-bridge-패턴-활용">4. 최종 해결책: Native Bridge 패턴 활용</h2>
<p>웹뷰 자체의 <code>navigator.geolocation</code> 기능을 포기하고, 앱(Native)의 기능을 빌려오는 <strong>Bridge 패턴</strong>을 선택했습니다.</p>
<h3 id="프로세스">프로세스</h3>
<ol>
<li><strong>Web:</strong> 웹뷰 감지 시, Native로 위치 정보 요청 메시지를 전송합니다.</li>
<li><strong>App:</strong> <code>expo-location</code> 등을 이용해 기기 자체 GPS 좌표를 취득합니다.</li>
<li><strong>App → Web:</strong> 취득한 좌표를 웹뷰의 <code>postMessage</code>를 통해 웹으로 다시 전달합니다.</li>
<li><strong>Web:</strong> 전달받은 좌표를 사용하여 장소 검색 API를 실행합니다.</li>
</ol>
<p>브릿지 통신은 크게 <strong>웹에서 요청하기</strong>와 <strong>앱에서 응답하기</strong> 두 단계로 나뉩니다.</p>
<h3 id="1-web-nextjs--typescript">1) Web (Next.js / TypeScript)</h3>
<p>웹에서는 현재 환경이 웹뷰인지 확인한 후, 네이티브에 메시지를 보냅니다.</p>
<pre><code class="language-tsx">// types/webview.d.ts
interface Window {
  ReactNativeWebView?: {
    postMessage: (message: string) =&gt; void;
  };
}

// components/LocationSearch.tsx
import { useEffect } from &#39;react&#39;;

const requestLocationFromNative = () =&gt; {
  if (window.ReactNativeWebView) {
    // 네이티브 앱에 위치 정보 요청 메시지 전송
    window.ReactNativeWebView.postMessage(
      JSON.stringify({ type: &#39;GET_LOCATION&#39; })
    );
  } else {
    console.warn(&quot;웹뷰 환경이 아닙니다.&quot;);
  }
};</code></pre>
<h3 id="2-app-react-native--expo">2) App (React Native / Expo)</h3>
<p>앱에서는 웹뷰의 <code>onMessage</code>를 통해 요청을 받고, <code>expo-location</code>으로 좌표를 구해 다시 전달합니다.</p>
<pre><code class="language-tsx">import React, { useRef } from &#39;react&#39;;
import { WebView, WebViewMessageEvent } from &#39;react-native-webview&#39;;
import * as Location from &#39;expo-location&#39;;

export default function App() {
  const webViewRef = useRef&lt;WebView&gt;(null);

  const onMessage = async (event: WebViewMessageEvent) =&gt; {
    const message = JSON.parse(event.nativeEvent.data);

    if (message.type === &#39;GET_LOCATION&#39;) {
      // 1. 위치 권한 요청
      const { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== &#39;granted&#39;) {
        console.log(&#39;위치 권한이 거부되었습니다.&#39;);
        return;
      }

      // 2. 현재 위치 가져오기
      const location = await Location.getCurrentPositionAsync({});
      const { latitude, longitude } = location.coords;

      // 3. 웹뷰로 좌표 전송
      const response = {
        type: &#39;SET_LOCATION&#39;,
        payload: { latitude, longitude },
      };

      webViewRef.current?.postMessage(JSON.stringify(response));
    }
  };

  return (
    &lt;WebView
      ref={webViewRef}
      source={{ uri: &#39;http://your-local-ip:3000&#39; }}
      onMessage={onMessage}
    /&gt;
  );
}</code></pre>
<hr>
<h2 id="5-expo-환경-설정-appjson">5. Expo 환경 설정 (app.json)</h2>
<p>Native 기능을 사용하기 위해 OS별 권한 설정이 필요합니다. Expo 환경에서의 설정법입니다.</p>
<h3 id="1-플러그인-설정">1) 플러그인 설정</h3>
<p><code>expo-location</code> 라이브러리가 필요한 설정을 자동으로 생성하도록 추가합니다.</p>
<pre><code class="language-json">&quot;plugins&quot;: [
  [
    &quot;expo-location&quot;,
    {
      &quot;locationAlwaysAndWhenInUsePermission&quot;: &quot;Allow $(PRODUCT_NAME) to use your location.&quot;
    }
  ]
]</code></pre>
<h3 id="2-ios-설정-iosinfoplist">2) iOS 설정 (<code>ios.infoPlist</code>)</h3>
<p>애플의 심사 기준을 준수하기 위해 구체적인 목적을 명시해야 합니다.</p>
<pre><code class="language-json">&quot;ios&quot;: {
  &quot;infoPlist&quot;: {
    &quot;NSLocationWhenInUseUsageDescription&quot;: &quot;사용자 주변 맛집 검색 및 위치 기록을 위해 현재 위치 권한을 사용합니다.&quot;
  }
}</code></pre>
<h3 id="3-android-설정-androidpermissions">3) Android 설정 (<code>android.permissions</code>)</h3>
<p>GPS와 기지국 기반 위치 정보를 모두 받기 위해 아래 권한들을 추가합니다.</p>
<pre><code class="language-json">&quot;android&quot;: {
  &quot;permissions&quot;: [
    &quot;android.permission.ACCESS_COARSE_LOCATION&quot;,
    &quot;android.permission.ACCESS_FINE_LOCATION&quot;
  ]
}</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>웹뷰 개발은 웹 브라우저와 네이티브 OS 사이의 보안 정책 차이를 이해하는 과정인 것 같습니다. 단순히 웹을 앱 안에 띄우는 것에 그치지 않고, <strong>Native Bridge</strong>를 적극 활용할 때 훨씬 견고하고 안정적인 사용자 경험을 만들 수 있다는 점을 배울 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[아이콘 노가다 탈출기: 피그마 추출부터 컴포넌트화까지 자동화하기]]></title>
            <link>https://velog.io/@jeong_eeeun/%EC%95%84%EC%9D%B4%EC%BD%98-%EB%85%B8%EA%B0%80%EB%8B%A4-%ED%83%88%EC%B6%9C%EA%B8%B0-%ED%94%BC%EA%B7%B8%EB%A7%88-%EC%B6%94%EC%B6%9C%EB%B6%80%ED%84%B0-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%ED%99%94%EA%B9%8C%EC%A7%80-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_eeeun/%EC%95%84%EC%9D%B4%EC%BD%98-%EB%85%B8%EA%B0%80%EB%8B%A4-%ED%83%88%EC%B6%9C%EA%B8%B0-%ED%94%BC%EA%B7%B8%EB%A7%88-%EC%B6%94%EC%B6%9C%EB%B6%80%ED%84%B0-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%ED%99%94%EA%B9%8C%EC%A7%80-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 02 Feb 2026 14:52:42 GMT</pubDate>
            <description><![CDATA[<h3 id="자동화를-결심한-이유">자동화를 결심한 이유</h3>
<p>프론트엔드 개발을 하다 보면 피그마에서 아이콘을 가져오는 과정이 정말 번거롭다.
아이콘 하나하나 Export 해서 다운로드하고, IDE 프로젝트 폴더에 옮기고, 파일명 컨벤션 맞추고, 다시 React 컴포넌트로 만드는 반복 작업을 하다보면 <strong>이 짓을 계속 해야 하나?</strong> 생각이 든다. </p>
<p>그래서 명령어 한 줄로 피그마의 최신 아이콘을 프로젝트의 React 컴포넌트로 꽂아버리는 프로세스를 구축했다.</p>
<hr>
<h3 id="준비-작업-figma-mcp와-연동">준비 작업: Figma MCP와 연동</h3>
<p>먼저 Figma API를 쓰기 위한 환경 세팅이 필요하다.</p>
<ol>
<li><strong>Figma Desktop 설치</strong>: 로컬 서버 연동을 위해 데스크탑 앱을 활용했다.</li>
<li><strong>Access Token 발급</strong>: 피그마 설정에서 발급받고 <code>.env</code>에 저장했다. (토큰은 발급 시 한 번만 볼 수 있으니 잘 보관해야 함)</li>
<li><strong>File Key &amp; Node ID 확보</strong>: 아이콘들이 담긴 피그마 파일의 URL과 해당 프레임(Node)의 ID를 환경변수에 등록했다.</li>
</ol>
<hr>
<h3 id="step-1-피그마에서-svg-뽑아오기">Step 1: 피그마에서 SVG 뽑아오기</h3>
<p>첫 번째 작업은 피그마 API를 호출해서 아이콘 리스트를 가져오고, SVG 파일로 내려받는 스크립트 작성이다.</p>
<p>핵심은 두 가지 헬퍼 함수다.</p>
<ul>
<li><code>fetchJson(url)</code>: 피그마 노드 정보를 받아와서 아이콘 다운로드 링크를 추출한다.</li>
<li><code>fetchFile(url, dest)</code>: 추출된 URL에서 실제 SVG 데이터를 가져와 파일로 저장한다.</li>
</ul>
<blockquote>
<p>💡 여기서 잠깐: 왜 arrayBuffer인가?
처음에 단순히 텍스트로 처리하려다 데이터 손실 가능성을 고려해 response.arrayBuffer()를 사용했다. 바이너리 수준에서 데이터를 받아와 저장해야 SVG 파일의 무결성을 완벽하게 보장할 수 있기 때문이다.</p>
</blockquote>
<p>이 과정을 거치면 디자인 시스템의 <code>assets</code> 폴더에 <code>ic-home.svg</code> 같은 형식으로 파일들이 차곡차곡 쌓인다.</p>
<p>fetch-icons.js 전체 코드</p>
<pre><code class="language-tsx">const fs = require(&#39;fs&#39;);
const path = require(&#39;path&#39;);

// Check Node.js version assurance (optional but good)
if (!globalThis.fetch) {
  console.error(
    &#39;\x1b[31mError: Native fetch is not available. Please use Node.js 18+.\x1b[0m&#39;,
  );
  process.exit(1);
}

// zero-dependency .env loader
const envPath = path.join(__dirname, &#39;../.env&#39;);
if (fs.existsSync(envPath)) {
  const envConfig = fs.readFileSync(envPath, &#39;utf8&#39;);
  envConfig.split(&#39;\n&#39;).forEach((line) =&gt; {
    const [key, ...valueParts] = line.split(&#39;=&#39;);
    if (key &amp;&amp; valueParts.length &gt; 0) {
      const value = valueParts.join(&#39;=&#39;).trim();
      if (!process.env[key.trim()]) {
        process.env[key.trim()] = value;
      }
    }
  });
}

// Configuration from Environment Variables
const FIGMA_TOKEN = process.env.FIGMA_ACCESS_TOKEN;
const FILE_KEY = process.env.FIGMA_FILE_KEY;
const NODE_ID = process.env.FIGMA_NODE_ID;

const ASSETS_DIR = path.join(__dirname, &#39;../assets&#39;);

if (!FIGMA_TOKEN || !FILE_KEY || !NODE_ID) {
  console.error(&#39;Error: Missing Environment Variables.&#39;);
  console.error(
    &#39;Please set FIGMA_ACCESS_TOKEN, FIGMA_FILE_KEY, and FIGMA_NODE_ID.&#39;,
  );
  process.exit(1);
}

// Helper for Fetch
const fetchJson = async (url) =&gt; {
  const response = await fetch(url, {
    headers: { &#39;X-Figma-Token&#39;: FIGMA_TOKEN },
  });

  if (!response.ok) {
    throw new Error(
      `Status Code: ${response.status}, Status: ${response.statusText}`,
    );
  }
  return response.json();
};

const fetchFile = async (url, dest) =&gt; {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to download image: ${response.statusText}`);
  }
  const arrayBuffer = await response.arrayBuffer();
  fs.writeFileSync(dest, Buffer.from(arrayBuffer));
};

const main = async () =&gt; {
  try {
    // 1. Get Node Children (Icons)
    console.log(&#39;Fetching icon list from Figma...&#39;);
    const nodeUrl = `https://api.figma.com/v1/files/${FILE_KEY}/nodes?ids=${NODE_ID}`;
    const nodeData = await fetchJson(nodeUrl);

    // The node might be a Frame/ComponentSet containing children
    const rootNode = nodeData.nodes[NODE_ID.replace(&#39;-&#39;, &#39;:&#39;)].document;

    if (!rootNode.children) {
      throw new Error(`No children found in node ${NODE_ID}`);
    }

    const icons = rootNode.children
      .filter(
        (child) =&gt;
          child.type === &#39;COMPONENT&#39; ||
          child.type === &#39;INSTANCE&#39; ||
          child.type === &#39;FRAME&#39; ||
          child.type === &#39;VECTOR&#39;,
      )
      .map((child) =&gt; ({
        id: child.id,
        name: child.name.trim(),
      }));

    if (icons.length === 0) {
      console.log(&#39;No icons found.&#39;);
      return;
    }

    console.log(`Found ${icons.length} icons.`);

    // 2. Get Image URLs
    const ids = icons.map((i) =&gt; i.id).join(&#39;,&#39;);
    const imageUrlsUrl = `https://api.figma.com/v1/images/${FILE_KEY}?ids=${ids}&amp;format=svg`;
    const imageData = await fetchJson(imageUrlsUrl);
    const images = imageData.images;

    // 3. Download SVGs
    if (!fs.existsSync(ASSETS_DIR)) {
      fs.mkdirSync(ASSETS_DIR, { recursive: true });
    }

    let downloadCount = 0;
    for (const icon of icons) {
      const imageUrl = images[icon.id];
      if (imageUrl) {
        // Sanitize name: &quot;Home Icon&quot; -&gt; &quot;home-icon&quot; -&gt; &quot;ic-home-icon.svg&quot;
        let safeName = icon.name
          .toLowerCase()
          .replace(/[^a-z0-9]/g, &#39;-&#39;)
          .replace(/-+/g, &#39;-&#39;)
          .replace(/^-|-$/g, &#39;&#39;);

        if (!safeName.startsWith(&#39;ic-&#39;)) {
          safeName = `ic-${safeName}`;
        }

        const fileName = `${safeName}.svg`;
        const filePath = path.join(ASSETS_DIR, fileName);

        await fetchFile(imageUrl, filePath);
        console.log(`Downloaded: ${fileName}`);
        downloadCount++;
      }
    }

    console.log(
      `\nSuccessfully downloaded ${downloadCount} icons to ${ASSETS_DIR}`,
    );
    console.log(&#39;Run &quot;npm run icons:gen&quot; to generate React components.&#39;);
  } catch (err) {
    console.error(&#39;Failed to fetch icons:&#39;, err);
    process.exit(1);
  }
};

main();
</code></pre>
<hr>
<h3 id="step-2-svg를-react-컴포넌트로-변환하기">Step 2: SVG를 React 컴포넌트로 변환하기</h3>
<p>단순히 SVG 파일만 있다고 끝이 아니다. 디자인 시스템에서 쓰려면 컬러를 동적으로 바꿀 수 있어야 하고, 타입스크립트 지원도 필요하다. 이를 위해 <code>generate-icons.js</code>를 만들었다.</p>
<p>이 스크립트가 하는 일은 다음과 같다.</p>
<ol>
<li><strong>PascalCase 변환</strong>: 파일명을 리액트 컴포넌트 컨벤션에 맞게 바꾼다. (예: <code>ic-search</code> -&gt; <code>IcSearch</code>)</li>
<li><strong>SVG 최적화</strong>: XML 선언문이나 불필요한 주석을 정규표현식으로 날려버린다.</li>
<li><strong>currentColor 적용</strong>: 이게 제일 중요하다. <code>fill</code>이나 <code>stroke</code> 속성 값을 찾아 <code>currentColor</code>로 치환했다. 이제 CSS <code>color</code> 속성만으로 아이콘 색상을 자유자재로 바꿀 수 있다.</li>
<li><strong>camelCase 속성 교체</strong>: SVG의 <code>stroke-width</code> 같은 속성을 리액트에서 쓸 수 있게 <code>strokeWidth</code>로 변환한다.</li>
<li><strong>TypeScript 정의</strong>: <code>SVGProps&lt;SVGSVGElement&gt;</code>를 확장해 외부에서 스타일이나 속성을 주입받을 수 있는 구조로 완성한다.</li>
</ol>
<p>generate-icon.js 전체 코드 </p>
<pre><code class="language-tsx">const fs = require(&#39;fs&#39;);
const path = require(&#39;path&#39;);

const ASSETS_DIR = path.join(__dirname, &#39;../assets&#39;);
const OUTPUT_DIR = path.join(__dirname, &#39;../src/icons&#39;);

// Helper to convert kebab-case to PascalCase
const toPascalCase = (str) =&gt; {
  return str.replace(/(^\w|-\w)/g, (match) =&gt;
    match.replace(&#39;-&#39;, &#39;&#39;).toUpperCase(),
  );
};

// Helper to convert kebab-case attributes to camelCase (for React)
const toCamelCaseAttr = (str) =&gt; {
  return str.replace(/-([a-z])/g, (g) =&gt; g[1].toUpperCase());
};

// Main process
const generateIcons = () =&gt; {
  if (!fs.existsSync(OUTPUT_DIR)) {
    fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  }

  const files = fs
    .readdirSync(ASSETS_DIR)
    .filter((file) =&gt; file.endsWith(&#39;.svg&#39;));
  const exportStatements = [];

  files.forEach((file) =&gt; {
    const fileName = path.basename(file, &#39;.svg&#39;);
    // Ensure &quot;Ic&quot; prefix
    const componentName = fileName.toLowerCase().startsWith(&#39;ic&#39;)
      ? toPascalCase(fileName)
      : `Ic${toPascalCase(fileName)}`;

    const filePath = path.join(ASSETS_DIR, file);
    let svgContent = fs.readFileSync(filePath, &#39;utf8&#39;);

    // 1. Sanitize: Remove XML declaration and comments
    svgContent = svgContent.replace(/&lt;\?xml.*?\?&gt;/g, &#39;&#39;);
    svgContent = svgContent.replace(/&lt;!--.*?--&gt;/g, &#39;&#39;);

    // 2. Extract SVG tag content and attributes
    const svgTagMatch = svgContent.match(/&lt;svg([^&gt;]*)&gt;([\s\S]*?)&lt;\/svg&gt;/);
    if (!svgTagMatch) {
      console.warn(`Skipping invalid SVG: ${file}`);
      return;
    }

    let [_, attributes, innerContent] = svgTagMatch;

    // 3. Replace fill/stroke colors with currentColor
    // Regex matches fill=&quot;...&quot; or stroke=&quot;...&quot; where value is NOT &quot;none&quot;
    innerContent = innerContent.replace(
      /(fill|stroke)=&quot;([^&quot;]+)&quot;/g,
      (match, attr, value) =&gt; {
        if (value === &#39;none&#39;) return match;
        return `${attr}=&quot;currentColor&quot;`;
      },
    );

    // Also handle attributes in the svg tag itself if necessary, but usually we pass props there.
    // For safety, let&#39;s just make the svg tag flexible via props.

    // 4. Convert attributes to camelCase (e.g. stroke-width -&gt; strokeWidth)
    // This is a naive regex but works for standard SVG props
    innerContent = innerContent.replace(/([a-z]+-[a-z]+)=/g, (match) =&gt;
      toCamelCaseAttr(match),
    );
    attributes = attributes.replace(/([a-z]+-[a-z]+)=/g, (match) =&gt;
      toCamelCaseAttr(match),
    );

    // 5. Construct React Component
    const componentContent = `import { SVGProps } from &#39;react&#39;;

export const ${componentName} = (props: SVGProps&lt;SVGSVGElement&gt;) =&gt; {
  return (
    &lt;svg 
      ${attributes.trim()} 
      {...props}
    &gt;
      ${innerContent.trim()}
    &lt;/svg&gt;
  );
};
`;

    fs.writeFileSync(
      path.join(OUTPUT_DIR, `${componentName}.tsx`),
      componentContent,
    );
    exportStatements.push(`export * from &#39;./${componentName}&#39;;`);
    console.log(`Generated: ${componentName}`);
  });

  // Generate index.ts
  fs.writeFileSync(
    path.join(OUTPUT_DIR, &#39;index.ts&#39;),
    exportStatements.join(&#39;\n&#39;) + &#39;\n&#39;,
  );
  console.log(&#39;Icon generation complete!&#39;);
};

generateIcons();
</code></pre>
<hr>
<h3 id="결과-명령어-한-줄로-끝나는-워크플로우">결과: 명령어 한 줄로 끝나는 워크플로우</h3>
<p>명령어 한 줄로 끝내기 위해서 package.json에 추가적으로 설정을 한다. </p>
<pre><code class="language-json">{
    &quot;icons&quot;: &quot;npm run icons:fetch &amp;&amp; npm run icons:gen&quot;,
    &quot;icons:fetch&quot;: &quot;node scripts/fetch-icons.js&quot;,
    &quot;icons:gen&quot;: &quot;node scripts/generate-icons.js&quot;
}</code></pre>
<p>이렇게 설정을 한 후, 피그마에 새 아이콘이 추가되면 터미널에 명령어 한 줄이면 된다!</p>
<pre><code class="language-bash">pnpm icons</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github actions으로 pr 자동화 하기 (with. AI summary)]]></title>
            <link>https://velog.io/@jeong_eeeun/Github-actions%EC%9C%BC%EB%A1%9C-pr-%EC%9E%90%EB%8F%99%ED%99%94-%ED%95%98%EA%B8%B0-with.-AI-summary</link>
            <guid>https://velog.io/@jeong_eeeun/Github-actions%EC%9C%BC%EB%A1%9C-pr-%EC%9E%90%EB%8F%99%ED%99%94-%ED%95%98%EA%B8%B0-with.-AI-summary</guid>
            <pubDate>Thu, 15 Jan 2026 14:33:18 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jeong_eeeun/post/7eef3aee-316b-4e15-a1fe-1124e722217f/image.png" alt=""></p>
<p>사이드 프로젝트를 하다가 pr를 생성할때마다 내용을 작성하는게 귀찮아서 자동화 해줄 수 있는 방법이 없을까? 생각했다. </p>
<p>내가 commit을 올린 파일들을 기반으로 AI로 파일들의 내용을 요약하면 어떨까?</p>
<h3 id="github-action">Github action</h3>
<p>먼저 github action이란 뭘까? </p>
<blockquote>
<p>GitHub Actions는 빌드, 테스트 및 배포 파이프라인을 자동화할 수 있는 CI/CD(연속 통합 및 지속적인 업데이트) 플랫폼입니다.</p>
</blockquote>
<p>나같은 경우엔 git push를 하는 순간 액션이 발동 되게 했다. </p>
<pre><code class="language-yml">name: Auto Pull Request

on:
  push:
    branches:
      - &quot;feature/*&quot;
      - &quot;bugfix/*&quot;
      - &quot;refactor/*&quot;

jobs:
    ...
      - name: Create Pull Request (Auto)
        if: success()
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |

          git fetch origin main

          # 1. Commits &amp; Internal Stats (Fallback/Base)
          printf &quot;## 📝 Commits\n&quot; &gt; pr_body.txt
          git log origin/main..HEAD --pretty=format:&quot;- %s (%h)&quot; &gt;&gt; pr_body.txt
          printf &quot;\n\n## 📊 File Changes\n\`\`\`\n&quot; &gt;&gt; pr_body.txt
          git diff origin/main..HEAD --stat &gt;&gt; pr_body.txt
          printf &quot;\`\`\`\n&quot; &gt;&gt; pr_body.txt

          FALLBACK_BODY=$(cat pr_body.txt)

          # 2. Try AI Summary
          FINAL_BODY=&quot;$FALLBACK_BODY&quot;

          if [[ -n &quot;$GEMINI_API_KEY&quot; ]]; then
            echo &quot;GEMINI_API_KEY is present, attempting AI summary... &quot;
            AI_SUMMARY=$(node scripts/generate-pr-summary.mjs || echo &quot;&quot;)

            if [[ -n &quot;$AI_SUMMARY&quot; ]]; then
              FINAL_BODY=&quot;$AI_SUMMARY

              ---
              $FALLBACK_BODY&quot;
            else
              echo &quot;AI Summary failed. Using fallback.&quot;
            fi
          else
            echo &quot;GEMINI_API_KEY not found. Using fallback.&quot;
          fi

          # Save to file to avoid shell argument limits/issues
          printf &quot;%s\n&quot; &quot;$FINAL_BODY&quot; &gt; pr_description.md

          gh pr create \
            --base main \
            --head ${{ github.ref_name }} \
            --title &quot;PR: ${{ github.ref_name }}&quot; \
            --body-file pr_description.md \
            || echo &quot;PR already exists or encountered an error&quot;
</code></pre>
<p>위의 단계에서 모두 성공했을때 실행이 되도록 했다. 
먼저 AI를 사용해야하기 때문에 API_KEY를 등록해줬다. </p>
<blockquote>
<p>등록 방법
Repository &gt; Settings &gt; Secrets and Variables &gt; Actions &gt; 
Repository secrets에 원하는 키 값을 넣어주면 된다. 
이때 API_KEY는 Gemini나 OpenAI를 활용하면 된다. </p>
</blockquote>
<h3 id="ai-summary">AI summary</h3>
<pre><code>AI_SUMMARY=$(node scripts/generate-pr-summary.mjs || echo &quot;&quot;)</code></pre><p>AI로 내용을 요약해야하기 때문에 스크립트를 만들어준다. </p>
<p>① Git 변경 사항 추출</p>
<pre><code class="language-js">const diff = execSync(
  &quot;git diff origin/main..HEAD -- . &#39;:!pnpm-lock.yaml&#39; &#39;:!*.min.js&#39; &#39;:!*.svg&#39;&quot;
).toString();</code></pre>
<ul>
<li><p>git diff origin/main..HEAD: 메인 브랜치와 현재 작업 중인 브랜치의 차이점을 텍스트로 뽑아낸다.</p>
</li>
<li><p>제외 설정 (:!file): pnpm-lock.yaml이나 이미지 파일 등은 텍스트가 너무 길고 AI에게 필요 없으므로 제외하여 토큰 비용을 아낀다.</p>
</li>
</ul>
<p>② 길이 제한 (Safety)</p>
<pre><code class="language-js">const MAX_CHARS = 100000;
// 너무 길면 자름</code></pre>
<p>변경 사항이 너무 방대하면 API 요청 한도를 넘거나 에러가 날 수 있어 안전하게 자른다.</p>
<pre><code class="language-js">③ 프롬프트 구성 및 API 호출

```js
const prompt = `
You are a skilled senior software engineer.
...
**IMPORTANT: Please write the response in Korean (한국어).**
...
Diff: ${diffData}
`;</code></pre>
<p>AI에게 &quot;너는 시니어 개발자야. 이 코드 변경점(diff)을 보고 한국어로 요약해줘&quot;라고 역할을 부여한다. 원하는 내용이 있으면 커스텀 하면 된다.</p>
<p>④ Gemini API 통신</p>
<pre><code class="language-js">const response = await fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent...`,
  // ...
);</code></pre>
<p>구글의 Gemini AI 모델(gemini-2.5-flash)에게 프롬프트를 보낸다.</p>
<p>참고: 현재 시점(2026년 1월)에서 사용 가능한 최신 모델 버전을 작성함.</p>
<p>⑤ 결과 출력</p>
<pre><code class="language-js">console.log(cleanSummary);</code></pre>
<p>스크립트가 console.log로 출력한 내용을 아까 YAML 파일의 AI_SUMMARY=$(node ...) 부분에서 변수로 받아채서 PR 본문에 넣습니다.</p>
<p>결과
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/d0eef869-696a-4afe-bd3b-5fc73d2afc16/image.png" alt="">
이런식으로 auto-pr runner가 생겼고 완료가 되면 
<img src="https://velog.velcdn.com/images/jeong_eeeun/post/cca7e150-ca54-4041-9e07-dfc648a921a8/image.png" alt="">
자동으로 pr 내용까지 작성해서 올려준다! (현재는 한국어로 작성해달라고 수정)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vite 빌드 시 발생하는 Parse Error와 manualChunks 해결 방안]]></title>
            <link>https://velog.io/@jeong_eeeun/Vite-%EB%B9%8C%EB%93%9C-%EC%8B%9C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-Parse-Error%EC%99%80-manualChunks-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EC%95%88</link>
            <guid>https://velog.io/@jeong_eeeun/Vite-%EB%B9%8C%EB%93%9C-%EC%8B%9C-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-Parse-Error%EC%99%80-manualChunks-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EC%95%88</guid>
            <pubDate>Wed, 07 Jan 2026 13:43:59 GMT</pubDate>
            <description><![CDATA[<h2 id="1-문제-상황-problem">1. 문제 상황 (Problem)</h2>
<p><code>apps/web</code> 프로젝트에서 Vite/Rollup을 사용하여 프로덕션 빌드를 수행하던 중, 생성된 자바스크립트 에셋 파일에서 다음과 같은 <strong>Parse error</strong>가 발생하여 빌드가 실패했습니다.</p>
<p><strong>에러 로그:</strong></p>
<pre><code class="language-assets/index-DXqmZrH1.js">
1  |  function uBn(r,n){...}/** @license React
   |  ^
2  |   * react-jsx-runtime.production.js</code></pre>
<h2 id="2-원인-분석-cause">2. 원인 분석 (Cause)</h2>
<p>이 현상은 Rollup이 여러 모듈을 하나의 파일로 병합하고 압축하는 과정에서 발생하는 Edge Case입니다. 구체적인 발생 메커니즘은 다음과 같습니다.</p>
<ol>
<li><strong>Minification (코드 압축):</strong> 빌드 도구(Rollup/Terser/Esbuild 등)가 파일 크기를 줄이기 위해 함수나 구문 끝의 <strong>마지막 세미콜론(<code>;</code>)을 생략</strong>합니다.</li>
<li><strong>License 주석 보존:</strong> 대부분의 압축 도구는 법적 이유로 <code>/** @license ... */</code> 형태의 주석은 제거하지 않고 남겨둡니다.</li>
<li><strong>청크 병합 (Merging):</strong> 여러 모듈이 하나의 파일(<code>index.js</code>)로 합쳐질 때, 줄바꿈 없이 코드가 이어집니다.</li>
</ol>
<p>결과:</p>
<p>앞선 함수의 닫는 중괄호(})와 뒤따르는 React 라이브러리의 주석 시작 부분(/**)이 바로 붙어버리면서 자바스크립트 파서가 이를 나눗셈 연산으로 오해하게 됩니다.</p>
<pre><code class="language-js">// 실제로 생성된 코드의 형태
function uBn(r,n){...}/** @license React ... */

// 파서(Parser)가 해석하는 방식
function uBn(r,n){...} / ** @license React ... */
//                     ↑
//       닫는 중괄호 뒤의 &#39;/&#39;를 나눗셈 연산자로 인식
//       그 뒤의 &#39;**&#39;를 지수 연산자 등으로 잘못 해석하여 문법 오류 발생</code></pre>
<blockquote>
<p>참고: 이 문제는 모듈 그래프의 해시값이나 병합 순서에 따라 간헐적으로 발생할 수 있어 재현이 까다로운 편입니다.</p>
</blockquote>
<h2 id="3-해결-방안-solution">3. 해결 방안 (Solution)</h2>
<p>가장 확실한 해결책은 <strong><code>manualChunks</code></strong> 설정을 통해 주석이 포함된 라이브러리(React 등)를 물리적으로 다른 파일로 분리하는 것입니다. 파일이 분리되면 코드가 이어 붙을 일이 없으므로 파싱 오류가 원천 차단됩니다.</p>
<p><code>apps/web/vite.config.ts</code> 파일에 다음 설정을 추가합니다:</p>
<pre><code class="language-ts">// apps/web/vite.config.ts
import { defineConfig } from &#39;vite&#39;;

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // React 관련 패키지들을 &#39;react&#39;라는 별도의 청크 파일로 분리
          react: [&#39;react&#39;, &#39;react-dom&#39;, &#39;react/jsx-runtime&#39;],
        },
      },
    },
  },
});</code></pre>
<h3 id="해결-원리-비교">해결 원리 비교</h3>
<ul>
<li><p>설정 전 (충돌 발생):</p>
<p>  dist/assets/index-xxx.js 안에 애플리케이션 코드와 React 코드가 한 줄로 합쳐져 충돌 발생.</p>
</li>
<li><p><strong>설정 후 (충돌 방지):</strong></p>
<ul>
<li><p><code>dist/assets/index-xxx.js</code> (애플리케이션 로직)</p>
</li>
<li><p>dist/assets/react-xxx.js (React 라이브러리)</p>
<p>  $\rightarrow$ 물리적으로 파일이 나뉘어 안전하게 로딩됨.</p>
</li>
</ul>
</li>
</ul>
<h2 id="4-추가-이점-additional-benefits">4. 추가 이점 (Additional Benefits)</h2>
<p>이 설정은 빌드 오류 해결 외에도 다음과 같은 성능상 이점이 있습니다.</p>
<ul>
<li><strong>캐싱 효율 증대:</strong> React와 같은 라이브러리는 비즈니스 로직에 비해 변경 빈도가 매우 낮습니다. 별도 파일로 분리해두면, 비즈니스 로직을 수정해 배포하더라도 사용자의 브라우저는 이미 캐시된 <code>react-xxx.js</code>를 재사용할 수 있습니다.</li>
<li><strong>병렬 로딩:</strong> 하나의 거대한 파일 대신 적절히 분리된 파일들을 브라우저가 병렬로 다운로드하여 초기 로딩 속도가 개선될 수 있습니다.</li>
</ul>
<h2 id="5-참고-문서">5. 참고 문서</h2>
<ul>
<li><a href="https://www.google.com/search?q=https://rollupjs.org/configuration-options/%23output-manualchunks">Rollup Output Options - manualChunks</a></li>
<li><a href="https://www.google.com/search?q=https://vitejs.dev/config/build-options.html%23build-rollupoptions">Vite Build Options</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[IntersectionObserver을 이용하여 image lazy loading 구현하기]]></title>
            <link>https://velog.io/@jeong_eeeun/IntersectionObserver%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-image-lazy-loading-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_eeeun/IntersectionObserver%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-image-lazy-loading-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 06 Jan 2026 12:36:18 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요-왜-지연-로딩이-필요했나">1. 개요: 왜 지연 로딩이 필요했나?</h2>
<p>현재 사내 서비스에서는 Jira REST API와 연동하여 등록된 이슈의 상세 내용을 보여주는 기능을 제공하고 있습니다. Jira 이슈의 <code>description</code>에는 텍스트뿐만 아니라 여러 장의 이미지가 포함될 수 있습니다.</p>
<p>하지만 기존 방식에는 두 가지 큰 문제점이 있었습니다.</p>
<ol>
<li><strong>불필요한 리소스 낭비</strong>: 이슈 컴포넌트가 마운트되는 순간, 화면 하단에 있어 당장 보이지 않는 이미지까지 한꺼번에 로드되었습니다.</li>
<li><strong>초기 로딩 속도 저하</strong>: 수많은 이미지 요청이 동시에 발생하면서 정작 중요한 이슈 텍스트 데이터를 확인하는 속도가 느려졌습니다.</li>
</ol>
<p>단순히 <code>loading=&quot;lazy&quot;</code> 속성을 쓰면 해결될 것 같았지만, <strong>인증이 필요한 리소스</strong>라는 특이점이 있었습니다.</p>
<hr>
<h2 id="2-왜-native-lazy-loading을-쓸-수-없었나">2. 왜 native lazy loading을 쓸 수 없었나?</h2>
<p>Jira API를 통해 가져오는 이미지는 보안상 <code>Authorization: Bearer &lt;token&gt;</code> 헤더가 포함된 요청으로만 가져올 수 있는 <strong>Private 리소스</strong>입니다.</p>
<ul>
<li><code>&lt;img&gt;</code> 태그의 기본 <code>src</code> 속성이나 HTML 표준 속성인 <code>loading=&quot;lazy&quot;</code>는 <strong>커스텀 헤더를 실어서 보낼 수 없습니다.</strong></li>
<li>따라서 <code>fetch</code> API를 통해 이미지 데이터를 <code>blob</code> 형태로 받은 뒤 <code>URL.createObjectURL</code>로 변환하는 과정이 반드시 필요했습니다.</li>
</ul>
<p>이 과정이 &quot;사용자에게 이미지가 보일 때&quot;만 트리거되도록 제어하기 위해 <strong>Intersection Observer API</strong>를 선택했습니다.</p>
<hr>
<h2 id="3-intersection-observer-api란">3. Intersection Observer API란?</h2>
<p>MDN에 따르면, <strong>Intersection Observer API</strong>는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰하는 수단입니다.</p>
<h3 id="핵심-옵션-3가지">핵심 옵션 3가지</h3>
<ul>
<li><strong>root</strong>: 대상 요소를 감지할 기준이 되는 요소입니다. (기본값: 브라우저 뷰포트)</li>
<li><strong>rootMargin</strong>: root 주위의 여백입니다. 마치 CSS의 margin처럼 감지 영역을 확장하거나 축소할 수 있어, 이미지가 화면에 보이기 조금 직전에 미리 로드를 시작하게 만들 수 있습니다.</li>
<li><strong>threshold</strong>: 대상 요소가 얼마나 노출되었을 때 콜백을 실행할지 결정합니다. (0.0 ~ 1.0)</li>
</ul>
<hr>
<h2 id="4-구현-코드">4. 구현 코드</h2>
<p>이미지가 뷰포트에 들어왔을 때 인증 요청을 보내고 브라우저에 렌더링하는 로직입니다.</p>
<pre><code class="language-typescript">interface LazyImageProps {
  img: HTMLImageElement;
  scrollContainer?: HTMLElement | null;
}

const setupLazyLoading = ({ img, scrollContainer }: LazyImageProps) =&gt; {
  const observer = new IntersectionObserver(
    (entries) =&gt; {
      entries.forEach(async (entry) =&gt; {
        // 1. 요소가 화면에 감지되었을 때만 실행
        if (entry.isIntersecting) {
          // 2. 한 번 감지되면 관찰 해제 (중복 로드 방지)
          observer.unobserve(img);

          const originalUrl = img.dataset.src;
          if (originalUrl) {
            try {
              // 인증 헤더가 포함된 이미지 fetch 함수 (예시)
              const blobUrl = await getLazyImageUrl(originalUrl);

              img.onload = () =&gt; {
                img.dataset.loading = &#39;false&#39;;
              };
              img.src = blobUrl;
            } catch (error) {
              console.error(&quot;이미지 로드 실패:&quot;, error);
              img.alt = &quot;이미지를 불러올 수 없습니다.&quot;;
            }
          }
        }
      });
    },
    {
      root: scrollContainer || null,
      rootMargin: &#39;200px&#39;, // 사용자 경험을 위해 200px 정도 미리 로드
      threshold: 0.1,
    }
  );

  observer.observe(img);
};</code></pre>
<p>왜 <code>data-src</code>를 사용하나요?
브라우저는 <code>&lt;img&gt;</code> 태그에 src 속성이 할당되는 즉시 리소스를 다운로드하려고 시도합니다. 이를 막기 위해 실제 URL을 data-src라는 커스텀 속성에 임시로 저장해 두었다가, 감지가 완료된 시점에만 src로 옮겨주는 방식을 사용합니다.</p>
<h2 id="5-결과-및-성과">5. 결과 및 성과</h2>
<p>Intersection Observer를 적용한 결과 다음과 같은 개선을 이뤄냈습니다.</p>
<ul>
<li><p>네트워크 트래픽 최적화: 사용자가 스크롤을 내리지 않으면 하단 이미지 요청을 아예 보내지 않습니다.</p>
</li>
<li><p>성능 향상: 이슈 상세 페이지 진입 시 초기 API 응답 및 렌더링 속도가 눈에 띄게 빨라졌습니다. (예: 이미지 5개 중 화면에 보이는 2개만 우선 로드)</p>
</li>
<li><p>세밀한 제어: rootMargin을 통해 사용자가 이미지를 인식하기 전 미리 로딩을 시작하여, &quot;끊기는 느낌&quot; 없는 자연스러운 지연 로딩을 구현했습니다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Husky에서 pre-commit 속도를 개선해보자]]></title>
            <link>https://velog.io/@jeong_eeeun/Husky%EC%97%90%EC%84%9C-pre-commit-%EC%86%8D%EB%8F%84%EB%A5%BC-%EA%B0%9C%EC%84%A0%ED%95%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@jeong_eeeun/Husky%EC%97%90%EC%84%9C-pre-commit-%EC%86%8D%EB%8F%84%EB%A5%BC-%EA%B0%9C%EC%84%A0%ED%95%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Mon, 05 Jan 2026 14:37:50 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요"><strong>1. 개요</strong></h2>
<p>현재 회사 프로젝트는 안정적인 코드 품질 유지를 위해 <code>husky</code>를 이용한 <strong>pre-push</strong> 체크를 수행하고 있다. 하지만 체크 과정에서 약 <strong>4분 30초</strong>가 소요됨에 따라 개발자의 집중력이 저하되고 배포 흐름이 끊기는 문제가 발생하여 이를 최적화하기로 했다.</p>
<h2 id="2-현황-및-문제점-분석"><strong>2. 현황 및 문제점 분석</strong></h2>
<h3 id="개선-전-소요-시간-total-약-4분-23초"><strong>개선 전 소요 시간 (Total: 약 4분 23초)</strong></h3>
<p>빌드 관련 작업(<code>build:packages</code> + <code>web</code> + <code>storybook</code>)이 전체의 <strong>43%</strong>를 차지하며, TypeScript Check 역시 빌드 시간과 맞먹는 시간을 소요하고 있었다.</p>
<table>
<thead>
<tr>
<th><strong>작업 항목</strong></th>
<th><strong>소요 시간</strong></th>
<th><strong>비중</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Prettier Check</td>
<td>~40초</td>
<td>15%</td>
</tr>
<tr>
<td>ESLint Check</td>
<td>~47초</td>
<td>18%</td>
</tr>
<tr>
<td>pnpm install</td>
<td>~4초</td>
<td>2%</td>
</tr>
<tr>
<td>build:packages</td>
<td>~58초</td>
<td>22%</td>
</tr>
<tr>
<td>web build</td>
<td>~37초</td>
<td>14%</td>
</tr>
<tr>
<td>storybook build</td>
<td>~18초</td>
<td>7%</td>
</tr>
<tr>
<td>TypeScript Check</td>
<td>~59초</td>
<td>22%</td>
</tr>
</tbody></table>
<blockquote>
<p>문제점: 파일 변경 여부와 상관없이 매번 전체 프로젝트에 대해 빌드 및 린트 검사를 수행하여 불필요한 리소스 낭비가 발생함.</p>
</blockquote>
<hr>
<h2 id="3-개선-방향-및-적용-내용"><strong>3. 개선 방향 및 적용 내용</strong></h2>
<h3 id="31-turborepo-캐싱-시스템-활용"><strong>3.1. Turborepo 캐싱 시스템 활용</strong></h3>
<p><code>turbo.json</code>의 캐시 정책을 설정하여, 소스 코드 변경이 없는 패키지는 빌드 과정을 건너뛰고 기존 <code>dist</code> 결과물을 재사용하도록 개선함.</p>
<pre><code class="language-json">// turbo.json
{
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;outputs&quot;: [&quot;dist/**&quot;],
      &quot;inputs&quot;: [&quot;src/**&quot;, &quot;package.json&quot;, &quot;tsconfig.json&quot;, &quot;vite.config.ts&quot;]
    },
    &quot;typecheck&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;cache&quot;: true,
      &quot;inputs&quot;: [&quot;src/**&quot;, &quot;tsconfig.json&quot;]
    },
  }
}
</code></pre>
<h3 id="32-git-diff-기반-선택적-검사-selective-check"><strong>3.2. Git Diff 기반 선택적 검사 (Selective Check)</strong></h3>
<p>전체 파일을 검사하던 방식에서 <code>git diff</code>를 활용해 <strong>실제 수정된 파일</strong>에 대해서만 Lint와 Prettier를 실행하도록 스크립트를 최적화함.</p>
<ul>
<li><strong>원격 브랜치(origin/dev) 대비 변경분만 추출</strong></li>
<li><strong>TypeScript/JavaScript 관련 파일 필터링</strong></li>
<li><strong>변경된 파일이 없을 경우 해당 스탭 스킵</strong></li>
</ul>
<pre><code class="language-bash"># 변경된 파일만 추출하는 로직 예시
REMOTE_BRANCH=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2&gt;/dev/null || echo &quot;origin/dev&quot;)
CHANGED_FILES=$(git diff --name-only &quot;$REMOTE_BRANCH&quot;...HEAD)
TS_FILES=$(echo &quot;$CHANGED_FILES&quot; | grep -E &#39;\.(ts|tsx|js|jsx)$&#39; | while read -r file; do
  [ -f &quot;$file&quot; ] &amp;&amp; echo &quot;$file&quot;
done)
# xargs를 이용해 변경된 파일만 타겟팅하여 검사
echo &quot;$TS_FILES&quot; | xargs -r npx eslint
</code></pre>
<hr>
<h2 id="4-개선-결과-및-기대-효과"><strong>4. 개선 결과 및 기대 효과</strong></h2>
<h3 id="41-소요-시간-비교-일반적인-코드-수정-시"><strong>4.1. 소요 시간 비교 (일반적인 코드 수정 시)</strong></h3>
<table>
<thead>
<tr>
<th><strong>작업 항목</strong></th>
<th><strong>개선 전</strong></th>
<th><strong>개선 후</strong></th>
<th><strong>비고</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Lint/Prettier</strong></td>
<td>~87초</td>
<td><strong>~15초</strong></td>
<td>변경 파일만 검사 (Diff)</td>
</tr>
<tr>
<td><strong>Packages Build</strong></td>
<td>~58초</td>
<td><strong>~1초</strong></td>
<td>Turbo Cache 활용</td>
</tr>
<tr>
<td><strong>Storybook Build</strong></td>
<td>~18초</td>
<td><strong>~1s</strong></td>
<td>Turbo Cache 활용</td>
</tr>
<tr>
<td><strong>TypeScript Check</strong></td>
<td>~59초</td>
<td><strong>~13s</strong></td>
<td>변경 파일만 검사</td>
</tr>
<tr>
<td><strong>총합</strong></td>
<td><strong>약 4분 23초</strong></td>
<td><strong>약 1분 11초</strong></td>
<td><strong>3분 12초 단축 (73%↓)</strong></td>
</tr>
</tbody></table>
<h3 id="42-문서-및-단순-수정-시의-이점"><strong>4.2. 문서 및 단순 수정 시의 이점</strong></h3>
<p>이번 개선의 가장 큰 장점 중 하나는 <strong>비코드 파일(Markdown, 단순 설정 등) 수정 시의 극적인 속도 향상</strong>이다.</p>
<ul>
<li><strong>Markdown(.md)이나 문서만 수정한 경우:</strong><ul>
<li>Lint/Prettier 대상에서 제외되어 즉시 통과</li>
<li>소스 코드 변경이 없으므로 Turborepo 빌드 및 Type Check가 <strong>1~2초 내에 캐시 히트로 완료</strong></li>
<li><strong>결과:</strong> push 프로세스가 <strong>10~20초 내외</strong>로 종료되어 문서 작업의 흐름을 방해하지 않음</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Type challenges 3]]></title>
            <link>https://velog.io/@jeong_eeeun/Type-challenge-3</link>
            <guid>https://velog.io/@jeong_eeeun/Type-challenge-3</guid>
            <pubDate>Mon, 15 Dec 2025 14:33:48 GMT</pubDate>
            <description><![CDATA[<h2 id="awaited">Awaited</h2>
<p>Promise와 같은 타입에 감싸인 타입이 있을 때, 안에 감싸인 타입이 무엇인지 어떻게 알 수 있을까요?</p>
<pre><code class="language-tsx">type ExampleType = Promise&lt;string&gt;

type Result = MyAwaited&lt;ExampleType&gt; // string</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type MyAwaited&lt;T extends PromiseLike&lt;any&gt;&gt; = T extends PromiseLike&lt;infer U&gt; ? U extends PromiseLike&lt;any&gt; ? MyAwaited&lt;U&gt; : U : never;</code></pre>
<ol>
<li><p>PromiseLike</p>
<p> Promise뿐만 아니라 then을 가진 커스텀 객체나 구형 라이브러리의 Promise까지 모두 포함. </p>
</li>
<li><p>MyAwaited&lt;U&gt;
만약 T가 Promise&lt;Promise&lt;string|number&gt;&gt; 일 경우를 대비하여 만약 U가 PromiseLike라면 재귀로 한 번 더 검증을 한다. </p>
</li>
</ol>
<h2 id="if">IF</h2>
<p>조건 <code>C</code>, 참일 때 반환하는 타입 <code>T</code>, 거짓일 때 반환하는 타입 <code>F</code>를 받는 타입 <code>If</code>를 구현하세요. <code>C</code>는 <code>true</code> 또는 <code>false</code>이고, <code>T</code>와 <code>F</code>는 아무 타입입니다.</p>
<pre><code class="language-tsx">type A = If&lt;true, &#39;a&#39;, &#39;b&#39;&gt;  // expected to be &#39;a&#39;
type B = If&lt;false, &#39;a&#39;, &#39;b&#39;&gt; // expected to be &#39;b&#39;</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type If&lt;C extends boolean, T, F&gt; = C extends true ? T : F;</code></pre>
<h2 id="concat">Concat</h2>
<p>JavaScript의 <code>Array.concat</code> 함수를 타입 시스템에서 구현하세요. 타입은 두 인수를 받고, 인수를 왼쪽부터 concat한 새로운 배열을 반환해야 합니다.</p>
<pre><code class="language-tsx">type Result = Concat&lt;[1], [2]&gt; // expected to be [1, 2]</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type Concat&lt;T extends readonly any[], U extends readonly any[]&gt; = [...T, ...U]; </code></pre>
<p>타입스크립트에서도 타입에 스프레드 연산자 가능! 정확하겐 <strong>Variadic Tuple Types (가변 튜플 타입)</strong> 을 사용한다고 함.</p>
<h2 id="push">Push</h2>
<p><code>Array.push</code>의 제네릭 버전을 구현하세요.</p>
<pre><code class="language-tsx">type Result = Push&lt;[1, 2], &#39;3&#39;&gt; // [1, 2, &#39;3&#39;]</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type Push&lt;T extends readonly any[], U&gt; = [...T, U];</code></pre>
<p>다른사람 풀이</p>
<pre><code class="language-tsx">type Push&lt;T extends readonly unknown[], U&gt; = [...T, U];</code></pre>
<p>나는 any를 썼고 다른 사람은 unknown을 썼다.</p>
<p>이 둘의 큰 차이는 뭘까? </p>
<ol>
<li><p>타입스크립트 컴파일러에서 </p>
<p> any: 타입 검사를 무시함. 어떤 타입이 들어와도 상관 안함. ⇒ 그래서 런타임 에러가 발생할 수 있음. </p>
<p> unknown: any와 같이 모든 타입을 받을 수 있다. 하지만 쓰려면 반드시 타입 확인이 필요하다. </p>
</li>
<li><p>타입 오염</p>
<p> 제네릭이나 교차 타입(&amp;)에서 any는 다른 타입을 덮어쓴다. </p>
<pre><code class="language-tsx"> type T1 = any &amp; string;     // 결과: any (string이 잡아먹힘)
 type T2 = unknown &amp; string; // 결과: string (string이 살아남음)</code></pre>
</li>
</ol>
<p>any보다 unknown이 안전하기 때문에 unknown을 쓰는걸 추천한다고 함.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Type challenges 2]]></title>
            <link>https://velog.io/@jeong_eeeun/Type-challenges-2</link>
            <guid>https://velog.io/@jeong_eeeun/Type-challenges-2</guid>
            <pubDate>Wed, 10 Dec 2025 15:21:30 GMT</pubDate>
            <description><![CDATA[<h2 id="first-of-array">First of Array</h2>
<p>배열(튜플) <code>T</code>를 받아 첫 원소의 타입을 반환하는 제네릭 <code>First&lt;T&gt;</code>를 구현하세요.</p>
<pre><code class="language-tsx">type arr1 = [&#39;a&#39;, &#39;b&#39;, &#39;c&#39;]
type arr2 = [3, 2, 1]

type head1 = First&lt;arr1&gt; // expected to be &#39;a&#39;
type head2 = First&lt;arr2&gt; // expected to be 3</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type First&lt;T extends any[]&gt; = T extends [infer F, ...any[]] ? F : never;</code></pre>
<ol>
<li><p>infer F</p>
<p> <code>T extends [infer F, …any[]] ? F : never</code> </p>
<p> infer F 는 해당 원소 타입의 첫 부분을 이런 타입으로 받아오겠다는 뜻. T가 [infer F, …any[]] 로 이루어져 있는지 확인함. </p>
 <aside>

<p> 이때 …any[]는 …rest라고 생각하면 된다.</p>
 </aside>
</li>
<li><p>extends</p>
<p> 개인적으로 자주 헷갈리는 문법.</p>
<ol>
<li><p>제네릭에서 사용</p>
<p> 해당 타입이 최소한 어떤 조건을 만족해야하는지 제한을 거는 것.</p>
</li>
<li><p>조건부 타입</p>
<p> 위에 사용한 extends가 해당 부분이다. 삼항 연산자를 생각하면 된다. </p>
</li>
<li><p>상속</p>
<p> <code>interface A extends B</code> 일때 사용. A가 B를 부모로 가진다고 생각하면 됨.</p>
</li>
</ol>
</li>
</ol>
<h2 id="length-of-tuple">Length of Tuple</h2>
<p>배열(튜플)을 받아 길이를 반환하는 제네릭 <code>Length&lt;T&gt;</code>를 구현하세요.</p>
<pre><code class="language-tsx">type tesla = [&#39;tesla&#39;, &#39;model 3&#39;, &#39;model X&#39;, &#39;model Y&#39;]
type spaceX = [&#39;FALCON 9&#39;, &#39;FALCON HEAVY&#39;, &#39;DRAGON&#39;, &#39;STARSHIP&#39;, &#39;HUMAN SPACEFLIGHT&#39;]

type teslaLength = Length&lt;tesla&gt;  // expected 4
type spaceXLength = Length&lt;spaceX&gt; // expected 5</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type Length&lt;T extends readonly any[]&gt; = T[&#39;length&#39;];</code></pre>
<ol>
<li><p>T[’length’]</p>
<p> 튜플은 타입스크립트의 타입 시스템에서 객체처럼 취급 받는다고 한다. 그리고 그 안에 <strong>length</strong>라는 속성이 실제로 정의 되어 있다. </p>
<p> 아래와 같은 느낌으로 이루어져 있다고 함. </p>
<pre><code class="language-tsx"> type MyTuple = [&#39;A&#39;, &#39;B&#39;, &#39;C&#39;];

 // 💡 타입스크립트가 바라보는 MyTuple의 내부 모습 (개념적)
 interface MyTupleConcept {
   0: &#39;A&#39;;        // 인덱스 0의 타입
   1: &#39;B&#39;;        // 인덱스 1의 타입
   2: &#39;C&#39;;        // 인덱스 2의 타입
   length: 3;     // ✨ 핵심: length 속성이 구체적인 숫자 리터럴 타입 &#39;3&#39;으로 고정됨

   // ... 그 외 push, pop, map 같은 배열 메서드들도 포함됨
 }</code></pre>
</li>
</ol>
<p>튜플에 대해서 조금 더 설명하자면 튜플에서 타입의 마지막에 ‘?’ 를 사용하여 optional 값을 줄 수 있다.</p>
<pre><code class="language-tsx">type Either2dOr3d = [number, number, number?];

function setCoordinate(coord: Either2dOr3d) {
  const [x, y, z] = coord;

                                // const z: number | undefined

  console.log(`Provided coordinates had ${coord.length} dimensions`);

                                                                                // (property) length: 2 | 3
}</code></pre>
<h2 id="exclude">Exclude</h2>
<p><code>T</code>에서 <code>U</code>에 할당할 수 있는 타입을 제외하는 내장 제네릭 <code>Exclude&lt;T, U&gt;</code>를 이를 사용하지 않고 구현하세요.</p>
<pre><code class="language-tsx">type Result = MyExclude&lt;&#39;a&#39; | &#39;b&#39; | &#39;c&#39;, &#39;a&#39;&gt; // &#39;b&#39; | &#39;c&#39;</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type MyExclude&lt;T, U&gt; = T extends U ? never : T;</code></pre>
<ol>
<li><p>분산(Distribution): ****제네릭 <code>T</code>가 유니언 타입(예: <code>&#39;a&#39; | &#39;b&#39;</code>)으로 들어오면, TypeScript는 각 구성 요소에 대해 개별적으로 조건을 검사</p>
</li>
<li><p>조건 검사</p>
<p> <code>T</code>의 개별 요소가 <code>U</code>에 할당 가능하다면(<code>T extends U</code>), <code>never</code>를 반환.</p>
<p> 할당 불가능하다면, <code>T</code> 그대로를 반환</p>
</li>
<li><p><code>never</code>의 소멸</p>
<p> 유니언 타입 결과에서 <code>never</code>는 무시되어 사라진다. (예: <code>string | never</code>는 <code>string</code>이 됨)</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Type-challenges 1]]></title>
            <link>https://velog.io/@jeong_eeeun/Type-challenges</link>
            <guid>https://velog.io/@jeong_eeeun/Type-challenges</guid>
            <pubDate>Tue, 09 Dec 2025 15:10:53 GMT</pubDate>
            <description><![CDATA[<h2 id="pick">Pick</h2>
<p><code>T</code>에서 <code>K</code> 프로퍼티만 선택해 새로운 오브젝트 타입을 만드는 내장 제네릭 <code>Pick&lt;T, K&gt;</code>을 이를 사용하지 않고 구현하세요.</p>
<pre><code class="language-tsx">interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyPick&lt;Todo, &#39;title&#39; | &#39;completed&#39;&gt;

const todo: TodoPreview = {
    title: &#39;Clean room&#39;,
    completed: false,
}</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type MyPick&lt;T, K keyof T&gt; = {[P in K]: T[P]};</code></pre>
<ol>
<li><p>keyof T</p>
<p> 여기서 <code>keyof T</code>가 핵심. </p>
<p> K 는 T의 키의 일부여야하기 때문에 이러한 제약 조건을 둬야한다. </p>
</li>
<li><p>Mapped Type </p>
<p> K는 유니온 타입. in 은 for…in과 같이 동작합니다. 따라서 ‘title’ | ‘completed’ 를 순환해서 P에 변수를 선언한다고 생각하면 됨.</p>
</li>
</ol>
<h2 id="readonly">Readonly</h2>
<p><code>T</code>의 모든 프로퍼티를 읽기 전용(재할당 불가)으로 바꾸는 내장 제네릭 <code>Readonly&lt;T&gt;</code>를 이를 사용하지 않고 구현하세요.</p>
<pre><code class="language-tsx">interface Todo {
  title: string
  description: string
}

const todo: MyReadonly&lt;Todo&gt; = {
  title: &quot;Hey&quot;,
  description: &quot;foobar&quot;
}

todo.title = &quot;Hello&quot; // Error: cannot reassign a readonly property
todo.description = &quot;barFoo&quot; // Error: cannot reassign a readonly property</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type MyReadonly&lt;T&gt; = {
    readonly [P in keyof T]: T[P];
}</code></pre>
<ol>
<li><p>keyof T</p>
<p> T의 key를 P에 선언해줘야함.</p>
</li>
</ol>
<h2 id="tuple-to-object">Tuple to Object</h2>
<p>배열(튜플)을 받아, 각 원소의 값을 key/value로 갖는 오브젝트 타입을 반환하는 타입을 구현하세요.</p>
<pre><code class="language-jsx">const tuple = [&#39;tesla&#39;, &#39;model 3&#39;, &#39;model X&#39;, &#39;model Y&#39;] as const

type result = TupleToObject&lt;typeof tuple&gt; // expected { &#39;tesla&#39;: &#39;tesla&#39;, &#39;model 3&#39;: &#39;model 3&#39;, &#39;model X&#39;: &#39;model X&#39;, &#39;model Y&#39;: &#39;model Y&#39;}</code></pre>
<p>정답</p>
<pre><code class="language-tsx">type TupleToObject&lt;T extends PropertyKey[]&gt; = {[P in T[number]: P};</code></pre>
<ol>
<li><p>PropertyKey?</p>
<p> 객체의 키에 올 수 있는 타입을 타입스크립트 내에서 선언해둔것.</p>
<p> <code>string | number | symbol</code> 이라고 함.</p>
</li>
<li><p>T[number]</p>
<p> 배열 타입 T에 인덱스 접근 이라고 함. 이러면 배열 안에 잇는 모든 요소들의 타입이 유니온으로 추출됨.</p>
<p> 이때 배열이 일반 배열인지 튜플인지에 따라서 결과가 다르게 나온다고 함. </p>
<ol>
<li><p>일반 배열</p>
<pre><code class="language-tsx"> type FruitList = string[];

 type Fruit = FruitList[number];
 // string;</code></pre>
<p> 배열 0번째도 string, 100번째, 1000번째도 string이니까 타입은 string이 된다. </p>
</li>
<li><p>튜플일 경우 (as const)</p>
<pre><code class="language-tsx"> const fruitList = [&#39;banana&#39;, &#39;apple&#39;, &#39;grape&#39;] as const;

 type Fruit = typeof fruitList[number];
 // &#39;banana&#39; | &#39;apple&#39; | &#39;grape&#39;</code></pre>
<p> 값을 타입으로 변환하고 싶을때 사용한다. </p>
<p> typeof fruitList ⇒ readonly [’banana’, ‘apple’, ‘grape’]</p>
<p> [number]로 접근하게 되면 타입스크립트는 0번째엔 banana, 1번째는 apple.. 그래서 유니온 타입으로 반환이 되는 것이다.</p>
</li>
</ol>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[design system repo library version up]]></title>
            <link>https://velog.io/@jeong_eeeun/design-system-repo-library-version-up</link>
            <guid>https://velog.io/@jeong_eeeun/design-system-repo-library-version-up</guid>
            <pubDate>Sat, 18 Oct 2025 12:09:41 GMT</pubDate>
            <description><![CDATA[<h3 id="storybook-8-→-9-마이그레이션">Storybook 8 → 9 마이그레이션</h3>
<p>Storybook 8에서 9로 마이그레이션 하는겸 react 버전도 같이 올림 (18 → 19)</p>
<p>기본적으로 크게 달라진건 </p>
<ul>
<li>@storybook/blocks 제거 → 이젠 @storybook/addon-docs/blocks 를 사용해야함. @storybook/blocks가 @storybook/addon-docs/blocks 내부에 들어감.</li>
<li>@storybook/react → @storybook/react-vite or @storybook/react-webpack5 이렇게 상세하게 작성해야함.</li>
<li>@storybook/addon-essentials 제거, <code>@storybook/addon-viewport</code>, <code>@storybook/addon-controls</code>, <code>@storybook/addon-interactions</code>, <code>@storybook/addon-actions</code> 이런것들은 storybook core로 들어갔다고 함.</li>
</ul>
<p>그냥 npx storybook@latest upgrade 하면 알아서 잘 해줌. </p>
<h3 id="react-18-→-react-19-마이그레이션">react 18 → react 19 마이그레이션</h3>
<p>역시 그냥 딸깍 해주면 된다. </p>
<p>달라진점은 기존 react 18에서는 children.props를 하면 unknown 타입이 아니였는데 19부턴 unknown으로 잡혀서 타입을 좁혀줘야한다곤 하는데…</p>
<p>react 공식문서를 보니 Children API를 사용하는건 불안정하기 때문에 좋지 않은 방법이라고 함. 추후에 변경 할 예정! (children에 넣을 배열을 props로 넘긴다던가…등등)</p>
<h3 id="vite-5-→-vite-7">vite 5 → vite 7</h3>
<p>너무 확 올렸긴한데 react를 19로 올리면서 vite도 버전을 올렸어야했었음</p>
<p>그러다보니 또 에러가 발생하는데 vite 7은 node version이 22 이상이 되어야한다고 함. </p>
<p>그래서 바로 nvm 설치해서 node 22 설치</p>
<h3 id="node-20-→-22로-변경">node 20 → 22로 변경</h3>
<p>nvm install 22 라고 설치하면 됨</p>
<p>이러면 끝인데</p>
<p>다른 레포에선 node 20으로 고정되어 써야하기 때문에 해당 레포에만 node 22 버전을 써야한다. </p>
<p>그렇기 때문에 .nvmrc 파일을 해당 레포 루트에 둠.</p>
<p>원하는 버전을 적어두면 된다. </p>
<pre><code>// .nvmrc
v22.20.0</code></pre><p>이러면 nvm use를 할때 알아서 .nvmrc 파일을 감지해서 해당 버전으로 설정해준다고 한다. </p>
<p>하지만 터미널을 새로 띄울때마다 nvm use 명령어를 해줘야하기 때문에 번거롭다. </p>
<p>그렇기 때문에 다른 사람들이 잘 짜놓은 script를 이용하면 된다. </p>
<p><code>vim ~/.zshrc</code> 로 가서 아래 내용을 입력해주면 된다. </p>
<pre><code class="language-bash"># place this after nvm initialization!
autoload -U add-zsh-hook
load-nvmrc() {
  local node_version=&quot;$(nvm version)&quot;
  local nvmrc_path=&quot;$(nvm_find_nvmrc)&quot;

  if [ -n &quot;$nvmrc_path&quot; ]; then
    local nvmrc_node_version=$(nvm version &quot;$(cat &quot;${nvmrc_path}&quot;)&quot;)

    if [ &quot;$nvmrc_node_version&quot; = &quot;N/A&quot; ]; then
      nvm install
    elif [ &quot;$nvmrc_node_version&quot; != &quot;$node_version&quot; ]; then
      nvm use
    fi
  elif [ &quot;$node_version&quot; != &quot;$(nvm version default)&quot; ]; then
    echo &quot;Reverting to nvm default version&quot;
    nvm use default
  fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc</code></pre>
<p>그런 뒤 터미널을 켜보면 자동으로 node 버전이 세팅된다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[es-toolkit > toCamelCaseKeys]]></title>
            <link>https://velog.io/@jeong_eeeun/es-toolkit-toCamelCaseKeys</link>
            <guid>https://velog.io/@jeong_eeeun/es-toolkit-toCamelCaseKeys</guid>
            <pubDate>Mon, 06 Oct 2025 09:32:16 GMT</pubDate>
            <description><![CDATA[<p><a href="https://github.com/toss/es-toolkit/pull/1362">https://github.com/toss/es-toolkit/pull/1362</a></p>
<h3 id="기존-타입">기존 타입</h3>
<pre><code class="language-tsx">
type CamelCase&lt;S extends string&gt; = S extends `${infer P1}_${infer P2}${infer P3}`
  ? `${Lowercase&lt;P1&gt;}${Uppercase&lt;P2&gt;}${CamelCase&lt;P3&gt;}`
  : Lowercase&lt;S&gt;;

type ToCamelCaseKeys&lt;T&gt; = T extends any[]
  ? Array&lt;ToCamelCaseKeys&lt;T[number]&gt;&gt;
  : T extends Record&lt;string, any&gt;
    ? { [K in keyof T as CamelCase&lt;string &amp; K&gt;]: ToCamelCaseKeys&lt;T[K]&gt; }
    : T; 
</code></pre>
<p>type CamelCase를 보면 snake_case만 고려가 되어 있고 PascalCase는 고려가 되어 있지 않는 걸 확인할 수 있다. </p>
<h3 id="변경된-점">변경된 점</h3>
<pre><code class="language-tsx">
type SnakeToCamel&lt;S extends string&gt; = S extends `${infer H}_${infer T}`
  ? `${Lowercase&lt;H&gt;}${Capitalize&lt;SnakeToCamel&lt;T&gt;&gt;}`
  : Lowercase&lt;S&gt;;

type PascalToCamel&lt;S extends string&gt; = S extends `${infer F}${infer R}` ? `${Lowercase&lt;F&gt;}${R}` : S;

/** If it&#39;s snake_case, apply the snake_case rule; otherwise, just lowercase the first letter (including PascalCase → camelCase). */
type AnyToCamel&lt;S extends string&gt; = S extends `${string}_${string}` ? SnakeToCamel&lt;S&gt; : PascalToCamel&lt;S&gt;;

type ToCamelCaseKeys&lt;T&gt; = T extends any[]
  ? Array&lt;ToCamelCaseKeys&lt;T[number]&gt;&gt;
  : T extends Record&lt;string, any&gt;
    ? { [K in keyof T as AnyToCamel&lt;Extract&lt;K, string&gt;&gt;]: ToCamelCaseKeys&lt;T[K]&gt; }
    : T;</code></pre>
<p>PascalCase를 고려하도록 함. </p>
<h3 id="배운점">배운점</h3>
<ul>
<li><p>infer</p>
<p>  <strong>설명</strong></p>
<ul>
<li><p><code>infer</code>는 <strong>조건부 타입(conditional type)</strong> 안에서 새로운 타입 변수를 선언할 때 사용함.</p>
</li>
<li><p>보통 <code>extends</code>와 함께 쓰여서, 타입의 일부분을 추출(infer = 추론)하는 역할을 함.</p>
</li>
<li><p>런타임 코드가 아니라 타입 레벨에서 &quot;이 부분을 타입 변수로 잡아서 써라&quot;라고 함.</p>
</li>
<li><p><em>예시*</em></p>
<pre><code class="language-tsx">type GetFirstArg&lt;T&gt; = T extends (arg: infer U, ...args: any[]) =&gt; any ? U : never;

type A = GetFirstArg&lt;(x: number, y: string) =&gt; void&gt;; // number
type B = GetFirstArg&lt;(x: boolean) =&gt; void&gt;;           // boolean</code></pre>
<p>👉 함수 타입에서 첫 번째 인자의 타입을 <code>U</code>로 추론해내는 패턴.</p>
</li>
</ul>
</li>
<li><p>Lowercase</p>
<p>  <strong>설명</strong></p>
<ul>
<li><p>타입스크립트가 제공하는 <strong>내장 유틸리티 타입</strong> 중 하나.</p>
</li>
<li><p>문자열 리터럴 타입의 모든 문자를 소문자로 변환해줌.</p>
</li>
<li><p>런타임 문자열이 아니라 <strong>타입 레벨에서만 작동</strong>한다는 게 포인트.</p>
</li>
<li><p><em>예시*</em></p>
<pre><code class="language-tsx">type A = Lowercase&lt;&#39;HELLO&#39;&gt;;     // &quot;hello&quot;
type B = Lowercase&lt;&#39;UserName&#39;&gt;;  // &quot;username&quot;</code></pre>
</li>
</ul>
</li>
<li><p>Capitalize</p>
<p>  <strong>설명</strong></p>
<ul>
<li><p>이것도 내장 유틸리티 타입.</p>
</li>
<li><p>문자열 리터럴 타입의 <strong>첫 글자만 대문자</strong>로 바꿔줌. 나머지는 그대로 둠.</p>
</li>
<li><p><em>예시*</em></p>
<pre><code class="language-tsx">type A = Capitalize&lt;&#39;hello&#39;&gt;;   // &quot;Hello&quot;
type B = Capitalize&lt;&#39;userName&#39;&gt;; // &quot;UserName&quot;</code></pre>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] JSX render won't allow webkitdirectory and directory to be used]]></title>
            <link>https://velog.io/@jeong_eeeun/React-JSX-render-wont-allow-webkitdirectory-and-directory-to-be-used</link>
            <guid>https://velog.io/@jeong_eeeun/React-JSX-render-wont-allow-webkitdirectory-and-directory-to-be-used</guid>
            <pubDate>Wed, 28 Feb 2024 09:28:43 GMT</pubDate>
            <description><![CDATA[<h1 id="문제명"><strong>문제명</strong></h1>
<p>JSX render won&#39;t allow webkitdirectory and directory to be used.</p>
<h2 id="개요"><strong>개요</strong></h2>
<ul>
<li>React v18</li>
<li>input의 속성인 webkitdirectory를 사용할 수 없는 현상</li>
</ul>
<p>=&gt; React내에서 typescript를 사용시 발생하는 현상. <code>DetailedHTMLProps&lt;InputHTMLAttributes&lt;HTMLInputElement&gt;, HTMLInputElement&gt;</code> 타입에 <code>webkitdirectory</code>랑 <code>directory</code> 타입이 존재하지 않아서 생기는 오류이다.</p>
<h2 id="해결법"><strong>해결법</strong></h2>
<ol>
<li><pre><code class="language-tsx">if (inputRef.current &amp;&amp; isUploadedDir) {
   inputRef.current.setAttribute(&#39;mozdirectory&#39;, &#39;mozdirectory&#39;);
   inputRef.current.setAttribute(&#39;webkitdirectory&#39;, &#39;webkitdirectory&#39;); 
   inputRef.current.setAttribute(&#39;directory&#39;, &#39;directory&#39;);
 }
 return ( &lt;input ref={inputRef} id={id} ... /&gt;
)</code></pre>
</li>
<li><p>직접 타입을 선언해주는 방법</p>
</li>
</ol>
<pre><code class="language-tsx">// index.d.ts
import &#39;react&#39;;

declare module &#39;react&#39; {
  interface InputHTMLAttributes&lt;T&gt; extends HTMLAttributes&lt;T&gt; {
    directory?: string;
    webkitdirectory?: string;
    mozdirectory?: string;
  }
}

// FileUploadMenu.tsx
&lt;input
  id={id}
  type=&quot;file&quot;
  className={customStyles.fileInput}
  multiple
  accept=&quot;*&quot;
  onChange={onChange}
  webkitdirectory={isUploadedDir ? &#39;true&#39; : undefined}
  mozdirectory={isUploadedDir ? &#39;true&#39; : undefined}
  directory={isUploadedDir ? &#39;true&#39; : undefined}
/&gt;</code></pre>
<h2 id="참조"><strong>참조</strong></h2>
<p><a href="https://github.com/facebook/react/issues/3468">JSX render won&#39;t allow webkitdirectory and directory to be used · Issue #3468 · facebook/react</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[24.02.13]]></title>
            <link>https://velog.io/@jeong_eeeun/24.02.13</link>
            <guid>https://velog.io/@jeong_eeeun/24.02.13</guid>
            <pubDate>Tue, 13 Feb 2024 15:40:38 GMT</pubDate>
            <description><![CDATA[<h3 id="배운점">배운점</h3>
<ul>
<li><p>Detected multiple Jotai instances. It may cause unexpected behavior with the default store.</p>
<p>  context가 중첩되면 나오는 에러. Provider 파일 내부에 사용자 정보를 불러오는 로직을 추가했는데 이것 때문에 문제가 발생하는 것 같다. </p>
<pre><code class="language-tsx">  // providers.tsx
  &lt;Provider&gt;
      &lt;InitProvider /&gt;
      {children}
  &lt;/Provider&gt;</code></pre>
<pre><code class="language-tsx">  // init-provider
  function InitProvider() {
    const setUserInfo = useSetAtom(setUserInfoAction);

    useEffect(() =&gt; {
      setUserInfo();
    }, []);

    return &lt;&gt;&lt;/&gt;;
  }</code></pre>
<p>  해당 글을 보고 해결 </p>
<p>  <a href="https://velog.io/@leehyewon0531/Detected-multiple-Jotai-instances.-It-may-cause-unexpected-behavior-with-the-default-store">https://velog.io/@leehyewon0531/Detected-multiple-Jotai-instances.-It-may-cause-unexpected-behavior-with-the-default-store</a></p>
</li>
<li><p>로컬의 포트를 변경하려고하면 <code>package.json</code> 의 scripts 명령어를 변경시킨다.</p>
<pre><code class="language-json">  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev -p 3100&quot;,
    &quot;build&quot;: &quot;next build&quot;,
    &quot;start&quot;: &quot;next start -p 3100&quot;,
  }

  &quot;dev&quot;: &quot;next dev -p [원하는 포트]&quot;</code></pre>
<p>  이렇게 하면 teamcity 설정 때문에 502 에러가 발생한다. 그러니까 start일 경우에는 3000 포트로 돌아가게 해놓자.</p>
<p>  dev와 start의 차이점 알아보기</p>
</li>
<li><p><a href="http://aaa.test.com">aaa.test.com</a> 에서 쿠키를 삭제하면 <a href="http://bbb.test.com">bbb.test.com</a> 에서는 삭제 되지 않는 현상. 어떻게 해결할 것인가</p>
<p>   ⇒ 삭제할 때 설정한 도메인과 같은 도메인을 설정에 넘겨준다. 그래야 다른 도메인으로 이동했을때도 잘 동작하는 것 같기도?</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[24.01.31]]></title>
            <link>https://velog.io/@jeong_eeeun/24.01.31</link>
            <guid>https://velog.io/@jeong_eeeun/24.01.31</guid>
            <pubDate>Wed, 31 Jan 2024 13:51:00 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-할-일">오늘 할 일</h2>
<ul>
<li>폴더 구조 표시 방법 생각해보기 (path를 이용해서 트리구조를 만들기) <strong>(내일)</strong></li>
<li>로그아웃 기능 만들기 <strong>(완료)</strong></li>
<li>로그인 후 유저 정보 불러오기 <strong>(완료)</strong></li>
<li>파일 업로드시 hidden 파일은 불러오지 않도록(전송되지 않도록) 로직 작성하기 <strong>(완료)</strong></li>
</ul>
<h3 id="배운점">배운점</h3>
<ul>
<li><p>webkitdirectory 에서 업로드시 alert창이 뜨는데 이는 새로운 보안정책이므로 없앨 수 있는 방법이 없다.</p>
</li>
<li><p>File Object는 스프레드 연산자를 써서 새로운 객체에 담으면 빈 객체를 반환한다.</p>
<pre><code class="language-tsx">  const files = e.target.files;

  setFileList(
    Array.from(files).map(file =&gt; ({
          ...file,
          path: file.webkitRelativePath
      })),
  );

  console.log(fileList); // { path: ~ }</code></pre>
<ul>
<li><p>그 이유는 뭘까요?</p>
<p>In JavaScript, the File object is generally created by user input through a file input element in HTML, and it represents a file selected by the user. The properties and methods of the File object are determined by the File API specification.</p>
<p>The File object itself is not directly mutable, meaning you cannot add or modify properties on it directly. This is by design, as the properties of a File object are defined by the File API specification, and altering them could lead to security issues or unexpected behavior.</p>
<p>라고 gpt가 답변을 해줌.</p>
</li>
</ul>
</li>
</ul>
<p>  암튼 해결책은</p>
<pre><code>```tsx
Object.assign(file, {});
```

를 이용해서 객체를 복사하면 됩니다.

아니면</code></pre><pre><code class="language-tsx">      let file = new File([blob], &#39;flower.jpg&#39;);
      file.custom = &quot;another properties&quot;;</code></pre>
<p>  으로 파일 객체 커스텀 하기...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ 24.01.23]]></title>
            <link>https://velog.io/@jeong_eeeun/24.01.23</link>
            <guid>https://velog.io/@jeong_eeeun/24.01.23</guid>
            <pubDate>Tue, 23 Jan 2024 16:05:58 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-할-일">오늘 할 일</h2>
<ul>
<li>파일 업로드 drag n drop <strong>(~ing)</strong></li>
</ul>
<h3 id="배운점">배운점</h3>
<ul>
<li>drag n drop 으로 파일 및 폴더를 업로드시 <strong>webkitGetAsEntry</strong> 메소드를 이용해야한다.<ul>
<li>이걸로 만든 directoryReader(entry.createReader)는 비동기처리를 해줘야해서 Promise로 감싸줘야한다.. 맞니? 내가 이해한게 맞을까?</li>
</ul>
</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>