<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jhwest.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Fri, 02 Oct 2026 08:25:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jhwest.log</title>
            <url>https://velog.velcdn.com/images/jhwest-dev/profile/4e60fe00-4e0a-451f-8d52-95a10d5a7e6f/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jhwest.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jhwest-dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[런치캐치 개발일지 #6] 찜 목록·쿠폰함 구현과 디자인 변경]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-6-%EC%B0%9C-%EB%AA%A9%EB%A1%9D%EC%BF%A0%ED%8F%B0%ED%95%A8-%EA%B5%AC%ED%98%84%EA%B3%BC-%EB%94%94%EC%9E%90%EC%9D%B8-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-6-%EC%B0%9C-%EB%AA%A9%EB%A1%9D%EC%BF%A0%ED%8F%B0%ED%95%A8-%EA%B5%AC%ED%98%84%EA%B3%BC-%EB%94%94%EC%9E%90%EC%9D%B8-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Fri, 02 Oct 2026 08:25:15 GMT</pubDate>
            <description><![CDATA[<p>이번 스프린트에서 사용자 앱의 찜 목록과 쿠폰함·QR을 만들었다. 시안을 화면으로 옮기면서 사용자 입장에서 다시 보고 디자인을 바꾼 부분이 있다. 각 화면에서 어떤 점이 불편했고 어떻게 바꿨는지 적어 둔다.</p>
<h2 id="1-찜-목록-위치">1. 찜 목록 위치</h2>
<h3 id="시안">시안</h3>
<ul>
<li>찜 목록은 단독 화면이고, 헤더 오른쪽에 &quot;쿠폰함&quot; 링크가 있다.</li>
<li>쿠폰함은 사용 가능 / 사용 내역 두 탭이다.</li>
<li>쿠폰함 화면에는 찜 목록으로 가는 길이 없다. 마이페이지의 &quot;찜한 캠페인&quot; 메뉴로만 갈 수 있다.</li>
</ul>
<h3 id="변경-내용">변경 내용</h3>
<p>쿠폰함 탭을 <strong>찜 목록 / 사용 가능 / 사용 내역</strong> 세 탭으로 나눴다.</p>
<table>
<thead>
<tr>
<th>탭</th>
<th>주소</th>
</tr>
</thead>
<tbody><tr>
<td>찜 목록</td>
<td><code>/coupons/wishlist</code></td>
</tr>
<tr>
<td>사용 가능</td>
<td><code>/coupons</code></td>
</tr>
<tr>
<td>사용 내역</td>
<td><code>/coupons/history</code></td>
</tr>
</tbody></table>
<ul>
<li>찜 목록은 쿠폰을 받는 곳이다. 찜 → 받기 → 사용이 한 탭 안에서 이어지는 게 자연스럽다.</li>
<li>시안 구조에서는 쿠폰함을 눌러도 찜 목록으로 갈 방법이 없어서, 사용자가 마이페이지까지 가야 한다.</li>
<li>하단 쿠폰함을 누르면 점심때 가장 자주 찾는 <strong>사용 가능</strong>(QR) 탭이 열린다. 11:00 선착순 때는 스와이프 헤더의 &quot;찜 목록&quot;이나 10:50 알림으로 들어오므로 찜 목록 탭이 바로 열린다.</li>
<li>탭마다 주소가 있어서 알림이나 다른 화면에서 원하는 탭으로 바로 열 수 있다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/a525c93e-d4b6-4f67-aef9-3ab8e8df76b4/image.png" alt=""></li>
</ul>
<h2 id="2-찜-카드-구조">2. 찜 카드 구조</h2>
<h3 id="시안-1">시안</h3>
<ul>
<li>카드(사진, 가게 이름, &quot;메뉴 · N원 할인&quot;, 상태) 아래에 사용 시간과 버튼이 카드 밖에 따로 있다.</li>
<li>마감, 하루 한도 상태에도 회색 버튼([마감], [오늘 발급 한도 3개 완료])이 있다.</li>
<li>받는 중 상태는 따로 그려져 있지 않다.</li>
</ul>
<h3 id="변경-내용-1">변경 내용</h3>
<p><strong>1. 사용 시간과 버튼을 카드 안으로 넣기</strong>
버튼이 카드 밖에 있으니 [받기]가 위 카드 것인지 아래 카드 것인지 헷갈렸다. 흰 카드 하나가 가게 하나가 되도록 묶었다.</p>
<p><strong>2. 오른쪽에 원래 가격과 할인가 정렬</strong>
쿠폰은 하루 3개까지만 받을 수 있어서, 찜이 여러 개면 그중 어떤 걸 받을지 골라야 한다. &quot;N원 할인&quot;이 문장 중간에 있으면 카드끼리 비교하기 어렵고, 실제로 얼마를 내는지도 계산해야 한다. 가격을 오른쪽 끝에 맞추니 훑어보기만 해도 비교된다.</p>
<p><strong>3. 마감, 하루 한도 상태 버튼 삭제</strong>
&quot;마감 · 수량 소진&quot; 상태 줄과 [마감] 버튼이 같은 정보를 두 번 보여줬다. 큰 회색 버튼이 화면을 차지해서 받을 수 있는 카드가 덜 눈에 띄었다. 오픈 전 [11:00 오픈] 버튼은 남겼다. 11:00에 같은 자리가 [받기]로 바뀌어서, 사용자가 미리 손가락을 올려둘 수 있다.</p>
<p><strong>4. 받을 수 있는 카드 맨 위로 정렬</strong>
찜한 순서대로 보여주면 11:00 선착순에 마감된 카드를 지나서 [받기]를 찾아야 한다. 받을 수 있음 → 받음 → 마감·한도 순으로 정렬했다.</p>
<p><strong>5. 받는 중에는 버튼 색 유지</strong>
처음에는 버튼을 비활성(<code>disabled</code>)으로 막았는데, 비활성 버튼은 회색이라 마감처럼 보였다. 디자인 시스템 버튼에 Loading 속성이 있어서, 색은 그대로 두고 글자 자리에 도는 아이콘을 보여주는 <code>loading</code> 옵션을 만들었다.</p>
<table align="center">
  <tr>
    <th>수정 전</th>
    <th>수정 후</th>
  </tr>
  <tr>
    <td><img src="https://velog.velcdn.com/images/jhwest-dev/post/2e22ba17-da63-482c-a6e5-87d94aa0dcd5/image.png" height="480" /></td>
    <td><img src="https://velog.velcdn.com/images/jhwest-dev/post/50be7b1b-2621-4812-9f5f-16797db6f781/image.png" height="480" /></td>
  </tr>
</table>


<h2 id="3-찜-삭제">3. 찜 삭제</h2>
<h3 id="시안-2">시안</h3>
<p>찜 목록 하단에 &quot;찜 취소는 가게 상세에서 할 수 있어요&quot;라고 적혀 있다.</p>
<h3 id="변경-내용-2">변경 내용</h3>
<p>카드 오른쪽 위 ✕ 버튼으로 바로 삭제하고, &quot;찜 목록에서 삭제했어요 [되돌리기]&quot; 토스트를 4초 보여준다. 받은 쿠폰 카드에는 ✕가 없다.</p>
<ul>
<li>요구사항에는 &quot;찜한 캠페인 삭제: 사용자가 찜한 캠페인을 목록에서 삭제한다&quot;가 필수로 있었다. 시안과 요구사항이 서로 달라서 요구사항을 따랐다.</li>
<li>확인 모달 대신 되돌리기를 넣어, 실수로 눌러도 바로 되돌릴 수 있게 했다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/d421cab9-fd4a-4fb9-a776-f3af75e2ff1f/image.png" alt=""></p>
<h2 id="4-qr-화면">4. QR 화면</h2>
<h3 id="시안-3">시안</h3>
<p>사용 가능 쿠폰에서 [QR 보기]를 누르면 &quot;쿠폰 사용&quot; 화면으로 넘어간다. 뒤로 가기를 눌러야 쿠폰함으로 돌아온다.</p>
<h3 id="변경-내용-3">변경 내용</h3>
<p>QR을 별도 화면 대신 <strong>바텀시트</strong>로 띄웠다. 사용 가능 쿠폰 카드를 누르면 쿠폰함 위로 시트가 올라오고, 그 안에 QR을 보여준다.</p>
<p>QR 화면은 매장 계산대 앞에서 직원에게 보여주는 화면이다. 뒤에 사람이 기다리고 있을 수 있어서, 빨리 열고 빨리 닫는 게 가장 중요하다고 봤다.</p>
<ul>
<li><strong>열기:</strong> 작은 [QR 보기] 버튼이 아니라 카드 어디를 눌러도 시트가 올라온다. 급하게 열 때 정확히 버튼을 노리지 않아도 된다.</li>
<li><strong>닫기:</strong> 시트를 끌어내리거나 바깥을 누르면 바로 닫힌다. 화면이 바뀌지 않아서 보고 있던 쿠폰함 그대로 돌아온다.</li>
<li><strong>다른 쿠폰 보기:</strong> 쿠폰이 여러 장이면 닫고 바로 옆 카드를 누르면 된다. 별도 화면에서는 뒤로 갔다가 다시 들어가야 했다.</li>
<li><strong>뒤로 가기 문제:</strong> 별도 화면은 알림이나 링크로 바로 들어왔을 때 이전 페이지가 없어서, 뒤로 가기를 누르면 앱 밖으로 나갈 수 있었다. 시트는 화면 이동이 없어서 이 문제가 없다.</li>
</ul>
<p>시트 안에서는 직원이 봐야 하는 QR을 가장 크게 두고, 남은 시간은 QR 아래 작은 글자로 내렸다.</p>
<table align="center">
  <tr>
    <th>시안 (별도 화면)</th>
    <th>변경 (바텀시트)</th>
  </tr>
  <tr>
    <td><img src="https://velog.velcdn.com/images/jhwest-dev/post/f3b8901a-a17e-4bb2-942e-1b71c0f1f018/image.png
" height="480" /></td>
    <td><img src="https://velog.velcdn.com/images/jhwest-dev/post/9217f8b4-81c5-476b-ad92-9e6ea8c9f983/image.png
" height="480" /></td>
  </tr>
</table>


<h2 id="5-멘토링-반영">5. 멘토링 반영</h2>
<p>멘토링에서 받은 피드백 중 사용자 앱 부분을 반영했다.</p>
<h3 id="첫-탭-이름">첫 탭 이름</h3>
<p>첫 탭 이름을 &quot;스와이프&quot;에서 &quot;오늘 점심&quot;으로 바꿨다. &quot;스와이프&quot;는 화면을 쓰는 방법(넘기기)만 설명하고, 이 화면에서 무엇을 하는지는 알려주지 않는다. 이 화면은 10:00~12:59에만 열리고 오늘 점심 쿠폰 포스터를 보고 찜하는 곳이다. 서빙 시간 외에 나오는 &quot;내일 10시에 만나요&quot; 화면과도 자연스럽게 이어진다.</p>
<p>아이콘도 좌우 화살표(⇄)에서 숟가락·포크로 바꿨다. 좌우 화살표는 &quot;교환&quot;처럼 보일 수 있어서, 이름과 같은 뜻의 아이콘을 골랐다.</p>
<h3 id="찜-배지">찜 배지</h3>
<p>헤더 찜 배지에서 주황 동그라미를 빼고 주황색 숫자만 남겼다. 멘토링에서 숫자만 있어도 된다는 피드백을 받았다.</p>
<h3 id="스와이프-화면-튐">스와이프 화면 튐</h3>
<p>카드를 넘기기 전과 후의 포스터 iframe을 비교해 보니, 뒤에 있던 카드가 앞으로 올 때 이미 그려진 포스터를 옮기지 않고 새로 그리고 있었다. 넘길 때마다 포스터 두 장을 다시 그리고 사진도 다시 요청했다.</p>
<p>앞 카드와 뒤 카드를 한 목록에서 카드마다 같은 key로 그리도록 바꿨다. 뒤 카드가 앞으로 와도 같은 요소라 크기와 위치만 바뀐다. 세 번째 카드까지 숨겨서 미리 그려 두었다.</p>
<pre><code class="language-tsx">{cards.slice(currentIndex, currentIndex + 3).map((card, position) =&gt; (
  &lt;div key={card.serveId} {...(position === 0 ? cardProps : {})}&gt;
    &lt;PosterCard card={card} /&gt;
  &lt;/div&gt;
))}</code></pre>
<p>수정 후 넘길 때 다시 그리는 포스터는 0장, 사진 재요청도 0번이 됐다.</p>
<h2 id="느낀점">느낀점</h2>
<p>사용자 입장에서 화면을 하나씩 다시 고쳐 나가니 점점 나아지는 게 보여서 작업이 재밌어지고 있다. 오늘 멘토링에서는 생각하지 못했던 부분들을 짚어 주셔서, 앞으로 어떤 부분을 어떻게 개발해 나가야 할지 조금 정리가 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[런치캐치 개발일지 #5] 로그인 흐름과 스와이프 피드]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-5-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9D%90%EB%A6%84%EA%B3%BC-%EC%8A%A4%EC%99%80%EC%9D%B4%ED%94%84-%ED%94%BC%EB%93%9C</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-5-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9D%90%EB%A6%84%EA%B3%BC-%EC%8A%A4%EC%99%80%EC%9D%B4%ED%94%84-%ED%94%BC%EB%93%9C</guid>
            <pubDate>Thu, 01 Oct 2026 00:08:51 GMT</pubDate>
            <description><![CDATA[<p>오늘은 사용자 앱의 입구(스플래시 → 로그인 → 온보딩)와 핵심 화면인 스와이프 피드를 만들었다. API 연동 전이라 로그인과 피드 데이터는 모두 mock이다.</p>
<h2 id="0-작업-방식-figma-mcp로-디자인-읽기">0. 작업 방식: Figma MCP로 디자인 읽기</h2>
<p>이번 작업은 Claude Code에 Figma MCP를 연결해서 진행했다. 화면을 만들 때마다 Figma 링크의 노드를 읽어서, 크기·색·글자 스펙을 눈대중이 아니라 실제 값으로 가져왔다.</p>
<h3 id="활용-방법">활용 방법</h3>
<ul>
<li><strong>화면 스펙 읽기:</strong> 노드를 읽으면 요소별 크기, 여백, 색 토큰, 글자 크기가 나온다. 예를 들어 포스터 카드는 폭 328, 사진 높이 320, 모서리 12, 가게 이름 24px Bold처럼 정확한 값으로 확인했다.</li>
<li><strong>디자인 시스템과 비교:</strong> 화면 값이 디자인 시스템 스케일에 있는지 확인했다. 화면은 좌우 여백 20px인데 그리드는 16px이고, 버튼 모서리 14px은 Radius 스케일(4, 8, 12, 16)에 없었다. 이런 경우는 디자인 시스템 기준으로 맞췄다.</li>
<li><strong>디자인 수정:</strong> 스플래시 버튼 삭제처럼 Figma 자체를 고칠 때는 Codex에 Figma MCP 작업을 맡겼다.</li>
</ul>
<p>이 과정에서 화면만 봤다면 지나쳤을 불일치도 꽤 찾았다.</p>
<ul>
<li>성별 선택 칩 중 &quot;기타&quot;가 레이어 이름은 아직 &quot;입력 안 함&quot;이고 스타일도 달랐다.</li>
<li>마스코트 컴포넌트 이름이 <code>Coupon Success</code>인데, 실제 그림은 X 표시된 쿠폰(쿠폰 없음)이었다.</li>
<li>디자인 시스템 안에서도 Button 컴포넌트(모서리 14)와 Radius 스케일이 서로 달랐다.</li>
</ul>
<p>코드에서는 모두 디자인 시스템 기준으로 맞춰서 구현했다.</p>
<h2 id="1-로그인-흐름">1. 로그인 흐름</h2>
<h3 id="로그인-상태-관리-context">로그인 상태 관리 (Context)</h3>
<p>로그인한 사용자 정보는 라우트 가드, 온보딩 완료 처리, 마이페이지 등 여러 화면에서 필요하다. props로 한 단계씩 내려주면 중간 컴포넌트가 쓰지도 않는 값을 계속 넘겨야 한다.</p>
<p>그래서 React Context로 앱 전체에 공유했다.</p>
<table>
<thead>
<tr>
<th>파일</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>authContext.ts</code></td>
<td>로그인 정보가 지나갈 통로를 만든다</td>
</tr>
<tr>
<td><code>AuthProvider.tsx</code></td>
<td>실제 값(<code>useState</code>)을 들고 아래로 전달한다</td>
</tr>
<tr>
<td><code>useAuth.ts</code></td>
<td>화면에서 <code>const { user, login } = useAuth()</code>로 꺼내 쓴다</td>
</tr>
</tbody></table>
<p><code>AuthProvider</code>를 앱 가장 바깥에 두니, 어느 화면에서든 로그인 정보를 바로 받을 수 있게 됐다.</p>
<h3 id="상태별-라우트-가드">상태별 라우트 가드</h3>
<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>스와이프로 이동</td>
</tr>
</tbody></table>
<p>화면마다 조건을 넣지 않고, 라우터에서 화면 묶음 단위로 감쌌다.</p>
<pre><code class="language-tsx">{ element: &lt;RequireAuth access=&quot;guest&quot; /&gt;, children: [스플래시, 로그인] },
{ element: &lt;RequireAuth access=&quot;onboarding&quot; /&gt;, children: [온보딩] },
{ element: &lt;RequireAuth access=&quot;member&quot; /&gt;, children: [탭 화면] },</code></pre>
<p>앞으로 화면을 추가할 때는 알맞은 묶음 안에 넣기만 하면 된다.</p>
<h3 id="스플래시-화면-하단-버튼-삭제">스플래시 화면 하단 버튼 삭제</h3>
<p>처음 디자인은 스플래시에 [시작하기] 버튼이 있고, 버튼을 누르면 로그인 화면으로 넘어가는 구조였다. 그런데 로그인 화면에도 소개 문구와 [카카오로 시작하기] 버튼이 있어서, 사용자는 비슷한 화면을 두 번 보고 버튼도 두 번 눌러야 했다.</p>
<p>스플래시는 앱을 열 때 브랜드를 잠깐 보여주는 화면이면 충분하다고 판단했다. 그래서 버튼을 없애고, 1.5초 뒤 로그인 화면으로 자동으로 넘어가게 했다.</p>
<p>넘어갈 때는 <code>replace</code>로 이동해서 방문 기록에서 스플래시를 지웠다. 로그인 화면에서 뒤로 가기를 눌러도 스플래시가 다시 나오지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/9cfe359a-aa42-4499-9773-68d0602ad183/image.png" alt=""></p>
<h2 id="2-스와이프-피드">2. 스와이프 피드</h2>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/31e7ca31-f783-4ec4-9b0c-a093388e089a/image.png" alt=""></p>
<h3 id="드래그-제스처-pointer-events">드래그 제스처 (Pointer Events)</h3>
<p>일정이 빠듯해서 버튼으로 찜/패스를 먼저 완성하고, 드래그 제스처는 그다음에 붙였다. 제스처도 결국 같은 찜/패스 함수를 부르기 때문에, 버튼으로 동작을 먼저 확인해 두니 제스처는 &quot;끌기와 방향 판단&quot;만 추가하면 됐다.</p>
<p>제스처 라이브러리는 새 패키지라 팀 합의가 필요해서, 브라우저 기본 기능인 Pointer Events로 직접 만들었다.</p>
<pre><code class="language-ts">onPointerDown  // 시작 위치 기억
onPointerMove  // 움직인 만큼 카드 이동, 살짝 기울이기
onPointerUp    // 카드 폭의 30% 넘게 밀었으면 찜/패스, 아니면 제자리로</code></pre>
<p><code>touch-action: pan-y</code>를 줘서 위아래로 끌면 화면이 스크롤되고, 좌우로 끌 때만 카드가 움직이게 했다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/1d30a5f6-6bed-422f-8189-ddf54785fd40/image.png" alt=""></p>
<h3 id="화면-높이에-맞춘-카드-크기">화면 높이에 맞춘 카드 크기</h3>
<p>이 부분에서 시행착오가 있었다.</p>
<ol>
<li><strong>고정 높이:</strong> Figma대로 카드 높이를 고정했더니, 긴 폰에서 카드 아래가 130px 정도 비었다.</li>
<li><strong>남는 공간 채우기:</strong> 카드가 남는 높이를 모두 차지하게 했더니, 이번엔 사진이 세로로 길쭉해졌다.</li>
<li><strong>4:5까지만:</strong> 사진이 4:5 비율까지만 늘어나게 제한하고, 남는 공간은 카드 묶음 위아래로 나눴다.</li>
</ol>
<pre><code class="language-tsx">&lt;div className=&quot;@container ...&quot;&gt;
  &lt;div className=&quot;max-h-[calc(125cqw+10.5rem)] flex-1 ...&quot;&gt;</code></pre>
<p><code>125cqw</code>는 컨테이너 폭의 125%라서, 사진 높이가 폭 × 1.25를 넘지 않는다. 짧은 폰에서는 사진이 줄어서 스크롤 없이 한 화면에 들어가고, 긴 폰에서도 길쭉해지지 않는다.</p>
<h3 id="찜-피드백-배지와-토스트">찜 피드백 (배지와 토스트)</h3>
<p>찜을 해도 카드만 넘어가서 저장됐는지 알기 어려웠다. 그렇다고 스와이프는 연속으로 하는 동작이라, 찜할 때마다 알림을 띄우면 방해가 된다.</p>
<p>그래서 두 가지로 나눴다.</p>
<ul>
<li><strong>헤더 찜 개수 배지:</strong> 찜할 때마다 숫자가 늘면서 살짝 튄다. 방해 없이 쌓이는 게 보인다.</li>
<li><strong>첫 찜에만 토스트:</strong> 그날 처음 찜했을 때만 &quot;찜 목록에 담았어요 · 11:00부터 받을 수 있어요&quot;를 띄운다.</li>
</ul>
<p>런치캐치에서 찜은 발급이 아니라 11:00 선착순에 참여하는 것이라, 이 규칙을 처음에 한 번 알려주는 게 중요했다.</p>
<h2 id="느낀-점">느낀 점</h2>
<p>이번 작업에서 Figma MCP를 써보니 정말 편했다. Claude Code가 Figma를 직접 읽어 들이고, 그 내용을 바탕으로 코드를 짠다는 게 신기했다. 디자인을 보고 크기와 색을 하나하나 옮겨 적던 과정이 줄어들면서, 개발이 훨씬 편해지고 있다는 게 느껴졌다.</p>
<blockquote>
<p>오늘 작업 중 겪은 의존성 문제는 <a href="https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-1-%EB%82%B4-%EC%BB%B4%ED%93%A8%ED%84%B0%EC%97%90%EC%84%9C%EB%A7%8C-%EB%90%98%EB%8A%94-Storybook-%ED%83%80%EC%9E%85-pnpm-%EC%9C%A0%EB%A0%B9-%EC%9D%98%EC%A1%B4%EC%84%B1">런치캐치 트러블슈팅 #1</a>에 따로 정리했다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[런치캐치 트러블슈팅 #1] 내 컴퓨터에서만 되는 Storybook 타입 (pnpm 유령 의존성)]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-1-%EB%82%B4-%EC%BB%B4%ED%93%A8%ED%84%B0%EC%97%90%EC%84%9C%EB%A7%8C-%EB%90%98%EB%8A%94-Storybook-%ED%83%80%EC%9E%85-pnpm-%EC%9C%A0%EB%A0%B9-%EC%9D%98%EC%A1%B4%EC%84%B1</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-1-%EB%82%B4-%EC%BB%B4%ED%93%A8%ED%84%B0%EC%97%90%EC%84%9C%EB%A7%8C-%EB%90%98%EB%8A%94-Storybook-%ED%83%80%EC%9E%85-pnpm-%EC%9C%A0%EB%A0%B9-%EC%9D%98%EC%A1%B4%EC%84%B1</guid>
            <pubDate>Wed, 30 Sep 2026 08:41:30 GMT</pubDate>
            <description><![CDATA[<p>런치캐치 프론트엔드는 pnpm workspaces + Turborepo 모노레포다. 사용자, 점주, 관리자 앱이 <code>apps/</code> 아래에 나뉘어 있고, 공통 컴포넌트는 <code>packages/ui</code>에 있다. 각 앱의 컴포넌트는 Storybook 스토리 파일로 문서화한다.</p>
<h2 id="문제">문제</h2>
<p>사용자 앱에 컴포넌트 스토리 파일(<code>*.stories.tsx</code>)을 추가하고 PR을 올렸다. 내 컴퓨터에서는 타입 검사와 빌드가 모두 통과했는데, 팀원 컴퓨터에서는 타입 에러가 났다.</p>
<pre><code>Cannot find module &#39;@storybook/react-vite&#39; or its corresponding type declarations.</code></pre><p>스토리 파일은 이렇게 Storybook 타입을 가져온다.</p>
<pre><code class="language-tsx">import type { Meta, StoryObj } from &#39;@storybook/react-vite&#39;;</code></pre>
<h2 id="원인">원인</h2>
<p>사용자 앱 <code>package.json</code>에 <code>@storybook/react-vite</code>를 선언하지 않았다. 그런데도 내 컴퓨터에서 동작한 이유는 <strong>예전 설치 파일이 남아 있었기 때문</strong>이었다.</p>
<ol>
<li>모노레포로 바꾸기 전에는 루트에 앱이 하나였고, Storybook 패키지를 npm으로 루트에 설치했다.</li>
<li>모노레포로 전환하면서 루트 <code>package.json</code>에서 Storybook이 빠졌지만, pnpm은 자기가 설치하지 않은 옛 폴더를 지우지 않는다. 그래서 내 컴퓨터 루트 <code>node_modules/@storybook/</code>이 그대로 남았다.</li>
<li>사용자 앱에서 <code>@storybook/react-vite</code>를 찾을 때, 앱 폴더에 없으니 상위 폴더로 올라가다 루트의 옛 폴더를 <strong>우연히 찾았다.</strong></li>
<li>팀원은 모노레포 전환 뒤에 새로 설치해서 루트에 그 폴더가 없었고, 그래서 에러가 났다.</li>
</ol>
<table>
<thead>
<tr>
<th></th>
<th>내 컴퓨터</th>
<th>팀원 컴퓨터</th>
</tr>
</thead>
<tbody><tr>
<td>루트에 옛 <code>@storybook</code> 폴더</td>
<td>있음</td>
<td>없음</td>
</tr>
<tr>
<td>사용자 앱에서 찾을 때</td>
<td>루트 폴더를 우연히 찾음</td>
<td>못 찾음</td>
</tr>
<tr>
<td>타입 검사</td>
<td>통과</td>
<td>에러</td>
</tr>
</tbody></table>
<p>선언하지 않은 패키지가 우연히 찾아지는 <strong>유령 의존성</strong>이 실제로 일어난 경우다. pnpm은 선언한 패키지만 쓸 수 있게 막아주지만, pnpm이 관리하지 않는 옛 폴더까지는 막지 못했다.</p>
<h2 id="해결">해결</h2>
<h3 id="1-선언-추가">1. 선언 추가</h3>
<p>스토리 파일은 개발할 때만 쓰고 실제 앱 빌드에는 들어가지 않아서 <code>devDependencies</code>에 추가했다.</p>
<pre><code class="language-json">&quot;devDependencies&quot;: {
  &quot;@storybook/react-vite&quot;: &quot;catalog:&quot;
}</code></pre>
<h3 id="2-내-설치-환경을-팀원과-같게-맞추기">2. 내 설치 환경을 팀원과 같게 맞추기</h3>
<p>옛 폴더가 남아 있으면 비슷한 문제가 또 가려질 수 있어서, <code>node_modules</code>를 전부 지우고 새로 설치했다.</p>
<pre><code class="language-bash">rm -rf node_modules apps/*/node_modules packages/*/node_modules
pnpm install
pnpm exec turbo run typecheck lint build --force</code></pre>
<p>새로 설치한 상태에서 전체 타입 검사, 린트, 빌드, Storybook 빌드가 모두 통과해서, 선언이 빠진 패키지가 더 없다는 것도 확인했다.</p>
<h2 id="배운-점">배운 점</h2>
<ul>
<li>새 패키지를 import할 때는 그 앱의 <code>package.json</code>에 선언이 있는지 먼저 확인한다.</li>
<li>&quot;내 컴퓨터에서는 되는데&quot;가 나오면, 먼저 <code>node_modules</code>를 새로 설치해서 팀원과 같은 상태로 맞춰본다.</li>
<li>프로젝트 구조를 크게 바꾼 뒤(예: 모노레포 전환)에는 <code>node_modules</code>를 한 번 새로 설치해 두는 게 안전하다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[런치캐치 개발일지 #4] pnpm 모노레포 구조 파악과 공통 BottomNav 만들기]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-4-pnpm-%EB%AA%A8%EB%85%B8%EB%A0%88%ED%8F%AC-%EA%B5%AC%EC%A1%B0-%ED%8C%8C%EC%95%85%EA%B3%BC-%EA%B3%B5%ED%86%B5-BottomNav-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-4-pnpm-%EB%AA%A8%EB%85%B8%EB%A0%88%ED%8F%AC-%EA%B5%AC%EC%A1%B0-%ED%8C%8C%EC%95%85%EA%B3%BC-%EA%B3%B5%ED%86%B5-BottomNav-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 29 Sep 2026 04:47:24 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-모노레포인가">왜 모노레포인가</h2>
<p>런치캐치는 사용자, 점주, 관리자 세 역할이 로그인부터 화면까지 완전히 독립적이다. 처음엔 앱 하나에서 role별 폴더로 나누고 있었는데, 구조를 정하면서 세 가지 방식을 비교했다.</p>
<table>
<thead>
<tr>
<th></th>
<th>단일 프로젝트</th>
<th>모노레포</th>
<th>멀티레포</th>
</tr>
</thead>
<tbody><tr>
<td>role 간 경계</td>
<td>ESLint 규칙으로 막음</td>
<td>구조로 보장</td>
<td>완전 분리</td>
</tr>
<tr>
<td>번들 / 배포</td>
<td>하나로 묶임</td>
<td>앱별 분리</td>
<td>앱별 분리</td>
</tr>
<tr>
<td>공통 코드 공유</td>
<td>쉬움</td>
<td>쉬움</td>
<td>npm 배포 + 버전 관리 필요</td>
</tr>
<tr>
<td>초기 설정</td>
<td>간단</td>
<td>번거로움</td>
<td>저장소마다 반복</td>
</tr>
</tbody></table>
<p>결정적이었던 건 두 가지다.</p>
<ul>
<li><strong>번들 분리:</strong> 단일 프로젝트에서 lazy loading을 해도 빌드 결과물 안에는 관리자 코드가 들어 있다. 모노레포에서는 사용자 앱 빌드에 관리자 코드가 아예 없다.</li>
<li><strong>공유는 쉽게:</strong> 멀티레포라면 공통 UI를 npm에 배포하고 세 앱에서 버전을 올려야 한다. 모노레포는 <code>packages/ui</code>를 고치면 바로 반영되고, 공통 컴포넌트 수정과 앱 수정을 한 PR에 담을 수 있다.</li>
</ul>
<p>대신 초기 설정 비용, 앱마다 늘어나는 배포 설정, 공통 패키지를 고치면 세 앱에 다 영향이 가는 점은 감수하기로 했다. 3명이 앱 하나씩 맡는 구조라 독립성과 공유를 둘 다 챙길 수 있는 모노레포가 가장 잘 맞았다.</p>
<h2 id="모노레포를-동작시키는-도구">모노레포를 동작시키는 도구</h2>
<p>모노레포는 폴더를 이렇게 두자는 구성 방식일 뿐이라, 폴더만 나눠서는 아무것도 연결되지 않는다. 실제로 동작하게 하려면 도구가 두 가지 필요하다.</p>
<table>
<thead>
<tr>
<th>역할</th>
<th>도구</th>
<th>설정 파일</th>
</tr>
</thead>
<tbody><tr>
<td>앱과 공통 패키지 연결</td>
<td>pnpm workspaces</td>
<td><code>pnpm-workspace.yaml</code></td>
</tr>
<tr>
<td>여러 앱의 명령 한 번에 실행</td>
<td>Turborepo</td>
<td><code>turbo.json</code></td>
</tr>
</tbody></table>
<ul>
<li><strong>pnpm workspaces:</strong> 앱에서 <code>import { BottomNav } from &#39;@repo/ui&#39;</code>라고 쓰면 <code>packages/ui</code> 폴더를 찾아가도록 연결해준다.</li>
<li><strong>Turborepo:</strong> 앱마다 들어가서 명령을 돌릴 필요 없이, 루트에서 <code>pnpm lint</code> 한 번으로 모든 앱과 패키지의 린트를 돌려준다. 바뀌지 않은 곳은 이전 결과를 재사용한다.</li>
</ul>
<h2 id="npm과-pnpm의-차이">npm과 pnpm의 차이</h2>
<p>npm과 pnpm은 하는 일은 같은데, 차이 중 하나가 <strong>유령 의존성</strong>을 막느냐다.</p>
<h3 id="유령-의존성이란">유령 의존성이란</h3>
<p>A라는 라이브러리만 설치했는데, A가 동작하려면 B가 필요해서 B도 같이 딸려 설치됐다고 해보자.</p>
<pre><code>package.json 목록:  A
실제 설치된 것:     A, B   ← B는 A 때문에 따라옴</code></pre><p>npm은 설치한 걸 전부 <code>node_modules</code>에 평평하게 늘어놓는다.</p>
<pre><code>node_modules/
├── A
└── B   ← 목록에 없는데 여기 있음</code></pre><p>그래서 내 코드에서 <code>import B</code>를 쓰면 <strong>그냥 된다.</strong> 목록에 없는데도.</p>
<p>문제는 나중이다. A가 업데이트되면서 B를 더 이상 안 쓰게 되면 B도 설치되지 않는다. 그러면 <code>import B</code>를 쓰던 내 코드가 <strong>아무것도 안 바꿨는데 갑자기 깨진다.</strong> 목록에 없는데 존재하던 B, 이게 유령 의존성이다.</p>
<h3 id="pnpm은-목록에-있는-것만-보여준다">pnpm은 목록에 있는 것만 보여준다</h3>
<p>pnpm은 package.json에 적은 패키지만 내 코드에서 import할 수 있게 한다. B는 목록에 없으니 처음부터 에러가 난다.</p>
<table>
<thead>
<tr>
<th></th>
<th>npm</th>
<th>pnpm</th>
</tr>
</thead>
<tbody><tr>
<td>목록에 없는 패키지 import</td>
<td>우연히 될 수 있음</td>
<td>에러</td>
</tr>
<tr>
<td>문제를 발견하는 시점</td>
<td>나중에 갑자기</td>
<td>처음부터 바로</td>
</tr>
</tbody></table>
<h3 id="우리-프로젝트에서는">우리 프로젝트에서는</h3>
<pre><code>packages/ui 목록:  lucide-react  ✅
apps/user 목록:    lucide-react  ❌</code></pre><p>공통 패키지에는 아이콘 라이브러리 <code>lucide-react</code>가 설치돼 있지만, 사용자 앱 package.json에는 없다. 그래서 사용자 앱에서 아이콘을 직접 import하려면 사용자 앱 package.json에도 따로 추가해야 한다.</p>
<h2 id="프로젝트-구조">프로젝트 구조</h2>
<pre><code>lc-frontend/
├── apps/
│   ├── user/        # 사용자 앱 (내 담당)
│   ├── owner/       # 점주 앱
│   ├── admin/       # 관리자 앱
│   └── storybook/   # 컴포넌트 미리보기
└── packages/
    ├── ui/             # 공통 컴포넌트, 디자인 토큰, 테마
    ├── utils/          # 순수 함수
    ├── tsconfig/       # 공통 TS 설정
    └── eslint-config/  # 공통 ESLint 설정</code></pre><p>각 앱 안은 이렇게 나뉜다.</p>
<pre><code>apps/user/src/
├── app/         # 라우터, 전역 Provider
├── layout/      # 여러 페이지를 감싸는 틀
├── pages/       # 주소 하나 = 파일 하나
├── features/    # 기능 단위 UI, 상태, 로직
├── components/  # 앱 안에서 여러 기능이 같이 쓰는 컴포넌트
├── api/         # 서버 요청 함수와 mock 데이터
└── auth/        # 로그인 상태, 접근 제어</code></pre><p>정리하면서 기억해둘 규칙은 두 가지였다.</p>
<p>첫째, <strong>page는 얇게, feature는 두껍게.</strong> page는 layout과 feature를 조립만 하고, 실제 화면과 로직은 feature에 둔다. import는 <code>app → pages → features → components</code> 한 방향으로만 하고, feature끼리는 서로 import하지 않는다.</p>
<p>둘째, <strong>컴포넌트 위치는 &quot;누가 쓰냐&quot;로 정한다.</strong></p>
<table>
<thead>
<tr>
<th>쓰는 곳</th>
<th>위치</th>
</tr>
</thead>
<tbody><tr>
<td>두 개 이상의 앱</td>
<td><code>packages/ui</code></td>
</tr>
<tr>
<td>한 앱 안의 여러 기능</td>
<td><code>apps/&lt;앱&gt;/src/components</code></td>
</tr>
<tr>
<td>한 기능만</td>
<td><code>apps/&lt;앱&gt;/src/features/&lt;기능&gt;</code></td>
</tr>
</tbody></table>
<h2 id="앱별-path-alias">앱별 path alias</h2>
<p>구조를 정리하는 사이에 팀에서 path alias 규칙이 추가됐다. 앱마다 자기 <code>src</code>를 가리키는 별칭이 생겼다.</p>
<table>
<thead>
<tr>
<th>앱</th>
<th>별칭</th>
</tr>
</thead>
<tbody><tr>
<td>user</td>
<td><code>@user/</code></td>
</tr>
<tr>
<td>owner</td>
<td><code>@owner/</code></td>
</tr>
<tr>
<td>admin</td>
<td><code>@admin/</code></td>
</tr>
</tbody></table>
<pre><code class="language-tsx">// 같은 폴더나 하위 폴더는 상대 경로
import { TermsItem } from &#39;./TermsItem&#39;;

// 상위 폴더는 별칭으로 (../ 는 ESLint 에러)
import { CouponCard } from &#39;@user/components/CouponCard&#39;;</code></pre>
<p>흔히 쓰는 <code>@/</code> 하나로 통일하지 않은 이유가 재밌었다. <strong>Storybook이 세 앱의 컴포넌트를 한꺼번에 읽기 때문에</strong>, 전부 <code>@/</code>면 어느 앱의 <code>src</code>인지 구분할 수 없다. 앱마다 이름을 다르게 하니 다른 앱의 별칭을 import하면 타입 에러가 난다.</p>
<p>별칭은 각 앱의 <code>tsconfig.app.json</code>의 <code>paths</code> 한 곳에만 정의하고, Vite와 Storybook은 <code>resolve.tsconfigPaths: true</code>로 그 값을 그대로 읽는다.</p>
<h2 id="공통-컴포넌트는-어디까지-빼야-할까">공통 컴포넌트는 어디까지 빼야 할까</h2>
<p>사용자 화면을 만들기 전에 고민이 하나 있었다. 점주 화면도 같은 톤으로 디자인돼 있는데, 공통 컴포넌트부터 다 빼고 시작해야 할까?</p>
<p>그런데 우리 Figma는 한 사람이 전체를 통일해서 만든 게 아니라, 각자 맡은 부분을 디자인한 것이다. 실제로 사용자 디자인과 점주 디자인이 같이 쓰는 Figma 컴포넌트는 0개였다. 게다가 점주와 사용자는 어떻게 보면 완전히 다른 앱이다.</p>
<p>그래서 억지로 다 맞춰서 빼기보다, <strong>두 앱에서 제일 확실하게 공통인 하단 탭바만</strong> 먼저 공통 컴포넌트로 빼기로 했다.</p>
<p>버튼, 입력창, 토스트, 바텀시트처럼 관리자, 점주, 사용자가 모두 쓰는 기본 컴포넌트는 지난주에 다른 팀원이 <code>packages/ui</code>에 만들어뒀다. 이건 새로 만들지 않고 그대로 가져다 쓰면 된다.</p>
<h2 id="bottomnav-만들기">BottomNav 만들기</h2>
<p>사용자 탭바와 점주 탭바의 Figma 스펙을 수치로 비교했다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>사용자</th>
<th>점주</th>
</tr>
</thead>
<tbody><tr>
<td>크기</td>
<td>366 × 68 floating dock</td>
<td>동일</td>
</tr>
<tr>
<td>모서리 / 그림자</td>
<td>24px</td>
<td>동일</td>
</tr>
<tr>
<td>선택 상태</td>
<td>brand-600, Bold</td>
<td>동일</td>
</tr>
<tr>
<td>탭</td>
<td>스와이프·탐색·쿠폰함·마이</td>
<td>홈·캠페인·통계·매장관리·더보기</td>
</tr>
<tr>
<td>아이콘</td>
<td>20px</td>
<td>22px</td>
</tr>
</tbody></table>
<p>틀은 같고 탭 구성만 달랐다. 그래서 <strong>틀은 <code>packages/ui</code>에 두고, 탭 항목은 각 앱에서 넘기는</strong> 구조로 만들었다.</p>
<pre><code class="language-tsx">&lt;BottomNav
  items={[
    { value: &#39;swipe&#39;, label: &#39;스와이프&#39;, icon: &lt;ArrowRightLeft /&gt; },
    { value: &#39;explore&#39;, label: &#39;탐색&#39;, icon: &lt;Compass /&gt; },
    { value: &#39;coupon&#39;, label: &#39;쿠폰함&#39;, icon: &lt;Ticket /&gt; },
    { value: &#39;my&#39;, label: &#39;마이&#39;, icon: &lt;User /&gt; },
  ]}
  value={currentTab}
  onValueChange={handleTabChange}
/&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/e8ba4ca6-8699-4d7c-bd6f-e7d514ecbaab/image.png" alt=""></p>
<p><em>사용자 앱: 스와이프 · 탐색 · 쿠폰함 · 마이</em></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/3ba1a941-7c45-4ef6-9ae5-c3e1eaffb9f0/image.png" alt=""></p>
<p><em>점주 앱: 홈 · 캠페인 · 통계 · 매장관리 · 더보기</em></p>
<p>만들면서 정한 것들이다.</p>
<ul>
<li><strong>탭 구성은 각 앱이 넘겨준다.</strong> 공통 컴포넌트는 탭바 모양만 그리고, 어떤 탭이 들어갈지(이름, 아이콘)는 사용자 앱과 점주 앱이 각자 정한다.</li>
<li><strong>탭을 눌렀을 때 페이지 이동도 각 앱이 처리한다.</strong> 공통 컴포넌트는 &quot;이 탭이 눌렸다&quot;는 것만 알려준다.</li>
<li><strong>아이콘 크기처럼 미세하게 다른 스펙은 하나로 통일했다.</strong> 사용자 20px, 점주 22px였는데 22px로 맞췄다. 디자이너와 확인이 필요하다.</li>
<li><strong>Storybook에 사용자 앱, 점주 앱 스토리를 둘 다 만들었다.</strong> 두 앱에서 어떻게 보일지 한 번에 확인할 수 있다.</li>
</ul>
<p>만드는 중에 기획이 바뀌어서 사용자 탭의 &quot;홈&quot;이 <strong>&quot;탐색&quot;</strong>이 됐다. 집 아이콘은 탐색과 어울리지 않아서 나침반 아이콘으로 바꿨다. 탭 구성을 앱에서 넘기는 구조라 컴포넌트는 건드릴 필요가 없었다.</p>
<h2 id="느낀-점">느낀 점</h2>
<p>이번에 팀원 의견으로 모노레포 구조를 처음 써보게 됐다.</p>
<p>원래는 레포를 여러 개 파야 하는지부터 고민이 많았다. 사용자, 점주, 관리자가 각각 쓰는 사람이 다르니까 저장소도 따로 가야 하나 싶었다. 그런데 저장소 하나 안에서 앱을 나누고 공통 코드는 같이 쓰는 방법도 있다는 걸 알게 됐다. 이런 구조도 있구나 싶어서 신기했고, 새로운 걸 하나 배운 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[런치캐치 개발일지 #3] Claude Code + Codex 문서 세팅]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-3-Claude-Code-Codex-%EB%AC%B8%EC%84%9C-%EC%84%B8%ED%8C%85</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-3-Claude-Code-Codex-%EB%AC%B8%EC%84%9C-%EC%84%B8%ED%8C%85</guid>
            <pubDate>Mon, 28 Sep 2026 14:09:43 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>우리 팀 프론트엔드는 3명인데, AI 코딩 도구가 각각 다르다.
같은 레포에서 작업하는데 도구가 다르면, AI한테 프로젝트 컨텍스트를 어떻게 공유할지가 문제였다. Claude Code는 <code>CLAUDE.md</code>를 읽고, Codex는 <code>AGENTS.md</code>를 읽는다. 각각 따로 관리하면 내용이 어긋날 수밖에 없다.</p>
<h2 id="agentsmd--claudemd-패턴">AGENTS.md + CLAUDE.md 패턴</h2>
<p><code>AGENTS.md</code>를 공유 소스로 두고, <code>CLAUDE.md</code>는 이걸 참조만 한다.</p>
<pre><code># CLAUDE.md
@AGENTS.md</code></pre><p>이렇게 하면:</p>
<ul>
<li><strong>Codex</strong> → <code>AGENTS.md</code> 직접 읽음</li>
<li><strong>Claude Code</strong> → <code>CLAUDE.md</code> 열면 <code>@AGENTS.md</code> 참조로 같은 내용을 읽음</li>
<li><strong>관리 포인트는 <code>AGENTS.md</code> 하나</strong>
Claude Code 전용 설정이 필요하면 <code>CLAUDE.md</code>에 <code>@AGENTS.md</code> 아래에 추가하면 된다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/aa68086f-fe88-42ce-b703-655b26b84d05/image.png" alt=""></p>
<h2 id="agentsmd">AGENTS.md</h2>
<pre><code class="language-markdown"># 런치캐치 (Lunch Catch)

위치 기반 점심 쿠폰 할인 서비스. 직장인이 주변 가게 쿠폰을
스와이프로 찜하고, 11:00 선착순 오픈에 발급받는 구조.

## 기술 스택
- React 19 + Vite 8 + TypeScript
- Tailwind CSS
- Storybook
- ESLint / Prettier / Husky

## 현재 스프린트
- Sprint 2 (9/28 ~ 10/2): UI + mock 데이터 구현 (API 연동 없음)

## 참고 문서
- docs/requirements-user.md — 사용자(직장인) 요구사항 명세서
- docs/requirements-owner.md — 점주 요구사항 명세서
- docs/requirements-admin.md — 관리자 요구사항 명세서
- docs/requirements-common.md — 공통/시스템 요구사항
- docs/folder-structure.md — 폴더 구조
- docs/conventions.md — 코딩/커밋/브랜치 규칙

## 핵심 규칙
- &quot;가게&quot; 사용 (&quot;식당&quot; X)
- 서빙 시간대: 10:00~12:59
- 쿠폰 발급은 찜 목록에서만 (피드 카드에 발급 버튼 없음)
- 11:00 선착순 오픈, 10:50 알림
- 브랜드 컬러: #F56B20

## 담당
| 역할 | 담당자 | 도구 |
|------|--------|------|
| FE-사용자 | 서지현 | Claude Code |
| FE-점주 | 이규태 | Codex |
| FE-관리자 | 고유정 | Claude Code |</code></pre>
<p>AI 도구가 코딩할 때 이 파일을 먼저 읽으면, 프로젝트 맥락을 잡고 규칙에 맞는 코드를 생성할 수 있다. 예를 들어 &quot;식당&quot;이라고 쓸 수 있는 곳에서 &quot;가게&quot;로 통일한다든지, 브랜드 컬러를 자동으로 맞춘다든지.</p>
<h2 id="docs-폴더-구조">docs/ 폴더 구조</h2>
<p>요구사항 명세서 + 프로젝트 규칙 문서를 <code>docs/</code> 폴더에 정리했다.</p>
<pre><code>lc-frontend/
├── AGENTS.md
├── CLAUDE.md
└── docs/
    ├── requirements-user.md      # 사용자 요구사항 (32개)
    ├── requirements-owner.md     # 점주 요구사항 (26개)
    ├── requirements-admin.md     # 관리자 요구사항 (27개)
    ├── requirements-common.md    # 공통/시스템 요구사항 (10개)
    ├── folder-structure.md       # 폴더 구조
    └── conventions.md            # 코딩/커밋/브랜치 규칙</code></pre><h3 id="요구사항을-역할별로-분리한-이유">요구사항을 역할별로 분리한 이유</h3>
<p>원래 요구사항 명세서는 하나의 큰 파일이었다. 
(사용자/점주/관리자/공통 전부 합쳐서 약 95개 기능). </p>
<h4 id="문제">문제</h4>
<ul>
<li>AI 도구가 사용자 피드 만드는데 관리자 대시보드 스펙까지 읽음</li>
<li>파일이 너무 길어서 컨텍스트 낭비
역할별로 나누니까 각 담당자의 AI가 자기 파트만 집중해서 읽을 수 있다.</li>
</ul>
<h3 id="conventionsmd">conventions.md</h3>
<p>커밋 컨벤션, 브랜치 전략, 코딩 규칙은 <code>AGENTS.md</code>에 넣을 수도 있었지만, 내용이 길어지면 핵심 규칙이 묻힌다. AGENTS.md는 짧고 핵심만, 상세 규칙은 docs/에 분리하고 참조 경로만 남기는 게 깔끔했다.</p>
<p>주요 규칙만 정리하면:</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>규칙</th>
</tr>
</thead>
<tbody><tr>
<td>커밋 타입</td>
<td>feat / fix / docs / style / refactor / test / chore / design / rename / remove</td>
</tr>
<tr>
<td>커밋 제목</td>
<td>한국어, 60자 이내, 마침표 없음</td>
</tr>
<tr>
<td>브랜치</td>
<td>main(배포) → develop(통합) → feature/*(기능)</td>
</tr>
<tr>
<td>컴포넌트</td>
<td>함수형 + arrow function</td>
</tr>
<tr>
<td>스타일</td>
<td>Tailwind 유틸리티 클래스</td>
</tr>
<tr>
<td>상태관리</td>
<td>useState / useContext만 사용</td>
</tr>
</tbody></table>
<h2 id="느낀-점">느낀 점</h2>
<p>이전 프로젝트(요고다)에서는 프론트/백 혼자 다 했기 때문에, AI한테 맥락을 줄 때도 그냥 프롬프트에 직접 넣어줬다. &quot;너는 프론트 개발자야&quot;, &quot;너는 백엔드 개발자야&quot; 하면서 각각 역할을 나눠 소통시키는 방식이었고, 프로젝트 정보를 md 파일로 정리해서 코드에 넣지는 않았다.</p>
<p>이번에는 팀 프로젝트고 도구도 다르다 보니, 공통되는 규칙이나 맥락을 md 파일로 관리하는 게 훨씬 효율적이겠다는 생각이 들었다. 한 번 정리해두면 팀원 누가 어떤 도구를 쓰든 같은 맥락에서 출발할 수 있으니까.</p>
<p>이번 프로젝트에서는 AI를 좀 더 다양하게, 제대로 활용해보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[런치캐치 개발일지 #2] Figma AI & MCP 디자인 워크플로우 세팅]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-2-Figma-AI-MCP-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%84%B8%ED%8C%85</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-2-Figma-AI-MCP-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%84%B8%ED%8C%85</guid>
            <pubDate>Wed, 16 Sep 2026 08:49:58 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>지난 글에서 피그마 AI로 빠르게 초안을 뽑고 다듬는 방식으로 가기로 하고 결제했다고 썼는데, 오늘 좀 당황스러운 걸 발견했다.</p>
<p><strong>피그마 AI 에이전트가 베타 기간이라 무료 플랜에서도 사용 가능하다.</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/01e32752-689c-4ad3-b773-ab46ab0c9eda/image.png" alt=""></p>
<p>그럼 내가 왜 결제한 거지? 싶어서 환불까지 알아봤는데, 조사하다 보니 상황이 좀 달랐다.</p>
<h2 id="무료-vs-유료--ai-관련-차이">무료 vs 유료 — AI 관련 차이</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Starter (무료)</th>
<th>Professional (유료)</th>
</tr>
</thead>
<tbody><tr>
<td>AI 에이전트 베타</td>
<td>✅ 사용 가능</td>
<td>✅ 사용 가능</td>
</tr>
<tr>
<td>AI 크레딧</td>
<td>일 150개 / 월 500개</td>
<td>월 3,000개</td>
</tr>
<tr>
<td>MCP 디자인 생성</td>
<td>❌</td>
<td>✅ (Full seat 전용)</td>
</tr>
<tr>
<td>MCP 디자인 읽기</td>
<td>월 6회 (사실상 불가)</td>
<td>일 200회</td>
</tr>
<tr>
<td>무제한 파일</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>팀 라이브러리</td>
<td>❌</td>
<td>✅</td>
</tr>
</tbody></table>
<p>지금은 베타라서 에이전트 사용 시 크레딧이 안 빠지지만, 정식 출시 후에는 크레딧이 소모된다. 런치캐치 화면이 67개나 되는데 무료 플랜의 월 500개로는 부족할 수 있다. 거기에 <strong>Figma MCP로 디자인을 생성하려면 유료 플랜의 Full seat이 필수</strong>다. 무료 플랜에서는 디자인 읽기만 월 6회 가능한데, 이건 사실상 안 되는 거나 마찬가지다. 그래서 일단 유료 플랜을 유지하면서 베타 기간 동안 최대한 뽑아보기로 했다.</p>
<h2 id="figma-mcp-발견">Figma MCP 발견</h2>
<p>피그마 AI 에이전트 말고 다른 방법도 있었다. <strong>Figma MCP</strong>라는 건데, 2026년 2월에 <code>generate_figma_design</code> 도구가 발표되면서 AI 코딩 도구에서 프롬프트를 입력하면 피그마 캔버스에 직접 디자인을 생성할 수 있게 됐다.</p>
<p>양방향으로 동작한다:</p>
<ul>
<li><strong>디자인 → 코드</strong>: 피그마에 있는 디자인을 읽어서 코드로 변환</li>
<li><strong>코드 → 디자인</strong>: 프롬프트로 피그마 캔버스에 디자인 생성</li>
</ul>
<p>나중에 런치캐치 프론트 개발할 때 디자인 → 코드 방향도 써볼 수 있을 것 같다.</p>
<h2 id="openai-codex--figma-mcp-연결">OpenAI Codex + Figma MCP 연결</h2>
<p>Figma MCP를 연결할 수 있는 도구가 여러 개 있는데 (Claude.ai, Cursor 등), 나는 OpenAI Codex 데스크톱 앱으로 연결했다.</p>
<h3 id="세팅-방법">세팅 방법</h3>
<ol>
<li><a href="https://openai.com/codex">openai.com/codex</a>에서 Codex 데스크톱 앱 설치</li>
<li>Codex 앱 안에 있는 플러그인 디렉토리에서 Figma 검색 → 설치</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/bbd1729b-a7c9-422e-9083-c7d49273dafa/image.png" alt=""></p>
<ol start="3">
<li>브라우저가 열리면 피그마 OAuth 로그인 → Allow access
 플러그인으로 설치하면 MCP 서버 URL을 직접 입력할 필요 없이 바로 연결된다.</li>
</ol>
<h3 id="디자인-생성">디자인 생성</h3>
<p>Codex 채팅창에 프롬프트를 입력하면 피그마 캔버스에 디자인이 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/41bdf39a-ec1f-4a98-ab4f-c425e7064701/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/ef2c5e7b-ecae-4267-b2bb-dded9caa9d1f/image.png" alt=""></p>
<h3 id="기존-프레임-수정">기존 프레임 수정</h3>
<p>이미 있는 디자인을 수정하고 싶으면:</p>
<ol>
<li>피그마에서 프레임 선택 → 우클릭 → <code>Copy/Paste as</code> → <code>Copy link to selection</code> (또는 <code>Cmd+L</code>)</li>
<li>Codex에 URL + 수정 요청 입력</li>
</ol>
<h2 id="현재-디자인-워크플로우-정리">현재 디자인 워크플로우 정리</h2>
<p>정리하면 지금 쓸 수 있는 디자인 도구가 두 가지다:</p>
<ol>
<li><strong>피그마 AI 에이전트</strong> : 피그마 안에서 프롬프트로 직접 디자인 생성/수정</li>
<li><strong>OpenAI Codex + Figma MCP</strong> : 외부에서 프롬프트로 피그마 캔버스에 디자인 생성/수정</li>
</ol>
<p>둘 다 써보면서 어떤 게 런치캐치 화면 뽑기에 더 효율적인지 비교해볼 예정이다.</p>
<h2 id="느낀-점">느낀 점</h2>
<ul>
<li>결제하고 나서 &quot;이거 무료인데?&quot; 하고 당황했는데, 알아보니 완전히 같은 건 아니었다. 뭐든 바로 환불하기보다 정확히 뭐가 다른지 먼저 확인하는 게 맞는 것 같다</li>
<li>Figma MCP라는 걸 오늘 처음 알았는데, 나중에 디자인 → 코드 변환에도 써볼 수 있을 것 같아서 기대된다</li>
<li>AI 도구가 진짜 많다. 피그마 AI, Claude, Codex, Cursor... 결국 중요한 건 도구 자체보다 &quot;어떤 상황에서 어떤 걸 쓸지&quot; 판단하는 것 같다</li>
</ul>
<h2 id="다음-할-일">다음 할 일</h2>
<ul>
<li>피그마 AI vs Codex + MCP로 실제 런치캐치 화면 뽑아보고 퀄리티 비교</li>
<li>9/18(금)까지 핵심 플로우(스와이프, 식당 상세, QR) 디자인 완성</li>
<li>색상/디자인 팀 내 피드백 받고 확정</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[런치캐치 개발일지 #1] 프로젝트 소개 및 기획]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%86%8C%EA%B0%9C-%EB%B0%8F-%EA%B8%B0%ED%9A%8D</link>
            <guid>https://velog.io/@jhwest-dev/%EB%9F%B0%EC%B9%98%EC%BA%90%EC%B9%98-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%86%8C%EA%B0%9C-%EB%B0%8F-%EA%B8%B0%ED%9A%8D</guid>
            <pubDate>Wed, 16 Sep 2026 00:34:31 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-소개">프로젝트 소개</h2>
<h3 id="서비스명">서비스명</h3>
<p>런치캐치 (Lunch Catch)</p>
<h3 id="한줄-정의">한줄 정의</h3>
<p>직장인을 위한 점심 쿠폰 할인서비스</p>
<h3 id="문제-→-정의-→-해결">문제 → 정의 → 해결</h3>
<ul>
<li><strong>문제</strong>: 직장인은 오르는 외식비가 부담이고, 동네 식당은 배달앱·블로그 광고 등 기존 홍보 수단이 비싸고 효과가 낮다</li>
<li><strong>정의</strong>: 소상공인에게 비용 대비 효과적인 홍보 채널이 없다</li>
<li><strong>해결</strong>: 점주가 직접 쿠폰을 등록하면 주변 직장인에게 바로 노출되는 위치 기반 점심 쿠폰 플랫폼</li>
</ul>
<h3 id="차별점">차별점</h3>
<ul>
<li>기존 서비스(테이블링, 캐치테이블)는 예약/웨이팅 중심</li>
<li>런치캐치는 <strong>틴더 스타일 스와이프 쿠폰</strong> + <strong>LLM 기반 포스터 자동 생성</strong></li>
<li>점주는 쿠폰 정보만 입력하면 AI가 포스터를 만들어줌</li>
<li>사용자는 스와이프로 찜 → 선착순 발급 → QR로 사용</li>
</ul>
<h2 id="팀-구성">팀 구성</h2>
<ul>
<li>프론트엔드 3명 / 백엔드 5명</li>
</ul>
<h2 id="내가-맡은-범위">내가 맡은 범위</h2>
<p>사용자(직장인) 페이지(프론트엔드) 전체를 담당하게 됐다.</p>
<h3 id="핵심-화면">핵심 화면</h3>
<ul>
<li>스와이프 피드 (틴더 스타일 카드)</li>
<li>홈 (식당 리스트 / 지도 토글)</li>
<li>식당 상세 (캠페인 정보 + 찜/발급)</li>
<li>쿠폰함 (찜 목록 + 발급 쿠폰 + QR)</li>
<li>마이페이지</li>
</ul>
<h3 id="탭바-구조">탭바 구조</h3>
<p>스와이프 | 홈 | 쿠폰함 | 마이페이지</p>
<h2 id="이번-주-한-일">이번 주 한 일</h2>
<h3 id="기획">기획</h3>
<ul>
<li>서비스 정의, 문제 정의, 차별점 정리</li>
<li>페르소나 4명 작성 (사용자 1, 점주 2, 관리자 1)</li>
<li>요구사항 정의서 작성</li>
<li>사용자 요구사항 명세서 작성</li>
</ul>
<h3 id="사용자-플로우-설계">사용자 플로우 설계</h3>
<ul>
<li>핵심 퍼널: 스와이프 → 찜 → 선착순 발급 → QR → 사용</li>
<li>탭별 화면 연결 정리</li>
<li>전체 67개 화면 도출</li>
</ul>
<h3 id="uiux-디자인">UI/UX 디자인</h3>
<ul>
<li>이전 프로젝트(요고다)에서 팀원이 피그마 AI로 디자인 초안을 뽑았는데 퀄리티가 괜찮았다</li>
<li>이번에도 피그마 AI로 빠르게 초안을 뽑고 다듬는 방식으로 가기로 하고 결제했다</li>
<li>화면을 직접 다 디자인하기엔 시간이 부족해서 AI로 초안 → 수정하는 게 효율적이라고 판단했다</li>
<li>색상은 여러 조합을 시도해보면서 현재 차콜 + 코랄레드로 진행 중 (확정은 아님)</li>
<li>폰트: Noto Sans KR</li>
</ul>
<h2 id="기술-스택">기술 스택</h2>
<ul>
<li>React (또는 Next.js)</li>
<li>Tailwind CSS</li>
<li>카카오 로그인 (OIDC)</li>
<li>네이버/카카오 지도 API</li>
<li>Noto Sans KR</li>
</ul>
<h2 id="느낀-점">느낀 점</h2>
<ul>
<li>기획 단계에서 요구사항을 상세하게 잡아두니까 &quot;뭘 만들어야 하는지&quot;가 명확해졌다</li>
<li>처음엔 기능만 나열했는데, 하나씩 파다 보니까 &quot;이것도 필요하네? 저것도 빠졌네?&quot; 하면서 화면이 계속 늘어났다. 처음부터 완벽하게 기획하는 건 불가능하고 만들면서 계속 보완해야 한다는 걸 느꼈다</li>
<li>AI 도구(Claude, 피그마 AI)를 적극 활용했는데, 결국 &quot;뭘 만들지&quot;를 결정하는 건 사람이 해야 한다. AI가 초안은 잘 뽑아주는데 방향을 잡아주진 않는다</li>
<li>피그마 AI가 초안 뽑는 속도는 빠른데, 프롬프트를 잘 써야 원하는 결과가 나온다. 프롬프트 작성에도 시간이 꽤 든다</li>
<li>사용자 화면을 정리하니 약 28개 페이지, 상태별 변형까지 포함하면 67개 화면이 나왔다. 처음엔 막막했는데 페이지별로 묶어서 정리하니까 관리할 만해졌다</li>
<li>색상 정하는 게 생각보다 어려웠다. 여러 번 바꿨는데 아직도 확정은 아니다<h2 id="다음-할-일">다음 할 일</h2>
</li>
<li>9/18(금)까지 피그마 UI 디자인 완성 목표<ul>
<li>스와이프, 식당 상세, QR (핵심 플로우) 우선</li>
<li>이후 홈, 쿠폰함, 마이페이지 순서로 진행</li>
</ul>
</li>
<li>색상/디자인 팀 내 피드백 받고 확정</li>
<li>디자인 완성 후 개발 환경 세팅 시작 예정</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG U+ URECA] 요고다(YOGODA) 종합 프로젝트 회고 [최우수상]]]></title>
            <link>https://velog.io/@jhwest-dev/%EC%A2%85%ED%95%A9%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0-%EC%9A%94%EA%B3%A0%EB%8B%A4Yogoda</link>
            <guid>https://velog.io/@jhwest-dev/%EC%A2%85%ED%95%A9%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0-%EC%9A%94%EA%B3%A0%EB%8B%A4Yogoda</guid>
            <pubDate>Sun, 06 Sep 2026 03:14:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>3주 동안 3명이서 AI 요금제 상담 → 가입 전환 → 관리자 모니터링까지 이어지는 풀사이클 서비스를 만들었다. 그 과정에서 배운 것들을 정리한다.</p>
</blockquote>
<hr>
<h2 id="프로젝트-소개">프로젝트 소개</h2>
<p><strong>YOGODA(요고다)</strong> 는 통신사 요금제를 AI가 상담해주는 서비스다. 단순히 &quot;이 요금제가 좋아요&quot;라고 추천만 하는 게 아니라, 추천 → 비교 → 가입 → 모니터링 → 프롬프트 개선까지 하나의 순환 구조로 연결한 플랫폼이다.</p>
<p>기존 요금제 추천 서비스들의 문제는 &quot;추천은 잘하는데, 그 뒤가 없다&quot;는 거였다. 사용자가 추천을 받고 실제로 가입했는지, 어디에서 이탈했는지, 어떤 프롬프트가 더 나은 결과를 내는지 등 이런 데이터가 전혀 남지 않았다. 요고다는 이 빈자리를 채워서 <strong>추천 품질을 데이터로 계속 개선할 수 있는 구조</strong>를 만드는 게 목표였다.</p>
<ul>
<li><strong>기간</strong>: 2026.08.14 - 2026.09.03 (3주)</li>
<li><strong>팀</strong>: 3명 (박해준, 고유정, 서지현) - 전원 FE/BE 풀스택, 기능 단위로 책임 분담</li>
<li><strong>내 담당</strong>: 프로젝트 세팅(BE), 소셜 로그인/인증, 관리자 페이지 전체</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/0499260c-8768-44f2-a37f-3ff4e316fe48/image.png" alt=""></p>
<hr>
<h2 id="🛠-기술-스택">🛠 기술 스택</h2>
<p><strong>Frontend</strong> : Next.js · React · TypeScript · TanStack Query · Zustand · Tailwind CSS · Storybook · next-intl
<strong>Backend</strong> : Node.js · Express · TypeScript · Socket.IO · JWT · Zod · Swagger
<strong>AI / Data</strong> : Gemini Interactions API · MongoDB Atlas · Mongoose · Azure Key Vault
<strong>Infra / Deploy</strong> : Vercel · Azure App Service
<strong>Auth</strong> : OAuth 2.0 (카카오 · 네이버 · 구글)
<strong>Quality / Collab</strong> : Vitest · Playwright · ESLint · Prettier · Husky · GitHub · Jira · Notion</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/fbe43c8a-da64-4082-aedd-5c8076c7df27/image.png" alt=""></p>
<hr>
<h2 id="담당-기능">담당 기능</h2>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/30ee384e-c597-4262-8a62-98cb0f787969/image.png" alt=""></p>
<p>나는 <strong>백엔드 초기 세팅</strong>, <strong>소셜 로그인(구글/네이버/카카오)</strong>, 그리고 <strong>관리자 페이지 전체</strong>를 담당했다.</p>
<p>관리자 페이지는 단순한 CRUD 어드민이 아니었다. 상담 퍼널 분석, UI 클릭률 추적, 프롬프트 버전 관리 및 A/B 테스트, 세션 로그 조회 등 이 모든 게 실제 채팅 데이터와 연결되어 돌아가야 했다. 프론트에서 데이터를 어떻게 보여줄지뿐 아니라, 백엔드에서 이벤트를 어떤 단위로 기록하고 어떻게 집계할지까지 설계해야 했기 때문에 풀스택 역할이 자연스러웠다.</p>
<hr>
<h2 id="주요-개발-기록">주요 개발 기록</h2>
<p>각 주제는 별도 개발일지에 상세히 정리해뒀다. 여기서는 뭘 했고 뭘 느꼈는지만 짧게 남긴다.</p>
<h3 id="1-소셜-로그인">1. 소셜 로그인</h3>
<p>카카오·구글·네이버 세 플랫폼 소셜 로그인을 모두 구현했다. 셋 다 OAuth 2.0 인가 코드 방식이라 핵심 로직은 동일했고, 코드보다 각 플랫폼 개발자 콘솔 세팅이 오히려 시간을 더 잡아먹었다. 하나의 표준을 제대로 이해하면 나머지는 따라온다는 걸 체감했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/1cb8edab-f04e-4365-b83c-f4ee3e761a83/image.png" alt=""></p>
<blockquote>
<p>📝 <a href="https://velog.io/@jhwest-dev/TIL-%EA%B5%AC%EA%B8%80%EB%84%A4%EC%9D%B4%EB%B2%84%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0">개발일지 #2 - 구글/네이버/카카오 소셜 로그인 구현해보기</a></p>
</blockquote>
<h3 id="2-비회원-채팅-세션">2. 비회원 채팅 세션</h3>
<p>&quot;비회원은 로컬에만 저장&quot;이라는 초기 설계가 관리자 페이지를 만들면서 깨졌다. 비회원 이탈 데이터가 서버에 없으니 분석 자체가 불가능했기 때문이다. &quot;연결 시점부터 저장&quot;으로 바꾸면서 소켓 인증 타이밍, 세션 쪼개짐, 이탈 상태 되살아남 등 연쇄적인 문제를 해결해야 했다.</p>
<blockquote>
<p>📝 <a href="https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%B9%84%ED%9A%8C%EC%9B%90-%EC%B1%84%ED%8C%85-%EC%84%B8%EC%85%98-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0">개발일지 #3 - 비회원 채팅 세션 저장하기</a></p>
</blockquote>
<h3 id="3-react-query-캐시와-usestate-초기값-충돌">3. React Query 캐시와 useState 초기값 충돌</h3>
<p>프롬프트 편집 화면에서 새로고침은 정상인데 탭 전환 후 돌아오면 텍스트박스가 비는 버그를 만났다. React Query 캐시가 재마운트 시 데이터를 즉시 채워주면서, useState 초기값 기반의 리셋 로직이 무력화되는 게 원인이었다. 초기값을 외부 데이터에서 유도하지 않고 undefined로 고정해서 해결했다.</p>
<blockquote>
<p>📝 <a href="https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-5-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-">개발일지 #5 - React Query 캐시 때문에 useState 초기값이 틀어지는 버그</a></p>
</blockquote>
<h3 id="4-프롬프트-관리---어디까지-관리자에게-열어줄-것인가">4. 프롬프트 관리 - 어디까지 관리자에게 열어줄 것인가</h3>
<p>AI 시스템 프롬프트 중 인사말+역할+규칙만 관리자 편집 범위로 열고, JSON 응답 형식은 코드에 고정시켰다. 관리자가 실수로 응답 형식을 건드리면 전체 채팅이 멈출 수 있기 때문이다. 세션 생성 시점에 프롬프트 버전을 고정하는 방식으로 대화 중 말투가 바뀌는 문제도 방지했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/f0148272-a1cb-411e-8ac7-356ff80fa18b/image.png" alt=""></p>
<blockquote>
<p>📝 <a href="https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-6-%EA%B4%80%EB%A6%AC%EC%9E%90%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8%EB%8A%94-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%8B%A4%EC%A0%9C-%EC%B1%84%ED%8C%85%EC%97%90-%EB%B0%98%EC%98%81%EB%90%98%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C">개발일지 #6 - 프롬프트는 어디까지, 어떻게 채팅에 반영되어야 할까?</a></p>
</blockquote>
<h3 id="5-퍼널-이탈--ui-클릭-분석">5. 퍼널 이탈 / UI 클릭 분석</h3>
<p>Hotjar 같은 히트맵 대신 직접 이벤트 추적을 구현했다. 채팅 화면은 동적 스크롤이라 히트맵이 강력한 조건이 아니었고, 관리자에게 필요한 건 이미 정해진 전환 버튼들의 CTR을 정밀하게 모니터링하는 것이었다. 퍼널은 순서 보장, UI 이벤트는 세션당 1건으로 중복 방지하며 기록했다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/f8fcd65f-2786-4f5e-9bc2-15b6c6ea4f01/image.png" alt=""></p>
<blockquote>
<p>📝 <a href="https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-7-%ED%8D%BC%EB%84%90-%EC%9D%B4%ED%83%88-UI-%ED%81%B4%EB%A6%AD-%EB%B6%84%EC%84%9D-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B8%B0%EB%A1%9D%ED%95%98%EA%B3%A0-%EC%A7%91%EA%B3%84%ED%96%88%EB%82%98">개발일지 #7 - 퍼널 이탈 / UI 클릭 분석 기록과 집계</a></p>
</blockquote>
<h3 id="6-api-응답-속도">6. API 응답 속도</h3>
<p>대시보드 API가 3초대로 느려서 직렬 쿼리를 Promise.all로 병렬화했지만 2초대에서 멈췄다. 쿼리 하나당 왕복 220ms가 일정하게 나와서 인프라를 의심했고, MongoDB Atlas 클러스터가 미국 리전이었던 게 원인이었다. 서울로 옮기니 1초 미만으로 해결됐다.</p>
<blockquote>
<p>📝 <a href="https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%803-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-API-%EC%9D%91%EB%8B%B5%EC%9D%B4-%EC%99%9C-%EB%98%90-%EB%8A%90%EB%A6%B4%EA%B9%8C-enu3772o">개발일지 #4 - API 응답이 왜 또 느릴까..?</a></p>
</blockquote>
<hr>
<h2 id="결과-수치">결과 수치</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>수치</th>
</tr>
</thead>
<tbody><tr>
<td>페이지 수</td>
<td>38개</td>
</tr>
<tr>
<td>API 수 (Swagger 문서화)</td>
<td>59개</td>
</tr>
<tr>
<td>테스트 통과</td>
<td>64개 (FE 32 + BE 26 + E2E 6)</td>
</tr>
<tr>
<td>PR</td>
<td>110개</td>
</tr>
<tr>
<td>Lighthouse Performance</td>
<td>95점</td>
</tr>
<tr>
<td>Lighthouse Accessibility</td>
<td>100점</td>
</tr>
</tbody></table>
<hr>
<h2 id="claude-design으로-관리자-ui-프로토타이핑">Claude Design으로 관리자 UI 프로토타이핑</h2>
<p>관리자 페이지 UI를 처음부터 그리기 막막해서 Claude Design을 써봤다. 디자인 시스템(메인 컬러, 카드 스타일, 레이아웃)을 프롬프트 상단에 정의해두고 페이지 단위로 생성 → html to design 플러그인으로 Figma에 가져와서 다듬는 워크플로우를 사용했다.</p>
<p>디자이너 없이 백지부터 시작하는 막막함이 확 줄었고, 일관된 톤앤매너를 유지하는 데도 효과적이었다.</p>
<hr>
<h2 id="팀-회고-kpt">팀 회고 (KPT)</h2>
<h3 id="keep-계속-가져갈-것">Keep 계속 가져갈 것</h3>
<ul>
<li><strong>API 계약 우선</strong>: Swagger로 59개 API를 문서화하고, FE/BE가 같은 명세를 기준으로 맞춰 작업했다. API 변경 시 계약 동기화를 먼저 하는 습관이 재작업을 줄여줬다.</li>
<li><strong>공통 UI / 품질 자동화</strong>: Storybook으로 공통 컴포넌트 25종을 독립 관리하고, Husky + lint-staged로 커밋 전 자동 검사를 걸었다. 프로젝트 후반에도 코드 품질이 흔들리지 않았다.</li>
<li><strong>데이터 기반 의사결정</strong>: 프롬프트 성과를 감이 아니라 전환율 데이터로 비교할 수 있게 구조를 만든 것.</li>
</ul>
<h3 id="problem-아쉬웠던-것">Problem 아쉬웠던 것</h3>
<ul>
<li><strong>API 명세 변경에 따른 재작업</strong>: 계약 우선이라는 원칙은 좋았지만, 초기 설계가 바뀌면서 이미 구현한 부분을 재작업한 경우가 있었다. 명세를 더 일찍 확정하거나, 변경 범위를 최소화하는 연습이 필요하다.</li>
<li><strong>초기 설계와 최종 구현 간 차이</strong>: 비회원 세션 저장 건처럼, 처음엔 합리적이었던 설계가 관리자 요구사항이 추가되면서 뒤집히는 경우가 있었다. 다양한 관점을 초기에 더 검토했어야 했다.</li>
<li><strong>통합 시점이 늦어진 부분</strong>: 각자 기능 단위로 개발하다 보니 FE-BE를 합쳐보는 시점이 좀 늦었다. 중간중간 통합 테스트를 했으면 마지막 주에 여유가 있었을 것이다.</li>
</ul>
<h3 id="try-다음에-바꿀-것">Try 다음에 바꿀 것</h3>
<ul>
<li><strong>실험 지표 사전 정의</strong>: 프롬프트 A/B 테스트를 만들긴 했지만, &quot;무엇을 기준으로 더 나은 프롬프트인지&quot; 지표를 사전에 명확히 정의하고 시작하면 더 의미 있는 실험이 될 것이다.</li>
<li><strong>통합 테스트 시점 앞당기기</strong>: 기능 개발 완료 후가 아니라, 스프린트 중간에 FE-BE 연결 테스트를 루틴으로 잡기.</li>
<li><strong>성능·접근성 자동 측정</strong>: Lighthouse 점수를 수동으로 재는 게 아니라 CI에 통합해서 매 배포마다 자동으로 체크하기.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/2b459a06-8cc7-43bc-b844-a5bb60289e5d/image.png" alt=""></li>
</ul>
<hr>
<h2 id="개인적으로-배운-것">개인적으로 배운 것</h2>
<ol>
<li><p>비회원 세션 건이 대표적이다. &quot;비회원은 저장 안 해도 된다&quot;는 판단이 틀린 게 아니었다. 다만 그때는 관리자 관점을 고려하지 못했을 뿐이다. 한 가지 관점에서 내린 합리적인 판단도, 다른 관점이 끼어들면 다시 흔들릴 수 있다는 걸 몸으로 배웠다.</p>
</li>
<li><p>프롬프트 관리 기능을 만들면서 느낀 건, 커스터마이징 권한을 준다고 전부 열어주면 안 된다는 것이다. JSON 응답 형식처럼 깨지면 서비스 전체가 멈추는 부분은 시스템이 쥐고 있어야 한다. &quot;얼마나 자유롭게 열어줄까&quot;가 아니라 &quot;어떤 걸 관리자에게 맡기고 어떤 걸 시스템이 소유해야 하는지 그 경계를 정하는 것&quot;이 핵심이었다.</p>
</li>
<li><p>히트맵 라이브러리 대신 직접 이벤트 추적을 만든 건, &quot;쉬운가 어려운가&quot;가 아니라 &quot;이 화면의 특성에 어떤 방식이 더 맞는가&quot;로 판단한 결과였다. 도구 선택의 기준이 난이도가 아니라 적합도여야 한다는 걸 실감했다.</p>
</li>
<li><p>API 응답 속도 문제에서, 쿼리 최적화만으로 해결하려 했으면 2초 벽을 넘지 못했을 것이다. &quot;이 데이터 양에 이 시간이 맞나?&quot; 하는 의문이 인프라(리전) 문제를 짚어줬다. 코드를 더 파는 것과 한 발 물러서서 전체 그림을 보는 것 사이의 전환이 중요하다.</p>
</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>3주라는 시간이 길면서도 굉장히 짧게 느껴졌다. 매일 새로운 문제를 만나고 해결하면서 밀도 있게 보낸 건 맞는데, 돌이켜보면 좀 더 개발 속도를 냈어야 했다는 아쉬움이 남는다.</p>
<p>개발을 더 빨리 끝냈으면 테스트 기간을 충분히 확보할 수 있었을 거고, 사용자 입장에서 UI/UX를 다듬는 시간도 가질 수 있었을 텐데 후반으로 갈수록 기능 구현에 급급해진 느낌이 있었다. 다음 프로젝트에서는 개발을 일찍 마무리하고, 테스트와 사용자 관점의 개선에 시간을 더 쓸 수 있는 페이스를 만들고 싶다.</p>
<p>그래도 이번 프로젝트를 통해 정말 많이 배웠다. 설계가 뒤집히는 순간의 대응, 데이터를 기록하고 집계하는 구조 설계, 코드 너머의 인프라까지 시야를 넓히는 감각 이런 것들은 강의에서 배울 수 없는, 직접 부딪혀봐야 느는 종류의 경험이었다.</p>
<hr>
<p><strong>GitHub</strong>: <a href="https://github.com/ureca-yogoda/yogoda-backend">yogoda-backend</a> · <a href="https://github.com/ureca-yogoda/yogoda-frontend">yogoda-frontend</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #7] 퍼널 이탈 / UI 클릭 분석 - 어떻게 기록하고 집계했나]]></title>
            <link>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-7-%ED%8D%BC%EB%84%90-%EC%9D%B4%ED%83%88-UI-%ED%81%B4%EB%A6%AD-%EB%B6%84%EC%84%9D-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B8%B0%EB%A1%9D%ED%95%98%EA%B3%A0-%EC%A7%91%EA%B3%84%ED%96%88%EB%82%98</link>
            <guid>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-7-%ED%8D%BC%EB%84%90-%EC%9D%B4%ED%83%88-UI-%ED%81%B4%EB%A6%AD-%EB%B6%84%EC%84%9D-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B8%B0%EB%A1%9D%ED%95%98%EA%B3%A0-%EC%A7%91%EA%B3%84%ED%96%88%EB%82%98</guid>
            <pubDate>Thu, 27 Aug 2026 08:23:51 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-한-일-한-줄">오늘 한 일 (한 줄)</h2>
<p>사용자가 상담 중 어느 단계에서 이탈하는지(퍼널), 어떤 UI 요소를 보고 누르는지(클릭률)를 기록하고, 그걸 관리자 대시보드 숫자로 집계하는 기능을 만들었다.</p>
<h2 id="왜-했나-계기">왜 했나 (계기)</h2>
<p>관리자가 대시보드에서 &quot;사람들이 어느 단계에서 많이 빠져나가는지&quot;, &quot;어떤 버튼이 노출은 많이 되는데 안 눌리는지&quot;를 보려면, 그 행동 자체가 서버에 기록돼 있어야 한다. 지금까지는 그런 데이터가 아예 없었다. 그래서 소켓 이벤트 두 개(<code>conversion_event</code>, <code>ui_event</code>)를 새로 배선하고, 그걸 의미 있는 숫자로 집계하는 로직까지 만들었다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/1135947f-afbb-4c37-9074-4d4f487c2461/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/fd41fcb2-311d-4713-8c43-5d1b12d96912/image.png" alt=""></p>
<h2 id="0-소켓-연결-하나로-여러-이벤트를-주고받는-구조">0. 소켓 연결 하나로 여러 이벤트를 주고받는 구조</h2>
<p>REST API는 요청 하나마다 연결을 새로 맺고 끊는다. 사용자가 버튼을 누르면 <code>fetch</code>로 요청 보내고, 서버가 응답하면 그 연결은 끝난다. 다음 버튼을 누르면 또 새로 연결한다.</p>
<p>소켓은 다르다. 사용자가 채팅 페이지에 들어오는 순간 연결을 한 번 맺으면, 그 연결은 페이지에 머무는 동안 계속 열려있는 하나의 파이프가 된다. 그리고 그 파이프 위로 이름이 다른 여러 종류의 이벤트를 얼마든지 주고받을 수 있다.</p>
<p>지금 이 파이프 위로 오가는 이벤트는 이렇다.</p>
<table>
<thead>
<tr>
<th>이벤트 이름</th>
<th>방향</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>message</code></td>
<td>Client → Server</td>
<td>사용자가 채팅 메시지 전송</td>
</tr>
<tr>
<td><code>chunk</code></td>
<td>Server → Client</td>
<td>AI 답변을 실시간으로 타이핑하듯 전달</td>
</tr>
<tr>
<td><code>conversion_event</code></td>
<td>Client → Server</td>
<td>퍼널 단계 도달 알림</td>
</tr>
<tr>
<td><code>ui_event</code></td>
<td>Client → Server</td>
<td>UI 요소 노출/클릭 알림</td>
</tr>
</tbody></table>
<p>애초에 AI 답변을 소켓 연결을 사용하여 응답하고 있었다. <code>conversion_event</code>, <code>ui_event</code>는 이 목적으로 이미 열려있는 연결에 얹어서 같이 보내는 것뿐이다 - 이 두 이벤트만을 위해 별도로 REST 엔드포인트를 새로 만들 필요가 없었다.</p>
<p>세션 식별도 연결 시점에 한 번만 이뤄진다. 소켓을 처음 맺을 때 인증 토큰과 세션 ID를 함께 보내는데, 서버는 이 정보로 &quot;이 연결이 곧 이 세션이다&quot;라고 확정해버린다. 그래서 그 이후에 오는 <code>message</code>든 <code>conversion_event</code>든 <code>ui_event</code>든, 이벤트마다 세션 ID를 다시 실어 보낼 필요가 없다. 서버는 이벤트 내용물이 아니라 &quot;어느 연결에서 왔는가&quot;로 세션을 구분한다.</p>
<h2 id="1-어떤-행동이-어떤-이벤트로-기록되나">1. 어떤 행동이 어떤 이벤트로 기록되나</h2>
<p><strong>퍼널 이벤트 (<code>conversion_event</code>) — 상담 진행 단계</strong></p>
<table>
<thead>
<tr>
<th>사용자 행동</th>
<th>발생하는 이벤트</th>
</tr>
</thead>
<tbody><tr>
<td>AI 상담을 시작함</td>
<td><code>consultation_started</code></td>
</tr>
<tr>
<td>AI가 요금제 추천을 완료함</td>
<td><code>recommendation_completed</code></td>
</tr>
<tr>
<td>요금제 비교 화면을 봄</td>
<td><code>plan_comparison_viewed</code></td>
</tr>
<tr>
<td>가입 신청 버튼을 눌러 가입 절차를 시작함</td>
<td><code>signup_started</code></td>
</tr>
<tr>
<td>가입이 실제로 완료됨</td>
<td><code>signup_completed</code></td>
</tr>
</tbody></table>
<p><strong>UI 이벤트 (<code>ui_event</code>) — 특정 요소의 노출/클릭</strong></p>
<table>
<thead>
<tr>
<th>UI 요소</th>
<th>어떤 버튼/카드인지</th>
</tr>
</thead>
<tbody><tr>
<td><code>plan_detail</code></td>
<td>요금제 &quot;자세히 보기&quot;</td>
</tr>
<tr>
<td><code>plan_comparison</code></td>
<td>&quot;요금제 비교&quot;</td>
</tr>
<tr>
<td><code>signup_button</code></td>
<td>&quot;가입하기&quot;</td>
</tr>
<tr>
<td><code>explore_plans</code></td>
<td>&quot;다른 요금제 탐색하기&quot;</td>
</tr>
</tbody></table>
<p>이 4개 요소가 화면에 나타나면 <code>view</code>, 사용자가 누르면 <code>click</code> 이벤트가 각각 발생한다.</p>
<p>이 두 종류의 이벤트가 지금까지 설명한 순서 보장 로직(퍼널)과 중복 방지 로직(UI)을 거쳐서 세션에 기록되고, 그 기록이 쌓여서 다음 섹션의 진입률/이탈률/CTR 계산으로 이어진다.</p>
<h2 id="2-퍼널-이벤트-기록---순서-보장">2. 퍼널 이벤트 기록 - 순서 보장</h2>
<p>세션에는 <code>last_stage</code>라는 필드가 있다. &quot;지금까지 도달한 가장 앞선 단계&quot;를 저장하는 값이다. 근데 클라이언트가 보내는 <code>conversion_event</code>가 네트워크 지연 때문에 순서 없이 도착할 수 있다 — 예를 들어 <code>signup_started</code> 이벤트가 <code>recommendation_completed</code>보다 서버에 늦게 도착하는 경우다. 이럴 때 나중에 도착한 이벤트로 무조건 덮어쓰면, 이미 더 앞선 단계까지 갔던 기록이 뒤로 밀려버릴 수 있다.</p>
<p>그래서 각 단계에 순서를 매겨두고, <strong>새로 들어온 단계가 기존에 기록된 것보다 앞서 있을 때만 갱신</strong>하도록 했다.</p>
<pre><code class="language-ts">// 퍼널 단계의 진행 순서
const FUNNEL_STAGE_ORDER = {
  consultation_started: 1,
  recommendation_completed: 2,
  plan_comparison_viewed: 3,
  signup_started: 4,
  signup_completed: 5,
};

export async function recordConversionEvent(sessionId: string, stage: ChatSessionFunnelStage) {
  const session = await ChatSessionModel.findById(sessionId).select(&quot;last_stage&quot;);
  if (!session) return;

  const currentOrder = session.last_stage ? FUNNEL_STAGE_ORDER[session.last_stage] : 0;
  if (FUNNEL_STAGE_ORDER[stage] &lt;= currentOrder) return; // 이미 더 앞선 단계면 무시

  session.last_stage = stage;
  await session.save();
}</code></pre>
<h2 id="3-ui-이벤트를-기록---발생-횟수가-아니라-본-사람-수로-설계">3. UI 이벤트를 기록 - &quot;발생 횟수&quot;가 아니라 &quot;본 사람 수&quot;로 설계</h2>
<p>처음엔 그냥 <code>view</code>/<code>click</code> 이벤트가 올 때마다 다 쌓으려고 했다. 근데 프론트가 스크롤할 때마다 같은 요소의 <code>view</code>를 여러 번 쏠 수 있다는 걸 짚고 나니, 그렇게 하면 노출 수가 실제보다 뻥튀기돼서 CTR이 왜곡될 수 있었다.</p>
<p>그래서 <strong>같은 세션에서 같은 요소를 여러 번 보거나 눌러도 1건으로만 집계</strong>되도록, <code>session_id + element + action</code> 조합에 유니크 인덱스를 걸고 <code>upsert</code>로 중복을 막았다.</p>
<pre><code class="language-ts">uiEventSchema.index({ session_id: 1, element: 1, action: 1 }, { unique: true });

export async function recordUiEvent(sessionId: string, element: UiEventElement, action: UiEventAction) {
  await UiEventModel.updateOne(
    { session_id: sessionId, element, action },
    { $setOnInsert: { session_id: sessionId, element, action } },
    { upsert: true },
  );
}</code></pre>
<p>이렇게 하면 &quot;노출 수&quot;는 &quot;이벤트가 총 몇 번 발생했나&quot;가 아니라 <strong>&quot;그 요소를 본 세션의 수&quot;</strong>를 의미하게 된다. 실제로 <code>view</code>를 3번, <code>click</code>을 1번 호출해도 DB에는 2건(view 1 + click 1)만 저장되는 걸 확인했다.</p>
<h2 id="⭐-왜-히트맵-라이브러리를-안-쓰고-직접-만들었나">⭐ 왜 히트맵 라이브러리를 안 쓰고 직접 만들었나</h2>
<p>UI 클릭/노출을 추적한다고 하면 보통 Hotjar, PostHog 같은 히트맵/분석 라이브러리부터 떠올린다. 라이브러리 붙이는 것 자체는 안 어렵다. SDK 설치하고 <code>capture(&#39;click&#39;, {...})</code> 한 줄이면 끝나는 수준이다. 그럼에도 직접 만든 이유는, 이 화면 자체가 히트맵이랑 안 맞는 케이스였기 때문이다.</p>
<p><strong>1. 채팅 화면은 히트맵이 강력한 조건(고정된 페이지)이 아니다</strong></p>
<p>히트맵은 보통 레이아웃이 고정된 정적인 페이지(랜딩페이지, 상세페이지 등)에서 강력하다. &quot;이 페이지에서 사람들 눈이 어디로 가나&quot;를 시각적으로 겹쳐 보여주는 방식이기 때문이다.</p>
<p>근데 채팅 화면은 매번 다른 대화가 쌓이는 동적인 스크롤 화면이다. 사용자마다 대화 길이도 다르고, 버튼이 화면에 나타나는 스크롤 위치도 매번 다르다. &quot;여기가 빨간색이다&quot;라고 겹쳐 보여줄 고정된 기준 화면 자체가 없다. 히트맵을 억지로 씌워도 의미 있는 시각화가 나오기 어려운 구조다.</p>
<p><strong>2. 관리자에게 필요한 건 &quot;탐색&quot;이 아니라 &quot;모니터링&quot;이다</strong></p>
<p>히트맵은 &quot;지금 뭘 봐야 할지 모르겠는데 일단 다 보여줘&quot;에 강하다(탐색적 분석). 근데 관리자는 이미 정확히 어떤 UI 요소들을 신경 써야 하는지 알고 있는 상황이다. 요금제 자세히 보기, 비교, 가입하기 같은 이미 정해진 전환 버튼들이다. 이럴 땐 &quot;이 중에 뭐가 문제인지&quot;를 딱 잘라 보여주는 게 더 유용하다.</p>
<p>그래서 CTR 낮은 순 정렬, <code>lowCtr</code> 플래그, 전주 대비 변화율처럼 <strong>&quot;어디가 문제인지 짚어서 바로 액션으로 이어지는&quot; 정량적 리스트</strong>로 구현했다. 히트맵의 &quot;여기가 빨갛네요&quot;보다 &quot;이 버튼이 정확히 15.2%고 지난주보다 3.1%p 떨어졌어요&quot;가 의사결정에는 더 직접적이다.</p>
<p><strong>3. + 우리 자체 데이터(프롬프트 버전)랑 묶어야 하는 것도 있었다</strong></p>
<p>여기에 더해서, 대시보드는 &quot;이 프롬프트 버전을 쓴 세션들의 전환율&quot;처럼 우리가 직접 관리하는 어드민 데이터(<code>prompt_version</code>)랑 묶여야 하는 지표도 필요했다. 외부 툴을 썼어도 결국 이 조인 로직은 직접 짜야 했을 거라, 처음부터 우리 세션 문서에 이벤트를 바로 붙이는 게 더 간단했다.</p>
<p><strong>정리하면</strong>: 정적인 페이지에서 탐색적으로 살펴봐야 하는 상황이었다면 히트맵이 나았을 거다. 근데 이 프로젝트는 동적인 화면에서 이미 알고 있는 특정 요소들을 정밀하게 모니터링해야 하는 상황이라, 용도 자체가 히트맵보다 지금 방식에 더 잘 맞았다.</p>
<h2 id="4-이-데이터를-실제-숫자로-집계하기">4. 이 데이터를 실제 숫자로 집계하기</h2>
<p><strong>퍼널 진입률/이탈률</strong></p>
<p>퍼널이란 &quot;상담 시작 → 추천 완료 → 요금제 비교 → 가입 신청 → 가입 완료&quot;처럼 사용자가 거쳐가는 단계들을 말한다. 사람 수가 단계를 지날수록 점점 줄어드는 게 보통이라(중간에 이탈하는 사람들이 생기니까), 그 모양이 깔때기(funnel)처럼 생겼다고 해서 퍼널이라고 부른다.</p>
<ul>
<li><strong>진입률(<code>entryRate</code>)</strong>: &quot;맨 처음 시작한 사람 중에 몇 %가 이 단계까지 왔나&quot;를 보여주는 숫자<pre><code>entryRate = 그 단계에 도달한 세션 수 ÷ 맨 처음(1단계) 세션 수 × 100</code></pre></li>
<li><strong>이탈률(<code>dropRate</code>)</strong>: &quot;바로 이전 단계에 있던 사람 중에 몇 %가 이 단계에서 빠져나갔나&quot;를 보여주는 숫자<pre><code>dropRate = (이 단계 세션 수 − 직전 단계 세션 수) ÷ 직전 단계 세션 수 × 100</code></pre></li>
</ul>
<p>예를 들어 상담 시작 1284명 → 추천 완료 1041명 → 요금제 비교 762명이라고 하면, 요금제 비교 단계의 진입률은 762 ÷ 1284 × 100 = <strong>59.3%</strong>(맨 처음 대비 몇 % 남았나)다.</p>
<p>이탈률은 맨 처음 대비 누적이 아니라, 직전 단계 대비로 구했다. 누적으로 구하면 뒤로 갈수록 숫자가 계속 커질 수밖에 없어서, 어느 구간에서 특별히 많이 빠지는지 구분하기 어렵다. 예를 들어 5단계의 누적 이탈률이 −70%라고 해도, 앞쪽에서 이미 다 빠진 건지 4→5에서 급격히 빠진 건지 알 수 없다. 직전 단계 대비로 구하면 각 구간의 이탈이 독립적으로 드러나기 때문에 &quot;여기가 병목이다&quot;를 바로 짚을 수 있다. 그리고 맨 처음 대비 누적 관점은 이미 진입률(entryRate)이 보여주고 있으니, 이탈률까지 누적으로 구하면 같은 정보를 두 번 보여주는 셈이다.</p>
<p><strong>UI 요소 CTR</strong></p>
<p>CTR(Click Through Rate)은 &quot;이 버튼을 본 사람 중에 몇 %가 실제로 눌렀나&quot;를 보여주는 비율이다.</p>
<pre><code>CTR = 클릭한 세션 수 ÷ 노출된 세션 수 × 100</code></pre><p>예를 들어 100명이 &quot;가입하기&quot; 버튼을 봤고 그중 15명이 눌렀다면 CTR은 15%다.</p>
<p>근데 여기서 애매한 경우가 하나 있었다. <strong>아직 아무도 안 본 버튼</strong>(노출 0건, 클릭 0건)은 어떻게 계산해야 할까? 공식대로 CTR을 0%로 처리하면, &quot;성과가 진짜 나쁜 버튼&quot;(예: 노출 100건인데 클릭 3건, CTR 3%)이랑 &quot;아직 데이터가 하나도 없는 버튼&quot;이 똑같이 &quot;저성과&quot;로 묶여버린다. 근데 이 둘은 완전히 다른 상황이다 — 하나는 &quot;고쳐야 할 문제&quot;고, 하나는 &quot;아직 판단할 수 없는 상태&quot;다. 그래서 노출이 0건인 요소는 CTR을 0으로 계산은 하되, &quot;저성과 요소&quot; 표시(<code>lowCtr</code>)에서는 제외하도록 따로 처리했다.</p>
<p><strong>&quot;전주 대비&quot; 비교를 기간과 무관하게 일반화</strong></p>
<p>명세서에는 &quot;전일 또는 전주 대비&quot;라고 적혀 있었다. 관리자가 조회하는 기간이 &quot;오늘 하루&quot;면 어제랑 비교하고, &quot;최근 7일&quot;이면 그 이전 7일이랑 비교하라는 뜻이었다. 근데 관리자가 조회 기간으로 30일도 선택할 수 있게 돼 있어서, &quot;전일/전주&quot;라는 표현만으로는 30일짜리 조회를 뭐랑 비교해야 할지 명확하지 않았다.</p>
<p>그래서 표현을 이렇게 일반화했다: <strong>&quot;지금 보고 있는 기간 바로 직전의, 똑같은 길이만큼의 기간&quot;</strong>과 비교한다.</p>
<ul>
<li>오늘(1일) 조회 중이면 → 어제(1일)와 비교</li>
<li>최근 7일 조회 중이면 → 그 이전 7일과 비교</li>
<li>최근 30일 조회 중이면 → 그 이전 30일과 비교</li>
</ul>
<p>이렇게 하면 관리자가 어떤 기간을 선택하든 항상 &quot;같은 길이의 바로 이전 기간&quot;과 자동으로 비교되기 때문에, 조회 기간이 몇 개로 늘어나든 코드를 따로 안 고쳐도 된다.</p>
<h2 id="마무리">마무리</h2>
<p>이벤트를 기록하는 것과 그걸 의미 있는 숫자로 집계하는 건 완전히 다른 고민이었다. 데이터를 그냥 쌓기만 하면 되는 게 아니라, 이 숫자가 나중에 뭘 의미하게 될지(발생 횟수인지, 사람 수인지)를 저장하는 시점에 미리 정해둬야 나중에 집계할 때 헤매지 않는다는 걸 느꼈다.</p>
<p>라이브러리를 쓸지 직접 만들지도 &quot;쉬운가 어려운가&quot;보다 &quot;이 화면의 특성에 어떤 방식이 더 맞는가&quot;로 판단해야 한다는 것도 배웠다. 기능이 단순해 보인다고 해서 늘 아쉬운 선택은 아니고, 용도를 따져본 결과 더 적합한 선택일 수도 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #6] 관리자가 작성한 프롬프트는 어디까지, 어떻게 실제 채팅에 반영되어야 할까?]]></title>
            <link>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-6-%EA%B4%80%EB%A6%AC%EC%9E%90%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8%EB%8A%94-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%8B%A4%EC%A0%9C-%EC%B1%84%ED%8C%85%EC%97%90-%EB%B0%98%EC%98%81%EB%90%98%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-6-%EA%B4%80%EB%A6%AC%EC%9E%90%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8%EB%8A%94-%EC%96%B4%EB%94%94%EA%B9%8C%EC%A7%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%8B%A4%EC%A0%9C-%EC%B1%84%ED%8C%85%EC%97%90-%EB%B0%98%EC%98%81%EB%90%98%EC%96%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 27 Aug 2026 04:59:34 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-한-일-한-줄">오늘 한 일 (한 줄)</h2>
<p>관리자 프롬프트 관리 기능(버전 생성/조회/되돌리기)을 만들면서, 그 프롬프트가 실제 채팅 로직에 어디까지 어떻게 반영돼야 하는지를 설계하고 연결했다.</p>
<h2 id="왜-했나-계기">왜 했나 (계기)</h2>
<p>채팅 기능은 관리자 프롬프트 관리 기능보다 먼저 만들어져 있었다. 그때는 관리 화면 자체가 없었으니 시스템 프롬프트가 코드에 하드코딩돼 있는 게 당연하고 맞는 선택이었다.</p>
<p>이제 관리자가 프롬프트를 만들고 버전을 관리할 수 있게 됐으니, 그 다음 단계로 <strong>&quot;이 프롬프트가 실제로 어디까지, 어떻게 채팅에 반영돼야 하나&quot;</strong>를 설계해야 했다. 저장하고 조회하는 API만으로는 기능이 끝난 게 아니라, 실제 채팅 로직까지 이어져야 완성이었다.</p>
<h2 id="프롬프트가-어떤-조각들로-구성되는지">프롬프트가 어떤 조각들로 구성되는지</h2>
<p>AI한테 실제로 보내는 시스템 프롬프트는 함수 하나(<code>buildSystemPrompt</code>)가 여러 조각을 순서대로 이어붙여서 만든다. 각 조각이 뭘 위한 건지, 그리고 이번에 뭐가 바뀌었는지 정리하면:</p>
<table>
<thead>
<tr>
<th>조각</th>
<th>어떤 데이터 / 왜 필요한지</th>
<th>기존</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>인사말 + <code>[역할]</code> + 규칙 6종</td>
<td>AI의 페르소나(요고다 상담원)와 말투·서식·질문 방식 등 기본 행동 지침</td>
<td>코드에 고정</td>
<td><strong>관리자가 저장한 실제 내용</strong>(<code>basePrompt</code>) - 세션에 고정된 버전으로 DB(<code>prompts.content</code>)에서 조회, 없으면 <code>DEFAULT_PROMPT_CONTENT</code>로 폴백</td>
</tr>
<tr>
<td><code>[대화 시작]</code></td>
<td><code>previousInteractionId</code> 유무로 첫 턴 여부 판단 → 매번 &quot;반갑습니다&quot; 반복 방지</td>
<td>매 메시지마다 계산</td>
<td>동일 (변화 없음)</td>
</tr>
<tr>
<td><code>[응답 형식]</code></td>
<td>AI가 반드시 지킬 JSON 스키마 지시. 깨지면 서버의 <code>JSON.parse</code>가 실패해서 채팅 전체가 멈춤</td>
<td>코드에 고정</td>
<td><strong>여전히 코드에 고정</strong> (관리자 편집 범위 밖으로 의도적으로 뺌)</td>
</tr>
<tr>
<td><code>[이미 파악된 정보]</code></td>
<td>사전 설문 답변 + 대화 중 파악된 정보(<code>collectedInfo</code>) → 같은 질문 반복 방지</td>
<td>매 메시지마다 계산</td>
<td>동일 (변화 없음)</td>
</tr>
<tr>
<td><code>[페르소나 분석]</code></td>
<td>설문 기반 사용 패턴 분석 결과(예: 영상 위주 사용자) → 사용자 성향 반영한 추천 유도</td>
<td>매 메시지마다 계산</td>
<td>동일 (변화 없음)</td>
</tr>
<tr>
<td><code>[요금제 목록]</code></td>
<td>DB에서 실시간 조회한 실제 판매 중인 요금제 목록 → 존재하지 않는 요금제를 지어내는 것 방지</td>
<td>매 메시지마다 계산</td>
<td>동일 (변화 없음)</td>
</tr>
</tbody></table>
<p>바뀐 건 <strong>첫 번째 줄(인사말+역할+규칙)뿐</strong>이다. &quot;코드에 박힌 고정값&quot;이던 자리가 &quot;DB에서 조회한 값(없으면 예전 고정값으로 폴백)&quot;으로 바뀌었고, 나머지는 순서도 성격도 그대로다.</p>
<h2 id="어디까지-관리자-편집-범위로-열지-정했다">어디까지 관리자 편집 범위로 열지 정했다</h2>
<p>인사말+역할+규칙 6종을 하나로 묶어서 <strong>관리자가 편집하는 대상</strong>으로 뺐다. <code>[응답 형식]</code>은 그대로 코드에 남겨뒀다.</p>
<p>이렇게 나눈 이유는, AI 응답이 JSON 형식으로 와야 하는데 그 형식을 지시하는 부분까지 편집 범위에 넣으면 관리자가 실수로 그 지시를 지우거나 바꾸는 순간 AI가 JSON이 아닌 걸 응답하기 시작하고 → 파싱 실패 → <strong>전체 유저의 채팅 기능이 통째로 멈추는</strong> 위험이 있었기 때문이다.</p>
<pre><code class="language-ts">// 관리자가 편집 가능한 부분 — DB에 저장되고 관리됨
export const DEFAULT_PROMPT_CONTENT = `
당신은 통신사 요금제 추천 서비스 &quot;요고다(Yogoda)&quot;의 AI 요금제 상담원입니다.
[역할] ...
`.trim();

export function buildSystemPrompt(
  basePrompt: string, // 관리자가 저장한 실제 내용이 여기로 들어옴
  ...
): string {
  const responseFormatBlock = `[응답 형식] ...`; // 여전히 코드에 고정
  return `${basePrompt}\n${turnBlock}\n${responseFormatBlock}\n...`.trim();
}</code></pre>
<h2 id="대화-도중-버전이-바뀌는-경우를-정리했다">대화 도중 버전이 바뀌는 경우를 정리했다</h2>
<p>관리자가 대화 중간에 새 버전을 활성화하면, 이미 대화하던 사람의 말투가 갑자기 바뀌어버리는 것도 이상했다. 그래서 <strong>세션이 생성되는 시점에 그때의 활성 버전을 세션에 고정</strong>해두기로 했다.</p>
<pre><code class="language-ts">// 세션 생성 시점에 활성 버전을 고정
const promptVersion = await getActivePromptVersion();
const session = await ChatSessionModel.create({
  user_id: userId,
  type: &quot;AIChat&quot;,
  prompt_version: promptVersion, // 이후 관리자가 새 버전을 배포해도 이 값은 안 바뀜
});</code></pre>
<p>이후 그 세션은 관리자가 몇 번을 새로 배포하든 <strong>자기가 시작할 때의 버전으로 끝까지 진행</strong>된다. 새로 연결하는 사람만 새 버전을 받는다.</p>
<h2 id="실제로-연결했다">실제로 연결했다</h2>
<p>소켓이 세션을 준비하는 시점에, 세션에 고정된 버전으로 실제 DB 내용을 조회해서 들고 있는다.</p>
<pre><code class="language-ts">export const getPromptContentByVersion = async (
  version: string | null,
): Promise&lt;string&gt; =&gt; {
  if (version) {
    const prompt = await PromptModel.findOne({ version }).select(&quot;content&quot;).lean();
    if (prompt) return prompt.content;
  }
  return DEFAULT_PROMPT_CONTENT; // 활성 프롬프트가 없어도 채팅은 멈추지 않게
};</code></pre>
<p>이렇게 가져온 내용을 메시지 처리할 때마다 <code>buildSystemPrompt</code>에 실제로 흘려보내면 끝이다.</p>
<pre><code class="language-ts">const systemInstruction = buildSystemPrompt(
  promptContent, // 관리자가 저장한 실제 내용
  surveyContext,
  collectedInfo,
  plans,
  isFirstTurn,
);</code></pre>
<h2 id="검증">검증</h2>
<p>눈에 띄는 문구(예: &quot;모든 답변 끝에 &#39;(테스트버전)&#39;을 붙여라&quot;)를 추가한 버전을 배포하고 실제로 채팅해봤다. AI 응답 끝에 그 문구가 그대로 붙어 나오는 걸 확인했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/f5a433ac-3f86-49ee-a40f-2ff44d0cf05a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/5115f715-14ac-46b8-a9bd-22b7ed8c373a/image.png" alt=""></p>
<h2 id="마무리">마무리</h2>
<p>이번에 프롬프트를 나누면서 든 생각은, 관리자한테 커스터마이징 권한을 준다고 해도 <strong>기본적인 응답 구조만큼은 백엔드가 계속 쥐고 있어야 한다</strong>는 거였다. 그걸 놓아버리면 관리자가 배포할 때마다 응답 형식이 조금씩 흔들리면서 전체가 중구난방이 될 수 있다.</p>
<p>그리고 요금제 목록이나 사용자가 이미 답한 정보 같은 동적 데이터는 애초에 관리자가 직접 채워 넣을 수 있는 값이 아니라, 매 요청마다 백엔드가 DB에서 조회하거나 계산해서 넣어줘야 하는 값이다. 그러니 이런 것도 결국 백엔드가 계속 관리해야 하는 영역이다.</p>
<p>그래서 이번 작업의 핵심은 &quot;얼마나 자유롭게 열어줄까&quot;가 아니라, <strong>&quot;어떤 걸 관리자에게 맡기고 어떤 걸 시스템이 계속 소유해야 하는지 그 경계를 정하는 것&quot;</strong>이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #5] React Query 캐시 때문에 useState 초기값이 틀어지는 버그]]></title>
            <link>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-5-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-</link>
            <guid>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-5-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-</guid>
            <pubDate>Thu, 27 Aug 2026 02:25:56 GMT</pubDate>
            <description><![CDATA[<h2 id="til">TIL</h2>
<p><code>useState</code>의 초기값은 컴포넌트가 <strong>처음 마운트되는 순간에 딱 한 번만</strong> 평가된다. 그런데 그 순간에 참조하는 값(예: React Query가 캐시에서 즉시 돌려준 데이터)이 이미 &quot;최종 상태&quot;와 같다면, &quot;값이 바뀌면 리셋한다&quot;는 로직 자체가 처음부터 무력화될 수 있다.</p>
<p>오늘 겪은 버그가 정확히 이 케이스였다.</p>
<h2 id="상황">상황</h2>
<p>관리자 페이지에 AI 프롬프트를 수정하는 화면을 만들었다. 서버에서 현재 운영 중인 프롬프트를 불러와서 텍스트박스에 보여주고, 수정해서 새 버전으로 배포하는 화면이다.</p>
<p>그런데 이상한 버그가 있었다.</p>
<ul>
<li><strong>새로고침(F5)해서 처음 들어가면</strong> → 프롬프트 내용이 정상적으로 보인다</li>
<li><strong>다른 탭(대시보드 등) 갔다가 다시 돌아오면</strong> → 텍스트박스가 텅 비어있다</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/4a359f20-43eb-4e93-ba06-db55c25f0458/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/22839914-53cb-424d-b03f-96ce8080aabc/image.png" alt=""></p>
<p>같은 화면, 같은 API, 같은 컴포넌트인데 <strong>&quot;어떻게 들어왔냐&quot;에 따라 결과가 다르다</strong>는 게 단서였다.</p>
<h2 id="문제의-코드">문제의 코드</h2>
<pre><code class="language-tsx">const { data: prompt } = useQuery({
  queryKey: [&quot;admin&quot;, &quot;prompts&quot;, &quot;active&quot;],
  queryFn: getActivePrompt,
});

const [content, setContent] = useState(&quot;&quot;);
const [syncedVersionId, setSyncedVersionId] = useState(prompt?.versionId);

// &quot;버전이 바뀌면&quot; 편집 중인 content를 서버 값으로 리셋
if (prompt &amp;&amp; prompt.versionId !== syncedVersionId) {
  setSyncedVersionId(prompt.versionId);
  setContent(prompt.content);
}</code></pre>
<p><code>content</code>는 텍스트박스에 바인딩된 로컬 상태다. 사용자가 자유롭게 수정할 수 있어야 하니까, 서버에서 받아온 <code>prompt.content</code>를 매 렌더마다 그대로 밀어넣을 수는 없다. 그러면 타이핑하는 족족 서버 값으로 덮어써진다.</p>
<p>그래서 &quot;버전(<code>versionId</code>)이 바뀌었을 때만 리셋한다&quot;는 조건을 걸었다. <a href="https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes">React 공식 문서가 소개하는 패턴</a>이기도 하다 — <code>useEffect</code> 없이 렌더링 도중에 상태를 조정하는 방식.</p>
<p>언뜻 문제없어 보인다. 근데 왜 탭을 갔다 오면 깨질까?</p>
<h2 id="원인">원인</h2>
<p>핵심은 이 한 줄이다.</p>
<pre><code class="language-tsx">const [syncedVersionId, setSyncedVersionId] = useState(prompt?.versionId);</code></pre>
<p><code>useState</code>의 초기값 인자는 <strong>컴포넌트가 처음 마운트되는 순간에만</strong> 평가된다. 그 뒤로 몇 번을 리렌더링하든 무시된다.</p>
<p>문제는 &quot;마운트되는 순간 <code>prompt</code>가 이미 값을 갖고 있느냐&quot;가 케이스마다 다르다는 것이었다.</p>
<h3 id="케이스-1---새로고침첫-로드">케이스 1 - 새로고침(첫 로드)</h3>
<ol>
<li>컴포넌트가 처음 마운트된다. 이 시점엔 API 응답이 아직 안 왔으니 <code>prompt</code>는 <code>undefined</code></li>
<li><code>syncedVersionId</code>의 초기값도 <code>undefined</code>로 정해진다</li>
<li>잠시 후 API 응답이 오면 <code>prompt</code>가 채워지고 리렌더링된다</li>
<li><code>prompt.versionId</code>(실제 값)와 <code>syncedVersionId</code>(여전히 <code>undefined</code>)가 <strong>다르다</strong> → 조건 참 → <code>setContent(prompt.content)</code> 실행 → 정상 동작</li>
</ol>
<h3 id="케이스-2---탭-갔다가-돌아옴">케이스 2 - 탭 갔다가 돌아옴</h3>
<ol>
<li><code>/prompts</code>를 떠나면 컴포넌트는 언마운트되지만, <strong>React Query 캐시엔 방금 받은 <code>prompt</code> 데이터가 그대로 남아있다</strong> (기본 동작이고, 원래 이게 훨씬 빠른 UX를 위한 의도된 설계다)</li>
<li>다시 <code>/prompts</code>로 돌아오면 컴포넌트가 새로 마운트된다</li>
<li><code>useQuery</code>는 캐시에 데이터가 있으니 로딩 없이 <strong>첫 렌더부터 바로</strong> <code>prompt</code>를 채워서 준다</li>
<li>그런데 <code>syncedVersionId</code>의 초기값도 바로 이 순간 정해지는데, <code>prompt?.versionId</code>를 참조하고 있어서 <strong>처음부터 <code>prompt.versionId</code>와 똑같은 값으로 시작</strong>해버린다</li>
<li><code>prompt.versionId !== syncedVersionId</code> → 처음부터 거짓 → <code>setContent</code>가 한 번도 실행되지 않음 → <code>content</code>는 최초 선언값인 빈 문자열(<code>&quot;&quot;</code>)로 방치</li>
</ol>
<blockquote>
<p>정리하면, <strong>&quot;데이터가 나중에 도착하느냐 vs 이미 도착해 있느냐&quot;라는 타이밍 차이가 <code>useState</code> 초기값의 정확성을 좌우했다.</strong> 새로고침 케이스에서는 우연히 &quot;초기값 ≠ 실제값&quot;이 성립해서 동작했을 뿐, 애초에 안정적인 로직이 아니었다.</p>
</blockquote>
<h2 id="해결">해결</h2>
<p><code>syncedVersionId</code>의 초기값을 <code>prompt</code>에서 유도하지 않고, 무조건 확실히 다른 값(<code>undefined</code>)으로 고정한다.</p>
<pre><code class="language-tsx">const [syncedVersionId, setSyncedVersionId] = useState&lt;string | undefined&gt;(
  undefined,
);</code></pre>
<p>이러면 마운트 시점에 <code>prompt</code>가 이미 채워져 있든 나중에 채워지든 상관없다. <strong>&quot;방금 막 마운트된 시점&quot;엔 항상 <code>syncedVersionId(undefined) !== prompt.versionId(실제 값)</code>가 성립</strong>하기 때문에, <code>content</code>를 채워주는 코드가 반드시 한 번은 실행된다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/7e07d271-035a-4cb1-b6b5-fcaa3865e7c6/image.png" alt=""></p>
<h2 id="정리">정리</h2>
<p>&quot;prop이나 쿼리 결과가 바뀌면 로컬 상태를 리셋한다&quot;는 패턴을 쓸 때, <strong>비교 기준이 되는 초기값을 그 prop/쿼리 결과에서 유도하면 위험하다.</strong> 그 값이 이미 준비된 채로 컴포넌트가 마운트될 수 있는 경로(캐시, 재마운트, key 재사용 등)가 하나라도 있으면, &quot;초기값 = 현재값&quot;이 되어버려서 리셋 로직 자체가 무력화된다.</p>
<p>이 패턴을 쓸 땐 초기값을 <strong>&quot;이 값과는 절대 같을 수 없는 것&quot;</strong>(대표적으로 <code>undefined</code>, 또는 아직 존재하지 않는 sentinel 값)으로 잡아야, 첫 렌더 시점에 데이터가 이미 와 있든 나중에 오든 항상 동일하게 동작한다.</p>
<p>React Query처럼 캐시를 기본으로 깔고 가는 라이브러리를 쓸 땐 &quot;이 컴포넌트가 마운트되는 순간엔 데이터가 비어있다&quot;는 가정을 은연중에 깔고 코드를 짜기 쉬운데, 캐시 히트 상황에서는 그 가정이 깨진다는 걸 이번에 제대로 겪었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #4] 트러블슈팅 - API 응답이 왜 또 느릴까..?]]></title>
            <link>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%803-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-API-%EC%9D%91%EB%8B%B5%EC%9D%B4-%EC%99%9C-%EB%98%90-%EB%8A%90%EB%A6%B4%EA%B9%8C-enu3772o</link>
            <guid>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%803-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-API-%EC%9D%91%EB%8B%B5%EC%9D%B4-%EC%99%9C-%EB%98%90-%EB%8A%90%EB%A6%B4%EA%B9%8C-enu3772o</guid>
            <pubDate>Wed, 26 Aug 2026 07:21:47 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-한-일-한-줄">오늘 한 일 (한 줄)</h2>
<p>관리자 대시보드 API 응답이 3초대로 느려서 원인을 찾다가, 쿼리 문제와 인프라(리전) 문제가 겹쳐있는 걸 발견하고 둘 다 고쳤다.</p>
<h2 id="왜-했나-계기문제">왜 했나 (계기/문제)</h2>
<p>관리자 대시보드(<code>GET /api/admin/dashboard</code>)를 열었는데 응답이 3초대로 걸렸다. 데이터 양 자체가 많은 것도 아닌데 이 정도 걸리는 건 이상했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/292a0492-656b-4b39-866a-5bad869e2ff1/image.png" alt=""></p>
<h2 id="문제-해결-과정">문제 해결 과정</h2>
<p><strong>1. 일단 코드부터 의심했다 — 실제로 문제가 있었다</strong></p>
<p>대시보드가 내부적으로 호출하는 <code>getPromptHistory</code>를 보니, 프롬프트 버전 개수만큼 DB 조회를 <strong>순서대로(직렬로)</strong> 하고 있었다.</p>
<pre><code class="language-ts">for (const prompt of prompts) {
  const stats = await getVersionStats(prompt.version); // 버전 하나마다 순서대로 기다림
  ...
}</code></pre>
<p>버전이 여러 개면 그만큼 DB 왕복이 한 줄로 쌓이는 구조였다. 서로 독립적인 조회라 <code>Promise.all</code>로 병렬화했다. 대시보드 자체도 KPI/퍼널/프롬프트버전을 3단계로 순서대로 조회하고 있어서, 이것도 한 번에 병렬로 보내도록 고쳤다.</p>
<p>결과: <strong>3초대 → 2초대</strong>. 확실히 나아졌다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/3475d76c-2b6b-4221-bcdb-bac7863fcfc4/image.png" alt=""></p>
<p><strong>2. 근데 2초대도 여전히 이상했다</strong></p>
<p>데이터 양을 생각하면 2초도 말이 안 됐다. 예전에 리전 문제로 비슷한 문제를 겪은 일이 있어서, &quot;이건 쿼리를 더 파봐야 나오는 문제가 아니다&quot;라는 감이 왔다. DB 왕복 시간 자체가 얼마나 걸리는지 확인하려고, 연결한 다음 똑같은 쿼리를 5번 연속으로 날려서 확인해봤다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/44efae21-3c1d-4bb2-87cf-98498fc2f959/image.png" alt=""></p>
<p>처음 연결하고 첫 쿼리까지는 연결 자체를 맺는 비용(워밍업)이라 느릴 수 있다 쳐도, <strong>그 이후로도 쿼리 하나당 220ms대가 꾸준히 나왔다.</strong> 같은 리전이면 보통 이것보다 훨씬 빨라야 하는데(수십 ms 이내), 이 정도로 일정하게 느리다는 건 쿼리를 더 최적화한다고 해결될 문제가 아니라 <strong>물리적 거리 때문에 나는 지연</strong>이라는 신호였다.</p>
<p><strong>3. 클러스터 리전을 확인했다</strong></p>
<p>MongoDB Atlas 콘솔에서 클러스터 리전을 확인해보니 <code>AWS / N. Virginia (us-east-1)</code>였다. 물리적으로 지구 반대편이니 왕복 220ms가 오히려 정상 수치였던 거다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/7670f1e8-ea5a-4dcf-a489-f27df91aab16/image.png" alt=""></p>
<p><strong>4. 서울 리전 클러스터로 옮겼다</strong></p>
<p>마침 팀원이 서울 리전에 만들어둔 클러스터가 있어서, 새로 만드는 대신 그쪽으로 데이터를 옮겼다. 결과: <strong>2초대 → 1초 미만</strong>.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/16a27fe9-9c3c-4440-8904-57e431bafb99/image.png" alt=""></p>
<h2 id="마무리">마무리</h2>
<p>쿼리 최적화는 그 자체로 유효한 개선이었지만, 거기서 멈췄으면 2초대에서 더 못 내려갔을 거다. 예전 트러블슈팅 경험 덕분에 &quot;이 정도 데이터 양에 이 정도 시간이 걸리는 게 말이 되나?&quot; 싶어서, 코드를 더 파는 대신 바로 인프라 쪽을 의심할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #3] 관리자 페이지 - 비회원 채팅 세션 저장하기]]></title>
            <link>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%B9%84%ED%9A%8C%EC%9B%90-%EC%B1%84%ED%8C%85-%EC%84%B8%EC%85%98-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/%EC%9A%94%EA%B3%A0%EB%8B%A4-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%B9%84%ED%9A%8C%EC%9B%90-%EC%B1%84%ED%8C%85-%EC%84%B8%EC%85%98-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 25 Aug 2026 14:34:14 GMT</pubDate>
            <description><![CDATA[<h2 id="오늘-한-일-한-줄">오늘 한 일 (한 줄)</h2>
<p>관리자 대시보드에서 비회원 상담 이탈까지 분석할 수 있도록, 비회원 채팅도 서버에 실시간 저장되게 구조를 바꿨다.</p>
<h2 id="왜-했나-계기문제">왜 했나 (계기/문제)</h2>
<p>원래 설계는 &quot;비회원은 로컬에만 쌓아두고, 로그인해야 서버에 저장한다&quot;였다. 이건 의도된 설계였다. 굳이 계정도 없는 사람의 대화를 서버에 저장할 이유가 없었으니까.</p>
<p>근데 관리자 페이지(대시보드, 세션 로그)를 만들면서 전제가 깨졌다. 상담 시작 → 요금제 추천 → 가입까지의 퍼널에서, 실제로 이탈이 제일 많이 나는 구간은 <strong>아직 로그인도 안 한 비회원 구간</strong>일 가능성이 높다. 근데 지금 구조로는 비회원 세션 자체가 서버에 없어서, 관리자가 &quot;얼마나 많은 사람이 상담 시작 후 로그인도 안 하고 나갔는지&quot;를 아예 볼 수가 없었다.</p>
<p>그래서 &quot;로그인해야 저장한다&quot;는 기존 설계를 &quot;연결되는 순간부터 저장한다&quot;로 바꿔야 했다.</p>
<h2 id="수정하면서-새로-생긴-문제들">수정하면서 새로 생긴 문제들</h2>
<p><strong>1. 로그인 여부를 언제 알 수 있나?</strong>
기존엔 메시지 보낼 때마다 토큰을 같이 보내서 그때그때 인증했다. 근데 세션을 &quot;연결되는 순간&quot;에 만들려면, 그 순간에 로그인 여부를 알아야 하는데 기존 구조로는 불가능했다. 그래서 토큰을 메시지 보낼 때마다가 아니라, 소켓 연결(<code>handshake.auth</code>)할 때 한 번만 보내도록 바꿨다.</p>
<p>문제는 그 다음이었다. 세션을 확보하는 작업(<code>resolveChatSession</code>)은 DB 조회가 필요해서 비동기(async)로 처리해야 하는데, <code>socket.on(&quot;message&quot;, ...)</code> 같은 리스너 등록은 연결되자마자 동기적으로 딱 걸려있어야 한다. 만약 세션 조회가 끝날 때까지 기다렸다가 리스너를 등록하면, 그 짧은 대기 시간 사이에 클라이언트가 메시지를 보내버릴 경우 그 이벤트를 통째로 놓쳐버린다.</p>
<p>그래서 리스너는 먼저 동기적으로 등록해두고, 세션 조회는 별도로 시작해서 그 결과(Promise)만 변수에 담아뒀다. 각 리스너 안에서는 실제 로직을 실행하기 전에 그 Promise를 <code>await</code>해서 &quot;세션 준비가 끝날 때까지만&quot; 기다리게 했다.</p>
<pre><code class="language-ts">// connection 핸들러를 async로 만들면, 이 구간이 끝나기 전까지
// 아래 리스너들이 등록되지 않아 그 사이 이벤트를 놓칠 수 있음.
// 그래서 리스너는 동기적으로 먼저 등록해두고, 각 핸들러 안에서
// 이 Promise를 await해서 세션이 준비될 때까지만 대기함
const sessionReady = (async () =&gt; {
  const { session } = await resolveChatSession(userId, sessionId);
  currentSessionId = session._id.toString();
  socket.emit(&quot;session_created&quot;, { sessionId: currentSessionId, promptVersion: session.prompt_version });
})();

socket.on(&quot;message&quot;, async (payload) =&gt; {
  await sessionReady; // 세션 준비될 때까지 대기
  // ...
});</code></pre>
<p><strong>2. 비회원이 로그인하면 세션이 두 개로 쪼개진다</strong></p>
<p>막상 연결 시점에 세션을 만들도록 바꾸고 나니, 로그인하는 순간의 처리 방식이 문제였다. 기존 방식은 &quot;로컬에 쌓인 대화 내역을 새 세션에 복사해서 저장&quot;이었는데, 이러면 로그인 전 세션(A)과 로그인 후 세션(B)이 따로 생긴다. 상담 시작~요금제 비교까지는 세션 A에, 가입 완료는 세션 B에 찍히는 식이다. 대시보드 입장에서는 세션 A가 &quot;상담만 하고 이탈한 사람&quot;으로, 세션 B가 &quot;상담 없이 바로 가입한 사람&quot;으로 잘못 집계된다.</p>
<p>그래서 로그인 시점에 새 세션을 만드는 대신, 이미 진행 중이던 세션을 찾아서 거기에 <code>user_id</code>만 채워 넣는 방식으로 바꿨다. </p>
<pre><code class="language-ts">const ownershipFilter = userId
  ? { $or: [{ user_id: userId }, { user_id: null }] } // 본인 것 or 아직 주인 없는 비회원 세션
  : { user_id: null };

const session = await ChatSessionModel.findOne({
  _id: sessionId,
  type: &quot;AIChat&quot;,
  ended_at: null,
  ...ownershipFilter,
});

if (session &amp;&amp; userId &amp;&amp; session.user_id === null) {
  session.user_id = userId; // 새로 만들지 않고 그 자리에서 승격
  await session.save();
}</code></pre>
<p><strong>3. 이미 &quot;이탈&quot;로 확정된 세션이 되살아나면?</strong></p>
<p>소켓 연결이 끊기면 그 시점 기준으로 세션 상태(완료/이탈)를 확정해서 저장해두는데, 그 세션에 나중에 다시 접속해서 대화가 이어지면 이미 확정된 &quot;이탈&quot; 상태가 그대로 남는 문제가 있었다. 그래서 세션을 재사용할 때마다 상태를 다시 비워두고, 진짜 끝날 때 다시 확정하도록 처리했다.</p>
<p><strong>4. 프론트가 세션 이어가기를 놓치면?</strong></p>
<p>로그인 유저인데 클라이언트가 실수로 이전 세션 id를 안 보내면 또 쪼개질 수 있다는 걸 뒤늦게 깨달았다. 그래서 서버가 안전장치로, sessionId가 없어도 그 유저의 진행 중인 최신 세션을 알아서 찾아 재사용하도록 추가했다.</p>
<h2 id="마무리">마무리</h2>
<p>&quot;비회원은 저장 안 해도 된다&quot;는 판단 자체는 틀리지 않았다. 다만 그때는 관리자 페이지에서 비회원 데이터까지 봐야 한다는 걸 미처 생각하지 못했을 뿐이다.
한 가지 관점에서 내린 합리적인 판단도, 다른 관점(이번엔 관리자)이 끼어들면 다시 흔들릴 수 있다는 걸 느낀 하루였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #2]구글/네이버/카카오 소셜 로그인 구현해보기]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EA%B5%AC%EA%B8%80%EB%84%A4%EC%9D%B4%EB%B2%84%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EA%B5%AC%EA%B8%80%EB%84%A4%EC%9D%B4%EB%B2%84%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Wed, 19 Aug 2026 10:08:58 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>이전에 사이드 프로젝트에서 카카오 로그인을 구현해본 적이 있었다. 이번에 학원 종합 프로젝트를 진행하면서 구글, 네이버 로그인까지 추가로 구현하게 됐는데, 직접 해보니까 결론부터 말하면 <strong>세 개 다 흐름이 거의 똑같다.</strong></p>
<p>처음엔 각 플랫폼마다 뭔가 다른 방식이 있을 줄 알았는데, OAuth 2.0이라는 공통 표준을 따르기 때문에 하나만 제대로 이해하면 나머지는 앱 등록 + 엔드포인트만 바꿔주면 된다.</p>
<hr>
<h2 id="oauth-20이란">OAuth 2.0이란?</h2>
<p><strong>OAuth 2.0(Open Authorization 2.0)</strong>은 사용자가 자신의 비밀번호를 제3자 서비스에 직접 넘기지 않고도, 다른 플랫폼의 정보에 접근할 수 있도록 권한을 위임하는 표준 프로토콜이다.</p>
<p>예를 들어 우리 서비스에서 카카오 로그인을 한다고 하면, 사용자는 카카오에 직접 로그인하고, 카카오가 &quot;이 사용자가 동의했으니 정보를 줄게&quot;라고 우리 서비스에 알려주는 방식이다. 우리 서비스는 사용자의 카카오 비밀번호를 절대 알 수 없고, 카카오가 허용한 범위의 정보만 받을 수 있다.</p>
<p>카카오, 구글, 네이버 등 대부분의 소셜 로그인이 이 OAuth 2.0을 기반으로 하고 있고, 그중에서도 <strong>인가 코드 방식(Authorization Code Grant)</strong>을 사용한다.</p>
<hr>
<h2 id="oauth-20-인가-코드-방식-흐름">OAuth 2.0 인가 코드 방식 흐름</h2>
<p>카카오, 구글, 네이버 모두 같은 인가 코드 방식을 따르기 때문에, 플랫폼에 관계없이 흐름은 동일하다.</p>
<ol>
<li><strong>사용자가 소셜 로그인 클릭</strong> - 각 플랫폼의 로그인 페이지로 리다이렉트된다.</li>
<li><strong>로그인 완료 → 인가 코드 발급</strong> - 사용자가 동의하면 플랫폼이 인가 코드(authorization code)를 프론트로 전달한다.</li>
<li><strong>백엔드에서 액세스 토큰 교환</strong> - 프론트가 인가 코드를 백엔드로 넘기면, 백엔드가 Client ID + Client Secret + 인가 코드를 가지고 플랫폼 API를 호출해서 액세스 토큰을 받는다.</li>
<li><strong>액세스 토큰으로 유저 정보 요청</strong> - 받은 액세스 토큰으로 플랫폼에 유저 프로필(고유 ID, 닉네임 등)을 요청한다.</li>
</ol>
<p>여기까지가 OAuth 표준 흐름이고, 이후에 받아온 유저 정보를 가지고 어떻게 처리할지는 프로젝트마다 다르게 설계하면 된다.</p>
<hr>
<h2 id="실제-프로젝트에-적용해보기">실제 프로젝트에 적용해보기</h2>
<h3 id="api-내부-로직">API 내부 로직</h3>
<p>OAuth 흐름으로 유저 정보를 받아온 이후, 우리 프로젝트에서는 다음과 같이 처리했다.</p>
<ol>
<li>프론트에서 인가 코드를 백엔드로 전달</li>
<li>백엔드에서 플랫폼 API 호출 → 인가 코드로 액세스 토큰 받음</li>
<li>액세스 토큰으로 유저 정보 요청 → 소셜 ID, nickname 가져옴</li>
<li>신규 유저 여부 확인<ul>
<li>users 컬렉션에 해당 소셜 ID 존재 여부 확인</li>
<li>신규 유저 → users 컬렉션 insert, <code>isNewUser: true</code></li>
<li>기존 유저 → users 컬렉션 조회, <code>isNewUser: false</code></li>
</ul>
</li>
<li>JWT 발급 → accessToken 생성, refreshToken 생성 후 users에 저장 + 쿠키로 전달</li>
<li>200 반환</li>
</ol>
<h3 id="api-명세">API 명세</h3>
<p>세 플랫폼 모두 프론트에서 인가 코드를 받아 백엔드로 넘기는 구조이기 때문에, 엔드포인트만 다르고 Request/Response 형태는 동일하게 설계했다.</p>
<p><strong>Request</strong></p>
<pre><code>POST /api/auth/kakao
POST /api/auth/google
POST /api/auth/naver</code></pre><pre><code>Content-Type: application/json</code></pre><pre><code class="language-json">{
  &quot;code&quot;: &quot;인가_코드&quot;
}</code></pre>
<p><strong>Response 200</strong></p>
<pre><code class="language-json">{
  &quot;accessToken&quot;: &quot;eyJhbGciOi...&quot;,
  &quot;userId&quot;: &quot;6a58450ecd397c1b9213b56c&quot;,
  &quot;name&quot;: &quot;홍길동&quot;,
  &quot;theme&quot;: &quot;light&quot;,
  &quot;isNewUser&quot;: true,
  &quot;role&quot;: &quot;user&quot;
}</code></pre>
<p><strong>Response 401</strong></p>
<pre><code class="language-json">{
  &quot;message&quot;: &quot;카카오 인증에 실패했어요.&quot;
}</code></pre>
<blockquote>
<p>401 응답의 메시지만 플랫폼에 맞게 바뀐다. (&quot;구글 인증에 실패했어요.&quot;, &quot;네이버 인증에 실패했어요.&quot;)</p>
</blockquote>
<hr>
<h2 id="플랫폼별-차이점">플랫폼별 차이점</h2>
<p>로직 자체는 동일하고, 실제로 다른 부분은 <strong>각 플랫폼에서 앱을 등록하고 설정하는 과정</strong>과 <strong>API 엔드포인트</strong>다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>카카오</th>
<th>구글</th>
<th>네이버</th>
</tr>
</thead>
<tbody><tr>
<td>개발자 콘솔</td>
<td><a href="https://developers.kakao.com">Kakao Developers</a></td>
<td><a href="https://console.cloud.google.com">Google Cloud Console</a></td>
<td><a href="https://developers.naver.com">네이버 개발자센터</a></td>
</tr>
<tr>
<td>필요한 키</td>
<td>REST API 키 + Client Secret</td>
<td>Client ID + Client Secret</td>
<td>Client ID + Client Secret</td>
</tr>
<tr>
<td>토큰 요청 URL</td>
<td><code>kauth.kakao.com/oauth/token</code></td>
<td><code>oauth2.googleapis.com/token</code></td>
<td><code>nid.naver.com/oauth2.0/token</code></td>
</tr>
<tr>
<td>유저 정보 URL</td>
<td><code>kapi.kakao.com/v2/user/me</code></td>
<td><code>www.googleapis.com/oauth2/v2/userinfo</code></td>
<td><code>openapi.naver.com/v1/nid/me</code></td>
</tr>
<tr>
<td>유저 고유 ID 필드</td>
<td><code>id</code> (kakaoId)</td>
<td><code>id</code> (googleId)</td>
<td><code>response.id</code> (naverId)</td>
</tr>
</tbody></table>
<blockquote>
<p>결국 바뀌는 건 <strong>엔드포인트 URL</strong>과 <strong>응답 JSON 구조</strong>뿐이다. 핵심 로직은 복붙 수준.</p>
</blockquote>
<h3 id="개발자-콘솔-세팅">개발자 콘솔 세팅</h3>
<p>각 플랫폼마다 개발자 콘솔에서 앱을 등록하고 Redirect URI, 동의 항목 등을 설정해야 한다. 코드보다 이 과정이 오히려 시간을 더 잡아먹었다.</p>
<ol>
<li><strong>카카오</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/3240a2a3-9ff8-42e7-9eac-648817230470/image.png" alt=""></li>
</ol>
<ol start="2">
<li><strong>구글</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/f0496119-6f28-45cc-a3ee-319a87d11abc/image.png" alt=""></li>
</ol>
<ol start="3">
<li><strong>네이버</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/7348cb75-8527-4035-886a-73194afa54b6/image.png" alt=""></li>
</ol>
<hr>
<h2 id="구현하면서-느낀-점">구현하면서 느낀 점</h2>
<ul>
<li><strong>하나만 제대로 이해하면 나머지는 쉽다.</strong> 카카오 로그인을 구현하면서 OAuth 흐름을 이해해두니까, 구글/네이버는 공식 문서 보면서 엔드포인트만 바꿔주면 됐다.</li>
<li><strong>각 플랫폼 개발자 콘솔 세팅이 오히려 더 귀찮다.</strong> 코드보다 Redirect URI 설정, 동의 항목 설정, 검수 신청 같은 콘솔 작업이 시간을 더 잡아먹었다.</li>
<li><strong>응답 구조가 미묘하게 다르다.</strong> 예를 들어 네이버는 유저 정보가 <code>response</code> 안에 한 번 더 감싸져 있다. 이런 사소한 차이만 주의하면 된다.</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>소셜 로그인은 결국 OAuth 2.0의 인가 코드 방식이라는 하나의 패턴이다. 카카오만 해봤을 때는 &quot;다른 플랫폼은 또 다르겠지&quot;라고 생각했는데, 막상 해보면 거의 같다. 처음 소셜 로그인을 구현해보는 사람이라면 하나를 확실히 이해하는 데 집중하는 걸 추천한다. 나머지는 자연스럽게 따라온다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[요고다 개발일지 #1] Claude 디자인 결과물 피그마로 가져오기]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-Claude%EB%94%94%EC%9E%90%EC%9D%B8-%EA%B2%B0%EA%B3%BC%EB%AC%BC-%ED%94%BC%EA%B7%B8%EB%A7%88%EB%A1%9C-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/TIL-Claude%EB%94%94%EC%9E%90%EC%9D%B8-%EA%B2%B0%EA%B3%BC%EB%AC%BC-%ED%94%BC%EA%B7%B8%EB%A7%88%EB%A1%9C-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0</guid>
            <pubDate>Fri, 14 Aug 2026 12:39:54 GMT</pubDate>
            <description><![CDATA[<p>요고다 프로젝트에서 관리자 페이지 UI를 그려야 했다. 사실 시간이 엄청 촉박했던 건 아니지만, <strong>UI를 백지 상태에서 새로 디자인하려니 막막하기도 했고 요새 핫한 Claude Design도 직접 써보고 싶어서</strong> 한번 도전해봤다.</p>
<p>결과적으로 <strong>AI로 프로토타입을 빠르게 생성하고, Figma 플러그인으로 가져와서 다듬는 워크플로우</strong>를 사용했는데 엄청 만족스러웠다.</p>
<hr>
<h2 id="1-claude-design에서-ui-뽑아내기">1. Claude Design에서 UI 뽑아내기</h2>
<p>프롬프트 쓸 때 가장 중요했던 건 <strong>디자인 시스템을 먼저 정의하고, 각 페이지 프롬프트에 계속 포함시키는 것</strong>이었다.</p>
<p>우리 사용자 앱이 <strong>마젠타 핑크(<code>#E6007E</code>) + 흰색 배경 + 둥근 카드 UI</strong> 스타일이라, 관리자 페이지도 이 톤을 유지해야 같은 서비스처럼 보이기 때문이다.</p>
<h3 id="작성한-프롬프트-예시-대시보드-페이지">작성한 프롬프트 예시 (대시보드 페이지)</h3>
<pre><code class="language-text">YOGODA 관리자 대시보드 페이지를 만들 거야.

[디자인 시스템]
- 메인 컬러: #E6007E (마젠타 핑크)
- 배경: 흰색 (#FFFFFF)
- 스타일: 카드형 UI, 둥근 모서리 (border-radius: 12px)
- 레이아웃: 데스크톱 1440px, 좌측 사이드바 네비게이션
- 전체 톤: 미니멀하고 여백이 넉넉한 스타일

[페이지 구성]
- 상단 (주요 지표 카드 3개): 오늘 추천 건수, 가입 건수, 전환율
- 중단: 퍼널 차트 (추천 시작 → 가입 완료)
- 하단: 인기 요금제 TOP 5 + 프롬프트 버전별 전환율 테이블</code></pre>
<blockquote>
<p><strong>📌 Tip:</strong> 전체 페이지를 한 번에 다 만들어달라고 하는 것보다, <strong>페이지 단위로 프롬프트를 나눠서 작성</strong>하는 게 결과물 퀄리티가 훨씬 좋았다.</p>
</blockquote>
<p>생각보다 색상도 지정한 대로 잘 반영되고, 사이드바나 카드 레이아웃 구조를 아주 깔끔하게 잡아줘서 놀랐다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/0fb2ec46-59df-4647-9f8b-8f81277859e3/image.png" alt=""></p>
<hr>
<h2 id="2-figma로-가져오기-html-to-design">2. Figma로 가져오기 (html to design)</h2>
<p>Claude Design 결과물은 HTML/CSS 기반이라 Figma 파일(<code>.fig</code>)로 바로 내보내기가 안 된다. 그래서 <strong>Claude에서 생성한 공유 URL</strong>을 피그마 플러그인에 붙여넣어 가져왔다.</p>
<h3 id="사용한-플러그인-html-to-design">사용한 플러그인: <code>html to design</code></h3>
<p>Figma 커뮤니티의 <strong><code>html to design</code></strong> (by ‹div›RIOTS) 플러그인을 써서 URL 기반으로 디자인을 피그마로 가져왔다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/adcf7b47-c22f-454b-9382-ef122b6d2ff6/image.png" alt=""></p>
<h3 id="피그마로-가져오는-과정">피그마로 가져오는 과정</h3>
<ol>
<li><strong>Claude Design에서 공유 URL 생성</strong>: Claude에게 아티팩트의 공유 URL을 만들어달라고 요청한다.</li>
<li><strong>플러그인 실행 및 URL 붙여넣기</strong>: Figma에서 <code>html to design</code> 플러그인을 열고, <strong>Web 탭</strong>에 복사한 URL을 붙여넣은 뒤 Import 한다.</li>
<li><strong>피그마 캔버스 확인</strong>: 플러그인이 URL에 접속해 렌더링된 페이지를 긁어와서, 변환된 프레임이 피그마 캔버스에 들어온다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/f5c7669d-4703-4466-9d77-79ad5602ac79/image.png" alt=""></li>
</ol>
<blockquote>
<p><strong>⚠️ 주의: URL 유효 시간은 약 10분!</strong>
Claude에서 생성되는 공유 URL은 임시 링크로, <strong>약 10분이 지나면 만료</strong>된다. URL을 생성한 뒤 바로 플러그인에 붙여넣어서 Import해야 한다.</p>
</blockquote>
<hr>
<h2 id="3-전체-워크플로우-요약">3. 전체 워크플로우 요약</h2>
<pre><code class="language-text">[1. 디자인 시스템 정의] (기존 앱 컬러, 폰트, 카드 스타일 추출)
         ↓
[2. 프롬프트 작성] (디자인 시스템 규칙 + 페이지별 요구사항)
         ↓
[3. Claude Design 실행] (프로토타입 생성 및 피드백 수정)
         ↓
[4. 공유 URL 생성] (Claude에게 아티팩트 URL 요청)
         ↓
[5. Figma 가져오기] (`html to design` 플러그인 Web 탭에 URL 붙여넣기)
         ↓
         ⚠️ URL 유효 시간 ~10분, 생성 즉시 Import할 것!
         ↓
[6. 피그마 리터칭] (폰트, 색상, 레이어 정리)
         ↓
[7. 최종 디자인 완성]</code></pre>
<hr>
<h2 id="4-후기--느낀-점">4. 후기 &amp; 느낀 점</h2>
<h3 id="👍-좋았던-점">👍 좋았던 점</h3>
<ul>
<li><strong>백지 상태의 막막함 해소</strong>: 디자인을 전문적으로 배우지 않은 개발자 입장에서, 빈 화면을 채우는 스트레스가 싹 사라졌다.</li>
<li><strong>새로운 AI 툴 경험</strong>: 말로만 듣던 Claude Design을 직접 워크플로우에 녹여보니 AI 툴 활용 범주가 훨씬 넓어진 기분이다.</li>
<li><strong>일관된 톤앤매너</strong>: 프롬프트 상단에 디자인 시스템을 박아두니 사용자 앱이랑 톤이 착 달라붙게 나온다.</li>
</ul>
<h3 id="👎-아쉬운-점--한계">👎 아쉬운 점 / 한계</h3>
<ul>
<li><strong>Figma 디테일 정리 필요</strong>: 변환 후 폰트나 레이어를 다듬는 최소한의 손가락 노동은 필요하다.</li>
<li><strong>URL 유효 시간 주의</strong>: 공유 URL이 약 10분밖에 안 되니까, URL 생성 전에 Figma 플러그인을 미리 열어두고 바로 Import하는 게 좋다.</li>
</ul>
<hr>
<h3 id="결론">결론</h3>
<p>디자이너가 없어서 백지부터 UI 그리기가 막막하거나, 새로운 AI 디자인 툴을 경험해보고 싶은 개발자라면 꼭 한번 써보는 걸 추천한다. 백지에서 고민하는 시간을 대폭 줄여주고 완성도도 꽤 높아서 개인적으로 너무 만족스러운 작업이었다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 개인 프로젝트에 Jira 적용해보기]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-Jira-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-Jira-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 07 Aug 2026 02:32:05 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트(BlueNote)를 진행하면서 이전까지는 노션에 할 일 목록만 대충 적고 개발했다. 근데 기획부터 설계, 개발까지 해야 할 게 많아지니까 &quot;지금 뭘 해야 하지?&quot;가 자주 발생했다.</p>
<p>학원에서 Jira를 배우게 되면서 이번 프로젝트에 직접 적용해보기로 했다. 이전 회사에서는 Azure DevOps를 사용했는데, Jira는 처음이라 비교하면서 정리해본다.</p>
<hr>
<h2 id="azure-devops-vs-jira">Azure DevOps vs Jira</h2>
<p>처음에 Jira를 열었을 때 Azure DevOps랑 비슷하면서도 달랐다.</p>
<p>가장 큰 차이는 계층 구조였다. DevOps는 Epic → Feature → User Story → Task 4단계인데, Jira는 Epic → Story → Sub-task 3단계다. Feature가 없다. DevOps에서 Feature로 쓰던 걸 Jira에서는 Epic으로 쓰면 된다.</p>
<p>백로그, 스프린트 같은 개념은 거의 같았고, DevOps의 User Story가 Jira의 Story, DevOps의 Task가 Jira의 Sub-task 정도의 차이였다.</p>
<hr>
<h2 id="백로그-구성">백로그 구성</h2>
<p>백로그를 만들 때 처음에는 Epic 아래에 Task만 쭉 넣었다. 그런데 스토리와 태스크의 차이를 알게 됐다.</p>
<p><strong>Story</strong> — 사용자 관점. 정석 포맷은 3파트로 쓴다.</p>
<pre><code>[역할]로서 [기능]을 하고 싶다. 그래서 [가치]를 얻을 수 있다.</code></pre><p><strong>Task</strong> — 개발자 관점. 실제 구현 작업이다.</p>
<p>예를 들면 이런 식이다.</p>
<pre><code>[Story] 사용자로서 카카오 계정으로 로그인하고 싶다.
        그래서 별도 회원가입 없이 빠르게 시작할 수 있다.
  ├── [Task] BE — 카카오 로그인 API 구현
  └── [Task] FE — 카카오 SDK 연동</code></pre><p>이렇게 나누니까 &quot;이 작업이 사용자에게 어떤 가치를 주는지&quot;가 명확해졌다. Task만 나열했을 때는 그냥 할 일 목록이었는데, Story가 붙으니 왜 이걸 만드는지가 보였다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/6393b18e-dc0d-494f-af1a-ae3de64dabec/image.png" alt=""></p>
<hr>
<h2 id="기획도-jira에-넣을까">기획도 Jira에 넣을까?</h2>
<p>고민이 있었다. 기획 단계에서 한 일들(서비스 방향 설계, 브랜드 정의, API 명세 작성 등)도 Jira에 넣어야 하나?</p>
<p>정석적으로는 스프린트에 기획을 안 넣는다. 스프린트는 &quot;동작하는 결과물&quot;을 만드는 기간이기 때문이다. 기획의 결과물은 코드가 아니라 문서다.</p>
<p>결론적으로 기획/설계 에픽은 만들되 스프린트에는 포함하지 않았다. 백로그에서 완료 처리만 했다. 이렇게 하면 Jira에서 기획부터 배포까지 전체 흐름이 보이면서도 스프린트 원칙은 지킬 수 있었다.</p>
<hr>
<h2 id="서브태스크와-스프린트">서브태스크와 스프린트</h2>
<p>하나 배운 게 있다. Jira에서 서브태스크는 부모 스토리의 스프린트를 따라간다. 서브태스크에는 Sprint 필드가 따로 없다.</p>
<p>처음에는 BE 태스크를 Sprint 1에, FE 태스크를 Sprint 2에 각각 넣으려고 했는데 안 됐다. 서브태스크를 독립 이슈로 만들면 가능하지만, 혼자 하는 프로젝트에서 그렇게까지 할 필요는 없었다.</p>
<p>대신 스토리를 Sprint 1에 넣고, Sprint 1에서 BE 태스크를 끝내고, Sprint 2에서 FE 태스크를 이어서 하는 방식으로 진행했다.</p>
<hr>
<h2 id="최종-구조">최종 구조</h2>
<pre><code>Epic 10개 (기획, 설계, 인프라, 인증, 사용자, 기록, 목표, 나의 패턴, 랜딩, 설정)
Story 22개
Task 59개
Sprint 3개 (기획/설계는 백로그에서 완료 처리)</code></pre><hr>
<h2 id="느낀-점">느낀 점</h2>
<p>혼자 하는 프로젝트에서 Jira가 과하다고 생각할 수 있다. 나도 처음엔 그랬다. 근데 막상 해보니까 좋았던 점이 있었다.</p>
<p>첫째, &quot;지금 뭘 해야 하지?&quot;가 사라졌다. 스프린트에 들어있는 태스크를 위에서부터 치면 된다.</p>
<p>둘째, 진행 상황이 눈에 보였다. 보드에서 To Do → In Progress → Done으로 옮기는 게 생각보다 성취감이 있었다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/61629690-5ee9-49bb-af7a-1c6fe1e2e7fc/image.png" alt=""></p>
<p>셋째, 범위 조절이 됐다. 백로그에 전부 넣어놓으니까 &quot;이건 이번 스프린트에서 빼고 다음에 하자&quot;가 자연스러웠다. 노션에서는 할 일 목록이 끝없이 늘어나기만 했는데.</p>
<p>다만 솔직히 아직은 Azure DevOps가 더 편했다. 익숙해서 그런 것도 있지만, Jira에서 유저 스토리를 작성하는 부분이 좀 생소했다. Jira는 &quot;사용자로서 ~하고 싶다. 그래서 ~를 얻을 수 있다&quot; 형태로 쓰는 게 처음이라 어떻게 쓰는 게 맞는 건지 감이 잘 안 잡혔다.</p>
<p>아직 Jira를 많이 안 써봐서 그런 것 같다. 더 써보면서 익숙해지면 달라질 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] GitHub Actions → Azure 배포 시 OIDC 로그인 실패 (AADSTS700213)]]></title>
            <link>https://velog.io/@jhwest-dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-GitHub-Actions-Azure-%EB%B0%B0%ED%8F%AC-%EC%8B%9C-OIDC-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%8B%A4%ED%8C%A8-AADSTS700213</link>
            <guid>https://velog.io/@jhwest-dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-GitHub-Actions-Azure-%EB%B0%B0%ED%8F%AC-%EC%8B%9C-OIDC-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%8B%A4%ED%8C%A8-AADSTS700213</guid>
            <pubDate>Wed, 05 Aug 2026 03:39:11 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트를 진행하면서 GitHub Actions + Azure App Service로 CI/CD를 구성했다. 그동안 별 문제 없이 잘 배포되고 있었는데, 새 저장소를 만들고 동일한 방식으로 연결하니까 갑자기 배포가 실패했다. 설정을 바꾼 것도 없는데 왜 안 되는 건지 한참 헤맸다.</p>
<hr>
<h2 id="증상">증상</h2>
<p><code>dev</code> 브랜치에 push 후 GitHub Actions의 <code>azure/login@v2</code> 스텝에서 아래 에러가 발생하면서 배포가 실패했다.</p>
<pre><code>Error: AADSTS700213: No matching federated identity record found for presented assertion subject
&#39;repo:bluenote-labs@313032406/bluenote-be@1323082459:ref:refs/heads/dev&#39;.</code></pre><p>Issuer와 Audience는 정상인데 Subject 값만 일치하지 않는 상황이었다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/fafd28ef-7928-4059-a5f7-69f2740278b2/image.png" alt=""></p>
<hr>
<h2 id="원인">원인</h2>
<p>Azure App Service의 Deployment Center를 통해 GitHub Actions 배포를 연결하면, Azure가 자동으로 Managed Identity와 OIDC federated credential을 생성한다. 이때 Subject는 아래와 같은 기존 포맷으로 등록된다.</p>
<pre><code>repo:bluenote-labs/bluenote-be:ref:refs/heads/dev</code></pre><p>그런데 GitHub은 2026년 4월에 발표한 <a href="https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/">Immutable Subject Claims</a> 정책에 따라, <strong>2026년 7월 15일 이후에 생성된 저장소</strong>는 OIDC 토큰의 Subject에 불변 숫자 ID를 포함시킨다.</p>
<pre><code>repo:bluenote-labs@313032406/bluenote-be@1323082459:ref:refs/heads/dev</code></pre><p>내 저장소는 2026년 8월 4일에 생성되어 새 포맷이 바로 적용됐다. 하지만 Azure Deployment Center는 아직 이 변경을 반영하지 못하고 구식 포맷으로만 credential을 등록하고 있어서, <strong>Subject 불일치로 인증이 거부</strong>된 것이었다.</p>
<p>Deployment Center에서 연결을 끊고 다시 해도 동일하게 구식 포맷으로만 생성되어 같은 에러가 반복됐다.</p>
<hr>
<h2 id="확인-방법">확인 방법</h2>
<p>GitHub API로 저장소의 조직 ID와 저장소 ID를 확인할 수 있다.</p>
<pre><code class="language-bash">curl -s https://api.github.com/repos/{org}/{repo} | jq &#39;{id, full_name, created_at}&#39;</code></pre>
<ul>
<li><code>id</code> → 저장소 ID</li>
<li><code>created_at</code> → 2026-07-15 이후면 새 포맷 대상</li>
</ul>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>Azure Deployment Center가 자동 생성한 federated credential의 Subject가 구식 포맷이므로, 이를 새 포맷으로 수정해야 한다.</p>
<ol>
<li>Azure Portal → 해당 App Service → <strong>Deployment Center</strong> → 인증에 사용 중인 Managed Identity 확인</li>
<li>해당 Managed Identity → <strong>페더레이션 자격 증명</strong> → 기존 credential 선택</li>
<li><strong>주체 식별자</strong>를 immutable ID 포맷으로 수정</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/c8f39513-95ab-429f-b48e-f1fbdca824a8/image.png" alt=""></p>
<pre><code># 기존 (구식 포맷)
repo:bluenote-labs/bluenote-be:ref:refs/heads/dev

# 수정 (새 포맷)
repo:bluenote-labs@313032406/bluenote-be@1323082459:ref:refs/heads/dev</code></pre><p>본인 저장소에 적용할 때는 아래 포맷을 참고하면 된다.</p>
<pre><code>repo:&lt;org&gt;@&lt;org_id&gt;/&lt;repo&gt;@&lt;repo_id&gt;:ref:refs/heads/&lt;branch&gt;</code></pre><ol start="4">
<li>저장 후 실패한 워크플로우 Re-run으로 확인</li>
</ol>
<hr>
<h2 id="주의할-점">주의할 점</h2>
<ul>
<li><code>main</code>, <code>dev</code> 등 브랜치별로 배포 워크플로우가 있다면, 각 브랜치에 대한 credential을 별도로 등록해야 한다.</li>
<li>저장소를 rename하거나 transfer해도 ID는 유지되므로 Subject는 변하지 않는다.</li>
<li>Azure Deployment Center가 새 포맷을 지원하게 업데이트되면 해결될 수 있지만, 현재(2026년 8월)는 수동 등록이 필요하다.</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<p>GitHub의 OIDC 정책 변경과 Azure Deployment Center의 업데이트 시차로 인해 발생한 문제였다. 핵심은 <strong>2026년 7월 15일 이후 생성된 저장소</strong>는 OIDC Subject 포맷이 달라진다는 것이고, Azure 쪽에서 아직 이를 자동 반영하지 못하고 있다는 것이다.</p>
<p>같은 에러를 만났다면 federated credential의 Subject를 immutable ID 포맷으로 수동 등록하면 해결된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[미니프로젝트2 회고 - 구움(Gooum)]]></title>
            <link>https://velog.io/@jhwest-dev/%EB%AF%B8%EB%8B%88%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B82-%ED%9A%8C%EA%B3%A0-%EA%B5%AC%EC%9B%80Gooum</link>
            <guid>https://velog.io/@jhwest-dev/%EB%AF%B8%EB%8B%88%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B82-%ED%9A%8C%EA%B3%A0-%EA%B5%AC%EC%9B%80Gooum</guid>
            <pubDate>Fri, 31 Jul 2026 07:56:03 GMT</pubDate>
            <description><![CDATA[<h2 id="팀-소개">팀 소개</h2>
<ul>
<li><strong>팀명:</strong> 구운 감자</li>
<li><strong>구성:</strong> 프론트엔드 2명 · 백엔드 1명(본인)</li>
<li><strong>기간:</strong> 2026.07.16 ~ 2026.07.30 (약 2주)</li>
</ul>
<hr>
<h2 id="프로젝트-소개">프로젝트 소개</h2>
<h3 id="구움gooum">구움(Gooum)</h3>
<p>팀명인 <strong>구운감자</strong>의 &#39;구움&#39;과 협업 공간을 뜻하는 <strong>Room</strong>을 결합한 이름.</p>
<blockquote>
<p>대화를 나누고, 협업하며, 결과물을 함께 구워내는 공간</p>
</blockquote>
<p>실시간 채팅 · AI 회의록 자동 생성 · 동시 문서 편집을 하나로 합친 AI 기반 협업 메신저다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/0f5700cd-c802-4bff-8762-c4c4e1e7a9d2/image.png" alt=""></p>
<hr>
<h2 id="왜-만들었는가">왜 만들었는가</h2>
<p>팀 협업을 할 때 보통 대화는 카카오톡, 기록은 노션, 문서 편집은 구글독스를 쓴다. 도구가 흩어져 있다 보니 탭을 계속 오가야 하고, 회의가 끝나면 누군가가 대화 내용을 일일이 정리해서 옮겨야 한다. 그 과정에서 결정사항이 빠지는 일도 생긴다.</p>
<p>Slack이나 Teams 같은 도구도 있지만 &quot;같은 조직&quot;이 전제되기 때문에 해커톤, 사이드 프로젝트, 대학 팀플 같은 상황에서는 도입 자체가 어렵다. 결국 다시 카톡과 구글독스로 돌아가는 악순환이 반복된다.</p>
<p>그래서 <strong>채팅 → AI 요약 → 동시 편집</strong>의 흐름을 하나의 서비스에서 끊김 없이 연결하고, 워크스페이스 없이 가입만으로 누구와든 바로 협업할 수 있는 서비스를 만들고자 했다.</p>
<hr>
<h2 id="내가-맡은-역할">내가 맡은 역할</h2>
<p>백엔드 전체를 담당했다. REST API 설계, 실시간 채팅(Socket.io), DB 설계, 인증, 파일 업로드, 배포까지 백엔드에 필요한 모든 영역을 직접 구현했다. 동시 편집(y-websocket)은 프론트 팀원이 구현과 테스트를 진행했고, 나는 해당 코드를 서버에 배포하는 역할을 맡았다.</p>
<hr>
<h2 id="핵심-기능">핵심 기능</h2>
<h3 id="1-실시간-채팅">1. 실시간 채팅</h3>
<p>1:1 및 그룹 채팅, 실시간 메시지 송수신을 지원한다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/2fe52b69-be68-4f3d-92ce-96dbeaad34fc/image.png" alt=""></p>
<h3 id="2-동시-문서-편집">2. 동시 문서 편집</h3>
<p>여러 사용자가 하나의 문서를 동시에 편집할 수 있고, 변경 사항이 실시간으로 반영된다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/8fff4e31-c628-469b-8fed-d97b9776e48b/image.png" alt=""></p>
<h3 id="3-ai-회의록-생성">3. AI 회의록 생성</h3>
<p>채팅 내용을 기반으로 AI가 회의록을 자동 생성하고, 생성된 회의록을 동시 편집 가능한 문서로 저장할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/ae5a2ad5-6fd1-4310-afa7-84a9bbafa5f2/image.png" alt=""></p>
<hr>
<h2 id="기술-선택과-이유">기술 선택과 이유</h2>
<h3 id="nodejs--typescript를-선택한-이유">Node.js + TypeScript를 선택한 이유</h3>
<p>원래 백엔드는 거의 Python + FastAPI 위주로 해왔다. 그런데 이번 프로젝트에서 실시간 채팅을 구현해야 했고, Socket.io가 가장 편하고 적합해 보였다. Socket.io를 제대로 활용하려면 Node.js 환경이 자연스러웠다.</p>
<p>거기에 부트캠프에서 프론트엔드를 배우면서 JavaScript를 익히게 됐는데, 매일 하던 Python보다는 새로운 언어로 백엔드에 도전해보는 게 좋겠다고 판단했다. TypeScript를 선택한 건 타입 안전성 때문이었고, 결과적으로 백엔드를 TypeScript로 처음 해본 경험이 됐다.</p>
<h3 id="azure-배포">Azure 배포</h3>
<p>Azure App Service 단일 인스턴스에 REST API, Socket.io, y-websocket을 함께 올렸다. 처음에는 이 세 가지가 하나의 서버에서 동시에 돌아간다는 게 신기했는데, 구조를 보니 이해가 됐다. Express가 HTTP 요청을 처리하면서, 같은 서버의 같은 포트에서 Socket.io와 y-websocket이 HTTP 연결을 WebSocket으로 업그레이드해서 붙는 방식이었다. Node.js가 이벤트 기반 논블로킹 I/O라서 하나의 프로세스에서 HTTP와 WebSocket을 동시에 처리할 수 있는 것이다.</p>
<p>배포가 단순하고 관리 포인트가 하나라는 장점이 있지만, 트래픽이 커지면 채팅이 몰릴 때 API도 같이 느려질 수 있다는 단점이 있다. 다만 MVP 단계에서는 이 구조가 훨씬 합리적인 선택이었다.</p>
<p>시크릿 관리는 Azure Key Vault를 사용했고, 시스템 할당 관리 ID를 통해 안전하게 접근하도록 구성했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/48d6f134-c117-4658-9d90-16c91d429f50/image.png" alt=""></p>
<h3 id="ai-호출-구조">AI 호출 구조</h3>
<p>AI 회의록 생성은 프론트엔드에서 Gemini API를 직접 호출하는 방식으로 구현했다. 프론트 팀원이 클라이언트에서 AI 호출하는 경험을 해보고 싶다고 해서 이번에는 그렇게 진행했다. 다만 이 구조에서는 API 키가 클라이언트에 노출되기 때문에, 향후에는 백엔드를 프록시로 두고 키를 서버 환경변수에서만 관리하는 방식으로 전환하기로 했다.</p>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="1-api-응답-시간-지연--db-리전-이슈">1. API 응답 시간 지연 — DB 리전 이슈</h3>
<p>가장 큰 이슈였다. API 응답이 대부분 3초 이상 걸렸고, 쿼리를 아무리 최적화해도 체감할 수 있는 변화가 없었다. 뭔가 근본적인 문제가 있다고 느껴서 원인을 파고들었더니, App Service는 Korea Central에 만들었는데 Cosmos DB는 브라질 리전에 생성되어 있었다. 물리적 거리에서 오는 네트워크 레이턴시가 원인이었다.</p>
<p>DB 리전을 Japan으로 옮기자 대부분의 API 응답이 <strong>3초 이상 → 0.5초 이내</strong>로 줄었다. 코드 레벨의 최적화보다 인프라 구성을 먼저 점검해야 한다는 걸 체감한 경험이었다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/45850091-f587-4a78-a03d-ecb65e8c508e/image.png" alt=""></p>
<h3 id="2-브라우저-캐시-이슈">2. 브라우저 캐시 이슈</h3>
<p>코드나 데이터를 수정해도 화면에 반영되지 않는 현상이 있었다. 브라우저가 이전 응답을 캐시에서 그대로 재사용하고 있었던 것. Ctrl+Shift+R로 하드 리프레시하면 캐시를 무시하고 강제 재요청이 되면서 수정 사항이 정상 반영됐다.</p>
<h3 id="3-로컬-환경-실시간-테스트-이슈">3. 로컬 환경 실시간 테스트 이슈</h3>
<p>팀원과 실시간 채팅을 테스트할 때 메시지가 서로에게 반영되지 않는 문제가 있었다. 원인은 단순했다. 각자 로컬 환경(localhost)에서 서버를 실행하고 있어서 같은 소켓 서버에 연결되지 않았던 것. 배포된 서버에 함께 접속해서 테스트를 진행하니 실시간 송수신이 정상 동작했다.</p>
<hr>
<h2 id="배운-점과-좋았던-점">배운 점과 좋았던 점</h2>
<h3 id="양방향-실시간-통신을-직접-경험한-것">양방향 실시간 통신을 직접 경험한 것</h3>
<p>이전까지는 단방향 통신만 해봤기 때문에 소켓이나 양방향 통신이 어떻게 동작하는지 개념으로만 알고 있었다. 이번에 Socket.io로 직접 메신저를 구현해보니 &quot;아, 메신저가 이런 식으로 동작하는 거구나&quot;를 확실히 체감할 수 있었다.</p>
<h3 id="익숙한-것-대신-도전을-선택한-것">익숙한 것 대신 도전을 선택한 것</h3>
<p>매일 쓰던 Python/FastAPI 대신 TypeScript + Node.js로 백엔드 전체를 구현한 건 처음이었는데, 새로운 언어로 실제 서비스를 만들어보는 경험 자체가 재밌었다.</p>
<hr>
<h2 id="아쉬운-점">아쉬운 점</h2>
<h3 id="시간-부족으로-못-넣은-기능들">시간 부족으로 못 넣은 기능들</h3>
<p>2주라는 시간 안에 MVP를 완성하는 데 집중하다 보니, 구현하고 싶었지만 넣지 못한 기능들이 있다. 이모지 리액션 기능은 메시지에 대한 간편한 피드백 수단으로 꼭 넣고 싶었고, 동시 편집도 현재 순수 텍스트만 지원하는데 마크다운 문법을 지원했다면 회의록이나 기획서 같은 실무 문서까지 플랫폼 안에서 완결할 수 있었을 것이다.</p>
<h3 id="ai-활용의-확장-가능성">AI 활용의 확장 가능성</h3>
<p>현재 AI는 채팅 내용을 요약해서 회의록을 생성하는 한 가지 용도로만 사용하고 있다. 하지만 대화 맥락을 기반으로 할 일(To-Do) 목록을 자동 추출하거나, 회의록을 여러 버전(간단 요약 / 상세 기록 / 액션 아이템 중심 등)으로 생성하는 기능까지 확장할 수 있었다면 서비스의 차별점이 더 뚜렷했을 것이다.</p>
<hr>
<h2 id="향후-개선-방향">향후 개선 방향</h2>
<h3 id="기능-확장">기능 확장</h3>
<p><strong>리액션 기능:</strong> 이모지 리액션을 추가하면 불필요한 답장을 줄이고 빠르게 의사 표현이 가능해진다.</p>
<p><strong>마크다운 기반 리치 문서 편집:</strong> 마크다운 문법을 지원하고 실시간 렌더링까지 되면 실무 문서를 플랫폼 안에서 완결할 수 있다.</p>
<p><strong>AI 다중 요약 모드:</strong> 간단 요약, 상세 기록, 액션 아이템 중심 등 목적에 맞는 여러 버전의 회의록을 생성할 수 있도록 확장한다.</p>
<h3 id="구조-개선">구조 개선</h3>
<p><strong>API 키 서버 사이드 전환:</strong> 현재 프론트에서 Gemini API를 직접 호출하고 있어 API 키가 클라이언트에 노출된 상태다. 백엔드 프록시를 통해 API를 호출하고 키는 서버 환경변수로만 관리하는 구조로 전환하면 키 탈취 및 요금 폭탄 위험을 제거할 수 있다.</p>
<hr>
<h2 id="링크">링크</h2>
<ul>
<li><strong>GitHub (Backend):</strong> <a href="https://github.com/ureca-Gooum/Gooum-BE">https://github.com/ureca-Gooum/Gooum-BE</a></li>
<li><strong>GitHub (Frontend):</strong> <a href="https://github.com/ureca-Gooum/Gooum-FE">https://github.com/ureca-Gooum/Gooum-FE</a></li>
<li><strong>배포 URL:</strong> <a href="https://gooum-green.vercel.app/">https://gooum-green.vercel.app/</a></li>
<li><strong>시연 영상:</strong> <a href="https://youtu.be/JqVb_mgDe8o">https://youtu.be/JqVb_mgDe8o</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] API 응답이 왜 느릴까..]]></title>
            <link>https://velog.io/@jhwest-dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-API-%EC%9D%91%EB%8B%B5%EC%9D%B4-%EC%99%9C-%EB%8A%90%EB%A6%B4%EA%B9%8C</link>
            <guid>https://velog.io/@jhwest-dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-API-%EC%9D%91%EB%8B%B5%EC%9D%B4-%EC%99%9C-%EB%8A%90%EB%A6%B4%EA%B9%8C</guid>
            <pubDate>Thu, 30 Jul 2026 14:00:28 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요">1. 개요</h2>
<p>이번에 학원에서 미니프로젝트 2를 진행하면서 API 응답 속도가 계속 <strong>3초 이상</strong> 걸리는 기이한 현상을 겪었다.
코드 상의 문제인가 싶어 쿼리를 최적화하고, 인덱스를 재점검하는 등 할 수 있는 방법은 다 시도해 보았지만 속도는 전혀 개선되지 않았다.</p>
<p>대체 왜 이렇게 느린 걸까 고민하던 중... 원인은 상상도 못 한 곳에 있었다.</p>
<hr>
<h2 id="2-문제-원인-분석">2. 문제 원인 분석</h2>
<p>어이없게도 문제는 <strong>Azure 리전(Region) 설정</strong>이었다.</p>
<ul>
<li><strong>Azure App Service (웹 앱):</strong> Korea Central (한국 중부)</li>
<li><strong>Azure DocumentDB (DB):</strong> Brazil South (브라질 남부)</li>
</ul>
<p>아무 생각 없이 DB 인스턴스를 생성할 때 리전 설정을 확인하지 못해 <strong>지구 반대편</strong>에 생성되어 있던 것이었다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/1b5e0422-931a-4b3e-98ea-861ae34d88e5/image.png" alt=""></p>
<h3 id="왜-3초나-걸렸을까">왜 3초나 걸렸을까?</h3>
<ol>
<li><strong>빛의 속도 한계 (RTT):</strong> 한국(Korea Central)에서 브라질(Brazil South) 데이터센터까지 네트워크 패킷이 왕복하는 데만 <strong>물리적으로 약 300ms ~ 350ms</strong>가 소요된다.</li>
<li><strong>지연시간의 누적:</strong> API 하나를 처리할 때 Azure DocumentDB 조회가 3<del>4번만 일어나도, 다른 로직을 제외하고 **오직 네트워크 대기시간으로만 1초</del>1.5초 이상**이 순식간에 누적된다.</li>
<li>여기에 애플리케이션의 비즈니스 로직과 데이터 처리 시간까지 합쳐지면서 최종 응답 시간이 3초를 훌쩍 넘어버렸던 것이다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/66be54de-5ddc-4a1c-a11c-3d5e3bab6d15/image.png" alt=""></p>
<blockquote>
<p>💡 아무리 백엔드 코드나 쿼리를 최적화해도, <strong>물리적 거리가 만들어내는 네트워크 레이턴시(Network Latency)</strong>의 벽은 넘을 수 없다!</p>
</blockquote>
<hr>
<h2 id="3-해결-과정">3. 해결 과정</h2>
<ol>
<li>기존 브라질 리전에 있던 Azure DocumentDB의 데이터를 정리/백업.</li>
<li>App Service가 위치한 <strong>Korea Central (한국 중부) 리전</strong>에 맞춰 DocumentDB를 새로 생성하고 마이그레이션 진행.</li>
</ol>
<h3 id="결과-비교">결과 비교</h3>
<ul>
<li><strong>기존 (Korea Central - Brazil South):</strong> DB 네트워크 Latency 약 <strong>300ms~350ms</strong> ➔ API 평균 응답시간 <strong>3초 이상</strong></li>
<li><strong>개선 (Korea Central - Korea Central):</strong> 동일 리전 내 Latency <strong>1ms~5ms</strong> ➔ API 평균 응답시간 <strong>0.x초대 (정상 복구)</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/e724f18c-53ca-496a-bb2b-7d3bcbc74f6e/image.png" alt=""></p>
<hr>
<h2 id="4-회고">4. 회고</h2>
<ul>
<li><strong>인프라 기본의 중요성:</strong> Azure에서 자원을 생성할 때 Resource Group이나 Region 배치가 서비스 전체 성능에 얼마나 치명적인 영향을 주는지 몸소 체감했다.</li>
<li><strong>디버깅 시야 확장:</strong> 성능 문제가 발생했을 때 애플리케이션 내부(코드, DB)만 파고들 것이 아니라, <strong>인프라 간 통신 경로와 물리적 위치</strong>도 반드시 주요 체크리스트에 포함해야 함을 배웠다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Node.js(Express + TypeScript)로 프로젝트 구조 잡기]]></title>
            <link>https://velog.io/@jhwest-dev/Node.jsExpress-TypeScript%EB%A1%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/Node.jsExpress-TypeScript%EB%A1%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0</guid>
            <pubDate>Mon, 20 Jul 2026 15:04:16 GMT</pubDate>
            <description><![CDATA[<p>Python FastAPI로 백엔드 개발을 해오다가, 이번 팀 프로젝트에서 Node.js + Express + TypeScript 조합으로 전환하게 됐다. 같은 REST API를 만드는 건데 프레임워크가 달라지니까 &quot;이건 Express에서는 어떻게 하지?&quot;라는 질문이 계속 나왔다.</p>
<p>오늘은 FastAPI에서 익숙했던 구조를 Express에서 어떻게 대응시켰는지 정리해본다.</p>
<hr>
<h2 id="폴더-구조-매핑">폴더 구조 매핑</h2>
<p>FastAPI에서는 보통 이런 구조로 개발했다.</p>
<pre><code>app/
├── api/          # 라우터 (엔드포인트)
├── core/         # 설정, DB 연결
├── schema/       # Pydantic 모델 (요청/응답 검증)
├── models/       # ORM/ODM 모델
├── service/      # 비즈니스 로직
└── util/         # 공통 유틸</code></pre><p>Express + TypeScript에서는 이렇게 대응시켰다.</p>
<pre><code>src/
├── api/
│   ├── routes/        # 라우터 (URL 매핑만)
│   └── controllers/   # 컨트롤러 (요청 처리 + 응답)
├── core/
│   ├── config/        # 환경변수, Swagger 설정
│   ├── db/            # DB 연결
│   ├── middlewares/    # 인증, 에러 핸들러
│   └── security/      # JWT 관련
├── models/            # Mongoose 스키마
├── schemas/           # Zod 검증 스키마
├── services/          # 비즈니스 로직
├── types/             # TypeScript 타입 선언
└── server.ts          # 진입점</code></pre><p>가장 큰 차이는 <strong>controller 레이어의 유무</strong>다. FastAPI에서도 service 레이어는 분리해서 사용했지만, 라우터 함수가 곧 컨트롤러 역할을 했다. Express에서는 route, controller, service 세 단계로 명시적으로 나뉜다.</p>
<hr>
<h2 id="route-→-controller-→-service-패턴">route → controller → service 패턴</h2>
<h3 id="fastapi에서는-route--service">FastAPI에서는 (route + service)</h3>
<p>라우터 함수가 컨트롤러 역할까지 했다. service는 별도로 분리했지만, 라우터에서 직접 service를 호출하고 응답을 반환했다.</p>
<pre><code class="language-python"># router (컨트롤러 역할 포함)
@router.post(&quot;/login&quot;)
async def login(request: LoginRequest):       # 검증 자동
    result = await auth_service.login(request.code)
    return result                              # return만 하면 끝

# service
async def login(code: str):
    token = await get_kakao_token(code)
    user = await find_or_create_user(...)
    return {&quot;accessToken&quot;: token}</code></pre>
<h3 id="express에서는-route--controller--service">Express에서는 (route + controller + service)</h3>
<p>controller가 추가로 들어간다.</p>
<p><strong>route</strong> — URL이랑 함수 연결만</p>
<pre><code class="language-typescript">router.post(&quot;/login&quot;, loginHandler);</code></pre>
<p><strong>controller</strong> — 요청 파싱 + 서비스 호출 + 응답 반환</p>
<pre><code class="language-typescript">export const loginHandler = async (req, res, next) =&gt; {
    try {
        const { code } = loginSchema.parse(req.body);
        const result = await login(code);
        res.status(200).json(result);
    } catch (err) {
        next(err);
    }
};</code></pre>
<p><strong>service</strong> — 순수 비즈니스 로직 (FastAPI의 service와 동일)</p>
<pre><code class="language-typescript">export const login = async (code: string) =&gt; {
    const kakaoToken = await getKakaoToken(code);
    const { user, isNewUser } = await findOrCreateUser(...);
    return { accessToken, userId, ... };
};</code></pre>
<p>FastAPI에서는 라우터가 검증, 응답 반환까지 자동으로 해줘서 controller가 필요 없었다. Express는 그런 자동화가 없어서 controller 레이어가 그 역할을 대신한다. 결국 service 로직 자체는 거의 동일하고, <strong>HTTP 처리 방식만 다른 셈이다.</strong></p>
<hr>
<h2 id="fastapi가-자동으로-해주던-것들">FastAPI가 자동으로 해주던 것들</h2>
<p>Express로 오면서 가장 체감했던 건 <strong>FastAPI가 얼마나 많은 걸 자동으로 해줬는지</strong>였다.</p>
<h3 id="1-요청-검증">1. 요청 검증</h3>
<pre><code class="language-python"># FastAPI — 파라미터 타입만 쓰면 자동 검증
async def login(request: LoginRequest):</code></pre>
<pre><code class="language-typescript">// Express — 수동으로 해야 함
const { code } = loginSchema.parse(req.body);</code></pre>
<p>FastAPI는 Pydantic 모델을 파라미터에 넣으면 자동 검증 + 422 응답까지 해줬다. Express는 Zod로 직접 검증하고, 에러 핸들러에서 422를 반환하도록 직접 만들어줘야 한다.</p>
<h3 id="2-에러-처리">2. 에러 처리</h3>
<pre><code class="language-python"># FastAPI — HTTPException 던지면 끝
raise HTTPException(status_code=401, detail=&quot;인증 실패&quot;)</code></pre>
<pre><code class="language-typescript">// Express — try/catch + next(err) 필수
try {
    // ...
} catch (err) {
    next(err);
}</code></pre>
<h3 id="3-응답-타입">3. 응답 타입</h3>
<pre><code class="language-python"># FastAPI — response_model로 자동 필터링
@router.post(&quot;/login&quot;, response_model=Token)</code></pre>
<pre><code class="language-typescript">// Express — 인터페이스로 수동 매핑
const response: LoginResponse = {
    accessToken: result.accessToken,
    userId: result.userId,
    // ...
};
res.status(200).json(response);</code></pre>
<hr>
<h2 id="라이브러리-대응표">라이브러리 대응표</h2>
<table>
<thead>
<tr>
<th>역할</th>
<th>FastAPI (Python)</th>
<th>Express (Node.js)</th>
</tr>
</thead>
<tbody><tr>
<td>프레임워크</td>
<td>FastAPI</td>
<td>Express</td>
</tr>
<tr>
<td>요청 검증</td>
<td>Pydantic</td>
<td>Zod</td>
</tr>
<tr>
<td>ODM</td>
<td>Beanie (MongoDB)</td>
<td>Mongoose</td>
</tr>
<tr>
<td>JWT</td>
<td>python-jose</td>
<td>jsonwebtoken</td>
</tr>
<tr>
<td>환경변수</td>
<td>pydantic-settings</td>
<td>dotenv + env.ts</td>
</tr>
<tr>
<td>비밀 저장소</td>
<td>azure-keyvault-secrets</td>
<td>@azure/keyvault-secrets</td>
</tr>
</tbody></table>
<p>Beanie에서 Mongoose로 전환하면서 느낀 차이도 있다. Beanie는 <code>Document</code> 클래스가 타입 + 스키마 + ODM 역할을 동시에 했는데, Mongoose는 <strong>인터페이스(타입) + 스키마(검증) + 모델(DB 조작)</strong>이 분리되어 있다.</p>
<pre><code class="language-typescript">// 1. 타입 정의
interface IUser extends Document {
    name: string;
    kakao_id: string;
}

// 2. 스키마 정의
const userSchema = new Schema&lt;IUser&gt;({
    name: { type: String, required: true },
    kakao_id: { type: String, required: true, unique: true },
});

// 3. 모델 생성
export const UserModel = model&lt;IUser&gt;(&quot;User&quot;, userSchema);</code></pre>
<p>Beanie에서는 한 클래스로 끝났던 걸 세 단계로 나눠야 하지만, 오히려 각 역할이 명확해지는 장점이 있었다.</p>
<hr>
<h2 id="types-폴더의-역할--dts-파일은-왜-만들어야-하나">types 폴더의 역할 — .d.ts 파일은 왜 만들어야 하나?</h2>
<p>TypeScript를 쓰면서 가장 낯설었던 건 <code>types/</code> 폴더에 <code>.d.ts</code> 파일을 직접 만들어줘야 하는 상황이었다.</p>
<p>JavaScript 라이브러리는 세 가지 유형이 있다.</p>
<table>
<thead>
<tr>
<th>유형</th>
<th>예시</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>타입 내장</td>
<td>mongoose, zod</td>
<td>바로 사용 가능</td>
</tr>
<tr>
<td>@types 패키지 있음</td>
<td>ws → @types/ws</td>
<td><code>npm install -D @types/ws</code></td>
</tr>
<tr>
<td>타입 없음</td>
<td>y-websocket, y-protocols</td>
<td>직접 <code>.d.ts</code> 작성</td>
</tr>
</tbody></table>
<p>타입이 없는 라이브러리를 import하면 에러가 난다.</p>
<pre><code class="language-typescript">import { WebsocketProvider } from &quot;y-websocket&quot;;
// ❌ Cannot find module &#39;y-websocket&#39; or its corresponding type declarations.</code></pre>
<p>이럴 때 직접 타입 선언 파일을 만들어줘야 한다.</p>
<pre><code class="language-typescript">// src/types/y-websocket.d.ts
declare module &quot;y-websocket&quot; {
    export class WebsocketProvider {
        constructor(serverUrl: string, roomname: string, doc: any);
        destroy(): void;
    }
}</code></pre>
<p>Python은 타입이 없어도 실행되지만, TypeScript는 타입을 모르면 컴파일 자체가 안 되기 때문이다.</p>
<p>또 하나 유용했던 건 Express의 <code>Request</code> 타입 확장이다. 인증 미들웨어에서 <code>req.user</code>를 넣어주는데, Express 기본 타입에는 <code>user</code>가 없다.</p>
<pre><code class="language-typescript">// src/types/express/index.d.ts
declare global {
    namespace Express {
        interface Request {
            user?: {
                userId: string;
                name: string;
            };
        }
    }
}
export {};</code></pre>
<p>이걸 만들어두면 <code>(req as any).user</code> 대신 <code>req.user</code>로 타입 안전하게 쓸 수 있다.</p>
<hr>
<h2 id="느낀-점">느낀 점</h2>
<ul>
<li>FastAPI가 정말 많은 걸 자동화해주고 있었다는 걸 Express를 쓰면서 체감했다.</li>
<li>대신 Express는 자유도가 높아서 구조를 원하는 대로 잡을 수 있다.</li>
<li>TypeScript의 타입 시스템은 처음에 번거롭지만, 프로젝트가 커질수록 안전망 역할을 해준다.</li>
<li>FastAPI와 Express는 겉보기엔 다르지만 <strong>route → 비즈니스 로직 → DB</strong>라는 큰 흐름은 동일하다. 하나를 알면 다른 하나도 금방 적응할 수 있다.</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>