<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Dev_won</title>
        <link>https://velog.io/</link>
        <description>Frontend Developer</description>
        <lastBuildDate>Mon, 04 May 2026 13:41:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Dev_won</title>
            <url>https://velog.velcdn.com/images/seongwon__105/profile/b82c4572-e7bf-408e-9008-f797478cfdc4/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Dev_won. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/seongwon__105" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Velog MCP 트러블슈팅]]></title>
            <link>https://velog.io/@seongwon__105/github-%EB%B8%94%EB%A1%9C%EA%B7%B8-velog-%EC%9D%B4%EC%A0%84%EA%B8%B0-mcp-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EB%A7%88%EC%A3%BC%EC%B9%9C-%EB%B6%80%ED%95%98-%ED%8F%AC%EC%9D%B8%ED%8A%B8-6%EA%B0%80%EC%A7%80</link>
            <guid>https://velog.io/@seongwon__105/github-%EB%B8%94%EB%A1%9C%EA%B7%B8-velog-%EC%9D%B4%EC%A0%84%EA%B8%B0-mcp-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EB%A7%88%EC%A3%BC%EC%B9%9C-%EB%B6%80%ED%95%98-%ED%8F%AC%EC%9D%B8%ED%8A%B8-6%EA%B0%80%EC%A7%80</guid>
            <pubDate>Mon, 04 May 2026 13:41:07 GMT</pubDate>
            <description><![CDATA[<p>Claude가 Velog에 직접 글을 쓸 수 있는 MCP(Model Context Protocol) 서버를 만들면서, GitHub 블로그 포스트를 Velog로 일괄 이전하는 기능도 함께 구현했습니다. 막상 붙여보니 성능 문제와 안정성 이슈가 생각보다 많았습니다.</p>
<hr>
<h2 id="velog-mcp란">velog-mcp란</h2>
<p>Velog는 공개 API가 없습니다. 그래서 내부 GraphQL API(<code>v2.velog.io/graphql</code>)를 리버스 엔지니어링해서 Claude가 직접 포스트를 작성·발행·수정할 수 있는 MCP 서버를 만들었어요.</p>
<p>GitHub 블로그 이전 기능(<code>velog_import_from_github</code>)은 그 중 하나로, GitHub 레포의 마크다운 파일을 읽어 Velog 초안으로 변환해주는 기능이에요. </p>
<p>개발 과정은 <a href="https://velog.io/@seongwon__105/Velog-MCP-%EA%B0%9C%EB%B0%9C%EA%B8%B0">Velog MCP 개발기</a>에 기록해뒀습니다 !</p>
<hr>
<h3 id="1-파일-다운로드-순차-처리">1. 파일 다운로드 순차 처리</h3>
<pre><code class="language-ts">for (const file of mdFiles) {
  const raw = await fetchRaw(file.download_url, ...); // 순차 실행
}</code></pre>
<p>포스트가 50개면 GitHub 요청 50번을 직렬로 날려요. 각 요청이 1<del>2초면 전체 import가 **1</del>2분 소요**됩니다. MCP 툴이라 Claude가 응답을 기다리는 동안 대화가 그냥 멈춰 보이죠.</p>
<p><strong>개선하기</strong>: <code>Promise.all</code> + concurrency limit으로 병렬 처리해서 개선했어요. 토큰이 있으면 동시 10개, 없으면 5개로 제한하는 <code>mapConcurrent</code> worker-pool을 붙여 10배 이상 단축했어요.</p>
<hr>
<h3 id="2-github-api-rate-limit-미방어">2. GitHub API Rate Limit 미방어</h3>
<p>GitHub API의 rate limit은 이렇습니다.</p>
<table>
<thead>
<tr>
<th>인증</th>
<th>한도</th>
</tr>
</thead>
<tbody><tr>
<td>토큰 없음</td>
<td>60 req/hour</td>
</tr>
<tr>
<td>토큰 있음</td>
<td>5,000 req/hour</td>
</tr>
</tbody></table>
<p><code>fetchGitHubContents</code> 1번 + 파일 수 N번 = <strong>1 + N 요청</strong>. 50개 포스트면 51번 요청이라 토큰 없이는 한도에 거의 닿게 됩니다. 더 큰 문제는 기존 코드가 403이 뜨면 <strong>import 중간에 폭발</strong>하고, 이미 만들어진 velog draft는 메모리에 고아로 남아요.</p>
<p><strong>개선하기</strong>: fetch와 draft 생성을 2단계로 분리</p>
<pre><code>Phase 1 (병렬): 모든 파일 fetch + 파싱 → 메모리에만 보관 (draft 없음)
              ↓ rate limit 감지 시 즉시 throw — draft 0개 생성된 상태
Phase 2 (순차): fetch 전부 성공 시에만 draft 일괄 생성</code></pre><p>403은 &quot;rate limit 한도 초과&quot; 에러로 명확히 구분했어요. 네트워크 에러 파일은 <code>failed[]</code>에 경로 + 원인을 기록해서 <code>skipped</code>와 구분했어요. </p>
<p>all-or-nothing 전략으로 중간에 에러가 나면 초안을 모두 지워버리는 방식으로, 고아 draft를 원천 차단했어요. </p>
<hr>
<h3 id="3-단일-디렉토리만-스캔">3. 단일 디렉토리만 스캔</h3>
<pre><code class="language-ts">const files = await fetchGitHubContents(repo, filePath, branch, ...);
// 재귀 없음</code></pre>
<p>Jekyll 블로그는 보통 <code>_posts/2024/01/post.md</code> 같은 연도/월 하위 폴더 구조를 씁니다. 재귀 스캔이 없으면 루트 <code>_posts/</code> 아래 <code>.md</code> 파일만 읽히고 하위 폴더는 통째로 누락되는 구조였어요.</p>
<p><strong>개선하기</strong>: <code>fetchGitHubContents</code>를 재귀 호출로 바꿔서 하위 디렉토리까지 전부 스캔하도록 개선했어요.</p>
<hr>
<h3 id="4-draft-메모리-누수">4. Draft 메모리 누수</h3>
<pre><code class="language-ts">const drafts = new Map&lt;string, Draft&gt;();</code></pre>
<p>MCP는 장시간 실행될 수 있어요. <code>importFromGitHub</code>가 draft를 N개 생성하는데, <code>publishPost</code>를 호출해야만 <code>deleteDraft</code>가 되는데, 사용자가 publish 안 하면 대용량 마크다운이 계속 메모리에 쌓여요.</p>
<p><strong>개선하기</strong>: draft에 24시간 TTL을 붙였어요. 생성 시 타임스탬프를 기록하고, 조회·발행 시 만료 여부를 체크해서 오래된 draft는 자동 삭제하도록 했습니다.</p>
<hr>
<h3 id="5-loadconfig-매-요청마다-파일-읽기">5. loadConfig 매 요청마다 파일 읽기</h3>
<pre><code class="language-ts">return JSON.parse(fs.readFileSync(CONFIG_PATH, &quot;utf-8&quot;));</code></pre>
<p><code>graphql()</code>과 <code>uploadImage()</code> 호출 시마다 디스크에서 JSON을 읽어요. 포스트 50개 publish하면 50번 disk I/O가 발생하게 됩니다.</p>
<p><strong>개선하기: 의도적으로 캐시하지 않는다.</strong> </p>
<p>Velog access token은 TTL이 1~2시간이고, 만료되면 GraphQL 응답의 Set-Cookie로 자동 갱신되는 구조에요. <code>saveConfig()</code>가 파일을 갱신하는데, 인메모리 캐시를 쓰면 토큰 리프레시 직후 요청이 구버전 토큰을 쓰게 되겠죠. 디스크 I/O가 느리긴 해도 SSD 기준 &lt; 1ms라 사용자 경험에 큰 영향은 없다고 판단했어요.</p>
<hr>
<h3 id="6-재시도이어쓰기-없음">6. 재시도/이어쓰기 없음</h3>
<p>import 도중 네트워크 에러가 나면 전체가 실패해요. 이미 생성된 draft는 고아가 되고 어디까지 됐는지 알 수 없어요.</p>
<p><strong>개선하기: 올-오어-낫씽 롤백으로 대체.</strong></p>
<p>2번 수정(2단계 분리)으로 Phase 1 실패 시 draft 0개를 보장해요. 재시도가 필요하면 처음부터 다시 하면 됩니다. 완벽하진 않지만 고아 draft 없는 예측 가능한 실패가 더 낫다고 판단했어요.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>MCP 서버처럼 장시간 실행되는 프로세스에서는 <strong>고아 상태(orphan state) 방지</strong>가 가장 중요했어요. 기능이 완벽하지 않아도 실패했을 때 상태가 예측 가능하면 복구가 쉽다는 걸 알게 되었어요. </p>
<p>올-오어-낫씽 전략이 완벽한 해답은 아니지만, 지금 단계에서는 &quot;고아 draft 없는 명확한 실패&quot;가 &quot;중간 성공 + 고아 draft&quot;보다 훨씬 낫다고 판단했어요. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ Velog MCP 개발기]]></title>
            <link>https://velog.io/@seongwon__105/Velog-MCP-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/Velog-MCP-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Sat, 25 Apr 2026 06:53:57 GMT</pubDate>
            <description><![CDATA[<h3 id="시작은-단순한-의문에서">시작은 단순한 의문에서</h3>
<p>&quot;Claude가 내 벨로그에 직접 글을 올릴 수 있으면 얼마나 편할까?&quot;  </p>
<p>이 생각 하나에서 시작됐어요. 찾아보니 Velog에는 공식 API가 없었습니다.</p>
<h3 id="mcp란">MCP란</h3>
<p>MCP(Model Context Protocol)는 Claude가 외부 도구를 직접 호출할 수 있게 해주는 Anthropic의 프로토콜이에요. 즉, MCP 서버만 만들면 Claude가 Velog를 직접 다룰 수 있다는 뜻이었습니다. </p>
<p>그래서 MCP를 직접 만들기로 했습니다.</p>
<h3 id="리버스-엔지니어링">리버스 엔지니어링</h3>
<p>브라우저 개발자 도구를 열고, Network 탭을 켜고, Velog에서 글을 쓰고 댓글을 달면서 어떤 GraphQL 요청이 오가는지 하나씩 확인했어요. URL 복붙하고, 요청 바디 분석하고, 응답 구조를 파악하는 원시적인 방법이었어요.</p>
<h3 id="설계는-gstack에게">설계는 gstack에게</h3>
<blockquote>
<p><a href="https://github.com/garrytan/gstack">https://github.com/garrytan/gstack</a> </p>
</blockquote>
<p>설계 단계에서는 gstack의 힘을 빌렸습니다. API 구조, MCP 툴 인터페이스, 에러 처리 방식 등 전반적인 아키텍처 설계에 사용했습니다.</p>
<h3 id="이틀만에-완성한-것들">이틀만에 완성한 것들</h3>
<ul>
<li>포스트 CRUD — 글 작성, 조회, 수정, 삭제, 임시저장                                            </li>
<li>댓글 CRUD — 댓글 작성, 수정, 삭제                                           </li>
<li>트렌드 분석 — 트렌딩 포스트 조회 및 분석                     </li>
</ul>
<p>Claude로 벨로그를 실제로 운영할 수 있는 수준이 되었습니다.</p>
<h3 id="npm-publish-그리고-뜻밖의-걱정">npm publish, 그리고 뜻밖의 걱정</h3>
<p>구현을 마치고 npm publish로 패키지를 배포했습니다. 그런데 마음 한켠이 찜찜했습니다. 공식 API도 아니고, 리버스 엔지니어링으로 만든 건데 이게 서비스 이용약관에 문제가 될 수 있지 않을까?</p>
<h3 id="벨로그-개발자분에게-메일-보내기">벨로그 개발자분에게 메일 보내기</h3>
<p>그래서 Velog 개발자분에게 직접 이메일을 보냈습니다.
                                                       <strong>&quot;이런 MCP 서버를 만들었는데, 계속 운영해도 괜찮을까요?&quot;</strong></p>
<p>당일 바로 답변을 주셨습니다. 허용한다는 답변이었습니다. 덕분에 마음 편하게 계속 개발할 수 있게 됐습니다. 물론 깃허브에도 오픈소스로 올려두었습니다.</p>
<h3 id="추가-기능">추가 기능</h3>
<p>바로 어제, GitHub 블로그에 올린 글을 Velog로 자동으로 옮겨주는 tool을 추가했습니다. 마크다운 파일을 읽어서 frontmatter를 파싱하고, 이미지를 업로드한 뒤 Velog에 그대로 발행하는 흐름입니다.</p>
<p>GitHub Pages에 블로그를 운영하다가 Velog로 이사 오고 싶은 분들에게 유용할 것 같습니다.</p>
<h3 id="api가-언제-바뀔지-모른다">API가 언제 바뀔지 모른다</h3>
<p>공식 API가 아니다 보니 한 가지 걱정이 생겼습니다. Velog 서버 측에서 GraphQL 스키마를 바꿔버리면 MCP 서버가 동작하지 않게 되겠죠. </p>
<p>그래서 매일 오전 9시마다 실제 Velog API를 호출해서 연결이 살아있는지 자동으로 확인하는 Github actions를 추가했습니다.</p>
<p>진짜 토큰으로 인증하고 API를 요청하는 흐름입니다. 성공하면 갱신된 토큰을 GitHub Secrets에 자동으로 업데이트하고, 실패하면 알림(slack)이 오도록 했습니다.</p>
<h3 id="마무리">마무리</h3>
<p>사실 저 혼자 쓰려고 개발한 건데 어쩌다보니 벨로그 개발자분에게 메일도 보내고, 재밌는 기능들이 계속 생각나서 열심히 하고 있는 중입니다. </p>
<p>추가되었으면 하는 기능 있으시면 편하게 댓글 달아주세요!! </p>
<blockquote>
<p>깃허브: <a href="https://github.com/seongwon030/velog_mcp">https://github.com/seongwon030/velog_mcp</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude와 Mixpanel MCP로 데이터 분석하기]]></title>
            <link>https://velog.io/@seongwon__105/Claude%EC%99%80-Mixpanel-MCP%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B6%84%EC%84%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/Claude%EC%99%80-Mixpanel-MCP%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B6%84%EC%84%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 20 Apr 2026 14:08:40 GMT</pubDate>
            <description><![CDATA[<h2 id="mixpanel이란">Mixpanel이란?</h2>
<p>제품 분석(Product Analytics)도구로서, 사용자의 이벤트를 트래킹할 수 있는 서비스에요.</p>
<p>쉽게 말하면 “우리 서비스에서 사용자들이 뭘 하는지, 어디서 이탈하는지 숫자로 보여주는 도구”에요.</p>
<p>저희 팀은 Mixpanel에서 <strong>직접 퍼널과 인사이트를 만들어서</strong> 데이터를 보고 있었어요.
쿼리를 짜는 게 아니라, Mixpanel UI 안에서 이벤트를 고르고, 필터를 붙이고, breakdown을 설정하는 방식이에요.</p>
<p>하지만 불편함이 있었어요.</p>
<h4 id="1-ui-적응-비용이-크다"><strong>1. UI 적응 비용이 크다.</strong></h4>
<p><code>Insights / Funnels / Retention</code> 각각 인터페이스가 달라요. 내가 머릿속에 그린 지표를 Mixpanel이 요구하는 방식으로 옮기는 과정 자체가 피로해요. 특히 처음 보는 breakdown 옵션을 찾아다니거나, 집계 방식(unique / total / median)을 바꾸는 것처럼 사소한 설정이 생각보다 많은 클릭을 요구해요.</p>
<p>Insights</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/0625b59e-0408-4e68-ba17-5381b5fd05e7/image.png' width=400/>


<p>Retention</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/6aa9a6a8-90de-4b68-8c2f-e96fce9e08b0/image.png" alt=""></p>
<p>Funnels
<img src="https://velog.velcdn.com/images/seongwon__105/post/0bf3c353-efa2-4ae6-bc78-ab33941c1d03/image.png" alt=""></p>
<h4 id="2-리포트는-최대-5개까지만-저장된다"><strong>2. 리포트는 최대 5개까지만 저장된다</strong></h4>
<p>Mixpanel 무료 플랜 기준으로 개인이 저장할 수 있는 리포트(퍼널, 인사이트 등)는 최대 5개에요. </p>
<p>분석을 하면 할수록 기존 리포트를 지워야 하는 상황이 생겼어요. 물론 팀원 한 명 당 하나의 리포트를 만들 수 있었지만, 사실 제가 거의 모든 리포트를 다 만드는 상황이었기에 여전히 어려움이 있었어요.</p>
<p>Mixpanel MCP를 Claude Code에 연결하면 이 두 가지를 모두 해결할 수 있어요.</p>
<p><strong>UI 대신 자연어로 묻고</strong>, 결과마다 <strong>고유한 Mixpanel URL을 받기 때문에 개수 제한이 없었어요.</strong></p>
<hr>
<h2 id="mcp란">MCP란?</h2>
<p>MCP(Model Context Protocol)는 Claude 같은 AI가 외부 툴을 직접 호출할 수 있게 해주는 프로토콜이에요. Mixpanel MCP는 Mixpanel의 쿼리 API를 Claude가 직접 호출할 수 있도록 래핑한 서버에요.</p>
<p>쉽게 말하면 아래와 같은 플로우가 가능해져요.</p>
<pre><code>나 → Claude에게 자연어로 질문
Claude → Mixpanel MCP 서버 호출
Mixpanel MCP → Mixpanel API로 쿼리 실행
결과 → Claude가 해석해서 나에게 전달</code></pre><hr>
<h2 id="설정-방법">설정 방법</h2>
<h3 id="claude웹에서-추가하기">Claude웹에서 추가하기</h3>
<h4 id="1-mcp-server-urls-얻기">1. <strong>MCP Server URLs 얻기</strong></h4>
<p><a href="https://docs.mixpanel.com/docs/mcp#mcp-server-urls">MCP Server - Mixpanel Docs</a></p>
<p><a href="https://mcp.mixpanel.com/mcp"><code>https://mcp.mixpanel.com/mcp</code></a>  이 URL을 복사</p>
<h4 id="2-claude-설정-→-커넥터-→-커스텀-커넥터-추가">2. Claude 설정 → 커넥터 → 커스텀 커넥터 추가</h4>
<img src='https://velog.velcdn.com/images/seongwon__105/post/bc04c169-954e-46d8-bbdc-e44b30d11b70/image.png' width=500/>



<p>이름은 아무거나 설정하면 되고, 원격 MCP 서버 URL에 이전에 복사해둔 주소를 붙여넣고 추가.</p>
<p>이제 웹에서 MCP를 사용할 수 있고, 터미널에서 또한 가능.</p>
<hr>
<h3 id="mcpjson으로-추가하기">mcp.json으로 추가하기</h3>
<h4 id="1-claude-code에서-mcp-서버-등록">1. Claude Code에서 MCP 서버 등록</h4>
<p>Claude Code 설정에서 Mixpanel MCP 서버를 추가한다. <code>~/.claude/settings.json</code> 또는 Claude Desktop 설정에서:</p>
<pre><code class="language-json">{
  &quot;mcpServers&quot;: {
    &quot;mixpanel&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@mixpanel/mcp&quot;],
      &quot;env&quot;: {
        &quot;MIXPANEL_SERVICE_ACCOUNT_USERNAME&quot;: &quot;your-service-account&quot;,
        &quot;MIXPANEL_SERVICE_ACCOUNT_SECRET&quot;: &quot;your-secret&quot;
      }
    }
  }
}</code></pre>
<h4 id="2-mixpanel-서비스-계정-발급">2. Mixpanel 서비스 계정 발급</h4>
<p>Mixpanel → Organization Settings → Service Accounts에서 생성.</p>
<ul>
<li><strong>권한</strong>: <code>Analyst</code> 이상 (읽기 전용으로 충분)</li>
<li><strong>username / secret</strong> 두 값을 복사해서 위 설정에 넣으면 끝</li>
</ul>
<h4 id="3-연결-확인">3. 연결 확인</h4>
<p>Claude Code 세션을 시작하면 MCP 서버가 자동으로 뜸. 아래와 같이 물어보기.</p>
<pre><code>&quot;우리 Mixpanel 프로젝트 목록 보여줘&quot;</code></pre><hr>
<h2 id="실제-사용-예시">실제 사용 예시</h2>
<p>연결 이후부터는 “mixpanel mcp로 ~~해줘”라고 물어보면 되는데, 그냥 물어보는 것보단 규칙을 정해서 일관성있게 답을 얻고 싶었어요. 그래서 mixpanel용 클로드 <code>command</code> 를 만들었어요.</p>
<p>단계는 6단계로 아래와 같이 설정</p>
<pre><code>[step1] 분석 유형 확인 
[step2] 필요한 데이터 확인
[step3] 쿼리 스키마 확인
[step4] 분석 실행
[step5] 결과 분석 및 해석
[step6] 문서화</code></pre><p>이 단계 안에서 주의할 점들이나 더 신경써야 할 점들은 프로젝트 성격에 맞게 커스텀했어요.</p>
<p><a href="https://github.com/Moadong/moadong/blob/develop-fe/frontend/.claude/commands/mixpanel.md">클로드 커맨드</a></p>
<hr>
<p>아래는 클로드 <code>mixpanel</code> 커맨드에 작성했던 “분석 유형”인데, 프로젝트마다 다르기 때문에 그냥 넘어가도 좋아요. 
(참고로 저는 현재 대학교의 동아리를 보여주고 지원할 수 있는 웹서비스를 운영 중이에요😊)</p>
<h3 id="패턴-1-퍼널-분석">패턴 1. 퍼널 분석</h3>
<p><strong>자연어 질문:</strong></p>
<pre><code>&quot;메인페이지 방문 → 상세페이지 방문 → 지원하기 버튼 클릭 퍼널을
 최근 30일 기준으로 유니크 유저로 보여줘&quot;</code></pre><p><strong>Claude가 하는 일:</strong></p>
<ul>
<li>Funnels 쿼리 스키마를 확인</li>
<li>이벤트명(<code>MainPage Visited</code>, <code>ClubDetailPage Visited</code>, <code>Club Apply Button Clicked</code>)을 매핑</li>
<li>API 호출 → 단계별 전환율 반환</li>
</ul>
<p><strong>결과 예시:</strong></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>1,709</td>
<td>100%</td>
</tr>
<tr>
<td>상세페이지 방문</td>
<td>451</td>
<td>26%</td>
</tr>
<tr>
<td>지원 버튼 클릭</td>
<td>61</td>
<td>14%</td>
</tr>
</tbody></table>
<p>핵심은 <strong>이벤트명을 정확히 몰라도 된다</strong>는 점이에요. &quot;지원하기 클릭&quot;이라고 하면 Claude가 알아서 <code>Club Apply Button Clicked</code>로 매핑해요.</p>
<hr>
<h3 id="패턴-2-top-n-랭킹">패턴 2. TOP N 랭킹</h3>
<p><strong>자연어 질문:</strong></p>
<pre><code>&quot;동아리별로 상세페이지 체류시간 중간값을 내림차순으로 보여줘 (최근 30일)&quot;</code></pre><p><strong>Claude가 하는 일:</strong></p>
<ul>
<li><code>ClubDetailPage Duration</code> 이벤트에서 <code>duration_seconds</code> 프로퍼티를 median으로 집계</li>
<li><code>clubName</code> 기준으로 breakdown</li>
<li>결과를 정렬해서 테이블로 반환</li>
</ul>
<p>이걸 Mixpanel UI에서 직접 하려면: Insights → 이벤트 선택 → Aggregate Property → breakdown 추가 → 정렬 순서 변경… 꽤 많은 클릭이 필요해요.</p>
<hr>
<h3 id="패턴-3-트렌드-분석">패턴 3. 트렌드 분석</h3>
<p><strong>자연어 질문:</strong></p>
<pre><code>&quot;지난 5주간 메인 방문, 상세 방문, 지원 클릭 유저 수 주간 추이 보여줘&quot;</code></pre><p><strong>결과:</strong> 주차별 꺾은선 데이터 + Mixpanel 리포트 링크</p>
<p>Claude가 리포트 URL도 함께 반환하기 때문에, 더 자세히 보고 싶으면 링크 타고 Mixpanel 대시보드에서 바로 확인할 수 있어요.</p>
<hr>
<h3 id="패턴-4-코호트-비교">패턴 4. 코호트 비교</h3>
<p><strong>자연어 질문:</strong></p>
<pre><code>&quot;지원하기 버튼을 클릭한 유저와 안 한 유저의 상세페이지 체류시간을 비교해줘&quot;</code></pre><p>이런 코호트성 비교는 Mixpanel UI에서 설정이 까다롭다. MCP는 Claude가 내부적으로 <code>frequency-per-user</code> breakdown을 써서 자동으로 처리해줘요.</p>
<hr>
<h2 id="mixpanel-ui-대비-달라지는-점">Mixpanel UI 대비 달라지는 점</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Mixpanel UI</th>
<th>MCP</th>
</tr>
</thead>
<tbody><tr>
<td>리포트 저장 개수</td>
<td><strong>개인당 최대 5개</strong></td>
<td><strong>제한 없음</strong> (쿼리마다 고유 URL 발급)</td>
</tr>
<tr>
<td>지표 설정 방법</td>
<td>이벤트/필터/breakdown 직접 클릭</td>
<td>자연어로 요청</td>
</tr>
<tr>
<td>결과 공유</td>
<td>링크 직접 복사</td>
<td>응답에 Mixpanel URL 자동 포함</td>
</tr>
<tr>
<td>이벤트명 숙지 필요 여부</td>
<td>알아야 함</td>
<td>몰라도 됨 (Claude가 검색)</td>
</tr>
<tr>
<td>반복 분석</td>
<td>매번 수동으로 설정</td>
<td>같은 질문 재사용 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="tip">Tip</h2>
<h3 id="이벤트명을-몰라도-된다">이벤트명을 몰라도 된다</h3>
<p>Claude가 <code>Get-Events</code> 툴로 먼저 이벤트 목록을 검색한 뒤 쿼리를 짜요. </p>
<p>&quot;지원 완료&quot; 같은 표현으로도 찾아줘서 편해요.</p>
<h3 id="프로젝트-id를-알려주면-빠르다">프로젝트 ID를 알려주면 빠르다</h3>
<p>매번 프로젝트를 물어보는 걸 피하려면 대화 초반에 한 번 알려줘요. 그럼 헷갈리는 일 없이 잘 대답해줍니다.</p>
<pre><code class="language-bash">&quot;우리 프로젝트 ID는 3611536이야, 앞으로 이걸로 써줘&quot;</code></pre>
<h2 id="주의사항">주의사항</h2>
<h3 id="외부-사이트-추적불가">외부 사이트 추적불가</h3>
<p>예를 들어, 결제 버튼이 외부회사(토스,카카오 등)의 페이지로 이동하는 경우, 버튼 클릭까지만 추적 가능하고 실제 결제 완료 여부는 알 수 없어요.</p>
<h3 id="너무-긴-기간--세분화된-breakdown은-타임아웃">너무 긴 기간 + 세분화된 breakdown은 타임아웃</h3>
<p>90일 범위 + 사용자별 일별 breakdown처럼 데이터 양이 많은 조합은 쿼리가 타임아웃될 수 있어요.</p>
<p>실제로 저도 90일 넘는 기간을 설정하니 데이터가 너무 많아 조회할 수 없었어요.  </p>
<p>이 경우 기간을 30일 이내로 줄이거나, 잡히는 이벤트 수를 줄이거나(이벤트 목록 자체를 줄이기), breakdown 기준을 완화해야 해요.</p>
<hr>
<h2 id="직접-적용해보기">직접 적용해보기</h2>
<p>1-3월 데이터를 MCP로 분석하면서 흥미로운 사실을 발견했어요.</p>
<p>처음 가설은 단순했어요. &quot;상세페이지를 오래 볼수록 관심이 높고, 지원하기를 더 많이 누를 것이다.&quot;</p>
<p>하지만 데이터는 반대였어요.</p>
<pre><code class="language-bash">┌────────────────────┬─────────────────┬───────────────┐
│ 지원하기 클릭 횟수 │ 체류시간 중간값 │ 체류시간 평균             │
├────────────────────┼─────────────────┼───────────────┤
│ 0~4번               │ 8초             │ 22~32초        │
├────────────────────┼─────────────────┼───────────────┤
│ 4~12번              │ 8초             │ 13~20초        │
├────────────────────┼─────────────────┼───────────────┤
│ 14번 이상            │ 6초             │ 12.6초         │
└────────────────────┴─────────────────┴───────────────┘</code></pre>
<ul>
<li>클릭을 가장 많이 한 유저일수록 오히려 체류시간이 짧았어요.</li>
<li>반대로 거의 안 누른 유저들이 페이지를 더 오래 봤어요.</li>
</ul>
<p>해석: 클릭을 많이 한 유저는 이미 플랫폼에 익숙해서 빠르게 스캔하고 결정한다. 오래 머문 유저는 아직 확신이 없어 고민중이거나, 정보가 충분하지 않다고 느끼는 상태일 가능성이 있다.</p>
<p>즉 체류시간 = 관심도라는 단순한 등식은 성립하지 않았어요. 이 인사이트를 얻기까지 UI에서 직접 코호트를 설정했다면 훨씬 오래 걸렸을 것 같아요.</p>
<h3 id="얻은-개선포인트">얻은 개선포인트</h3>
<p>이 데이터 하나로 방향이 명확해졌습니다. 상세페이지를 오래 보는 유저는 아직 확신을 못 잡은 상태일 가능성이 높아요. 즉 페이지 체류시간을 늘리는 것보다, 오래 머문 유저가 결국 지원 버튼을 누르게 만드는 것이 진짜 개선 포인트인거죠.</p>
<p>아직 어떤 UI 개선을 할 지 정확하게 정하지는 않았지만, 방향성을 잘 잡을 수 있었어요.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>Mixpanel MCP를 쓰면서 좋은점은 두 가지였어요.</p>
<p><strong>UI 적응 비용 제거</strong></p>
<ul>
<li>Mixpanel 인터페이스를 익히지 않아도 됩니다. 머릿속에 있는 지표를 그대로 말하면 Claude가 알아서 쿼리로 만들어줘요.</li>
</ul>
<p><strong>리포트 개수 제한 해소</strong> </p>
<ul>
<li>쿼리를 실행할 때마다 고유한 Mixpanel URL이 발급해줘요. 저장해두지 않아도 링크만 있으면 언제든 다시 볼 수 있고, 5개 제한에 걸릴 일이 없어요. 특히 주간 리포트처럼 반복적인 분석이나, 갑자기 생긴 수치 이상을 빠르게 파악할 때 효과적입니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[나의 첫 장기 프로젝트, 그 1년 간의 기록]]></title>
            <link>https://velog.io/@seongwon__105/project-review</link>
            <guid>https://velog.io/@seongwon__105/project-review</guid>
            <pubDate>Mon, 16 Mar 2026 15:16:51 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>작년 1월부터 나는 <a href="https://www.moadong.com/">모아동</a>이라는 대학교 동아리를 모아 보는 사이트를 운영하고 있다. </p>
<p>2025년은 나에겐 <strong>모아동의 해</strong>였다고 해도 과언이 아닐 정도로 많은 노력을 투자했다. </p>
<p>그래서 2025 회고를 적을까하다가, 정말 많은 시간을 이 프로젝트에 투자했기에  오로지 이 프로젝트를 위한 회고를 써 주겠다 마음을 먹은 것이다.</p>
<h2 id="2025년-1월">2025년 1월</h2>
<p>이 프로젝트를 하기 전, 나는 대학교 학생증 QR을 인식해 출석체크 및 인원관리를 하는 프로젝트를 했었다. </p>
<p>어찌저찌해서 GDG Busan DevFest에서 사이드부스를 운영하게 되었고 충분히 가능성있고 확장가능한 서비스라는 피드백을 받았다. 하지만 정말 큰 문제가 있었다. 그건 바로 <strong>개인정보문제</strong>였다. </p>
<p>학생들의 개인정보를 DB에 저장해야 인식이 가능했는데, 학교 전담 변호사분과도 얘기해야 했고, 당장 내가 속해있던 동아리 내부에서도 사용을 꺼려하는 사람들이 일부 있었다.</p>
<p>기존 팀원들끼리 다시 의견을 모았다. <strong>결국 개인정보가 문제가 될 것이고, 우리는 다른 방법으로 학교 내에 가치를 전달해야 한다.</strong> </p>
<p>그래서 나온 아이디어가 바로 동아리 서비스였다. 다른 학교에서도 성공한 서비스가 있었다. 심지어 우리 학교에서도 몇 년 전 앱으로 출시했다가 금방 망한 사례가 있었다. 하지만 우리는 믿음이 있었다.</p>
<h3 id="신입생들의-이야기">신입생들의 이야기</h3>
<p>아무 이유없이 프로젝트를 시작할 수는 없었다. 10명이라도 좋으니 수요를 관찰하고 만들어보자는게 우리의 생각이었다. </p>
<p>다행히도 동아리 내에 신입생들이 많이 있어서 그들의 의견을 쉽게 들을 수 있었다. 그들의 의견은 이랬다. </p>
<p><strong>&quot;에브리타임에서만 동아리를 찾는게 불편하다. 항상 올라오는 글을 직접 찾아봐야한다.</strong></p>
<p><strong>&quot;학교에 어떤 동아리가 있는지 모르겠다. 한 번에 볼 수 있는 곳이 있으면 좋겠다.&quot;</strong></p>
<p>우리 학교는 68개가 넘는 다양한 동아리가 있는데 인스타나 학교 홈페이지를 찾아봐도 정보가 없으니 알 수가 없다. 사실 신입생뿐만 아니라 재학생들도 자신이 한 동아리 외에는 잘 모르는 분위기였다.</p>
<p> 무엇보다 처음 온 신입생들은 에브리타임 새내기게시판만을 사용할 수 있는데, 일부 동아리들은 새내기가 아니라 동아리게시판에 올리니 신입생들이 얻을 수 있는 정보가 제한적이었다.</p>
<h3 id="바로-시작하자">바로 시작하자</h3>
<p>주변 신입생들의 얘기를 듣고 수요가 있다고 판단했다. 무엇보다 다른 대학교의 잘 된 선례가 있으니, 우리가 못 할 게 뭐가 있냐는 생각도 있었다.</p>
<p>물론 잘 되기 위해서는 디자인적 완성도가 필수라고 생각했다. 무엇보다 개발자들 중 디자인을 좋아하거나 잘 하는 사람도 없었다. 그래서 같은 학교 시각디자인전공 디자이너 한 분은 섭외했다.</p>
<p>그렇게 프론트 2명, 백엔드 4명, 디자이너1명으로 팀이 꾸려졌다. 첫 회의는 새마음 새뜻으로 1월 2일에 시작했다. 2월말부터 동아리 모집이 시작되기에 디자인과 개발을 합쳐 1개월 반 정도 남아있었다.</p>
<h3 id="문제는-개발이-아니다">문제는 개발이 아니다</h3>
<p>우리가 개발해야 할 건 동아리 목록과 배너가 있는 메인페이지, 그리고 각 동아리 상세정보가 있는 상세페이지였다. 개발은 정말 간단했다. 어 근데 이걸 어떻게 동아리 관리자들이 쓰게 하지?라는 생각이 몰려왔다.</p>
<p>68개의 동아리 관리자들은 이미 에브리타임으로 동아리를 홍보하고 있었다. 1차목표는 에브리타임에 홍보를 올릴 때 우리 서비스링크를 첨부하게 하기 였다. </p>
<p>그들과 컨택하기 위해서는 모든 동아리를 관리하는 총동아리연합회의 힘이 필요했다. 하지만 총동연측에선 완성될지 모르는 단계에서 관리자들에게 사용하라고 부추기기엔 리스크가 있다는 입장이었다.</p>
<p>그리고 1개월 반만에 관리자들을 위한 기능도 추가하는 건 무리라고 판단했다. 그렇게 야심찬 3월의 부흥은 물거품으로 돌아갔다.</p>
<h2 id="약간의-희망">약간의 희망</h2>
<p>팀의 목표는 <strong>&quot;대학교 학생들 모두가 우리 서비스로 동아리를 찾고 지원한다.&quot;</strong>였다. 그러기 위해서는 온전히 우리 힘으로만 할 수는 없었다.</p>
<p>홍보를 하려면 홍보 플랫폼이 있어야 하는데, 자체적인 홍보효과가 없으니 그것마저 에브리타임의 힘을 빌려야 했다. </p>
<p>총동연의 도움이라면 충분히 홍보효과를 볼 수 있었지만, 그들은 새로운 시도보다는 기존의 안정성을 택했다.</p>
<p>장기적으로 우리 서비스가 안정적으로 유지되려면 모든 동아리를 관리하고 있는 총동아리연합회의 힘이 무조건 필요하다고 생각했다. 그들을 설득하기 위해서는 <strong>일단 신뢰성을 쌓아야 했다.</strong> </p>
<h3 id="총동연과의-첫-미팅">총동연과의 첫 미팅</h3>
<p>개강 후 총동아리연합회 회장과 첫 대면 미팅을 가졌다. 우리가 얘기한 신입생들의 고충을 동감해주었고, 6월말까지 모든 동아리를 섭외하겠다는 공동의 목표를 세웠다.</p>
<p>그때까지 서비스 완성도를 높이는 일에 집중해야 했다.</p>
<h3 id="관리자-기능">관리자 기능?</h3>
<p>그 당시에는 우리 서비스를 많은 사람들이 사용하려면 먼저 동아리 관리자를 섭외해야한다고 생각했다. 관리자들이 사용한다면 학생들도 신뢰하고 이용할 것이라는 믿음 때문이었다. 훗날 이 믿음은 서비스를 더 늪으로 가져갔다.</p>
<p>돌아와서 관리자들이 겪는 어려움이 무엇인지부터 파악해야 했다. 총동연의 힘을 빌려 관리자들만 모아 둔 단톡을 만드는 것까진 성공했기에, 편하게 구글폼으로 피드백을 받을 수 있었다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/778bfe33-4a75-4ea8-91de-0ccc010903e1/image.png" alt=""></p>
<p>그 많은 관리자들 중 비록 20퍼센트가 안 되는 인원들이 피드백에 응해줬고, 그 중 다수가 동아리 지원과 SNS 공유 기능을 원했다. </p>
<p>SNS 공유 기능은 매우 간단했지만, 지원하기는 신경 쓸 부분이 많았다.</p>
<ul>
<li>모집기간 설정 기능</li>
<li>외부지원폼 설정 기능 (구글, 네이버 등)</li>
<li>서비스 자체 지원서 기능</li>
</ul>
<p>이번에도 개발 자체의 문제는 없었다. 모집시기까지는 아직 3개월이라는 시간이 있었다. 그 사이에 우리는 빠르게 관리자 기능을 개발하기 시작했다.</p>
<h3 id="첫-홍보">첫 홍보</h3>
<p>5월에는 학생들에게 서비스를 처음 홍보했다. 모집기간은 방학이 시작되는 7월, 그 전에 미리 서비스를 각인시켜줘야겠다는 생각이 들었다.</p>
<p>학생들이 홍보하기 가장 좋은 곳은 아무래도 에브리타임이다. 간단한 사진과 글을 적어 올렸고 다행히도 반응이 좋았다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/22160b8c-2f07-4d08-a3ac-fddf08ccc6f8/image.png' width=400/>

<p>덕분에 당일 사용자가 300명에 가깝게 들어왔고, 우리는 가능성을 보았다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/9a4758e3-95dc-4ef1-a8da-3306e52cf015/image.png' width=200/>



<h3 id="내부지원서라는-위기">내부지원서라는 위기</h3>
<p>관리자들이 사용할 수 있는 지원서를 만들기 위한 계획을 세웠다. 맹점은 관리자들은 이미 구글폼, 네이버폼 등의 외부 사이트를 사용하고 있었고, 굳이 우리 서비스 내부의 지원서 기능을 사용할 이유가 없었다.</p>
<p>하지만 우리는 그 부분을 크게 받아들이지 않고 내부지원서 기능을 감행했다. 지원서뿐만 아니라 정보 수정을 위한 관리자 기능이 우선순위였다. 3개월, 즉 12주에서 시험기간을 제외하고 6주만에 그 기능들을 모두 완성해야 했다. </p>
<p>간과한 것은 동시에 <strong>동아리 관리자들이 우리 서비스의 지원서 기능을 사용하도록 설득해야 한다는 것</strong>이었다.</p>
<p>관리자 기능에 생각보다 많은 리소스가 들어갔고, 결국 6월말 지원서 기능을 완성하지 못했다. 우리가 보여줄 수 있는 건 메인 페이지, 상세 페이지 둘 뿐이었다. 지금 생각해보면 외부지원서폼만 넣도록 설정해뒀다면 서비스상 큰 문제가 없었을 것이다. </p>
<p>결과적으로 동아리 관리자가 사용할 수 있는 건 정보 수정밖에 없었다. 중간중간에 몇 번 총동연에 부탁하여 전체 동아리에 우리 서비스를 홍보했었지만, <strong>동아리를 하지 않는 학생들은 서비스에 대해 잘 모르고 있었다.</strong> 우리의 타겟은 동아리를 하지 않는 인원들인데, 정작 홍보는 동아리를 하고 있는 사람들한테만 한 것이다. </p>
<p>동아리 관리자들도 에브리타임에 올리면 되는데, 굳이 학생들이 잘 안 쓰는 서비스를 사용할 이유는 없었다.</p>
<h3 id="2025년-7월">2025년 7월</h3>
<p>모집기간이 시작되었을 때 에브리타임에 모집공고가 올라올 때마다 직접 서비스에 내용을 채워넣었다. 그리고 1주마다 에브리타임에 올리며 서비스를 홍보했다.</p>
<img src=https://velog.velcdn.com/images/seongwon__105/post/b00c1d68-335c-4a43-9f89-bda402d15029/image.png width=300/>



<p>그렇게 2개월동안 홍보했지만 누적사용자는 1400명에 불과했다. </p>
<p>신입생들은 이미 에브리타임에 익숙해져 있었고, 웹이라는 특성때문에 리텐션을 유지하는 것은 더 어려웠다.</p>
<h2 id="새로운-마음으로">새로운 마음으로</h2>
<p>지원서 기능에서의 병목, 그리고 동아리 관리자 포섭 실패가 있었지만 서비스에 대한 믿음은 여전했다.</p>
<h3 id="새로운-팀원">새로운 팀원</h3>
<p>8월이 되었을 무렵, 나를 포함한 2명이 부스트캠프를 하게 되었고 개발 리소스를 다 쳐내기 힘들다고 판단했다.</p>
<p>그리하여 3명을 더 뽑았고 이는 패착이었다. </p>
<p>첫 번째는 문서 부족이었다. 새로운 팀원들은 기존 프로젝트 구조를 모르기에 문서가 필요하다. 그 당시 문서라고는 테스스택, 회의록, 미팅 자료가 전부였다. </p>
<p>두 번째는 기존 개발자들이 온보딩에 사용할 시간이 없었다. 진행하던 개발과 부트캠프를 병행하면서도 새로운 팀원들을 기존 팀에 적응시키기엔 역부족이었다. </p>
<p>마지막은 7개월 간 달려 온 기존 팀원들과 새로운 팀원들의 싱크를 맞추는 일이었다. <strong>그 간극은 풀 수 있는 과제라기보단 열정의 온도를 맞추는 일에 더 가까웠다.</strong> </p>
<h3 id="빠른-이별">빠른 이별</h3>
<p>팀 생산성은 저하되었고 쳐내지 못 한 일들이 다음 스프린트로 계속 미뤄지는 현상이 발생했다. 이렇게 가면 저번과 같은 결과를 불러올 것이라는 두려움이 닥쳤다. 그럴수록 객관적인 시선이 필요했다. </p>
<p>모아동이 시작되기 초기부터 협업, 애자일, 홍보 등 많은 면에서 도움을 주었던 현업자 선배에게 나는 조언을 구했다. 활발하지 않은 소통과 팀 생산성이 저하된 상황에서 팀장이 해야 할 일은 빠르게 핵심 인원만 남기는 것이라는 조언을 들었다. 무엇보다 <strong>열정적인 팀원의 생산력을 낮추는 일을 방지하기 위해서라도</strong> 결정해야 한다고 했다.</p>
<p>지금 생각해보면 이 순간이 개인적으로 가장 힘들었던 상황인 동시에 성장할 수 있었던 큰 발판이었던 것 같다.</p>
<p>그렇게 회의에서 내가 느낀 점들을 설명하며 <strong>개발보다는 홍보에 집중할 미래를 그려갈 사람만 남아달라고 했다.</strong>
3명이 나갔고 남은 사람은 총 8명이었다. 목표가 같은 사람끼리 남았기에 더 큰 활력을 불어넣어 주었다.</p>
<h2 id="2026년">2026년</h2>
<p>2025년 우리는 두 번의 실패를 겪었다. 다음해에도 성공하지 못 한다면 어떻게 해야 할까라는 불안감이 있었다. 그래도 투자한 시간이 있어 쉽게 포기할 생각은 없었다.</p>
<p>방학동안 10명 정도 동아리 관리자들을 만나면서 들은 공통된 의견은 <strong>학생들이 잘 몰라서 사용하기 어렵다</strong>였다. 정리하자면</p>
<ul>
<li>동아리 관리자들은 신뢰성있는 서비스여야 사용한다.</li>
<li>총동아리연합회는 신뢰성이 보장된 단체다.</li>
</ul>
<p>그와 별개로 관리자들이 사용유무와 사용자들의 사용유무는 비례하지 않는다고 생각했다. 공통된 것은 <strong>신뢰성있는 서비스여야 모두가 사용한다</strong>였다.</p>
<h3 id="미팅에서의-성과">미팅에서의 성과</h3>
<p>총동아리연합회도 1년이 지나 새로운 인원들로 교체되었다. </p>
<p>새로운 회장과 첫 미팅을 했다. 회장은 작년 홍보쪽을 담당하던 분이었다. 
그때부터 모아동을 눈여겨봤다고 했다. 적극적으로 사용하고 싶었는데 아쉬웠다고 했다. </p>
<p>우리는 다양한 홍보방식을 제안했다. 2월부터 신입생이 들어올텐데 그때 만들어지는 모든 과 단톡에 홍보하기, 총동연 인스타에 서비스 홍보 게시물 올리기 등이 있었다. 다행히도 모든 제안을 흔쾌히 받아주었고, 그때부터 팀에는 확실한 동기부여가 생기기 시작했다.</p>
<p>이것 말고도 여러 가지 방안들이 오갔고 기대가 현실이 되는 순간이었다.</p>
<p>아래는 올해 진행했던 미팅 기록인데 <strong>대작전</strong>의 이름만큼 절실했다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/165eea4a-45bc-4434-859f-7e4d2ad76a6e/image.png' width=300 />

<h3 id="앱-도입">앱 도입</h3>
<p>웹은 최초 접근성이 좋지만 유지력은 떨어진다. 앱은 다운받고 어디 들어갈 필요없이 클릭 한 번으로 서비스에 접근할 수 있다.</p>
<p>무엇보다 <strong>알림기능</strong>은 장기적으로 유리할 것이라 생각했다. 모집정보, 모집기간 등 계속해서 사용자를 끌어오기 위해서 필수적이라고 판단했다.</p>
<p>팀에는 모바일 엔지니어로 일하고 계신 현업자 선배가 있었고, 덕분에 빠르게 React Native 웹뷰기반으로 앱을 만들 수 있었다. </p>
<h2 id="안정화">안정화</h2>
<p>신입생이 들어오기 전, 첫 홍보를 했다. 이전처럼 에브리타임으로 진행했고 역시나 좋은 반응이었다. 그러나 그게 끝이 아니었다.</p>
<p>모아동에 대한 글이 에브리타임에 자주 올라오기 시작했다. 동시에 바이럴인지 몰라도 앱다운로드수가 기하급수적으로 늘었다. 1개월간 무려 1000명이 넘게 앱을 다운받았다. 오로지 글 하나로 유입된 사용자였기에 입소문의 힘은 강력하다는 것을 체감했다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/23aa9573-9931-42d2-a1bc-5a4edb5d9f40/image.png' width=200/>
<img src='https://velog.velcdn.com/images/seongwon__105/post/293c0912-61d8-4a68-abc7-921b34f784a8/image.png' width=200/>



<h3 id="동아리소개한마당">동아리소개한마당</h3>
<p>3월에 열리는 동아리 소개 한마당은 모집 시즌에 지원하지 못한 학생들을 위해 하루 동안 오프라인 부스를 운영하며 추가 모집을 진행하는 행사이다.</p>
<p>아직 지원하지 못 한 학생들을 위해 모아동이 할 수 있는 일은 아직 있었다. 행사 일주일을 남기고 행사에 참여하는 동아리 부스 지도와 공연 시간표의 디자인+개발을 완성했다. <a href="https://velog.io/@seongwon__105/Figma-MCP%EB%A1%9C-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-feat-codex">당시 개발기록</a></p>
<p>운좋게도 우리 모아동팀 또한 행사에서 부스를 하게 되어 겸사겸사 홍보를 했다. 개인적으로는 현장실습때문에 못 갔지만 열심히 부스를 이끌어 준 팀원들한테 정말 고마웠다. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/37b1374d-9dd4-4f9c-84b3-d15516d5acca/image.jpeg' width=200/>



<h3 id="행사-후기">행사 후기</h3>
<p>행사 전 팝업과 배너로 미리 홍보를 했어서 그런지 사용자가 많이 들어왔다. 당일을 포함하여 무려 5000명 가까이 우리 사이트에 방문했고, 앱다운로드수는 2500이 넘었다.
<img src='https://velog.velcdn.com/images/seongwon__105/post/a088d9ff-66e8-45f1-9e6f-5c7b33b8ed20/image.png' width=200/></p>
<p>불과 2개월 전 10명도 들어오지 않던 서비스였는데 이렇게나 성장했다는 게 신기했다. 무엇보다 <strong>많은 사람들이 우리 서비스로 동아리를 찾고 지원한다</strong>는 우리의 첫 번째 목표를 달성한 것 같아서 뿌듯했다. </p>
<h2 id="다음-목표">다음 목표</h2>
<p>동아리 지원이라는 1차 목표를 달성한 후, 우리는 새로운 목표를 다시 세웠다.</p>
<p><strong>지원 시기에만 사용되는 앱은 지속적인 가치를 만들기 어렵다고 판단했기 때문이다.</strong></p>
<p>하지만 동아리 활동은 학기 내내 이어진다. 그래서 동아리원뿐만 아니라 모든 학생들이 각 동아리의 행사와 활동을 확인할 수 있도록 홍보 게시판 기능을 기획했다. 현재 대부분의 기능 개발을 마쳤고, 다음 주 중으로 배포할 예정이다.</p>
<h2 id="끝이-아닌-시작">끝이 아닌 시작</h2>
<p>앞서 이야기한 기능 외에도 다른 학교로의 확장, 서비스를 통한 수익 모델 구축 등 여러 계획을 가지고 있다. 때로는 현실적인 목표만큼이나, 야심찬 포부도 필요하다.</p>
<p>돌이켜보면 이렇게 오랜 기간 팀 프로젝트를 이어온 것은 처음이었다. 기술이 특별히 뛰어났던 것도, 아이디어가 압도적으로 독창적이었던 것도 아니었다. 다만 정말로 <strong>사람들이 사용하는 서비스를 만들고 운영해 보고 싶다는 마음이 컸다.</strong></p>
<p>1년이 넘는 시간 동안 함께해 준 팀원들에게 다시 한 번 감사의 마음을 전하며, 이 프로젝트가 하나의 마무리가 아니라 더 큰 도전을 향한 새로운 시작이 되기를 기대한다.</p>
<p>2026년도 화이팅!!! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React.FC에 대해]]></title>
            <link>https://velog.io/@seongwon__105/React.FC%EC%97%90-%EB%8C%80%ED%95%B4</link>
            <guid>https://velog.io/@seongwon__105/React.FC%EC%97%90-%EB%8C%80%ED%95%B4</guid>
            <pubDate>Mon, 09 Mar 2026 12:51:26 GMT</pubDate>
            <description><![CDATA[<h3 id="reactfc를-지양해야-할-때">React.FC를 지양해야 할 때</h3>
<p>지금은 사용하지 않는 CRA에서는 React.FC를 템플릿에서 제거한 <a href="https://github.com/facebook/create-react-app/pull/8177">PR</a>을 확인할 수 있다. 그렇다면 왜 React.FC 사용을 지양해야 할까? 무조건 쓰지 말아야 하는 걸까?</p>
<pre><code class="language-tsx">// ❌ 지양                                                                                                                                      
  const Button: React.FC&lt;ButtonProps&gt; = ({ children }) =&gt; {                                                                                       
    return &lt;button&gt;{children}&lt;/button&gt;;                                                                                                           
  };

// ✅ 권장
  const Button = ({ children }: ButtonProps) =&gt; {
    return &lt;button&gt;{children}&lt;/button&gt;;
  };</code></pre>
<h3 id="1-children-이-암묵적으로-포함된다">1. <code>children</code> 이 암묵적으로 포함된다.</h3>
<p>React.FC는 자동으로 children을 props에 추가하므로, children을 받지 않는 컴포넌트에서도 children을 넘길 수 있어서 버그 가능성이 생긴다. (React 18에서는 제거됐지만 레거시에서 고려할 필요)</p>
<h3 id="2-제네릭-컴포넌트를-못-만든다">2. 제네릭 컴포넌트를 못 만든다.</h3>
<pre><code class="language-tsx">// ❌ React.FC로는 불가능
const List: React.FC&lt;ListProps&lt;T&gt;&gt; = ... // T를 어디에?

// ✅ 직접 선언하면 가능
const List = &lt;T,&gt;(props: ListProps&lt;T&gt;) =&gt; { ... };</code></pre>
<h3 id="3-compound-component가-번거롭다">3. compound component가 번거롭다.</h3>
<p>예시: Dropdown </p>
<pre><code class="language-tsx">// ❌ React.FC — sub-component마다 타입 선언에 다 적어야 함                                                                                     
  const Dropdown: React.FC&lt;DropdownProps&gt; &amp; {                                                                                                     
    Trigger: React.FC&lt;TriggerProps&gt;;                                                                                                              
    Menu: React.FC&lt;MenuProps&gt;;
    Item: React.FC&lt;ItemProps&gt;;
  } = ({ children }) =&gt; {
    return &lt;div className=&quot;dropdown&quot;&gt;{children}&lt;/div&gt;;
  };

  Dropdown.Trigger = ({ children }) =&gt; &lt;button&gt;{children}&lt;/button&gt;;
  Dropdown.Menu = ({ children }) =&gt; &lt;ul&gt;{children}&lt;/ul&gt;;
  Dropdown.Item = ({ label, onClick }) =&gt; &lt;li onClick={onClick}&gt;{label}&lt;/li&gt;;</code></pre>
<p><code>React.FC&lt;DropdownProps&gt;</code>로 타입을 선언하면, TypeScript는 Dropdown이 정확히 그 타입만 가진다고 본다. </p>
<pre><code class="language-tsx">  // TypeScript가 보는 관점:
  const Dropdown: React.FC&lt;DropdownProps&gt; = ...                                                                                                        
  // → Dropdown의 타입 = (props: DropdownProps) =&gt; ReactElement | null
  // → 그 외 프로퍼티? 없음</code></pre>
<p>Dropdown.Trigger는 React.FC<DropdownProps>에 없는 프로퍼티기에, 미리 “이 함수에는 Trigger, Menu, Item이 있어.”라고 <code>intersection(&amp;)</code> 으로 알려줘야 한다.</p>
<pre><code class="language-tsx">const Dropdown: React.FC&lt;DropdownProps&gt; &amp; {
    Trigger: React.FC&lt;TriggerProps&gt;;   // ← &quot;Trigger도 있을 거야&quot;
    Menu: React.FC&lt;MenuProps&gt;;         // ← &quot;Menu도 있을 거야&quot;
    Item: React.FC&lt;ItemProps&gt;;         // ← &quot;Item도 있을 거야&quot;
  } = ({ children }) =&gt; { ... };</code></pre>
<p>하지만 서브 컴포넌트가 많아질수록 React.FC로 타입 선언을 일일이 해 줘야 하기에 매우 번거롭고, 코드도 복잡해진다.</p>
<p>반면 FC 없이는 TS가 타입 추론만 하고 고정하지는 않는다. 그래서 프로퍼티를 붙이면 자동으로 타입을 추론한다.</p>
<pre><code class="language-tsx">const Dropdown = ({ children }: DropdownProps) =&gt; { ... };

Dropdown.Trigger = ... // 자동 추론해서 반영</code></pre>
<h3 id="정리">정리</h3>
<p>FC를 쓰면 타입이 잠겨서 미리 다 열거해야 하고, 안 쓰면 타입이 열려있어서 그냥 붙이기만 하면 되는 것으로 이해할 수 있겠다.</p>
<h3 id="reactfc를-사용하는-예시">React.FC를 사용하는 예시</h3>
<pre><code class="language-tsx">const TRIGGER_TYPE_ICON_MAP: Record&lt;
  TriggerType,
  React.FC&lt;{ color?: string; size?: string }&gt;
&gt; </code></pre>
<p>여기서 React.FC를 쓴 이유는 compound component가 아니라 Record의 value 타입을 지정하는 용도이기 때문이다.</p>
<p>⇒ “<strong>이 Record의 value는 color와 size props를 받는 React 컴포넌트다.</strong>”라는 타입 제약을 뜻한다.</p>
<p>만약 React.FC없이 같은 걸 표현하려면</p>
<pre><code class="language-tsx">  const TRIGGER_TYPE_ICON_MAP: Record&lt;
    TriggerType,
    (props: { color?: string; size?: string }) =&gt; React.ReactElement | null
  &gt;</code></pre>
<p>더 길고 번거롭다. 그래서 컴포넌트 타입을 참조용으로 쓸 때는 React.FC가 간결하다.</p>
<p>예시는 타입 참조기 때문에 children을 암묵적으로 포함하지 않으며, 타입 파라미터로 넘기면 되니까 제네릭과 상관이 없고, compound와도 무관하기 때문에 <strong>가독성과 간결함을 위해 사용한 것이다.</strong></p>
<p>ex. 타입 참조 시 - FC로 제네릭 문제는 없음</p>
<pre><code class="language-tsx">  type ListProps&lt;T&gt; = {
    items: T[];
    renderItem: (item: T) =&gt; React.ReactNode;
  };

  // 타입 참조에서는 T를 구체적으로 지정하니까 문제없음
  type Props = {
    userList: React.FC&lt;ListProps&lt;User&gt;&gt;;
    productList: React.FC&lt;ListProps&lt;Product&gt;&gt;;
  };</code></pre>
<p>선언 시에는 <T>를 함수 앞에 붙여야 하는데 FC 문법 구조상 넣을 곳이 없고, 타입 참조 시에는 이미 <code>&lt;User&gt;</code> 같이 구체적인 타입을 넣기 때문에 문제가 생기지 않는다.</p>
<h3 id="참고">참고</h3>
<blockquote>
<p><a href="https://www.reddit.com/r/reactjs/comments/ys70t9/is_is_still_problematic_to_use_reactfc_if_our/?tl=ko">https://www.reddit.com/r/reactjs/comments/ys70t9/is_is_still_problematic_to_use_reactfc_if_our/?tl=ko</a></p>
</blockquote>
<blockquote>
<p><a href="https://github.com/facebook/create-react-app/pull/8177">https://github.com/facebook/create-react-app/pull/8177</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Figma MCP로 빠르게 개발하기 (feat. codex)]]></title>
            <link>https://velog.io/@seongwon__105/Figma-MCP%EB%A1%9C-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-feat-codex</link>
            <guid>https://velog.io/@seongwon__105/Figma-MCP%EB%A1%9C-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-feat-codex</guid>
            <pubDate>Mon, 02 Mar 2026 04:15:48 GMT</pubDate>
            <description><![CDATA[<h3 id="배경">배경</h3>
<p>작년부터 저는 <strong>모아동</strong>이라는 대학교 동아리 서비스를 개발하여 지금까지 운영중이에요. 3월 5일 열리는 <strong>동아리 소개 한마당</strong>은 동아리에 마지막으로 지원할 수 있는 행사에요.</p>
<h3 id="요구사항">요구사항</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3ad83e23-823f-4aec-9d9f-2971b34c85c8/image.png" alt=""></p>
<p>3월 5일에 열리는 동아리 소개 한마당에서 사용되는 <strong>동아리 부스 지도</strong>를 만들어야 했어요. 테스트까지 생각하면 촉박한 시간이었기에 figma MCP를 사용해 빠르게 개발하는 것을 선택했어요.</p>
<h3 id="codex에-figma-mcp-연결">codex에 figma mcp 연결</h3>
<p>저는 터미널에서 codex를 쓰고 있었어요. 그래서 <code>.codex/config.toml</code> 에서 직접 수정해야 합니다. </p>
<h4 id="권한-변경">권한 변경</h4>
<pre><code class="language-toml">[projects.&quot;/Users/..../Desktop/moadong&quot;]
trust_level = &quot;untrusted&quot;</code></pre>
<p>제가 작업하고 있는 디렉토리의 권한을 볼 수 있어요. 디폴트는 untrusted기 때문에 trusted로 바꿔야 합니다.</p>
<h4 id="figma-mcp-붙이기">figma mcp 붙이기</h4>
<p>ide는 Cursor를 사용하고 있어요. Cursor에서 MCP를 사용하기 위해 Remote MCP Cient 기능을 활성화해야 해요. 이는 외부 MCP 서버 (여기선 Figma MCP)에 연결이 가능하도록 만드는 스위치라고 보면 됩니다.</p>
<pre><code class="language-toml">[features]
rmcp_client = true</code></pre>
<p>이제 Figma MCP 서버 연결 정보를 넣어줍니다.</p>
<pre><code class="language-toml">[mcp_servers.figma]
url = &quot;https://mcp.figma.com/mcp&quot;
bearer_token_env_var = &quot;FIGMA_OAUTH_TOKEN&quot;
http_headers = { &quot;X-Figma-Region&quot; = &quot;us-east-1&quot; }</code></pre>
<ul>
<li>url: Figma 공식 MCP 서버주소에요. Cursor는 여기로 API 요청을 보내요.</li>
<li><code>bearer_token_env_var</code>: 인증 토큰을 직접 쓰는 것이 아니라 환경변수에 저장된 값을 사용하도록 하는 설정이에요. </li>
</ul>
<pre><code class="language-bash">export FIGMA_OAUTH_TOKEN=피그마_토큰</code></pre>
<p>이렇게 세팅되어야 해요. Cursor는 실행할 때 </p>
<pre><code>Authorization: Bearer &lt;환경변수값&gt;</code></pre><p> 이렇게 헤더를 붙여 Figma MCP에 요청을 보내요. </p>
<ul>
<li><code>http_headers</code>: Figma MCP를 리전 기반으로 동작해요. 저는 us-east-1을 썼어요.</li>
</ul>
<h4 id="figma-token-발급">Figma token 발급</h4>
<p>Figma Token은 홈에서 프로필 클릭하면 setting이 나오는데요. 거기서 Security를 보면 액세스 토큰을 발급할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/45fabca8-6c53-4bb6-bce4-3193585b20b1/image.png" alt=""></p>
<h4 id="token-적용">token 적용</h4>
<p>token을 적용하는 것은 두 개의 방법이 있어요. mac인 경우 <code>~/.zshrc</code>에 </p>
<p><code>&#39;export FIGMA_OAUTH_TOKEN=발급받은토큰&#39;</code></p>
<p>이걸 저장하고 <code>source ~./zshrc</code>로 저장하면 됩니다.</p>
<p>두 번째는 mcp를 쓸 터미널에서 미리 <code>&#39;export FIGMA_OAUTH_TOKEN=발급받은토큰&#39;</code>를 입력하면 터미널에서 token을 사용할 수 있어요.</p>
<h3 id="figma-node-id">Figma node-id</h3>
<p>저는 cursor ide에서 터미널로 codex를 켜서 작업했고, codex 실행 전에 터미널에 피그마 토큰을 주입하는 것으로 했습니다.</p>
<p>Figma에서는 node-id로 컴포넌트를 읽을 수 있습니다. 
<code>https://www.figma.com/design/...?node-id=8847-8004&amp;m=draw</code> 컴포넌트를 클릭하면 이런 식으로 된 URL을 볼 수 있는데, node-id를 codex에게 던져주면 해당 컴포넌트를 읽을 수 있습니다.</p>
<h3 id="cursor---codex---figma-mcp-통신-순서">Cursor - Codex - Figma MCP 통신 순서</h3>
<p>실제로는 <strong>Cursor(또는 Codex 실행 환경)</strong>가 MCP 클라이언트 역할을 하고, Figma 공식 MCP 서버와 통신합니다.
  요청은 내가 링크(노드 id 포함)를 입력하는 순간부터 아래 순서로 진행돼요.</p>
<ol>
<li>사용자 입력<ul>
<li>Figma URL(예: ...?node-id=8851-6378)을 Codex에 전달</li>
</ul>
</li>
<li>Codex가 node-id 추출<ul>
<li>링크에서 fileKey, node-id를 파싱해서 어떤 프레임을 읽을지 결정</li>
</ul>
</li>
<li>MCP 서버 설정 확인<ul>
<li>~/.codex/config.toml의 [mcp_servers.figma] 설정 사용</li>
<li>bearer_token_env_var = &quot;FIGMA_OAUTH_TOKEN&quot;로 토큰을 환경변수에서 읽음</li>
</ul>
</li>
<li>Figma MCP 서버로 요청 전송<ul>
<li>Codex → <a href="https://mcp.figma.com/mcp">https://mcp.figma.com/mcp</a></li>
<li>헤더:<ul>
<li>Authorization: Bearer <FIGMA_OAUTH_TOKEN></li>
<li>X-Figma-Region: us-east-1</li>
</ul>
</li>
</ul>
</li>
<li>Figma MCP가 Figma 데이터 조회<ul>
<li>해당 노드의 구조(텍스트, 레이아웃, 스타일), 메타데이터, 스크린샷/에셋 URL 반환</li>
</ul>
</li>
<li>Codex가 결과를 코드로 변환<ul>
<li>반환된 디자인 컨텍스트를 기준으로 코드 생성/수정</li>
<li>필요하면 여러 노드를 순차 조회해 화면 전체 구성 완성</li>
</ul>
</li>
</ol>
<h3 id="요구사항-전달하기">요구사항 전달하기</h3>
<pre><code>내가 준 node-id 4개를 읽고 특정 파일 내에 동아리 부스지도 컴포넌트를 만들어줘.
아래 1/4 ~ 4/4 보여주도록 추가해주고 점선 누르면 슬라이드 이동도 가능해야해</code></pre><img src='https://velog.velcdn.com/images/seongwon__105/post/18ede646-8348-4a0d-a694-d53e2478444e/image.gif' width=300/>


<p>결과적으로 기존 컴포넌트를 깨뜨리지 않으면서 요구사항과 동일하게 구현하는 데 성공했습니다.
프로젝트에서는 이미 Swiper 라이브러리를 사용하고 있었고, Codex가 프로젝트 구조를 파악한 뒤 기존 Swiper 기반 구조에 맞춰 슬라이드를 구현해주는 것을 확인할 수 있었습니다.</p>
<h3 id="후기">후기</h3>
<p>작년 10월에도 Figma MCP를 이용해 컴포넌트를 구현한 적이 있었는데 그때는 픽셀도 제각각에다가 결과물도 좋지 않았어요. 제가 직접 구현하는게 더 빠를 정도였으니까요. 5개월이 지난 지금 Figma MCP의 성능은 훨씬 좋아졌고 직접 개발하는 것의 몇 배는 생산성이 높아졌어요.</p>
<p>조금 아쉬웠던 것은 카드 컴포넌트 내에 들어가는 여러 요소들의 위치를 하드코딩으로 구현한 것이었어요. 정말 껍데기만 잘 만들고 확장성이나 유지보수를 전혀 생각하지 않은 느낌이었어요. </p>
<p>이번 기능 개발은 딱 하루를 위한 기능이었기 때문에 확장성과 유지보수를 크게 고려하지 않아도 되기에 다시 수정하지는 않았습니다. 하지만 이후에도 간단한 컴포넌트는 Figma MCP로 개발해 볼 생각이에요. 확장성과 유지보수성을 프롬프트에 추가한다면 결과물이 또 어떻게 달라질지 궁금해지네요. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[swiper 라이브러리 내부 톺아보기 2]]></title>
            <link>https://velog.io/@seongwon__105/swiper-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-2</link>
            <guid>https://velog.io/@seongwon__105/swiper-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-2</guid>
            <pubDate>Thu, 15 Jan 2026 15:26:43 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@seongwon__105/swipe-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-1">이전글</a>에서는 swiper 라이브러리에 구현되어 있는 슬라이드 종류와 일반 슬라이드 구현의 특징에 대해 알아보았다. </p>
<p>이번에는 Virtual 슬라이드의 개념과 쓰임에 대해 알아보려 한다.</p>
<h2 id="virtural-슬라이드의-내부동작">Virtural 슬라이드의 내부동작</h2>
<p>Virtual 슬라이드의 메소드 인터페이스는 <a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/types/modules/virtual.d.ts#L1">virtual.d.ts</a>에서 볼 수 있다.</p>
<p>Virtual 슬라이드에서는 <a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/types/modules/manipulation.d.ts#L1">일반 슬라이드 메소드</a>인 <code>appendSlide, prependSlide, removeSlide, removeAllSlides</code>외에도 <code>from, to, cache, slides, update</code>를 가진다. </p>
<p>이 메소드들은 왜 더 필요할까? 그것은 DOM을 다루는 방식에 있다.</p>
<h3 id="1-일반-vs-virtual의-상태를-가지는-주인">1. 일반 vs Virtual의 상태를 가지는 주인</h3>
<h4 id="일반-슬라이드">일반 슬라이드</h4>
<ul>
<li>상태의 근원 = DOM</li>
<li>슬라이드 추가/삭제 = DOM에 append/prepend/remove</li>
<li>Swiper는 DOM을 읽어 (recalcSlides, update) 내부 상태를 재조정</li>
</ul>
<h4 id="virtual-슬라이드">Virtual 슬라이드</h4>
<ul>
<li>상태의 근원 = <code>virtual.slides</code>배열</li>
<li>DOM은 &quot;화면에 필요한 만큼&quot; 부분적으로 존재</li>
<li>Swiper는 아래의 값들을 계산한다</li>
</ul>
<pre><code>    - 지금 화면에 어떤 인덱스를 보여줘야 할지 (from/to)
    - 지금 렌더링된 DOM을 어디에 놓아야 하는지 (offset)
    - 이미 만들어 둔 DOM을 재사용해야 하는지 (cache)</code></pre><p>Virtual은 렌더링 엔진이 하나 더 붙은 구조라서, 인터페이스가 &quot;상태+렌더 제어&quot;까지 포함한다.</p>
<h3 id="2-virtual에-cachefromtoupdate가-있는-이유">2. Virtual에 cache/from/to/update가 있는 이유</h3>
<h4 id="from-to">from, to</h4>
<p>Virtual은 전체 슬라이드가 DOM에 없다. 그래서 &quot;현재 렌더링된 구간&quot;을 반드시 기억해야 한다.</p>
<ul>
<li>from = 현재 DOM에 존재하는 첫 슬라이드 인덱스</li>
<li>to = 현재 DOM에 존재하는 마지막 슬라이드 인덱스</li>
</ul>
<h4 id="offset">offset</h4>
<p>Virtual은 일부만 DOM에 렌더링하기 때문에, 실제로는 &quot;100번째 슬라이드부터 렌더링&quot;해도 DOM상으로는 첫 번째처럼 보일 수 있다.</p>
<p>그래서 원래 위치처럼 보이도록 밀어주는 값이 필요하다.</p>
<p>가로면 left/right, 세로면 top을 사용한다. 실제로 React호환성 코드에서도 이 값을 style로 사용한다. <a href="https://github.com/nolimits4web/swiper/blob/ec4977b629c0823173e7b8bde7feb040a9fc4ff3/src/react/virtual.mjs">react/virual.mjs</a></p>
<h4 id="cache">cache</h4>
<p>Virtual은 계속 DOM을 만들었다가 지웠다를 반복한다. 매번 새로 만드는 비용을 줄이기 위해 한 번 만든 SlideEl을 저장해두고 재사용하는 방식으로 비용을 줄인다.</p>
<p>일반 슬라이드는 DOM에 계속 존재하기에 cache가 필요없다.</p>
<h4 id="update">update</h4>
<p>일반 슬라이드는 DOM을 추가하면 되지만, Virtual은 데이터만 바뀌면 DOM을 다시 맞춰 렌더링해야 한다.</p>
<p>그래서 <code>virtual.update()</code>는 아래와 같은 역할을 수행한다.</p>
<ul>
<li>activeIndex 기준 <code>from, to</code> 재계산</li>
<li>DOM에 있어야 할 슬라이드만 남기고 교체</li>
<li>offset 재적용</li>
</ul>
<h3 id="3-addslide의-부재">3. addSlide의 부재</h3>
<ul>
<li>일반 슬라이드: append / prepend / add / remove / removeAll</li>
<li>Virtual: append / prepend / remove / removeAll / update</li>
</ul>
<p>Virtual에 addSlide가 없는 이유는 중간 삽입은 배열 수정으로 하고 update만 호출하면 되기 때문이다.</p>
<h3 id="4-reactvue에서-virtual을-지양한다">4. React/Vue에서 Virtual을 지양한다</h3>
<blockquote>
<p>Only for Core version (in React &amp; Vue it should be done by modifying slides array/data/source) </p>
</blockquote>
<p>React와 Vue는 렌더링 주도권이 자신에게 있다.</p>
<p>만약 Swiper가 DOM을 직접 append, prepend하면 React의 Virtual DOM과 충돌한다.</p>
<h3 id="5-virtual-최적화">5. Virtual 최적화</h3>
<p><a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/modules/virtual/virtual.css#L14">virtual.css</a>에서는 Virtual 슬라이드의 성능이나 스크롤이 깨지지 않도록 한다.</p>
<p>핵심부분을 살펴보자면</p>
<p><strong>translateZ(0)</strong></p>
<ul>
<li>해당 요소를 CPU 레이어로 올리는 역할이다. 스와이프 중 깜빡임이나 떨림을 방지한다.</li>
</ul>
<p><strong>backface-visibility: hidden</strong></p>
<ul>
<li>3D 변환 중 뒷면 렌더링으로 발생할 수 있는 텍스트 깨짐을 방지한다.</li>
</ul>
<p>Virtual에서 이 부분들이 중요한 이유는 DOM을 계속 갈아끼우는 데에 있다. 그렇게 갈아끼운 DOM의 위치를 transform으로 재조정한다.</p>
<p>만약 GPU 레이어로 올리지 않으면 페인트 비용과 시각적으로 깨짐이 발생한다. 이것은 브라우저 렌더링 순서와 관련이 있다.</p>
<h4 id="브라우저-렌더링-순서">브라우저 렌더링 순서</h4>
<ol>
<li>style 계산</li>
<li>Layout (reflow) - 위치/크기 계산</li>
<li>Paint - 픽셀 비트맵으로 그림</li>
<li>Composite - 레이어 합성해서 화면에 출력</li>
</ol>
<p>여기서 깨짐(flicker, jitter, tearing)은 대부분 <strong>Paint 단계에서 발생</strong>한다.</p>
<h4 id="virtual에서-일어나는-일">Virtual에서 일어나는 일</h4>
<p>Virtual은 스와이프할 때마다 <code>기존 슬라이드 DOM 제거 -&gt; 새로운 슬라이드 DOM 삽입 -&gt; 위치 재조정</code>이 일어난다. 즉, 한 프레임 안에서 DOM 트리 변경, 위치 변경, 스타일 변경이 한꺼번에 일어나는 것이다.</p>
<h4 id="cpu-레이어만-쓴다면">CPU 레이어만 쓴다면</h4>
<p>DOM 제거/추가는 reflow, 위치 변경은 repaint라 하면 브라우더 렌더링 순서 중 Layout -&gt; Paint가 자주 발생하게 된다. 프레임 안에 다 못 끝낸다면 중간 상태가 화면에 노출된다.</p>
<p>Paint는 이전 비트맵 위에 변경된 영역만 다시 그리는 <strong>누적 비트맵</strong>이라 Virtual에서 Layout -&gt; Paint 가 원자적으로 처리되지 않으면 앞에서 말한 &quot;깨짐&quot;이 발생한다.</p>
<h4 id="gpu-레이어를-쓰면-달라지는-것">GPU 레이어를 쓰면 달라지는 것</h4>
<p>GPU 레이어의 특징은 이렇다.</p>
<ul>
<li>Layouy / Paint를 다시 안 함</li>
<li>이미 그려진 비트맵을 CPU에서 위치만 이동</li>
<li>Composite 단계에서만 처리</li>
</ul>
<p>그렇기에 요소를 별도의 합성 레이어로 분리함으로써 repaint가 아니라 composite단계에서 처리하는 것이다.</p>
<p>깨짐이 사라지는 이유도 프레임 단위로 레이어가 교체되기 때문에 중간 상태가 화면에 노출될 일이 없기 때문이다.</p>
<h4 id="virtual에서-특히-중요한-이유">virtual에서 특히 중요한 이유</h4>
<p>일반 슬라이드에선 DOM이 거의 고정되어 있고 위치 변화가 적다. 그렇기에 repaint 빈도가 낮은 반면, Virtual에서는 DOM을 자주 교체하여 offset이 계속 변경되므로 repaint 빈도가 높다.</p>
<p>CPU 레이어는 Virtual 슬라이드에서 일어나는 Paint를 감당할 수 없기에 GPU 레이어로 올린 것이다.</p>
<h3 id="다음-챕터">다음 챕터</h3>
<p>Virtual 슬라이드를 알아보니 브라우저 내부 동작과 관련있다는 것이 신기했다. 다음은 React에서 Swiper가 어떻게 돌아가는지 알아볼 예정이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[swiper 라이브러리 내부 톺아보기 1]]></title>
            <link>https://velog.io/@seongwon__105/swipe-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-1</link>
            <guid>https://velog.io/@seongwon__105/swipe-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-1</guid>
            <pubDate>Tue, 13 Jan 2026 17:21:07 GMT</pubDate>
            <description><![CDATA[<p>최근 캐러셀 구현을 위해 swipe 라이브러리를 사용했다.</p>
<p>내부 동작이 궁금해 <a href="https://github.com/nolimits4web/swiper/tree/ec4977b629c0823173e7b8bde7feb040a9fc4ff3">swipe 깃허브</a>에서 코드를 보고 간단하게 정리해보았다.</p>
<h2 id="swipe-슬라이드-구분">Swipe 슬라이드 구분</h2>
<p>Swipe 라이브러리에는 슬라이드 관리 방식이 크게 2가지가 있다.</p>
<h3 id="1-일반-슬라이드-dom-기반-슬라이드">1. 일반 슬라이드 (DOM 기반 슬라이드)</h3>
<blockquote>
<p>모든 슬라이드를 실제 DOM으로 다 만들어서 관리</p>
</blockquote>
<ul>
<li>슬라이드가 전부 <code>.swipe-slide</code> DOM으로 존재한다.</li>
<li><code>appendSlide</code>, <code>removeSlide</code>는 DOM을 직접 추가/삭제</li>
</ul>
<h4 id="언제-쓸까">언제 쓸까?</h4>
<ul>
<li>슬라이드 수가 적을 때</li>
<li>단순한 캐러셀, 배너</li>
<li>구현이나 디버깅이 쉬운 것을 원할 때</li>
</ul>
<h3 id="2-virtual-슬라이드">2. Virtual 슬라이드</h3>
<blockquote>
<p>슬라이드 데이터만 들고 있고
화면에 필요한 일부만 DOM으로 렌더링</p>
</blockquote>
<ul>
<li>전체 슬라이드는 JS배열로 관리한다.</li>
<li>DOM에는 항상 현재 화면 주변 슬라이드만 존재한다.</li>
<li>스크롤/스와이프 시 DOM을 교체한다.</li>
</ul>
<h4 id="언제-쓸까-1">언제 쓸까?</h4>
<ul>
<li>슬라이드가 매우 많을 때</li>
<li>무한 스크롤</li>
<li>React/Vue와 같이 Virtual DOM을 쓰는 환경</li>
</ul>
<h3 id="일반-슬라이드-내부-동작">일반 슬라이드 내부 동작</h3>
<p>일반 슬라이드의 메소드 인터페이스는 <a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/types/modules/manipulation.d.ts#L1">manipulation.ts</a>에서 볼 수 있다.</p>
<p>이제 <a href="https://github.com/nolimits4web/swiper/tree/ec4977b629c0823173e7b8bde7feb040a9fc4ff3/src/modules/manipulation/methods">코드</a>를 살펴보자.</p>
<h4 id="1-addslide">1. <code>addSlide</code></h4>
<h4 id="동작흐름">동작흐름</h4>
<pre><code>1. loop면 구조 해체
2. index 뒤 슬라이드 전부 잠시 제거
3. 새 슬라이드 삽입
4. 제거했던 슬라이드 다시 붙임
5. 전체 재계산 + loop 복구 + slideTo로 화면유지</code></pre><h4 id="핵심로직">핵심로직</h4>
<pre><code class="language-ts">// 1. index 이후 슬라이드들을 DOM에서 제거해서 임시 저장
const slidesBuffer = [];
for (let i = baseLength - 1; i &gt;= index; i -= 1) {
  const currentSlide = swiper.slides[i];
  currentSlide.remove();
  slidesBuffer.unshift(currentSlide);
}

// 2. 새 슬라이드를 원하는 위치에 append
slidesEl.append(slides);

// 3. 제거했던 슬라이드들을 다시 뒤에 붙임
for (let i = 0; i &lt; slidesBuffer.length; i += 1) {
  slidesEl.append(slidesBuffer[i]);
}</code></pre>
<p>DOM에는 insertBefore 같은 고수준 API를 쓰지 않고 있다.</p>
<p><code>&quot;뒤쪽 슬라이드 전부 빼고 -&gt; 새 슬라이드 붙이고 -&gt; 다시 붙인다&quot;</code> 전략을 쓰기 때문이다.</p>
<h4 id="why">why</h4>
<p>Swipe는 내부적으로 슬라이드 순서를 단순하게 <code>slidesEl.children</code> 순서로 관리하기 때문에 중간 삽입을 직접 지원하지 않는다. 그래서 물리적으로 DOM을 재배치하는 방식을 사용하고 있다.</p>
<h3 id="2-removeslide">2. removeSlide</h3>
<h4 id="동작흐름-1">동작흐름</h4>
<pre><code>1. loop 모드면 복제 슬라이드 때문에 loop 제거
2. 지정한 인덱스의 슬라이드 DOM 제거
3. activeIndex 보정
4. 슬라이드 목록 재계산
5. loop 복구
6. 화면 유지 (보던 슬라이드로 이동)</code></pre><p>loop 모드에서는 앞/뒤에 <strong>복제 슬라이드</strong>가 붙어서 인덱스가 어긋나기에 loop를 제거한다.</p>
<h4 id="핵심로직-1">핵심로직</h4>
<pre><code class="language-ts">// 1. 슬라이드 DOM 제거
swiper.slides[indexToRemove].remove();

// 2. 삭제로 인해 activeIndex 보정
if (indexToRemove &lt; activeIndex) {
  activeIndex -= 1;
}

// 3. 슬라이드 목록 재수집
swiper.recalcSlides();</code></pre>
<p>슬라이드 DOM을 제거하고, 그로 인해 밀린 activeIndex를 보정한 뒤 Swiper가 다시 계산하게 하는 것이다.</p>
<p>이외에도 모든 슬라이드를 제거하는 <code>removeAllSlides</code>가 있다.</p>
<h3 id="3-appendslide">3. appendSlide</h3>
<h4 id="동작흐름-2">동작흐름</h4>
<pre><code>1. loop 모드면 기존 loop 구조 제거
2. 슬라이드 DOM 추가 
    2-1. 문자열 -&gt; 임시 DOM 생성 후 append
    2-2. HTMLElement -&gt; 그대로 append
3. 슬라이드 목록 재계산
4. loop 복구 
5. 레이아웃과 상태 업데이트</code></pre><h4 id="핵심로직-2">핵심로직</h4>
<pre><code class="language-ts">// 1. 슬라이드 DOM에 추가
slidesEl.append(slideEl);

// 2. Swiper 내부 슬라이드 목록 재수집
swiper.recalcSlides();</code></pre>
<p>복잡한 로직은 없었고 슬라이드 DOM 추가 -&gt; Swipe가 변화를 인식하는 것이 다였다.</p>
<h3 id="4-prependslide">4. prependSlide</h3>
<h4 id="동작흐름-3">동작흐름</h4>
<pre><code>1. loop 모드면 기존 loop 구조 제거
2. 슬라이드 DOM을 맨 앞에 추가
    2-1. 문자열 → 임시 DOM → prepend
    2-2. HTMLElement → 그대로 prepend
3. activeIndex 보정 (앞의 슬라이드가 추가되었으므로)
4. 슬라이드 목록 재계산
5. loop 복구
6. 레이아웃과 상태 업데이트
7. 보던 슬라이드로 유지 </code></pre><h4 id="핵심로직-3">핵심로직</h4>
<pre><code class="language-ts">// 1. 슬라이드를 맨 앞에 DOM으로 추가
slidesEl.prepend(slideEl);

// 2. 앞에 추가됐으므로 activeIndex 보정
newActiveIndex = activeIndex + addedSlidesCount;

// 3. Swiper 내부 상태 재계산
swiper.recalcSlides();</code></pre>
<p>슬라이드를 DOM 앞에 붙이고, activeIndex를 밀어준 뒤 Swipe가 다시 계산하게 한다.</p>
<h3 id="간단정리">간단정리</h3>
<p><code>appendSlide</code> → DOM 뒤에 추가</p>
<p><code>prependSlide</code> → DOM 앞에 추가 + index 밀기</p>
<p><code>addSlide</code> → DOM 재배치</p>
<p><code>removeSlide</code> → DOM 제거 + index 당기기</p>
<h3 id="다음-챕터">다음 챕터</h3>
<p>다음글에서는 Virtual 슬라이드 동작과 특징, 그리고 일반 슬라이드와 Virtural 슬라이드의 차이점과 쓰임에 대해 작성해보려 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 주도 개발 ]]></title>
            <link>https://velog.io/@seongwon__105/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C</link>
            <guid>https://velog.io/@seongwon__105/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C</guid>
            <pubDate>Fri, 07 Nov 2025 09:18:56 GMT</pubDate>
            <description><![CDATA[<h2 id="주도-개발">주도 개발</h2>
<p>TDD, BDD, DDD 뒤에 붙은 DD(Driven Development) 에서 따온 단어인데요.</p>
<p>개발뿐만 아니라 협업에서도 주도하는 역할이 중요하다고 생각합니다. 저는 프론트엔드 개발자로서 여러 프로젝트에서 다양한 분들과 협업을 해 왔는데요. 프론트엔드 개발자가 할 수 있는 주도 개발에는 어떤 것이 있을 지 고민해 봤습니다.</p>
<h2 id="프론트엔드가-하는-일">프론트엔드가 하는 일</h2>
<p>프로젝트에서 프론트엔드가 하는 작업은 UI/UX를 고려한 컴포넌트 설계 및 제작, api 요청응답 관리, 라우팅 설계, 최적화 작업 등 여러 가지가 있습니다.</p>
<p>이 중에서 저는 오늘 UI/UX와 api에 관련하여 프론트엔드가 주도할 수 있는 작업을 얘기하려고 합니다. </p>
<h2 id="늦어지는-작업-속도">늦어지는 작업 속도</h2>
<p>&quot;디자인이 아직 없어서 컴포넌트를 제작할 수 없어.&quot;</p>
<p>&quot;백엔드 api 구현이 안 되어서 api 연동을 못 하네. 일단 mock data로 대신해서 보여줘야 하나..?&quot;</p>
<p>다들 프로젝트를 하면서 이런 경험을 한 번쯤 해 보셨을 것 같아요. 개발을 처음 시작했을 때는 이 간극을 어떻게 메워야 할 지, 팀원에게 어떤 식으로 얘기할 지 고민이 많았던 것 같습니다.</p>
<p>여러 프로젝트를 진행해 오면서 이 고민은 점점 커졌습니다. 어떻게 하면 디자이너-프론트, 프론트-백엔드 간의 작업 병목을 해소할 수 있을까 하고요. </p>
<h2 id="storybook으로-디자이너와-협업하기">Storybook으로 디자이너와 협업하기</h2>
<p>Storybook은 React, Vue 등에서 사용되는 UI 컴포넌트를 효율적으로 관리할 수 있는 개발 도구입니다. 
그 외에 Storybook에 대해 조금 더 알고 싶으시다면 <a href="https://velog.io/@seongwon__105/React-Storybook-%EC%A0%81%EC%9A%A9">React + Storybook 적용</a>을 참고해주세요!</p>
<p>개발을 하다 보면 UI 컴포넌트가 많아져 폴더를 일일이 찾아봐야 하는 번거로움이 생길 때가 있습니다.</p>
<p>이때 Storybook을 사용하면, 현재 추가한 UI 컴포넌트들만 모아 볼 수 있어요. 버튼을 예로 들면 <code>Button.stories.ts</code>로 버튼 스토리를 추가할 수 있어요. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/ba069af0-584a-439d-9275-374b3bfe8d21/image.png' width=200 />

<p>UI 컴포넌트가 많아질수록 추가해야 할 스토리 파일도 많아질 거에요. 저는 <code>stories</code> 라는 폴더에 스토리 파일들을 배치했어요. 컴포넌트 파일과 같이 두는 경우도 있지만 확장성을 고려한다면 저처럼 다른 폴더로 분리하는 것을 추천합니다.</p>
<h3 id="storybook-본-컴포넌트">Storybook 본 컴포넌트</h3>
<p>스토리북을 설치하셨다면 <code>npm run storybook</code>으로 스토리북을 실행할 수 있어요. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/9beb7d73-f454-414b-ac61-74f76ad7f478/image.png" alt=""></p>
<p>왼쪽 파일들이 모두 <code>stories.ts</code> 파일입니다. 오른쪽엔 이름, 설명, 타입, 제어 등 다양한 속성이 존재해요.</p>
<pre><code class="language-typescript">import { Button } from &#39;@/components/Button/Button&#39;;
import type { Meta, StoryObj } from &#39;@storybook/nextjs&#39;;

const meta: Meta&lt;typeof Button&gt; = {
  title: &#39;Components/Button&#39;,
  component: Button,
  tags: [&#39;autodocs&#39;],
};

export default meta;
type Story = StoryObj&lt;typeof Button&gt;;</code></pre>
<p>해당 코드는 Button 스토리 파일에서 기본으로 설정해야 하는 코드입니다. <code>tags: [&#39;autodocs&#39;]</code> 부분이 문서 페이지를 자동으로 생성해주는 부분이고요. </p>
<p>meta 객체는 스토리북 사이드바에 표시될 경로, 그리고 문서를 만들 대상 컴포넌트를 지정하는 핵심적인 설정이에요. 
그 외 StoryObj 타입이나 export default meta 구문은 타입스크립트의 타입 안정성을 등록하기 위한 표준 보일러플레이트입니다. </p>
<h3 id="chromatic으로-배포">Chromatic으로 배포</h3>
<p>아직은 로컬에서만 보이는 스토리북을 배포할 수 있는 방법이 따로 있습니다. 바로 Chromatic이라는 도구입니다. netlify,vercel 같은 배포 도구가 있지만 chromatic은 배포 전에 컴포넌트를 미리 볼 수 있고, 무엇보다 스토리북에 최적화되어 있다는 것이 장점이기에 사용했습니다.</p>
<pre><code class="language-bash">npm install --save-dev chromatic // 의존성 설치

npx chromatic --project-token &lt;your-project-token&gt; // 스토리북을 chromatic에 배포</code></pre>
<p>명령어를 수행하면 추가한 스토리북 파일들이 chromatic에 배포됩니다. 실제로 제가 배포한 <a href="https://687f952cbf0b6b7d2e31932f-ysfatlexad.chromatic.com/">스토리북 배포링크</a> 입니다.</p>
<h3 id="디자이너에게-공유하기">디자이너에게 공유하기</h3>
<p>위 링크는 서비스가 배포되기 전에 미리 디자이너에게 공유할 수 있습니다. 이것의 가장 큰 장점은 디자이너가 보는 UI와 개발자가 제작한 UI 간극을 최대한 줄일 수 있다는 것입니다.</p>
<p>이미 머지가 된 PR인데 갑자기 수정 요청이 오면 당황스럽겠죠. 또 자주 그런 일이 발생한다면 개발자나 디자이너 모두에게 부담이 될 것입니다.</p>
<h3 id="💁🏻-개발-주도하기">💁🏻 개발 주도하기</h3>
<p>일반적으로는 디자이너가 먼저 Figma 같은 디자인툴을 이용해 컴포넌트를 디자인하고, 개발자가 이를 코드로 구현하는 &quot;디자인 주도 개발&quot; 방식을 따릅니다.</p>
<p>하지만 개발자가 먼저 공통 컴포넌트를 만들고 디자이너에게 공유하는 개발을 주도할 수 있지 않을까요?</p>
<h3 id="장점">장점</h3>
<p>심미적인 부분이나 UX적인 흐름보다 기능 구현에 초점을 맞춰 공유한다면, 디자이너는 기능적인 측면의 고민들보다 UX와 그 외 스타일 부분들에 집중할 수 있어요. 또한 빠른 프로토타입을 만들어야 할 때 유용합니다.</p>
<h3 id="단점">단점</h3>
<p>물론 단점도 존재합니다. 디자이너가 나중에 컴포넌트를 보고 마음에 들지 않는다고 하면, 개발자는 다시 코드를 대폭 수정해야 하겠죠. </p>
<h3 id="그럼에도-추천해요">그럼에도 추천해요</h3>
<p>프로젝트의 성격에 따라 개발을 주도할 수 있는 방법이 달라질 것 같은데요, 이런 방법도 가능할 것 같다~ 라는 하나의 예시로 봐 주시면 될 것 같아요. 그럼에도 불구하고 Storybook과 Chromatic을 사용하는 것은 프론트엔드와 디자인 간의 의사소통을 더 적극적으로 가능하게 하고, 작업 병목을 해소할 수 있는 아주 좋은 방법이라고 생각합니다.</p>
<h2 id="백엔드와-협업하기">백엔드와 협업하기</h2>
<p>그 다음은 프론트엔드-백엔드 사이의 작업 병목을 해결하기 위해서 어떤 방법을 사용할 수 있을까에 대한 고민을 해 봤어요.</p>
<h3 id="api-연동-프로세스">API 연동 프로세스</h3>
<p>개발자는 API 개발을 하기 위해 해야 하는 여러 가지 단계가 있습니다.</p>
<pre><code>➡️ API 명세서 작성 -&gt; 프론트 UI 개발 -&gt; 백엔드 API 개발 (기다려야 함) -&gt; API 연동</code></pre><p>여기서 백엔드 개발자가 API를 개발하기 전까지 프론트엔드는 기다려야 합니다. </p>
<p>추가적으로 생기는 귀찮음도 존재해요.</p>
<ul>
<li>백엔드는 매번 mock api를 생성해주고 삭제해야 함</li>
<li>mock api를 적용할 때와 아닐 때 api 주소를 변경해야 함 </li>
</ul>
<p>이때 MSW를 활용하여 프론트에서 미리 API 요청/응답을 모의 테스트할 수 있습니다.</p>
<h3 id="msw">msw</h3>
<p>Mock Server Worker(msw)는 프론트엔드 개발에서 백엔드 개발이 완료되기 전에 API 요청을 가로채서 모의(mock)응답을 보내주는 라이브러리입니다.</p>
<h3 id="msw-사용-방법">msw 사용 방법</h3>
<p>이제 msw 기본 설정에 필요한 핵심 코드들을 설명해보겠습니다.</p>
<h3 id="api-핸들러-정의">API 핸들러 정의</h3>
<p><strong>📂 src/mocks/handlers.js</strong></p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/5a114970-c162-4dec-81b5-9350d52cc405/image.png" alt=""></p>
<p><code>http</code>는 어떤 HTTP 요청을 가로챌지 정의하는 메서드들의 모음입니다. 흔히 아는 HTTP 메서드명과 이름이 같아요. 실제 api 코드처럼 작성하면 됩니다.</p>
<p>단지 실제 api 요청을 보내는 것이 아니라, 요청을 백엔드 서버에 도착하기전에 가로챈다는 것이 차이점이에요.</p>
<p><code>HttpResponse</code>는 http가 가로챈 요청에 대해 실제 브라우저에게 돌려줄 mock 응답을 쉽게 만들어줍니다. 가장 많이 사용되는 게 바로 <code>HttpResponse.json()</code> 메서드에요.</p>
<h3 id="브라우저-환경-설정">브라우저 환경 설정</h3>
<p><strong>📂 src/mocks/browser.js</strong></p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/247e1624-b504-437f-92be-5c56f64a9480/image.png" alt=""></p>
<p>이제 서비스 워커를 등록하여 mock 폴더에서 발생하는 모든 네트워크 요청을 서비스 워커가 감시하고 가로채도록 합니다.</p>
<p>과거 모킹 라이브러리에서는 <code>fetch</code>나 <code>axios</code> 함수 자체를 덮어쓰는 방식을 사용했다고 해요. MSW는 그보다 더 낮은 네트워크 레벨에서 서비스 워커를 통해 요청을 가로채기 때문에, 코드를 더 깔끔하게 유지할 수 있어요.</p>
<h3 id="어플리케이션에-적용">어플리케이션에 적용</h3>
<p>MSW는 개발 환경에서만 실행되어야 하기 때문에 앱 진입점에 설정해야 할 부분이 있어요.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7326ad89-7c8e-4076-affd-d38d0c23a733/image.png" alt=""></p>
<p>이제 development 환경에서만 msw가 실행되도록 변경했습니다.
bypass 옵션은 핸들러애 정의되지 않은 요청을 실제 네트워크로 전달하도록 합니다. </p>
<h3 id="💁🏻-개발-주도하기-1">💁🏻 개발 주도하기</h3>
<pre><code>➡️ API 명세서 작성 -&gt; 프론트 UI 개발 -&gt; 백엔드 API 개발 (기다려야 함) -&gt; API 연동</code></pre><p>다시 API 개발 프로세스를 가져오면, 이제 프론트엔드는 백엔드 API 개발이 완료되기 전에 기다림없이 API 요청과 응답을 테스트할 수 있어요. </p>
<p>실제 api 로직이 들어갈 부분에 mock api 코드를 추가하여 개발할 수 있고,
추후 백엔드에서 API 개발이 완료되면 mock api 부분을 실제 api 요청 코드로 변경만 하면 바로 연동이 가능합니다.</p>
<h2 id="마무리">마무리</h2>
<p>프론트엔드 개발자 입장에서 작업을 주도하는 방법에 대해 알아봤습니다. </p>
<p>글을 쓰면서 프론트 뿐만 아니라 백엔드, 디자이너 등 각 분야의 팀원이 주도할 수 있는 방법은 무엇일지 고민해봐도 좋을 것 같다는 생각이 들었습니다. </p>
<p>제 포스트가 팀프로젝트에 도움이 되었길 바라며 이것으로 마칩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[네이버 부스트캠프 웹모바일 10기 합격 후기]]></title>
            <link>https://velog.io/@seongwon__105/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B6%80%EC%8A%A4%ED%8A%B8%EC%BA%A0%ED%94%84-%EC%9B%B9%EB%AA%A8%EB%B0%94%EC%9D%BC-10%EA%B8%B0-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B6%80%EC%8A%A4%ED%8A%B8%EC%BA%A0%ED%94%84-%EC%9B%B9%EB%AA%A8%EB%B0%94%EC%9D%BC-10%EA%B8%B0-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Thu, 14 Aug 2025 07:01:41 GMT</pubDate>
            <description><![CDATA[<h2 id="네이버-부스트캠프란">네이버 부스트캠프란?</h2>
<p> AI시대에 맞는 개발자를 교육하는 네이버 커넥트재단에서 운영하는 프로그램이다. 
 과정은 <strong>베이직, 챌린지, 멤버쉽</strong>로 총 3개로 구성되어 있다. <a href="https://boostcamp.connect.or.kr/program_wm.html">참고</a></p>
<p>모집 분야는 웹 풀스택, 모바일(ios, android)로 나누어져 있다. 프론트엔드 개발만 해 오다가 최근 백엔드에도 관심이 생겼었고, 마침 부스트캠프에 웹 풀스택 과정이 있는 것을 보고 지원하게 되었다. </p>
<h2 id="베이직">베이직</h2>
<p>2주동안 진행되는 챌린지 맛보기(?) 과정이다. 금요일을 제외하고 월<del>목요일 동안 매일 나오는 미션을 풀고, 다른 사람의 코드를 리뷰한다. 리뷰가 필수는 아니지만, 다양한 방식의 풀이가 궁금해서 미션마다 2</del>3개 정도 리뷰를 했다.</p>
<p>챌린지에 입과하려면 베이직 과정과 문제 해결력 테스트까지 해야 한다. 문제 해결력 테스트를 보려면 베이직에 있는 모든 문제를 풀어야 해서 정말 열심히 했다.</p>
<h2 id="문제-해결력-테스트">문제 해결력 테스트</h2>
<p>베이직이 목요일에 끝나고, 준비할 시간이 금요일밖에 없었다. 풀었던 미션을 보면서 부족했던 JS메소드를 숙지하고 갔다. 다행히 시험 환경에서 MDN 을 제공해줘서 생각 안 나는 메소드를 바로바로 찾아볼 수 있었다. </p>
<p>제한시간은 3시간이었고 코테 3개, 나머지는 CS 문제였다. (CS 문제가 좀 많았던 걸로 기억한다.) 1,2번을 풀고 나머지 시간은 CS 문제에 투자했다. 베이직에서 학습했던 내용이 생각보다 많았다. 제대로 학습하지 않았다면 힘들 수도 있기 때문에 베이직 과정을 착실히 해 낸 사람이 유리해보였다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/6eaf9663-8791-4ba2-87cc-70754c41fb61/image.png" alt=""></p>
<p>엄청 잘 본 것도, 그렇다고 못 본 것도 아닌 애매한 느낌이었다. 다행히 챌린지가 되어서 더더 열심히 해야겠다는 생각이 들었다.</p>
<h2 id="챌린지">챌린지</h2>
<p>주말을 제외하고 한 달 동안 매일매일 미션을 풀어야 한다.  </p>
<p>이 과정이 가장 힘들었다. 매일 10<del>19는 코어 타임, 10</del>12는 동료들과 실시간 피드백 및 피드백 작성으로 구성되어 있다. 지금와서 보면 코어 타임은 의미가 없었던 것 같다. 하루종일 해야 하기 때문이다. 매일 새벽에 잤는데, 매일매일 문제를 풀어야 하고 멤버쉽에 떨어지면 어떡하지 하는 불안감 때문에 더 열심히 했다.</p>
<h3 id="🏃챌린지-전의-나">🏃챌린지 전의 나</h3>
<p> 나는 프로젝트를 위한 공부만 해 왔기에, 필요할 때마다 찾아보고 적용하는 방식으로 학습했다. 단점은 빠르게 휘발되어서 시간이 지나면 잘 기억이 안 났다.</p>
<p>전공에서 배운 CS지식은 하나도 기억이 안 나고, 진정 내가 전공생인가 의구심이 들 정도였다.</p>
<h3 id="🥕얻은-것">🥕얻은 것</h3>
<p>챌린지에서는 학습 내용과 리드미를 추가로 작성해야 한다. 하루에 하나의 미션을 풀어야 하기에 학습과 구현의 밸런스를 잘 찾아야 한다. </p>
<p>주차가 지나면서 시간 분배가 정말 중요하다는 것을 느꼈다. 또한 너무 깊은 학습은 오히려 <strong>야크털을 깎는 행위</strong>라는 것이다. (멘토분께서 이렇게 표현하셨다.)</p>
<p>처음에는 야크털을 많이 깎으면서 시행착오를 많이 겪었고, 점점 학습과 구현의 밸런스를 찾아갔다. 학습을 많이 한다고 미션이 잘 해결되는 것이 아니고, 구현에만 집중해도 기억에 잘 남지 않았다. 그래서 각 미션마다 <strong>내가 얻어가고 싶은 것</strong>에 집중했다. </p>
<h3 id="챌린지의-끝">챌린지의 끝</h3>
<p>미션이 어려워 스스로 타협하는 순간도 있었고, 잠을 줄이면서 열심히 했던 순간도 있었다. 그러다보니 모르는 것 자체에 면역이 생겼다. 모르면 또 학습하면 되지 하는 마인드가 자연스럽게 생겼고 나에게 맞는 구현-학습 사이클이 생긴 것을 체감했다.</p>
<p>가장 큰 수확은 CS 지식이 모든 문제 해결의 근간이라는 것을 깨닫게 된 것이다. 학교에서 배울 때는 정말 먼 얘기처럼 들렸는데, 부스트캠프에서는 실제로 CS지식이 어떻게 활용되는지 이해할 수 있었다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7fe979c2-b380-4a3f-9539-c0250a205fbb/image.png" alt=""></p>
<p>결과는 합격이었다.. 마음속으로는 안 되면 어떡하지 하는 생각이 정말 많이 들었다. 챌린지를 잘 끝낸 나에게 정말 고생했다고 말해주고 싶고, 부족한 만큼 더 열심히, 잘 하고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JIRA] Github - JIRA담당자 자동화]]></title>
            <link>https://velog.io/@seongwon__105/JIRA-Github-JIRA%EB%8B%B4%EB%8B%B9%EC%9E%90-%EC%9E%90%EB%8F%99%ED%99%94</link>
            <guid>https://velog.io/@seongwon__105/JIRA-Github-JIRA%EB%8B%B4%EB%8B%B9%EC%9E%90-%EC%9E%90%EB%8F%99%ED%99%94</guid>
            <pubDate>Mon, 02 Jun 2025 16:11:58 GMT</pubDate>
            <description><![CDATA[<h2 id="jira-이슈-담당자-자동화">Jira 이슈 담당자 자동화</h2>
<p><a href="https://velog.io/@seongwon__105/JIRA-Github-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%83%9D%EC%84%B1">이전 포스팅</a>에서는 깃허브 이슈와 지라 이슈 자동화에 대한 워크플로우를 만들어봤습니다. 하지만 지라 이슈의 담당자를 직접 설정해줘야 하는 문제가 있었습니다. 이 부분을 자동화하지 않으면 워크플로우의 의미가 없다고 생각했어요. 그래서 이번엔 이슈 담당자 자동화를 해 보았습니다.</p>
<h3 id="이슈-템플릿에-assignee추가">이슈 템플릿에 Assignee추가</h3>
<p>먼저 이슈 템플릿에서 Assignee를 드롭박스에서 선택할 수 있도록 만들었어요. <a href="https://github.com/Moadong/moadong/blob/main/.github/ISSUE_TEMPLATE/jira-issue-form.yml">이슈 템플릿 링크</a></p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/c6d5362c-a1b8-4690-85d7-c2e042f4657f/image.png' width=450/>


<h3 id="터미널에서-accountid-찾기">터미널에서 accountId 찾기</h3>
<p>깃허브 Assignee에 나오는 이름은 깃허브 닉네임이에요. 이것은 Jira에서 호환이 안 되겠죠. Jira에서 담당자를 설정하려면 각 담당자의 <code>accountId</code>를 알아야 합니다. 터미널에서 아래 명령어를 입력하면 기본적으로 50명의 사용자의 정보를 반환합니다.</p>
<pre><code class="language-bash">curl -u &lt;email&gt;:&lt;api_token&gt; &quot;https://&lt;your-domain&gt;.atlassian.net/rest/api/3/user/search&quot;</code></pre>
<p>원래는 제일 뒤에 <code>/query=사용자이름</code>으로 찾으려 했지만 잘 안 되었어요. 한글 문제인가 싶어서 영어를 입력했는데도 404가 뜨더군요. 그래서 위 명령어를 입력해서 손수 노가다로 <code>accountId</code>를 찾았습니다.</p>
<h3 id="깃허브-지라-간의-매핑-테이블-만들기">깃허브 지라 간의 매핑 테이블 만들기</h3>
<p>위에서 찾은 <code>accountId</code>와 깃허브 Assignee를 매핑한 테이블을 만들 차례입니다. 이것은 지라 워크플로우에 들어갈 필수적인 요소에요.</p>
<pre><code class="language-json">{
  &quot;seongwon030&quot;: &quot;accountId1&quot;,
  &quot;oesnuj&quot;: &quot;accountId2&quot;,
  &quot;Zepelown&quot;: &quot;accountId3&quot;,
  &quot;Due-IT&quot;: &quot;accountId4&quot;,
  &quot;PororoAndFriends&quot;: &quot;accountId5&quot;,
  &quot;lepitaaar&quot;: &quot;accountId6&quot;,
}</code></pre>
<p>이렇게 json형식으로 만들어줍니다. accountId부분은 임의로 넣었어요.</p>
<h3 id="accountid-보안-위험">accountId 보안 위험</h3>
<p><code>accountId</code>를 깃허브에 노출하면 해당 accountId로 api요청 시 displayName이 노출될 우려가 있습니다. 사용자 실명 유추가 가능한 것이죠. 또한, 조직 내 특정 유저가 존재한다는 사실이 노출되어 피싱 공격이 가능합니다. </p>
<h3 id="repository-secret으로-설정">repository secret으로 설정</h3>
<p>repository -&gt; setting -&gt; Secrets -&gt; Secrets and variables -&gt; Actions 으로 이동한 다음, New repository secret 를 클릭합니다.
<img src='https://velog.velcdn.com/images/seongwon__105/post/994ddc73-16c7-4e8c-8748-20bbb0737a7f/image.png' width=450/></p>
<h3 id="기존-워크플로우-수정">기존 워크플로우 수정</h3>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/common-jira-create.yml#L75">common-jira-create.yml</a></p>
<pre><code class="language-yml">      - name: Map GitHub username to Jira accountId
        id: assignee
        run: |
          echo &#39;${{ secrets.JIRA_USER_MAP }}&#39; &gt; user_map.json
          FORM_ASSIGNEE=&quot;${{ steps.issue-parser.outputs.issueparser_assignee }}&quot;
          ACCOUNT_ID=$(jq -r --arg user &quot;$FORM_ASSIGNEE&quot; &#39;.[$user]&#39; user_map.json)

          echo &quot;Resolved accountId for $FORM_ASSIGNEE → $ACCOUNT_ID&quot;
          echo &quot;accountId=$ACCOUNT_ID&quot; &gt;&gt; $GITHUB_OUTPUT</code></pre>
<ol>
<li><code>secrets.JIRA_USER_MAP</code>이라는 JSON 문자열을 user_map.json으로 저장합니다.</li>
<li>GitHub 이슈 템플릿에서 추출한 assignee 값을 $FORM_ASSIGNEE에 저장합니다.</li>
<li>jq를 사용해서 해당 사용자명의 <code>Jira accountId</code>를 추출합니다.</li>
<li>GITHUB_OUTPUT에 accountId를 저장해서 다음 step에서 사용 가능하게 만듭니다.</li>
</ol>
<p>중요한 것은 Login 워크플로우 후에 해당 작업이 수행되어야 한다는 것입니다. 로그인을 하지 않는다면 accountId를 식별하지 못 할 것입니다.</p>
<h3 id="워크플로우-테스트">워크플로우 테스트</h3>
<p>먼저 이슈를 생성합니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/1af5e0b0-40fb-4144-8152-7f94c2c08dd0/image.png' width=450/>


<p>이제 지라에 이슈가 생성되었는지 확인합니다. 아래처럼 이슈가 잘 생성된 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/58e2a7d5-396d-4353-9e60-a0443b16a250/image.png" alt=""></p>
<p>마지막으로 담당자도 잘 할당되는 것을 볼 수 있습니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/a7f2212f-0c60-418d-ad64-1e80814ceaa5/image.png' width=300/>

]]></description>
        </item>
        <item>
            <title><![CDATA[[JIRA] Github 이슈 워크플로우]]></title>
            <link>https://velog.io/@seongwon__105/JIRA-Github-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%83%9D%EC%84%B1</link>
            <guid>https://velog.io/@seongwon__105/JIRA-Github-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%83%9D%EC%84%B1</guid>
            <pubDate>Sun, 01 Jun 2025 12:27:43 GMT</pubDate>
            <description><![CDATA[<h2 id="jira란">Jira란?</h2>
<p>Atlassian에서 개발한 프로젝트 관리 및 이슈 추적 도구로, 주로 소프트웨어 개발팀에서 사용됩니다. 애자일 방식에 유용하며, 칸반, 스크럼 등 다양한 방법론을 지원합니다. </p>
<h2 id="처음엔-그냥-">처음엔 그냥 ..</h2>
<p>프로젝트를 도중에 &#39;깃허브 프로젝트 기능보다 더 나은 도구가 없을까?&#39;하는 생각이 들었습니다. 깃허브 프로젝트는 TODO, INPROGRESS, DONE 세 가지로 프로젝트 진행 상황을 관리하며, 추가로 Milestone으로 스프린트 또한 관리할 수 있습니다.</p>
<p>깃허브 프로젝트를 도입하려던 찰나에 Jira라는 협업 도구가 제 눈에 띄었습니다. 애자일 프로세스에 유용하다는 것을 알게 되었고, 먼저 프론트엔드 이슈에 도입하였습니다. </p>
<p>사실 큰 이유는 없었어요. 저희 팀이 프론트엔드, 백엔드, 디자이너로 구성된 팀이었고 회의 때마다 각자의 진행상황을 공유했습니다. 하지만 실시간으로 각자의 진행상황을 파악하기 힘들었어요. 카카오톡으로 일일이 묻기 번거롭고, 계속 묻는 것도 서로에게 부담일 거니까요. 
개발자들은 디스코드 웹훅으로 어떤 이슈가 올라갔는지 실시간으로 알 수 있었지만, 작업들을 모아보기도 힘들었어요.</p>
<h2 id="fe만-워크플로우-만들기">FE만 워크플로우 만들기</h2>
<p>저는 여러 자료를 찾아보면서 깃허브 이슈와 지라 이슈를 연동하는 방법을 알게 되었습니다. 해당 워크플로우는 다음과 같습니다.</p>
<ol>
<li>깃허브에서 이슈를 생성한다.</li>
<li>Title과 Description을 작성한다.</li>
<li>FE-번호 형식에 맞게 티켓 넘버를 작성한다.</li>
<li>브랜치명을 적는다.</li>
<li>create를 한다.</li>
</ol>
<p>이렇게 하면 자동으로 브랜치명에 이슈 넘버와 티켓넘버가 붙도록 만들었어요. 예를 들면, FE-11 티켓넘버에 브랜치명은 <code>feature/add-login-ui</code>라고 가정했을 때 최종 브랜치명은 <code>feature/#깃허브이슈번호-add-login-ui</code>가 됩니다. 깃허브 이슈번호는 생성한 이슈에서 바로 가져오는 방식이에요.
해당 코드는 여기서 보실 수 있어요. <a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/create-jira-issue.yml">create-jira-issue.yml</a> </p>
<p>초반에는 백로그 없이 보드로만 작업을 했어요. <img src="https://velog.velcdn.com/images/seongwon__105/post/81936a39-b0ce-4d32-a558-27632a0376c9/image.png" alt=""></p>
<h3 id="장점">장점</h3>
<p>브랜치를 생성할 때 이슈넘버를 확인하지 않아도 되어 정말 편해졌습니다. 보드로 작업 진행도를 파악하기도 쉬워졌어요. </p>
<h3 id="단점">단점</h3>
<p>하지만 깃허브 프로젝트와 다를 게 없다는 생각이 들었습니다. 보드 그 이상의 기능을 활용할 수 없었어요. 모든 팀원이 사용해야 스프린트, 백로그의 의미가 생기고 모든 작업의 진행도를 파악할 수 있으니까요. </p>
<h2 id="드디어-이유가-생겼다">드디어 이유가 생겼다</h2>
<p>저번주에 갑자기 팀장님이 Jira를 사용하는 게 어떠냐고 모든 팀원들에게 물어보셨습니다. 부트캠프에서 여러 현직자분들을 만나셨는데, 거의 다 Jira를 사용한다는 얘기를 들었다고 하셨어요. 그래서 팀원 모두의 찬성으로 저희 팀 모두가 Jira를 사용하기로 했어요. </p>
<p>저희가 쓰던 FE프로젝트는 놔두고, 새로운 프로젝트를 만들었습니다. 새로운 프로젝트에도 이슈가 생성되도록 워크플로우를 추가했습니다.</p>
<h2 id="깃허브-jira-연동하기">깃허브 jira 연동하기</h2>
<p>기존의 워크플로우는 무조건 <code>develop-fe</code> 브랜치에서 분기되었었는데, 이제는 모든 브랜치에서 분기가 가능하도록 해야 합니다. 프론트엔드, 백엔드 모두가 써야하기 때문입니다. </p>
<h3 id="이슈템플릿-변경하기">이슈템플릿 변경하기</h3>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/ISSUE_TEMPLATE/jira-issue-form.yml">이슈 템플릿 링크</a>
<img src="https://velog.velcdn.com/images/seongwon__105/post/e421dfb1-01ab-4728-b3d7-623230abc5f9/image.png" alt=""></p>
<p>분기할 브랜치 선택 항목을 추가하여, 어떤 브랜치든 분기할 수 있도록 만들었어요.</p>
<h3 id="워크플로우-변경하기">워크플로우 변경하기</h3>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/common-jira-create.yml">common-jira-create.yml</a>
입력한 브랜치에 대해 브랜치를 분기하도록 변경하였습니다. 추가로 기존에 없는 브랜치라면 워크플로우를 미리 중단하도록 했어요. </p>
<h2 id="결과-확인하기">결과 확인하기</h2>
<p>깃허브 이슈를 생성하니 지라에도 이슈가 잘 생성되네요!
<img src="https://velog.velcdn.com/images/seongwon__105/post/d23e5f21-d0c8-48a5-b862-8b95b69e3038/image.png" alt=""></p>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/close-jira-issue.yml">close-jira-issue.yml</a> 이것은 깃허브 이슈 닫기 시 지라 이슈를 완료상태로 변경해주는 코드입니다.</p>
<h2 id="마치며">마치며</h2>
<p>팀원 모두가 Jira를 사용하게 되어 기쁩니다. 스프린트, 백로그, 회고까지 애자일 프로세스를 적극 경험해보고 싶네요. 사용자를 만나면서 백로그를 작업하는 경험이 제일 기다려지네요. 피드백 환영합니다~!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sentry with React]]></title>
            <link>https://velog.io/@seongwon__105/Sentry-with-React</link>
            <guid>https://velog.io/@seongwon__105/Sentry-with-React</guid>
            <pubDate>Fri, 16 May 2025 11:47:03 GMT</pubDate>
            <description><![CDATA[<h2 id="sentry란">Sentry란?</h2>
<p>Sentry는 어플리케이션에서 발생하는 에러를 자동으로 감지하고 추적합니다. 에러는 매일 감지되며 ip, 브라우저 정보, os정보 등 세부적인 정보를 제공합니다. </p>
<p><a href="https://sentry.io/welcome/">Sentry공식문서</a>에서 볼 수 있듯이 여러 언어, 프레임워크를 지원합니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/6a7818dd-1961-467e-afb6-ae7e8ea64197/image.png" alt=""></p>
<h2 id="왜-사용할까">왜 사용할까?</h2>
<p>지금 진행 중인 <a href="https://github.com/Moadong/moadong">moadong</a> 프로젝트가 얼마 전 사용자에게 배포되었습니다. 배포하고 나서 문득 그런 생각이 들었습니다. &quot;사용자에게 보이는 에러는 어떻게 방지하지?&quot; </p>
<p>로컬에서 발생한 에러는 개발자 눈에 보이기 때문에 바로 고칠 수 있습니다. 반면 사용자에게 에러가 났을 때 언제 에러가 발생했는지, 무슨 에러인지 파악할 수  없습니다. 
Sentry는 로컬 환경이 아니어도 배포 주소만 설정하면 사용자에게 뜨는 모든 에러를 추적할 수 있습니다. 더 세세한 에러에 대처하고, 사용자에게 최대한 편한 UI/UX를 제공하기 위해 저희 프론트엔드 팀은 Sentry를 적용하기로 하였습니다. </p>
<h2 id="적용하기">적용하기</h2>
<h3 id="시작하기">시작하기</h3>
<p><a href="https://github.com/Moadong/moadong">Sentry</a>에 먼저 접속해줍니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/f7ff0921-1264-4972-9777-83592070a173/image.png" alt=""></p>
<p>여기서 <code>GET STARTED</code> 를 눌러줍니다. </p>
<h3 id="로그인">로그인</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3973a508-b894-4961-82c1-b98480ab4248/image.png" alt=""></p>
<p>여기서 저는 깃허브 계정으로 로그인하였습니다.</p>
<h3 id="organization-만들기">Organization 만들기</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/fdc51916-834f-40ba-8d20-29c0cf491063/image.png" alt=""></p>
<p>Sentry내에서 쓸 Organization을 만듭니다. 이름은 자유롭게 지어주시면 됩니다.
Data Storage Location은 US로 설정해주었습니다. </p>
<h3 id="플랫폼-설정">플랫폼 설정</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/83ff182b-3b62-4d32-93bf-07cc94ffbb54/image.png" alt=""></p>
<p>React를 클릭해줍니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/ca96fb88-1896-4ea1-8e8b-f3c4ce15a538/image.png" alt=""></p>
<p>아래 설정은 자유롭게 해주세요. 그 다음 Create Project를 합니다.</p>
<h3 id="라이브러리-설치">라이브러리 설치</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/0158ff06-e1aa-49a8-a17d-882b3f660866/image.png" alt=""></p>
<p>위 사진처럼 자신의 프로젝트 폴더에서 <code>npm install --save @sentry/react</code>를 합니다.</p>
<h3 id="sdk-설정">SDK 설정</h3>
<p><strong>주의</strong> : dsn에 나와있는 key는 자신의 sentry 프로젝트에 부여된 고유한 key이기 때문에 노출하면 안 됩니다. 
해당 키를 .env파일에 설정해 주세요. </p>
<p>추가로 제 프로젝트에서는 여러 sdk를 사용하고 있었기에 <code>initSDK.ts</code> 파일을 따로 생성한 다음 각각 함수로 묶어주었습니다.</p>
<pre><code class="language-typescript">// initSDK.ts
export function initializeSentry() {
  if (process.env.NODE_ENV === &#39;development&#39;) {
    return;
  }

  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    sendDefaultPii: false,
    release: process.env.SENTRY_RELEASE,
    tracesSampleRate: 0.1,
  });
}

// index.tsx
import {
  initializeSentry,
} from &#39;./utils/initSDK&#39;;

initializeSentry();

const root = ReactDOM.createRoot(
  document.getElementById(&#39;root&#39;) as HTMLElement,
);
root.render(&lt;App /&gt;);</code></pre>
<h3 id="error-테스트">error 테스트</h3>
<p>로컬에서 에러를 테스트하기 위해 먼저 <code>if (process.env.NODE_ENV === &#39;development&#39;)</code> 를 주석처리 해 둡니다. </p>
<p>그리고 나서 임시로 error 코드를 작성합니다.</p>
<pre><code class="language-typescript">import React from &#39;react&#39;;
import * as Styled from &#39;./Footer.styles&#39;;

const Footer = () =&gt; {
  return (
    &lt;&gt;
      &lt;Styled.FooterContainer&gt;
        &lt;Styled.Divider /&gt;
        &lt;Styled.FooterContent&gt;
          &lt;Styled.PolicyText&gt;개인정보 처리방침&lt;/Styled.PolicyText&gt;
          &lt;Styled.CopyRightText&gt;
            Copyright © moodong. All Rights Reserved
          &lt;/Styled.CopyRightText&gt;
          &lt;Styled.EmailText&gt;
            e-mail:{&#39; &#39;}
            &lt;a href=&#39;mailto:pknu.moadong@gmail.com&#39;&gt;pknu.moadong@gmail.com&lt;/a&gt;
            &lt;button
              onClick={() =&gt; {
                throw new Error(&#39;test&#39;);
              }}
            &gt;
              test
            &lt;/button&gt;
          &lt;/Styled.EmailText&gt;
        &lt;/Styled.FooterContent&gt;
      &lt;/Styled.FooterContainer&gt;
    &lt;/&gt;
  );
};

export default Footer;</code></pre>
<p>당연하게도 로컬에서는 에러가 발생할 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/e6efbe81-98f9-4b49-b4bb-0739bf816279/image.png" alt=""></p>
<p>예상대로 에러가 잘 나옵니다. 그럼 이제 Sentry에 해당 에러가 추적되었는지 확인해봅시다.</p>
<h3 id="sentry-이벤트-화면">Sentry 이벤트 화면</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/99ba8f4c-2e40-426c-b4f0-6b3e1264f0f1/image.png" alt=""></p>
<p>이벤트 목록에서 에러가 잘 뜨는 것을 확인할 수 있습니다. <img src="https://velog.velcdn.com/images/seongwon__105/post/403d5cf0-c574-4a01-bb1f-67b9fbe7a8e1/image.png" alt=""></p>
<p>이벤트 수, 유저 수를 보여줍니다. 브라우저 정보, 릴리즈(개발 및 배포 환경), URL, environment(어떤 환경에서 발생했는지) 등을 볼 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/39b7e2e4-33d0-4e95-85de-c2314ee59ec4/image.png" alt="">
여기선 어떤 동작이 에러를 발생시켰는지 볼 수 있습니다. 문자가 이상하게 나오는 것은 소스맵 문제입니다. 해당 내용도 추후에 추가할 예정입니다.</p>
<h3 id="배포-서버-설정">배포 서버 설정</h3>
<p>저는 netlify에서 배포하고 있었기 때문에 deploy settings의 environment에 새로운 variables로 <code>dsn</code>을 설정해주었습니다. 환경변수를 추가하지 않으면 배포 사이트 에러를 추적할 수 없기에 꼭 해주셔야 합니다.</p>
<h2 id="디스코드-웹-훅-연동">디스코드 웹 훅 연동</h2>
<p>저희 팀은 디스코드와 깃허브를 연동하여 작업 시 활발하게 사용 중이었습니다. sentry에도 웹 훅 연동 기능이 있어서 겸사겸사 연동을 해 보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3aae4e4a-9316-410e-beeb-a7acc03dbe77/image.png" alt=""></p>
<p> <code>organization settings -&gt; Integrations -&gt; Discord</code> 순입니다.</p>
<p> <img src="https://velog.velcdn.com/images/seongwon__105/post/0d134138-ed37-45b2-915c-c3b630e58d52/image.png" alt=""></p>
<p>Add Installation을 누르면 아래와 같은 창이 나옵니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/d7264a7b-ffca-4be9-a776-5b56ad9c2bba/image.png' width=400 />

<p>여기서 프로젝트 Discord를 선택하고 승인을 눌러줍니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/57e18d64-f043-4626-8048-5f3fb90b5c88/image.png" alt=""></p>
<p>승인이 완료되면 알람 세팅을 할 수 있습니다. 어차피 개발 서버에서 실행되지 않기에 All Environments로 설정해 줍니다.
저는 간단하게  이슈 생성 시(에러 발생)에 알람을 주도록 하였습니다. </p>
<p>두번째 설정은 자유롭게 해 주시면 됩니다. 중요한 것은 세 번째 설정인데, 여기서 알림을 받을 디스코드 채널 Id를 입력해야 합니다. <img src="https://velog.velcdn.com/images/seongwon__105/post/51cd7397-e83b-4814-bee6-f1bedaa2b7f1/image.png" alt=""></p>
<p>나머지 설정도 완료한 다음 Save Rule을 합니다. 이제 디스코드 알림이 오는지 확인하겠습니다. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/71d61193-28fa-4cc0-a0ca-a9a40752570b/image.png' width=400 />

<p>잘 오는 것을 볼 수 있습니다. Assign은 해당 에러를 팀원에게 할당하는 기능입니다.
에러를 해결했다면 Resolve를 합니다.</p>
<h2 id="마치며">마치며</h2>
<p>이상으로 Sentry 설정과 디스코드 연동까지 해 보았습니다. 지금까지 에러는 개발자에게만 일어나는 것이라 생각했습니다. 사용자에게 배포하고 나서 에러에 대한 시각이 더 넓어진 느낌이 들었습니다. 이제부터 사용자에게 불편함 없이 서비스를 제공하기 위해 한 층 더 나아가 보려고 합니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[타입스크립트 keyof typeof ]]></title>
            <link>https://velog.io/@seongwon__105/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-keyof-typeof</link>
            <guid>https://velog.io/@seongwon__105/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-keyof-typeof</guid>
            <pubDate>Sun, 27 Apr 2025 07:29:52 GMT</pubDate>
            <description><![CDATA[<h2 id="사용법">사용법</h2>
<blockquote>
<p>객체를 선언하고 그 key를 타입으로 쓰고 싶다면 무조건 <code>as const</code>를 붙이고 <code>keyof typeof</code>를 쓴다.</p>
</blockquote>
<h2 id="1-버튼-타입-제한">1. 버튼 타입 제한</h2>
<pre><code class="language-typescript">const BUTTON_TYPES = {
  primary: &#39;Primary Button&#39;,
  secondary: &#39;Secondary Button&#39;,
  danger: &#39;Danger Button&#39;,
} as const;

type ButtonType = keyof typeof BUTTON_TYPES;
// ButtonType = &#39;primary&#39; | &#39;secondary&#39; | &#39;danger&#39;

interface ButtonProps {
  type: ButtonType;
}

const Button = ({ type }: ButtonProps) =&gt; {
  return &lt;button&gt;{BUTTON_TYPES[type]}&lt;/button&gt;;</code></pre>
<ul>
<li>버튼 타입 prop은 &#39;primary&#39; | &#39;secondary&#39; | &#39;danger&#39;만 가능하다.</li>
<li>실수로 <code>&#39;Third&#39;</code> 를 넣으면 컴파일 에러가 난다.</li>
</ul>
<h2 id="2-api-요청-타입-강제">2. API 요청 타입 강제</h2>
<pre><code class="language-typescript">const API_ENDPOINTS = {
  getUser: &#39;/api/user&#39;,
  getPosts: &#39;/api/posts&#39;,
  getComments: &#39;/api/comments&#39;,
} as const;

type ApiType = keyof typeof API_ENDPOINTS;
// &#39;getUser&#39; | &#39;getPosts&#39; | &#39;getComments&#39;

function fetchApi(api: ApiType) {
  return fetch(API_ENDPOINTS[api]);
}</code></pre>
<ul>
<li><code>fetchApi(&#39;getPosts&#39;)</code> 처럼 사용한다.</li>
<li>없는 API 이름 쓰면 에러 발생.</li>
</ul>
<h2 id="3-테마-설정">3. 테마 설정</h2>
<pre><code class="language-typescript">const THEMES = {
  light: &#39;Light Mode&#39;,
  dark: &#39;Dark Mode&#39;,
  system: &#39;System Default&#39;,
} as const;

type ThemeType = keyof typeof THEMES;
// &#39;light&#39; | &#39;dark&#39; | &#39;system&#39;

function setTheme(theme: ThemeType) {
  console.log(`Setting theme to ${THEMES[theme]}`);
}</code></pre>
<ul>
<li><code>setTheme(&#39;light&#39;)</code> or <code>setTheme(&#39;dark&#39;)</code> </li>
</ul>
<h2 id="4-key-매핑해서-타입-변환하기">4. key 매핑해서 타입 변환하기</h2>
<blockquote>
<p>키를 이용해 새로운 타입 만들기</p>
</blockquote>
<pre><code class="language-typescript">const THEMES = {
  light: &#39;Light Mode&#39;,
  dark: &#39;Dark Mode&#39;,
  system: &#39;System Default&#39;,
} as const;

type ThemeValue = {
  [K in keyof typeof THEMES]: string;</code></pre>
<pre><code class="language-typescript">{
  light: string;
  dark: string;
  system: string;
}</code></pre>
<p>➡️ <code>ThemeValues</code> 타입의 모습입니다. 
객체의 키들을 순회하며 새로운 타입을 만들 수 있으며, 이것을 <strong>Mapped Type</strong> 이라 합니다.</p>
<h2 id="5-키-값value에-따라-타입-달리하기">5. 키 값(value)에 따라 타입 달리하기</h2>
<pre><code class="language-typescript">const API_ENDPOINTS = {
  getUser: &#39;/api/user&#39;,
  getPosts: &#39;/api/posts&#39;,
  deletePost: null,
} as const;

// value가 null이면 optional, 아니면 required로
type ApiFunctions = {
  [K in keyof typeof API_ENDPOINTS]: type of API_ENDPOINTS[K] extends string
      ? () =&gt; Promise&lt;void&gt;
    : never;
};</code></pre>
<ul>
<li><code>extends</code>는 타입 상속과 타입 검사에 둘 다 사용됩니다. 여기서는 <code>string</code>인지 검사합니다.</li>
<li>&#39;getUser&#39;, &#39;getPosts&#39;는 () =&gt; Promise<void> 타입으로 매핑</li>
<li>&#39;deletePost&#39;는 never로 매핑</li>
</ul>
<h2 id="언제-사용할까">언제 사용할까?</h2>
<ul>
<li>정해진 키들만 쓰도록 할 때 </li>
<li>props 안전하게 하고 싶을 때</li>
<li>객체의 키들을 타입으로 안전하게 뽑아내고 싶을 때</li>
</ul>
<h2 id="string-리터럴-사용하면-안-되나">string 리터럴 사용하면 안 되나?</h2>
<pre><code class="language-typescript">type ColorKey = &#39;red&#39; | &#39;blue&#39; | &#39;green&#39;;</code></pre>
<p>-&gt; 리터럴로 직접 쓰기.</p>
<pre><code class="language-typescript">  const COLORS = {
  red: &#39;#ff0000&#39;,
  blue: &#39;#0000ff&#39;,
  green: &#39;#00ff00&#39;,
  yellow: &#39;#ffff00&#39;,  // 새로운 색 추가
} as const;</code></pre>
<p>여기서 만약 새로운 프로퍼티를 추가해야 하는 상황이라면?</p>
<pre><code class="language-typescript">// 기존
type ColorKey = &#39;red&#39; | &#39;blue&#39; | &#39;green&#39;;

// yellow 추가했으니까 여기도 수정해야 함
type ColorKey = &#39;red&#39; | &#39;blue&#39; | &#39;green&#39; | &#39;yellow&#39;;</code></pre>
<p>객체를 바꿀 때마다 타입도 수동으로 수정해야 합니다. 
  이것은 매우 비효율적입니다.</p>
<h3 id="keyof-typeof-쓰기">keyof typeof 쓰기</h3>
<pre><code class="language-typescript">type ColorKey = keyof typeof COLORS;  </code></pre>
<p>객체가 바뀌면 타입도 자동으로 따라갑니다.</p>
<h2 id="프로젝트에-적용해보기">프로젝트에 적용해보기</h2>
<ol>
<li>믹스패널 track 이벤트를 클릭함수 내에 적용해야 한다.</li>
<li>데스크탑 home 버튼과 모바일 home버튼에 각각 다른 이벤트를 적용해야 한다.</li>
</ol>
<h3 id="trackevent-타입">trackEvent 타입</h3>
<pre><code class="language-typescript"> const trackEventNames = {
  desktop: &#39;Home Button Clicked&#39;,
  mobile: &#39;Mobile Home Button Clicked&#39;,
} as const; </code></pre>
<h3 id="click-함수에-매개변수로-전달하기">Click 함수에 매개변수로 전달하기</h3>
<pre><code class="language-typescript">const handleHomeClick = (device: keyof typeof trackEventNames) =&gt; {
    navigate(&#39;/&#39;);
    setKeyword(&#39;&#39;);
    setInputValue(&#39;&#39;);
    trackEvent(trackEventNames[device]);
};</code></pre>
<h2 id="마치며">마치며</h2>
<p>프로젝트를 하면서 타입스크립트를 그때그때 배우고 있지만 항상 새롭습니다. 그래도 그때그때 배우는게 기억에 제일 잘 남는 것 같아요. 피드백은 언제나 환영입니다! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React의  intersection observer]]></title>
            <link>https://velog.io/@seongwon__105/React%EC%9D%98-intersection-observer</link>
            <guid>https://velog.io/@seongwon__105/React%EC%9D%98-intersection-observer</guid>
            <pubDate>Fri, 25 Apr 2025 16:40:56 GMT</pubDate>
            <description><![CDATA[<p>프로젝트에서 옆으로 넘어가는 카드 슬라이드를 구현하였습니다. 
슬라이드는 보통 옆으로 쭉 늘어뜨려 카드를 배열해 놓고 슬라이드 할 때마다 현재 카드만 보이도록 옆쪽에 위치한 카드를 의도적으로 가립니다.</p>
<p>즉, <strong>현재 보이는 카드 부분</strong>만 사용자에게 보여주면 된다는 것입니다. 
만약 모든 카드를 렌더링마다 불러오게 된다면, 사용자에게 보이지 않는 카드들 모두 가져오게 됩니다. 이는 불필요한 이미지 렌더링이 일어나게 되고 성능에도 문제가 발생합니다.</p>
<p>그래서 <strong>현재 카드 부분이 보일 때</strong>만 카드를 렌더링 하도록 하고 싶었습니다. 자바스크립트에서는<code>IntersectionObserver</code>라는 API를 제공합니다. <a href="https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver">MDN</a></p>
<p>해당 API로 이미지가 뷰포트에 들어왔을 시에만 이미지를 지연로딩하는 컴포넌트를 제작하였습니다. 참고로 리액트 + 타입스크립트 조합입니다.</p>
<h3 id="이미지-props">이미지 Props</h3>
<pre><code class="language-typescript">interface LazyImageProps {
  src: string;        // 로드할 이미지 주소
  alt: string;        // 이미지 설명 (접근성용)
  onError?: () =&gt; void; // 이미지 로드 실패 시 콜백
  index?: number;     // 리스트일 경우 지연 순서 지정
  delayMs?: number;   // 각 이미지 로딩 간의 지연 시간
}</code></pre>
<h3 id="상태-정의">상태 정의</h3>
<pre><code class="language-typescript">const [shouldLoad, setShouldLoad] = useState(false);
const [isVisible, setIsVisible] = useState(false);</code></pre>
<ul>
<li>shouldLoad : 이미지 로드를 시작할지 여부</li>
<li>isVisible : 이미지 렌더링 여부</li>
</ul>
<h3 id="ref">ref</h3>
<pre><code class="language-typescript">const imgRef = useRef&lt;HTMLImageElement | null&gt;(null);</code></pre>
<ul>
<li>dom 요소 추적으로 <code>IntersectionObserver</code>가 관찰할 수 있도록 함</li>
</ul>
<h3 id="뷰포트-감지">뷰포트 감지</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const observer = new IntersectionObserver(([entry]) =&gt; {
    if (entry.isIntersecting) {
      const delay = index * delayMs;
      const timeout = setTimeout(() =&gt; {
        setShouldLoad(true);
      }, delay);
      observer.disconnect();

      return () =&gt; clearTimeout(timeout);
    }
  }, { threshold: 0.1 });

  if (imgRef.current) {
    observer.observe(imgRef.current);
  }

  return () =&gt; observer.disconnect();
}, [index, delayMs]);</code></pre>
<ul>
<li>이미지가 뷰포트에 10% 이상 들어오면 -&gt; <code>threshold: 0.1</code></li>
<li><code>index * delayMs</code>만큼 기다렸다가 <code>setShouldLoad(true)</code> </li>
<li>이는 로딩이 시작되었다는 뜻입니다. 그렇다면 요소를 <strong>더 이상 관찰할 필요</strong>가 없기에 <code>observer.disconnect()</code>로 중지합니다.
(disconnect는 모든 요소, unobserve()는 특정요소입니다.)</li>
</ul>
<h3 id="이미지-로딩">이미지 로딩</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (shouldLoad) {
    setIsVisible(true);
  }
}, [shouldLoad]);</code></pre>
<h3 id="렌더링">렌더링</h3>
<pre><code class="language-typescript">return isVisible ? (
  &lt;img ref={imgRef} src={src} alt={alt} onError={onError} /&gt;
) : (
  &lt;div ref={imgRef} ... /&gt; // placeholder
);</code></pre>
<p>이미지가 아직 안 보이면 placeholder를 렌더링합니다. 
로드가 시작되면 진짜 이미지로 교체합니다.</p>
<h2 id="마치며">마치며</h2>
<p>제가 만든 컴포넌트는 뷰포트에 하나의 카드 + 두 번째 카드의 10분의 1의 정도 보이는 구조였습니다. 만약 뷰포트에 거의 하나의 요소만 들어간다면 <code>index * delayMs</code>를 추가하는 것은 필수는 아니라는 생각이 드네요. </p>
<p>결과적으로 카드 인덱스가 낮은 순으로 순차적 렌더링이 됨을 확인할 수 있었습니다. 원한다면 onError 함수에 fallback 이미지로 대체하여 에러 방지도 할 수 있습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[4월 회고]]></title>
            <link>https://velog.io/@seongwon__105/4%EC%9B%94-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@seongwon__105/4%EC%9B%94-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sat, 05 Apr 2025 18:04:20 GMT</pubDate>
            <description><![CDATA[<p>올해 1월부터 3월까지 저는 총 3개의 프로젝트를 진행해왔는데요. 함께 자라기에서 본 회고를 실천하기 위해 이제부터 자주 회고글을 작성할 생각입니다.</p>
<h2 id="keep">Keep</h2>
<ul>
<li>깃허브와 디스코드, jira 연동으로 변경사항 추적이 쉽고 간편해졌다.</li>
<li>사용한 기술에 대해 기록하는 습관을 들였다.</li>
<li>더 좋은 리뷰에 대해 고민하는 시간이 많다.</li>
<li>디자인 시스템인 스토리북을 도입하고 사용했다.</li>
<li>jest를 도입하고 단위테스트를 학습했다.</li>
<li>요구사항 개발에 대한 날짜를 정한 뒤 마감일을 지켰다.</li>
<li>연락을 자주 봄으로써 매끄럽게 개발을 진행했다.</li>
</ul>
<h2 id="problem">Problem</h2>
<ul>
<li>명확한 이유없이 jira를 도입했다.</li>
<li>디자인시스템 도입 후 제대로 활용하지 못했다.</li>
<li>pr에 리뷰요청을 한 뒤 개인적으로 연락을 한 적이 많았다. (컨벤션에 있던 1일을 기다리지 않고)</li>
<li>세 개의 프로젝트를 진행하면서 한 가지 일에 제대로 집중하지 못 했다.</li>
<li>회고없이 기능 개발과 회의만 진행했다.</li>
<li>새벽에 개발을 하면서 아침 시간을 잘 활용하지 못 했다. </li>
<li>급하게 merge해야 하는 상황에서 양질의 리뷰를 하지 못했다.</li>
<li>여러 가지 일을 하면서 스트레스 관리가 어려웠다.</li>
</ul>
<h2 id="try">Try</h2>
<ul>
<li>jira를 도입한 이유를 명확히 하고, 우리 팀에 필요한 것인지 조금 더 고민해본다.</li>
<li>리뷰 시간은 컨벤션대로 기다린다.</li>
<li>디자인 시스템을 활용해 디자이너와 협업한다.</li>
<li>늦어도 새벽 2시에는 잔다.</li>
<li>아침 9시 데일리 미팅에 늦지 않기.</li>
<li>사용한 기술에 대해 조금 더 깊은 공부를 해 본다. ex) tanstack-query </li>
<li>jest로 단위 테스트를 한다.</li>
<li>팀 역량을 끌어올리는 팀원이 되도록 노력한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드래빗 깃허브 연동]]></title>
            <link>https://velog.io/@seongwon__105/%EC%BD%94%EB%93%9C%EB%9E%98%EB%B9%97-%EA%B9%83%ED%97%88%EB%B8%8C-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@seongwon__105/%EC%BD%94%EB%93%9C%EB%9E%98%EB%B9%97-%EA%B9%83%ED%97%88%EB%B8%8C-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Wed, 19 Mar 2025 06:19:15 GMT</pubDate>
            <description><![CDATA[<p>코드 리뷰를 자동으로 해 주는 툴이 있다고 해서 적용해 보았는데요
<a href="https://www.coderabbit.ai/">CodeRabbit</a> 에 먼저 들어갑니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/bde9e7cf-e188-41a2-a0e9-6d6f5f8efbd2/image.png" alt=""></p>
<p>여기서 <code>Get a free trial</code>을 클릭합니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/74bda0f8-44d4-4a89-bde3-cddf0b83905a/image.png' width=400/>

<p>저는 github와 바로 연동하기 위해 <code>Sign up with Github</code>를 클릭해 주었습니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7e9a40dc-9530-4fae-95b6-325ebf797ccb/image.png" alt=""></p>
<p>깃허브 내에서 코드래빗을 적용하고 싶은 레포지토리를 연결해줍니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/0c33f593-379e-47e9-bf47-6dce36acc492/image.png' width=400/>


<p>연결이 완료되면 왼쪽 메뉴에 대시보드와 여러 세팅이 보입니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/a6469445-ad45-472a-af4e-039866a064f1/image.png" alt=""></p>
<p>여기서 Organization Settings -&gt; Configuration 으로 갑니다. 
여기서 Review Language를 Korean으로 설정한 다음 오른쪽 위 <code>Apply Changes</code>를 눌러주면 설정 완료입니다.</p>
<p>이제 PR에 코멘트를 달아주는지 확인해 봅시다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/ee8acfde-7065-4e02-a40a-d96430659a5b/image.png" alt=""></p>
<p>리뷰가 스킵되었네요. Review -&gt; Auto Review 설정을 해 준다면  자동을 리뷰를 해 줄 겁니다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/ca563fae-10b1-4ac1-b594-266e924464d5/image.png" alt=""></p>
<p>하지만 제 리뷰는 이미 스킵되었기 때문에 강제로 리뷰를 시켜야겠습니다. 위에 코멘트를 보시면 <code>@coderabbitai review</code>로 강제 리뷰를 할 수 있다고 합니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/e56263ec-776d-4ce8-9918-06c6402a0a4a/image.png" alt=""></p>
<p>Quote reply로 해당 명령을 치고 comment를 눌러줍니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/ee840dda-e39b-4f24-af80-17fb8127c514/image.png" alt=""></p>
<p>그러면 코드래빗이 pr에 올라간 커밋들을 모두 검사하여 리뷰를 달아줍니다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/385efb36-7393-4fdf-93cb-0875a5c60b72/image.png" alt=""></p>
<p>코멘트에 대한 답도 해 주는군요. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/21f33164-2ea4-4070-bc16-0b44b9f93aac/image.png" alt=""></p>
<p>시퀸스 다이어그램까지 만들어주네요. </p>
<h3 id="변경사항">변경사항</h3>
<p>pr 자동 리뷰가 되지 않은 건 설정을 빼 먹었기 때문입니다..  먼저 organization settings로 다시 갑니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/a3440bdb-73ca-471b-9b00-fa2be42d1962/image.png" alt=""></p>
<p>가장 아래 <strong>Base Branches</strong> 부분에 <code>.*</code> 를 입력하고 엔터를 누른 뒤 <code>apply  changes</code> 하면 전체 브랜치에 대한 pr에 자동으로 리뷰하겠다는 뜻입니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/9fc2625a-841e-44d4-b5b4-7ec1d6b16613/image.png" alt=""></p>
<p>만약 특정 브랜치에만 리뷰하도록 하려면 <code>feature/*</code> 같이 설정해주시면 됩니다.</p>
<p>또한, <code>coderabbit.yaml</code>로 설정하는 방법도 있습니다. 자신의 프로젝트 루트 폴더에 해당 파일을 추가하면 가능합니다.</p>
<pre><code class="language-yaml">language: &#39;ko-KR&#39;
early_access: false
reviews:
  profile: &#39;chill&#39;
  request_changes_workflow: false
  high_level_summary: true
  poem: false
  review_status: true
  collapse_walkthrough: false
  auto_review:
    enabled: true
    drafts: false
    base_branches:
      - &quot;/*&quot;
chat:
  auto_reply: true</code></pre>
<p>여기서 원하는대로 수정하여 사용하시면 됩니다!</p>
<h2 id="마치며">마치며</h2>
<p>자동 코드리뷰를 해 주는 것은 너무 편리한 기능인 것 같습니다. 하지만 ai가 언제나 정답일 수는 없기에 저희 팀에서는 팀원 간 리뷰 후 명령어를 사용하여 코드래빗으로 추가적인 리뷰를 받는 방식을 선택했습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React 무한 캐러셀 구현하기]]></title>
            <link>https://velog.io/@seongwon__105/React-%EB%AC%B4%ED%95%9C-%EC%BA%90%EB%9F%AC%EC%85%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/React-%EB%AC%B4%ED%95%9C-%EC%BA%90%EB%9F%AC%EC%85%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 28 Feb 2025 16:21:07 GMT</pubDate>
            <description><![CDATA[<p>저는 현재 학교에서 동아리 지원 관리 플랫폼을 제작 중입니다. 아래는 해당 프로젝트 깃허브 링크입니다.</p>
<blockquote>
<p><a href="https://github.com/Moadong/moadong">moadong</a></p>
</blockquote>
<p>이번에 메인페이지에 들어갈 캐러셀을 제작하게 되었습니다.<br>Typescript와 styled-component를 사용하였고 반응형과 무한 캐러셀 동작에 집중하였습니다. </p>
<h2 id="기능-요구사항">기능 요구사항</h2>
<ul>
<li>좌우 버튼을 눌러 배너를 슬라이드할 수 있어야 한다.</li>
<li>자동으로 3초마다 배너가 이동한다.</li>
<li>첫 번째 배너에서 이전 버튼을 누르면 마지막 배너로 이동해야 한다.</li>
<li>마지막 배너에서 다음 버튼을 누르면 첫 번째 배너로 이동해야 한다.</li>
<li>창 크기가 변경되면 슬라이드 크기가 자동 조정되어야 한다.</li>
</ul>
<h2 id="구현-과정">구현 과정</h2>
<h3 id="1-무한-루프를-위한-슬라이드-배열-확장">1. 무한 루프를 위한 슬라이드 배열 확장</h3>
<pre><code class="language-typescript">const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];</code></pre>
<h3 id="2-슬라이드-크기-동적-업데이트">2. 슬라이드 크기 동적 업데이트</h3>
<pre><code class="language-typescript">  const [currentSlideIndex, setCurrentSlideIndex] = useState(1); 

const updateSlideWidth = useCallback(() =&gt; {
  if (slideRef.current) {
    setSlideWidth(slideRef.current.offsetWidth);
    slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideRef.current.offsetWidth}px)`;
  }
}, [currentSlideIndex]);</code></pre>
<p><code>currentSlideIndex</code>는 현재 슬라이드 인덱스를 저장하고, <code>updateSlideWidth</code> 함수는 currentSlideIndex가 변경될 때마다 실행됩니다.</p>
<p>현재 슬라이드가 있다면 현재 슬라이드 width 값을 가져와 슬라이드 너비를 업데이트합니다. </p>
<h3 id="3-슬라이드-이동-로직">3. 슬라이드 이동 로직</h3>
<pre><code class="language-typescript">const [isAnimating, setIsAnimating] = useState(true);
const [isTransitioning, setIsTransitioning] = useState(false);

const moveToNextSlide = useCallback(() =&gt; {
  if (isTransitioning) return;
  setIsTransitioning(true);
  setIsAnimating(true);
  setCurrentSlideIndex((prev) =&gt; prev + 1);
}, [isTransitioning]);

const moveToPrevSlide = useCallback(() =&gt; {
  if (isTransitioning) return;
  setIsTransitioning(true);
  setIsAnimating(true);
  setCurrentSlideIndex((prev) =&gt; prev - 1);
}, [isTransitioning]);</code></pre>
<p><code>isAnimating</code>는 애니메이션 상태를 관리합니다.
<code>isTransitioning</code>는 슬라이드가 애니메이션 중인지 여부를 나타내는 상태입니다. </p>
<p><code>isTransitioning</code> 상태가 true라면 애니메이션 중이므로 중복 호출을 방지합니다. 그렇지 않다면 슬라이드 전환이 시작되었기 때문에 상태를 true로 바꿉니다. 슬라이드 인덱스 또한 업데이트 해 줍니다.</p>
<h3 id="4-무한-슬라이드-구현">4. 무한 슬라이드 구현</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (!slideRef.current) return;

  if (isAnimating) {
    slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideWidth}px)`;
  } else {
    if (currentSlideIndex === 1) {
      slideRef.current.style.transform = `translateX(-${slideWidth}px)`;
    } else if (currentSlideIndex === banners.length) {
      slideRef.current.style.transform = `translateX(-${banners.length * slideWidth}px)`;
    }
  }

  const transitionEndHandler = () =&gt; {
    if (currentSlideIndex === banners.length + 1) {
      setIsAnimating(false);
      setCurrentSlideIndex(1);
    } else if (currentSlideIndex === 0) {
      setIsAnimating(false);
      setCurrentSlideIndex(banners.length);
    }
    setIsTransitioning(false);
  };

  slideRef.current.addEventListener(&#39;transitionend&#39;, transitionEndHandler);
  return () =&gt; slideRef.current?.removeEventListener(&#39;transitionend&#39;, transitionEndHandler);
}, [currentSlideIndex, slideWidth, banners.length, isAnimating]);</code></pre>
<p><strong>📌 1. 기본적인 슬라이드 이동</strong></p>
<p>currentSlideIndex가 변경되면 useEffect가 실행됩니다.
<code>isAnimating이 true</code>이면 translateX(-currentSlideIndex * slideWidth)를 적용하여 슬라이드를 이동시킵니다.
transitionend 이벤트가 발생하면 transitionEndHandler를  실행합니다.</p>
<p><strong>📌 2. 무한 루프 효과</strong></p>
<p>마지막 배너(banners.length)에서 다음 슬라이드로 이동하면?
currentSlideIndex === banners.length + 1 → 첫 번째 배너(1번)로 이동합니다.</p>
<p>첫 번째 배너(1번)에서 이전 슬라이드로 이동하면?
currentSlideIndex === 0 → 마지막 배너(banners.length)로 이동하여 이를 통해 슬라이드가 처음과 끝을 무한히 반복하는 것처럼 보이게 만듭니다.</p>
<h3 id="5-자동-슬라이드-기능">5. 자동 슬라이드 기능</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const interval = setInterval(() =&gt; {
    moveToNextSlide();
  }, 3000);

  return () =&gt; clearInterval(interval);
}, [moveToNextSlide]);</code></pre>
<p>3초마다 자동으로 배너가 이동하도록 설정합니다. 컴포넌트가 언마운트될 때 clearInterval을 호출하여 메모리 누수를 방지합니다.</p>
<h2 id="전체-코드">전체 코드</h2>
<pre><code class="language-typescript">import React, { useRef, useState, useEffect, useCallback } from &#39;react&#39;;
import * as Styled from &#39;./Banner.styles&#39;;
import { SlideButton } from &#39;@/utils/banners&#39;;

export interface BannerProps {
  backgroundImage?: string;
}

interface BannerComponentProps {
  banners: BannerProps[];
}

const Banner = ({ banners }: BannerComponentProps) =&gt; {
  const slideRef = useRef&lt;HTMLDivElement&gt;(null);
  const [currentSlideIndex, setCurrentSlideIndex] = useState(1);
  const [slideWidth, setSlideWidth] = useState(0);
  const [isAnimating, setIsAnimating] = useState(true);
  const [isTransitioning, setIsTransitioning] = useState(false);

  const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];

  const updateSlideWidth = useCallback(() =&gt; {
    if (slideRef.current) {
      setSlideWidth(slideRef.current.offsetWidth);
      slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideRef.current.offsetWidth}px)`;
    }
  }, [currentSlideIndex]);

  useEffect(() =&gt; {
    updateSlideWidth();
    window.addEventListener(&#39;resize&#39;, updateSlideWidth);

    return () =&gt; {
      window.removeEventListener(&#39;resize&#39;, updateSlideWidth);
    };
  }, [updateSlideWidth]);

  useEffect(() =&gt; {
    if (!slideRef.current) return;

    if (isAnimating) {
      slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideWidth}px)`;
    } else {
      // 애니메이션 없이 즉시 이동
      if (currentSlideIndex === 1) {
        slideRef.current.style.transform = `translateX(-${slideWidth}px)`;
      } else if (currentSlideIndex === banners.length) {
        slideRef.current.style.transform = `translateX(-${banners.length * slideWidth}px)`;
      }
    }

    const transitionEndHandler = () =&gt; {
      if (currentSlideIndex === banners.length + 1) {
        setIsAnimating(false);
        setCurrentSlideIndex(1);
      } else if (currentSlideIndex === 0) {
        setIsAnimating(false);
        setCurrentSlideIndex(banners.length);
      }
      setIsTransitioning(false);
    };

    slideRef.current.addEventListener(&#39;transitionend&#39;, transitionEndHandler);
    return () =&gt; {
      slideRef.current?.removeEventListener(
        &#39;transitionend&#39;,
        transitionEndHandler,
      );
    };
  }, [currentSlideIndex, slideWidth, banners.length, isAnimating]);

  const moveToNextSlide = useCallback(() =&gt; {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setIsAnimating(true);
    setCurrentSlideIndex((prev) =&gt; prev + 1);
  }, [isTransitioning]);

  const moveToPrevSlide = useCallback(() =&gt; {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setIsAnimating(true);
    setCurrentSlideIndex((prev) =&gt; prev - 1);
  }, [isTransitioning]);

  useEffect(() =&gt; {
    const interval = setInterval(() =&gt; {
      moveToNextSlide();
    }, 3000);

    return () =&gt; clearInterval(interval);
  }, [moveToNextSlide]);

  return (
    &lt;Styled.BannerContainer&gt;
      &lt;Styled.BannerWrapper&gt;
        &lt;Styled.ButtonContainer&gt;
          &lt;Styled.SlideButton onClick={moveToPrevSlide}&gt;
            &lt;img src={SlideButton[0]} alt=&#39;Previous Slide&#39; /&gt;
          &lt;/Styled.SlideButton&gt;
          &lt;Styled.SlideButton onClick={moveToNextSlide}&gt;
            &lt;img src={SlideButton[1]} alt=&#39;Next Slide&#39; /&gt;
          &lt;/Styled.SlideButton&gt;
        &lt;/Styled.ButtonContainer&gt;
        &lt;Styled.SlideWrapper ref={slideRef} isAnimating={isAnimating}&gt;
          {extendedBanners.map((banner, index) =&gt; (
            &lt;Styled.BannerItem key={index}&gt;
              &lt;img
                src={banner.backgroundImage}
                alt={`banner-${index}`}
                style={{
                  width: &#39;100%&#39;,
                  height: &#39;100%&#39;,
                  objectFit: &#39;cover&#39;,
                }}
              /&gt;
            &lt;/Styled.BannerItem&gt;
          ))}
        &lt;/Styled.SlideWrapper&gt;
      &lt;/Styled.BannerWrapper&gt;
    &lt;/Styled.BannerContainer&gt;
  );
};

export default Banner;
</code></pre>
<h2 id="스타일링">스타일링</h2>
<pre><code class="language-typescript">import styled from &#39;styled-components&#39;;
import { BannerProps } from &#39;./Banner&#39;;

export const BannerContainer = styled.div`
  padding: 0 40px;
  max-width: 1180px;
  margin: 0 auto;
  width: 100%;

  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 90px;
  position: relative;

  @media (max-width: 500px) {
    margin-top: 42px;
    padding: 0;
  }
`;

export const BannerWrapper = styled.div&lt;BannerProps&gt;`
  position: relative;
  width: 100%;
  max-width: 1180px;
  height: auto;
  aspect-ratio: 1180 / 316;
  border-radius: 26px;
  overflow: hidden;
  background-color: transparent;
  ${({ backgroundImage }) =&gt;
    backgroundImage &amp;&amp;
    `
    background-image: url(${backgroundImage});
    background-size: cover;
    background-position: center;
    `}

  @media (max-width: 500px) {
    width: 100vw;
    border-radius: 0;
  }
`;

export const SlideWrapper = styled.div&lt;{ isAnimating: boolean }&gt;`
  display: flex;
  width: 100%;
  height: 100%;
  ${({ isAnimating }) =&gt;
    isAnimating
      ? &#39;transition: transform 0.5s ease-in-out;&#39;
      : &#39;transition: none;&#39;}
`;

export const BannerItem = styled.div`
  flex: none;
  width: 100%;
  height: 100%;
  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
`;
export const ButtonContainer = styled.div`
  position: absolute;
  width: 100%;
  top: 50%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  transform: translateY(-50%);
  z-index: 1;
`;

export const SlideButton = styled.button`
  width: 60px;
  height: auto;
  padding: 10px 20px;
  border: none;
  background-color: transparent;
  cursor: pointer;

  img {
    width: 100%;
    height: auto;
    object-fit: cover;
  }

  @media (max-width: 698px) {
    width: 35px;
    padding: 6px 12px;
  }


  @media (max-width: 375px) {
    width: 30px;
    padding: 4px 8px;
  }
`;
</code></pre>
<h2 id="구현-영상">구현 영상</h2>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/8680c5a2-7040-4ed8-a838-87782321d65b/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next js 시작하기 ]]></title>
            <link>https://velog.io/@seongwon__105/Next-js-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/Next-js-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 24 Feb 2025 09:16:34 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-많이-사용하는가">왜 많이 사용하는가?</h2>
<p>Library가 아닌 Framework 이기 때문입니다. </p>
<p>참고로 next는 React.js 전용 웹 개발 프레임워크이고, React는 라이브러리입니다.</p>
<h3 id="프레임워크-vs-라이브러리">프레임워크 vs 라이브러리</h3>
<blockquote>
<p>기능 구현의 주도권이 누구에게 있는가?</p>
</blockquote>
<p>주도권이 개발자에게 있다 -&gt; Library
주도권이 개발자에게 없다 → Framework</p>
<ul>
<li>Library<ul>
<li>어떤 기능을 구현할 때 제약없이 사용합니다.</li>
<li>Ex) React page routing에서 React router또는 tanstack을 사용합니다.</li>
</ul>
</li>
<li>Framework<ul>
<li>기능 구현 시 프레임워크가 자체적으로 제공하는 범위 내에서 사용 가능합니다.
  <img src="https://velog.velcdn.com/images/seongwon__105/post/292379c9-b1ba-44ec-8d28-fe5dd5617773/image.png" alt=""><h3 id="자유도">자유도</h3>
</li>
</ul>
</li>
</ul>
<blockquote>
<p>Next &lt; React</p>
</blockquote>
<p>자유도가 낮다고 하는 Next를 사용하는게 괜찮을까요? 너무 높아도 좋지 않습니다. 왜냐하면 주요 기능을 제외한 그 외의 모든 기능을 만들거나, 비슷한 기능을 하는 라이브러리를 직접 찾아야하기 때문입니다.
<img src='https://velog.velcdn.com/images/seongwon__105/post/13f61f98-2e4b-4382-8245-eac21a575ade/image.png' width=300 />
Next는 웹개발에 필요한 모든 기능이 제공됩니다. 추가로 React 확장판이라서 빠르게 배울 수 있습니다.</p>
<h2 id="사전렌더링">사전렌더링</h2>
<p>브라우저 요청에 사전에 렌더링이 완료된 HTML을 응답하는 렌더링 방식입니다. 이것은 CSR의 단점을 효율적으로 해결합니다.</p>
<h3 id="csr">CSR</h3>
<p>Client Side Rendering의 약자는 CSR은 React에 기본적인 렌더링 방식입니다. 클라이언트에서 직접 화면을 렌더링합니다.<img src="https://velog.velcdn.com/images/seongwon__105/post/fdaaa751-9cc8-4a6b-88fd-dd1c04fc1319/image.png" alt=""></p>
<p><strong>장점</strong></p>
<ul>
<li><p>페이지 이동이 매우 빠르고 쾌적합니다.    </p>
<ul>
<li>서버에서 브라우저로 JS Bundle 과정이 일어납니다. 여기엔 서비스에서 접근 가능한 모든 컴포넌트 코드가 존재합니다.</li>
<li>초기 접속 이후 페이지 이동하게 되더라도 서버에 새로운 페이지를 요청할 필요가 없어집니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/239c2d13-7580-409d-b412-627c405afc24/image.png" alt=""></li>
</ul>
</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>초기 접속 속도가 느립니다.<ul>
<li>초기 접속에서 실제로 화면 렌더링까지 오래 걸립니다.</li>
<li>컨텐츠 렌더링 전까지 index.html도 받아오고 js 번들 파일도 받아온 다음 js 실행까지 해야 합니다.</li>
</ul>
</li>
</ul>
<h3 id="fcpfirst-contentful-paint">FCP(First Contentful Paint)</h3>
<blockquote>
<p>&quot;요청 시작&quot; 시점으로부터 컨텐츠가 화면에 처음 나타나는데 걸리는 시간</p>
</blockquote>
<img src='https://velog.velcdn.com/images/seongwon__105/post/d8c8e08a-0f68-46e9-bb1c-91b995fd26b5/image.png' width=300/>

<h2 id="next의-렌더링">Next의 렌더링</h2>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/0640eb25-4cc9-48af-ab8a-61b24436fbbb/image.png" alt="">
여기서 렌더링은 두 가지 종류가 있습니다.</p>
<ul>
<li>JS실행(렌더링): JS코드를 HTML로 변환합니다.</li>
<li>화면에 렌더링: HTML코드를 브라우저가 화면에 그립니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3fb61efb-d8c1-4dea-99ff-f97ff0ea4813/image.png" alt=""></p>
<p>상호작용이란 html이 아닌 js가 처리하는 영역이기 때문에 FCP 시점에서는 상호작용이 불가능합니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/cfa72921-73ed-4a99-b2bc-69cd6c9a3fc7/image.png" alt=""></p>
<p>JS 번들링이 끝나고 브라우저에서 html을 연결하면, 상호작용이 가능한 페이지가 됩니다.</p>
<h2 id="수화hydration">수화(Hydration)</h2>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/b92dbeb2-07ec-4ce0-992c-8f3bc56ac257/image.png" alt=""></p>
<p>브라우저가 js번들링 후 html과 연결할 때, html에 js를 뿌리는 형태같다고 하여 수화(Hydration)이라 불립니다. 참고로 TTI는 Time To Interactive의 약자로 초기 요청에서 hydration까지의 시간을 말합니다.</p>
<h2 id="페이지-이동-요청">페이지 이동 요청</h2>
<blockquote>
<p>CSR과 같은 방식</p>
</blockquote>
<p>앞선 초기접속 요청과정에서 hydration을 위해 서버가 브라우저에 js 번들파일(React app)을 전달했기 때문에 사실상 리액트 컴포넌트를 미리 받아온 것입니다.</p>
<p><strong>사전렌더링</strong></p>
<p>서버측에서 js코드를 html로 미리 렌더링하는 사전렌더링을 통해 기존 CSR의 단점인 FCP를 개선하고, 페이지 이동은 CSR과 똑같이 동작합니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/29f0122c-e35f-4a34-b0e1-778c7f87d5c9/image.png" alt=""></p>
<h2 id="참고">참고</h2>
<blockquote>
<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs">한 입 크기로 잘라먹는 Next.js</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[리액트 자동스크롤 구현]]></title>
            <link>https://velog.io/@seongwon__105/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%9E%90%EB%8F%99%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@seongwon__105/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%9E%90%EB%8F%99%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Wed, 12 Feb 2025 13:14:50 GMT</pubDate>
            <description><![CDATA[<h2 id="figma-화면">Figma 화면</h2>
<img src='https://velog.velcdn.com/images/seongwon__105/post/75c57b38-9c10-460b-a221-30d5d956f1e0/image.gif' width=300/>


<p>지금 만들고 있는 동아리 지원 플랫폼에서 디자이너분이 주신 피그마 영상입니다. 이 페이지는 동아리 상세보기 페이지입니다. 개발하려고 하는 것은 모집정보, 동아리정보, 소개글, 활동사진이 있는 <strong>탭</strong>과 자동 스크롤입니다.</p>
<ol>
<li><p>탭 클릭 시 클릭된 메뉴의 아래 테두리가 검게 칠해진다.</p>
</li>
<li><p>탭을 클릭할 시 해당 컨텐츠로 자동 스크롤된다.</p>
</li>
</ol>
<p>이것이  구현할 때 필요한 기능입니다.</p>
<h2 id="메뉴tab">메뉴Tab</h2>
<p>모바일 화면이기 때문에 500px보다 클 때는 탭이 보이지 않도록 설정합니다. 총 4개의 버튼이 필요하기 때문에 width는 25%로 해 두었습니다.</p>
<h3 id="infotabstylests">InfoTab.styles.ts</h3>
<pre><code class="language-typescript">import styled from &#39;styled-components&#39;;

export const InfoTabWrapper = styled.div`
  display: none;
  position: fixed;
  margin-top: -40px;

  @media (max-width: 500px) {
    display: flex;
    flex-direction: row;
    width: 100%;
    height: 45px;
    background-color: white;
  }
`;

export const InfoTabButton = styled.button`
  width: 25%;
  border: none;
  border-bottom: 2px solid #cdcdcd;
  background-color: transparent;
  cursor: pointer;
  font-size: 14px;
  transition: border-bottom 0.3s ease;

  &amp;.active {
    border-bottom: 2px solid black;
  }
`;</code></pre>
<p>active로 클릭할 시에만 border-bottom을 black으로 설정할 겁니다. </p>
<p>active 클래스를 사용하려면 현재 선택된 탭이 무엇인지 알아야 합니다. 그러기 위해선 Tab의 상태를 관리하는 로직이 필요합니다.</p>
<h3 id="infotabtsx">InfoTab.tsx</h3>
<pre><code class="language-typescript">import React, { useState } from &#39;react&#39;;
import * as Styled from &#39;./InfoTabs.styles&#39;;

const tabLabels = [&#39;모집정보&#39;, &#39;동아리정보&#39;, &#39;소개글&#39;, &#39;활동사진&#39;];

const InfoTabs = ({ onTabClick }: { onTabClick: (index: number) =&gt; void }) =&gt; {
  const [activeTab, setActiveTab] = useState(0);

  const handleTabClick = (index: number) =&gt; {
    setActiveTab(index);
    onTabClick(index);
  };

  return (
    &lt;Styled.InfoTabWrapper&gt;
      {tabLabels.map((label, index) =&gt; (
        &lt;Styled.InfoTabButton
          key={label}
          className={activeTab === index ? &#39;active&#39; : &#39;&#39;}
          onClick={() =&gt; handleTabClick(index)}&gt;
          {label}
        &lt;/Styled.InfoTabButton&gt;
      ))}
    &lt;/Styled.InfoTabWrapper&gt;
  );
};

export default InfoTabs;</code></pre>
<p><code>useState</code>로 클릭한 버튼만 active상태가 되고, 다른 버튼은 비활성화되도록 하였습니다.</p>
<h2 id="자동-스크롤">자동 스크롤</h2>
<h3 id="useautoscrollts">useAutoScroll.ts</h3>
<pre><code class="language-typescript">import { useRef } from &#39;react&#39;;

const useAutoScroll = () =&gt; {
  const sectionRefs = useRef&lt;(HTMLDivElement | null)[]&gt;(
    new Array(4).fill(null),
  );

  const scrollToSection = (index: number) =&gt; {
    if (sectionRefs.current[index]) {
      const element = sectionRefs.current[index];
      const yOffset = -100;

      window.scrollTo({
        top: element.getBoundingClientRect().top + window.scrollY + yOffset,
        behavior: &#39;smooth&#39;,
      });
    }
  };

  return { sectionRefs, scrollToSection };
};

export default useAutoScroll;</code></pre>
<p>📌 useAutoScroll 훅 설명</p>
<ul>
<li><code>useRef</code>로 스크롤할 섹션들을 참조할 배열을 만듭니다. 배열 요소는 위에 만들어 두었던 탭의 메뉴들에 해당합니다.</li>
<li><code>scrollToSection</code>는 index를 받아서 해당 인덱스 섹션으로 스크롤되도록 합니다.</li>
<li>yOffset은 스크롤 위치를 조정하기 위해 추가했습니다.</li>
<li><code>element.getBoundingClientRect().top</code>는 현재 뷰포트 내에서 해당 요소의 상대적인 위치를 가져오는데,여기에 <code>window.scrollY</code>와 yOffset을 더하면 절대적인 화면 위치로 스크롤됩니다.</li>
</ul>
<h3 id="infoboxtsx">InfoBox.tsx</h3>
<pre><code class="language-typescript">import React from &#39;react&#39;;
import * as Styled from &#39;./InfoBox.styles&#39;;
import { InfoList } from &#39;@/types/Info&#39;;

const infoData: InfoList[] = [
  {
    title: &#39;모집정보&#39;,
    descriptions: [
      { label: &#39;모집기간&#39;, value: &#39;2025.02.28&#39; },
      { label: &#39;모집대상&#39;, value: &#39;재학생&#39; },
    ],
  },
  {
    title: &#39;동아리정보&#39;,
    descriptions: [
      { label: &#39;회장이름&#39;, value: &#39;xxx&#39; },
      { label: &#39;전화번호&#39;, value: &#39;010-1234-5678&#39; },
    ],
  },
];

const InfoBox = ({
  sectionRefs,
}: {
  sectionRefs: React.RefObject&lt;(HTMLDivElement | null)[]&gt;;
}) =&gt; {
  return (
    &lt;Styled.InfoBoxWrapper&gt;
      {infoData.map((info, index) =&gt; (
        &lt;Styled.InfoBox
          key={index}
          ref={(el) =&gt; {
            sectionRefs.current[index] = el;
          }}&gt;
          &lt;Styled.Title&gt;{info.title}&lt;/Styled.Title&gt;
          &lt;Styled.DescriptionContainer&gt;
            {info.descriptions.map((desc, idx) =&gt; (
              &lt;Styled.DescriptionWrapper key={idx}&gt;
                &lt;Styled.LeftText&gt;{desc.label}&lt;/Styled.LeftText&gt;
                &lt;Styled.RightText&gt;{desc.value}&lt;/Styled.RightText&gt;
              &lt;/Styled.DescriptionWrapper&gt;
            ))}
          &lt;/Styled.DescriptionContainer&gt;
        &lt;/Styled.InfoBox&gt;
      ))}
    &lt;/Styled.InfoBoxWrapper&gt;
  );
};

export default InfoBox;</code></pre>
<ul>
<li>반환값 타입을 <code>React.RefObject<T></code>로 하면 DOM요소에 접근할 수 있습니다. 특징은 current가 <code>readonly</code>라 직접 변경할 수 없다는 점입니다.<ul>
<li>배열에 <code>ref</code>를 동적으로 할당하면 클릭 시 해당 div로 이동합니다.</li>
</ul>
</li>
</ul>
<h3 id="clubdetailpagetsx">ClubDetailPage.tsx</h3>
<pre><code class="language-typescript">const ClubDetailPage = () =&gt; {
  const { sectionRefs, scrollToSection } = useAutoScroll();

  return (
    &lt;&gt;
      &lt;Header /&gt;
      &lt;InfoTabs onTabClick={scrollToSection} /&gt;
      &lt;Styled.PageContainer&gt;
        &lt;InfoBox sectionRefs={sectionRefs} /&gt;
        &lt;IntroduceBox sectionRefs={sectionRefs} /&gt;
      &lt;/Styled.PageContainer&gt;
      &lt;Footer /&gt;
    &lt;/&gt;
  );
};
export default ClubDetailPage;</code></pre>
<p> 이제 상세페이지로 돌아가서 <code>useAutoScroll</code>훅에서 sectionRefs를 가져옵니다. 스크롤되어야 하는 컴포넌트에 sectionRefs를 전달하면 완성입니다.</p>
<h2 id="최종화면">최종화면</h2>
<img src='https://velog.velcdn.com/images/seongwon__105/post/66d197e9-c523-4b20-baa8-32e83de57315/image.gif' width=300/>


]]></description>
        </item>
    </channel>
</rss>