<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>k-svelte-master.log</title>
        <link>https://velog.io/</link>
        <description>기부하면 코드 드려요</description>
        <lastBuildDate>Sat, 11 Apr 2026 07:36:31 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>k-svelte-master.log</title>
            <url>https://velog.velcdn.com/images/k-svelte-master/profile/3b42f169-8ba0-4881-9f8f-e4ccd21f0c2c/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. k-svelte-master.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/k-svelte-master" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[하네스 엔지니어링과 개발자의 미래]]></title>
            <link>https://velog.io/@k-svelte-master/vibecoding-is-good</link>
            <guid>https://velog.io/@k-svelte-master/vibecoding-is-good</guid>
            <pubDate>Sat, 11 Apr 2026 07:36:31 GMT</pubDate>
            <description><![CDATA[<h2 id="ai가-내-일을-다-한다는데-나는-뭘-해야-하지">AI가 내 일을 다 한다는데, 나는 뭘 해야 하지?</h2>
<p>AI가 코드를 짜고, 테스트를 돌리고, 심지어 커밋까지 합니다. &quot;LLM이 다 해준대요.&quot; 사장님은 바이브코딩으로 개발하라고 합니다. 이런 말들 사이에서 개발자로서 불안하지 않으면 거짓말이겠죠.</p>
<p>근데 저는 실제로 LLM과 함께 일하면서, 좀 다른 생각을 갖게 됐습니다. <strong>AI가 잘 달리려면 누군가는 길을 깔아줘야 한다는 것.</strong> 그리고 그 일이 꽤 재밌다는 것.</p>
<p>어떤 경험이었냐면요,</p>
<p>zustand이라는 라이브러리를 쓰고 있는데, Next.js 같은 SSR 환경에서는 전역 모듈로 활용하면 문제가 생깁니다. 전역변수처럼 다른 사용자의 데이터로 오염될 수 있는 여지가 있거든요. 그래서 zustand에서도 React Context와 조합해서, 사용자 요청별로 전역 객체를 생성하게 해서 오염을 격리하는 사용법이 있고, 저는 여기에 제가 원하는 규칙을 덧대어서 LLM한테 가이드를 하고 있었습니다.</p>
<p><strong>근데 문제는,</strong> zustand 문서의 예시가 CSR 기준으로 되어 있다 보니까, LLM이 많은 요청을 한번에 수행하면 제 지침을 누락하더라고요. 프롬프트에 &quot;제발 좀 지켜줘&quot;라고 넣어도, 모델 성능이 발전해도, 이 누락이 0이 되지 않았습니다.</p>
<p>그래서 eslint rule을 짰습니다. 컴파일 결과를 받고, 제가 원하는 형태로 zustand가 쓰이지 않았을 때 에러가 뜨게 했습니다. 덕분에 AI가 실수하는 일은 없어졌는데... <strong>실수하고 → 아차 하고 → 다시 고치고,</strong> 이게 토큰이 아까웠습니다. 무엇보다 한번에 끝낼 수 있었던 일을 두 번에 걸쳐서 하게 되니까요.</p>
<p>그래서 아예 라이브러리를 새로 만들었습니다. <strong>애초에 전역 모듈에서 import하는 사용법 자체를 막아버렸습니다.</strong> 그랬더니 LLM이 원하는 대로 한번에 움직이더라고요. 그거 아니면 사용할 수가 없으니까요.</p>
<p>이런 경험을 하면서 하네스 엔지니어링이라는 개념에 관심을 갖게 됐습니다.</p>
<h2 id="하네스-엔지니어링이-뭔데">하네스 엔지니어링이 뭔데?</h2>
<p>앤트로픽이 공식 엔지니어링 블로그에서 하네스 엔지니어링을 다루고 있습니다:</p>
<blockquote>
<p>&quot;모든 하네스 구성 요소는 모델이 단독으로 할 수 없는 것에 대한 가정을 인코딩한다&quot;
— <a href="https://www.anthropic.com/engineering/harness-design-long-running-apps">Harness design for long-running application development</a></p>
</blockquote>
<blockquote>
<p>&quot;모델의 성능을 기준선보다 훨씬 높이기 위해서는 프롬프트 엔지니어링과 하네스 디자인이 필수적이다&quot;
— <a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents">Effective harnesses for long-running agents</a></p>
</blockquote>
<h3 id="에이전트와-하네스의-차이">에이전트와 하네스의 차이</h3>
<p>이해를 돕기 위해, 에이전트와 하네스를 구분해보겠습니다.</p>
<p>최근 Claude Code 소스가 유출되면서 내부 구조가 공개됐는데(<a href="https://wavespeed.ai/blog/posts/claude-code-architecture-leaked-source-deep-dive/">분석 글</a>), 4,600개 이상의 파일, 512,000줄 규모의 코드에서 핵심 기술들이 드러났습니다.</p>
<p><strong>에이전트 기술</strong>은 LLM의 능력 자체를 올려주는 쪽입니다:</p>
<ul>
<li><strong>에이전트 루프</strong>: 1,729줄의 while(true) 루프 — 압축 → API 호출 → 에러 복구 → Hook 검증 → 도구 실행 → 상태 전이, 이 6단계 파이프라인을 계속 반복합니다</li>
<li><strong>4단계 메시지 압축</strong>: 컨텍스트 윈도우가 가득 차면 Snip → Microcompact → Context Collapse → Auto-Compact 순서로 대화를 압축해서 맥락을 유지합니다</li>
<li><strong>QueryEngine</strong>: 46,000줄 규모로 LLM API 호출, 스트리밍, 캐싱, 오케스트레이션을 전부 관리합니다</li>
<li><strong>코디네이터-워커 모델</strong>: 멀티 에이전트가 메일박스 패턴으로 위험한 작업을 승인받고 실행합니다</li>
</ul>
<p>이런 것들은 LLM이 더 똑똑하게, 더 오래, 더 정확하게 작업할 수 있도록 <strong>LLM 안쪽에서 접근하는 기술</strong>입니다.</p>
<p><strong>하네스 엔지니어링</strong>은 반대로 <strong>LLM 바깥에서 접근합니다.</strong> 앤트로픽 블로그의 실제 예시를 보면:</p>
<ul>
<li><strong>Feature List</strong>: 200개 이상의 세부 기능을 JSON으로 나열하고, 에이전트가 하나씩 완료 표시하게 합니다. 마크다운이 아니라 JSON인 이유는 변조 저항성이 높기 때문입니다</li>
<li><strong>Progress File</strong>: <code>claude-progress.txt</code>에 에이전트가 한 일의 로그를 기록합니다. 새 세션이 시작되면 이걸 읽고 맥락을 빠르게 파악합니다</li>
<li><strong>스프린트 계약</strong>: 코드를 작성하기 전에 &quot;완료&quot;가 뭔지 구체적 기준을 정합니다. 27개 같은 세부 기준으로 성공을 정의하고 나서야 구현에 들어갑니다</li>
<li><strong>Planner → Generator → Evaluator 구조</strong>: 계획하는 에이전트, 구현하는 에이전트, 평가하는 에이전트를 분리합니다. 평가자는 Playwright MCP로 실제 브라우저를 띄워서 사용자처럼 클릭하면서 검증합니다</li>
<li><strong>Init.sh</strong>: 새 세션에서 개발 서버를 자동으로 띄우고, 기본 테스트를 돌려서 환경이 정상인지 확인합니다</li>
</ul>
<p>앤트로픽이 직접 밝히는데, 이건 <strong>&quot;효율적인 소프트웨어 엔지니어가 매일 하는 일&quot;에서 영감을 받은 것</strong>이라고 합니다. 명확한 진행 상황 기록, 작은 단위의 커밋, 세션 간 문맥 전달 — 사람이 일을 잘하기 위해 하는 것들을 AI한테도 적용한 거죠.</p>
<p>차이가 보이시나요? 에이전트는 LLM을 직접 다루는 기술이고, 하네스는 <strong>LLM이 일을 잘할 수 있는 환경</strong>을 만드는 겁니다. 그리고 사람이 일할 때도 유용한 것들이에요. 진행 기록 남기고, 완료 기준 정하고, 세션 간 맥락 넘기는 건 사람한테도 좋은 습관이니까요.</p>
<h3 id="그런데-저는-여기서-조금-더-넓게-봅니다">그런데 저는 여기서 조금 더 넓게 봅니다</h3>
<p>앤트로픽이 말하는 하네스는 주로 특정 도메인의 지침 구축, 의사결정 노하우 정리, 에이전트 자율주행 환경 설계 — 이런 쪽입니다.</p>
<p>근데 LLM이 코드를 짤 때 뭘 쓰나요? React 쓰고, Vite 쓰고, Spring 쓰고, Next.js 쓰잖아요. <strong>LLM이 쓰는 도구를 만드는 것도 하네스 엔지니어링이라고 생각합니다.</strong> 라이브러리, 프레임워크, 개발 도구 — 종래의 개발자들이 해오던 이 일들이, AI 시대에 와서 보면 하네스 엔지니어링에 기여하고 있었던 거라고 봅니다.</p>
<h2 id="개발-도구를-만드는-것도-하네스-엔지니어링이다">개발 도구를 만드는 것도 하네스 엔지니어링이다</h2>
<h3 id="앤트로픽의-아이러니">앤트로픽의 아이러니</h3>
<p>올해 2월에 앤트로픽이 코볼 현대화 도구를 발표했습니다. &quot;수천 줄의 코볼 코드를 AI가 분석하고, 인간이 몇 달 걸릴 일을 신속하게 처리할 수 있다.&quot; IBM 주가가 하루 만에 13.2% 폭락할 만큼 임팩트가 컸습니다.</p>
<p>근데 아시나요? <strong>앤트로픽의 Claude 데스크톱 앱, Electron으로 만들어져 있습니다.</strong> 444MB짜리, 느리고 무겁기로 유명한 그 Electron입니다. 테크 블로거 John Gruber는 아예 <a href="https://daringfireball.net/linked/2024/10/31/anthropic-mac-app-electron-turd">&quot;Electron Turd&quot;</a>라고 불렀을 정도입니다.</p>
<p>AI가 개발자를 대체한다면서, 자기네 앱은 왜 Electron을 못 벗어날까요? <a href="https://www.dbreunig.com/2026/02/21/why-is-claude-an-electron-app.html">&quot;왜 Claude는 Electron 앱인가?&quot;</a>라는 글에서는 이렇게 분석합니다. <strong>&quot;에이전트는 개발의 처음 90%에는 뛰어나지만, 마지막 10% — 엣지 케이스 처리와 지속적 지원 — 은 여전히 어렵다.&quot;</strong> 결국 개발자 친숙도와 유지보수 단순성 때문에 기존 프레임워크를 쓸 수밖에 없다는 겁니다.</p>
<p>기존 도구와 프레임워크의 생태계는 그만큼 강력하고, AI가 등장했다고 쉽게 대체되는 게 아닙니다.</p>
<h3 id="llm의-학습-구조를-생각해보면-답이-나옵니다">LLM의 학습 구조를 생각해보면 답이 나옵니다</h3>
<p>LLM이 어떻게 발전해왔는지 한번 보겠습니다.</p>
<p>처음에는 <strong>다음 단어 예측기</strong>였습니다. A라는 문장 뒤에 B라는 문장이 올 확률을 학습하는 거였습니다. 그러다가 &quot;A라는 질문 → B라는 답변&quot;으로 바뀌었고, 지금은 &quot;A라는 요청 → B라는 행동&quot;으로 진화했습니다. 행동이라고 해도 결국 문자열이긴 한데, 잘 정의된 문자열을 에이전트가 파싱해서 프로그램을 실행하는 방식입니다.</p>
<p>&quot;웹사이트 만들어줘&quot;라고 하면 LLM이 뱉는 행동은 이런 식입니다:</p>
<ul>
<li>Vite로 프로젝트 생성</li>
<li>React 설치</li>
<li>컴포넌트 구성</li>
<li>...</li>
</ul>
<p>여기서 중요한 포인트가 있습니다. <strong>&quot;범용 AI&quot;라는 게 특수한 지식이 없다는 뜻이 아닙니다.</strong> 오히려 반대입니다. React로 웹 만드는 법, Spring으로 서버 구성하는 법, Next.js로 SSR 처리하는 법 — 이런 특수한 지식을 어마어마하게 학습했기 때문에 범용이 된 겁니다.</p>
<p>그러면 React 없이 순수 JavaScript로만 웹을 만들거나, Spring 없이 순수 Java로만 서버를 구성하게 학습시킬 수 있을까요? 이론적으로는 가능하겠지만, 그런 학습 데이터가 압도적으로 부족합니다. 자바로 웹 서버 만드는 사람 대부분이 Spring을 쓰니까요.</p>
<h3 id="프레임워크가-오히려-ai-성능을-올려줍니다">프레임워크가 오히려 AI 성능을 올려줍니다</h3>
<p>단순히 &quot;AI가 프레임워크를 벗어날 수 없다&quot;가 아닙니다. <strong>프레임워크가 AI의 성능을 끌어올려주는 존재</strong>입니다.</p>
<p>프레임워크가 코딩 규칙을 강제하면 → AI가 실수할 여지가 줄어듭니다.
리액트가 더 많은 API를 일관되게 제공하면 → AI가 더 빠르고 정확하게 코드를 짭니다.</p>
<p>잘 만든 프레임워크는 AI한테 <strong>가드레일이자 고속도로</strong>입니다. 규칙이 명확할수록, 인터페이스가 일관될수록, AI는 더 잘 달립니다.</p>
<p>그래서 라이브러리를 만들고, 프레임워크를 개선하고, 개발 도구를 설계하는 것 — 개발자들이 원래 해오던 이 일들이 AI 시대에 와서는 하네스 엔지니어링이 됩니다.</p>
<h2 id="그래서-우리는-뭘-할-수-있는가">그래서 우리는 뭘 할 수 있는가</h2>
<p>개발을 좋아하면서 AI와 상생하는 방법이 뭘까요?</p>
<p><strong>AI가 할 수 있는 일을 내가 직접 하는 게 아니라, AI가 할 수 없는 일을 할 수 있게 만드는 겁니다.</strong></p>
<p>생각해보면, 예전에는 이런 데 투자할 시간이 없었습니다. 매일 API 짜고, 테이블 설계하고, 화면 기획하고, 피그마 반영하고... 당장의 비즈니스 요구사항 처리하느라 시간이 없었거든요.</p>
<p>근데 이제 LLM한테 이런 일들을 위임할 수 있게 되면서, 드디어 시간이 생겼습니다. 그 시간에 뭘 할 수 있냐면:</p>
<p>AI가 우리 회사의 코딩 컨벤션을 따르도록 시스템 프롬프트를 설계하고, 특정 워크플로우를 정확히 수행하도록 에이전트를 구성하고, 실수할 수 없는 구조의 라이브러리를 만들고, 결과물을 검증하는 시스템을 구축하는 겁니다.</p>
<p>직접 코딩하는 사람에서, AI가 잘 일할 수 있는 환경을 만드는 사람으로. 개발자의 포지션이 바뀔 수 있다고 생각합니다.</p>
<h2 id="실제-사례들">실제 사례들</h2>
<h3 id="autobe--애초에-실수할-수-없는-구조">AutoBE — 애초에 실수할 수 없는 구조</h3>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/3b4b9f8c-65ed-4683-b00a-3e73785d435f/image.png" alt="AutoBe"></p>
<p><a href="https://autobe.dev">AutoBE</a>라는 오픈소스가 있습니다. LLM한테 코드를 직접 짜게 하는 대신, <strong>AST(추상 구문 트리)를 생성하게 합니다.</strong></p>
<p>LLM이 코드를 텍스트로 생성하면 문자열이 깨지거나 구문 오류가 생길 수 있습니다. 근데 AST로 생성하면 각 노드가 타입 규칙에 따라 검증되니까 <strong>애초에 컴파일 에러가 날 수가 없습니다.</strong> 실제로 100% 빌드 성공률을 달성하고 있습니다.</p>
<p>AI를 더 잘하게 만드는 게 아니라, <strong>AI가 실수할 수 없는 환경을 설계하는 것.</strong> 하네스 엔지니어링의 좋은 예시라고 생각합니다.</p>
<h3 id="flitter--바이브코딩으로-차트를-커스텀할-수-있다면">Flitter — 바이브코딩으로 차트를 커스텀할 수 있다면</h3>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/ff73b3f9-f6c1-4785-9eeb-87d19e91f968/image.png" alt="ui.flitter.dev"></p>
<p>LLM은 HTML을 꽤 잘 다룹니다. 근데 SVG나 Canvas로 원하는 인터랙션을 구현하라고 하면 갑자기 품질이 떨어집니다. 복잡한 그래픽 인터랙션 코드는 학습 데이터가 상대적으로 적으니까요.</p>
<p>저는 <a href="https://ui.flitter.dev">Flitter</a>라는 Canvas/SVG 렌더링 엔진을 만들었는데요, 이걸로 차트 라이브러리도 만들었습니다. 목표는 사람들이 바이브코딩만으로 원하는 부분을 커스텀할 수 있게 API를 제공하는 것이었습니다.</p>
<p>실제로 AI가 Flitter 코드를 꽤 잘 짭니다. 문서를 보다 보면 왜 AI가 잘 짜는지 알 수 있을 겁니다. 약간 꼼수를 부렸거든요.</p>
<h2 id="개발자의-역할은-사라지지-않습니다">개발자의 역할은 사라지지 않습니다</h2>
<p>LLM이 등장해서 개발자의 역할이 사라지는 게 아닙니다. 이동하는 겁니다.</p>
<p>매일 API 짜고, 테이블 설계하고, 피그마 반영하느라 정작 하고 싶었던 개발은 못 했잖아요. 이제 그 시간에 진짜 재밌는 걸 할 수 있게 된 겁니다. AI가 처음부터 하기 어려운 일을 쉽게 할 수 있도록 도구를 만들고, 실수할 수 없는 구조를 설계하고, 일관되게 높은 품질을 낼 수 있도록 환경을 조성하는 것.</p>
<p>오히려 LLM 덕분에 그동안 하고 싶었지만 못 했던 — 색다른 개발에 집중할 수 있게 된 거 아닐까요?</p>
<p>여러분은 어떤 하네스를 만들고 있나요?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[리액트에서 데코레이터 쓰기 — LLM이 못 해주는 클린코드]]></title>
            <link>https://velog.io/@k-svelte-master/react-decorator-clean-over-llm</link>
            <guid>https://velog.io/@k-svelte-master/react-decorator-clean-over-llm</guid>
            <pubDate>Tue, 17 Feb 2026 12:49:41 GMT</pubDate>
            <description><![CDATA[<h1 id="react에서-데코레이터-패턴으로-클린코드-만들기">React에서 데코레이터 패턴으로 클린코드 만들기</h1>
<p>요즘 LLM한테 &quot;게시글 CRUD 만들어줘&quot; 하면 5분이면 나오잖아요. 근데 그렇게 만든 코드로 3개월 버텨본 적 있으신가요? 제 경험상, 사람이 구조를 안 잡아주면 코드는 결국 산으로 가더라고요.</p>
<p>제 생각에 LLM이 아무리 발전해도 <strong>코드의 구조를 설계하는 건 여전히 사람의 몫</strong>인 것 같아요. LLM은 &quot;이렇게 짜줘&quot;라고 하면 잘 짜주는데, &quot;어떻게 짜야 하는지&quot;를 결정하는 건 못 하거든요. 적어도 아직까지는요.</p>
<p>그리고 저는 클린코드를 &quot;보기 좋은 코드&quot;보다는 <strong>변경에 강한 코드</strong>에 가깝다고 생각하는 편이에요. 이 감각은 이직 면접에서도 꽤 차이를 만들더라고요. &quot;상태관리 어떻게 하세요?&quot;라는 질문에 &quot;zustand이요&quot;가 아니라, 어떤 설계 원칙 위에서 관리하는지를 말할 수 있으면 인상이 확 달라지거든요.</p>
<p>오늘은 React에서 데코레이터 패턴이 어떻게 클린코드로 이어질 수 있는지, 제가 경험한 것들을 코드와 함께 공유해보려고 해요.</p>
<hr>
<h2 id="먼저-이-코드가-왜-문제인지부터">먼저, 이 코드가 왜 문제인지부터</h2>
<p>React에서 비동기 로직 짜면 이런 코드 많이 만들어보셨을 거예요.</p>
<pre><code class="language-ts">async function createPost(title: string) {
  try {
    const created = await api.post.create({ title })
    setPosts((prev) =&gt; [...prev, created])
    toast.success(&#39;작성 완료!&#39;)
    router.push(&#39;/posts&#39;)
  } catch (error) {
    toast.error(error instanceof Error ? error.message : &#39;실패했습니다&#39;)
    console.error(error)
  }
}

async function deletePost(postId: string) {
  try {
    await api.post.delete(postId)
    setPosts((prev) =&gt; prev.filter((p) =&gt; p.id !== postId))
    toast.success(&#39;삭제 완료!&#39;)
  } catch (error) {
    toast.error(error instanceof Error ? error.message : &#39;실패했습니다&#39;)
    console.error(error)
  }
}</code></pre>
<p>두 함수를 나란히 놓고 보면, 뭔가 이상하지 않나요?</p>
<p>에러 처리 코드가 똑같아요. <code>toast.error(...)</code> 부분이 그대로 복붙이죠. 성공 시 토스트 띄우는 패턴도 같고요. 함수가 2개일 때는 괜찮지만, 10개, 20개로 늘어나면 이 반복이 프로젝트 전체에 퍼져요. 나중에 에러 처리 방식을 바꾸고 싶으면? 20군데를 다 고쳐야 해요.</p>
<p>이렇게 <strong>여러 함수에 걸쳐서 반복되는 부가 로직</strong>을 &quot;횡단 관심사(Cross-Cutting Concern)&quot;라고 불러요. 에러 처리, 로깅, 권한 체크, 로딩 상태 관리 같은 것들이 대표적이에요.</p>
<p>각 함수의 <strong>핵심 비즈니스 로직</strong>은 분명히 다른데, 그 주변을 감싸는 부가 로직은 계속 똑같은 거죠. 문제는 이 둘이 한 함수 안에 뒤섞여 있다는 거예요.</p>
<p>백엔드, 특히 Spring을 써보신 분이면 여기서 바로 느낌이 오실 거예요. &quot;이거 AOP로 빼면 되는 거 아니야?&quot; <code>@Transactional</code>, <code>@Cacheable</code> 같은 어노테이션으로 횡단 관심사를 깔끔하게 분리하잖아요. 근데 프론트엔드에서는 이런 접근이 잘 안 보이죠. React 생태계에서는 보통 커스텀 훅으로 해결하려고 하는데, 훅은 컴포넌트 레벨의 관심사 분리이지 함수(액션) 레벨의 관심사 분리는 아니거든요.</p>
<p>오늘 이 문제를 데코레이터 패턴으로 풀어볼 건데, 그 전에 먼저 짚고 넘어가야 할 게 있어요. 데코레이터는 <strong>클래스 메서드</strong>에 붙이는 거잖아요. 그러면 React에서 상태와 액션을 클래스로 다루는 패턴부터 알아야 이야기가 이어져요.</p>
<hr>
<h2 id="상태와-액션을-클래스로-묶는다">상태와 액션을 클래스로 묶는다</h2>
<h3 id="react에서도-oop를">React에서도 OOP를?</h3>
<p>React 하면 함수형 컴포넌트, 훅, 불변 상태가 먼저 떠오르잖아요. 근데 <strong>상태 관리</strong> 영역에서는 OOP적 접근이 꽤 오래 전부터 쓰여왔어요.</p>
<p>MobX가 대표적이에요. 클래스에 상태를 정의하고, 메서드로 상태를 변경하는 패턴이죠.</p>
<pre><code class="language-ts">class PostStore {
  posts: Post[] = []

  async create(title: string) {
    const created = await api.post.create({ title })
    this.posts.push(created)
  }

  async delete(postId: string) {
    await api.post.delete(postId)
    this.posts = this.posts.filter((p) =&gt; p.id !== postId)
  }
}</code></pre>
<p>함수형으로 작성한 처음 코드와 비교해보면, 핵심 로직이 훨씬 깔끔하게 드러나죠? <code>this.posts</code>에 직접 push하고, 직접 filter하고. <code>setPosts((prev) =&gt; ...)</code> 같은 함수형 업데이트 패턴이 필요 없어요.</p>
<p>zustand도 비슷한 접근을 지원해요.</p>
<pre><code class="language-ts">const usePostStore = create((set, get) =&gt; ({
  posts: [],
  create: async (title: string) =&gt; {
    const created = await api.post.create({ title })
    set((s) =&gt; ({ posts: [...s.posts, created] }))
  },
}))</code></pre>
<p>valtio는 더 직관적이에요. proxy 기반이라 직접 mutation이 가능하거든요.</p>
<pre><code class="language-ts">const postState = proxy({
  posts: [] as Post[],
  async create(title: string) {
    const created = await api.post.create({ title })
    postState.posts.push(created)
  },
})</code></pre>
<p>이런 라이브러리들의 공통점이 보이시나요? <strong>상태(state)와 그 상태를 변경하는 액션(action)을 하나의 단위로 묶는 거예요.</strong> 데이터와 행동이 함께 있으니, 관련된 코드를 찾아 돌아다닐 필요가 없죠.</p>
<h3 id="근데-아직-문제가-남아있어요">근데 아직 문제가 남아있어요</h3>
<p>상태와 액션을 묶는 건 좋은데, 처음에 봤던 <strong>횡단 관심사 문제</strong>는 여전해요. MobX든 valtio든, 에러 처리를 하려면 결국 try-catch를 각 메서드 안에 넣어야 하거든요.</p>
<pre><code class="language-ts">class PostStore {
  posts: Post[] = []

  async create(title: string) {
    try {  // 👈 여전히 이게 필요해요
      const created = await api.post.create({ title })
      this.posts.push(created)
      toast.success(&#39;작성 완료!&#39;)
    } catch (error) {
      toast.error(error instanceof Error ? error.message : &#39;실패했습니다&#39;)
    }
  }
}</code></pre>
<p>상태와 액션은 클래스로 깔끔하게 묶었는데, 에러 처리·성공 알림·권한 체크 같은 부가 로직은 여전히 메서드 안에 섞여 있어요.</p>
<p><strong>여기서 데코레이터가 등장합니다.</strong> 클래스 메서드가 있으니까, 그 메서드에 데코레이터를 붙일 수 있거든요.</p>
<hr>
<h2 id="데코레이터-패턴-이게-뭔데">데코레이터 패턴, 이게 뭔데?</h2>
<h3 id="디자인-패턴으로서의-데코레이터">디자인 패턴으로서의 데코레이터</h3>
<p>데코레이터 패턴은 GoF 디자인 패턴 중 하나로, 핵심 아이디어는 이래요.</p>
<blockquote>
<p>원래 객체를 수정하지 않고, 감싸서 기능을 추가한다.</p>
</blockquote>
<p>커피를 생각해보면 직관적이에요. 아메리카노가 원래 객체라면, 시럽 추가, 샷 추가, 휘핑 추가는 각각 데코레이터예요. 아메리카노 자체를 바꾸는 게 아니라, 바깥에서 감싸서 기능을 얹는 거죠.</p>
<p>코드로 보면 이런 느낌이에요.</p>
<pre><code class="language-ts">// 원래 함수
async function createPost(title: string) {
  const created = await api.post.create({ title })
  posts.push(created)
}

// 데코레이터로 감싸기
const createPostWithErrorHandling = withErrorHandler(createPost)
const createPostFull = withSuccessToast(createPostWithErrorHandling, &#39;작성 완료!&#39;)</code></pre>
<p>원래 함수는 핵심 로직만 갖고 있고, 에러 처리와 성공 토스트는 바깥에서 감싸서 추가했어요. 원래 함수를 건드리지 않고 기능을 덧붙인 거죠.</p>
<h3 id="-문법-선언적으로-표현하기"><code>@</code> 문법: 선언적으로 표현하기</h3>
<p>이 패턴을 언어 차원에서 지원하는 게 데코레이터 문법이에요. <code>@</code> 기호를 사용해서 함수를 감싸는 걸 선언적으로 표현할 수 있죠.</p>
<pre><code class="language-ts">class PostStore {
  posts: Post[] = []

  @withSuccessToast(&#39;작성 완료!&#39;)
  @withErrorHandler
  async create(title: string) {
    const created = await api.post.create({ title })
    this.posts.push(created)
  }
}</code></pre>
<p>앞에서 클래스로 상태와 액션을 묶어뒀으니까, 이렇게 메서드 위에 <code>@</code>로 데코레이터를 얹을 수 있는 거예요. 위에서 함수를 감싸던 걸 <code>@</code> 문법으로 선언한 것뿐인데, <strong>읽는 사람 입장에서는 훨씬 직관적</strong>이죠. 메서드 본문을 읽기도 전에 &quot;이 메서드는 에러 처리가 되어 있고, 성공하면 토스트가 뜨는구나&quot;를 바로 알 수 있으니까요.</p>
<blockquote>
<p>💡 TC39 데코레이터 프로포절은 현재 Stage 3이고, TypeScript 5.0부터 사용할 수 있어요. <code>tsconfig.json</code>에서 <code>&quot;experimentalDecorators&quot;: true</code>로 켜면 됩니다.</p>
</blockquote>
<h3 id="데코레이터를-직접-만들어보면">데코레이터를 직접 만들어보면</h3>
<p>원리를 이해하기 위해 간단한 데코레이터를 직접 만들어볼게요.</p>
<pre><code class="language-ts">// 에러 핸들링 데코레이터
function OnError(handler: (error: unknown) =&gt; void) {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value

    descriptor.value = async function (...args: any[]) {
      try {
        return await original.apply(this, args)
      } catch (error) {
        handler(error)
        throw error
      }
    }

    return descriptor
  }
}</code></pre>
<p>별거 아니죠? 원래 메서드를 <code>try-catch</code>로 감싸는 새 함수로 바꿔치기하는 거예요. 핵심은 <strong>원래 메서드의 코드를 건드리지 않는다</strong>는 거예요. 감싸기만 할 뿐이죠.</p>
<p>이 원리를 알면, 어떤 횡단 관심사든 데코레이터로 만들 수 있다는 감이 와요.</p>
<hr>
<h2 id="comwit-이-패턴들을-하나로-묶은-라이브러리">comwit: 이 패턴들을 하나로 묶은 라이브러리</h2>
<p>앞에서 다룬 두 가지를 정리하면 이래요.</p>
<ol>
<li>상태와 액션을 클래스로 묶는다 (OOP적 상태관리)</li>
<li>횡단 관심사를 데코레이터로 분리한다</li>
</ol>
<p>저는 이 두 가지를 결합한 <strong>comwit</strong>이라는 상태관리 라이브러리를 만들었어요. 바이브코딩 플랫폼 서비스에서 내부적으로 쓰다가 최근에 오픈소스로 공개했습니다.</p>
<h3 id="action으로-액션-클래스를-정의한다">action()으로 액션 클래스를 정의한다</h3>
<p>comwit에서 액션은 <code>action()</code> 팩토리 함수로 정의해요.</p>
<pre><code class="language-ts">import { action, OnError, OnSuccess } from &#39;comwit&#39;

export const postActions = action&lt;Pick&lt;PostActions, &#39;create&#39; | &#39;delete&#39;&gt;, AppContext&gt;(
  ({ state, context }) =&gt; {
    class PostActions {
      private model = state(post)

      @OnSuccess(() =&gt; {
        toast.success(&#39;작성 완료!&#39;)
        context.router.push(&#39;/posts&#39;)
      })
      @OnError((e) =&gt; toast.error(e instanceof Error ? e.message : &#39;실패했습니다&#39;))
      async create(title: string) {
        const created = await api.post.create({ title })
        this.model.posts.data.push(created)
      }

      @OnSuccess(() =&gt; toast.success(&#39;삭제 완료!&#39;))
      @OnError((e) =&gt; toast.error(e instanceof Error ? e.message : &#39;실패했습니다&#39;))
      async delete(postId: string) {
        await api.post.delete(postId)
        this.model.posts.data = this.model.posts.data.filter((p) =&gt; p.id !== postId)
      }
    }
    return new PostActions()
  }
)</code></pre>
<p>앞에서 다뤘던 내용이 전부 여기에 녹아 있어요. 클래스 안에 상태(<code>this.model</code>)와 액션(<code>create</code>, <code>delete</code>)이 함께 있고, 횡단 관심사는 <code>@OnSuccess</code>, <code>@OnError</code> 데코레이터로 분리되어 있죠.</p>
<p><code>action()</code>은 <code>({ state, context })</code>를 인자로 받아요. <code>state</code>는 다른 도메인의 상태에 접근하는 함수이고, <code>context</code>는 라우터 같은 외부 의존성을 주입받는 곳이에요. <code>this.model = state(post)</code>로 상태 인스턴스를 가져오는 부분이 좀 특이한데, 이건 뒤에서 SSR 이야기할 때 다시 나올 거예요.</p>
<p><code>create</code> 메서드 본문에는 <strong>&quot;게시글 만들고 목록에 추가한다&quot;</strong>라는 핵심 로직만 남아 있어요. 성공하면 뭘 하는지, 에러나면 어떻게 처리하는지는 데코레이터가 선언적으로 말해주고 있죠. try-catch가 사라졌어요.</p>
<h3 id="내장-데코레이터들">내장 데코레이터들</h3>
<p>comwit이 제공하는 데코레이터들을 좀 더 살펴볼게요.</p>
<p><strong><code>@OnError(handler)</code></strong> — 에러 발생 시 handler를 실행해요. 에러는 그대로 전파됩니다.</p>
<pre><code class="language-ts">@OnError((e) =&gt; toast.error(e instanceof Error ? e.message : &#39;에러 발생&#39;))
async save() { /* ... */ }</code></pre>
<p><strong><code>@OnSuccess(handler)</code></strong> — 성공 시 handler를 실행해요.</p>
<pre><code class="language-ts">@OnSuccess(() =&gt; router.push(&#39;/list&#39;))
async create() { /* ... */ }</code></pre>
<p><strong><code>@Debounce(ms)</code></strong> — 검색 입력처럼 연속 호출을 제한할 때요.</p>
<pre><code class="language-ts">@Debounce(300)
async search(keyword: string) {
  await this.model.posts.query(keyword)
}</code></pre>
<p><strong><code>@Throttle(ms)</code></strong> — 스크롤 이벤트처럼 일정 간격으로만 실행하고 싶을 때요.</p>
<pre><code class="language-ts">@Throttle(1000)
async trackScroll(position: number) { /* ... */ }</code></pre>
<p><strong><code>@Authorized({ when, onDeny })</code></strong> — 권한 체크. 조건 실패 시 대체 동작을 실행합니다.</p>
<pre><code class="language-ts">@Authorized({
  when: () =&gt; Boolean(user.me),
  onDeny: () =&gt; router.push(&#39;/login&#39;),
})
async create(title: string) { /* ... */ }</code></pre>
<h3 id="데코레이터를-쌓으면-코드가-자기-자신을-설명한다">데코레이터를 쌓으면 코드가 자기 자신을 설명한다</h3>
<p>하나하나 보면 별것 아닌 것 같은데, <strong>이걸 조합해서 쌓을 수 있다는 게 진짜 포인트예요.</strong></p>
<pre><code class="language-ts">@LoginRequired
@OnSuccess(() =&gt; toast.success(&#39;작성 완료!&#39;))
@OnError((e) =&gt; toast.error(e.message))
async create(title: string) {
  const created = await api.post.create({ title })
  this.model.posts.data.push(created)
}</code></pre>
<p>메서드 본문을 읽기도 전에 이 메서드의 전체 흐름이 보여요. 로그인이 필요하고, 성공하면 토스트가 뜨고, 실패하면 에러 토스트가 뜨고, 핵심 로직은 게시글을 만들고 목록에 추가하는 것. <strong>코드가 자기 자신을 설명하는 거예요.</strong> 개인적으로 데코레이터의 가장 큰 가치가 여기에 있다고 생각해요.</p>
<h3 id="커스텀-데코레이터-팀의-규칙을-코드로">커스텀 데코레이터: 팀의 규칙을 코드로</h3>
<p>내장 데코레이터만으로 끝이 아니에요. <code>createInterceptor</code>로 <strong>나만의 데코레이터</strong>를 만들 수 있어요. Spring 커스텀 어노테이션이랑 비슷한 개념이에요.</p>
<p>예를 들어 &quot;로그인 필수&quot; 체크를 매번 <code>if (!this.user.me) return</code>으로 하고 있었다면:</p>
<pre><code class="language-ts">const LoginRequired = createInterceptor(({ state, context }) =&gt; {
  const u = state(user)
  return onAuthorized({
    when: () =&gt; Boolean(u.me),
    onDeny: () =&gt; context.router.push(&#39;/login&#39;),
  })
})</code></pre>
<p>이제 프로젝트 어디서든 <code>@LoginRequired</code> 한 줄로 끝이에요. <code>@AdminOnly</code>, <code>@RateLimited</code>, <code>@WithAnalytics</code> 같은 것도 같은 방식으로 만들 수 있고요. <strong>팀의 규칙을 코드로 표현하는 거죠.</strong></p>
<hr>
<h2 id="잠깐-statepost가-뭐예요">잠깐, state(post)가 뭐예요?</h2>
<p>위 코드에서 한 가지 눈에 걸리는 부분이 있었을 거예요.</p>
<pre><code class="language-ts">class PostActions {
  private model = state(post)
  // ...
}</code></pre>
<p>왜 <code>post</code>를 직접 import해서 쓰지 않고, 굳이 <code>state()</code>로 감싸서 가져올까요? 이게 좀 생뚱맞아 보일 수 있는데, 여기에 꽤 중요한 이유가 있어요. 그리고 이 이유를 알면, SSR에서 전역 상태를 다룰 때 왜 조심해야 하는지까지 이해할 수 있어요.</p>
<h3 id="모듈-스코프-상태의-함정">모듈 스코프 상태의 함정</h3>
<p>zustand의 일반적인 사용법을 볼게요.</p>
<pre><code class="language-ts">const useStore = create((set) =&gt; ({
  count: 0,
  increment: () =&gt; set((s) =&gt; ({ count: s.count + 1 })),
}))</code></pre>
<p><code>useStore</code>는 모듈 레벨에서 한 번 생성돼요. 클라이언트에서는 상관없죠. 브라우저 탭 하나에 사용자 하나니까요.</p>
<p>근데 <strong>SSR에서는 얘기가 달라져요.</strong> Node.js 서버는 모듈을 한 번 로드하고 모든 요청에서 공유하거든요. 사용자 A의 요청이 전역 스토어에 데이터를 넣으면, 바로 다음에 들어온 사용자 B의 요청이 그 데이터를 볼 수 있어요. 인증 토큰이나 개인 정보가 다른 사람에게 새어나갈 수 있는 거죠.</p>
<p>물론 zustand도 <code>createStore</code> + Context로 해결할 수 있고, jotai도 <code>Provider</code> 스코프로 나눌 수 있어요. 근데 핵심은 <strong>그렇게 안 해도 코드가 돌아간다는 거예요.</strong> 기본 사용법이 위험한 패턴을 허용하고, 안전한 패턴은 추가 설정으로 가야 하거든요.</p>
<h3 id="provider-스코프-설계로-실수를-막는다">Provider 스코프: 설계로 실수를 막는다</h3>
<p>comwit은 다른 접근을 해요. 상태 인스턴스를 모듈 레벨이 아니라 <strong>Provider 안에서 생성해요.</strong></p>
<pre><code class="language-tsx">&lt;StateProvider models={[post, user, comment]}&gt;
  {children}
&lt;/StateProvider&gt;</code></pre>
<p><code>model()</code>로 정의한 건 템플릿이지 인스턴스가 아니에요. &quot;설계도&quot;라고 보시면 돼요.</p>
<pre><code class="language-ts">// 이건 &quot;설계도&quot;예요. 상태가 아닙니다.
export const post = model({
  posts: query({ initialData: [], queryFn: () =&gt; api.post.findAll() }),
  current: null,
})</code></pre>
<p>실제 상태는 Provider가 마운트될 때 생성되고, 언마운트되면 사라져요. SSR에서 요청마다 Provider가 새로 마운트되니까, <strong>상태 오염이 구조적으로 불가능해지는 거예요.</strong></p>
<p>&quot;조심해서 쓰면 된다&quot;가 아니라 &quot;잘못 쓸 수가 없다&quot;에 가까운 거죠. 이 차이가 꽤 크다고 느꼈어요.</p>
<h3 id="그래서-statepost인-거예요">그래서 state(post)인 거예요</h3>
<p>이제 아까 질문의 답이 나와요. 모듈에서 <code>post</code>를 직접 import하면 그건 설계도예요. 현재 Provider 안에서 생성된 <strong>실제 인스턴스</strong>를 가져오려면 <code>state(post)</code>를 통해야 해요.</p>
<pre><code class="language-ts">class PostActions {
  // ❌ 이러면 모듈 레벨의 설계도를 가져옴
  // private model = post

  // ✅ 현재 Provider의 실제 인스턴스를 가져옴
  private model = state(post)
}</code></pre>
<p>Spring의 의존성 주입(DI)과 비슷한 개념이에요. 직접 <code>new</code>로 만들지 않고, 컨테이너(Provider)가 관리하는 인스턴스를 주입받는 거죠. 다른 도메인의 상태도 <code>state(user)</code> 이렇게 바로 접근할 수 있고요.</p>
<p>전역 상태처럼 어디서든 접근할 수 있으면서도, 생명주기는 Provider에 묶여 있어요. <strong>편의성과 안전성을 동시에 잡는 구조</strong>예요.</p>
<h3 id="ssr-하이드레이션은-silent로">SSR 하이드레이션은 silent()로</h3>
<p>서버에서 받은 초기 데이터를 클라이언트 상태에 넣을 때는 <code>silent()</code>를 써요.</p>
<pre><code class="language-ts">init(data: Post) {
  silent(() =&gt; {
    this.model.current = data
  })
}</code></pre>
<p><code>silent()</code> 안의 상태 변경은 리렌더를 트리거하지 않아요. 서버 컴포넌트에서 받은 데이터를 안전하게 주입할 수 있는 거죠.</p>
<pre><code class="language-tsx">function PostDetail({ initialPost }: { initialPost: Post }) {
  const { actions } = usePost((s) =&gt; ({ actions: s.actions }))
  actions.init(initialPost) // useEffect 없이 직접 호출해도 안전해요
  return &lt;PostView /&gt;
}</code></pre>
<hr>
<h2 id="도메인-단위-구조-프로젝트-수준의-클린코드">도메인 단위 구조: 프로젝트 수준의 클린코드</h2>
<p>데코레이터가 메서드 수준의 클린코드라면, 도메인 구조는 프로젝트 수준의 클린코드라고 할 수 있어요.</p>
<h3 id="왜-도메인-단위인가">왜 도메인 단위인가</h3>
<p>React 프로젝트를 처음 시작하면 보통 이렇게 나누잖아요.</p>
<pre><code>src/
  components/
  hooks/
  utils/
  types/
  api/</code></pre><p>이 구조의 문제는, 기능을 하나 수정하려면 여러 폴더를 돌아다녀야 한다는 거예요. 게시글 기능을 고치려면 <code>components/PostList.tsx</code>, <code>hooks/usePost.ts</code>, <code>types/post.ts</code>, <code>api/post.ts</code>를 다 열어야 하죠.</p>
<p>도메인 단위로 나누면 <strong>관련된 모든 것이 한 폴더에 모여요.</strong></p>
<pre><code>state/
  post/
    types.ts      ← 상태 + 액션 타입 (이게 계약서 역할이에요)
    model.ts      ← 초기 상태 + query 정의
    actions/
      crud.ts     ← 생성, 수정, 삭제
      load.ts     ← 데이터 페칭
      init.ts     ← SSR 하이드레이션
    index.ts      ← 훅 + re-export
  user/
    types.ts
    model.ts
    actions/
    index.ts</code></pre><p><code>types.ts</code> 하나를 열면 그 도메인이 뭘 하는지 전부 보여요. 어떤 상태를 가지고 있고, 어떤 액션이 가능한지. 이게 사람한테도 좋은데, <strong>LLM한테는 특히 효과가 큰 것 같아요.</strong> &quot;댓글 기능 추가해줘&quot; 했을 때 LLM이 타입 파일 하나만 읽으면 전체 맥락을 파악할 수 있거든요.</p>
<h3 id="query-데이터-페칭도-도메인에-녹인다">query: 데이터 페칭도 도메인에 녹인다</h3>
<p>재밌는 건 데이터 페칭이 상태 모델 안에 녹아있다는 거예요. 모델에서 <code>query()</code>로 필드를 선언하면:</p>
<pre><code class="language-ts">export const post = model({
  posts: query({
    initialData: [],
    queryFn: () =&gt; api.post.findAll(),
  }),
  current: null,
})</code></pre>
<p>액션에서는 <code>.query()</code>를 호출하기만 하면 돼요.</p>
<pre><code class="language-ts">async loadPosts() {
  await this.model.posts.query()
}</code></pre>
<p>이 한 줄이 실행되면 <code>isLoading</code>, <code>isFetching</code>이 자동으로 <code>true</code>가 되고, 성공하면 <code>isSuccess</code>가 켜지고 <code>data</code>에 결과가 들어가요. 에러나면 <code>isError</code>랑 <code>error</code>가 세팅되고요. 별도로 로딩 상태를 관리하는 코드를 짤 필요가 없어요.</p>
<pre><code class="language-tsx">const { posts } = usePost((s) =&gt; ({ posts: s.posts }))

if (posts.isLoading) return &lt;Skeleton /&gt;
if (posts.isError) return &lt;e&gt;{posts.error}&lt;/e&gt;
return posts.data.map((p) =&gt; &lt;PostCard key={p.id} {...p} /&gt;)</code></pre>
<p>TanStack Query 써보신 분이면 <code>staleTime</code>, <code>cacheTime</code>, <code>placeholderData</code> 같은 옵션도 익숙하실 거예요. 비슷한 인터페이스를 상태 모델 안에서 바로 쓸 수 있다는 게 포인트입니다.</p>
<hr>
<h2 id="마무리-설계는-사람이-구현은-llm이">마무리: 설계는 사람이, 구현은 LLM이</h2>
<p>오늘 네 가지를 살펴봤어요.</p>
<p><strong>상태와 액션을 클래스로 묶는 것.</strong> OOP적 상태관리로 데이터와 행동을 하나의 단위로 만들면, 관련 코드가 흩어지지 않아요.</p>
<p><strong>데코레이터로 횡단 관심사를 분리하는 것.</strong> try-catch, 권한 체크, 디바운스 같은 부가 로직을 핵심 비즈니스 로직에서 떼어내면, 코드가 자기 자신을 설명하기 시작해요.</p>
<p><strong>Provider 스코프로 SSR 안전성을 확보하는 것.</strong> 모듈 레벨 상태의 위험을 &quot;조심해서 쓰기&quot;가 아니라 &quot;구조적으로 막기&quot;로 해결하면, 실수할 여지 자체가 사라져요.</p>
<p><strong>도메인 단위로 코드를 조직하는 것.</strong> 기능별로 관련 코드를 모아두면 사람도 LLM도 맥락 파악이 빨라지고, 변경에 강한 구조가 돼요.</p>
<p>제 경험상, 이런 구조적인 판단은 LLM이 대신해주기 어려운 영역이에요. 사람이 설계하고, LLM은 그 안에서 코드를 채우는 거죠. 이 감각을 가지고 있으면 이직 시장에서도 분명 차이가 나지 않을까 생각합니다.</p>
<p>comwit은 지금 1000개 넘는 프로젝트에서 돌아가고 있어요. 관심 있으시면 직접 코드를 한번 까보세요.</p>
<p><strong>GitHub:</strong> <a href="https://github.com/meursyphus/comwit">https://github.com/meursyphus/comwit</a></p>
<p>Claude Code나 Cursor 쓰고 계시다면, LLM에게 아래 URL을 알려주고 &quot;이걸 읽고 세팅해줘&quot;라고 해보세요:</p>
<pre><code>comwit.io/llm.txt</code></pre><p>위에서 본 도메인 구조부터 데코레이터 패턴까지, 그대로 잡아줄 거예요.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 회사 가고 싶으면 이 UX 원리는 알아두세요]]></title>
            <link>https://velog.io/@k-svelte-master/ai-chatbot-ux</link>
            <guid>https://velog.io/@k-svelte-master/ai-chatbot-ux</guid>
            <pubDate>Sun, 07 Dec 2025 04:01:05 GMT</pubDate>
            <description><![CDATA[<p>Claude나 ChatGPT를 쓰다 보면 뭔가 다르다는 느낌 받아본 적 있으신가요?</p>
<p>일반 메신저랑 비슷하게 생겼는데, 쓰는 느낌은 다릅니다. 뭔가 답변을 읽기가 편해요. 화면이 넓게 느껴지고, 스크롤도 자연스럽습니다.</p>
<p>저도 처음엔 그냥 &quot;잘 만들었네&quot; 하고 넘어갔는데, 직접 AI 채팅 UI를 구현하면서 이게 우연이 아니라는 걸 알게 됐습니다.</p>
<p>AI 회사는 계속 늘어날 겁니다. 프론트엔드 개발자로서 이 UX 원리를 알아두면 면접에서든, 실무에서든 분명 써먹을 일이 생깁니다.</p>
<h2 id="일반-채팅-vs-ai-채팅-뭐가-다를까">일반 채팅 vs AI 채팅, 뭐가 다를까?</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/907f1938-818d-4280-a62b-8f7d00d8450d/image.png" alt="good"></p>
<p>일반 채팅은 <strong>쌍방향 소통</strong>입니다. 카카오톡이나 슬랙처럼 서로 주거니 받거니 하죠. 내가 메시지를 보내면 바로 그 위치로 스크롤되고, 상대방 답장이 오면 또 그 위치로 갑니다. 누가 질문자고 답변자인지 구분이 없어요.</p>
<p>AI 채팅은 다릅니다. <strong>질문자와 답변자가 명확히 나뉘어 있어요.</strong></p>
<p>내가 질문하면, AI가 길게 답변합니다. 그것도 스트리밍으로 주르륵 내려오죠. 이 상황에서 일반 채팅처럼 동작하면 어떻게 될까요?</p>
<p>답변이 화면 맨 아래에서 시작합니다. 시선이 계속 화면 하단에 고정돼요. AI 답변은 길어질 텐데, 고개 숙이고 화면 아래만 쳐다보면서 읽어야 합니다. 쾌적하지 않아요.</p>
<p>그래서 AI 채팅 UI는 다른 전략을 씁니다.</p>
<p><strong>&quot;공간을 미리 확보해서, AI 답변이 화면 상단에서 시작하게 한다.&quot;</strong></p>
<p>유저 메시지가 위로 올라가고, 그 아래 넓은 공간에서 AI 답변이 펼쳐지면 시선이 자연스럽게 위에서 아래로 흐릅니다. 긴 답변도 편하게 읽을 수 있어요.</p>
<h2 id="claude는-이걸-어떻게-구현했을까">Claude는 이걸 어떻게 구현했을까?</h2>
<p>직접 devtools로 까봤습니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/c8c3c797-0662-4f35-adf1-7950f6e5eabf/image.gif" alt="크롬 데브툴"></p>
<p>비밀은 <strong>여백</strong>에 있었습니다.</p>
<p>유저 메시지가 올라오면, 그 아래에 거대한 여백이 생깁니다. 이 여백 덕분에 스크롤할 공간이 확보되고, 유저 메시지를 화면 상단으로 밀어올릴 수 있는 거죠.</p>
<p>AI 응답이 스트리밍되면서 길어지면? 여백이 그만큼 줄어듭니다. 응답이 화면을 다 채우면 여백은 0이 되고, 그때부터는 일반적인 스크롤 동작을 합니다.</p>
<h2 id="핵심-원리-여백이-있어야-스크롤이-된다">핵심 원리: 여백이 있어야 스크롤이 된다</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/b2a4875c-8a00-41d4-913e-79327fe8199b/image.png" alt="hihihihi"></p>
<p>당연한 얘기 같지만, 이게 핵심입니다.</p>
<p>스크롤을 올려서 유저 메시지를 화면 상단에 보여주고 싶다고 해봅시다. 근데 콘텐츠가 화면보다 짧으면? 스크롤 자체가 안 됩니다. 올릴 게 없으니까요.</p>
<p>그래서 <strong>인위적으로 여백을 만들어야 합니다.</strong></p>
<p>여백 계산 공식은 간단해요:</p>
<pre><code>여백 = 뷰포트 높이 - (유저 메시지 높이 + AI 응답 높이)</code></pre><ul>
<li>AI 응답이 없을 때: 여백이 최대 → 유저 메시지가 상단에 고정됨</li>
<li>AI 응답이 길어질수록: 여백이 줄어듦</li>
<li>AI 응답이 화면을 다 채우면: 여백 = 0 → 일반 스크롤</li>
</ul>
<hr>
<h2 id="구현할-때-알아야-할-것들">구현할 때 알아야 할 것들</h2>
<h3 id="1-필요한-수치들">1. 필요한 수치들</h3>
<p>여백을 계산하려면 이 값들을 실시간으로 알아야 합니다:</p>
<ul>
<li><strong>뷰포트(컨테이너) 높이</strong>: ResizeObserver로 추적</li>
<li><strong>유저 메시지 높이</strong>: 렌더링 후 측정</li>
<li><strong>AI 응답 높이</strong>: 스트리밍 중 계속 변하므로 지속적으로 추적</li>
</ul>
<pre><code class="language-typescript">const footerMargin = useMemo(() =&gt; {
  const contentHeight = userMessageHeight + aiResponseHeight;
  return Math.max(0, containerSize.height - contentHeight);
}, [containerSize.height, userMessageHeight, aiResponseHeight]);</code></pre>
<h3 id="2-가상-스크롤에서의-타이밍-이슈">2. 가상 스크롤에서의 타이밍 이슈</h3>
<p>react-virtuoso 같은 가상 스크롤 라이브러리를 쓴다면 타이밍을 신경 써야 합니다.</p>
<p>가상 스크롤은 요소가 추가된다고 즉시 스크롤 위치가 계산되지 않아요. 내부적으로 높이 계산을 해야 하거든요.</p>
<p>그래서 <code>totalListHeightChanged</code> 같은 콜백을 활용해서, <strong>높이 계산이 끝난 후에</strong> 스크롤을 조작해야 합니다.</p>
<pre><code class="language-typescript">totalListHeightChanged={() =&gt; {
  const lastMessage = messages[messages.length - 1];
  if (!lastMessage) return;

  if (lastMessage.type === &quot;user&quot;) {
    // 유저 메시지: 상단으로 스크롤
    virtuosoRef.current?.scrollToIndex({
      index: &quot;LAST&quot;,
      align: &quot;start&quot;,
      behavior: &quot;smooth&quot;,
    });
  } else {
    // AI 응답: 하단으로 따라가기
    virtuosoRef.current?.scrollToIndex({
      index: &quot;LAST&quot;,
      align: &quot;end&quot;,
      behavior: &quot;auto&quot;,
    });
  }
}}</code></pre>
<h3 id="3-새-유저-메시지가-오면-리셋">3. 새 유저 메시지가 오면 리셋</h3>
<p>유저가 새 질문을 하면 AI 응답 높이를 리셋해야 합니다. 안 그러면 이전 대화의 높이가 누적돼서 계산이 꼬여요.</p>
<pre><code class="language-typescript">const resetAiResponseHeight = useCallback(() =&gt; {
  setAiResponseHeight(0);
  measuredHeightsRef.current.clear();
}, []);</code></pre>
<hr>
<h2 id="실제-구현-결과">실제 구현 결과</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/c8193f03-2a88-4fd9-8717-bee45caa9340/image.gif" alt="https://mvpstar.ai">
39-bde1-4db4f54f0a1a/image.png)</p>
<hr>
<h2 id="팁-스토리북으로-개발하세요">팁: 스토리북으로 개발하세요</h2>
<p>이런 UX를 실제 프로덕션 환경에서 개발하면 고통스럽습니다.</p>
<p>LLM 응답을 기다려야 하니까 토큰 소모도 심하고, 느리고, 답답해요. 스크롤 타이밍 하나 고치려고 몇 분씩 기다리는 건 비효율의 극치입니다.</p>
<p>스토리북으로 목 데이터를 넣고 UX만 따로 개발하세요. 스트리밍 응답도 시뮬레이션할 수 있고, 다양한 케이스를 빠르게 테스트할 수 있습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>정리하면:</p>
<ol>
<li>AI 채팅은 질문-답변 구조라서 일반 채팅과 다른 UX가 필요합니다</li>
<li>유저 메시지를 상단에 고정하려면 <strong>여백</strong>이 핵심입니다</li>
<li>여백 = 뷰포트 - (유저 메시지 + AI 응답)</li>
<li>가상 스크롤 쓸 때는 높이 계산 타이밍을 신경 쓰세요</li>
</ol>
<p>AI 회사 면접에서 &quot;채팅 UI 구현해본 적 있어요?&quot; 물어보면, 이 정도는 말할 수 있어야 하지 않을까요?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 면접에서 "성능 최적화 경험 있으세요?" 라고 물으면, 이렇게 대답하세요]]></title>
            <link>https://velog.io/@k-svelte-master/optimize-frontend-performance-interview</link>
            <guid>https://velog.io/@k-svelte-master/optimize-frontend-performance-interview</guid>
            <pubDate>Sun, 07 Dec 2025 02:54:02 GMT</pubDate>
            <description><![CDATA[<p>프론트엔드 개발을 하다 보면 성능 최적화를 피할 수 없는 순간이 온다. 특히 애니메이션은 성능 문제가 눈에 바로 보이기 때문에 더 신경 쓰이는 영역이기도 하다.</p>
<p>최근에 페이지 전환 라이브러리(<a href="https://ssgoi.dev">https://ssgoi.dev</a>)
를 만들면서 흥미로운 문제를 만났고, 해결하는 과정에서 브라우저 렌더링에 대해 더 깊이 이해하게 됐다. 그 경험을 공유해보려 한다.</p>
<hr>
<h2 id="문제를-만나다">문제를 만나다</h2>
<p>ssgoi라는 페이지 전환 라이브러리를 만들고 있었다. Spring 물리 기반으로 페이지가 부드럽게 전환되는 게 핵심 기능이었다.</p>
<p>데스크톱 Chrome에서는 만족스러웠다. 60fps로 부드럽게 동작했다. 그런데 아이폰 13에서 테스트해보니 뭔가 미세하게 끊기는 느낌이 들었다.</p>
<p>&quot;기분 탓인가?&quot; 싶어서 Chrome DevTools에서 CPU 성능을 6배 느리게 설정하고 다시 테스트해봤다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/8ba98105-95b9-4a7c-afe9-a3de8baeb530/image.gif" alt="https://mvpstar.ai"></p>
<p>확실히 버벅였다. 페이지가 마운트되는 순간마다 애니메이션이 100ms씩 뚝뚝 끊겼다.</p>
<hr>
<h2 id="브라우저-안에서-무슨-일이-벌어지고-있을까">브라우저 안에서 무슨 일이 벌어지고 있을까</h2>
<p>원인을 찾기 위해 브라우저의 렌더링 구조를 다시 살펴봤다.</p>
<h3 id="메인쓰레드-바쁜-일꾼">메인쓰레드: 바쁜 일꾼</h3>
<p>브라우저에서 우리가 작성하는 대부분의 코드는 <strong>메인쓰레드</strong>에서 실행된다.</p>
<ul>
<li>JavaScript 실행</li>
<li>DOM 파싱과 업데이트</li>
<li>스타일 계산 (어떤 CSS가 어떤 요소에 적용되는지)</li>
<li>레이아웃 계산 (각 요소의 크기와 위치)</li>
<li>React, Vue 같은 프레임워크의 컴포넌트 렌더링</li>
</ul>
<p>이 모든 작업이 <strong>하나의 쓰레드</strong>에서 순차적으로 처리된다. 메인쓰레드가 바쁘면 다른 작업들은 기다려야 한다.</p>
<h3 id="컴포지터-쓰레드-여유로운-일꾼">컴포지터 쓰레드: 여유로운 일꾼</h3>
<p>반면 <strong>컴포지터 쓰레드</strong>는 다른 일을 한다.</p>
<ul>
<li>레이어 합성 (여러 레이어를 하나의 화면으로 합치기)</li>
<li><code>transform</code>, <code>opacity</code> 같은 속성의 애니메이션</li>
<li>스크롤 처리</li>
</ul>
<p>컴포지터 쓰레드는 메인쓰레드와 <strong>분리되어</strong> 동작한다. 그래서 JavaScript가 무거운 작업을 하고 있어도 스크롤은 부드럽게 동작하는 것이다.</p>
<pre><code>┌─────────────────────────────────────────────────────────┐
│                    Renderer Process                     │
│  ┌───────────────────┐    ┌───────────────────┐        │
│  │    Main Thread    │    │ Compositor Thread │        │
│  │                   │    │                   │        │
│  │  - JavaScript     │    │  - Scroll         │        │
│  │  - DOM            │    │  - Animation      │        │
│  │  - Style Calc     │    │  - Layer Composite│        │
│  │  - Layout         │    │                   │        │
│  │  - Paint          │    │                   │        │
│  └───────────────────┘    └─────────┬─────────┘        │
│                                     │                   │
└─────────────────────────────────────│───────────────────┘
                                      │ Compositor Frame
                                      ▼
                          ┌───────────────────┐
                          │    GPU Process    │
                          │                   │
                          │  - Rasterization  │
                          │  - Draw to Screen │
                          └───────────────────┘</code></pre><p>이 두 쓰레드의 관계를 이해하면, 왜 어떤 애니메이션은 부드럽고 어떤 애니메이션은 끊기는지 설명할 수 있다.</p>
<h3 id="렌더링-파이프라인">렌더링 파이프라인</h3>
<p>브라우저가 화면을 그리는 과정을 간단히 정리하면 이렇다:</p>
<pre><code>JavaScript → Style → Layout → Paint → Composite</code></pre><ol>
<li><strong>JavaScript</strong>: 코드 실행, DOM 변경</li>
<li><strong>Style</strong>: CSS 규칙 계산</li>
<li><strong>Layout</strong>: 요소들의 크기와 위치 계산</li>
<li><strong>Paint</strong>: 픽셀로 그리기 (레이어별로)</li>
<li><strong>Composite</strong>: 레이어들을 합쳐서 화면에 표시</li>
</ol>
<p>여기서 중요한 건, <strong>1~4단계는 메인쓰레드</strong>에서, <strong>5단계는 컴포지터 쓰레드</strong>에서 처리된다는 점이다.</p>
<p><code>transform</code>이나 <code>opacity</code>를 변경하면 Layout과 Paint를 건너뛰고 바로 Composite 단계로 갈 수 있다. 그래서 이 속성들이 애니메이션에 좋다고 알려진 것이다.</p>
<h3 id="그런데-왜-내-애니메이션은-끊겼을까">그런데 왜 내 애니메이션은 끊겼을까?</h3>
<p>내 코드는 <code>requestAnimationFrame</code>(rAF)으로 매 프레임 Spring 물리를 계산하고 스타일을 업데이트하고 있었다.</p>
<pre><code class="language-javascript">function tick() {
  // Spring 물리 계산
  state = stepSpring(state, target, constants, deltaTime);

  // 스타일 업데이트
  element.style.transform = `translateX(${state.position}px)`;

  requestAnimationFrame(tick);
}</code></pre>
<p><code>transform</code>만 바꾸니까 괜찮을 것 같지만, 함정이 있다.</p>
<p><code>transform</code> 값의 <strong>변경 자체</strong>는 컴포지터에서 처리되지만, <strong>&quot;이 값으로 바꿔라&quot;라는 명령</strong>은 메인쓰레드에서 내려야 한다. rAF 콜백이 메인쓰레드에서 실행되기 때문이다.</p>
<p>페이지 전환 상황을 생각해보면:</p>
<ol>
<li>새 페이지로 이동한다</li>
<li>React 컴포넌트들이 마운트되면서 메인쓰레드가 바빠진다</li>
<li>rAF 콜백은 줄 서서 기다린다</li>
<li>컴포지터 쓰레드는 한가한데, 명령이 안 오니까 애니메이션이 멈춘다</li>
</ol>
<p>컴포지터는 일할 준비가 되어 있는데, 정작 <strong>일감을 전달하는 통로</strong>가 막혀버린 것이다.</p>
<hr>
<h2 id="spring을-포기할-수-없는-이유">Spring을 포기할 수 없는 이유</h2>
<p>&quot;그러면 CSS 애니메이션이나 Web Animation API를 쓰면 되지 않나요?&quot;</p>
<p>맞는 말이다. CSS 애니메이션은 컴포지터 쓰레드에서 독립적으로 실행될 수 있다. 하지만 문제가 있다.</p>
<h3 id="spring-물리의-특별함">Spring 물리의 특별함</h3>
<p>Spring 애니메이션은 시간 기반이 아니라 <strong>상태 기반</strong>이다. 현재 위치(position)와 속도(velocity)라는 상태를 가지고 있다.</p>
<p>이게 중요한 이유는, <strong>애니메이션 도중에 목표가 바뀌어도 자연스럽게 이어진다</strong>는 점이다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/e950f102-0717-43ff-a70f-b72e79289a5c/image.gif" alt="https://ssgoi.dev"></p>
<p>드래그하다가 놓으면 원래 위치로 돌아가는 UI를 생각해보자. 돌아가는 중에 다시 드래그하면? Spring은 현재 속도를 유지하면서 새로운 목표로 부드럽게 방향을 튼다.</p>
<p>CSS의 <code>ease-in-out</code>이나 <code>cubic-bezier</code> 같은 시간 기반 이징으로는 이런 자연스러움을 만들기 어렵다. 중간에 끊고 새로 시작하면 속도가 갑자기 바뀌면서 부자연스러워진다.</p>
<p>motion.dev 같은 유명 라이브러리들이 Web Animation API를 두고도 여전히 rAF 기반으로 Spring을 구현하는 이유가 여기에 있다.</p>
<hr>
<h2 id="두-마리-토끼를-잡는-방법">두 마리 토끼를 잡는 방법</h2>
<p>며칠을 고민하다가 Svelte의 transition 코드를 살펴보게 됐다.</p>
<p>Svelte는 CSS transition을 사용하면서도 JavaScript로 keyframe을 <strong>미리 생성</strong>하는 방식을 쓰고 있었다. 동적으로 keyframe을 만들어서 CSS에 주입하고, 브라우저에게 실행을 맡기는 것이다.</p>
<p>&quot;이 방식을 Spring에도 적용할 수 있지 않을까?&quot;</p>
<h3 id="아이디어">아이디어</h3>
<p>Spring 물리 계산은 결정론적이다. 시작 위치, 시작 속도, 목표값, Spring 설정값이 같으면 결과는 언제나 같다.</p>
<p>그렇다면:</p>
<ol>
<li>애니메이션 시작 전에 <strong>전체 경로를 미리 계산</strong>해둔다</li>
<li>각 프레임의 위치값을 <strong>keyframes 배열로 변환</strong>한다</li>
<li>Web Animation API로 <strong>컴포지터 쓰레드에서 실행</strong>한다</li>
</ol>
<p>메인쓰레드는 맨 처음에 시뮬레이션 한 번만 돌리고, 이후 애니메이션은 컴포지터가 알아서 처리하게 하는 것이다.</p>
<h3 id="구현">구현</h3>
<p><strong>1단계: Spring 시뮬레이션</strong></p>
<pre><code class="language-javascript">function simulateSpring(from, to, spring, initialVelocity = 0) {
  const frames = [];
  let state = { position: from, velocity: initialVelocity };

  for (let i = 0; i &lt; MAX_FRAMES; i++) {
    // 현재 상태 기록
    frames.push({
      time: i * FRAME_TIME,
      position: state.position,
      velocity: state.velocity
    });

    // 다음 프레임으로
    state = stepSpring(state, to, constants, FRAME_TIME / 1000);

    // 목표에 충분히 가까워지면 종료
    if (isSettled(state, to)) break;
  }

  return frames;
}</code></pre>
<p>Spring 물리를 프레임 단위로 돌리면서 각 시점의 위치와 속도를 기록한다. 이 시뮬레이션은 동기적으로 실행되고, 보통 수 밀리초면 끝난다.</p>
<p><strong>2단계: Keyframes로 변환</strong></p>
<pre><code class="language-javascript">function framesToKeyframes(frames, styleFn) {
  return frames.map(frame =&gt; styleFn(frame.position));
}

// 사용 예시
const keyframes = framesToKeyframes(frames, (pos) =&gt; ({
  transform: `translateX(${pos}px)`
}));</code></pre>
<p>시뮬레이션 결과를 Web Animation API가 이해하는 형식으로 변환한다.</p>
<p><strong>3단계: Web Animation API로 실행</strong></p>
<pre><code class="language-javascript">const animation = element.animate(keyframes, {
  duration: totalDuration,
  fill: &#39;forwards&#39;,
  easing: &#39;linear&#39;  // Spring 물리가 이미 적용되어 있으므로
});</code></pre>
<p><code>easing: &#39;linear&#39;</code>가 포인트다. Spring의 가속과 감속이 이미 keyframes에 담겨 있기 때문에, 브라우저는 프레임 사이를 선형으로 보간하기만 하면 된다.</p>
<h3 id="연속성-유지하기">연속성 유지하기</h3>
<p>애니메이션 도중에 새로운 목표로 바꿔야 할 때는 어떻게 할까?</p>
<p>시뮬레이션할 때 각 프레임의 속도도 함께 기록해뒀기 때문에, 경과 시간으로 현재 상태를 계산할 수 있다.</p>
<pre><code class="language-javascript">function interpolateFrame(frames, elapsedTime) {
  // 이진 탐색으로 해당 시점의 프레임을 찾고
  // 두 프레임 사이를 선형 보간
  return { position, velocity };
}</code></pre>
<p>애니메이션을 멈추는 순간 <code>interpolateFrame</code>으로 현재 위치와 속도를 구하고, 그 상태에서 새 목표를 향하는 시뮬레이션을 다시 돌리면 된다. Spring의 연속성이 그대로 유지된다.</p>
<hr>
<h2 id="결과">결과</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/3544341a-0ac1-4f79-a20e-fd8eec48e0fd/image.gif" alt="개빨라짐"></p>
<p>CPU를 6배 느리게 해도 애니메이션이 끊기지 않는다. React 컴포넌트가 마운트되든, JavaScript가 무거운 작업을 하든, 애니메이션은 컴포지터 쓰레드에서 독립적으로 실행된다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이 아이디어는 사실 내가 처음 생각한 게 아니다. Svelte의 transition 코드를 읽다가 &quot;keyframe을 미리 생성한다&quot;는 패턴을 발견했고, 그걸 Spring에 적용해본 것뿐이다.</p>
<p>문제를 풀다 막힐 때, 비슷한 문제를 겪었을 선배 개발자들의 코드를 찾아보는 습관이 도움이 되는 것 같다. 오픈소스 라이브러리 코드에는 그런 노하우가 많이 담겨 있다.</p>
<p>&quot;어, 이거 어디서 본 패턴인데?&quot; 하는 순간이 오면 꽤 뿌듯하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[애니메이션 라이브러리 모바일 최적화 A to Z]]></title>
            <link>https://velog.io/@k-svelte-master/optimize-anaimtion-performance-in-mobile</link>
            <guid>https://velog.io/@k-svelte-master/optimize-anaimtion-performance-in-mobile</guid>
            <pubDate>Fri, 07 Nov 2025 02:55:24 GMT</pubDate>
            <description><![CDATA[<h1 id=""></h1>
<h2 id="tldr---3줄-요약">TL;DR - 3줄 요약</h2>
<p><strong>문제</strong>: 내 라이브러리, 모바일에서 애니메이션이 뻑뻑하고 백그라운드 복귀 시 깨짐<br><strong>해결</strong>: dt clamping + lag smoothing + 적절한 수치해석 선택<br><strong>결과</strong>: 60fps → 불규칙 FPS 환경에서도 부드럽게 작동</p>
<hr>
<h2 id="1-애니메이션-라이브러리-101">1. 애니메이션 라이브러리 101</h2>
<h3 id="11-raf--dt의-세계">1.1 RAF + dt의 세계</h3>
<p>CSS가 아닌 JavaScript 애니메이션 라이브러리의 핵심 구조:</p>
<pre><code>시간축 다이어그램:
┌─────┬─────┬─────┬─────┐
│ dt1 │ dt2 │ dt3 │ dt4 │
└─────┴─────┴─────┴─────┘
  16ms  16ms  16ms  16ms  (이상적)

position 업데이트:
0 ──→ 16 ──→ 32 ──→ 48 ──→ 64</code></pre><pre><code class="language-typescript">function animate() {
  const currentTime = performance.now();
  const dt = (currentTime - lastTime) / 1000;  // delta time (초 단위)

  position += velocity * dt;  // dt만큼 값 업데이트
  render(position);            // 화면 갱신

  lastTime = currentTime;
  requestAnimationFrame(animate);  // 재귀 호출
}</code></pre>
<p><strong>핵심</strong>: <code>requestAnimationFrame(RAF)</code>으로 브라우저의 화면 주사율에 맞춰 반복 실행하며, <code>dt</code>(delta time)만큼 값을 조작해 애니메이션을 진행시킨다.</p>
<h3 id="12-spring-physics-용수철-물리">1.2 Spring Physics (용수철 물리)</h3>
<p>자연스러운 애니메이션의 비밀은 <strong>물리 시뮬레이션</strong>:</p>
<pre><code>F = ma = -kx - cv

k: stiffness (용수철 계수 - 얼마나 뻣뻣한가)
c: damping (저항 계수 - 얼마나 빨리 멈추는가)
x: 목표로부터의 거리
v: 속도</code></pre><p><strong>의미</strong>: </p>
<ul>
<li>용수철이 당기는 힘 (<code>-kx</code>)</li>
<li>저항력이 속도를 줄이는 힘 (<code>-cv</code>)</li>
<li>이 둘이 합쳐져 자연스러운 움직임 생성</li>
</ul>
<h3 id="13-수치해석이란">1.3 수치해석이란?</h3>
<p><strong>dt만큼 속도와 위치를 보정해서 다음 프레임을 계산하는 방법</strong></p>
<p>가장 단순한 Euler 방법:</p>
<pre><code class="language-typescript">// 매 프레임마다
const acceleration = force / mass;
velocity += acceleration * dt;
position += velocity * dt;</code></pre>
<p>문제는 <strong>어떤 방법으로 식을 세울 것인가</strong>가 성능과 안정성을 결정한다는 것!</p>
<h2 id="2-모바일의-현실-16ms는-거짓말">2. 모바일의 현실: 16ms는 거짓말</h2>
<h3 id="21-실측-데이터">2.1 실측 데이터</h3>
<p>데스크톱에서는 60fps(16.67ms)가 일반적이지만, 모바일은 완전히 다른 세계:</p>
<table>
<thead>
<tr>
<th>환경</th>
<th>평균 dt</th>
<th>최대 dt</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>데스크톱 Chrome</td>
<td>16.67ms</td>
<td>~20ms</td>
<td>안정적</td>
</tr>
<tr>
<td>iPhone 13</td>
<td>16ms</td>
<td>200ms</td>
<td>백그라운드 복귀 시</td>
</tr>
<tr>
<td>Galaxy S23</td>
<td>33ms</td>
<td>150ms</td>
<td>가변적</td>
</tr>
<tr>
<td>저사양 안드로이드</td>
<td>50-100ms</td>
<td>500ms+</td>
<td>💀</td>
</tr>
</tbody></table>
<h3 id="22-가변-fps의-문제들">2.2 가변 FPS의 문제들</h3>
<pre><code class="language-typescript">// dt = 16ms:  ✅ 정상 작동
// dt = 100ms: ⚠️ Spring이 과도하게 튐
// dt = 5000ms: 💥 완전히 폭발</code></pre>
<p><strong>실제 시나리오</strong>:</p>
<ul>
<li>백그라운드 → 포그라운드 복귀: 5000ms+</li>
<li>CPU 과부하 (다른 앱 실행): 100-500ms</li>
<li>프레임 드롭 (렌더링 지연): 50-100ms</li>
</ul>
<h3 id="23-내-경험담">2.3 내 경험담</h3>
<blockquote>
<p>&quot;모바일에서 뻑뻑하다는 피드백을 받고, 기존 라이브러리(오래된 버전)를 뜯어봤더니 가변 FPS 대응이 전혀 안 되어 있었다. dt가 100ms만 넘어가도 Spring이 미친 듯이 튀고, 백그라운드에서 돌아오면 애니메이션이 순간이동했다. 결국 직접 만들기로 결정.&quot;</p>
</blockquote>
<p><strong>교훈</strong>: 모바일은 데스크톱이 아니다. 16ms를 기대하지 말고, 500ms도 대응할 수 있게 만들어야 한다.</p>
<h2 id="3-수치해석-방법-비교">3. 수치해석 방법 비교</h2>
<h3 id="31-5가지-방법-한눈에-보기">3.1 5가지 방법 한눈에 보기</h3>
<table>
<thead>
<tr>
<th>방법</th>
<th>안정성</th>
<th>정확도</th>
<th>성능</th>
<th>유연성</th>
<th>모바일 추천</th>
</tr>
</thead>
<tbody><tr>
<td>Explicit Euler</td>
<td>❌</td>
<td>❌</td>
<td>✅✅</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td><strong>Semi-Implicit Euler</strong></td>
<td>✅</td>
<td>⭐</td>
<td>✅</td>
<td>✅</td>
<td>✅✅</td>
</tr>
<tr>
<td>RK4 (룽게 쿠타)</td>
<td>⚠️</td>
<td>✅✅</td>
<td>⭐</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td>Verlet/Leapfrog</td>
<td>✅</td>
<td>✅</td>
<td>✅</td>
<td>⭐</td>
<td>✅</td>
</tr>
<tr>
<td><strong>Analytical Solution</strong></td>
<td>✅✅</td>
<td>✅✅</td>
<td>✅✅</td>
<td>❌</td>
<td>✅✅</td>
</tr>
</tbody></table>
<h3 id="32-semi-implicit-euler-allen-chou-방식">3.2 Semi-Implicit Euler (Allen Chou 방식)</h3>
<p><strong>출처</strong>: <a href="https://allenchou.net/2015/04/game-math-more-on-numeric-springing/">Allen Chou - Game Math: Numeric Springing</a></p>
<p><strong>사용처</strong>: Svelte Motion, 웹 애니메이션 라이브러리</p>
<pre><code class="language-cpp">// 정규화된 파라미터 사용
// ω (omega) = sqrt(k/m) : 각진동수 (얼마나 빨리 진동)
// ζ (zeta) = c/(2√km) : 감쇠비 (얼마나 빨리 멈춤)

void SpringSemiImplicitEuler(
    float &amp;x,      // 현재 값
    float &amp;v,      // 속도
    float xt,      // 목표 값
    float zeta,    // damping ratio
    float omega,   // angular frequency
    float h        // timestep (dt)
) {
    v += -2.0f * h * zeta * omega * v +    // 감쇠력
         h * omega * omega * (xt - x);      // 복원력
    x += h * v;  // 핵심: 속도 먼저, 위치 나중!
}</code></pre>
<p><strong>장점</strong>:</p>
<ul>
<li>Explicit Euler보다 훨씬 안정적 (진동 폭이 계속 커지는 오류 없음)</li>
<li>가변 dt에서도 안정적</li>
<li><strong>중간에 목표치가 변해도 연속된 움직임 유지</strong> (내가 선택한 이유!)</li>
</ul>
<p><strong>왜 Explicit Euler는 안 되는가</strong>:</p>
<pre><code class="language-typescript">// Explicit Euler (위치 먼저, 속도 나중)
position += velocity * dt;
velocity += acceleration * dt;
// → 진동 폭이 계속 커지는 오류 (에너지 증가)</code></pre>
<h3 id="33-analytical-solution-정확한-해">3.3 Analytical Solution (정확한 해)</h3>
<p><strong>출처</strong>: <a href="https://github.com/software-mansion/react-native-reanimated">React Native Reanimated 소스코드</a></p>
<p><strong>사용처</strong>: React Native Reanimated (네이티브)</p>
<pre><code class="language-typescript">// 미분방정식의 정확한 해를 직접 계산
const t = (currentTime - startTime) / 1000;
const envelope = Math.exp(-zeta * omega0 * t);

// Under-damped (ζ &lt; 1, 진동하면서 멈춤)
const position = target + envelope * 
  (Math.sin(omega1 * t) * A + x0 * Math.cos(omega1 * t));</code></pre>
<p><strong>장점</strong>:</p>
<ul>
<li>O(1) 연산 (프레임 수와 무관)</li>
<li>수학적으로 정확함</li>
<li>프레임 독립적 (드롭해도 정확)</li>
</ul>
<p><strong>단점</strong>:</p>
<ul>
<li>표준 spring만 가능</li>
<li><strong>중간에 목표치 변경 어려움</strong> (매번 초기화 필요)</li>
</ul>
<h3 id="34-왜-rk4룽게-쿠타는-모바일에서-망할까">3.4 왜 RK4(룽게 쿠타)는 모바일에서 망할까?</h3>
<pre><code class="language-typescript">// RK4: 4단계 계산
k1 = f(t, x)
k2 = f(t + dt/2, x + k1*dt/2)
k3 = f(t + dt/2, x + k2*dt/2)
k4 = f(t + dt, x + k3*dt)
x += (k1 + 2*k2 + 2*k3 + k4) * dt / 6</code></pre>
<p><strong>문제점</strong>:</p>
<ul>
<li><strong>dt가 일정할 때 설계됨</strong> (Fixed timestep 가정)</li>
<li><strong>가변적이고 큰 dt에서 오차가 심함</strong> (모바일의 현실)</li>
<li>장시간 시뮬레이션에서 에너지 손실 (dissipative)<pre><code class="language-typescript">// dt = 16ms (일정):  ✅ RK4 정확함
// dt = 16~100ms (가변): ❌ RK4 오차 심함
// dt = 5000ms (큼):  💥 RK4 완전히 망가짐</code></pre>
</li>
</ul>
<p><strong>결론</strong>: 모바일 가변 FPS 환경에선 Semi-Implicit이나 Analytical이 훨씬 현명한 선택</p>
<h2 id="4-필수-최적화-3종-세트">4. 필수 최적화 3종 세트</h2>
<h3 id="41-delta-time-clamping--lag-smoothing">4.1 Delta Time Clamping + Lag Smoothing</h3>
<p><strong>문제 인식</strong>: 가변 FPS를 어떻게 처리할 것인가?</p>
<p>모바일에서 뻑뻑하다는 피드백을 받고, &quot;국밥 애니메이션 라이브러리&quot; GSAP의 코드를 뜯어봤다.</p>
<p><strong>출처</strong>: <a href="https://github.com/greensock/GSAP/blob/master/src/gsap-core.js">GSAP Ticker 소스코드</a></p>
<h4 id="clamping-dt-최대값-제한-라인-908-914">Clamping: dt 최대값 제한 (라인 908-914)</h4>
<pre><code class="language-typescript">dt = Math.min(dt, 0.1);  // 100ms로 제한</code></pre>
<p><strong>목적</strong>: Spiral of Death 방지</p>
<pre><code>느려짐 → dt 커짐 → 계산량 증가 → 더 느려짐 → 💥</code></pre><h4 id="smoothing-비정상-시간-보정-라인-955-958">Smoothing: 비정상 시간 보정 (라인 955-958)</h4>
<pre><code class="language-typescript">// 백그라운드 복귀 등 비정상적으로 긴 시간 경과 시
if (elapsed &gt; 500) {  // lagThreshold
  startTime += elapsed - 33;  // adjustedLag
}</code></pre>
<p><strong>동작 원리</strong>:</p>
<pre><code class="language-typescript">// 백그라운드 5초 후 복귀
elapsed = 5000ms
// 보정 없으면: 애니메이션 5초치 점프 💥
// 보정 후: startTime 조정으로 33ms만 경과한 것처럼
startTime += (5000 - 33) = 4967ms 추가
// → 결과적으로 33ms만 진행된 것처럼 계산됨</code></pre>
<p><strong>내가 적용한 값</strong>:</p>
<pre><code class="language-typescript">const MAX_DT = 0.1;         // 100ms
const LAG_THRESHOLD = 0.5;   // 500ms
const ADJUSTED_LAG = 0.033;  // 33ms</code></pre>
<h3 id="42-adaptive-frame-rate">4.2 Adaptive Frame Rate</h3>
<p><strong>출처</strong>: <a href="https://github.com/greensock/GSAP/blob/master/src/gsap-core.js#L908-914">GSAP Ticker adaptive scheduling</a></p>
<pre><code>프레임 스케줄:

정상 상황:
┌──────┬──────┬──────┐
│ 16ms │ 16ms │ 16ms │
└──────┴──────┴──────┘
   ✓      ✓      ✓

프레임 드롭:
┌──────┬────────────┬────┐
│ 16ms │   50ms     │ 4ms│ ← 빠르게 스킵
└──────┴────────────┴────┘
   ✓        ✗         ✓</code></pre><pre><code class="language-typescript">const overlap = time - nextTime;  // 예정보다 얼마나 늦었는지
nextTime += overlap + (overlap &gt;= gap ? 4 : gap - overlap);</code></pre>
<p><strong>동작 원리</strong>:</p>
<pre><code class="language-typescript">// gap = 16.67ms (60fps 목표)

// 조금 늦음 (overlap = 3ms)
nextTime += 3 + (16.67 - 3) = 16.67ms
// → 정상 간격 유지

// 많이 늦음 (overlap = 20ms)
nextTime += 20 + 4 = 24ms
// → 빠르게 스킵해서 따라잡기</code></pre>
<p><strong>효과</strong>: 프레임 드롭 시 자동으로 간격 조정</p>
<h3 id="43-ticker-싱글톤-패턴">4.3 Ticker 싱글톤 패턴</h3>
<p><strong>문제</strong>: <code>requestAnimationFrame</code>은 비싼 함수</p>
<pre><code class="language-typescript">// ❌ 나쁜 예
animation1: requestAnimationFrame(update1);
animation2: requestAnimationFrame(update2);
animation3: requestAnimationFrame(update3);
// → RAF 호출 3번

// ✅ 좋은 예: Ticker 싱글톤
class Ticker {
  private listeners = new Set&lt;(dt: number) =&gt; void&gt;();

  private tick = (timestamp: number) =&gt; {
    const dt = (timestamp - this.lastTime) / 1000;

    // 모든 애니메이션을 한 번에
    this.listeners.forEach(callback =&gt; callback(dt));

    if (this.listeners.size &gt; 0) {
      this.rafId = requestAnimationFrame(this.tick);
    }
  };

  subscribe(callback: (dt: number) =&gt; void) {
    this.listeners.add(callback);
    if (this.listeners.size === 1) {
      requestAnimationFrame(this.tick);
    }
  }
}

ticker.subscribe(animation1.update);
ticker.subscribe(animation2.update);
// → RAF 호출 1번만!</code></pre>
<p><strong>효과</strong>: RAF 호출 최소화, 배터리 절약</p>
<p><strong>적용 사례</strong>: GSAP, Framer Motion, 내 라이브러리</p>
<h2 id="5-놓치기-쉬운-디테일-수렴-조건">5. 놓치기 쉬운 디테일: 수렴 조건</h2>
<p><strong>문제</strong>: <code>===</code> 비교는 영원히 안 맞음</p>
<pre><code class="language-typescript">// ❌ 이렇게 하면 영원히 안 멈춤
if (position === target &amp;&amp; velocity === 0) {
  stop();
}
// → 부동소수점 오차로 딱 맞지 않음
// position = 99.999999...
// velocity = 0.0000001...</code></pre>
<p><strong>해결</strong>: 임계값(threshold) 사용</p>
<pre><code class="language-typescript">// ✅ 올바른 방법
const POSITION_THRESHOLD = 0.01;
const VELOCITY_THRESHOLD = 0.01;

const isSettled = 
  Math.abs(position - target) &lt; POSITION_THRESHOLD &amp;&amp;
  Math.abs(velocity) &lt; VELOCITY_THRESHOLD;

if (isSettled) {
  position = target;  // 딱 맞춰주고
  velocity = 0;
  stop();
}</code></pre>
<hr>
<h2 id="6-결론--체크리스트">6. 결론 &amp; 체크리스트</h2>
<h3 id="내-경험-정리">내 경험 정리</h3>
<pre><code>Before:
- 기존 라이브러리 사용 (오래된 버전)
- 모바일에서 뻑뻑함 💀
- 백그라운드 복귀 시 깨짐 💥

After (GSAP 코드 분석 후 적용):
- Semi-Implicit Euler 직접 구현
- dt clamping (100ms) 적용
- lag smoothing (500ms → 33ms) 적용
- adaptive frame rate 추가
- Ticker 싱글톤 패턴

Result:
- 저사양 기기에서도 부드러움 ✅
- 백그라운드 복귀 안정적 ✅</code></pre><h3 id="구현-체크리스트">구현 체크리스트</h3>
<pre><code>[ ] 수치해석 방법 선택
    → Semi-Implicit Euler (중간에 목표치 변경 가능)

[ ] dt clamping 적용
    → Math.min(dt, 0.1)

[ ] lag smoothing 구현
    → if (elapsed &gt; 500) startTime += elapsed - 33

[ ] 수렴 조건 threshold 설정
    → position &lt; 0.01, velocity &lt; 0.01

[ ] Ticker 싱글톤 패턴
    → RAF 호출 최소화

[ ] 실기기 테스트
    → 저사양 안드로이드 포함</code></pre><h3 id="핵심-레퍼런스">핵심 레퍼런스</h3>
<p><strong>구현</strong>:</p>
<ul>
<li><a href="https://allenchou.net/2015/04/game-math-more-on-numeric-springing/">Allen Chou - Numeric Springing</a></li>
<li><a href="https://github.com/greensock/GSAP/blob/master/src/gsap-core.js">GSAP Ticker 소스</a></li>
<li><a href="https://github.com/sveltejs/svelte/tree/main/packages/svelte/src/motion">Svelte Motion</a></li>
<li><a href="https://github.com/software-mansion/react-native-reanimated">React Native Reanimated</a></li>
</ul>
<p><strong>이론</strong>:</p>
<ul>
<li><a href="https://gafferongames.com/post/integration_basics/">Gaffer on Games - Integration Basics</a></li>
</ul>
<hr>
<h3 id="마치며">마치며</h3>
<blockquote>
<p><strong>&quot;모바일은 데스크톱이 아니다. 16ms를 기대하지 말고, 500ms도 대응할 수 있게 만들어라.&quot;</strong></p>
</blockquote>
<p><strong>핵심 3줄</strong>:</p>
<ol>
<li><strong>안정성 &gt; 정확도</strong>: Semi-Implicit &gt; RK4</li>
<li><strong>방어 코드는 필수</strong>: clamping, smoothing</li>
<li><strong>디테일이 차이를 만든다</strong>: threshold, ticker</li>
</ol>
<p><strong>배운 점</strong>:</p>
<ul>
<li>가변 FPS는 필연이다 (대응 필수)</li>
<li>좋은 라이브러리의 코드를 뜯어보자 (GSAP)</li>
<li>작은 최적화가 모여 큰 차이를 만든다</li>
</ul>
<hr>
<p>이 모든 것을 적용해서 <a href="https://ssgoi.dev/ko"><strong>ssgoi 2.5.2</strong></a>를 릴리즈했다.<br>화면 전환 라이브러리로, 부드럽습니다 ㅎ</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/508c19a9-43e8-4995-a0f7-02f9afab40e8/image.gif" alt="ssgoi.dev/en"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드에서 코드는 어떻게 잘 짜는 것일까? (Feat. FSD) ]]></title>
            <link>https://velog.io/@k-svelte-master/frontend-domain-architecture</link>
            <guid>https://velog.io/@k-svelte-master/frontend-domain-architecture</guid>
            <pubDate>Fri, 17 Oct 2025 07:16:15 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/8a1f9737-b971-4b81-b500-a370ae37d922/image.png" alt="windter"></p>
<h2 id="왜-잘-짠-코드는-시간이-지나도-문제-없을까">왜 잘 짠 코드는 시간이 지나도 문제 없을까?</h2>
<p>훌륭한 개발자가 되고 싶다면 누구나 클린 코드와 아키텍처를 공부합니다. 그런데 정작 실력 있는 개발자란 무엇일까요? 한마디로 정의하자면, <strong>빨리 만들고, 프로덕트가 커져도 그 속도를 유지하는 사람</strong>입니다.</p>
<p>초보 개발자와 고수 개발자가 빈 프로젝트에서 첫 기능을 구현하는 속도는 비슷할 수 있습니다. 하지만 프로젝트가 커질수록 격차가 벌어집니다. 잘 짠 코드는 10번째 기능도 첫 번째만큼 빠르게 추가할 수 있지만, 못 짠 코드는 갈수록 느려집니다.</p>
<h3 id="우리는-왜-느려질까">우리는 왜 느려질까?</h3>
<p>실무에서 이런 경험들, 한 번쯤 해보셨나요?</p>
<p><strong>1. 재사용 가능하게 공통화했습니다.</strong></p>
<p>포스팅 카드와 제품 카드가 똑같이 썸네일, 제목, 설명, 댓글 수, 좋아요 수를 가지고 있길래 <code>CardView</code> 컴포넌트를 만들었죠. 그런데 시간이 지나자 포스팅에는 작성자 정보가 필요했고, 제품에는 장바구니 버튼이 추가됐습니다. 묘하게 달라지는 요구사항들을 처리하다 보니 결국 분기문 투성이가 되었습니다. 차라리 따로 만드는 게 나았을까요?</p>
<p><strong>2. 결합을 피하려고 따로 만들었더니</strong></p>
<p>팔로워 수를 표시할 때를 생각해봅시다. 서버에서는 <code>1234567</code> 같은 숫자로 오지만, 화면에는 <code>1.2M</code>으로 보여줘야 합니다. 데이터와 표시는 다른 관심사니까 UI 컴포넌트에서 포맷팅하도록 했죠.</p>
<p>작성일자도 마찬가지입니다. 서버에서는 <code>2024-01-15</code> 형식이지만, 화면에는 <code>3일 전</code> 같은 상대 시간으로 보여줘야 합니다. 이것도 UI에서 처리했습니다.</p>
<p>그런데 결국 모든 UI 컴포넌트에서 똑같은 포맷팅 로직을 반복하고 있었습니다. 급하게 <code>utils</code>로 빼봤지만, 여전히 쓰는 곳마다 import해서 호출해야 했죠. 애초에 서버에서 데이터를 받아오는 부분에서 이런 전처리를 결합해서 제공했다면, UI는 그냥 표시만 하면 됐을 텐데요.</p>
<p><strong>3. 함수를 작게 쪼개면 유연해질 거라 생각했습니다.</strong></p>
<p>큰 함수 하나로 되어 있으면 일부만 수정이 필요해도 전체를 다시 작성해야 하니까요. A, B, C, D로 나누면 C만 C&#39;로 바꿔서 다시 조합하면 되겠죠? </p>
<p>예를 들어 블로그 포스팅에 좋아요를 누르면, (1) 해당 포스팅의 좋아요 수를 증가시키고, (2) 내가 좋아요를 체크했다는 표시를 하고, (3) 리스트에도 반영하기 위해 캐시를 해제하거나 옵티미스틱 업데이트를 해야 합니다. 각각을 따로 정의해두면 필요할 때 하나씩만 호출할 수도 있을 테니 유연하겠죠?</p>
<p>그런데 실제로는 A, B, C, D 중 어느 것 하나 따로 사용하거나 교체한 적이 없었습니다. 항상 세트로 움직였고, 호출하는 곳에서는 네 가지를 매번 순서대로 호출해야 했습니다. 하나라도 누락되면 정보 갱신이 덜 되는 버그가 생겼죠. 그제야 깨달았습니다. 이건 유연함이 아니라, <strong>함께 일어나야 하는 하나의 트랜잭션</strong>이었던 거죠.</p>
<p>무조건 나누는 것도 아니고, 무조건 반복하는 것도 아니고, 무조건 공통화하는 것도 아니라면, <strong>대체 어떻게 해야 하는 건가요?</strong> 미래를 예측할 수 있는 무당이어야만 좋은 코드를 짤 수 있는 걸까요?</p>
<h3 id="범인을-찾아라">범인을 찾아라</h3>
<p>그런데 우리는 왜 이런 고민을 해야 할까요? 어디를 재사용하고, 어디를 따로 구분해야 할지 고민하는 이유는 무엇일까요?</p>
<p>역설적이게도, 한번 정해놓고 절대 바꾸지 않는다면 사실 어떻게 짜든 크게 문제가 없습니다. 반복하든, 재사용하든, 분기문을 난사하든 말이죠.</p>
<p>하지만 수정이 일어나면 이야기가 달라집니다:</p>
<ul>
<li><strong>잘못 공통화한 것</strong>은 수정할 때마다 계속 복잡해지면서 오류가 날 확률이 높아집니다.</li>
<li><strong>결합을 피하려고 반복한 것</strong>은 로직이 한 번에 바뀌어야 할 때 수정하다가 누락되는 곳이 생깁니다.</li>
<li><strong>함께 움직이는 동작을 쪼갠 것</strong>은 같이 움직여야 할 내용이 추가될 때 다른 파일도 똑같이 반영하다가 오류가 나거나 변경이 누락됩니다.</li>
</ul>
<p>그러다 수정이 일어나면 어딜 고쳐야 할지, 어딜 빼먹으면 안 되는지, 어디까지 영향을 받는지 찾아야 하죠. <strong>이게 문제입니다.</strong></p>
<h3 id="범인의-정체">범인의 정체</h3>
<p>우리는 괴롭힘을 받고 있습니다. <strong>수정이라는 괴롭힘</strong>이죠.</p>
<p>그렇다면 왜 우리는 수정을 해야 할까요? 처음부터 제대로 만들면 되는 거 아닌가요?</p>
<p>일단 우리는 자의로 만들지 않습니다. 소프트웨어는 <strong>돈 받고 만듭니다.</strong> 그리고 돈 주는 사람이 말을 바꿉니다.</p>
<p>비즈니스 요구사항이 있고, 엔지니어는 이를 코드로 반영합니다. <strong>엔지니어는 타의로 움직입니다.</strong> 시키는 사람의 마음이 변하면, 우리가 짠 코드도 바뀌어야 합니다.</p>
<p>시키는 대로 짰는데, 말을 번복하고 수정을 다시 요청받습니다. 그래서 이 사람이 자꾸 말을 번복하니까 내가 괴로운 거죠.</p>
<p><strong>진짜 범인은 한번 말한 걸 번복하는 사장님이죠.</strong></p>
<h3 id="근데-여기서-변별력이-나온다">근데 여기서 변별력이 나온다</h3>
<p>처음에는 사장님이 답답하게 느껴질 수 있습니다. &quot;처음부터 확 정해주면 되는 거 아냐?&quot;</p>
<p>만약 사장님이 모든 변화를 예측해서 처음부터 완벽한 제품 방향을 정해줬다면? 애초에 그런 사람이랑 일할 기회도 없었겠죠. 이미 그 회사는 유니콘이 되어있을 테니까요.</p>
<p>그리고 다 한번만에 정해지면 애초에 개발자 실력 차이가 안 납니다. <strong>이 변경에 얼마만큼 빠르게 대처하느냐가 변별력입니다.</strong></p>
<p>만약 모두가 한 번 정한 것을 절대 안 바꾼다면, 사실 어떻게 개발하든 문제가 없습니다. 반대로 &quot;한 번 정한 것은 수정 못 하겠다&quot;며 낙장불입을 외치는 개발자만큼 꼴불견도 없습니다.</p>
<p>변경은 우리의 적이 아닙니다. 변경은 우리가 더 나은 개발자가 될 수 있는 기회입니다. 그 변경을 어떻게 다루느냐가 실력의 차이를 만듭니다.</p>
<h3 id="소프트웨어를-보는-시선의-전환">소프트웨어를 보는 시선의 전환</h3>
<p>소프트웨어의 본질을 생각해봅시다. 소프트웨어의 모습은 엔지니어가 결정하지 않습니다. 언제나 시장이 변하고, 사용자 피드백이 쌓이고, 경쟁사가 새로운 기능을 출시합니다. 개발자가 아니라 주변 비즈니스가 결정합니다.</p>
<p>소프트웨어란 개발자가 아니라 비즈니스에 의해 만들어진다는 사실을 받아들이면, 코드를 보는 시선도 달라집니다.</p>
<p>비즈니스 요구사항이 바뀌는 범위대로 코드를 구분해두면, 적어도 바뀌는 범위를 한정할 수 있습니다. 이것이 바로 <strong>도메인 중심 설계</strong>가 등장한 이유입니다.</p>
<p>예전에는 기술 중심, 레이어 중심으로 코드를 나눠왔습니다. <code>ui/</code>, <code>hooks/</code>, <code>api/</code>, <code>utils/</code> 같은 구조로요.</p>
<p>이제는 사장님의 생각대로, 기획자가 말하는 방식대로 코드를 나누기 시작합니다. <code>user/</code>, <code>product/</code>, <code>payment/</code> 처럼요.</p>
<p>기획자들은 어떻게 말할까요? 이들은 React hook이나 API가 무엇인지, UI 컴포넌트가 무엇인지 알까요?</p>
<p>아니요. 그들은 이렇게 말합니다:</p>
<ul>
<li>&quot;이 <strong>기능</strong>을 추가해주세요&quot;</li>
<li>&quot;<strong>유저 프로필</strong>에 이 정보가 더 보여야 해요&quot;</li>
<li>&quot;<strong>장바구니</strong>에 가격이 표시되어야 해요&quot;</li>
<li>&quot;<strong>팔로워 수</strong>를 인스타그램처럼 바꿔주세요&quot;</li>
</ul>
<p>개발자들은 이런 요청을 듣고 ui, 상태, api 파일들을 뒤집니다.</p>
<p>여기서 흥미로운 패턴을 발견할 수 있습니다. <strong>이런 수정 범위에는 경계가 있습니다.</strong></p>
<p>유저에 대해 이야기할 때는 장바구니는 상관없습니다. 장바구니 이야기를 할 때는 유저 프로필은 무관합니다. 팔로워 수 표시 방식을 바꾸면 팔로워 수가 보이는 모든 곳이 영향을 받지만, 장바구니 가격과는 아무 관계가 없죠.</p>
<p><strong>도메인 단위로 수정사항이 전파되고, 도메인 단위로 내용이 달라집니다.</strong></p>
<p>그리고 이미 당신은 도메인 중심으로 코드를 짜고 있을지도 모릅니다. <strong>slice</strong>란 글자를 봤다면 말이죠.</p>
<h2 id="이미-당신은-도메인-중심으로-개발하고-있습니다">이미 당신은 도메인 중심으로 개발하고 있습니다</h2>
<p>도메인 중심 설계라고 하면 뭔가 거창하고 새로운 개념처럼 들릴 수 있습니다. 백엔드 개발자들이나 쓰는 DDD(Domain-Driven Design) 같은 거 아닌가요?</p>
<p>하지만 프론트엔드에서도 이미 도메인 중심 사고는 널리 퍼져있습니다. 어쩌면 당신도 이미 사용하고 있을지 모릅니다.</p>
<p><strong>slice</strong>라는 단어를 본 적이 있나요?</p>
<h3 id="redux의-slice">Redux의 Slice</h3>
<p>Redux Toolkit을 사용해보셨다면 <code>createSlice</code>를 써보셨을 겁니다. Redux에서 slice는 특정 기능이나 도메인에 관련된 상태와 리듀서를 하나로 묶은 단위입니다.</p>
<pre><code class="language-javascript">// userSlice.js
import { createSlice } from &#39;@reduxjs/toolkit&#39;;

const userSlice = createSlice({
  name: &#39;user&#39;,
  initialState: { profile: null, isLoggedIn: false },
  reducers: {
    login: (state, action) =&gt; { /* ... */ },
    logout: (state) =&gt; { /* ... */ },
    updateProfile: (state, action) =&gt; { /* ... */ }
  }
});</code></pre>
<p>예전 Redux 방식을 기억하시나요? 액션 타입 상수를 정의하고, 액션 크리에이터를 만들고, 리듀서를 따로 작성했습니다. 이런 파일들이 <code>actions/</code>, <code>reducers/</code>, <code>constants/</code> 디렉토리에 흩어져 있었죠.</p>
<pre><code>src/
  actions/
    userActions.js
    productActions.js
    cartActions.js
  reducers/
    userReducer.js
    productReducer.js
    cartReducer.js
  constants/
    actionTypes.js</code></pre><p>유저 프로필 업데이트 기능을 수정하려면? <code>userActions.js</code>, <code>userReducer.js</code>, <code>actionTypes.js</code> 세 파일을 왔다갔다 해야 했습니다.</p>
<p>하지만 slice를 사용하면 이야기가 달라집니다:</p>
<pre><code>src/
  store/
    slices/
      userSlice.js      // 유저 관련 모든 것
      productSlice.js   // 제품 관련 모든 것
      cartSlice.js      // 장바구니 관련 모든 것</code></pre><p>유저 프로필을 수정해야 한다면? <code>userSlice.js</code> 하나만 열면 됩니다. 상태 구조, 액션, 리듀서 로직이 모두 한 곳에 있으니까요.</p>
<p><strong>&quot;유저&quot;라는 도메인과 관련된 것들을 한 곳에 모아둔 것입니다.</strong></p>
<p>Redux 공식 문서를 보면 slice의 이름들이 어떻게 지어지는지 주목해보세요. <code>users</code>, <code>posts</code>, <code>comments</code>, <code>todos</code> - 모두 비즈니스 도메인입니다. <code>ui</code>, <code>hooks</code>, <code>utils</code> 같은 기술적 분류가 아닙니다.</p>
<h3 id="fsd의-slice">FSD의 Slice</h3>
<p>Feature-Sliced Design(FSD)를 들어보셨나요? 최근 프론트엔드 커뮤니티에서 주목받고 있는 아키텍처 방법론입니다.</p>
<p>FSD의 핵심 개념 중 하나도 바로 <strong>slice</strong>입니다. FSD에서는 각 레이어(layer) 아래에 slice를 두어 도메인별로 코드를 구분합니다.</p>
<pre><code>src/
  entities/          ## 레이어
    user/           ## slice (도메인)
      ui/
        UserCard.tsx
        UserAvatar.tsx
      model/
        userStore.ts
        types.ts
      api/
        userApi.ts
      index.ts
    product/        ## slice (도메인)
      ui/
        ProductCard.tsx
        ProductGrid.tsx
      model/
        productStore.ts
      api/
        productApi.ts
      index.ts
    cart/           ## slice (도메인)
      ui/
        CartItem.tsx
      model/
        cartStore.ts
      api/
        cartApi.ts
      index.ts</code></pre><p>FSD에서 slice는 하나의 비즈니스 엔티티나 기능을 나타냅니다. 각 slice 안에는 그 도메인에 필요한 UI 컴포넌트(<code>ui/</code>), 비즈니스 로직과 상태(<code>model/</code>), API 호출(<code>api/</code>) 등이 모여있습니다.</p>
<p>제품(product) 관련 수정사항이 생기면? <code>entities/product/</code> 폴더만 열면 됩니다. UI도, 상태 관리도, API 호출 로직도 모두 거기 있으니까요.</p>
<p><strong>여기서도 slice는 도메인 단위입니다.</strong> <code>user</code>, <code>product</code>, <code>cart</code> - 사장님이 말하는 방식 그대로죠.</p>
<h3 id="수직-슬라이스-아키텍처">수직 슬라이스 아키텍처</h3>
<p>Redux의 slice와 FSD의 slice. 둘 다 같은 단어를 쓰는 건 우연이 아닙니다. 이들은 모두 <strong>수직 슬라이스 아키텍처(Vertical Slice Architecture)</strong>라는 개념에서 영감을 받았습니다.</p>
<p>전통적인 아키텍처는 <strong>수평(horizontal)</strong>으로 나눕니다:</p>
<pre><code>src/
  components/     ## UI 레이어
  hooks/          ## 로직 레이어
  services/       ## API 레이어
  utils/          ## 유틸리티 레이어</code></pre><p>이런 구조에서 새로운 기능을 추가하거나 수정하려면 어떻게 될까요? 여러 레이어를 오가며 파일을 수정해야 합니다.</p>
<p>&quot;팔로워 수 표시 방식 변경&quot; 요청이 왔다고 해봅시다:</p>
<ol>
<li><code>components/</code>에서 팔로워 수를 표시하는 모든 컴포넌트 찾기</li>
<li><code>hooks/</code>에서 팔로워 데이터를 가져오는 훅 수정</li>
<li><code>utils/</code>에서 숫자 포맷팅 로직 수정</li>
<li><code>services/</code>에서 API 호출 확인</li>
</ol>
<p>수직 슬라이스는 다르게 접근합니다. <strong>수정이 일어나는 방향대로</strong> 코드를 자릅니다:</p>
<pre><code>src/
  user/           ## 수직 슬라이스
    components/
    hooks/
    api/
    utils/
  product/        ## 수직 슬라이스
    components/
    hooks/
    api/
    utils/</code></pre><p>&quot;팔로워 수 표시 방식 변경&quot; 요청이 오면? <code>user/</code> 폴더만 열면 됩니다. UI 컴포넌트도, 데이터 훅도, 포맷팅 유틸도, API 호출도 모두 거기 있으니까요.</p>
<p><strong>핵심은 &quot;수정할 때 여러 계층을 넘나들지 않는 것&quot;입니다.</strong></p>
<p>서론에서 이야기했던 문제들을 기억하시나요?</p>
<ul>
<li>어딜 고쳐야 할지</li>
<li>어딜 빼먹으면 안 되는지</li>
<li>어디까지 영향을 받는지</li>
</ul>
<p>수직 슬라이스는 이 범위를 명확하게 만듭니다. 유저 관련 수정이면 <code>user/</code> 슬라이스를, 제품 관련 수정이면 <code>product/</code> 슬라이스를 보면 됩니다.</p>
<h3 id="왜-slice인가">왜 Slice인가?</h3>
<p>slice를 직역하면 &quot;조각&quot;입니다. 하지만 여기서는 &quot;얇게 자른 한 조각&quot;이라는 의미가 더 중요합니다.</p>
<p>피자를 떠올려보세요. 피자를 수평으로 자르면 도우 층, 토핑 층, 치즈 층으로 나뉩니다(레이어 아키텍처). 하지만 피자를 먹을 때는 수직으로 자릅니다. 한 조각에 도우부터 치즈까지 모든 층이 다 들어있죠(수직 슬라이스).</p>
<p>소프트웨어도 마찬가지입니다. 하나의 slice에는 UI부터 데이터 레이어까지 필요한 모든 것이 들어있습니다. 그리고 그 slice는 <strong>하나의 비즈니스 도메인</strong>을 나타냅니다.</p>
<p>Redux의 <code>userSlice</code>, FSD의 <code>entities/user/</code>, 수직 슬라이스의 <code>user/</code> - 모두 같은 이야기를 하고 있습니다.</p>
<p><strong>도메인 단위로 코드를 묶어두면, 변경도 도메인 단위로 일어납니다.</strong></p>
<p>당신이 Redux Toolkit을 쓰고 있다면, 당신은 이미 도메인 중심으로 상태를 관리하고 있는 겁니다. FSD를 적용하고 있다면, 당신은 이미 도메인 중심으로 전체 코드베이스를 구조화하고 있는 거죠.</p>
<h2 id="저는-이렇게-짜고-있습니다">저는 이렇게 짜고 있습니다</h2>
<p>정답은 없습니다. 하지만 저는 이렇게 짜고 있습니다.</p>
<p>저는 스타트업에서 제품을 만드는 사람 기준으로 이야기하겠습니다. 당연히 이것이 정답은 아니고, 재사용의 기준도 프로젝트마다 다를 수 있습니다. 다만 제 경험을 공유하면서, 여러분이 각자의 맥락에서 적용할 수 있는 인사이트를 드리고자 합니다.</p>
<h3 id="레이어-구조-전체-그림">레이어 구조: 전체 그림</h3>
<p>FSD에서도 그렇지만, 도메인별로 나눈다 하더라도 레이어를 따로 두기 마련입니다.</p>
<p>FSD에서는 pages, app, features, entities, shared 같은 레이어를 둡니다. 레이어는 필요에 의해 기업마다, 프로젝트마다 다를 수 있습니다. </p>
<p>저는 FSD에는 없지만 <code>api/</code>를 레이어로 따로 모아두고, 거기서 도메인별로 분리합니다. 반대로 FSD의 entities와 features는 <code>state/</code>로 합쳤습니다. 이 둘이 불필요하게 도메인을 분할한다고 보기 때문입니다.</p>
<p>그 밖에 Storybook을 쓴다면 <code>stories/</code>, 테스트 코드는 <code>test/</code>로 나눠서 관리합니다.</p>
<pre><code>src/
  pages/       ## 페이지별 UI 구성
  state/       ## 도메인 상태 관리
  api/         ## API 호출 및 데이터 변환
  stories/     ## Storybook
  test/        ## 테스트</code></pre><p>각 레이어를 하나씩 살펴보겠습니다.</p>
<h3 id="pages-페이지별로-격리합니다">Pages: 페이지별로 격리합니다</h3>
<p>Pages에서는 페이지별로, 즉 라우트별로 분리합니다. 하나의 페이지가 있으면:</p>
<pre><code>pages/
  home/
    index.tsx
    hero-section.tsx
    feature-section.tsx</code></pre><p>이런 식으로 구획별로 쪼갠 다음에 <code>index.tsx</code>에서 모아서 구성합니다.</p>
<p>이때 컴포넌트 분리는 재사용성이라기보단 <strong>구획을 나눈다는 느낌</strong>으로 합니다.</p>
<h3 id="도메인-ui는-공통화하지-않습니다">도메인 UI는 공통화하지 않습니다</h3>
<p>서론에서 CardView 예시를 기억하시나요? 포스팅 카드와 제품 카드가 비슷해 보여서 공통화했다가 결국 분기문 투성이가 됐던 이야기 말입니다.</p>
<p>저는 도메인과 관련된 UI는 페이지별로 격리해서 놓습니다. 겉보기엔 비슷한 UI라도 시간이 지나면 아예 달라지는 경우가 많기 때문입니다.</p>
<p>같은 정보를 보여주고 같은 동작을 한다 하더라도, 보여주는 페이지가 강조하고 싶은 정보가 다르기 때문에 UI는 달라지기 마련입니다.</p>
<p>도메인과 관련된 UI는 재사용이 잘 안 됩니다. 단순한 캐러셀이나 토글 버튼 같은 공통 컴포넌트 말고, 실제 비즈니스 도메인과 결합된 UI 말입니다.</p>
<p><strong>저의 전략은 명확합니다: &quot;상태와 로직은 재사용하되, UI는 반복한다.&quot;</strong></p>
<h3 id="pages는-모든-도메인을-참조할-수-있습니다">Pages는 모든 도메인을 참조할 수 있습니다</h3>
<p>저는 pages에서는 단일 책임 원칙 같은 것은 없다고 생각합니다. 어떤 도메인이든 참조될 수 있어야 합니다.</p>
<p>왜냐하면 기획을 하다 보면 &quot;이 정보를 이 페이지에서도 필요하다고?&quot; 하는 경우들이 정말 많기 때문입니다.</p>
<p>예를 들어:</p>
<ul>
<li>결제는 결제 페이지뿐만 아니라 곳곳에 결제 버튼이 있을 수 있습니다</li>
<li>노션의 경우 문서의 타이틀을 수정할 수 있는 곳이 여러 군데입니다</li>
</ul>
<p>그래서 도메인의 상태와 기능들은 pages 어디서든 재사용할 수 있도록 합니다.</p>
<h3 id="state-도메인-상태는-항상-글로벌">State: 도메인 상태는 항상 글로벌</h3>
<p>도메인과 관련된 상태는 항상 글로벌 최상단으로 둡니다.</p>
<pre><code>state/
  user.ts
  post.ts
  cart.ts</code></pre><p>각 파일은 Redux의 slice나 Zustand store를 떠올리시면 됩니다. 상태와 상태를 바라보는 값(computed values)을 한 번에 정의합니다.</p>
<h4 id="list와-detail을-함께-관리합니다">List와 Detail을 함께 관리합니다</h4>
<p>Post 같은 경우는 list로 불러온 것과 현재 보고 있는 Post 객체를 모두 다룹니다.</p>
<pre><code class="language-javascript">// state/post.ts
const usePost = create({
  state: {
    posts: [],           // 리스트
    currentPost: null    // 현재 보고 있는 포스트
  },
  actions: (state) =&gt; ({
    likePost: () =&gt; {
      // currentPost 좋아요 증가
      state.currentPost.likeCount += 1;
      state.currentPost.isLiked = true;

      // posts 배열에서도 동기화
      const post = state.posts.find(p =&gt; p.id === state.currentPost.id);
      if (post) {
        post.likeCount += 1;
        post.isLiked = true;
      }
    },

    addComment: (content) =&gt; {
      // postId를 인자로 받지 않음!
      // 내부에서 currentPost 참조
      const postId = state.currentPost.id;

      // 댓글 추가
      const comment = { postId, content, createdAt: new Date() };
      state.currentPost.comments.push(comment);

      // posts 배열의 댓글 수도 동기화
      const post = state.posts.find(p =&gt; p.id === postId);
      if (post) {
        post.commentCount += 1;
      }
    }
  })
});

// 사용
const { posts, currentPost } = usePost();</code></pre>
<p>이렇게 했을 때 현재 보고 있는 post에 좋아요나 댓글 같은 동작을 수행할 때 list 뷰도 자동으로 동기화됩니다.</p>
<p>서론에서 이야기했던 문제를 기억하시나요? 좋아요를 누르면 여러 함수를 차례차례 호출해야 했고, 하나라도 빼먹으면 정보 갱신이 덜 되는 버그가 생겼던 것 말입니다.</p>
<p><strong>저는 하나의 action에 이런 파생 상태 갱신을 모두 담습니다.</strong> UI에서 직접 차례차례 호출하기보다는, 하나의 동작 안에 상태의 갱신을 모두 포함시키는 겁니다.</p>
<h3 id="action은-순수함수가-아닙니다">Action은 순수함수가 아닙니다</h3>
<p>저는 action을 순수함수로 두지 않습니다. <strong>상태지향적인 함수</strong>로 둡니다.</p>
<pre><code class="language-javascript">// ❌ 순수함수 스타일 - UI에서 상태를 꺼내서 전달
function addComment(postId, content) {
  // ...
}

// UI에서
const { currentPost } = usePost();
addComment(currentPost.id, content);  // postId를 매번 전달

// ✅ 상태지향적 스타일 - 내부에서 상태 참조
function addComment(content) {
  const postId = state.currentPost.id;  // 내부 참조
  // ...
}

// UI에서
addComment(content);  // 간단!</code></pre>
<p>현재 보고 있는 <code>currentPost</code>가 있다면, 댓글 쓰기에서 인자로 <code>postId</code>를 UI에서 받을 필요가 있을까요? </p>
<p>action이 내부적으로 상태를 참조하게 하면, 동작이 수정될 때 UI 호출부는 꿈쩍도 하지 않습니다.</p>
<p><code>currentPost</code>가 없는데 호출하면? 에러를 내거나 무시하게 합니다. 왜냐하면 어차피 순수함수로 두면 UI에서 지속해서 상태를 꺼내서 action 인자로 넣어야 하기 때문입니다.</p>
<h3 id="상태-라이브러리는-추상화합니다">상태 라이브러리는 추상화합니다</h3>
<p>저는 Zustand를 쓰고 있는데, <code>state/</code>에서는 Zustand를 직접 참조하지 않습니다. <code>utils/state</code>를 따로 만들어 거기서 만든 함수로 상태를 정의합니다.</p>
<pre><code class="language-javascript">// utils/state - 추상화 레이어
export function createState(config) {
  // 내부적으로 Zustand 사용
  // 하지만 나중에 Redux로 바꿀 수도 있음
}

// state/post.ts
export const usePost = createState({
  state: { /* ... */ },
  actions: { /* ... */ }
});</code></pre>
<p>이렇게 하면 구현체를 Zustand에서 Redux로 바꿔도 사용하는 곳은 상관없습니다.</p>
<p>더 나아가, 상태 관리 방식 자체를 바꿀 수도 있습니다. Zustand나 Redux 같은 전역 상태 관리 대신 React Query로 서버 상태를 관리하더라도, 인터페이스는 여전히 같습니다:</p>
<pre><code class="language-javascript">// UI 컴포넌트에서
const { posts, currentPost, addComment } = usePost();

// 내부 구현이 Zustand든, Redux든, React Query든
// 사용하는 쪽은 동일한 인터페이스</code></pre>
<p><strong>UI 컴포넌트에서는 특정 상태 라이브러리를 직접적으로 호출하지 않습니다.</strong> 항상 추상화된 훅을 통해 접근합니다.</p>
<h3 id="api-프론트엔드-도메인으로-변환합니다">API: 프론트엔드 도메인으로 변환합니다</h3>
<p>API에서는 UI에 보여야 할 정보를 미리 전처리합니다.</p>
<p>서론에서 이야기했던 팔로워 수나 &quot;며칠 전&quot; 같은 처리들 말입니다:</p>
<ul>
<li><code>1234567</code> → <code>1.2M</code></li>
<li><code>2024-01-15</code> → <code>3일 전</code></li>
</ul>
<p>서버의 리소스를 프론트엔드 도메인에 맞게 가공하는 겁니다.</p>
<h4 id="ui에서-계산하지-않습니다">UI에서 계산하지 않습니다</h4>
<p>예를 들어 포스트의 좋아요 중에서 내가 누른 게 있는지 표시해야 한다면 (하트가 빨간색으로 나와야 하니까), UI나 State에서 여러 정보를 결합해서 계산할 수도 있습니다.</p>
<p>하지만 이런 계산이 계속 반복되면 어떻게 될까요? 모든 곳에서 같은 로직을 작성하게 됩니다.</p>
<p><strong>저는 API 레이어에서 이미 계산해서 전달합니다:</strong></p>
<pre><code class="language-javascript">// api/post.ts
async function getPost(id) {
  const [post, myLikes] = await Promise.all([
    fetchPost(id),
    fetchMyLikes()
  ]);

  return {
    ...post,
    isLikedByMe: myLikes.includes(post.id),  // 이미 계산됨
    followerCountDisplay: formatNumber(post.followerCount),  // &quot;1.2M&quot;
    createdAtDisplay: formatRelativeTime(post.createdAt)  // &quot;3일 전&quot;
  };
}</code></pre>
<p>UI에서는 그냥 <code>post.isLikedByMe</code>를 쓰기만 하면 됩니다.</p>
<h4 id="서버-리소스를-병합합니다">서버 리소스를 병합합니다</h4>
<p>서버의 리소스가 여러 엔드포인트로 나눠져 있다면, API 레이어에서 이것들을 모아서 병합해줍니다.</p>
<p>이것은 BFF(Backend For Frontend) 패턴과 비슷합니다. 프론트엔드가 필요한 형태로 데이터를 재구성하는 거죠.</p>
<p>UI는 그냥 받아서 표시만 하면 됩니다.</p>
<h3 id="ui-컴포넌트의-원칙">UI 컴포넌트의 원칙</h3>
<h4 id="도메인-상태는-props로-받지-않습니다">도메인 상태는 props로 받지 않습니다</h4>
<p>컴포넌트에서 글로벌 도메인 상태가 필요하면 props로 받지 않고 <code>usePost()</code> 같은 훅으로 직접 접근합니다.</p>
<pre><code class="language-javascript">// ❌ props로 전달받기
function PostDetail({ currentPost, posts }) {
  // ...
}

// ✅ 직접 접근
function PostDetail() {
  const { currentPost, posts } = usePost();
  // ...
}</code></pre>
<p>왜냐하면 도메인은 pages 내에서 언제 어디서 어떻게 호출될지가 기획할 때마다 계속 바뀌기 때문입니다.</p>
<h4 id="도메인-객체는-통째로-넘깁니다">도메인 객체는 통째로 넘깁니다</h4>
<p>카드 같은 컴포넌트의 경우, 필요한 요소(title, thumbnail, description)를 각각 받는 것보다:</p>
<pre><code class="language-javascript">// ❌ 필요한 것만
&lt;PostCard 
  title={post.title} 
  thumbnail={post.thumbnail}
  description={post.description}
/&gt;

// ✅ 도메인 객체 통째로
&lt;PostCard post={post} /&gt;</code></pre>
<p>도메인 객체를 통째로 받습니다.</p>
<p>왜냐하면 도메인 객체 내부적으로는 필드가 추가되거나 수정될 확률이 높기 때문입니다. 필요한 것만 넘긴다는 게 오히려 수정의 전파로 이어집니다.</p>
<p>예를 들어 도메인의 어떤 정보를 추가로 보여줘야 한다고 하면, PostCard를 호출하는 쪽도 코드가 수정되어야 합니다. 하지만 통째로 넘기면 이 컴포넌트만 수정하면 됩니다.</p>
<p>&quot;함수가 필요한 최소 정보만 전해준다&quot;는 원칙을 여기서는 쓰지 않습니다. 도메인에 관련해서는 가변성이 크다고 판단하니까요.</p>
<h3 id="의존성은-단방향입니다">의존성은 단방향입니다</h3>
<pre><code>pages → state → api</code></pre><ul>
<li>UI(pages)는 state만 봅니다</li>
<li>state의 action만 api를 호출합니다</li>
<li>(예외: UI에서 직접 api를 호출하는 경우도 있긴 합니다)</li>
</ul>
<p>이런 단방향 구조로 계층을 놓습니다.</p>
<hr>
<p>요지는 명확합니다. <strong>도메인 관점으로 레이어와 코드 담당 범위를 나눕니다.</strong></p>
<p>각 레이어는 도메인별로 구분되어 있고, 변경이 일어나면 그 도메인의 레이어들만 수정하면 됩니다. 여러 계층을 넘나들며 수정할 필요가 없습니다.</p>
<p>이것이 제가 실전에서 도메인 중심 사고를 적용하는 방식입니다.</p>
<h2 id="ai시대에서-더욱-중요해진-설계-능력">AI시대에서 더욱 중요해진 설계 능력</h2>
<p>사실 지금 이런 코드 패턴을 다시 정리하는 이유가 있습니다. 바로 <strong>AI에게 컨텍스트로 제공하기 위해서</strong>입니다.</p>
<p>사용자(개발자)가 AI에게 요청하고 수정하는 것도 결국 도메인 단위입니다. &quot;유저 프로필에 팔로워 수 추가해줘&quot;, &quot;장바구니에서 할인 금액 보여줘&quot; - 이런 식으로 말이죠.</p>
<p>그렇다면 AI 프롬프트도 도메인에 최적화되어 있으면, 바이브 코딩할 때 코드가 꼬이고 복잡해져서 만들었던 거 또 만들거나 수정이 누락되는 일을 막을 수 있지 않을까요?</p>
<p>프로덕트에 코드가 차곡차곡 정리되어서 쌓여있을 수 있지 않을까요?</p>
<p>저는 이런 내용들을 모두 프로젝트 루트의 <code>CLAUDE.md</code>에 넣고, 각 레이어별로도 <code>AI.md</code>를 넣어서 해당 레이어의 코드를 수정할 때는 AI가 꼭 지침을 읽도록 하고 있습니다.</p>
<h3 id="바이브-코딩의-진짜-문제">바이브 코딩의 진짜 문제</h3>
<p>바이브 코딩의 문제가 뭔가요? 처음엔 잘 되다가 프로젝트가 커지면 코드를 수정하지 못하는 겁니다.</p>
<p>그런데 생각해보면, 이것은 사람도 마찬가지입니다. 그래서 우리가 도메인 중심 설계를 이야기하고 있는 거죠.</p>
<p>명확한 지침으로 최대한 안 무너지게, 일관되게 코드 맥락을 정해주면 프로젝트가 커져도 괜찮습니다. 사람도, AI도요.</p>
<p>저희도 이런 노하우를 반영해서 노코드 툴을 만들고 있습니다. (내부적으로는 사설 Git을 호스팅해서 코드를 직접 받을 수도 있고, 푸시하면 바로 사이트에 연동까지 됩니다)</p>
<p>LLM엔진은 Claude Code로 돌리고 있어요.</p>
<p>더 자세한 내용이 궁금하시다면: <a href="https://mvpstar.ai/vibe-coding?utm_source=velog">MVP스타</a></p>
<p>응원해주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[당신의 수준 파악완료: 하지말아야 할 애니메이션 실수들]]></title>
            <link>https://velog.io/@k-svelte-master/ux-pattern-transition</link>
            <guid>https://velog.io/@k-svelte-master/ux-pattern-transition</guid>
            <pubDate>Sun, 21 Sep 2025 00:11:11 GMT</pubDate>
            <description><![CDATA[<p>3년차가 되고 이것저것 할 줄은 아는 것 같은데, 앞으로 무엇을 커리어 방향으로 잡아야 할지 고민했을 때 저는 UX 능력도 갖춘 프론트엔드면 좋을 거라 생각했습니다. 사이드 프로젝트할 때도 혼자서 뚝딱뚝딱 만들어볼 수도 있고 말이죠.</p>
<p>공교롭게도 일각에서는 AI의 등장으로 개발자가 대체되기보다는, 기획자-디자이너-개발자 트라이앵글의 협업 구조가 1인 1프로덕트로 바뀔 수 있다고 말했습니다. 저조차도 현 회사에서 프로덕트 하나당 개발자 1~2명만 담당하고 여기에는 기획과 디자인을 포함하고 있죠. (고맙습니다 클로드 코드!)</p>
<p>그래서 저는 UX 역량을 키울 때 가장 가시적인 능력을 보일 수 있는 게 애니메이션을 적재적소에 하는 거라고 생각합니다. 스크롤 애니메이션으로 랜딩페이지를 화려하게 만드는 것도 재밌고, 로딩 애니메이션을 부여할 수도 있죠. </p>
<p>그중에서 저는 <strong>트랜지션</strong>을 좋아합니다. 멋지기도 하지만 실용적이기도 하거든요! 갑작스러운 변화는 사용자에게 부담을 느끼게 합니다. 그래서 앱에서는 트랜지션이 많이 적용되죠. 이런 추세가 웹에도 퍼지고 있습니다. </p>
<p>유튜브 뮤직을 보면 페이지 경로가 이동하지만 트랜지션 효과도 같이 부여되어서 마치 내가 페이지를 떠나지 않고 콘텐츠만 바뀐다고 생각하죠. 인스타그램 웹도 마찬가지고요. 저는 주기적으로 이런 웹사이트들의 UX를 분석하는데 매번 유저들이 어떻게 하면 콘텐츠를 쉽게 이동하며 볼 수 있을까 고민의 흔적이 느껴집니다. 은근히 자주 업데이트하더라고요. 그 덕에 저는 오늘도 하루종일 유튜브와 인스타그램을 봅니다 ㅋㅋ</p>
<p>하여튼 트랜지션은 Google의 Material Design 팀과 Uber에서도 중요하게 생각합니다.</p>
<ul>
<li><a href="https://m3.material.io/styles/motion/transitions/applying-transitions">Material Design - Transitions</a></li>
<li><a href="https://base.uber.com/6d2425e9f/v/0/p/312fab-transitions">Uber Base Design - Transitions</a></li>
</ul>
<p>여기서는 잘된 트랜지션 소개와 함께 <strong>잘못된 트랜지션</strong>도 소개합니다. 설계 자체가 잘못되었거나 쓰임새를 잘못 쓰고 있는 거죠.</p>
<p>트랜지션에서도 패턴이 있다는 거 알고 계셨나요? 전환 효과가 그저 이쁜 게 적용되는 게 아니라, 페이지 이동 구조에 따라 어느 정도 정답지가 있습니다. 오늘은 전문가들이 &quot;절대 하지 마라&quot;고 하는 트랜지션 실수들을 알아보도록 하겠습니다.</p>
<h2 id="전문가들이-절대-하지-마라고-하는-트랜지션-실수들">전문가들이 &quot;절대 하지 마라&quot;고 하는 트랜지션 실수들</h2>
<p>Material Design과 Uber Base 가이드라인을 분석하면서 발견한 건데, 개발자들이 무심코 저지르는 트랜지션 실수들에는 확실한 패턴이 있더라고요. 이런 실수들은 사용자에게 &quot;아, 이 앱 뭔가 어색하네&quot;라는 느낌을 주죠.</p>
<h3 id="1-어-화면이-뿌옇게-겹쳐-보이네">1. 어? 화면이 뿌옇게 겹쳐 보이네?</h3>
<p>fade 효과로 콘텐츠가 바뀔 때 이전 콘텐츠가 사라지고, 새로운 콘텐츠가 생기는 상황을 생각해보세요. 이때 이전 콘텐츠가 사라지고 난 다음을 기다리고, 새로운 콘텐츠가 나오도록 기다려야 합니다. 동시에 오버랩을 하면 지저분해 보이거든요.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/a4d97278-2bad-4a4f-a244-f0956765677d/image.gif" alt="fade"></p>
<p>&lt;출처: <a href="https://m3.material.io/styles/motion/transitions/applying-transitions">Material Design - Transitions</a>&gt;</p>
<p>Material Design에서는 &quot;기존 콘텐츠를 완전히 사라지게 한 후에 새 콘텐츠를 나타내라&quot;고 명시합니다. Uber에서는 한 발 더 나아가서 등장 fade 시간보다 사라지는 fade 시간을 더 짧게 가져가길 권유합니다. 이러면 답답함 없이 자연스럽게 전환되죠.</p>
<p><strong>특히 주의할 점</strong>: 공유 요소가 확장하면서 전환될 때도 오버랩을 피해야 합니다. 카드가 전체 화면으로 확장되는 애니메이션에서 배경과 겹치면서 뿌옇게 보이는 경우가 대표적입니다.</p>
<p>참고로 Framer Motion의 AnimatePresence 컴포넌트에는 <code>mode=&quot;wait&quot;</code> prop이 있어서 이런 걸 쉽게 구현할 수 있어요.</p>
<h3 id="2-hero-요소는-하나만-나머지는-제자리에서">2. Hero 요소는 하나만, 나머지는 제자리에서!</h3>
<p>공통된 요소만 모두 트랜지션 효과를 주는 실수입니다. 저는 이걸 Hero 애니메이션이라고 하는데, 계층 이동할 때 공유된 요소가 움직이면서 전환 효과를 주면 수려한 효과를 낼 수 있습니다. <strong>하지만 정도껏!</strong></p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/4c459b65-b929-46b3-b79c-c8498f8eaf3e/image.gif" alt="hero">
&lt;출처: <a href="https://m3.material.io/styles/motion/transitions/applying-transitions">Material Design - Transitions</a>&gt;</p>
<p>인스타그램이나 핀터레스트에서 썸네일이 전체화면으로 확장되는 효과를 생각해보세요. 이런 확장하듯 펼쳐지는 트랜지션은 자주 활용되니까 주의하면 좋습니다.</p>
<p><strong>주축을 만들어야 하는 이유</strong>: 공유 요소가 움직이면서 중간에 겹치면 이상해 보이기 때문입니다. Hero 요소 하나만 멋지게 변환시키고, 나머지는 fade 효과로 그자리에서 등장하는 게 훨씬 깔끔해 보여요.</p>
<h3 id="3-아무-애니메이션이나-넣으면-되는-줄-알았지">3. 아무 애니메이션이나 넣으면 되는 줄 알았지?</h3>
<h4 id="계층-이동에-탭용-슬라이드-넣기">계층 이동에 탭용 슬라이드 넣기</h4>
<p>계층 이동할 때는 drill forward 방식을 써야 합니다. 나가는(out) 페이지는 모션을 짧게(30px 정도만 translate) 해서 모션감을 줄이고, 들어오는(in) 페이지는 그대로 입장하는 방식이죠. 탭에서나 쓰는 전체 페이지 슬라이드와는 완전히 다른 접근법입니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/345f0c40-04b1-4dec-876c-8df8061a318d/image.gif" alt="계층">
&lt;출처: <a href="https://m3.material.io/styles/motion/transitions/applying-transitions">Material Design - Transitions</a>&gt;</p>
<p><strong>해결 방법</strong>: 계층 이동에는 drill forward 패턴으로 &quot;들어간다&quot;는 느낌을 자연스럽게 표현하세요.</p>
<h4 id="top-level에서-형제관계용-슬라이드-사용하기">Top Level에서 형제관계용 슬라이드 사용하기</h4>
<p>바텀 네비게이션바나 헤더로 움직이는 것들은 top level 이동입니다. 즉, 페이지 간의 연관성이 별로 없어요. 이런 데 형제 관계에서 할 법한 슬라이딩 효과는 어색합니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/b372aff8-da57-44c8-ae56-297bf286106b/image.gif" alt="top-level"></p>
<p>&lt;출처: <a href="https://m3.material.io/styles/motion/transitions/applying-transitions">Material Design - Transitions</a>&gt;</p>
<p>슬라이드 트랜지션은 onboarding이나 회원가입 퍼널처럼 &quot;같은 레벨의 계층에서 관련된 전체의 일부&quot;를 보여줄 때 적합합니다. 화면들이 수평으로 함께 움직이면서 &quot;관련된 전체의 일부&quot;라는 느낌을 주거든요.</p>
<p><strong>해결 방법</strong>: Top level 효과는 fade가 가장 단순한 방법이고, awwwards 사이트에 가보면 브랜드 효과를 내는 참신한 효과들도 많이 만나볼 수 있어요.</p>
<hr>
<p>이런 실수들을 피하는 것만으로도 사용자는 &quot;어? 이 앱 뭔가 자연스럽네&quot;라고 느끼게 됩니다. 하지만 매번 이런 걸 신경 쓰면서 개발하기는 쉽지 않죠. 그래서 다음에는 이런 문제들을 쉽게 해결해줄 수 있는 도구를 소개해드리려고 합니다.</p>
<h2 id="트랜지션-패턴-3가지">트랜지션 패턴 3가지</h2>
<p>트랜지션 패턴에는 정답이 있습니다. 페이지 이동 구조에 따라 어느 정도 정답지가 있다는 거죠. 그래서 저는 <strong>ssgoi</strong>라는 웹 페이지 이동 애니메이션 라이브러리를 만들어서 이를 크게 3가지 카테고리로 나눴습니다.</p>
<h3 id="1-top-level-이동">1. Top Level 이동</h3>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/da940540-74a3-4c8f-aaee-e508d9a5b8b0/image.gif" alt="https://ssgoi.dev/en">
&lt;출처: <a href="https://ssgoi.dev/ko&gt;">https://ssgoi.dev/ko&gt;</a></p>
<p>홈에서 설정으로, 쇼핑에서 마이페이지로... 전혀 관련 없는 페이지 간 이동입니다. 바텀 네비게이션이나 헤더 메뉴를 통한 이동이 대표적이죠. 페이지 간의 연관성이 별로 없기 때문에 컨텍스트가 완전히 바뀌었음을 자연스럽게 전달해야 합니다.</p>
<p>저는 Flim이 영사기 앞에서 전환되는듯한 효과를 표현해 봤습니다.</p>
<h3 id="2-형제-관계-이동">2. 형제 관계 이동</h3>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/6d67625a-578b-4939-baf8-1ddda1be3458/image.gif" alt="https://ssgoi.dev/en">
&lt;출처: <a href="https://ssgoi.dev/ko/docs&gt;">https://ssgoi.dev/ko/docs&gt;</a></p>
<p>블로그 포스트 간 이동, 온보딩 스텝, 갤러리 이미지 넘기기... 같은 레벨의 콘텐츠를 순차적으로 탐색하는 상황입니다. 인스타그램에서 릴스 넘길 때를 떠올려보세요. 웹에서도 한번 확인해보시면 비슷한 패턴을 발견할 수 있을 거예요. 연속성과 순서감을 주는 게 핵심입니다.</p>
<p>여기서는 문서를 탐색할때 마치 스크롤로 이동하는 듯한 효과를 표현했습니다.</p>
<h3 id="3-계층-이동">3. 계층 이동</h3>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/99c072f8-c5a5-41e1-a437-54783cda2a93/image.gif" alt="https://ssgoi.dev/en">
&lt;출처: <a href="https://ssgoi.dev/ko/blog&gt;">https://ssgoi.dev/ko/blog&gt;</a></p>
<p>제품 목록에서 상세 페이지로, 갤러리에서 풀스크린으로... 더 깊은 레벨로 들어가거나 나오는 상황입니다. 많이 보셨을 거예요. 스택처럼 쌓이듯 가거나, 카드가 확대되면서 상세 화면으로 변하는 그런 느낌이죠. 사용자가 &quot;더 깊이 들어갔다&quot;는 것을 시각적으로 알려주는 게 중요합니다.</p>
<h3 id="더-자세한-내용은">더 자세한 내용은...</h3>
<p>각 트랜지션 패턴의 구체적인 사용법과 실제 구현 예시는 제가 별도로 정리한 글에서 확인하실 수 있어요:</p>
<p><strong><a href="https://ssgoi.dev/ko/blog/view-transitions-types">View Transition의 3가지 패러다임과 실전 활용법</a></strong></p>
<p>여기서는 실제 데모와 함께 언제 어떤 패턴을 써야 하는지 정리했습니다!</p>
<h2 id="그래서-결국">그래서 결국...</h2>
<p>저는 트랜지션이 &quot;있으면 좋고 없어도 되는&quot; 옵션이 아니라고 생각합니다. 사용자가 &quot;이 앱/웹사이트 잘 만들었네&quot;라고 느끼는 순간들 중 상당 부분이 이런 디테일에서 나온다고 보거든요. 물론 기능이 제대로 작동하는 게 먼저지만, 같은 기능이라면 더 자연스럽고 부드러운 경험을 주는 쪽이 승리한다고 생각해요. 물론 성능과 주 사용층 유저 기기의 성능을 고려해서 해야겠지만요.</p>
<p>이런 거 넣는 게 어렵긴 합니다. 무작정 화려한 애니메이션을 넣는 건 오히려 독이 될 수 있고요. 중요한 건 &quot;왜 이 전환을 쓰는지&quot; 이해하는 것이라고 생각해요. 사용자가 지금 어디에 있고, 어디로 가고 있는지를 자연스럽게 알려주는 것. 그게 바로 UX 감각을 갖춘 개발자와 그렇지 않은 개발자의 차이가 아닐까 싶습니다. 앞으로 이런 디테일에 조금씩 관심을 가져보시면, 분명 사용자도, 동료도, 그리고 본인도 만족할 만한 결과를 얻을 수 있을 거라 생각합니다.</p>
<p><a href="https://github.com/meursyphus/ssgoi">github</a> &lt;-- star 눌러주시면 큰 도움이 됩니다!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹에서 화면이 전환될 때 벌어지는 일을 아시나요? 🤯]]></title>
            <link>https://velog.io/@k-svelte-master/do-you-know-web-view-transition</link>
            <guid>https://velog.io/@k-svelte-master/do-you-know-web-view-transition</guid>
            <pubDate>Sat, 26 Jul 2025 07:31:34 GMT</pubDate>
            <description><![CDATA[<p>웹에서 화면이 전환될 때 벌어지는 일:</p>
<ol>
<li>현재 DOM 트리 파괴 ❌</li>
<li>새로운 DOM 트리 생성 🆕  </li>
<li>리페인트 &amp; 리플로우 🎨</li>
</ol>
<p>이 0.03초 동안 사용자의 뇌는 &#39;맥락 상실&#39;을 경험합니다.
앱에서는 이미 해결한 문제인데, 웹은 왜 아직일까요?</p>
<p>시니어 프론트엔드 개발자들은 이 문제를 알고 있습니다. 그리고 해결하려 노력합니다.
하지만 기존 솔루션들은 한계가 명확했죠.</p>
<p>이 글에서는 <strong>SSGOI(<a href="https://ssgoi.dev)%EA%B0%80">https://ssgoi.dev)가</a> 어떻게 이 문제를 해결했는지</strong>, 그 동작 원리와 구현 과정을 깊이 있게 살펴보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/7369829b-9972-4ec7-acf2-e1aeb2996fdb/image.gif" alt="https://ssgoi.dev"></p>
<h2 id="🔍-dom-애니메이션의-근본적인-문제">🔍 DOM 애니메이션의 근본적인 문제</h2>
<p>프론트엔드 개발자라면 한 번쯤 이런 고민을 해보셨을 겁니다:</p>
<p>&quot;페이지가 전환될 때 요소가 사라지기 전에 애니메이션을 주고 싶은데...&quot;</p>
<p>간단해 보이지만, 실제로는 복잡한 문제입니다:</p>
<pre><code class="language-javascript">// 이상적인 코드 (하지만 동작하지 않음)
if (shouldRemove) {
  element.classList.add(&#39;fade-out&#39;); // 애니메이션 추가
  await sleep(300); // 애니메이션 대기
  element.remove(); // 그 다음 제거
}</code></pre>
<p>문제는 React, Vue 같은 프레임워크는 <strong>선언적</strong>으로 동작한다는 점입니다. 컴포넌트가 언마운트되면 DOM은 즉시 사라집니다. 애니메이션을 기다려주지 않죠.</p>
<h2 id="🧩-ssgoi의-핵심-아이디어-dom-생명주기-가로채기">🧩 SSGOI의 핵심 아이디어: DOM 생명주기 가로채기</h2>
<p>SSGOI의 핵심은 <strong>DOM 요소의 생성과 소멸 시점을 가로채는 것</strong>입니다. 이를 통해 애니메이션을 삽입할 타이밍을 확보합니다.</p>
<pre><code>┌─────────────────────────────────────────────────────┐
│                  일반적인 DOM 생명주기                │
├─────────────────────────────────────────────────────┤
│                                                     │
│  생성 ──────&gt; 렌더링 ──────&gt; 언마운트 ──────&gt; 제거    │
│                                                     │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│                 SSGOI의 DOM 생명주기                 │
├─────────────────────────────────────────────────────┤
│                                                     │
│  생성 ──┬──&gt; [IN 애니메이션] ──&gt; 렌더링              │
│         │                                           │
│         └──&gt; 언마운트 ──&gt; [OUT 애니메이션] ──&gt; 제거  │
│                                                     │
└─────────────────────────────────────────────────────┘</code></pre><h2 id="🤔-기존-방식의-한계와-ssgoi의-해결책">🤔 기존 방식의 한계와 SSGOI의 해결책</h2>
<h3 id="chrome의-view-transition-api의-한계">Chrome의 View Transition API의 한계</h3>
<pre><code class="language-javascript">// 크롬에서만 동작하는 코드
if (document.startViewTransition) {
  document.startViewTransition(() =&gt; {
    // 페이지 전환
  });
}
// Firefox, Safari 사용자는? 🤷‍♂️</code></pre>
<p>View Transition API는 멋지지만 <strong>크롬 전용</strong>입니다. 전체 사용자의 30%는 이 경험을 할 수 없죠.</p>
<h3 id="css-트릭만으로는-부족했던-이유">CSS 트릭만으로는 부족했던 이유</h3>
<pre><code class="language-css">.page-enter {
  animation: fadeIn 0.3s ease-in;
}</code></pre>
<p>CSS 애니메이션은 간단하지만:</p>
<ul>
<li>페이지 간 상태 공유 불가능</li>
<li>동적인 전환 효과 구현 어려움</li>
<li>스크롤 위치, 요소 위치 추적 불가</li>
</ul>
<h3 id="프레임워크별-라우팅-시스템의-차이">프레임워크별 라우팅 시스템의 차이</h3>
<ul>
<li>React Router의 <code>&lt;Link&gt;</code></li>
<li>Vue Router의 <code>router-link</code></li>
<li>Next.js의 <code>next/link</code></li>
<li>SvelteKit의 <code>goto()</code></li>
</ul>
<p>각자 다른 방식으로 동작하는데, 이걸 하나로 통합하는 게 쉬울까요? </p>
<h2 id="🏗️-ssgoi의-아키텍처-3층-구조">🏗️ SSGOI의 아키텍처: 3층 구조</h2>
<h3 id="1-core-layer-애니메이션-엔진">1. Core Layer: 애니메이션 엔진</h3>
<p>SSGOI의 핵심은 <strong>스프링 물리 기반 애니메이션 엔진</strong>입니다:</p>
<pre><code class="language-typescript">// 실제 SSGOI의 타입 정의
interface TransitionConfig {
  // 스프링 물리 설정
  spring?: {
    stiffness: number;  // 강성: 얼마나 빠르게 목표에 도달할지
    damping: number;    // 감쇠: 얼마나 부드럽게 멈출지
  };

  // 매 프레임마다 호출되는 콜백
  tick?: (progress: number) =&gt; void;

  // 애니메이션 시작 전 준비
  prepare?: (element: HTMLElement) =&gt; void;

  // 생명주기 훅
  onStart?: () =&gt; void;
  onEnd?: () =&gt; void;
}</code></pre>
<p><strong>핵심 인사이트</strong>: <code>progress</code>는 단순한 0-1 값이 아닙니다. 스프링 물리 엔진이 생성하는 자연스러운 곡선입니다.</p>
<pre><code>progress 값의 변화 (스프링 물리)
┌─────────────────────────────────┐
│ 1.2 ┤     ╭─╮                  │  오버슈트
│ 1.0 ┤   ╭─╯  ╰─────────        │  (자연스러운 바운스)
│ 0.8 ┤  ╱                       │
│ 0.6 ┤ ╱                        │
│ 0.4 ┤╱                         │
│ 0.2 ┤                          │
│ 0.0 ┴────────────────────      │
└─────────────────────────────────┘
      시간 →</code></pre><h3 id="2-transition-callback-layer-생명주기-관리">2. Transition Callback Layer: 생명주기 관리</h3>
<p>이 레이어가 SSGOI의 핵심 마법이 일어나는 곳입니다:</p>
<pre><code class="language-typescript">// 실제 구현의 핵심 로직
export function createTransitionCallback(
  getTransition: () =&gt; Transition,
  options?: { onCleanupEnd?: () =&gt; void }
): TransitionCallback {
  let currentAnimation: { animator: Animator; direction: &quot;in&quot; | &quot;out&quot; } | null = null;
  let currentClone: HTMLElement | null = null;

  return (element: HTMLElement | null) =&gt; {
    if (!element) return;

    // 1. 요소가 마운트될 때: IN 애니메이션 실행
    runEntrance(element);

    // 2. cleanup 함수 반환 (React의 useEffect cleanup과 유사)
    return () =&gt; {
      // 3. 요소가 언마운트될 때: 복제본 생성 후 OUT 애니메이션
      const cloned = element.cloneNode(true) as HTMLElement;
      runExitTransition(cloned);
    };
  };
}</code></pre>
<p><strong>핵심 트릭</strong>: 요소가 제거될 때 <strong>복제본을 생성</strong>하여 애니메이션을 계속 진행합니다!</p>
<pre><code>언마운트 시 동작 과정:
┌────────────────────────────────────────────────┐
│ 1. React가 컴포넌트 언마운트 시작              │
│    └─&gt; cleanup 함수 호출                      │
│                                               │
│ 2. SSGOI가 DOM 복제본 생성                     │
│    └─&gt; 원본과 동일한 위치에 삽입              │
│                                               │
│ 3. 원본 DOM 제거 (React에 의해)                │
│    └─&gt; 사용자는 복제본을 보고 있음            │
│                                               │
│ 4. 복제본에서 OUT 애니메이션 실행              │
│    └─&gt; 애니메이션 완료 후 복제본도 제거        │
└────────────────────────────────────────────────┘</code></pre><h3 id="3-framework-adapter-layer-프레임워크별-통합">3. Framework Adapter Layer: 프레임워크별 통합</h3>
<p>각 프레임워크는 DOM을 다루는 방식이 다릅니다. SSGOI는 이를 추상화합니다:</p>
<pre><code class="language-typescript">// React Adapter
export function transition(options: TransitionOptions) {
  const callback = createTransitionCallback(/* ... */);

  // React의 ref 패턴 활용
  return (element: HTMLElement | null) =&gt; {
    if (element) {
      // React는 ref가 변경될 때마다 이전 cleanup을 호출
      return callback(element);
    }
  };
}

// Svelte Adapter  
export function transition(node: HTMLElement, options: TransitionOptions) {
  const cleanup = createTransitionCallback(/* ... */)(node);

  // Svelte의 action 패턴
  return {
    destroy() {
      cleanup?.();
    }
  };
}</code></pre>
<h2 id="💡-실제-동작-예시-문서-간-스크롤-전환">💡 실제 동작 예시: 문서 간 스크롤 전환</h2>
<p>SSGOI 문서 사이트에서 실제로 사용하는 스크롤 전환을 예시로 살펴보겠습니다:</p>
<h3 id="설정-코드">설정 코드</h3>
<pre><code class="language-typescript">// 문서 네비게이션 설정
const transitions = [
  {
    from: &#39;/docs/introduction&#39;,
    to: &#39;/docs/quick-start&#39;,
    transition: scroll({ 
      direction: &#39;up&#39;,  // 다음 페이지로 갈 때는 위로
      spring: { stiffness: 20, damping: 7 }  // 부드러운 스프링 설정
    })
  },
  {
    from: &#39;/docs/quick-start&#39;,
    to: &#39;/docs/introduction&#39;,
    transition: scroll({ 
      direction: &#39;down&#39;,  // 이전 페이지로 갈 때는 아래로
      spring: { stiffness: 20, damping: 7 }
    })
  }
];</code></pre>
<h3 id="동작-과정">동작 과정</h3>
<pre><code>시간 흐름 →
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

T0: Introduction 페이지 (현재)
┌─────────────────────────┐
│ # SSGOI 소개            │
│                         │
│ SSGOI는 웹에서 네이티브 │
│ 앱과 같은 자연스러운... │
│                         │
│ [다음: Quick Start →]   │ ← 사용자 클릭
└─────────────────────────┘

T1: 전환 시작 (DOM 복제 발생)
┌─────────────────────────┐ 
│ # SSGOI 소개 [복제본]   │ ← OUT 애니메이션
│                         │    translateY: 0 → -100%
│ SSGOI는 웹에서 네이티브 │
│ 앱과 같은 자연스러운... │
└─────────────────────────┘
              ↑ 위로 스크롤되며 사라짐

T2: 새 페이지 진입
              ↓ 아래에서 올라옴
┌─────────────────────────┐
│ # Quick Start           │ ← IN 애니메이션
│                         │    translateY: 100% → 0
│ SSGOI를 시작하는 가장   │
│ 빠른 방법을 알아봅시다  │
└─────────────────────────┘

T3: 전환 완료
┌─────────────────────────┐
│ # Quick Start           │
│                         │
│ SSGOI를 시작하는 가장   │
│ 빠른 방법을 알아봅시다  │
│                         │
│ [← 이전] [다음 →]       │
└─────────────────────────┘</code></pre><h3 id="핵심-구현-원리">핵심 구현 원리</h3>
<pre><code class="language-typescript">// scroll 전환 효과의 실제 구현
export const scroll = (options: ScrollOptions = {}): SggoiTransition =&gt; {
  const isUp = options.direction === &quot;up&quot;;

  return {
    in: (element) =&gt; ({
      spring: { stiffness: 20, damping: 7 },
      tick: (progress) =&gt; {
        // progress: 0 → 1
        const translateY = isUp 
          ? (1 - progress) * 100   // 100% → 0 (아래에서 위로)
          : (1 - progress) * -100; // -100% → 0 (위에서 아래로)
        element.style.transform = `translateY(${translateY}%)`;
      }
    }),
    out: (element) =&gt; ({
      prepare: prepareOutgoing,  // 복제본을 절대 위치로 고정
      tick: (progress) =&gt; {
        // progress: 1 → 0  
        const translateY = isUp
          ? (1 - progress) * -100  // 0 → -100% (위로 사라짐)
          : (1 - progress) * 100;  // 0 → 100% (아래로 사라짐)
        element.style.transform = `translateY(${translateY}%)`;
      }
    })
  };
};</code></pre>
<p>이렇게 마치 <strong>연속된 문서를 스크롤하듯</strong> 자연스럽게 페이지가 전환됩니다!</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/71f1db42-e1aa-402a-ac6a-07bdd93adbdb/image.gif" alt=""></p>
<h2 id="🔧-성능-최적화-전략">🔧 성능 최적화 전략</h2>
<h3 id="1-스프링-물리-vs-css-transition">1. 스프링 물리 vs CSS Transition</h3>
<p>왜 CSS transition 대신 JavaScript 스프링 물리를 선택했을까요?</p>
<pre><code class="language-css">/* CSS Transition의 한계 */
.fade-out {
  transition: opacity 300ms ease-out;
  opacity: 0;
}</code></pre>
<p>문제점:</p>
<ul>
<li>중간에 멈추거나 방향을 바꿀 수 없음</li>
<li>한 번 시작하면 끝까지 가야 함</li>
<li>복잡한 곡선 표현 불가</li>
</ul>
<pre><code class="language-typescript">// 스프링 물리의 장점
const animator = new Animator({
  spring: { stiffness: 300, damping: 30 },
  onUpdate: (progress) =&gt; {
    // 언제든 중단, 역방향, 속도 변경 가능
    element.style.opacity = progress;
  }
});

// 사용자가 빠르게 클릭하면?
animator.reverse(); // 즉시 반대 방향으로</code></pre>
<h3 id="2-프레임-드롭-방지">2. 프레임 드롭 방지</h3>
<p>SSGOI는 <code>requestAnimationFrame</code>을 통해 브라우저의 렌더링 주기와 동기화됩니다:</p>
<pre><code class="language-typescript">// Popmotion 라이브러리 활용
import { animate } from &quot;popmotion&quot;;

// 60fps 유지를 위한 최적화
this.controls = animate({
  from: this.currentValue,
  to: target,
  velocity: this.velocity * 1000,
  stiffness: this.options.spring.stiffness,
  damping: this.options.spring.damping,

  onUpdate: (value: number) =&gt; {
    // RAF와 동기화되어 프레임 드롭 최소화
    this.currentValue = value;
    this.options.onUpdate(value);
  }
});</code></pre>
<h2 id="🚀-실전-활용-고급-패턴과-팁">🚀 실전 활용: 고급 패턴과 팁</h2>
<h3 id="1-상태-기반-전환-전략">1. 상태 기반 전환 전략</h3>
<p>SSGOI의 강력한 기능 중 하나는 <strong>전환 전략(Transition Strategy)</strong>입니다:</p>
<pre><code class="language-typescript">// 애니메이션이 진행 중일 때 방향이 바뀌면?
const strategy = (context: StrategyContext) =&gt; ({
  async runIn(configs) {
    const { currentAnimation } = context;

    if (currentAnimation?.direction === &quot;out&quot;) {
      // OUT 애니메이션 중이면 현재 상태에서 역방향
      return {
        config: await configs.in,
        state: currentAnimation.animator.getCurrentState(),
        direction: &quot;backward&quot;,
        from: 1,
        to: 0
      };
    }

    // 기본: 0에서 1로
    return {
      config: await configs.in,
      state: { position: 0, velocity: 0 },
      direction: &quot;forward&quot;,
      from: 0,
      to: 1
    };
  }
});</code></pre>
<h3 id="2-조건부-애니메이션">2. 조건부 애니메이션</h3>
<pre><code class="language-typescript">// 모바일에서는 단순하게, 데스크톱에서는 화려하게
const responsiveTransition = {
  in: (element) =&gt; ({
    spring: {
      stiffness: window.innerWidth &gt; 768 ? 300 : 500,
      damping: window.innerWidth &gt; 768 ? 30 : 40
    },
    tick: (progress) =&gt; {
      if (window.innerWidth &gt; 768) {
        // 데스크톱: 3D 회전 + 스케일
        element.style.transform = `
          perspective(1000px)
          rotateY(${90 * (1 - progress)}deg)
          scale(${0.8 + 0.2 * progress})
        `;
      } else {
        // 모바일: 단순 페이드
        element.style.opacity = progress;
      }
    }
  })
};</code></pre>
<h3 id="3-공유-요소-애니메이션-hero-transition">3. 공유 요소 애니메이션 (Hero Transition)</h3>
<p>Instagram 스토리처럼 요소가 화면을 넘나드는 효과:</p>
<pre><code class="language-typescript">// 상품 이미지가 리스트에서 상세 페이지로 이동하는 효과
const heroTransition = {
  out: async (element) =&gt; {
    const rect = element.getBoundingClientRect();

    return {
      prepare: (el) =&gt; {
        // 절대 위치로 고정
        el.style.position = &#39;fixed&#39;;
        el.style.top = `${rect.top}px`;
        el.style.left = `${rect.left}px`;
        el.style.width = `${rect.width}px`;
        el.style.height = `${rect.height}px`;
      },
      tick: (progress) =&gt; {
        // 화면 중앙으로 이동하며 확대
        const scale = 1 + (2 - 1) * (1 - progress);
        const x = (window.innerWidth / 2 - rect.left - rect.width / 2) * (1 - progress);
        const y = (window.innerHeight / 2 - rect.top - rect.height / 2) * (1 - progress);

        element.style.transform = `
          translate(${x}px, ${y}px)
          scale(${scale})
        `;
      }
    };
  }
};</code></pre>
<h2 id="💭-마무리-웹의-미래를-향해">💭 마무리: 웹의 미래를 향해</h2>
<p>SSGOI를 만들면서 깨달은 것은, <strong>웹과 앱의 경계가 점점 흐려지고 있다</strong>는 점입니다. </p>
<p>과거에는 &quot;웹은 문서, 앱은 애플리케이션&quot;이라는 명확한 구분이 있었지만, 이제 웹도 충분히 풍부한 인터랙션을 제공할 수 있습니다.</p>
<h3 id="핵심-교훈">핵심 교훈</h3>
<ol>
<li><p><strong>선언적 UI의 한계를 극복하는 방법은 있다</strong></p>
<ul>
<li>DOM 복제, 생명주기 가로채기 등 창의적인 해결책</li>
</ul>
</li>
<li><p><strong>물리 기반 애니메이션이 자연스러움의 핵심</strong></p>
<ul>
<li>CSS보다 복잡하지만, 그만한 가치가 있음</li>
</ul>
</li>
<li><p><strong>프레임워크 중립적 설계의 중요성</strong></p>
<ul>
<li>코어를 잘 설계하면 어떤 프레임워크든 지원 가능</li>
</ul>
</li>
</ol>
<h3 id="다음-도전-과제">다음 도전 과제</h3>
<ul>
<li><strong>더 많은 프레임워크 지원</strong>: 리액트, 스벨트를 넘어 현대 모든 웹프레임워크</li>
<li><strong>접근성 향상</strong>: 모션 감소 설정 지원</li>
</ul>
<h2 id="🚀-시작하기">🚀 시작하기</h2>
<pre><code class="language-bash"># React
npm install @ssgoi/react

# Svelte  
npm install @ssgoi/svelte

# Vue (Coming Soon)
npm install @ssgoi/vue</code></pre>
<pre><code class="language-jsx">// 단 3줄로 시작 +layout.tsx
import { Ssgoi } from &#39;@ssgoi/react&#39;;
import { fade } from &#39;@ssgoi/react/view-transitions&#39;;

&lt;Ssgoi config={{ defaultTransition: fade() }}&gt;
  {children}
&lt;/Ssgoi&gt;</code></pre>
<h3 id="함께-만들어가요-🙏">함께 만들어가요 🙏</h3>
<p><a href="https://github.com/meursyphus/ssgoi">GitHub: meursyphus/ssgoi</a> ⭐</p>
<p>이 글이 여러분의 웹 개발 여정에 새로운 관점을 제공했기를 바랍니다. </p>
<p><strong>웹을 더 부드럽게, 더 자연스럽게, 더 아름답게.</strong></p>
<p>그것이 SSGOI의 미션입니다. 🎯</p>
<p>UTM 파라미터가 추가된 링크와 함께 블로그 글 마지막에 넣을 수 있는 소개 문구를 만들어드릴게요:</p>
<hr>
<p><strong>🔗 참고로 이력서 서비스 운용하고 있습니다. 한번 둘러봐주세용</strong> 👀✨</p>
<p><strong>AI 이력서 분석 서비스</strong>: <a href="https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=ssgoi">여기!</a></p>
<p>PDF 업로드만으로 30초 안에 이력서 점수와 개선점을 확인할 수 있어요! 현직 개발자이자 채용 담당자가 만든 AI 분석으로 더욱 정확한 피드백을 받아보세요 📝</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[비전공자도 개발지식은 언제나 중요합니다]]></title>
            <link>https://velog.io/@k-svelte-master/tcp-slow-qwik-jjang</link>
            <guid>https://velog.io/@k-svelte-master/tcp-slow-qwik-jjang</guid>
            <pubDate>Fri, 11 Jul 2025 21:32:35 GMT</pubDate>
            <description><![CDATA[<p>비전공자도 개발지식은 언제나 중요합니다.</p>
<p><strong>1KB 줄인다고 뭐가 달라지나요?</strong></p>
<p>저는 비전공자 출신 개발자입니다. 그동안 성능 최적화라고 하면 단순히 &quot;코드를 더 효율적으로 작성하고, 번들 사이즈를 줄이면 되겠지&quot;라고 생각했습니다. 코드 스플리팅을 하면 선형적으로 로딩 시간이 줄어들 거라고 막연히 믿고 있었죠.</p>
<p>그런데 최근 지인이 Qwik이라는 프레임워크를 소개해주면서, 제 생각이 완전히 바뀌었습니다. &quot;Qwik이 왜 이렇게 빠른지 아냐?&quot;라는 질문에서 시작된 대화는 TCP slow start라는 개념으로 이어졌고, 저에게는 완전히 새로운 세상이 열렸습니다.</p>
<h3 id="🚗-고속도로에서-배우는-네트워크-원리">🚗 고속도로에서 배우는 네트워크 원리</h3>
<p>TCP slow start를 고속도로에 비유해보면 이렇습니다.</p>
<p>상상해보세요. 새로 개통된 고속도로가 있는데, 이 도로가 얼마나 많은 차량을 수용할 수 있는지 아무도 모릅니다. 도로의 폭도 다르고, 중간중간 있는 교차로나 톨게이트의 처리 능력도 제각각이죠.</p>
<p>그래서 교통 관제센터는 이렇게 결정합니다: &quot;처음에는 10대의 차만 보내보자. 이 차들이 무사히 목적지에 도착하면, 그다음에는 20대를 보내고, 그다음에는 40대를 보내자.&quot;</p>
<p>이것이 바로 TCP slow start의 핵심 아이디어입니다.</p>
<h3 id="💡-1kb가-만드는-차이">💡 1KB가 만드는 차이</h3>
<p>웹에서도 마찬가지입니다. 서버와 브라우저가 처음 만날 때, 네트워크 상황을 모르기 때문에 안전하게 시작합니다. 첫 번째 왕복에서는 약 14KB 정도의 데이터만 보낼 수 있죠.</p>
<p>만약 당신의 웹사이트가 15KB라면, 그 1KB 때문에 사용자는 추가 왕복을 기다려야 합니다. 위성 인터넷 환경에서는 612ms, 일반적인 모바일 환경에서도 수십에서 수백 밀리초의 추가 지연이 발생합니다.</p>
<p>사용자 경험 관점에서 보면, 14KB vs 15KB는 단순한 7% 차이가 아닙니다. 체감되는 로딩 속도에서는 몇 배의 차이를 만들어낼 수 있죠.</p>
<h3 id="어떻게-하면-최적화-할-수-있을까">어떻게 하면 최적화 할 수 있을까</h3>
<p>대부분의 프레임워크는 &quot;어떻게 하면 번들 사이즈를 줄일까?&quot;에 집중합니다. 하지만 일부 혁신적인 접근법들은 다른 관점에서 생각합니다:</p>
<p><strong>&quot;실제로 필요한 순간에만 로드하자&quot;</strong></p>
<p>이런 접근법은 코드를 최대한 세밀하게 분리해서, 사용자가 실제로 상호작용할 때만 해당 기능의 코드를 로드합니다. 초기 로딩 시에는 정말 필요한 최소한의 JavaScript만 보내는 거죠.</p>
<p>이 경험으로 깨달은 건, 컴퓨터 공학 기초가 단순한 이론이 아니라는 점입니다. TCP/IP를 이해하면 프레임워크 선택부터 성능 최적화까지 모든 게 달라집니다.</p>
<p>비전공자든 전공자든, 기술의 동작 원리를 깊이 아는 것이 더 나은 개발자가 되는 길이라고 생각해요. 이제 TCP slow start가 정확히 어떻게 작동하는지, 그리고 이것이 웹 성능에 어떤 영향을 미치는지 자세히 살펴보겠습니다.</p>
<h2 id="tcp-slow-start의-비밀">TCP Slow Start의 비밀</h2>
<p>앞서 고속도로 비유로 TCP slow start를 간단히 설명했지만, 실제로는 훨씬 더 정교한 메커니즘이 작동합니다. 이 원리를 제대로 이해하면 웹 성능 최적화에 대한 관점이 완전히 바뀝니다.</p>
<h3 id="tcp-congestion-window의-동작-원리">TCP Congestion Window의 동작 원리</h3>
<p>TCP는 네트워크 혼잡을 방지하기 위해 <strong>Congestion Window(혼잡 윈도우)</strong>라는 개념을 사용합니다. 이 윈도우의 크기가 한 번에 보낼 수 있는 데이터의 양을 결정하죠.</p>
<pre><code>Round Trip 1: [패킷 1개] → ACK 받음 → 윈도우 크기 2로 증가
Round Trip 2: [패킷 2개] → ACK 받음 → 윈도우 크기 4로 증가
Round Trip 3: [패킷 4개] → ACK 받음 → 윈도우 크기 8로 증가
Round Trip 4: [패킷 8개] → ACK 받음 → 윈도우 크기 16으로 증가</code></pre><p>처음에는 1개의 패킷만 보내고, 성공적으로 전달되면 2개, 4개, 8개... 이런 식으로 <strong>지수적으로 증가</strong>합니다. 이를 <strong>Slow Start Phase</strong>라고 부릅니다.</p>
<h3 id="왜-하필-14kb일까">왜 하필 14KB일까?</h3>
<p>그런데 왜 하필 14KB일까요? 여기에는 명확한 기술적 근거가 있습니다.</p>
<h3 id="초기-congestion-window-크기">초기 Congestion Window 크기</h3>
<p>대부분의 현대 시스템에서 초기 혼잡 윈도우 크기는 <strong>10개 패킷</strong>으로 설정되어 있습니다. (RFC 6928에서 권장)</p>
<h3 id="패킷당-실제-데이터-크기">패킷당 실제 데이터 크기</h3>
<pre><code>이더넷 프레임 최대 크기: 1,500바이트 (MTU)
IP 헤더: 20바이트
TCP 헤더: 20바이트
실제 데이터 영역: 1,500 - 20 - 20 = 1,460바이트</code></pre><h3 id="계산-결과">계산 결과</h3>
<pre><code>첫 번째 라운드트립에서 전송 가능한 데이터:
10개 패킷 × 1,460바이트 = 14,600바이트 ≈ 14.6KB</code></pre><p>즉, <strong>첫 번째 네트워크 왕복에서 최대 14KB의 데이터만 전송 가능</strong>합니다.</p>
<h2 id="실제-측정-14kb-vs-15kb의-체감-차이">실제 측정: 14KB vs 15KB의 체감 차이</h2>
<p>이론적 계산을 넘어서, 실제로 어떤 차이가 발생하는지 살펴보겠습니다.</p>
<h3 id="14kb-이하인-경우">14KB 이하인 경우</h3>
<pre><code>시간 0ms:    클라이언트 → 서버 (요청)
시간 50ms:   서버 → 클라이언트 (14KB 데이터, 완료!)
총 소요시간: 50ms (1 Round Trip)</code></pre><h3 id="15kb인-경우">15KB인 경우</h3>
<pre><code>시간 0ms:    클라이언트 → 서버 (요청)
시간 50ms:   서버 → 클라이언트 (14KB 데이터)
시간 50ms:   클라이언트 → 서버 (ACK)
시간 100ms:  서버 → 클라이언트 (나머지 1KB, 완료!)
총 소요시간: 100ms (2 Round Trip)</code></pre><p>단 1KB 차이로 <strong>로딩 시간이 2배</strong>가 됩니다!</p>
<h2 id="네트워크-환경별-영향도">네트워크 환경별 영향도</h2>
<p>이 차이는 네트워크 지연(RTT, Round Trip Time)에 따라 더욱 극명해집니다:</p>
<h3 id="일반적인-환경">일반적인 환경</h3>
<ul>
<li><strong>유선 브로드밴드</strong>: 10-30ms RTT → 10-30ms vs 20-60ms</li>
<li><strong>4G LTE</strong>: 30-70ms RTT → 30-70ms vs 60-140ms</li>
<li><strong>3G</strong>: 100-500ms RTT → 100-500ms vs 200-1000ms</li>
</ul>
<h3 id="극단적인-환경">극단적인 환경</h3>
<ul>
<li><strong>위성 인터넷</strong>: 600ms RTT → 600ms vs 1200ms</li>
<li><strong>국제 연결</strong>: 200-300ms RTT → 200-300ms vs 400-600ms</li>
</ul>
<p>모바일 환경이나 네트워크 품질이 좋지 않은 환경에서는 1KB의 차이가 <strong>수백 밀리초에서 1초 이상의 차이</strong>를 만들어낼 수 있습니다.</p>
<h2 id="인프라-엔지니어의-마음을-알겠어요">인프라 엔지니어의 마음을 알겠어요</h2>
<p>TCP slow start를 이해하고 나니, 성능 최적화에 대한 기존 생각들이 얼마나 단순했는지 깨닫게 됩니다.</p>
<h3 id="기존-생각들의-오해">기존 생각들의 오해</h3>
<p><strong>&quot;1KB 줄인다고 뭐가 달라지겠어?&quot;</strong>
→ 14KB와 15KB는 2배의 로딩 시간 차이를 만듭니다.</p>
<p><strong>&quot;번들 사이즈가 늘어나면 선형적으로 늘어나겠지&quot;</strong>
→ 14KB 임계점을 넘는 순간 갑자기 팍 늘어납니다.</p>
<p><strong>&quot;gzip으로 압축하면 용량이 절반이 되니까 로딩도 절반!&quot;</strong>
→ 네트워크 왕복 횟수가 같다면 체감 속도는 거의 비슷합니다.</p>
<h3 id="새로운-사고방식">새로운 사고방식</h3>
<p>이제 성능 최적화는 단순한 &quot;용량 줄이기&quot;가 아닙니다:</p>
<ul>
<li><strong>&quot;첫 로딩에 정말 필요한 것만 14KB 안에 담자&quot;</strong></li>
<li><strong>&quot;나머지는 사용자가 실제로 상호작용할 때 로드하자&quot;</strong></li>
<li><strong>&quot;네트워크 왕복 횟수를 최소화하는 것이 핵심&quot;</strong></li>
</ul>
<pre><code>기존 접근법: 50KB → 40KB (20% 개선이라고 생각)
실제 결과: 4번 왕복 → 3번 왕복 (체감상 큰 차이 없음)

새로운 접근법: 50KB → 13KB + 37KB 지연 로딩
실제 결과: 4번 왕복 → 1번 왕복 (체감상 4배 빨라짐)</code></pre><p>단순히 &quot;용량을 줄이는&quot; 것이 아니라 <strong>&quot;언제 무엇을 로드할지&quot;</strong>를 전략적으로 결정하는 것이 진짜 성능 최적화입니다.</p>
<p>이런 관점을 가지고 기존 프레임워크들이 어떤 방식으로 이 문제에 접근해왔는지, 그리고 그 한계가 무엇인지 살펴보겠습니다.</p>
<h2 id="nextjs는-어떻게-이-문제를-해결하고-있나">Next.js는 어떻게 이 문제를 해결하고 있나</h2>
<p>14KB 임계점을 알고 나니, 기존 프레임워크들이 얼마나 치열하게 이 문제와 싸워왔는지 보입니다. 특히 Next.js는 다양한 코드 스플릿 기법을 도입하며 끊임없이 진화해왔죠.</p>
<h3 id="spa의-등장-그리고-새로운-문제들">SPA의 등장, 그리고 새로운 문제들</h3>
<p>React가 등장하면서 웹 개발 패러다임이 크게 바뀌었습니다. 전통적인 멀티 페이지 애플리케이션(MPA)에서는 페이지를 이동할 때마다 전체 HTML을 다시 받아와야 했죠. 하지만 SPA(Single Page Application)는 거의 빈 HTML을 받습니다.</p>
<pre><code class="language-html">&lt;div id=&quot;root&quot;&gt;&lt;/div&gt;</code></pre>
<p>이것만 받아오고, JavaScript로 화면을 동적으로 다시 만드는 거죠.</p>
<p>사용자 경험은 확실히 좋아졌습니다. 페이지 전환이 매끄럽고, 마치 네이티브 앱을 사용하는 것 같은 느낌이죠. 하지만 새로운 문제들이 생겼습니다.</p>
<p>첫 번째는 <strong>초기 로딩 시간</strong>입니다. 모든 JavaScript 코드를 다운로드하고 실행해야 첫 화면을 볼 수 있으니까요. 두 번째는 <strong>SEO 문제</strong>입니다. 검색 엔진 봇이 JavaScript를 실행하기 전의 빈 HTML만 보게 되어 콘텐츠를 제대로 인덱싱하지 못했습니다.</p>
<h3 id="ssr의-등장과-해결책">SSR의 등장과 해결책</h3>
<p>이 문제들을 해결하기 위해 서버사이드 렌더링(SSR)이 등장했습니다. React에서는 <code>renderToString</code>이나 Next.js 같은 프레임워크를 통해 서버에서 미리 HTML을 생성해서 보내주는 방식이죠.</p>
<p>SSR을 사용하면 사용자는 훨씬 빠르게 첫 화면을 볼 수 있습니다. 검색 엔진도 완성된 HTML을 받아서 SEO 문제가 해결되고요. 하지만 여기서 또 다른 문제가 생깁니다.</p>
<h3 id="하이드레이션-그리고-새로운-네트워크-병목">하이드레이션, 그리고 새로운 네트워크 병목</h3>
<p>서버에서 받은 HTML은 정적입니다. 클릭해도 반응하지 않고, 상태도 변하지 않죠. 이를 인터랙티브하게 만들어주는 과정이 바로 <strong>하이드레이션(Hydration)</strong>입니다.</p>
<pre><code class="language-jsx">// 서버에서는 정적 HTML 생성
&lt;button&gt;클릭하세요&lt;/button&gt;

// 클라이언트에서 하이드레이션으로 이벤트 리스너 부착
&lt;button onClick={handleClick}&gt;클릭하세요&lt;/button&gt;</code></pre>
<p>여기서 <strong>네트워크 관점의 문제</strong>가 생깁니다. 하이드레이션을 위해서는 해당하는 <strong>모든 JavaScript를 초기에 다운로드</strong>해야 합니다. </p>
<p>첫 화면에 인터랙티브한 요소가 많을수록:</p>
<ul>
<li>초기에 보내야 하는 JavaScript 양이 증가</li>
<li>14KB 임계점을 넘을 가능성이 높아짐</li>
<li>추가 네트워크 왕복이 필요해짐</li>
</ul>
<p>결국 <strong>하이드레이션 대상이 많다 = 초기 번들 사이즈가 크다</strong>는 뜻이고, 이는 곧 네트워크 성능 저하로 이어집니다.</p>
<h2 id="nextjs의-자동-라우트-코드-스플릿">Next.js의 자동 라우트 코드 스플릿</h2>
<p>Next.js를 쓴다면 이미 코드 스플릿을 하고 있습니다. Next.js는 기본적으로 <strong>라우트별로 코드를 자동 분리</strong>해주거든요.</p>
<pre><code>pages/
  index.js        → /_next/static/chunks/pages/index.js
  about.js        → /_next/static/chunks/pages/about.js
  contact.js      → /_next/static/chunks/pages/contact.js</code></pre><p><code>/about</code> 페이지에 접근할 때만 해당 페이지의 JavaScript가 로드됩니다. <code>/contact</code> 페이지를 방문하지 않는 사용자는 그 페이지의 코드를 다운로드할 필요가 없죠.</p>
<p>하지만 이것만으로는 충분하지 않습니다. 하나의 페이지 안에서도 수많은 컴포넌트와 라이브러리들이 한꺼번에 로드되면서 여전히 성능 병목이 발생할 수 있습니다.</p>
<h2 id="dynamic으로-컴포넌트-레벨-최적화">dynamic()으로 컴포넌트 레벨 최적화</h2>
<p>Next.js의 <code>dynamic()</code> 함수는 React의 <code>lazy()</code>와 <code>Suspense</code>를 합친 것이라고 보면 됩니다. 특정 컴포넌트를 필요할 때만 로드할 수 있게 해주죠.</p>
<pre><code class="language-jsx">import dynamic from &#39;next/dynamic&#39;

// 차트 라이브러리는 용량이 크니까 나중에 로드
const Chart = dynamic(() =&gt; import(&#39;../components/Chart&#39;))

// 모달은 사용자가 버튼을 클릭할 때만 필요
const Modal = dynamic(() =&gt; import(&#39;../components/Modal&#39;))

function Dashboard() {
  const [showModal, setShowModal] = useState(false)

  return (
    &lt;div&gt;
      &lt;h1&gt;대시보드&lt;/h1&gt;
      &lt;Chart data={chartData} /&gt;
      {showModal &amp;&amp; &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;}
    &lt;/div&gt;
  )
}</code></pre>
<p>여기서 중요한 점은 <strong>초기 번들에 포함되는 순서</strong>입니다. 처음에는 <code>dynamic</code>으로 명시하지 않은 컴포넌트들의 JavaScript만 다운로드하고, <code>dynamic</code>으로 감싼 컴포넌트들의 JavaScript는 나중에 필요할 때 다운로드합니다. <strong>초기 네트워크 비용을 줄이는 것</strong>이 핵심이죠.</p>
<h2 id="ssr-제어로-더-세밀한-최적화">SSR 제어로 더 세밀한 최적화</h2>
<p>때로는 특정 컴포넌트를 아예 클라이언트에서만 렌더링하고 싶을 때가 있습니다. 브라우저 API를 사용하는 컴포넌트나, 서버에서 렌더링할 필요가 없는 경우죠.</p>
<pre><code class="language-jsx">const ClientOnlyComponent = dynamic(
  () =&gt; import(&#39;../components/InteractiveWidget&#39;),
  {
    ssr: false,
    loading: () =&gt; &lt;div&gt;위젯을 불러오는 중...&lt;/div&gt;
  }
)

// 차트 라이브러리는 캔버스니까 SSR에서 렌더링할 필요 없음
const ChartComponent = dynamic(
  () =&gt; import(&#39;../components/Chart&#39;),
  { ssr: false }
)</code></pre>
<p><code>ssr: false</code>로 설정하면 서버사이드 렌더링에서는 아예 HTML 태그마저 생략해서 클라이언트에서 다시 그려옵니다. 그러면 <strong>초기에 보내야 하는 HTML과 JavaScript 양이 줄어들어</strong> 네트워크 병목도 줄고, 14KB 임계점을 넘지 않을 가능성이 높아집니다.</p>
<h2 id="서버-컴포넌트의-등장">서버 컴포넌트의 등장</h2>
<p>Next.js 13부터는 <strong>서버 컴포넌트</strong>라는 새로운 개념이 등장했습니다. 모든 컴포넌트는 기본적으로 서버에서만 실행되고, JavaScript 번들에 포함되지 않습니다. 더욱더 하이드레이션 과정을 줄여주는 거죠.</p>
<pre><code class="language-jsx">// app/page.tsx - 서버 컴포넌트 (기본값)
async function HomePage() {
  const data = await fetch(&#39;https://api.example.com/data&#39;)

  return (
    &lt;div&gt;
      &lt;h1&gt;홈페이지&lt;/h1&gt;
      &lt;UserProfile data={data} /&gt;
    &lt;/div&gt;
  )
}</code></pre>
<p>서버 컴포넌트는 서버에서 HTML 스니펫으로 렌더링되어 클라이언트에 전달되며, <strong>클라이언트 JavaScript 번들에 포함되지 않습니다</strong>. React 렌더 트리는 이 위치를 &quot;구멍(hole)&quot;으로 남겨두었다가 해당 HTML 스니펫을 삽입하는 형태로 페이지를 완성합니다.</p>
<p>이는 <strong>초기 번들 사이즈를 크게 줄여주는</strong> 혁신적인 방법입니다.</p>
<p>서버 컴포넌트는 대신 이벤트 핸들러나 <code>useState</code>를 쓸 수 없습니다. <strong>클라이언트 JavaScript 번들에 포함되지 않도록</strong> 명시하는 거기 때문에, Next.js에서는 서버 컴포넌트에서 <code>useState</code>를 쓰면 클라이언트 번들이 필요한 컴포넌트라고 판단해서 <code>use client</code>를 붙이라고 하죠. 안 그러면 빌드 실패합니다.</p>
<h2 id="여전히-남는-근본적-한계">여전히 남는 근본적 한계</h2>
<p>지금까지 살펴본 최적화 기법들은 분명 효과적입니다. 하지만 <strong>초기 번들 사이즈를 줄이는 데는 근본적인 한계</strong>가 있습니다.</p>
<h3 id="초기-로드-시-필요한-javascript의-최소-임계점">초기 로드 시 필요한 JavaScript의 최소 임계점</h3>
<p>JavaScript 다운로드는 비동기이지만, 하이드레이션 과정에서 서버사이드에서 했던 렌더링을 그대로 반복하면 메인 스레드를 잡아먹습니다. 특히 모바일에서는 더더욱 느리죠.</p>
<p>핵심 문제는 <strong>초기 번들에 포함되는 JavaScript의 양</strong>입니다:</p>
<ul>
<li><code>dynamic</code>으로 import하려면 해당 컴포넌트를 따로 파일로 빼야 함</li>
<li><code>useState</code> 하나만 써도 전체 컴포넌트가 초기 번들에 포함됨  </li>
<li><strong>하나의 컴포넌트 안에서는 모든 JavaScript가 함께 번들링</strong>됨</li>
<li>파일 단위로만 분리 가능해서 세밀한 최적화가 어려움</li>
</ul>
<h3 id="개발자의-수동-최적화-부담">개발자의 수동 최적화 부담</h3>
<p>결국 개발자가 &quot;이건 초기 번들에, 이건 나중에&quot; 하나하나 수동으로 결정해야 합니다:</p>
<pre><code class="language-jsx">// 이렇게 써야 초기 번들에서 제외
const Modal = dynamic(() =&gt; import(&#39;./Modal&#39;))

// 하지만 이렇게 쓰면 초기 번들에 포함
function Component() {
  const [state, setState] = useState(false)
  return &lt;Modal show={state} /&gt;
}</code></pre>
<p>서버 컴포넌트로 잘게 쪼개는 것도 어렵죠. 만들다 보면 <code>useState</code>나 이벤트 핸들러 하나씩은 들어간 컴포넌트가 나오거든요. 그렇다고 모든 컴포넌트를 <code>use client</code>로 명시해버리면... 초기 번들 사이즈 최적화의 의미가 없어집니다.</p>
<h3 id="초기-번들-사이즈를-줄이는-것에는-구조적-한계">초기 번들 사이즈를 줄이는 것에는 구조적 한계</h3>
<p>아무리 최적화해도 복잡한 애플리케이션에서는 <strong>초기에 필요한 JavaScript가 14KB를 넘기 쉽습니다</strong>:</p>
<ul>
<li>React 런타임 자체의 네트워크 오버헤드</li>
<li>라우터, 상태 관리 라이브러리들의 번들 사이즈</li>
<li>첫 화면의 모든 인터랙티브 요소들의 JavaScript</li>
<li>하이드레이션을 위한 컴포넌트 트리 정보</li>
</ul>
<p><strong>초기 번들 사이즈를 줄이는 것에는 구조적 한계</strong>가 있는 것이죠.</p>
<p>그렇다면 이 한계를 어떻게 극복할 수 있을까요? 여기서 완전히 다른 접근법이 등장합니다.</p>
<h1 id="qwik의-혁신-zero-hydration과-재개가능성">Qwik의 혁신: Zero Hydration과 재개가능성</h1>
<p>Next.js의 한계를 보면서 드는 생각이 있습니다. <strong>대부분의 컴포넌트는 인터랙션이 있지만, 동시에 대부분의 컴포넌트는 인터랙션이 필요 없습니다.</strong></p>
<p>모든 페이지들의 요소요소들은 다 상호작용할 부분들이 많으면서도, 동시에 유저들은 모든 요소들을 한 페이지에서 눌러보지 않기 때문이죠. 사용자는 보통 페이지의 일부분만 실제로 상호작용합니다.</p>
<p>그렇다면 <strong>필요할 때만 그 부분의 JavaScript를 로드</strong>하면 되지 않을까요?</p>
<h2 id="기존-접근법의-답답한-현실">기존 접근법의 답답한 현실</h2>
<p>Next.js에서 <code>useState</code> 하나 썼다고 전체 컴포넌트가 초기 번들에 포함된다거나, <code>dynamic</code>으로 import하려면 해당 컴포넌트를 따로 파일로 빼야 하는 불편함이 있습니다.</p>
<p>서버 컴포넌트로 잘게 쪼개는 것도 어렵죠. 만들다 보면 <code>useState</code>나 이벤트 핸들러 하나씩은 들어간 컴포넌트가 나오거든요. 그렇다고 모든 컴포넌트를 <code>use client</code>로 명시해버리면... 최적화의 의미가 없어집니다.</p>
<p>결국 개발자가 수동으로 &quot;이건 서버에서, 이건 클라이언트에서&quot; 하나하나 결정해야 하는 상황입니다.</p>
<h1 id="qwik이-등장-no-hydration과-자동-코드-스플릿">Qwik이 등장! No Hydration과 자동 코드 스플릿</h1>
<p>Qwik은 완전히 다른 접근을 합니다. <strong>하이드레이션을 아예 하지 않습니다.</strong></p>
<p>기존 프레임워크들은 서버에서 했던 렌더링을 클라이언트에서 한 번 더 반복하면서 하이드레이션을 수행합니다. 하지만 Qwik은 <strong>서버에서 실행을 일시정지하고, 클라이언트에서 실행을 재개</strong>합니다.</p>
<p>핵심은 <strong>HTML에 모든 정보가 이미 시리얼라이즈되어 있다</strong>는 점입니다.</p>
<pre><code class="language-html">&lt;button on:click=&quot;./chunk-c.js#Counter_onClick[0,1]&quot;&gt;클릭하세요&lt;/button&gt;</code></pre>
<p>이 버튼을 보세요. <code>on:click</code> 속성에 어떤 파일의 어떤 함수를 실행해야 하는지, 심지어 어떤 변수들을 복원해야 하는지(<code>[0,1]</code>) 모든 정보가 들어있습니다.</p>
<h2 id="qwikloader-1kb의-마법">Qwikloader: 1KB의 마법</h2>
<p>Qwik이 클라이언트에 보내는 JavaScript는 <strong>Qwikloader라는 1KB짜리 스크립트 하나</strong>뿐입니다.</p>
<pre><code class="language-html">&lt;html&gt;
  &lt;body q:base=&quot;/build/&quot;&gt;
    &lt;button on:click=&quot;./myHandler.js#clickHandler&quot;&gt;push me&lt;/button&gt;
    &lt;script&gt;
      /* Qwikloader */
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>Qwikloader의 역할은 단순합니다:</p>
<ol>
<li><strong>전역 이벤트 리스너 하나만 등록</strong></li>
<li>사용자가 클릭하면 해당 요소에서 <code>on:click</code> 속성 찾기</li>
<li>속성값을 파싱해서 필요한 청크 파일 다운로드</li>
<li>해당 함수 실행</li>
</ol>
<h2 id="실제-동작-원리-개발자-코드에서-최적화까지">실제 동작 원리: 개발자 코드에서 최적화까지</h2>
<p>개발자가 이렇게 코드를 작성한다면:</p>
<pre><code class="language-jsx">export const Counter = component$((props: { step: number }) =&gt; {
  const count = useSignal(0);

  return &lt;button onClick$={() =&gt; (count.value += props.step || 1)}&gt;{count.value}&lt;/button&gt;;
});</code></pre>
<p>Qwik의 Optimizer가 이를 자동으로 여러 청크로 분리합니다:</p>
<pre><code>개발자 코드 (하나의 컴포넌트)
         │
         ▼
   Qwik Optimizer
         │
    ┌────┼────┐
    ▼    ▼    ▼
chunk-a chunk-b chunk-c
(mount) (render) (click)</code></pre><pre><code class="language-jsx">// chunk-a.js - 컴포넌트 마운트
export const Counter_onMount = (props) =&gt; {
  const count = useSignal(0);
  return qrl(&#39;./chunk-b.js&#39;, &#39;Counter_onRender&#39;, [count, props]);
};

// chunk-b.js - 렌더링
const Counter_onRender = () =&gt; {
  const [count, props] = useLexicalScope();
  return (
    &lt;button onClick$={qrl(&#39;./chunk-c.js&#39;, &#39;Counter_onClick&#39;, [count, props])}&gt;{count.value}&lt;/button&gt;
  );
};

// chunk-c.js - 클릭 핸들러
const Counter_onClick = () =&gt; {
  const [count, props] = useLexicalScope();
  return (count.value += props.step || 1);
};</code></pre>
<p>결과적으로 생성되는 HTML:</p>
<pre><code class="language-html">&lt;button q:obj=&quot;456, 123&quot; on:click=&quot;./chunk-c.js#Counter_onClick[0,1]&quot;&gt;0&lt;/button&gt;</code></pre>
<h2 id="사용자-클릭의-순간">사용자 클릭의 순간</h2>
<p>사용자가 버튼을 클릭하는 순간:</p>
<pre><code>   사용자 클릭
       │
       ▼
 Qwikloader (1KB)
       │
       ▼
on:click 속성 파싱
&quot;./chunk-c.js#Counter_onClick[0,1]&quot;
       │
       ▼
chunk-c.js 다운로드
       │
       ▼
함수 실행 + 상태 복원
  count, props</code></pre><ol>
<li>Qwikloader가 클릭 이벤트를 감지</li>
<li><code>on:click=&quot;./chunk-c.js#Counter_onClick[0,1]&quot;</code> 파싱</li>
<li><code>chunk-c.js</code> 파일을 동적으로 로드</li>
<li><code>Counter_onClick</code> 함수 실행</li>
<li><code>[0,1]</code>로 필요한 변수들(<code>count</code>, <code>props</code>) 복원</li>
</ol>
<h2 id="대박인건-컴포넌트-내에서도-핸들러별-청킹이-됩니다">대박인건, 컴포넌트 내에서도 핸들러별 청킹이 됩니다</h2>
<p>기존 접근법의 한계였던 &quot;<strong>하나의 컴포넌트 안에서는 모든 JavaScript가 함께 번들링</strong>&quot;되는 문제를 Qwik은 근본적으로 해결합니다.</p>
<p><strong>컴포넌트 내에서 이벤트 핸들러마저도</strong> 알아서 Optimizer가 다른 번들로 분리합니다. 개발자는 신경 쓸 필요 없이 평범하게 코드를 작성하면, 프레임워크가 알아서 최적의 코드 스플릿을 만들어줍니다.</p>
<p>사용자가 실제로 상호작용하는 그 순간에만 해당 JavaScript가 로드되니, 메인 스레드 블로킹도 없고 인터랙션 지연도 없습니다.</p>
<h2 id="14kb-임계점과의-완벽한-조화">14KB 임계점과의 완벽한 조화</h2>
<p>TCP slow start의 14KB 임계점을 생각해보면, Qwik의 접근법이 얼마나 이상적인지 알 수 있습니다:</p>
<pre><code>기존 방식:
- React 런타임: ~45KB
- 컴포넌트들: ~100KB+
- 첫 로딩: 여러 번의 네트워크 왕복 필요

Qwik 방식:
- Qwikloader: 1KB
- HTML: ~10KB
- 첫 로딩: 14KB 이하로 단일 왕복 완료</code></pre><p>사용자는 첫 화면을 즉시 볼 수 있고, 실제로 상호작용할 때만 필요한 JavaScript가 로드됩니다. TCP slow start의 특성을 완벽하게 활용하는 구조죠.</p>
<h2 id="zero-hydration의-의미">Zero Hydration의 의미</h2>
<p>이것이 바로 <strong>Zero Hydration</strong>이자 <strong>재개가능성(Resumability)</strong>의 힘입니다. 서버에서 일시정지된 실행이 클라이언트에서 필요한 순간에 정확히 재개되는 것이죠.</p>
<p>기존 프레임워크들이 &quot;어떻게 하면 하이드레이션을 더 효율적으로 할까?&quot;를 고민했다면, Qwik은 &quot;하이드레이션을 왜 해야 하지?&quot;라는 질문부터 시작했습니다. 이런 근본적인 사고의 전환이 혁신을 만들어내는 것 같습니다.</p>
<h1 id="앞으로-개발자들은-더욱-게을러져도-됩니다">앞으로 개발자들은 더욱 게을러져도 됩니다</h1>
<p>Qwik을 보면서 정말 감탄했습니다. 하이드레이션이라는 근본적인 문제를 아예 다른 관점에서 해결해버린 접근법이 인상적이었어요.</p>
<pre><code>┌─────────────────┐    ┌─────────────────┐
│  기존 방식      │    │  Qwik 방식     │
├─────────────────┤    ├─────────────────┤
│ 전체 JS: 500KB  │    │ Qwikloader: 1KB │
│ 하이드레이션 ✓  │    │ 하이드레이션 ✗  │
│ 초기 로딩 느림  │    │ 초기 로딩 빠름  │
└─────────────────┘    └─────────────────┘</code></pre><p>기존 프레임워크들이 &quot;어떻게 하면 하이드레이션을 더 효율적으로 할까?&quot;를 고민했다면, Qwik은 &quot;하이드레이션을 왜 해야 하지?&quot;라는 질문부터 시작했습니다. 이런 근본적인 사고의 전환이 혁신을 만들어내는 것 같습니다.</p>
<h2 id="프레임워크가-담당하는-최적화">프레임워크가 담당하는 최적화</h2>
<p>Svelte가 &quot;리렌더링을 왜 개발자가 신경 써야 해?&quot;라고 하면서 등장했듯이, Qwik은 &quot;코드 스플릿을 왜 개발자가 생각해야 해?&quot;라고 묻고 있습니다.</p>
<p>어떻게 하면 서버 컴포넌트로 잘게 더 쪼개볼 수 있을지, &quot;use client&quot;를 어떻게 하면 덜 쓸 수 있을까, 어떻게 파일을 분리해야 할지, 어떤 컴포넌트를 <code>dynamic</code>으로 감쌀지 같은 복잡한 결정들을 개발자가 매번 고민해야 하는 것은 피곤한 일이죠.</p>
<p><strong>프로젝트가 아무리 커져도 O(1)의 속도</strong>를 유지할 수 있도록 프레임워크가 담당하는 거죠. 개발자가 컴포넌트를 100개 만들든 1000개 만들든, 사용자가 실제로 상호작용하는 부분만 로드되니까 초기 성능은 일정하게 유지됩니다.</p>
<p>성능 최적화를 프레임워크가 담당하니까 <strong>개발자의 인지 부하와 초기 지식 습득 난이도가 줄어듭니다</strong>. 복잡한 최적화 지식들을 프레임워크에게 위임하면서 좀 더 제품에 집중할 수 있는 거겠죠.</p>
<h2 id="기초-지식이-만드는-차이">기초 지식이 만드는 차이</h2>
<p>하지만 이런 혁신적인 프레임워크들을 제대로 이해하고 활용하려면 결국 <strong>기초 지식</strong>이 중요합니다.</p>
<p>TCP slow start를 몰랐다면 Qwik의 1KB Qwikloader가 왜 혁신적인지 이해하기 어려웠을 거예요. 네트워크 지연과 패킷 단위 전송을 모르면 단순히 &quot;용량이 작으니까 빠르겠지&quot;라고만 생각했을 테죠.</p>
<p>14KB 임계점을 이해하고 나니:</p>
<ul>
<li>프레임워크 선택 기준이 바뀝니다</li>
<li>성능 최적화 전략이 달라집니다  </li>
<li>사용자 경험 설계가 개선됩니다</li>
</ul>
<p>비전공자든 전공자든, <strong>기술의 동작 원리를 깊이 아는 것이 더 나은 개발자가 되는 길</strong>이라고 생각해요.</p>
<h2 id="계속-발전하는-웹의-미래">계속 발전하는 웹의 미래</h2>
<p>SPA에서 시작해서 SSR, 하이드레이션, 코드 스플릿, 서버 컴포넌트, 그리고 이제는 재개가능성(resumability)까지. 각 단계마다 &quot;개발자가 신경 써야 할 것들&quot;을 하나씩 프레임워크가 가져가는 과정이었다고 생각합니다.</p>
<p>프레임워크들이 점점 더 똑똑해져서, 우리는 비즈니스 로직과 사용자 가치 창출에만 집중할 수 있게 되는 것 같아요. 앞으로도 이런 흐름은 계속될 것 같고, 저도 그런 변화를 따라가면서 더 나은 사용자 경험을 만드는 개발자가 되려고 노력하고 있습니다.</p>
<h2 id="저도-배우고-있습니다">저도 배우고 있습니다</h2>
<p>사실 커뮤니티에서 어느 한 분이 소개해줘서 Qwik이라는 것을 알게 되었습니다. 이런 글을 쓰면서도 저 역시 계속 배우고 있는 중이에요.</p>
<p>성능 최적화라는 게 결국 개발자의 인지 부하를 줄이면서도 사용자 경험을 개선하는 방향으로 발전하고 있다는 걸 느낍니다.</p>
<p><strong>1KB 줄인다고 뭐가 달라지나요?</strong></p>
<p>이제 이 질문에 자신 있게 대답할 수 있습니다. 때로는 그 1KB가 사용자 경험을 완전히 바꿀 수 있다고요. 그리고 그것을 이해하는 것이 더 나은 개발자가 되는 첫걸음이라고 생각합니다.</p>
<p><strong>여러분은 최근에 어떤 기술적 깨달음이 있으셨나요?</strong></p>
<hr>
<h2 id="📝-프론트엔드-개발자를-위한-이력서--면접-준비">📝 프론트엔드 개발자를 위한 이력서 &amp; 면접 준비</h2>
<p>이런 기술적 깨달음들을 면접에서 어떻게 어필할지 고민이신가요? </p>
<p><strong><a href="https://fe-resume.coach?utm_source=blog&amp;utm_medium=article&amp;utm_campaign=tcp_qwik_post">AI 이력서 분석</a></strong>에서 프론트엔드 개발자를 위한 이력서 작성 서비스를 만들고 있습니다. 모의면접 질문을 공유하고 대답하면서 실력을 쌓아가봐요!</p>
<h3 id="🎯-오늘-글과-관련된-면접-질문들">🎯 오늘 글과 관련된 면접 질문들</h3>
<p>이런 질문들이 나올 때 어떻게 대답하시겠어요?</p>
<p><strong>성능 최적화 관련:</strong></p>
<ul>
<li>&quot;웹 성능 최적화를 위해 어떤 방법들을 시도해보셨나요?&quot;</li>
<li>&quot;번들 사이즈를 줄이기 위해 사용한 기법들을 설명해주세요&quot;</li>
<li>&quot;코드 스플리팅을 어떻게 적용하셨고, 그 효과는 어땠나요?&quot;</li>
</ul>
<p><strong>프레임워크 이해도:</strong></p>
<ul>
<li>&quot;Next.js의 장단점과 사용 경험을 말씀해주세요&quot;</li>
<li>&quot;SSR과 CSR의 차이점과 각각의 장단점은?&quot;</li>
<li>&quot;최근 관심 있게 본 새로운 기술이나 프레임워크가 있나요?&quot;</li>
</ul>
<p>이런 질문들에 자신 있게 답할 수 있도록, <strong>실전 면접 준비와 이력서 작성</strong>을 도와드리고 있습니다.</p>
<p>👉 <strong><a href="https://fe-resume.coach?utm_source=blog&amp;utm_medium=article&amp;utm_campaign=tcp_qwik_post">지금 바로 시작하기</a></strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드에 명령하는 프론트엔드가 되어보자]]></title>
            <link>https://velog.io/@k-svelte-master/frontend-win-backend</link>
            <guid>https://velog.io/@k-svelte-master/frontend-win-backend</guid>
            <pubDate>Fri, 11 Jul 2025 14:12:45 GMT</pubDate>
            <description><![CDATA[<h2 id="백엔드-눈치-보는-프론트엔드의-현실">백엔드 눈치 보는 프론트엔드의 현실</h2>
<p>&quot;API 언제 나와요?&quot;</p>
<p>회사든 사이드프로젝트든, 프론트엔드 개발자라면 한 번쯤 해봤을 질문입니다. 백엔드 개발자에게 눈치를 주며 물어보지만, 돌아오는 답변은 늘 &quot;조금만 더 기다려주세요&quot;입니다.</p>
<p><strong>그렇다고 손 놓고 있을 수는 없죠.</strong> 일정은 촉박하고, 화면은 그려야 하니까 임시로 하드코딩된 데이터를 넣어서 개발을 시작합니다.</p>
<pre><code class="language-jsx">// 이런 식으로 임시 데이터 넣고 개발하죠...
const posts = [
  { id: 1, title: &quot;임시 게시글 1&quot;, content: &quot;임시 내용...&quot; },
  { id: 2, title: &quot;임시 게시글 2&quot;, content: &quot;임시 내용...&quot; },
];</code></pre>
<p>그런데 문제는 여기서 시작됩니다.</p>
<p><strong>게시판을 만든다고 생각해보세요.</strong> 게시글 목록, 상세 보기, 댓글, 좋아요, 페이징... 이 모든 기능들이 백엔드 API와 연동되어야 제대로 동작하는지 확인할 수 있습니다.</p>
<p>임시 데이터로는 한계가 있어요:</p>
<ul>
<li>페이징이 제대로 작동하는지 모름</li>
<li>댓글 등록/삭제가 실제로 반영되는지 확인 불가</li>
<li>로딩 상태나 에러 처리 테스트 어려움</li>
</ul>
<p><strong>결국 백엔드 API가 나올 때까지 기다리다가</strong>, 일정 마감 직전에 모든 걸 한 번에 연동하게 됩니다. 그리고는 예상치 못한 버그들과 마주하며 밤을 새우게 되죠. <strong>마감 못 지키면? 당연히 프론트엔드가 다 떠안습니다.</strong></p>
<p>&quot;백엔드는 API 다 만들었는데, 프론트엔드가 연동을 못 했네요.&quot; 이런 소리 들어본 적 있지 않나요? 진짜 개같죠.</p>
<p>더 짜증나는 건, <strong>API 스펙이 예상과 달라서</strong> 임시로 만들어놨던 코드를 다시 뜯어고쳐야 할 때입니다. &quot;아 이거 다시 해야 해?&quot; 하면서 중복 작업에 시달리게 됩니다.</p>
<p><strong>이런 악순환, 이제 그만 끝내고 싶지 않나요?</strong></p>
<p>오늘 소개할 방법을 사용하면, 더 이상 백엔드 개발자에게 눈치 주지 않아도 됩니다. 오히려 여러분이 먼저 화면을 완성해서 보여주고, &quot;이대로 API 만들어주세요&quot;라고 말할 수 있게 될 거예요.</p>
<h2 id="나는-페이지-자체를-스토리북으로-구현한다">나는 페이지 자체를 스토리북으로 구현한다</h2>
<p><strong>스토리북(Storybook)</strong> 들어보셨나요? 보통은 버튼, 카드 같은 UI 컴포넌트 문서 만드는 도구 정도로 알려져 있어요.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/5972cb9a-ec98-4f5f-8ff3-241a42d1e8a8/image.png" alt="https://storybook.js.org/"></p>
<p><strong>하지만 저는 완전히 다르게 사용합니다.</strong></p>
<p>컴포넌트가 아닌 <strong>페이지 전체</strong>를 스토리북에 올려요. 실제 서비스 화면을 그대로 스토리북에서 구현하는 거죠.</p>
<h3 id="실제-예시-게시판-서비스">실제 예시: 게시판 서비스</h3>
<p>간단한 게시판을 예로 들어보겠습니다. 게시글 작성, 댓글 달기, 좋아요 기능이 있는 일반적인 게시판이에요.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/325f47e2-1489-4ad7-892f-b861d404a430/image.gif" alt="https://fe-resume.coach"></p>
<p>보시다시피 <strong>실제 서비스와 똑같이 동작</strong>합니다.</p>
<ul>
<li>게시글 작성하고</li>
<li>댓글 달고 삭제하고</li>
<li>좋아요 버튼 누르면 숫자 증가하고</li>
<li>페이지네이션도 자연스럽게 되고</li>
</ul>
<p><strong>&quot;어? 이거 백엔드 API 없이 어떻게 동작하는 거지?&quot;</strong></p>
<p>바로 여기서 핵심이 나옵니다. <strong>모든 API를 Mock으로 구현했거든요.</strong></p>
<h3 id="이렇게-하면-뭐가-좋은데">이렇게 하면 뭐가 좋은데?</h3>
<p><strong>1. 백엔드 기다릴 필요 없음</strong>
더 이상 &quot;API 언제 나와요?&quot; 묻지 않아도 됩니다. 제가 먼저 Mock API로 모든 기능을 구현해놓고, 백엔드 개발자에게 &quot;이대로 만들어주세요&quot;라고 말할 수 있어요.</p>
<p><strong>2. 복잡한 시나리오도 쉽게 테스트</strong></p>
<p>평소에 디자인 한 번 수정하고 확인할 때마다 얼마나 귀찮았나요?</p>
<ul>
<li><strong>결제 페이지</strong> 확인하려면: 로그인 → 상품 선택 → 장바구니 → 결제 정보 입력 → ...</li>
<li><strong>로그인 필수 페이지</strong> 확인하려면: 매번 로그인하고 → 해당 메뉴 찾아서 클릭하고 → ...</li>
<li><strong>에러 상태</strong> 확인하려면: 일부러 잘못된 데이터 입력하고 → 에러 발생시키고 → ...</li>
</ul>
<p><strong>이제는 스토리북에서 상태별로 페이지들을 한 번에 볼 수 있어요:</strong></p>
<ul>
<li>결제 완료 상태</li>
<li>결제 실패 상태</li>
<li>로딩 중 상태</li>
<li>네트워크 에러 상태</li>
<li>빈 데이터 상태</li>
</ul>
<p>클릭 한 번으로 모든 상황을 바로 확인할 수 있습니다. 짱 쉽죠?</p>
<p><strong>3. 디자이너/기획자와 소통 편함</strong>
스토리북 링크 하나만 보내주면 됩니다. &quot;이 페이지에서 이 버튼 눌러보세요&quot; 하면서 실제로 체험해볼 수 있어요.</p>
<h3 id="핵심은-mockreal-api-쉬운-전환">핵심은 Mock/Real API 쉬운 전환</h3>
<p>이런 걸 가능하게 만드는 핵심은 <strong>환경에 따라 Mock과 Real API를 쉽게 전환</strong>할 수 있는 구조입니다.</p>
<pre><code class="language-json">// package.json
{
  &quot;imports&quot;: {
    &quot;#api&quot;: {
      &quot;storybook&quot;: &quot;./lib/api/mock/index.ts&quot;,
      &quot;default&quot;: &quot;./lib/api/client/index.ts&quot;
    }
  }
}</code></pre>
<p>개발할 때는 Mock API 쓰고, 프로덕션에서는 Real API 쓰는 거죠. <strong>코드 한 줄 바꾸지 않고</strong> 말이에요.</p>
<pre><code class="language-typescript">// 환경에 따라 자동으로 Mock/Real 매칭
import { postService } from &quot;#api&quot;;</code></pre>
<p><strong>이제 더 이상 백엔드 기다리지 마세요.</strong>
<code>npm run storybook</code> 켜고 Mock API로 모든 기능을 완성한 다음, 백엔드가 준비되면 import 개발모드를 켜서 확인해보면 됩니다.</p>
<h2 id="프론트엔드가-주도하는-개발">프론트엔드가 주도하는 개발</h2>
<p>이 방식을 사용하면서 겪은 <strong>실제 경험</strong>들을 공유해드릴게요.</p>
<h3 id="경험-1-외주사에서-영웅되기">경험 1: 외주사에서 영웅되기</h3>
<p><strong>상황:</strong> 블록체인 프로젝트 외주를 받았는데, <strong>프론트엔드만</strong> 담당하게 되었어요. 스마트 컨트랙트(백엔드라고 생각하시면 됩니다)는 외주사 팀에서 개발 중이었고요.</p>
<p><strong>문제:</strong> 막상 개발 시작하려니 <strong>컨트랙트가 아직 배포 안 되어 있더라고요.</strong></p>
<p><strong>기존 방식이었다면:</strong></p>
<ul>
<li>&quot;컨트랙트 배포 언제 되나요?&quot;</li>
<li>&quot;일단 기다리겠습니다...&quot;</li>
<li>마지막에 부랴부랴 연동하다가 시연에서 동작 안 함 💥</li>
</ul>
<p><strong>제가 한 방식:</strong></p>
<pre><code>1주차: Mock 컨트랙트로 모든 화면 완성 (스토리북)
2주차: 클라이언트 시연 → &quot;와 벌써 다 됐네요!&quot;
3주차: 실제 컨트랙트 배포됨
4주차: 파일 한두 개만 교체해서 연동 완료 ✨</code></pre><p><strong>Mock 컨트랙트 예시:</strong></p>
<pre><code class="language-typescript">// lib/api/mock/contract.ts
export class TokenContract {
  async getBalance(address: string): Promise&lt;string&gt; {
    // 가짜 잔액 반환
    return &quot;1,234.56 ETH&quot;;
  }

  async transfer(to: string, amount: string): Promise&lt;string&gt; {
    // 가짜 트랜잭션 해시 반환
    await new Promise((resolve) =&gt; setTimeout(resolve, 2000)); // 로딩 시뮬레이션
    return &quot;0x123abc...&quot;;
  }
}</code></pre>
<p><strong>결과:</strong></p>
<ul>
<li>클라이언트: &quot;다른 팀은 컨트랙트 기다린다고 지연되는데, 이 팀은 벌써 다 완성했네?&quot;</li>
<li>외주사 팀: &quot;어떻게 컨트랙트 없이 이렇게 완벽하게 동작하지?&quot;</li>
<li>나: <strong>파일 한두 개만 바꿔서</strong> 실제 컨트랙트 연동 완료 → 영웅 등극 ✨</li>
</ul>
<p>시연할 때 <strong>지갑 연결, 토큰 전송, 잔액 조회</strong> 모든 기능이 실제처럼 동작하니까 임팩트가 완전 달랐어요. &quot;아직 컨트랙트 연동은 안 됐지만...&quot;이 아니라 &quot;이미 다 완성됐습니다!&quot;라고 말할 수 있었거든요.</p>
<h3 id="경험-2-사이드프로젝트에서-백엔드에게-명령하기">경험 2: 사이드프로젝트에서 백엔드에게 명령하기</h3>
<p><strong>상황:</strong> 팀원 4명 (기획 1, 디자인 1, 프론트 1(나), 백엔드 1) 사이드프로젝트</p>
<p><strong>기존 방식:</strong></p>
<ol>
<li>기획서 작성</li>
<li>백엔드가 API 설계 고민</li>
<li>프론트는 기다리기</li>
<li>API 나오면 연동하면서 &quot;어? 이건 이렇게 나와야 하는데...&quot;</li>
<li>수정 요청 → 다시 기다리기 → 무한 반복</li>
</ol>
<p><strong>제가 바꾼 방식:</strong></p>
<ol>
<li>기획서 받자마자 Mock API로 전체 서비스 구현</li>
<li>스토리북 링크 공유: &quot;이렇게 동작해야 합니다&quot;</li>
<li>백엔드팀원에게: <strong>&quot;이 API 스펙대로 만들어주세요&quot;</strong> (명령 톤 ㅋㅋ)</li>
</ol>
<pre><code class="language-typescript">// 백엔드팀원에게 전달한 API 스펙
interface UserAPI {
  // GET /api/users/me
  getMe(): Promise&lt;{
    id: string;
    nickname: string;
    profileImage: string;
    point: number; // 바로 이 필드가 필요해요!
  }&gt;;

  // POST /api/posts
  createPost(data: {
    title: string;
    content: string;
    images: string[]; // 이미지 URL 배열로 주세요
  }): Promise&lt;{ id: string }&gt;;
}</code></pre>
<p><strong>백엔드팀원 반응:</strong></p>
<ul>
<li>&quot;어? 벌써 화면 다 나왔네? 신기하다&quot;</li>
<li>&quot;API 스펙 고민할 필요 없이 그대로 만들면 되겠네&quot;</li>
<li>&quot;프론트에서 필요한 데이터가 뭔지 명확하니까 개발하기 편하다&quot;</li>
</ul>
<h3 id="경험-3-디자이너와의-소통이-편해짐">경험 3: 디자이너와의 소통이 편해짐</h3>
<p><strong>Before:</strong></p>
<ul>
<li>디자이너: &quot;이 버튼 색깔 좀 바꿔주세요&quot;</li>
<li>나: &quot;네, 수정해서 스테이징 서버에 배포할게요&quot;</li>
<li>문제: <strong>스테이징 서버가 없어요...</strong> 😅</li>
<li>결국 &quot;나중에 확인해주세요&quot; → 소통 단절</li>
</ul>
<p><strong>After:</strong></p>
<ul>
<li>디자이너: &quot;이 버튼 색깔 좀 바꿔주세요&quot;</li>
<li>나: 스토리북 수정 → <strong>Chromatic으로 바로 배포</strong> (2분 소요)</li>
<li>나: &quot;링크 보내드릴게요, 이렇게 동작할 거예요&quot; 📱</li>
<li>디자이너: &quot;아 이런 느낌이군요! 좋아요!&quot; 👍</li>
</ul>
<p><strong>Chromatic 배포의 장점:</strong></p>
<pre><code class="language-bash"># 명령어 한 줄이면 끝
npx chromatic --project-token=xxx</code></pre>
<ul>
<li><strong>별도 서버 구축 필요 없음</strong></li>
<li><strong>모든 상태별 화면</strong> 한 번에 공유 가능</li>
<li><strong>모바일/데스크톱</strong> 모두 확인 가능</li>
<li><strong>URL 하나</strong>로 모든 페이지 접근</li>
</ul>
<p>디자이너가 <strong>실제로 클릭하고 체험할 수 있는</strong> 링크를 바로 공유하니까 소통이 완전 달라졌어요. &quot;이렇게 될 예정입니다&quot;가 아니라 <strong>&quot;지금 당장 확인해보세요!&quot;</strong></p>
<h2 id="결론---이제-백엔드에게-명령하세요">결론 - 이제 백엔드에게 명령하세요</h2>
<p><strong>&quot;API 언제 나와요?&quot;</strong> ❌</p>
<p><strong>&quot;이 스펙대로 만들어주세요!&quot;</strong> ✅</p>
<p>Mock API + 스토리북으로 완성된 화면을 보여주면서 당당하게 말할 수 있어요. </p>
<p>더 이상 을(乙)이 아닌, <strong>갑(甲) 포지션의 프론트엔드 개발자</strong>가 되어보세요!</p>
<h3 id="프론트엔드가-주도하는-개발의-장점">프론트엔드가 주도하는 개발의 장점</h3>
<p><strong>1. 일정 주도권 확보</strong><br>더 이상 백엔드 일정에 종속되지 않음</p>
<p><strong>2. 클라이언트/팀원들에게 신뢰 확보</strong><br>&quot;이 사람은 일을 빠르게 해내는구나&quot;</p>
<p><strong>3. 백엔드와의 관계 개선</strong><br>명확한 스펙 제공으로 서로 편해짐</p>
<p><strong>4. 품질 향상</strong><br>미리 모든 케이스를 검토하고 완성도 높은 결과물</p>
<hr>
<h2 id="실제-프로젝트-소개">실제 프로젝트 소개</h2>
<p>현재 저는 <strong>AI 이력서 코칭 서비스</strong>를 만들고 있습니다!</p>
<p>🔗 <strong><a href="https://fe-resume.coach?utm_source=blog&amp;utm_medium=article&amp;utm_campaign=storybook_guide">https://fe-resume.coach?utm_source=blog&amp;utm_medium=article&amp;utm_campaign=storybook_guide</a></strong></p>
<p>앞서 보여드린 게시판 예제도 바로 이 프로젝트에요. 마찬가지로 <strong>스토리북으로 화면 먼저 완성</strong>하고, 백엔드는 뒤에서 작업하고 있답니다.</p>
<ul>
<li>✅ 이력서 업로드 및 AI 분석</li>
<li>✅ 개인화된 피드백 제공  </li>
<li>✅ 이력서 템플릿 추천</li>
<li>✅ 진행상황 추적</li>
</ul>
<p><strong>여러분도 이런 방식으로 개발해보세요!</strong> </p>
<p>백엔드 기다리지 말고, Mock API + 스토리북으로 먼저 완성해서 보여주는 거예요. 정말 개발 경험이 달라질 거예요.</p>
<p>많은 관심 부탁드립니다! ㅎㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[면접준비!] React19에서 useRef는 뭐가 달라졌을까?]]></title>
            <link>https://velog.io/@k-svelte-master/react19-useref</link>
            <guid>https://velog.io/@k-svelte-master/react19-useref</guid>
            <pubDate>Thu, 03 Jul 2025 23:40:19 GMT</pubDate>
            <description><![CDATA[<h2 id="리액트19-useref-바뀐-소식-아직도-모르시는건아니죠-🤔">리액트19 useRef 바뀐 소식 아직도 모르시는건아니죠? 🤔</h2>
<p>리액트 19에서는 다양한 업데이트가 있었습니다. 당연히 이걸 잘 알아야 리액트를 잘 쓸 수 있는거겠죠? 이번업데이트에서 ref 관련해서는 정말 큰 변화들이 있었어요!</p>
<p>오늘은 React 19에서 달라진 <strong>ref의 핵심 변화</strong>와 많은 분들이 놓치고 있는 <strong>흔한 오해들</strong>에 대해 깊이 있게 알아보겠습니다.</p>
<h2 id="🚀-react-19의-핵심-변화들">🚀 React 19의 핵심 변화들</h2>
<h3 id="forwardref-이제-안녕---ref-as-prop">forwardRef 이제 안녕! - ref as prop</h3>
<p>React 19 이전에는 함수 컴포넌트에 ref를 전달하려면 <code>forwardRef</code>가 필수였어요:</p>
<pre><code class="language-tsx">// React 18 이전 방식
const Input = forwardRef&lt;HTMLInputElement, { placeholder: string }&gt;(
  ({ placeholder }, ref) =&gt; {
    return &lt;input ref={ref} placeholder={placeholder} /&gt;;
  }
);</code></pre>
<p>React 19에서는 <strong>ref를 일반 prop처럼</strong> 사용할 수 있어요:</p>
<pre><code class="language-tsx">// React 19 방식 - 훨씬 간단!
function Input({
  placeholder,
  ref,
}: {
  placeholder: string;
  ref?: React.Ref&lt;HTMLInputElement&gt;;
}) {
  return &lt;input ref={ref} placeholder={placeholder} /&gt;;
}</code></pre>
<p>forwardRef의 보일러플레이트가 완전히 사라졌죠!</p>
<h3 id="cleanup-함수-지원---react-19-정식-등장">cleanup 함수 지원 - React 19 정식 등장!</h3>
<p>React 19에서 <strong>ref 콜백에서 cleanup 함수를 반환할 수 있게</strong> 되었어요. 이 기능은 React 19에서 정식으로 등장했습니다!</p>
<pre><code class="language-tsx">const handleRef = (node: HTMLDivElement | null) =&gt; {
  if (!node) return;

  console.log(&quot;요소 마운트!&quot;);

  const handleClick = () =&gt; console.log(&quot;클릭!&quot;);
  node.addEventListener(&quot;click&quot;, handleClick);

  // cleanup 함수 반환 - 돔이 사라질 때 실행되어요!
  return () =&gt; {
    console.log(&quot;cleanup 실행!&quot;);
    node.removeEventListener(&quot;click&quot;, handleClick);
  };
};</code></pre>
<h2 id="💡-흔한-오해들과-인사이트">💡 흔한 오해들과 인사이트</h2>
<h3 id="오해-1-useref는-dom만-할당한다">오해 1: &quot;useRef는 DOM만 할당한다&quot;</h3>
<p>많은 분들이 <code>useRef</code>를 DOM 참조 전용으로 생각하시는데, 사실 <strong>그냥 값 저장하는 훅</strong>이에요!</p>
<pre><code class="language-tsx">// DOM 참조뿐만 아니라
const domRef = useRef&lt;HTMLDivElement&gt;(null);

// 어떤 값이든 저장 가능
const timerRef = useRef&lt;NodeJS.Timeout | null&gt;(null);
const countRef = useRef(0);
const objectRef = useRef({ name: &quot;React&quot;, version: 19 });</code></pre>
<p>더 놀라운 건, <strong>useCallback과 useMemo도 useRef로 구현할 수 있어요!</strong> (실제 구현체는 다르지만요)</p>
<pre><code class="language-tsx">// useCallback을 useRef로 구현
function useMyCallback&lt;T extends (...args: any[]) =&gt; any&gt;(
  callback: T,
  deps: React.DependencyList
): T {
  const ref = useRef&lt;{ callback: T; deps: React.DependencyList }&gt;({
    callback,
    deps,
  });

  if (!shallowEqual(deps, ref.current.deps)) {
    ref.current = { callback, deps };
  }

  return ref.current.callback;
}</code></pre>
<p>왜냐하면 결국 모든 훅들은 <strong>fiber 노드의 memoizedState</strong>에 저장되는 값들일 뿐이거든요!</p>
<h3 id="오해-2-ref에는-useref만-쓸-수-있다">오해 2: &quot;ref에는 useRef만 쓸 수 있다&quot;</h3>
<p>이것도 틀렸어요! ref의 진짜 스펙은 <strong>&quot;DOM 노드를 받는 콜백 함수&quot;</strong>입니다:</p>
<pre><code class="language-tsx">// useRef 사용
&lt;div ref={myRef} /&gt;

// 콜백 함수 직접 사용
&lt;div ref={(node) =&gt; console.log(&#39;마운트!&#39;, node)} /&gt;

// 커스텀 함수도 가능
const handleRef = (node: HTMLDivElement | null) =&gt; {
  // 원하는 로직
};
&lt;div ref={handleRef} /&gt;</code></pre>
<h2 id="⚡-ref-콜백의-실행-타이밍">⚡ ref 콜백의 실행 타이밍</h2>
<p>여기서 중요한 건 <strong>ref 콜백이 언제 실행되느냐</strong>예요. useEffect와 비교해볼까요?</p>
<pre><code class="language-tsx">function TimingExample() {
  const [count, setCount] = useState(0);

  useEffect(() =&gt; {
    console.log(&quot;useEffect 실행&quot;);
  }, []);

  const refCallback = useCallback(() =&gt; {
    console.log(&quot;ref 콜백 실행&quot;);
  }, []);

  return (
    &lt;div&gt;
      &lt;div ref={refCallback}&gt;카운트: {count}&lt;/div&gt;
      &lt;button onClick={() =&gt; setCount(count + 1)}&gt;+1&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>⚠️ <strong>주의:</strong> ref에 할당되는 함수가 달라지면 리렌더링할 때마다 실행되니 주의해주세요! 그래서 useCallback으로 감싸주는 것이 좋습니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/e41a0896-6a40-4d80-82db-489969f84330/image.png" alt="https://fe-resume.coach"></p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/4b9d63cf-ab8f-4ca6-961c-2ccd37824b20/image.gif" alt="https://fe-resume.coach"></p>
<p><strong>실행 순서:</strong></p>
<ol>
<li>컴포넌트 렌더링</li>
<li><strong>ref 콜백 실행</strong> ← DOM이 마운트되는 순간!</li>
<li>useEffect 실행</li>
</ol>
<p>ref 콜백은 <strong>DOM의 생명주기와 직접 연결</strong>되어 있어서:</p>
<ul>
<li>useEffect보다 더 빠르게 동작</li>
<li><strong>리렌더링과 관계없이 DOM이 실제로 마운트될 때만 실행</strong></li>
</ul>
<h2 id="🔥-심화-실전-활용법">🔥 심화!! 실전 활용법</h2>
<p>이제 이런 지식을 바탕으로 어떤 멋진 것들을 만들 수 있는지 보여드릴게요!</p>
<h3 id="useoutsideclick---더-이상-useeffect-안녕">useOutsideClick - 더 이상 useEffect 안녕!</h3>
<p>밖에 클릭하면 닫히는 거, 매번 useEffect로 구현하시나요?</p>
<pre><code class="language-tsx">// 기존 방식 - 번거로워요 😵
useEffect(() =&gt; {
  const handleClick = (e) =&gt; {
    if (ref.current &amp;&amp; !ref.current.contains(e.target)) {
      setIsOpen(false);
    }
  };
  document.addEventListener(&quot;mousedown&quot;, handleClick);
  return () =&gt; document.removeEventListener(&quot;mousedown&quot;, handleClick);
}, []);</code></pre>
<p><strong>✨ 이벤트 핸들러는 DOM 가까이에 있어야 자연스럽죠!</strong></p>
<pre><code class="language-tsx">const onOutsideClick = useOutsideClick();

&lt;div ref={onOutsideClick(() =&gt; setIsOpen(false))}&gt;메뉴 내용&lt;/div&gt;;</code></pre>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/947f9cca-bbea-46e0-a891-0194c22f1f3b/image.gif" alt="https://fe-resume.coach"></p>
<p>구현은 이렇게:</p>
<pre><code class="language-tsx">import { useEffect, useRef, useCallback } from &quot;react&quot;;

type OutsideClickCallback = () =&gt; void;

export const useOutsideClick = () =&gt; {
  // 현재 요소와 콜백을 저장
  const elementRef = useRef&lt;HTMLElement | null&gt;(null);
  const callbackRef = useRef&lt;OutsideClickCallback&gt;(() =&gt; {});

  useEffect(() =&gt; {
    const handleClick = (e: MouseEvent) =&gt; {
      const element = elementRef.current;
      if (!element) return;

      if (!element.contains(e.target as Node)) {
        callbackRef.current();
      }
    };

    // 모바일도 고려!
    document.addEventListener(&quot;mousedown&quot;, handleClick);
    document.addEventListener(&quot;touchstart&quot;, handleClick, { passive: true });

    return () =&gt; {
      document.removeEventListener(&quot;mousedown&quot;, handleClick);
      document.removeEventListener(&quot;touchstart&quot;, handleClick);
    };
  }, []);

  // stable한 ref 콜백 - useEvent 패턴 활용
  const stableCallback = useCallback((element: HTMLElement | null) =&gt; {
    elementRef.current = element;

    if (!element) return;

    // React 19 cleanup 활용!
    return () =&gt; {
      elementRef.current = null;
    };
  }, []);

  // 콜백을 업데이트하고 stable ref를 반환하는 함수
  return (callback: OutsideClickCallback) =&gt; {
    callbackRef.current = callback;
    return stableCallback;
  };
};</code></pre>
<p><strong>💡 useEvent 패턴</strong>: 콜백 함수는 stable하게 유지하되, 최신 값(옵션)에는 접근할 수 있는 패턴이에요. React 팀이 RFC로 제안한 useEvent 훅과 같은 아이디어로, &quot;latest ref pattern&quot;이라고도 불립니다.</p>
<p><strong>핵심 아이디어:</strong></p>
<ul>
<li>등록된 요소들을 Map에 저장</li>
<li>전역 클릭 이벤트에서 <code>!element.contains(e.target)</code>이면 콜백 실행</li>
<li>cleanup으로 자동 정리</li>
</ul>
<h3 id="usefade---애니메이션까지">useFade - 애니메이션까지!</h3>
<p>DOM이 사라질 때 애니메이션을 부여하려면? <strong>당연히 다시 DOM을 원래 위치에 삽입하고, 사라지는 효과 주고, 다시 제거하면 됩니다!</strong></p>
<p>ref 콜백으로 이걸 통제하는 방법을 보여드릴게요!</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/da8ec55d-62a4-4ebb-a866-1585cca111ca/image.gif" alt="https://fe-resume.coach"></p>
<pre><code class="language-tsx">import { useRef, useCallback } from &quot;react&quot;;

export type FadeOptions = {
  duration?: number;
};

export const useFade = () =&gt; {
  const containerRef = useRef&lt;HTMLElement | null&gt;(null);
  const optionsRef = useRef&lt;FadeOptions&gt;({});

  // stable한 ref 콜백 - useEvent 패턴 활용
  const stableCallback = useCallback((element: HTMLElement | null) =&gt; {
    const { duration = 300 } = optionsRef.current;
    if (!element) return;

    // fadeIn 효과
    element.style.opacity = &quot;0&quot;;
    element.style.transition = `opacity ${duration}ms ease-out`;
    containerRef.current = element.parentElement;

    requestAnimationFrame(() =&gt; {
      element.style.opacity = &quot;1&quot;;
    });

    // cleanup - 사라질 때 fadeOut!
    return () =&gt; {
      if (!containerRef.current || !element) return;

      // 1. 복사본을 원래 위치에 삽입
      const clone = element.cloneNode(true) as HTMLElement;
      clone.style.opacity = &quot;1&quot;;
      clone.style.transition = `opacity ${duration}ms ease-out`;
      containerRef.current.appendChild(clone);

      // 2. fadeOut 효과
      requestAnimationFrame(() =&gt; {
        clone.style.opacity = &quot;0&quot;;
      });

      // 3. 애니메이션 후 제거
      setTimeout(() =&gt; {
        clone.remove();
      }, duration);
    };
  }, []);

  // 옵션을 업데이트하고 stable 콜백을 반환하는 함수
  return (options: FadeOptions = {}) =&gt; {
    optionsRef.current = options;
    return stableCallback;
  };
};</code></pre>
<p><strong>💡 useEvent 패턴</strong>: 여기서도 같은 패턴을 사용했어요. ref 콜백은 stable하게 유지하되, 최신 콜백 함수에는 접근할 수 있도록 했습니다. 이는 Kent C. Dodds가 소개한 &quot;latest ref pattern&quot;으로, React 팀이 RFC로 제안한 useEvent 훅과 같은 아이디어입니다.</p>
<h3 id="ref-어떻게-여러개-쓸건데---mergerefs">ref 어떻게 여러개 쓸건데? - mergeRefs</h3>
<p>하나의 요소에 여러 ref를 적용하고 싶다면? mergeRefs 유틸리티로 금방이죠:</p>
<pre><code class="language-tsx">export function mergeRefs&lt;T = any&gt;(
  ...refs: Array&lt;React.Ref&lt;T&gt; | undefined&gt;
): React.RefCallback&lt;T&gt; {
  return (element: T | null) =&gt; {
    const cleanups: Array&lt;(() =&gt; void) | undefined&gt; = [];

    refs.forEach((ref) =&gt; {
      if (!ref) return;

      if (typeof ref === &quot;function&quot;) {
        const cleanup = ref(element);
        if (typeof cleanup === &quot;function&quot;) {
          cleanups.push(cleanup);
        }
      } else if (&quot;current&quot; in ref) {
        (ref as React.MutableRefObject&lt;T | null&gt;).current = element;
      }
    });

    // 클린업 함수들이 있으면 합쳐서 반환
    if (cleanups.length &gt; 0) {
      return () =&gt; {
        cleanups.forEach((cleanup) =&gt; {
          if (cleanup) cleanup();
        });
      };
    }
  };
}</code></pre>
<h2 id="🎯-마무리">🎯 마무리</h2>
<p>이런 활용법 뭔가 참신하신가죠? 사실 이건 제 아이디어가 아니라 스벨트를 참고한거에요. (제 닉네임을 확인 ㅎㅎ;;)</p>
<p><strong>Svelte 문법에는 이미 이런걸로 애니메이션이나 outside click을 구현하는 스펙</strong>이 있거든요. 타 프레임워크의 인사이트가 React에서도 적용이 되네요!</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/28f73125-fd99-48c2-ac2e-e4dd8cd90747/image.gif" alt="https://fe-resume.coach"></p>
<p><a href="https://ssgoi.dev/demo">스벨트로 만든 데모페이지</a></p>
<p>React 19의 ref 변화는 단순한 문법 개선이 아니라 <strong>더 나은 개발 경험</strong>을 제공합니다:</p>
<ul>
<li><strong>forwardRef 제거</strong>로 보일러플레이트 감소</li>
<li><strong>cleanup 함수 지원</strong>으로 더 강력한 리소스 관리</li>
<li><strong>DOM 생명주기와의 밀접한 연동</strong>으로 더 정확한 타이밍 제어</li>
</ul>
<p>여러분도 React 19의 새로운 ref 기능들을 활용해서 더 선언적이고 효율적인 코드를 작성해보세요! 🚀</p>
<hr>
<p><strong>💼 이력서 서비스 운영합니다!</strong><br>개발자 이력서 작성에 도움이 필요하시다면: <a href="https://fe-resume.coach/ai-resume?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=react19_useref">fe-resume.coach/ai-resume</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스벨트전도사, FeConf에서 스벨트 전도합니다!]]></title>
            <link>https://velog.io/@k-svelte-master/feconf-svelte-jeondosa</link>
            <guid>https://velog.io/@k-svelte-master/feconf-svelte-jeondosa</guid>
            <pubDate>Tue, 01 Jul 2025 15:17:26 GMT</pubDate>
            <description><![CDATA[<h2 id="여러분-큰일났습니다-feconf에서-스벨트-전도하게-됐어요-🎉">여러분! 큰일났습니다. FeConf에서 스벨트 전도하게 됐어요 🎉</h2>
<p>안녕하세요, 스벨트전도사입니다. 아니 정확히는 <strong>타락한스벨트전도사</strong>죠 ㅋㅋ</p>
<p>이번에 FeConf 2025에서 연사로 초청받았습니다! </p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/99672782-9308-4e97-ab1d-ebcefb0fa263/image.png" alt=""></p>
<h2 id="스벨트전도사의-타락-😅">스벨트전도사의 타락 😅</h2>
<p>저는 평소에 스벨트전도사라는 닉네임으로 요즘IT, velog, 링크드인, 각종 오픈톡방에서 스벨트를 전도하러 다니는데요. 이런 큰 행사에 초청받아서 정말 기쁩니다.</p>
<p>근데 사실 고백할 게 있어요. velog에서는... 스벨트 글 올려봐야 아무도 안 봐서 리액트 글만 쭉 올렸거든요 ㅋㅋㅋ 그래서 제 닉네임이 <strong>&quot;타락한&quot;스벨트전도사</strong>입니다. 현실과 타협한 전도사의 슬픈 이야기죠.</p>
<p>하지만 이제 FeConf라는 큰 무대에서 당당하게 스벨트를 외칠 수 있게 됐네요!</p>
<h2 id="발표-내용-리액트-빼고-다-쓰는-그-렌더링-방식">발표 내용: &quot;리액트 빼고 다 쓰는 그 렌더링 방식&quot;</h2>
<p>이번 발표에서는 <strong>시그널(Signal)</strong>에 대해 이야기할 예정입니다.</p>
<p>요즘 Vue, SolidJS, Preact, Angular... 모든 프레임워크가 시그널을 도입하고 있어요. 리액트만 빼고요. 왜 모두가 시그널로 향하고 있을까요? 리액트의 리렌더링 지옥에서 벗어날 수 있는 방법이 정말 있을까요?</p>
<p>발표에서는 이런 내용들을 다룰 예정입니다:</p>
<ul>
<li>가상 DOM vs 시그널 기반 업데이트의 철학적 차이</li>
<li>왜 모든 프레임워크가 시그널로 갈아타고 있는지</li>
<li>스벨트의 컴파일 타임 최적화가 어떻게 마법을 부리는지</li>
</ul>
<h2 id="스벨트로-만든-페이지-전환-라이브러리-자랑하기-😎">스벨트로 만든 페이지 전환 라이브러리 자랑하기 😎</h2>
<p>발표에서 실제로 시연할 예정인 제가 만든 <strong>ssgoi</strong> 라이브러리입니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/02991cef-87bc-45db-8d58-191b8634a452/image.gif" alt=""></p>
<p>스벨트만으로 외부 라이브러리 하나 없이 이런 모바일 수준의 페이지 전환 애니메이션을 만들었어요. 어떻게요? 황벨트니까 가능한 거죠 ㅎㅎ</p>
<p>이런 애니메이션 완전 날로먹었죠? framer-motion 같은 무거운 라이브러리 없이도 이렇게 부드러운 전환이 가능합니다.</p>
<p>🌟 <strong>GitHub에서 스타 한 번씩만 눌러주세요!</strong> 🌟
👉 <a href="https://github.com/meursyphus/ssgoi">https://github.com/meursyphus/ssgoi</a></p>
<h2 id="8월-23일-feconf에서-만나요">8월 23일, FeConf에서 만나요!</h2>
<p>정말 설레네요. 그동안 온라인에서만 스벨트를 전도하다가 이제 오프라인 큰 무대에서 할 수 있게 됐다니!</p>
<p>발표 들으러 오실 분들 댓글로 알려주세요. 현장에서 뵙겠습니다 🙌</p>
<hr>
<h2 id="마지막으로-하나만-더">마지막으로 하나만 더...</h2>
<p>요즘 프론트엔드 개발자분들의 이력서 피드백 서비스를 운영하고 있어요. AI가 이력서를 분석해서 개선점을 알려드리는 서비스인데, 한번 둘러보세요!</p>
<p>👉 <a href="https://fe-resume.coach/ai-resume?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=feconf_announcement">이력서 코치 - AI 이력서 분석</a></p>
<p>그럼 8월 23일에 뵙겠습니다! 🚀</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[100달러 충전했는데 3일만에 5만원 긁힌 이력서 AI 서비스 개발기]]></title>
            <link>https://velog.io/@k-svelte-master/snu-ai-resume</link>
            <guid>https://velog.io/@k-svelte-master/snu-ai-resume</guid>
            <pubDate>Fri, 27 Jun 2025 23:58:50 GMT</pubDate>
            <description><![CDATA[<h2 id="어-뭔가-이상한데">&quot;어? 뭔가 이상한데?&quot;</h2>
<p>클로드 API에 100달러 충전해놓고 여유롭게 있었는데, 서비스 공개 후 3일 만에 5만원이 긁혔다. 지금은 68달러, 약 8만원 정도가 소진된 상태다. </p>
<p>처음에는 &quot;뭔가 잘못됐나?&quot; 싶었는데, 알고 보니 생각보다 많은 분들이 이력서 평가 서비스를 사용해주신 거였다.</p>
<p>한 번에 클로드 API를 6번 호출하는 구조라 비용이 꽤 나가는 편인데, 그래도 사람들이 써주니까 뿌듯하기도 하고 지갑은 아프기도 하고.</p>
<h2 id="사실-나도-서탈로-개고생했던-사람">&quot;사실 나도 서탈로 개고생했던 사람&quot;</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/cf045c94-363d-4578-84bc-66979fe144df/image.jpeg" alt="서울대 졸업사진"></p>
<p>미안하다. 어그로 좀 끌어봤다. 보다시피 난 서울대 학부졸업을 했다. 그래서 너는 서울대니까 다 붙는거 아니야라는 이야기를 종종 듣는다.</p>
<p>근데 현실인 서류광탈 ㅋㅋ 겁나 떨어지고, 서류 붙은 곳들은 이상한 곳들이었다(그래서 안 갔다). 그렇다고 내가 뭐 눈이 높은 것도 아니고, 3년 차에 4~5천 바란 게 전부인데도 말이다.</p>
<p>그때 깨달은 건 학력보다는 빅테크 출신이 100만 배 더 먹힌다는 것이었다. 근데 빅테크를 어떻게 가냐고!! 이런 상황이었다.</p>
<p>시대가 달라졌다. 이제는 학력의 메리트가 전보다 줄었다. 경제가 각박해져서 &quot;대충 학력 좋은 애 뽑아놓으면 성장하겠지&quot;를 못 기다리는 시대가 됐다. </p>
<p>그거보다 강력한 근거를 줘야 뽑히는 시대. 더 깐깐해졌기 때문에 학력을 안 보는 거지, 네카라 출신 경력은 여전히 프리패스일 것 같다.</p>
<h2 id="채용담당자가-되고-나서-보인-것들">&quot;채용담당자가 되고 나서 보인 것들&quot;</h2>
<p>그런데 어쩌다 보니 내가 채용담당자가 됐다. 이력서를 보는 입장이 되니까 이전과는 완전히 다른 게 보이기 시작했다.</p>
<p><strong>대부분의 이력서에서 누락되는 것들:</strong></p>
<ul>
<li>(신입) 부트캠프에서 어땠는지</li>
<li>(신입) 대학교 조별과제에서 어떤 역할이었는지  </li>
<li>(경력) 회사에서 기획자, 디자이너와 어떤 일을 했는지</li>
</ul>
<p>다들 기술 스택만 주구장창 나열하는데, 솔직히 기술적 역량에서 개발자들 간 차이가 크지 않다. 오히려 <strong>소프트스킬</strong>이 더 중요하다. 노벨평화상을 받았다 이런 수준이 아니라, 그냥 나의 의사소통 능력에 대한 남들의 평가, 경험을 녹여내면 된다.</p>
<p>나는 프로덕트 엔지니어를 지향하는 주의라서, 개발자가 개발만 하는 게 아니라 자신의 제품에 관심을 가지고 의견을 낼 줄 알아야 한다고 생각한다.</p>
<p>그래서 <strong>비즈니스적으로 도메인 관심사</strong>를 보이라고 한다. 관련 전공이거나 취미, 또는 지원하는 회사 도메인의 사이드 프로젝트를 한 경험 같은 것들 말이다.</p>
<p>솔직히 말해서 뽑는 입장에서는 대박이 걸리기보단 꽝을 피하는게 더 우선이다.. 세상엔 진짜 다양한 사람들이 많으니까..</p>
<h2 id="그래서-ai로-만들어봤다">&quot;그래서 AI로 만들어봤다&quot;</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/ebfb804d-0291-435e-82f9-c99d18b3d286/image.png" alt="https://fe-resume.coach/ai-resume?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=resume_analysis_0628&amp;utm_content=ai_resume_service"></p>
<p>이런 인사이트들을 바탕으로 이력서 평가 서비스를 만들어봤다. </p>
<p><strong>특이한 점들:</strong></p>
<ul>
<li>항목별 체크리스트로 해당 내용이 존재하느냐 부재하느냐만 체크해서 점수를 낸다</li>
<li>기술적 역량은 점수를 낮게 본다 (위에서 말한 이유로)</li>
<li>그래서 토스, 올영 같은 곳 분들 이력서도 낮게 나온다</li>
<li>내 이력서도 낮게 나온다 (나도 그리 잘 쓰진 않았거든)</li>
</ul>
<p>하지만 나는 라이브러리를 만들고 운영 중이라는 한 줄로 통과한다. 보여줄 성과가 뚜렷하면 이력서는 간결해도 된다는 방증이다. 그런데 그런 경우는 흔치 않으니까... (내 라이브러리는 <a href="https://flitter.dev">https://flitter.dev</a> 이다)</p>
<h2 id="기술적으로는-이렇게-만들었다">&quot;기술적으로는 이렇게 만들었다&quot;</h2>
<p>할루시네이션을 줄이려고 여러 전략을 써봤다. 같은 이력서인데 언제는 3점, 어제는 5점 나오면 안 되니까.</p>
<p><strong>주요 전략들:</strong></p>
<ul>
<li>평가 항목이 늘어날수록 길어지는 시스템 프롬프트를 분할해서 합치기</li>
<li>그래서 한 번에 6개의 클로드 API를 호출한다 (ㅠㅠ 그래서 비용이 비싸다)</li>
<li>구조화된 응답을 얻기 위해 실패하면 retry로 어느 부분이 파싱 실패했는지 확인하고 다시 시도</li>
<li>최대한 실패 없이 일관된 응답을 내기 위한 전략들</li>
</ul>
<p>이런 전략들은 내 고유 아이디어라기보다는 이전부터 나온 AI 활용 기술들이다. 클로드 블로그 포스팅 가면 쉽게 접할 수 있다. AI의 대중화로 이런 기법들도 대중화되면 좋겠다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/926ff73a-15f0-4448-bf38-efd9ca74deb2/image.png" alt="agent">
&lt;출처: <a href="https://www.anthropic.com/engineering/building-effective-agents&gt;">https://www.anthropic.com/engineering/building-effective-agents&gt;</a></p>
<p>당연히 스벨트 전도사인 나는 이걸 스벨트킷으로 만들었다 ㅋㅋ</p>
<h2 id="개발은-클로드코드가-화면은-스토리북이">&quot;개발은 클로드코드가, 화면은 스토리북이&quot;</h2>
<p>이 모든 걸 클로드코드로 만들었다. 사실 화면을 잘 안 본다. </p>
<p>터미널에서 클로드코드로 주구장창 개발하다가, 화면을 보면서 피드백해야 할 일이 있으면 스토리북을 띄워둔다. </p>
<p>생각해보라. 이력서 평가지의 레이아웃을 고치고 확인할 때마다 실제 이력서를 올려보는 건 너무 귀찮다. 매번 클릭해서 결과 보는 데 30초 걸리고, 게다가 비싸다 ㅠㅠ 클로드 API 6번 호출하는데 테스트할 때마다 돈이 나간다고.</p>
<p>그래서 화면 개발할 때는 주로 스토리북을 띄워두고 개발하는 편이다. 컴포넌트별로 독립적으로 확인할 수 있고, 다양한 상태를 시뮬레이션할 수 있어서 훨씬 효율적이다.</p>
<p>스토리북 설정도 클로드코드가 다 짜줬다! </p>
<p>&quot;스벨트킷 프로젝트에 스토리북 설정해줘, 그리고 이력서 평가 결과 컴포넌트 스토리 만들어줘&quot;</p>
<p>이런 식으로 말하면 알아서 다 만들어준다. 개발자 인생이 이렇게 편해져도 되나 싶을 정도다.</p>
<p>덕분에 실제 서비스 로직과 UI 개발을 완전히 분리해서 작업할 수 있었고, 비용 걱정 없이 화면을 다듬을 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/2b880ed7-2a8f-4096-bc7b-f02c9f6f17ca/image.png" alt="이력서"></p>
<h2 id="그래서-뭘-배웠나">&quot;그래서 뭘 배웠나&quot;</h2>
<p><strong>학력은 정말 중요하지 않다.</strong> 내가 이걸 당당히 말할 수 있는 이유는 서울대 나와도 줄기차게 떨어져봤기 때문이다. 하지만 그렇다고 헛된 희망을 주려는 게 아니다. 사실 나는 누구보다 학력을 봤으면 좋겠다고 생각했다.(그래야 내가 유리함 ㅠ) 그런데 그것만으로는 안 됐다.</p>
<p><strong>지금은 더 깐깐한 시대다.</strong> 그냥 각박한 거다. 더 강력한 근거를 줘야 뽑히는 시대가 됐다.</p>
<p><strong>자기 PR의 시대가 왔다.</strong> 라이브러리도 만들고, 요즘IT에 글도 쓰고, velog에도 글을 쓰고 있다. 네카라 출신이 아닌 내가 어필하기 위한 방법들이다.</p>
<hr>
<p><em>P.S. 이력서 평가가 궁금하다면 한 번 써보시길. 100달러 다 떨어지기 전에 ㅋㅋ</em></p>
<p><a href="https://fe-resume.coach/ai-resume?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=resume_analysis_0628&amp;utm_content=ai_resume_service">이력서 AI 평가 서비스 링크</a></p>
<p>그리고 이것도 AI가 쓴 글임ㅋㅋ </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 개발자도 디자인패턴 알아야할까요? - 상태패턴편]]></title>
            <link>https://velog.io/@k-svelte-master/frontend-designpattern-xstate</link>
            <guid>https://velog.io/@k-svelte-master/frontend-designpattern-xstate</guid>
            <pubDate>Thu, 12 Jun 2025 01:56:44 GMT</pubDate>
            <description><![CDATA[<p><strong>기획자</strong>: &quot;아, 그 회원가입 플로우에서 본인인증을 좀 더 앞쪽으로 옮겨야 할 것 같아요.&quot;</p>
<p><strong>개발자</strong>: &quot;네? 그럼 전체 단계를 다시 짜야 하는데요...&quot;</p>
<p><strong>기획자</strong>: &quot;그리고 직장인이랑 프리랜서 가입 과정도 좀 다르게 해야겠어요. 아, 그리고 중간에 추가 서류 업로드 단계도 넣어주세요.&quot;</p>
<p><strong>개발자</strong>: &quot;😭&quot;</p>
<hr>
<p>이런 상황, 경험해보신 적 있나요? </p>
<p>처음에는 단순한 3단계 가입 플로우였는데, 기획이 바뀔 때마다 코드가 점점 복잡해지고... 결국 <code>if/else</code> 분기문이 모든 부분에 퍼지는 상태가 되어버리는 거죠.</p>
<h3 id="이런-걸-해결하려고-디자인패턴이-있어요">이런 걸 해결하려고 디자인패턴이 있어요</h3>
<p>사실 이런 문제를 우아하게 해결하려고 디자인패턴이라는 게 고안되었거든요. 복잡한 로직과 자주 바뀌는 요구사항에 대처하는 선배 개발자들의 지혜 같은 거죠.</p>
<p>&quot;그런데 그거 백엔드에서나 쓰는 거 아닌가요?&quot;</p>
<p>당신은 이미 쓰고 있을지도 모릅니다. React의 컴포넌트 시스템, Redux의 상태 관리, 각종 Hook들... 우리가 매일 쓰는 라이브러리들이 디자인패턴으로 만들어져 있어요.</p>
<h3 id="상태패턴으로-복잡한-플로우-정리하기">상태패턴으로 복잡한 플로우 정리하기</h3>
<p>오늘은 <strong>상태패턴</strong>을 살펴보려고 해요. 특히 복잡한 다단계 플로우에서 어떻게 활용할 수 있는지 알아보겠습니다.</p>
<p>한 번 상황을 가정해볼게요. 사용자가 어떤 서비스에 가입하는 플로우인데, 입력하는 정보에 따라 다음 단계가 달라지는 거예요. 그리고 기획자는 계속 &quot;이 조건일 때는 저 단계로 보내주세요&quot;라고 하고...</p>
<p>이런 상황에서 상태패턴이 어떻게 우리를 구원해줄 수 있는지, 실제 코드와 함께 살펴보겠습니다.</p>
<h2 id="본론1-상태패턴-맛보기">본론1: 상태패턴 맛보기</h2>
<h3 id="상태에-따라-다르게-동작하는-것들">상태에 따라 다르게 동작하는 것들</h3>
<p>상태에 따라 다르게 동작하는 것들은 상태패턴으로 하는 게 좋아요.</p>
<p>여러분도 의식하지 않고 이미 구현하고 있는 게 있어요. 바로 <strong>토글</strong>입니다.</p>
<pre><code class="language-javascript">const [isOn, setIsOn] = useState(false);

const handleClick = () =&gt; {
  if (isOn) {
    // OFF로 변경
    setIsOn(false);
  } else {
    // ON으로 변경  
    setIsOn(true);
  }
};</code></pre>
<p>상태가 2개니까 괜찮아요. 하지만 다단계 가입 플로우는 어떨까요? </p>
<p>&quot;다음&quot; 버튼은 계속 동일하게 노출되어 있는데, 유저가 뭘 선택하고 어떤 단계를 거쳤느냐에 따라 다른 단계로 보내야 해요:</p>
<pre><code class="language-javascript">const handleNext = () =&gt; {
  if (step === 1) {
    setStep(2);
  } else if (step === 2 &amp;&amp; userJob === &#39;employee&#39;) {
    setStep(3);
  } else if (step === 2 &amp;&amp; userJob === &#39;freelancer&#39;) {
    setStep(4);
  } else if (step === 3) {
    submitData();
  }
  // ... 계속 늘어남
};</code></pre>
<p>이런 식으로 분기문이 폭발하죠.</p>
<h3 id="상태패턴의-핵심-아이디어">상태패턴의 핵심 아이디어</h3>
<p>상태라는 것이 단순한 값이 아니라, <strong>동작까지 여기로 위임시켜버리는</strong> 게 상태패턴입니다.</p>
<pre><code class="language-javascript">// 상태 = 값 + 동작
const states = {
  OFF: {
    label: &#39;OFF&#39;,
    handleClick: (setState) =&gt; {
      console.log(&#39;전원을 켭니다&#39;);
      setState(states.ON);  // 동작 자체에서 상태도 바꿈
    }
  },
  ON: {
    label: &#39;ON&#39;,
    handleClick: (setState) =&gt; {
      console.log(&#39;전원을 끕니다&#39;);
      setState(states.OFF);  // 동작 자체에서 상태도 바꿈
    }
  }
};

function ToggleButton() {
  const [currentState, setCurrentState] = useState(states.OFF);

  const handleClick = () =&gt; {
    // 그냥 현재 상태에서 호출하면 됨
    currentState.handleClick(setCurrentState);
  };

  return &lt;button onClick={handleClick}&gt;{currentState.label}&lt;/button&gt;;
}</code></pre>
<p>이제 <code>handleClick</code>에는 분기문이 없어요. 현재 상태가 알아서 동작도 하고, 다음 상태로의 전환도 처리하니까요.</p>
<h3 id="위임의-힘">위임의 힘</h3>
<p>핵심은 <strong>위임</strong>이에요. 기존에는 메인 함수에서 모든 상태를 확인하고 처리했다면:</p>
<pre><code>기존: this.handleClick() { if (상태1) {...} else if (상태2) {...} }</code></pre><p>상태패턴에서는 현재 상태에게 처리를 맡겨버려요:</p>
<pre><code>패턴: this.handleClick() { this.currentState.handleClick() }</code></pre><p>이렇게 하면 상태별 로직이 각 상태 객체 안에 깔끔하게 정리되고, 메인 로직은 단순해지죠.</p>
<h3 id="언제-써야-할까요">언제 써야 할까요?</h3>
<p>&quot;<strong>한 동작이 상태에 따라 다르게 처리</strong>되는 상황&quot;이면 상태패턴을 고려해보세요:</p>
<ul>
<li>같은 버튼인데 상태별로 다른 일을 해야 할 때</li>
<li>복잡한 플로우에서 단계별로 다른 처리가 필요할 때  </li>
<li><code>if/else</code> 분기문이 여러 곳에 퍼져있을 때</li>
</ul>
<p>특히 복잡한 플로우에서는 각 단계가 자신의 다음 단계와 동작을 알고 있어서 더욱 유용해요. 다음에는 실제로 복잡한 플로우에서 이 패턴이 어떻게 빛을 발하는지 살펴보겠습니다.</p>
<h2 id="본론2-백엔드와-똑같이-설계할-수-없는-이유">본론2: 백엔드와 똑같이 설계할 수 없는 이유</h2>
<h3 id="백엔드는-깔끔하게-나누면-끝">백엔드는 깔끔하게 나누면 끝</h3>
<p>적금 상품 API를 만든다고 생각해보세요. 아주 깔끔하죠:</p>
<pre><code class="language-javascript">// 정기적금
POST /api/savings/regular
{
  name: &quot;김철수&quot;,
  monthlyAmount: 500000,
  period: 12
}

// 자유적금  
POST /api/savings/flexible
{
  name: &quot;이영희&quot;, 
  targetAmount: 10000000,
  depositMethod: &quot;automatic&quot;
}

// ISA 계좌
POST /api/savings/isa
{
  name: &quot;박민수&quot;,
  age: 28,
  income: 50000000,
  hasExistingISA: false
}</code></pre>
<p>각 상품별로 필요한 데이터만 받으면 끝이에요. 깔끔하고 명확하죠.</p>
<h3 id="프론트엔드는-똑같이-설계할-수-없어요">프론트엔드는 똑같이 설계할 수 없어요</h3>
<p>그런데 프론트엔드에서 이걸 그대로 따라하면 어떻게 될까요?</p>
<p><strong>사용자에게 이렇게 보여주는 거죠:</strong></p>
<pre><code>┌─────────────────────────────────┐
│        적금 상품 선택하기          │
├─────────────────────────────────┤
│ □ 정기적금 (매달 일정금액)         │
│ □ 자유적금 (자유롭게 입금)         │
│ □ 정기예금 (목돈 한번에)          │
│ □ 월급통장연계적금               │
│ □ ISA 계좌                     │
│ □ 연금저축                     │
│ □ 주택청약종합저축               │
│ □ 소액투자상품연계적금            │
└─────────────────────────────────┘</code></pre><p><strong>사용자 심리:</strong></p>
<ul>
<li>&quot;ISA가 뭐지? 나한테 맞나?&quot;</li>
<li>&quot;월급통장연계는 뭔 차이지?&quot;</li>
<li>&quot;정기적금이랑 정기예금 차이가 뭐지?&quot;</li>
</ul>
<p><strong>결과:</strong> 선택 부담 → 페이지 이탈</p>
<h3 id="유저를-붙잡아두려면-코드가-지저분해져야-해요">유저를 붙잡아두려면 코드가 지저분해져야 해요</h3>
<p>그래서 <strong>진입점을 최대한 줄이고</strong>, <strong>먼저 선택하게 하지 말고</strong> 이런 전략을 써야 해요:</p>
<p><strong>퍼널(Funnel)</strong>: 복잡한 프로세스를 여러 단계로 나누어서 사용자를 단계별로 유도하는 방식</p>
<p><strong>1단계: 부담 없는 시작</strong></p>
<pre><code>┌─────────────────────────────────┐
│     돈 모으기 시작하기            │
│                               │
│  목표 금액: [_________]원         │
│  언제까지: [_____]개월 후         │
│                               │
│         [다음]                 │
└─────────────────────────────────┘</code></pre><p><strong>2단계: 자연스러운 분기</strong></p>
<pre><code>┌─────────────────────────────────┐
│    어떤 방식으로 모으실건가요?     │
│                               │
│  ○ 매달 일정 금액씩             │
│  ○ 있을 때마다 자유롭게          │
│  ○ 목돈으로 한번에              │
│                               │
│         [다음]                 │
└─────────────────────────────────┘</code></pre><p><strong>3단계: 조건부 추가 정보</strong></p>
<pre><code>┌─────────────────────────────────┐
│      세금 혜택도 받고 싶나요?     │
│                               │
│  ○ 네 (나이/소득 확인 필요)       │
│  ○ 아니요                      │
│                               │
│         [다음]                 │
└─────────────────────────────────┘</code></pre><p>그리고 <strong>회원가입도 전략적으로</strong>: 먼저 회원가입시키는 게 아니라 일단 기본정보 입력하고 나서 회원가입을 유도하면, 사용자는 입력했던 노력을 생각해서 이탈하지 않아요.</p>
<h3 id="여기서-분기가-폭발합니다">여기서 분기가 폭발합니다</h3>
<p><strong>다음 버튼</strong>이 상태에 따라 어디로 갈지, 이전에 입력했던 값에 따라 어디로 갈지 달라지겠죠?</p>
<pre><code class="language-javascript">const handleNext = () =&gt; {
  if (step === 1) {
    setStep(2);
  } else if (step === 2) {
    // 저축 방식에 따라 다른 단계로
    if (savingType === &#39;regular&#39;) {
      setStep(3); // 정기적금 상세 설정
    } else if (savingType === &#39;flexible&#39;) {
      setStep(4); // 자유적금 상세 설정  
    } else if (savingType === &#39;lump-sum&#39;) {
      setStep(5); // 예금 상세 설정
    }
  } else if (step === 3) {
    // 세금 혜택 여부에 따라 분기
    if (wantsTaxBenefit &amp;&amp; age &lt; 30) {
      setStep(6); // ISA 안내
    } else if (wantsTaxBenefit &amp;&amp; age &gt;= 30) {
      setStep(7); // 연금저축 안내
    } else {
      setStep(8); // 일반 적금
    }
  }
  // ... 계속 늘어남
};</code></pre>
<p><strong>그리고 이전 단계도 복잡:</strong></p>
<pre><code class="language-javascript">const handlePrev = () =&gt; {
  if (step === 3 || step === 4 || step === 5) {
    setStep(2); // 저축 방식 선택으로
  } else if (step === 6 || step === 7 || step === 8) {
    setStep(3); // 세금 혜택 선택으로
  }
  // ... 역시 복잡
};</code></pre>
<h3 id="결국-프론트엔드가-고생합니다-😭">결국 프론트엔드가 고생합니다 😭</h3>
<p><strong>백엔드:</strong> 깔끔한 API 설계<br><strong>프론트엔드:</strong> 사용자 이탈 방지를 위한 복잡한 플로우 관리</p>
<p>이 딜레마를 해결하는 게 상태패턴입니다. 다음에는 이 복잡한 플로우를 상태패턴으로 어떻게 우아하게 정리할 수 있는지 살펴보겠어요.</p>
<h2 id="본론3-상태패턴으로-퍼널-정리하기">본론3: 상태패턴으로 퍼널 정리하기</h2>
<h3 id="분기를-상태로-바꾸기">분기를 상태로 바꾸기</h3>
<p>본론2에서 봤던 복잡한 적금 퍼널을 기억하시나요? 이걸 상태패턴으로 어떻게 정리할 수 있을까요?</p>
<p>먼저 모든 상태들을 미리 정의해두겠습니다:</p>
<pre><code class="language-javascript">// 모든 퍼널 상태들을 미리 정의
const 목표금액입력 = {
  component: GoalAmountStep,
  getNextState: () =&gt; {
    // 항상 다음 단계로
    return 저축방식선택;
  }
};

const 저축방식선택 = {
  component: SavingTypeStep,
  getNextState: () =&gt; {
    // 내부 상태 보고 다음 단계 결정
    const savingType = getUserAnswer(&#39;savingType&#39;);
    if (savingType === &#39;regular&#39;) return 정기적금설정;
    if (savingType === &#39;flexible&#39;) return 자유적금설정;
    return 목돈예금설정;
  }
};

const 정기적금설정 = {
  component: RegularSavingStep,
  getNextState: () =&gt; {
    // 설정 완료 후 다음 단계로
    return 세금혜택질문;
  }
};

// ... 다른 상태들도 동일한 패턴</code></pre>
<p><strong>기존 방식 vs 상태패턴 방식:</strong></p>
<pre><code class="language-javascript">// 기존: 분기문 지옥
if (step === 2) {
  if (savingType === &#39;regular&#39;) setStep(3);
  else if (savingType === &#39;flexible&#39;) setStep(4);
  else if (savingType === &#39;lump-sum&#39;) setStep(5);
}

// 상태패턴: 각 상태가 자신의 다음 단계를 안다
const 저축방식선택 = {
  getNextState: () =&gt; {
    // 내부 상태 보고 다음 단계 결정
    const savingType = getUserAnswer(&#39;savingType&#39;);
    if (savingType === &#39;regular&#39;) return 정기적금설정;
    return 다른상태들;
  }
};</code></pre>
<h3 id="다이어그램으로-설계하기">다이어그램으로 설계하기</h3>
<p>적금 퍼널의 복잡한 분기를 상태 다이어그램으로 표현하면 이렇게 됩니다:</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/93db8b38-e92d-4a89-8bdd-106691d578db/image.png" alt=""></p>
<p>각 상태가 <strong>독립적</strong>이고, 자신의 <strong>다음 상태</strong>를 결정할 수 있어요.</p>
<h3 id="모든-상태를-글로벌하게-관리하기">모든 상태를 글로벌하게 관리하기</h3>
<p>이제 모든 상태들을 하나의 객체로 관리해요:</p>
<pre><code class="language-javascript">// 모든 상태들을 글로벌하게 관리
const funnelStates = {
  목표금액입력,
  저축방식선택,
  정기적금설정,
  자유적금설정,
  목돈예금설정,
  세금혜택질문,
  ISA계좌안내,
  연금저축안내,
  일반적금안내,
  회원가입유도,
  완료단계
};</code></pre>
<h3 id="react-hook으로-구현하기">React Hook으로 구현하기</h3>
<p>퍼널 전용 React Hook을 만들어서 사용해요:</p>
<pre><code class="language-javascript">// 퍼널 전용 React Hook
const useFunnelState = () =&gt; {
  const [currentState, setCurrentState] = useState(funnelStates.목표금액입력);
  const [userAnswers, setUserAnswers] = useState({
    goalAmount: null,
    savingType: null,
    wantsTaxBenefit: null,
    age: null
  });

  const handleNext = () =&gt; {
    const nextState = currentState.getNextState();
    setCurrentState(nextState);
  };

  const handlePrev = () =&gt; {
    const prevState = currentState.getPrevState();
    setCurrentState(prevState);
  };

  return { currentState, handleNext, handlePrev };
};</code></pre>
<h3 id="메인-컴포넌트-단순하게-만들기">메인 컴포넌트 단순하게 만들기</h3>
<p>이제 메인 퍼널 컴포넌트는 custom hook을 사용해서 더욱 단순해져요:</p>
<pre><code class="language-javascript">function SavingsFunnel() {
  const { currentState, handleNext, handlePrev } = useFunnelState();

  return (
    &lt;div&gt;
      &lt;currentState.component /&gt;
      &lt;button onClick={handlePrev}&gt;이전&lt;/button&gt;
      &lt;button onClick={handleNext}&gt;다음&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>분기문이 완전히 사라졌어요! 각 상태가 자신의 로직을 알고 있으니까요.</p>
<h3 id="새로운-단계-쉽게-추가하기">새로운 단계 쉽게 추가하기</h3>
<p>새로운 단계를 추가하려면? 새로운 상태 객체만 만들고, 관련 상태들의 연결만 수정하면 돼요:</p>
<pre><code class="language-javascript">const 추가인증단계 = {
  component: VerificationStep,
  getNextState: () =&gt; {
    return funnelStates.완료단계;
  },
  getPrevState: () =&gt; {
    return funnelStates.회원가입유도;
  }
};

// 기존 상태의 연결만 수정
회원가입유도.getNextState = () =&gt; funnelStates.추가인증단계;</code></pre>
<p>기존 <code>handleNext</code>, <code>handlePrev</code> 함수는 전혀 건드릴 필요가 없어요.</p>
<p>이게 상태패턴의 진짜 매력입니다. 복잡한 분기 로직을 각 상태로 분산시켜서, 전체적으로는 단순하고 확장 가능한 구조를 만드는 거예요.</p>
<h2 id="본론4-상태패턴의-다양한-활용처">본론4: 상태패턴의 다양한 활용처</h2>
<h3 id="에디터에서-툴-상태-관리하기">에디터에서 툴 상태 관리하기</h3>
<p>그림 에디터를 생각해보세요. 사용자는 항상 똑같은 동작을 해요:</p>
<ul>
<li>마우스 버튼 누르기</li>
<li>끌기  </li>
<li>떼기</li>
</ul>
<p>하지만 현재 선택된 <strong>툴</strong>에 따라 결과가 완전히 달라지죠:</p>
<ul>
<li><strong>브러시 툴</strong>: 누르고 끌면 선이 그려짐 → 떼면 그리기 완료, <strong>기본 선택 툴로 변경</strong></li>
<li><strong>지우개 툴</strong>: 누르고 끌면 지워짐 → 떼면 지우기 완료, <strong>기본 선택 툴로 변경</strong></li>
<li><strong>선택 툴</strong>: 누르고 끌면 선택 영역 생성 → 떼면 선택 완료, <strong>선택 툴 유지</strong></li>
<li><strong>도형 툴</strong>: 누르고 끌면 미리보기 → 떼면 도형 생성 후 <strong>기본 선택 툴로 변경</strong></li>
</ul>
<p>특히 주목할 점은 <strong>툴 자동 변경</strong>이에요. 도형을 그리고 나면 자동으로 기본 툴로 바뀌는 게 UX적으로 자연스럽거든요. 이런 로직까지 각 툴 상태가 스스로 관리하는 거죠.</p>
<p>실제로 <strong>tldraw</strong> 같은 유명한 오픈소스 화이트보드 라이브러리에서도 tool을 상태로 관리해요. 각 툴이 자신만의 마우스 이벤트 처리 로직과 상태 전환 로직을 가지고 있죠.</p>
<p>똑같은 마우스 이벤트인데 툴마다 완전히 다른 처리가 필요해요. 이걸 하나의 함수에서 분기문으로 처리한다면? 툴이 추가될 때마다 지옥이 되겠죠.</p>
<p>상태패턴을 쓰면 각 툴이 자신의 동작을 캡슐화해서, 메인 로직은 단순히 &quot;현재 툴에게 위임&quot;만 하면 돼요.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/c86e89a5-2959-4352-9286-5f488526350b/image.png" alt=""></p>
<h3 id="xstate로-상태패턴-쉽게-만들기">XState로 상태패턴 쉽게 만들기</h3>
<p>XState는 이런 상태패턴을 쉽게 작성해주는 라이브러리예요. </p>
<p>특히 좋은 점은:</p>
<ul>
<li><strong>코드 작성 → 다이어그램 자동 생성</strong></li>
<li><strong>다이어그램 작성 → 코드 자동 생성</strong>  </li>
<li><strong>웹 에디터 서비스</strong> 제공으로 시각적으로 상태 설계 가능</li>
</ul>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/cc8edea0-1e83-416f-8e18-1a55d0b16ba9/image.png" alt=""></p>
<p>복잡한 상태 전환을 눈으로 보면서 설계할 수 있어서, 놓치기 쉬운 예외 상황들도 미리 발견할 수 있어요.</p>
<h3 id="웹소켓-연결-상태-관리하기">웹소켓 연결 상태 관리하기</h3>
<p>프론트엔드라면 한 번쯤 만나게 되는 웹소켓! 여기서도 상태패턴이 빛을 발해요.</p>
<p>&quot;메시지 보내기&quot; 버튼을 눌렀을 때:</p>
<ul>
<li><strong>연결 중</strong>: 메시지를 큐에 저장하고 연결 완료까지 대기</li>
<li><strong>연결 완료</strong>: 즉시 메시지 전송</li>
<li><strong>연결 끊김</strong>: 재연결 시도하고 메시지는 큐에 저장</li>
<li><strong>연결 실패</strong>: 사용자에게 실패 메시지 표시</li>
</ul>
<p>똑같은 &quot;메시지 보내기&quot; 동작인데 연결 상태에 따라 완전히 다른 처리가 필요하죠.</p>
<p>GOF의 디자인패턴 책에서도 상태패턴의 예시로 <strong>TCP 연결</strong>을 들었어요. 네트워크 연결 관리는 상태패턴의 대표적인 활용처거든요.</p>
<p>웹소켓에서는 이런 니즈들이 있어요:</p>
<ul>
<li><strong>중복 연결 방지</strong>: 이미 연결 중일 때 또 연결 시도하면 안 됨</li>
<li><strong>메시지 전송 버튼</strong>: 상태별로 다른 동작 (전송/대기/재시도)</li>
<li><strong>자동 재연결</strong>: 연결이 끊어지면 자동으로 재시도하되, 너무 자주 시도하면 안 됨</li>
</ul>
<p>이런 복잡한 로직들을 상태패턴으로 각 상태별로 분리하면 훨씬 관리하기 쉬워져요.</p>
<h2 id="결론-마무리하며">결론: 마무리하며</h2>
<p>지금까지 상태패턴에 대해 알아봤는데, 어떠셨나요? </p>
<p>여러분도 일상적으로 쓰던 토글부터 복잡한 퍼널까지, 상태패턴은 생각보다 많은 곳에서 활용할 수 있는 패턴이에요. 기획이 자주 바뀌는 프로젝트에서 특히 빛을 발하죠.</p>
<h3 id="핵심-정리">핵심 정리</h3>
<p><strong>상태패턴의 핵심</strong>은 &quot;상태를 단순한 값이 아니라, 동작까지 포함한 객체로 만드는 것&quot;이었어요. 그래서:</p>
<ul>
<li>분기문 지옥에서 벗어날 수 있고</li>
<li>새로운 상태 추가가 쉬워지고</li>
<li>각 상태의 로직이 캡슐화되어 유지보수가 편해져요</li>
</ul>
<h3 id="주의사항-상황에-따라-안티패턴이-될-수-있어요">주의사항: 상황에 따라 안티패턴이 될 수 있어요</h3>
<p>하지만 <strong>디자인패턴은 만능이 아닙니다.</strong> 복잡하지 않은 상황에서는 오히려 <strong>분기문으로 처리하는 게 좋아요</strong>. </p>
<p>예를 들어:</p>
<ul>
<li>상태가 2-3개뿐이고 앞으로 늘어날 가능성이 낮다면</li>
<li>각 상태의 로직이 매우 간단하다면</li>
<li>팀원들이 상태패턴에 익숙하지 않다면</li>
</ul>
<p>이런 경우에는 단순한 <code>if/else</code>나 <code>switch</code>문이 더 읽기 쉽고 유지보수하기 좋을 수 있어요.</p>
<p>상태패턴은 <strong>복잡성이 충분히 정당화될 때</strong>만 사용하는 게 좋습니다.</p>
<h3 id="다음은-어떤-패턴을-다뤄볼까요">다음은 어떤 패턴을 다뤄볼까요?</h3>
<p><strong>여러분이 만난 디자인패턴은 무엇인가요?</strong> </p>
<p>프로젝트에서 사용해본 패턴이나 &quot;이런 상황에서 어떤 패턴을 쓸까?&quot; 같은 고민이 있다면 댓글로 남겨주세요. 다음 글에서 소개해보겠습니다!</p>
<p>옵저버 패턴? 전략 패턴? 팩토리 패턴? 여러분의 관심사를 알려주세요.</p>
<p>읽어주셔서 감사합니다. 🙏</p>
<h2 id="이력서-멘토링-신청받습니다">이력서 멘토링 신청받습니다</h2>
<p>안녕하세요 최근 사이드로 멘토링을 받고 있습니다.</p>
<p>신청란: 신청란: <a href="https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=xstate">https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=xstate</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 성능최적화를 위한 코드 스플릿 알아보기]]></title>
            <link>https://velog.io/@k-svelte-master/codesplit-qwik-over-nextjs</link>
            <guid>https://velog.io/@k-svelte-master/codesplit-qwik-over-nextjs</guid>
            <pubDate>Tue, 10 Jun 2025 07:27:04 GMT</pubDate>
            <description><![CDATA[<p>처음 개발할 때는 괜찮았는데 조금만 지나도 점점 무거워지는 느낌 안 드나요?</p>
<p>어느 프로젝트든 처음 개발할 때는 빠릿빠릿합니다. 그러다가 서비스가 커지고 기능이 추가될수록 웹은 무거워지죠. 개발 환경에서는 여전히 빠른데, 실제 배포된 서비스를 모바일로 접속해보면 답답할 정도로 느립니다. 특히 3-4년 된 안드로이드 폰에서는 첫 화면이 뜨는 데만 5-6초가 걸리기도 하죠.</p>
<p>그래서 이런 성능 최적화하는 역량도 프론트엔드로서 중요합니다. 주니어 시절에는 기획에 맞게 기능 구현하는 것을 최우선으로 두었다면, 연차가 쌓여갈수록 사용자들에게 일관된 경험을 주기 위해 번들 사이즈를 줄이고 성능 병목을 찾아내는 역량이 필요하죠.</p>
<p><strong>그런데 왜 성능 최적화가 중요할까요?</strong></p>
<p>단순히 기능 구현을 넘어서 번들 사이즈, 로딩 시간, 인터랙션 지연까지 챙기는 것이 비즈니스적으로도, 사용자 경험으로도 중요하기 때문입니다. </p>
<p>실제 데이터를 보면 그 영향력이 상당합니다. 홈페이지 로딩 속도가 100ms 빨라질 때마다 전환율이 1% 증가하고, 페이지 로드 시간을 절반으로 줄이면 매출이 12% 향상됩니다. BBC는 사이트 로딩이 1초 늦어질 때마다 사용자의 10%를 잃는다고 발표했죠.</p>
<p>모든 사용자의 접근성을 고려해야 합니다. SEO에서도 속도는 중요한 랭킹 요소가 되어 서버사이드 렌더링을 도입하지만, HTML을 먼저 보여주는 것만으로는 부족합니다.</p>
<p><strong>Next.js를 쓴다면 이미 코드 스플릿을 하고 있습니다</strong></p>
<p>혹시 모르셨을 수도 있지만, Next.js는 자연스럽게 코드 스플릿을 지원합니다. 라우트별로 모든 스크립트 파일을 자동으로 분리해주거든요. <code>/about</code> 페이지에 접근할 때만 해당 페이지의 JavaScript가 로드되는 식으로요.</p>
<p>하지만 이것만으로는 충분하지 않습니다. 하나의 페이지 안에서도 수많은 컴포넌트와 라이브러리들이 한꺼번에 로드되면서 여전히 성능 병목이 발생할 수 있습니다. 더 세밀한 최적화가 필요한 이유죠.</p>
<p>이 글에서는 기본적인 SPA, SSR, 하이드레이션 개념부터 시작해서, Next.js에서 제공하는 실전 코드 스플릿 기법들을 알아보겠습니다. 그리고 마지막에는 기존 접근법의 한계를 넘어선 Qwik의 혁신적인 아이디어까지 살펴보며, 웹 프론트엔드 성능 최적화의 현재와 미래를 함께 탐구해보겠습니다.</p>
<h2 id="웹-개발의-진화---spa에서-ssr-그리고-하이드레이션까지">웹 개발의 진화 - SPA에서 SSR, 그리고 하이드레이션까지</h2>
<h3 id="spa의-등장-그리고-새로운-문제들">SPA의 등장, 그리고 새로운 문제들</h3>
<p>React가 등장하면서 웹 개발 패러다임이 크게 바뀌었습니다. 전통적인 멀티 페이지 애플리케이션(MPA)에서는 페이지를 이동할 때마다 전체 HTML을 다시 받아와야 했죠. 하지만 SPA(Single Page Application)는 거의 빈 HTML을 받습니다.</p>
<pre><code>┌─────────────────────┐     ┌─────────────────────┐
│        MPA          │     │        SPA          │
├─────────────────────┤     ├─────────────────────┤
│  완성된 HTML 전송   │     │  &lt;div id=&quot;root&quot;&gt;    │
│  &lt;h1&gt;Title&lt;/h1&gt;     │     │                     │
│  &lt;p&gt;Content&lt;/p&gt;     │     │  JS로 동적 렌더링   │
│  매번 전체 로드     │     │  한 번만 로드       │
└─────────────────────┘     └─────────────────────┘</code></pre><pre><code class="language-html">&lt;div id=&quot;root&quot;&gt;&lt;/div&gt;</code></pre>
<p>이것만 받아오고, JavaScript로 화면을 동적으로 다시 만드는 거죠.</p>
<p>사용자 경험은 확실히 좋아졌습니다. 페이지 전환이 매끄럽고, 마치 네이티브 앱을 사용하는 것 같은 느낌이죠. 하지만 새로운 문제들이 생겼습니다.</p>
<p>첫 번째는 <strong>초기 로딩 시간</strong>입니다. 모든 JavaScript 코드를 다운로드하고 실행해야 첫 화면을 볼 수 있으니까요. 두 번째는 <strong>SEO 문제</strong>입니다. 검색 엔진 봇이 JavaScript를 실행하기 전의 빈 HTML만 보게 되어 콘텐츠를 제대로 인덱싱하지 못했습니다.</p>
<h3 id="ssr의-등장과-해결책">SSR의 등장과 해결책</h3>
<p>이 문제들을 해결하기 위해 서버사이드 렌더링(SSR)이 등장했습니다. React에서는 <code>renderToString</code>이나 Next.js 같은 프레임워크를 통해 서버에서 미리 HTML을 생성해서 보내주는 방식이죠.</p>
<p>SSR을 사용하면 사용자는 훨씬 빠르게 첫 화면을 볼 수 있습니다. 검색 엔진도 완성된 HTML을 받아서 SEO 문제가 해결되고요. 하지만 여기서 또 다른 문제가 생깁니다.</p>
<h3 id="하이드레이션-그리고-새로운-성능-병목">하이드레이션, 그리고 새로운 성능 병목</h3>
<p>서버에서 받은 HTML은 정적입니다. 클릭해도 반응하지 않고, 상태도 변하지 않죠. 이를 인터랙티브하게 만들어주는 과정이 바로 <strong>하이드레이션(Hydration)</strong>입니다.</p>
<pre><code>서버 렌더링                    클라이언트 하이드레이션
    │                               │
    ▼                               ▼
┌────────────┐                ┌────────────┐
│ 정적 HTML  │  ─────────────▶ │ 정적 HTML  │
│            │                │     +      │
│ 이벤트 ❌   │                │ 이벤트 ✅   │
│ 상태 ❌     │                │ 상태 ✅     │
└────────────┘                └────────────┘</code></pre><p>서버사이드에서는 컴포넌트 트리를 다 렌더링한 후 <code>toString()</code>으로 HTML 문자열을 만들어서 보냅니다. 클라이언트에서는 React가 같은 컴포넌트 트리를 다시 객체로 만들고, 서버에서 받은 DOM에 이벤트 리스너를 부착하는 과정을 거칩니다.</p>
<pre><code class="language-jsx">// 서버에서는 정적 HTML 생성 (toString())
&lt;button&gt;클릭하세요&lt;/button&gt;

// 클라이언트에서 하이드레이션으로 이벤트 리스너 부착
&lt;button onClick={handleClick}&gt;클릭하세요&lt;/button&gt;</code></pre>
<h3 id="하이드레이션의-성능-문제">하이드레이션의 성능 문제</h3>
<p>여기서 문제가 생깁니다. 하이드레이션 과정에서 JavaScript가 메인 스레드를 점유하면서 UI가 블로킹되기도 하고, 이벤트가 아직 안 붙어있으니까 모든 이벤트가 다 붙고 나서야 클릭이 되는 거죠. 사용자는 화면은 보이지만 클릭할 때 반응이 없다가 시간이 지나야 버튼들이 동작하는 경험을 하게 됩니다.</p>
<pre><code>하이드레이션 시간
    ▲
    │
80ms├─────────────────────────╱╱
    │                    ╱╱╱
60ms├──────────────╱╱╱╱╱
    │         ╱╱╱╱╱
40ms├────╱╱╱╱╱
    │╱╱╱╱
20ms├
    │
    └────┬────┬────┬────┬────▶
         100  500  1K   5K   요소 개수</code></pre><p>저도 렌더링 라이브러리를 만들면서 이 문제를 직접 겪었습니다. SVG를 서버사이드 렌더링으로 조작함과 동시에 SVG에 대한 가상 DOM을 만드는 하이드레이션을 수행한 결과, 1프레임인 16ms에 5배나 넘는 시간이 마운트 동작에 소요되었습니다. 이것저것 최적화를 해봤지만 근본적인 해결은 어려웠죠.</p>
<p>하이드레이션 비용은 첫 화면의 요소 개수에 비례합니다. 아무리 코드를 최적화해도, 첫 화면에 요소가 많을수록 하이드레이션 비용이 커지는 것은 막을 수 없습니다. 뒤에 나오는 방법을 제외하고는 말이죠.</p>
<p>그렇다면 이 문제를 어떻게 해결할 수 있을까요? 여기서 코드 스플릿이 중요한 역할을 하게 됩니다.</p>
<h2 id="nextjs로-배우는-코드-스플릿-실전-가이드">Next.js로 배우는 코드 스플릿 실전 가이드</h2>
<h3 id="코드-스플릿이란">코드 스플릿이란?</h3>
<p>하이드레이션 문제를 해결하는 핵심 기법이 바로 <strong>코드 스플릿</strong>입니다. 모든 JavaScript를 한 번에 다운로드하고 실행하는 대신, 필요한 코드만 필요한 시점에 로드하는 방식이죠.</p>
<p>간단히 말하면 &quot;지금 당장 필요하지 않은 코드는 나중에 불러오자&quot;는 아이디어입니다. 사용자가 실제로 해당 기능을 사용할 때까지 기다리는 거죠.</p>
<h3 id="nextjs의-자동-라우트-코드-스플릿">Next.js의 자동 라우트 코드 스플릿</h3>
<p>Next.js를 쓴다면 이미 코드 스플릿을 하고 있습니다. Next.js는 기본적으로 <strong>라우트별로 코드를 자동 분리</strong>해주거든요.</p>
<pre><code>            main.js (전체 번들)
                  │
            코드 스플릿 적용
                  │
     ┌────────────┼────────────┐
     ▼            ▼            ▼
index.js      about.js    contact.js
 (30KB)       (25KB)       (20KB)</code></pre><pre><code>pages/
  index.js        → /_next/static/chunks/pages/index.js
  about.js        → /_next/static/chunks/pages/about.js
  contact.js      → /_next/static/chunks/pages/contact.js</code></pre><p><code>/about</code> 페이지에 접근할 때만 해당 페이지의 JavaScript가 로드됩니다. <code>/contact</code> 페이지를 방문하지 않는 사용자는 그 페이지의 코드를 다운로드할 필요가 없죠.</p>
<p>하지만 이것만으로는 충분하지 않습니다. 하나의 페이지 안에서도 수많은 컴포넌트와 라이브러리들이 한꺼번에 로드되면서 여전히 성능 병목이 발생할 수 있습니다.</p>
<h3 id="dynamic으로-컴포넌트-레벨-최적화">dynamic()으로 컴포넌트 레벨 최적화</h3>
<p>Next.js의 <code>dynamic()</code> 함수는 React의 <code>lazy()</code>와 <code>Suspense</code>를 합친 것이라고 보면 됩니다. 특정 컴포넌트를 필요할 때만 로드할 수 있게 해주죠.</p>
<pre><code class="language-jsx">import dynamic from &#39;next/dynamic&#39;

// 차트 라이브러리는 용량이 크니까 나중에 로드
const Chart = dynamic(() =&gt; import(&#39;../components/Chart&#39;))

// 모달은 사용자가 버튼을 클릭할 때만 필요
const Modal = dynamic(() =&gt; import(&#39;../components/Modal&#39;))

function Dashboard() {
  const [showModal, setShowModal] = useState(false)

  return (
    &lt;div&gt;
      &lt;h1&gt;대시보드&lt;/h1&gt;
      &lt;Chart data={chartData} /&gt;
      {showModal &amp;&amp; &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;}
    &lt;/div&gt;
  )
}</code></pre>
<p>여기서 중요한 점은 <strong>하이드레이션 순서</strong>입니다. 처음에는 <code>dynamic</code>으로 명시하지 않은 컴포넌트들만 하이드레이션하고, <code>dynamic</code>으로 감싼 컴포넌트들은 나중에 필요할 때 하이드레이션합니다. 하이드레이션을 지연하면, 다른 것들은 미리미리 인터랙션이 가능하게 되죠.</p>
<h3 id="ssr-제어로-더-세밀한-최적화">SSR 제어로 더 세밀한 최적화</h3>
<p>때로는 특정 컴포넌트를 아예 클라이언트에서만 렌더링하고 싶을 때가 있습니다. 브라우저 API를 사용하는 컴포넌트나, 서버에서 렌더링할 필요가 없는 경우죠.</p>
<pre><code class="language-jsx">const ClientOnlyComponent = dynamic(
  () =&gt; import(&#39;../components/InteractiveWidget&#39;),
  {
    ssr: false,
    loading: () =&gt; &lt;div&gt;위젯을 불러오는 중...&lt;/div&gt;
  }
)

// 차트 라이브러리는 캔버스니까 SSR에서 렌더링할 필요 없음
const ChartComponent = dynamic(
  () =&gt; import(&#39;../components/Chart&#39;),
  { ssr: false }
)</code></pre>
<p><code>ssr: false</code>로 설정하면 서버사이드 렌더링에서는 아예 HTML 태그마저 생략해서 클라이언트에서 다시 그려옵니다. 그러면 네트워크 병목도 줄고, 한 번에 더 적은 코드를 실행시키니까 더 빠르게 동작까지 이어질 수 있죠.</p>
<h3 id="서버-컴포넌트의-등장">서버 컴포넌트의 등장</h3>
<p>Next.js 13부터는 <strong>서버 컴포넌트</strong>라는 새로운 개념이 등장했습니다. 모든 컴포넌트는 기본적으로 서버에서만 실행되고, JavaScript 번들에 포함되지 않습니다. 더욱더 하이드레이션 과정을 줄여주는 거죠.</p>
<p>서버 컴포넌트는 서버에서 HTML 스니펫으로 렌더링되어 클라이언트에 전달되며, 하이드레이션 대상에서 제외됩니다. React 렌더 트리는 이 위치를 &quot;구멍(hole)&quot;으로 남겨두었다가 해당 HTML 스니펫을 삽입하는 형태로 페이지를 완성합니다.</p>
<pre><code>[Server: RSC Render] --&gt; [HTML snippet] 
                              | 
                              v 
        [React Render Tree]: &quot;구멍&quot; 위치 마련
                              | 
                              v 
            합친 결과 HTML --&gt; 클라이언트 전송 
            (서버컴포넌트는 하이드레이션 대상에서 제외)</code></pre><pre><code class="language-jsx">// app/page.tsx - 서버 컴포넌트 (기본값)
async function HomePage() {
  const data = await fetch(&#39;https://api.example.com/data&#39;)

  return (
    &lt;div&gt;
      &lt;h1&gt;홈페이지&lt;/h1&gt;
      &lt;UserProfile data={data} /&gt;
    &lt;/div&gt;
  )
}</code></pre>
<p>서버 컴포넌트는 대신 이벤트 핸들러나 <code>useState</code>를 쓸 수 없습니다. JS 파일에 포함 안 되도록 명시하는 거기 때문에, Next.js에서는 서버 컴포넌트에서 <code>useState</code>를 쓰면 하이드레이션이 필요한 컴포넌트라고 판단해서 <code>use client</code>를 붙이라고 하죠. 안 그러면 빌드 실패합니다.</p>
<h3 id="여전히-남는-근본적-한계">여전히 남는 근본적 한계</h3>
<p>지금까지 살펴본 최적화 기법들은 분명 효과적입니다. 하지만 <strong>근본적인 문제</strong>가 여전히 남아있습니다.</p>
<p>JavaScript 다운로드는 비동기이지만, 하이드레이션 과정에서 서버사이드에서 했던 렌더링을 그대로 반복하면 메인 스레드를 잡아먹습니다. 특히 모바일에서는 더더욱 느리죠. </p>
<p>하이드레이션을 우선하는 것과 우선하지 않는 것들을 개발자가 정하는 것도 귀찮고, 파일 단위라서 만들기도 힘들고, <strong>하나의 컴포넌트 안에서는 모든 JavaScript가 함께 번들링</strong>됩니다. 하이드레이션을 나눠도 요소가 많아지고 복잡해질수록 여전히 부담이 됩니다.</p>
<p>그렇다면 이 한계를 어떻게 극복할 수 있을까요? 여기서 완전히 다른 접근법이 등장합니다.</p>
<h2 id="zero-hydration-재개가능성resumability---qwik의-혁신적-접근">Zero Hydration, 재개가능성(Resumability) - Qwik의 혁신적 접근</h2>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/c33071f1-522c-4779-bd92-2c752aca7000/image.png" alt=""></p>
<h3 id="기존-접근법의-답답한-현실">기존 접근법의 답답한 현실</h3>
<p>Next.js에서 <code>useState</code> 하나 썼다고 전체 컴포넌트가 하이드레이션 대상이 된다거나, <code>dynamic</code>으로 import하려면 해당 컴포넌트를 따로 파일로 빼야 하는 불편함이 있습니다.</p>
<p>서버 컴포넌트로 잘게 쪼개는 것도 어렵죠. 만들다 보면 <code>useState</code>나 이벤트 핸들러 하나씩은 들어간 컴포넌트가 나오거든요. 그렇다고 모든 컴포넌트를 <code>use client</code>로 명시해버리면... 최적화의 의미가 없어집니다.</p>
<p>결국 개발자가 수동으로 &quot;이건 서버에서, 이건 클라이언트에서&quot; 하나하나 결정해야 하는 상황입니다.</p>
<h3 id="대부분의-컴포넌트는-인터랙션이-있지만-동시에-필요-없다">대부분의 컴포넌트는 인터랙션이 있지만, 동시에 필요 없다</h3>
<p>흥미로운 점이 있습니다. <strong>대부분의 컴포넌트는 인터랙션이 들어가지만, 동시에 대부분의 컴포넌트는 인터랙션이 필요 없습니다.</strong></p>
<p>모든 페이지들의 요소요소들은 다 상호작용할 부분들이 많으면서도, 동시에 유저들은 모든 요소들을 한 페이지에서 눌러보지 않기 때문이죠. 사용자는 보통 페이지의 일부분만 실제로 상호작용합니다.</p>
<p>그렇다면 <strong>필요할 때만 그 부분의 JavaScript를 로드</strong>하면 되지 않을까요?</p>
<h3 id="qwik의-혁신-no-hydration과-자동-코드-스플릿">Qwik의 혁신: No Hydration과 자동 코드 스플릿</h3>
<p>Qwik은 완전히 다른 접근을 합니다. <strong>하이드레이션을 아예 하지 않습니다.</strong></p>
<p>기존 프레임워크들은 서버에서 했던 렌더링을 클라이언트에서 한 번 더 반복하면서 하이드레이션을 수행합니다. 하지만 Qwik은 <strong>서버에서 실행을 일시정지하고, 클라이언트에서 실행을 재개</strong>합니다.</p>
<p>핵심은 <strong>HTML에 모든 정보가 이미 시리얼라이즈되어 있다</strong>는 점입니다.</p>
<pre><code class="language-html">&lt;button on:click=&quot;./chunk-c.js#Counter_onClick[0,1]&quot;&gt;클릭하세요&lt;/button&gt;</code></pre>
<p>이 버튼을 보세요. <code>on:click</code> 속성에 어떤 파일의 어떤 함수를 실행해야 하는지, 심지어 어떤 변수들을 복원해야 하는지(<code>[0,1]</code>) 모든 정보가 들어있습니다.</p>
<h3 id="qwikloader-1kb의-마법">Qwikloader: 1KB의 마법</h3>
<p>Qwik이 클라이언트에 보내는 JavaScript는 <strong>Qwikloader라는 1KB짜리 스크립트 하나</strong>뿐입니다.</p>
<pre><code class="language-html">&lt;html&gt;
  &lt;body q:base=&quot;/build/&quot;&gt;
    &lt;button on:click=&quot;./myHandler.js#clickHandler&quot;&gt;push me&lt;/button&gt;
    &lt;script&gt;
      /* Qwikloader */
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>Qwikloader의 역할은 단순합니다:</p>
<ol>
<li><strong>전역 이벤트 리스너 하나만 등록</strong></li>
<li>사용자가 클릭하면 해당 요소에서 <code>on:click</code> 속성 찾기</li>
<li>속성값을 파싱해서 필요한 청크 파일 다운로드</li>
<li>해당 함수 실행</li>
</ol>
<h3 id="실제-동작-원리-개발자-코드에서-최적화까지">실제 동작 원리: 개발자 코드에서 최적화까지</h3>
<p>개발자가 이렇게 코드를 작성한다면:</p>
<pre><code class="language-jsx">export const Counter = component$((props: { step: number }) =&gt; {
  const count = useSignal(0);

  return &lt;button onClick$={() =&gt; (count.value += props.step || 1)}&gt;{count.value}&lt;/button&gt;;
});</code></pre>
<p>Qwik의 Optimizer가 이를 자동으로 여러 청크로 분리합니다:</p>
<pre><code>개발자 코드 (하나의 컴포넌트)
         │
         ▼
   Qwik Optimizer
         │
    ┌────┼────┐
    ▼    ▼    ▼
chunk-a chunk-b chunk-c
(mount) (render) (click)</code></pre><pre><code class="language-jsx">// chunk-a.js - 컴포넌트 마운트
export const Counter_onMount = (props) =&gt; {
  const count = useSignal(0);
  return qrl(&#39;./chunk-b.js&#39;, &#39;Counter_onRender&#39;, [count, props]);
};

// chunk-b.js - 렌더링
const Counter_onRender = () =&gt; {
  const [count, props] = useLexicalScope();
  return (
    &lt;button onClick$={qrl(&#39;./chunk-c.js&#39;, &#39;Counter_onClick&#39;, [count, props])}&gt;{count.value}&lt;/button&gt;
  );
};

// chunk-c.js - 클릭 핸들러
const Counter_onClick = () =&gt; {
  const [count, props] = useLexicalScope();
  return (count.value += props.step || 1);
};</code></pre>
<p>결과적으로 생성되는 HTML:</p>
<pre><code class="language-html">&lt;button q:obj=&quot;456, 123&quot; on:click=&quot;./chunk-c.js#Counter_onClick[0,1]&quot;&gt;0&lt;/button&gt;</code></pre>
<p>사용자가 버튼을 클릭하는 순간:</p>
<pre><code>   사용자 클릭
       │
       ▼
 Qwikloader (1KB)
       │
       ▼
on:click 속성 파싱
&quot;./chunk-c.js#Counter_onClick[0,1]&quot;
       │
       ▼
chunk-c.js 다운로드
       │
       ▼
함수 실행 + 상태 복원
  count, props</code></pre><ol>
<li>Qwikloader가 클릭 이벤트를 감지</li>
<li><code>on:click=&quot;./chunk-c.js#Counter_onClick[0,1]&quot;</code> 파싱</li>
<li><code>chunk-c.js</code> 파일을 동적으로 로드</li>
<li><code>Counter_onClick</code> 함수 실행</li>
<li><code>[0,1]</code>로 필요한 변수들(<code>count</code>, <code>props</code>) 복원</li>
</ol>
<h3 id="혁신의-핵심-컴포넌트-내에서도-핸들러별-청킹">혁신의 핵심: 컴포넌트 내에서도 핸들러별 청킹</h3>
<p>기존 접근법의 한계였던 &quot;<strong>하나의 컴포넌트 안에서는 모든 JavaScript가 함께 번들링</strong>&quot;되는 문제를 Qwik은 근본적으로 해결합니다.</p>
<p><strong>컴포넌트 내에서 이벤트 핸들러마저도</strong> 알아서 Optimizer가 다른 번들로 분리합니다. 개발자는 신경 쓸 필요 없이 평범하게 코드를 작성하면, 프레임워크가 알아서 최적의 코드 스플릿을 만들어줍니다.</p>
<p>사용자가 실제로 상호작용하는 그 순간에만 해당 JavaScript가 로드되니, 메인 스레드 블로킹도 없고 인터랙션 지연도 없습니다. </p>
<p>이것이 바로 <strong>Zero Hydration</strong>이자 <strong>재개가능성(Resumability)</strong>의 힘입니다. 서버에서 일시정지된 실행이 클라이언트에서 필요한 순간에 정확히 재개되는 것이죠.</p>
<h2 id="앞으로-개발자들은-더욱-게을러져도-됩니다">앞으로 개발자들은 더욱 게을러져도 됩니다</h2>
<p>Qwik을 보면서 정말 감탄했습니다. 하이드레이션이라는 근본적인 문제를 아예 다른 관점에서 해결해버린 접근법이 인상적이었어요. </p>
<pre><code>┌─────────────────┐    ┌─────────────────┐
│  기존 방식      │    │  Qwik 방식     │
├─────────────────┤    ├─────────────────┤
│ 전체 JS: 500KB  │    │ Qwikloader: 1KB │
│ 하이드레이션 ✓  │    │ 하이드레이션 ✗  │
│ 초기 로딩 느림  │    │ 초기 로딩 빠름  │
└─────────────────┘    └─────────────────┘</code></pre><p>기존 프레임워크들이 &quot;어떻게 하면 하이드레이션을 더 효율적으로 할까?&quot;를 고민했다면, Qwik은 &quot;하이드레이션을 왜 해야 하지?&quot;라는 질문부터 시작했습니다. 이런 근본적인 사고의 전환이 혁신을 만들어내는 것 같습니다.</p>
<h3 id="프레임워크가-담당하는-최적화">프레임워크가 담당하는 최적화</h3>
<p>Svelte가 &quot;리렌더링을 왜 개발자가 신경 써야 해?&quot;라고 하면서 등장했듯이, Qwik은 &quot;코드 스플릿을 왜 개발자가 생각해야 해?&quot;라고 묻고 있습니다.</p>
<p>어떻게 하면 서버 컴포넌트로 잘게 더 쪼개볼 수 있을지, &quot;use client&quot;를 어떻게 하면 덜 쓸 수 있을까, 어떻게 파일을 분리해야 할지, 어떤 컴포넌트를 <code>dynamic</code>으로 감쌀지 같은 복잡한 결정들을 개발자가 매번 고민해야 하는 것은 피곤한 일이죠.</p>
<p><strong>프로젝트가 아무리 커져도 O(1)의 속도</strong>를 유지할 수 있도록 프레임워크가 담당하는 거죠. 개발자가 컴포넌트를 100개 만들든 1000개 만들든, 사용자가 실제로 상호작용하는 부분만 로드되니까 초기 성능은 일정하게 유지됩니다.</p>
<p>성능 최적화를 프레임워크가 담당하니까 <strong>개발자의 인지 부하와 초기 지식 습득 난이도가 줄어듭니다</strong>. 복잡한 최적화 지식들을 프레임워크에게 위임하면서 좀 더 제품에 집중할 수 있는 거겠죠.</p>
<h3 id="저도-배우고-있습니다">저도 배우고 있습니다</h3>
<p>사실 커뮤니티에서 어느 한 분이 소개해줘서 Qwik이라는 것을 알게 되었습니다. 이런 글을 쓰면서도 저 역시 계속 배우고 있는 중이에요.</p>
<p>Qwik도 다른 프레임워크와 마찬가지로 리렌더링을 신경 쓰지 않도록 설계되었더라고요. 성능 최적화라는 게 결국 개발자의 인지 부하를 줄이면서도 사용자 경험을 개선하는 방향으로 발전하고 있다는 걸 느낍니다.</p>
<p>SPA에서 시작해서 SSR, 하이드레이션, 코드 스플릿, 서버 컴포넌트, 그리고 이제는 재개가능성(resumability)까지. 각 단계마다 &quot;개발자가 신경 써야 할 것들&quot;을 하나씩 프레임워크가 가져가는 과정이었다고 생각합니다.</p>
<p>프레임워크들이 점점 더 똑똑해져서, 우리는 비즈니스 로직과 사용자 가치 창출에만 집중할 수 있게 되는 것 같아요. 앞으로도 이런 흐름은 계속될 것 같고, 저도 그런 변화를 따라가면서 더 나은 사용자 경험을 만드는 개발자가 되려고 노력하고 있습니다.</p>
<h2 id="이력서멘토링-신청받습니다">이력서멘토링 신청받습니다</h2>
<p>신청란: <a href="https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=codesplit-qwik-over-nextjs">https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=codesplit-qwik-over-nextjs</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[가상스크롤의 원리 알고 계신가요?]]></title>
            <link>https://velog.io/@k-svelte-master/virtual-scroll-principle</link>
            <guid>https://velog.io/@k-svelte-master/virtual-scroll-principle</guid>
            <pubDate>Mon, 09 Jun 2025 07:07:42 GMT</pubDate>
            <description><![CDATA[<p>프론트엔드 개발을 하다 보면 언젠가는 마주치게 되는 과제가 있습니다. 무한스크롤로 계속 아이템을 누적해서 보여주다가 어느 순간 페이지가 버벅거리기 시작하거나, 대시보드에서 테이블로 많은 데이터를 스크롤로 표시해야 할 때 말이죠.</p>
<p>Instagram이나 X 같은 앱에서 피드를 아래로 계속 내리다 보면 수백 개의 게시물이 쌓이는데도 매끄럽게 동작하는 걸 본 적 있을 겁니다. 혹은 관리자 대시보드에서 수천 줄의 로그를 테이블로 보여주면서도 스크롤이 자연스럽게 동작하는 경험도 있을 거고요. 이런 상황에서 브라우저가 느려지지 않는 비밀이 바로 가상스크롤입니다.</p>
<p>React Virtualized, React Window, @tanstack/react-virtual, React Virtuoso 같은 라이브러리를 사용해보신 분들은 이미 가상스크롤의 효과를 경험해봤을 텐데요. 하지만 내부에서 정확히 어떤 일이 일어나는지 알고 계신가요?</p>
<p>문제는 커스터마이징이 필요하거나 직접 구현해야 할 때입니다. 기존 라이브러리로는 해결되지 않는 특별한 요구사항이 생겼을 때, 혹은 라이브러리 설정을 어떻게 조정해야 할지 판단이 서지 않을 때 막막해지죠. 현업에서 개발하다 보면 이런 상황을 피할 수 없습니다.</p>
<p>게다가 2024년에는 CSS만으로도 기본적인 가상스크롤 효과를 낼 수 있는 새로운 방법이 등장했습니다. 이 글에서는 가상스크롤의 동작 원리부터 최신 CSS 기법까지, 실무에서 바로 활용할 수 있는 내용들을 다뤄보겠습니다.</p>
<h2 id="언제-가상스크롤이-필요할까">언제 가상스크롤이 필요할까?</h2>
<p>가상스크롤이 필요한 상황들을 구체적으로 살펴보면, 생각보다 우리가 자주 마주치는 경우들입니다.</p>
<p><strong>대용량 데이터를 한번에 보여줘야 하는 경우</strong></p>
<p>관리자 페이지에서 사용자 목록이나 주문 내역을 테이블로 보여줄 때를 생각해보세요. 처음에는 페이지네이션으로 나눠서 보여주려고 했지만, 기획자나 사용자가 &quot;한 화면에서 다 보고 싶다&quot;고 요청하는 경우가 있습니다. Excel처럼 스크롤로 쭉 내려가면서 데이터를 확인하고 싶어하죠. 이때 수백 개의 행을 그냥 렌더링하면 브라우저가 버벅거리기 시작합니다.</p>
<p><strong>무한 스크롤에서 계속 쌓이는 검색 결과</strong></p>
<p>쇼핑몰이나 검색 사이트에서 무한스크롤을 구현할 때도 마찬가지입니다. 처음 20개 상품을 보여주는 건 문제없지만, 사용자가 계속 스크롤을 내려서 200개, 500개가 쌓이면 상황이 달라집니다. 특히 각 상품 카드에 이미지가 여러 개 있거나 복잡한 UI가 포함되어 있다면 메모리 사용량이 급격히 증가하죠.</p>
<p><strong>로그 화면처럼 엄청난 스크롤이 발생하는 상황</strong></p>
<p>개발자 도구나 서버 모니터링 대시보드에서 로그를 실시간으로 보여주는 화면을 만들어본 적 있나요? 로그는 특성상 계속해서 새로운 줄이 추가되고, 사용자는 이전 로그를 확인하기 위해 위로 스크롤을 올리기도 합니다. 수천 줄의 로그가 DOM에 쌓이면 브라우저는 감당하기 어려워집니다.</p>
<p><strong>실시간 업데이트되는 피드</strong></p>
<p>채팅 애플리케이션이나 실시간 알림 피드도 비슷한 문제를 겪습니다. 새로운 메시지가 계속 추가되면서 기존 메시지들은 위로 밀려나지만, 모든 메시지를 DOM에 유지하면 메모리 사용량이 계속 늘어납니다. 특히 이미지나 파일이 포함된 메시지들이 많을 때는 더욱 심각해지죠.</p>
<p><strong>모바일 환경에서의 성능 한계</strong></p>
<p>데스크톱에서는 괜찮았던 성능이 모바일에서는 문제가 되는 경우도 많습니다. 모바일 브라우저는 메모리와 CPU 성능이 제한적이기 때문에, 같은 양의 데이터라도 훨씬 더 느리게 동작할 수 있습니다. 특히 저사양 안드로이드 기기에서는 몇백 개의 아이템만 렌더링해도 스크롤이 버벅거릴 수 있어요.</p>
<p>이런 상황들에서 가상스크롤은 단순히 &#39;성능 최적화&#39;를 넘어서 &#39;서비스의 사용 가능성&#39;을 결정하는 핵심 기술이 됩니다.</p>
<h2 id="가상스크롤은-어떻게-동작할까">가상스크롤은 어떻게 동작할까?</h2>
<p>가상스크롤의 핵심 아이디어는 간단합니다. &quot;화면에 보이는 것만 렌더링하자&quot;는 것이죠. 하지만 이를 실제로 구현하려면 몇 가지 까다로운 문제들을 해결해야 합니다.</p>
<p><strong>스크롤바 UX의 핵심</strong></p>
<p>가장 먼저 해결해야 할 문제는 스크롤바입니다. 사용자가 스크롤바를 보고 &quot;전체 길이&quot;를 직감적으로 느낄 수 있어야 하죠. 1000개 아이템이 있다면 스크롤바도 그만큼 작아져야 하고, 스크롤 위치도 정확해야 합니다.</p>
<pre><code>스크롤바 크기 = (화면에 보이는 아이템 수 / 전체 아이템 수) × 100%
스크롤 위치 = (현재 첫 번째 아이템 인덱스 / 전체 아이템 수) × 100%</code></pre><p>이 문제를 해결하는 방법은 전체 컨테이너의 높이를 미리 계산해두는 것입니다:</p>
<pre><code>전체 높이 = 아이템 개수 × 각 아이템의 예상 높이</code></pre><p>그리고 실제 아이템들은 렌더링하지 않고, 대신 <code>padding-top</code>과 <code>padding-bottom</code>으로 스크롤 공간을 확보해두는 거죠. 이렇게 하면 네이티브 스크롤을 그대로 활용하면서도 자연스러운 스크롤 경험을 제공할 수 있습니다.</p>
<p><strong>보이는 영역과 안 보이는 영역</strong></p>
<p>가상스크롤에서는 전체 데이터를 세 영역으로 나눠서 생각합니다:</p>
<pre><code>[ 위쪽 안보이는 영역 ] ← padding-top으로 공간 확보
[    현재 보이는 영역    ] ← 실제 DOM에 렌더링
[ 아래쪽 안보이는 영역 ] ← padding-bottom으로 공간 확보</code></pre><p>전체 1000개 아이템이 있고, 화면에는 5개씩 보인다고 가정해봅시다:</p>
<pre><code>전체 데이터: [0, 1, 2, 3, ..., 999]

현재 스크롤 위치에서 보이는 부분만 슬라이싱:
items.slice(245, 250) = [245, 246, 247, 248, 249]

실제 렌더링:
padding-top: 245 × itemHeight ← 위쪽 244개 아이템의 공간
┌─────────────────────┐
│ 아이템 245          │  ← 실제 DOM 요소 (5개만)
│ 아이템 246          │
│ 아이템 247          │
│ 아이템 248          │
│ 아이템 249          │
└─────────────────────┘
padding-bottom: 750 × itemHeight ← 아래쪽 750개 아이템의 공간</code></pre><p><strong>레이아웃 변화 없는 교체</strong></p>
<p>사용자가 스크롤을 내려서 아이템 245가 화면 위로 완전히 사라진 순간을 봅시다:</p>
<pre><code>스크롤 진행 중 (아이템 245가 화면 위로 넘어감):
     [아이템 245] ↑ (화면 밖으로)
┌─────────────────────┐ ← 화면 상단
│ 아이템 246          │
│ 아이템 247          │
│ 아이템 248          │
│ 아이템 249          │
└─────────────────────┘ ← 화면 하단</code></pre><p>이 순간 데이터 슬라이싱과 패딩을 조정합니다:</p>
<pre><code>교체 전:
items.slice(245, 250) = [245, 246, 247, 248, 249]
- 아이템 246이 배열의 1번째 인덱스 (두 번째 요소)

교체 후:
items.slice(246, 250) = [246, 247, 248, 249]  ← 245 제거
- 아이템 246이 배열의 0번째 인덱스 (첫 번째 요소)로 이동</code></pre><p>실제 화면에서는 이렇게 보입니다:</p>
<pre><code>교체 후 (레이아웃상 변화 없음):
┌─────────────────────┐ ← 화면 상단
│ 아이템 246          │ ← 정확히 같은 위치 (인덱스 1→0으로 변경)
│ 아이템 247          │ ← 정확히 같은 위치 (인덱스 2→1로 변경)
│ 아이템 248          │ ← 정확히 같은 위치 (인덱스 3→2로 변경)
│ 아이템 249          │ ← 정확히 같은 위치 (인덱스 4→3로 변경)
└─────────────────────┘ ← 화면 하단

padding-top: 246 × itemHeight (1줄만큼 증가)
padding-bottom: 751 × itemHeight (1줄만큼 증가)</code></pre><p>핵심은 렌더링되는 아이템들의 배열 인덱스는 모두 바뀌었지만, 화면에서는 레이아웃상 차이가 전혀 없다는 것입니다. 패딩 조정으로 정확히 같은 위치에 같은 내용이 표시되어 사용자는 자연스럽게 스크롤한 것처럼 느끼죠.</p>
<p><strong>버퍼 영역으로 자연스러운 스크롤</strong></p>
<p>스크롤이 빠르게 진행될 때를 대비해서 보이는 영역보다 조금 더 많이 렌더링해두는 것이 좋습니다:</p>
<pre><code>실제 구현에서는 버퍼를 추가:
[ 위쪽 버퍼 (2개) ]     ← 미리 렌더링
[ 화면에 보이는 영역 (5개) ]
[ 아래쪽 버퍼 (2개) ]   ← 미리 렌더링

총 9개 DOM 요소로 빠른 스크롤에도 자연스럽게 대응</code></pre><p>예를 들어 실제로는 243번부터 252번까지 렌더링해두지만, 화면에는 245-249만 보이게 하는 방식입니다. 이렇게 하면 스크롤이 빨라져도 깜빡임 없이 부드러운 경험을 제공할 수 있습니다.</p>
<p>이렇게 구현하면 1000개든 10000개든 상관없이 항상 일정한 DOM 요소 개수를 유지하면서도 자연스러운 스크롤 경험을 제공할 수 있습니다. 사용자는 마치 모든 요소가 다 렌더링되어 있는 것처럼 느끼지만, 실제로는 필요한 부분만 그려지고 있는 거죠.</p>
<h2 id="css-content-visibility로-더-쉬운-해결책">CSS content-visibility로 더 쉬운 해결책</h2>
<p>지금까지 가상스크롤의 복잡한 구현 원리를 살펴봤습니다. 데이터 슬라이싱, 패딩 조정, 인덱스 관리까지... 꽤 많은 로직이 필요하죠. 하지만 2024년부터는 훨씬 간단한 방법이 등장했습니다.</p>
<p><strong>브라우저 렌더링의 원리</strong></p>
<p>먼저 브라우저 렌더링 과정을 간단히 살펴봅시다. 우리가 HTML 요소를 만들면 브라우저는 다음 과정을 거칩니다:</p>
<pre><code>HTML 파싱 → 렌더트리 생성 → 레이아웃 계산 → 페인팅 → 컴포지팅</code></pre><p>여기서 중요한 건 <strong>화면 밖에 있는 요소들도 레이아웃 계산에 포함된다</strong>는 점입니다. 1000개 리스트 아이템이 있다면 화면에 5개만 보여도 나머지 995개의 위치와 크기까지 모두 계산하고 있었던 거죠.</p>
<p><strong>접근 방식의 차이</strong></p>
<p>기존 가상스크롤과 CSS <code>content-visibility</code>의 가장 큰 차이는 관리 주체입니다.</p>
<ul>
<li><strong>기존 가상스크롤</strong>: 컨테이너에서 &quot;어떤 아이템들을 보여줄지&quot; 중앙 관리</li>
<li><strong>CSS content-visibility</strong>: 각 아이템이 &quot;내가 보이는지&quot; 개별 판단</li>
</ul>
<p>이제 컨테이너가 복잡한 계산을 할 필요 없이, 각 아이템이 알아서 자신의 렌더링 여부를 결정하는 거죠.</p>
<p><strong>content-visibility가 무엇인가?</strong></p>
<p>CSS <code>content-visibility</code>는 요소의 콘텐츠를 언제 렌더링할지 브라우저에게 알려주는 속성입니다. <code>display: none</code>과 비슷하게 요소를 숨길 수 있지만, 중요한 차이점이 있습니다.</p>
<p><code>content-visibility</code>의 세 가지 값:</p>
<ul>
<li><code>visible</code>: 기본값, 평소와 같이 렌더링</li>
<li><code>hidden</code>: 콘텐츠를 완전히 숨김 (display: none과 유사)</li>
<li><code>auto</code>: 화면에 보이지 않으면 렌더링을 최대한 지연</li>
</ul>
<p>우리가 관심 있는 건 <code>auto</code> 값입니다. 이 값을 사용하면 요소가 뷰포트에 가까워질 때까지 레이아웃과 페인팅 작업을 건너뛸 수 있습니다.</p>
<p><strong>딱 하나만 추가하면 되는 간편함</strong></p>
<pre><code class="language-css">.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 200px;
}</code></pre>
<p>이게 전부입니다. 복잡한 JavaScript 로직도, 데이터 슬라이싱도, 패딩 계산도 필요 없어요.</p>
<p><strong>contain-intrinsic-size와 함께 사용하는 이유</strong></p>
<p><code>content-visibility: auto</code>만 사용하면 한 가지 문제가 생깁니다. 렌더링하지 않는 요소들의 크기를 브라우저가 알 수 없어서 스크롤바가 이상하게 동작할 수 있거든요.</p>
<p><code>contain-intrinsic-size</code>는 이 문제를 해결합니다:</p>
<pre><code class="language-css">contain-intrinsic-size: auto 200px;</code></pre>
<p>이 속성은 &quot;렌더링하지 않는 요소의 예상 크기는 200px이고, 한 번이라도 렌더링된 적이 있으면 그때의 실제 크기를 기억해서 사용해&quot;라고 브라우저에게 알려줍니다. 이렇게 하면 스크롤바도 자연스럽게 동작하고, 레이아웃 시프트도 방지할 수 있죠.</p>
<p><strong>라이브러리 vs CSS 해결책</strong></p>
<p>물론 React Virtualized 같은 라이브러리가 여전히 더 강력합니다. 정교한 버퍼링, 동적 높이 처리, 복잡한 스크롤 동작까지 세밀하게 제어할 수 있거든요. 하지만 <code>content-visibility</code>는 다른 장점들이 있습니다:</p>
<ul>
<li><strong>구현 복잡도</strong>: CSS 두 줄이면 끝</li>
<li><strong>번들 크기</strong>: 추가 라이브러리 불필요  </li>
<li><strong>유지보수</strong>: 브라우저가 알아서 최적화</li>
<li><strong>접근성</strong>: DOM 구조가 그대로 유지되어 스크린 리더 등에서 자연스럽게 동작</li>
</ul>
<p><strong>라이브러리 도입이 어려운 상황의 대안</strong></p>
<p>팀에서 새로운 라이브러리 도입을 꺼리거나, 기존 코드베이스를 크게 변경하기 어려운 상황이라면 <code>content-visibility</code>부터 시작해보는 것도 좋은 선택입니다. 당장 성능 개선 효과를 볼 수 있고, 나중에 필요하다면 본격적인 가상스크롤 라이브러리로 교체할 수도 있거든요.</p>
<pre><code class="language-html">&lt;!-- 기존 코드에 CSS만 추가하면 됨 --&gt;
&lt;div class=&quot;item-list&quot;&gt;
  &lt;div class=&quot;list-item&quot;&gt;아이템 1&lt;/div&gt;
  &lt;div class=&quot;list-item&quot;&gt;아이템 2&lt;/div&gt;
  &lt;!-- ... 수백 개 아이템 --&gt;
&lt;/div&gt;</code></pre>
<pre><code class="language-css">.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 100px;
}</code></pre>
<p>이렇게 하면 기존 HTML 구조는 전혀 건드리지 않고도 성능 개선을 경험할 수 있습니다. CSS 한 줄로 가상스크롤의 기본적인 효과를 낼 수 있는 셈이죠.</p>
<h2 id="결론">결론</h2>
<p>가상스크롤의 핵심은 결국 <strong>&quot;화면에 안 보이는 것은 그리지 않는다&quot;</strong>는 단순하면서도 강력한 아이디어입니다.</p>
<p>이 개념은 웹 개발에만 국한되지 않습니다. 게임에서는 플레이어의 시야 범위 밖에 있는 오브젝트들을 렌더링하지 않고, DB 다이어그램 툴에서는 현재 화면에 보이는 다이어그램만 선택적으로 그립니다. 볼 필요가 없으면 렌더링 안 하는 게 당연하고, 그게 성능 최적화의 기본이죠.</p>
<p><strong>상황에 맞는 선택</strong></p>
<p>현업에서는 상황에 따라 적절한 방법을 선택하면 됩니다:</p>
<p><strong>JavaScript 가상스크롤 라이브러리</strong>: 복잡한 요구사항이나 최대 성능이 필요할 때<br><strong>CSS content-visibility</strong>: 간단한 성능 개선이나 기존 코드 변경을 최소화하고 싶을 때</p>
<p>두 방식 모두 &quot;필요한 것만 렌더링한다&quot;는 같은 철학을 공유하지만, 접근 방법이 다릅니다. 컨테이너가 중앙에서 관리하느냐, 개별 요소가 스스로 판단하느냐의 차이죠.</p>
<p>가상스크롤의 원리를 이해하는 것은 단순히 성능 최적화 기법 하나를 배우는 게 아닙니다. 브라우저 렌더링 과정, DOM 조작의 비용, 데이터와 뷰의 분리 같은 프론트엔드 개발의 핵심 개념들을 자연스럽게 익힐 수 있습니다. 무엇보다 라이브러리를 단순히 가져다 쓰는 것과, 내부 동작을 이해하고 필요에 따라 커스터마이징할 수 있는 것은 완전히 다른 수준의 개발 역량이니까요.</p>
<h2 id="이력서-멘토링-신청받습니다">이력서 멘토링 신청받습니다</h2>
<p>안녕하세요 최근 사이드로 멘토링을 받고 있습니다.많관부</p>
<p>신청란: <a href="https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=resume_tool">https://fe-resume.coach?utm_source=velog&amp;utm_medium=blog&amp;utm_campaign=resume_tool</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[벨로그 트렌드 1위 5번 경력자가 이력서용 글쓰기 팁 알려드립니다]]></title>
            <link>https://velog.io/@k-svelte-master/resume-posting-tips</link>
            <guid>https://velog.io/@k-svelte-master/resume-posting-tips</guid>
            <pubDate>Mon, 12 May 2025 13:22:04 GMT</pubDate>
            <description><![CDATA[<p>개발자로서 이력서에 기재할 수 있는 경력이나 성과가 부족하다고 걱정하시나요? 실무 경험이 적더라도 자신의 역량을 효과적으로 보여줄 수 있는 방법이 있습니다. 핵심은 단순히 무언가를 &#39;만들었다&#39;는 사실이 아니라, 그것이 실제로 &#39;가치 있음&#39;을 증명하는 것입니다.</p>
<p>그리고 포스팅만큼 효과적인 방법은 없죠 :)</p>
<h2 id="차별화된-포스팅이란">차별화된 포스팅이란?</h2>
<p>블로그는 단순한 학습 일지가 아닌, 자신의 전문성을 드러내는 플랫폼입니다. &quot;React 배웠음&quot; 또는 &quot;Redux 학습함&quot;과 같은 단순 기록은 누구나 할 수 있습니다. 차별화된 블로그 글을 작성하려면:</p>
<ul>
<li><p><strong>공감을 불러일으키는 문제 제시</strong>: 많은 개발자들이 겪는 문제를 설명하고, 여러분이 어떻게 해결했는지 보여주세요. 지엽적인 문제보다는 보편적인 고민을 다루는 것이 더 많은 공감을 얻습니다.</p>
</li>
<li><p><strong>배경과 맥락 제공</strong>: 문제 해결 과정만 나열하지 말고, 왜 그 문제가 중요한지, 어떤 시행착오를 겪었는지 스토리텔링으로 풀어내세요. 독자들은 단순 코드 스니펫보다 여정에 더 관심을 갖습니다.</p>
</li>
<li><p><strong>후킹 요소 활용</strong>: 제목과 도입부에서 독자의 관심을 사로잡으세요. &quot;React에서 렌더링 성능을 10배 개선한 방법&quot; 같은 제목은 &quot;React 최적화 방법&quot;보다 클릭을 유도합니다.</p>
</li>
<li><p><strong>지표 수집 플랫폼 활용</strong>: Velog, Medium과 같이 조회수, 좋아요, 댓글 수를 측정할 수 있는 플랫폼을 활용하세요. 높은 호응을 받은 글은 채용 담당자에게도 인상적으로 다가갑니다.</p>
</li>
<li><p><strong>학습 기록과 공유 콘텐츠 분리</strong>: 개인 학습은 Notion 같은 도구에 정리하고, 블로그에는 다른 사람에게 실질적인 가치를 제공할 수 있는 글만 게시하세요.</p>
</li>
</ul>
<p>핵심은 재미있는 글을 작성하세요. 그러면 많은 사람들이 읽고 좋아요를 남깁니다. 채용 담당자는 여러분의 글이 다른 사람들에게 인정받고 있다는 증거(많은 조회수, 댓글, 좋아요)를 보면, 여러분의 커뮤니케이션 능력과 지식 수준을 더 신뢰하게 됩니다.</p>
<h2 id="그래서-어떻게-쓰는-건데요">그래서 어떻게 쓰는 건데요?</h2>
<p>아쉽게도 독자들이 좋아하는 주제는 정해져 있습니다. 대부분은 돈을 더 잘 벌고 싶고, 더 좋은 일자리를 얻고 싶어하죠. 그러나 우리는 글을 쓸 때, 독자를 잊는 실수를 합니다. 내가 경험한 지식이 가치가 있으니 이걸 전달하는 것만으로도 많은 사람들이 읽어줄거라는 착각을 합니다.</p>
<h4 id="그러면-주제를-바꾸라는건가요">그러면 주제를 바꾸라는건가요?</h4>
<p>아닙니다. 자신이 원하는 주제를 쓰면서도 독자들이 읽게끔 꼬드길 수 있습니다. 딱 &quot;제목과 서론만&quot; 독자들이 원하는 이야기로 엮으세요. 커리어나 이직 이야기로 시작하거나 프론트관련 글이라면 &quot;리액트 역량 증진&quot;과 같은 키워드로 엮으세요. 저는 스벨트 글을 쓸 때 제목에 &quot;리액트를 더욱 잘 아는법&quot;과 같은 제목으로 시작합니다. 세상이 관심있어하는 주제와 자신이 관심있는 주제는 다릅니다. 이 점을 인정하고, 어떻게든 서론은 독자들이 듣고싶어하는 주제로 시작하세요. 그다음 서론에서 내가 말하고자 하는바가 당신이 원하는 주제와 관련있음을 설득하세요.</p>
<h2 id="글쓰기에-지름길은-없다">글쓰기에 지름길은 없다</h2>
<p>이론을 안다고 바로 적용하면 그 사람은 천재겠죠? 당연히 많이 써봐야 합니다. 저는 한달에 최소 6만자 이상의 글을 씁니다. 제가 쓰는 velog의 평균 글자수는 5천자입니다. 요즘IT에 기고할때는 2만자 내외로 한달에 2~3편씩 기고하기도 하죠. </p>
<p>핵심은 많이 써보는 것입니다. 꾸준히 글을 작성해보세요. 숙련된 개발자도 글쓰기는 어색할 수 있습니다. 그러나 글을 쓰는 만큼 기회는 열립니다. 필자는 velog글로 강의 제안과 집필 제안을 수차례 받았습니다.</p>
<h2 id="결국-이글은-홍보였다">결국 이글은 홍보였다!</h2>
<p>ㅎㅎㅈㅅ..
그래서 글쓰기 챌린지 모임을 홍보합니다. 돈을 걸고 하는 블로그 챌린지!! 달성하지 못해도 낙오자ㄴㄴ 후원자입니다. 후원 금액은 특정 미션 달성 상금으로 쓰입니다!</p>
<h3 id="📝-블로그-글쓰기-챌린지-운영방침-📝">📝 블로그 글쓰기 챌린지 운영방침 📝</h3>
<h4 id="📅-챌린지-개요">📅 챌린지 개요</h4>
<ul>
<li><strong>운영 방식</strong>: 매주 토요일 자정 기준으로 새로운 조 편성</li>
<li><strong>챌린지 기간</strong>: 각 조별로 2주간 진행 (조 편성 후 14일)</li>
<li><strong>목표</strong>: 블로그 포스팅 한 편을 작성하고 발행하여 공유 가능한 링크 제출</li>
<li><strong>분량</strong>: 공백 포함 4,000자 이상 (<a href="https://lettercounter.net/">https://lettercounter.net/</a> 사이트로 인증)</li>
</ul>
<h4 id="🔍-참가-방법">🔍 참가 방법</h4>
<ul>
<li><strong>디스코드</strong> 채널에 들어오기: <a href="https://discord.gg/TVJWwpxcRd">https://discord.gg/TVJWwpxcRd</a></li>
<li><strong>신청 기한</strong>: 매주 토요일 자정까지 참가비 입금 (당일 자정까지 참가한 인원으로 새로운 조 편성)</li>
<li><strong>인증 기한</strong>: 조 편성 후 2주차 일요일 자정까지 글 발행 인증</li>
<li><strong>환급 방식</strong>: 미션 달성 시 참가비 전액 결제 취소</li>
</ul>
<h4 id="✅-미션-달성-조건">✅ 미션 달성 조건</h4>
<ol>
<li><strong>글자수 요건</strong>: 공백 포함 4,000자 이상 (<a href="https://lettercounter.net/">https://lettercounter.net/</a> 에서 원고 넣고 인증 스크린샷 제출)</li>
<li><strong>발행 완료</strong>: 누구나 볼 수 있는 발행된 글 링크 공유</li>
</ol>
<h4 id="💰-상금-제도">💰 상금 제도</h4>
<ul>
<li><strong>트렌드 1위 상금</strong>: velog 플랫폼에 발행 후 실시간 트렌드 1위 달성 시 20만원 지급</li>
<li><strong>좋아요 상금</strong>: 인증 마감 이후, velog에서 일주일 내 좋아요 10개 이상 달성 시 2만원 상당 선물 증정(미리 발행해도 상관X, 홍보 권장)</li>
<li><strong>긴 글 상금</strong>: 1만자 이상 글 작성 시 2만원 상당 선물 증정 (도전해보세요! 운영자의 최장 글은 22,533자였습니다!)</li>
<li><strong>나는 항상 남들보다 빠르지</strong>: 조 편성 후 1주차 자정 전에 미션 달성 완료 시, 스타벅스 아메리카노 드립니다!</li>
<li><strong>상금 재원</strong>: 미션 달성 실패자의 참가비 + 자발적 후원금 (부족 시 운영자 사비 충당)</li>
</ul>
<h4 id="🎁-후원자-혜택">🎁 &quot;후원자&quot; 혜택</h4>
<ul>
<li>미션 달성 실패 시 &quot;후원자&quot;로 활동 가능</li>
<li>자발적 후원도 가능</li>
<li><strong>혜택</strong>:<ul>
<li>이력서 피드백 무료 제공</li>
<li>글쓰기 팁 및 주제 제안 받기</li>
</ul>
</li>
</ul>
<h4 id="👥-참가자-커뮤니티">👥 참가자 커뮤니티</h4>
<ul>
<li>매주 토요일 자정 기준 인원에 따라 소그룹으로 조 편성</li>
<li>서로 제목 추천 및 피드백 교환</li>
<li>채팅으로만 의견 교환 (대면/비대면 미팅 없음)</li>
</ul>
<h4 id="💻-플랫폼-추천">💻 플랫폼 추천</h4>
<ul>
<li>지표를 볼 수 있는 블로그 플랫폼 권장 (velog 등)</li>
<li>개인 블로그의 경우 구글 애널리틱스로 조회수 확인 가능</li>
<li>SEO 처리 및 구글 서치콘솔 등록 권장</li>
</ul>
<h4 id="💡-참고사항">💡 참고사항</h4>
<ul>
<li>돈을 걸지 않고도 챌린지 참여 가능 (상금 제도 이용 불가)</li>
<li>피드백은 하나의 관점일 뿐, 글에는 정답이 없습니다</li>
<li>조회수와 댓글이 많이 달리는 글이 더 많은 기회를 열어주므로, 독자의 관점에서 읽히는 글 작성을 권장합니다</li>
</ul>
<p><strong>인증 마감일</strong>: 각 조별 2주차 일요일 자정까지 발행글을 인증해주세요. <a href="https://lettercounter.net/">https://lettercounter.net/</a> 에서 원고를 넣고 스크린샷 찍으셔서 인증하세요. 공백포함 4,000자를 넘기는 것이 목표입니다! 가즈아~ 🔈</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[개발자 이력서에 "이것" 빠뜨리진 않으셨나요?]]></title>
            <link>https://velog.io/@k-svelte-master/resume-tips</link>
            <guid>https://velog.io/@k-svelte-master/resume-tips</guid>
            <pubDate>Tue, 29 Apr 2025 12:48:19 GMT</pubDate>
            <description><![CDATA[<p>&quot;저는 프론트엔드 개발자로서 가상화 스크롤 구현, 페이지 로드 개선(LCP), React-Query 사용, 이미지 lazy 로드, 리액트 리렌더링 최적화... 등을 해냈습니다&quot;</p>
<p>익숙한가요? 대부분의 개발자 이력서에서 볼 수 있는 뻔한 내용들입니다. 한 두 개만 보면 괜찮을지 모르지만, 채용 담당자가 <strong>100개의 이력서</strong> 사이에서 당신의 이력서를 볼 때는 전혀 눈에 띄지 않습니다.</p>
<p>수십 명의 개발자 이력서를 검토하면서 발견한 놀라운, 그러나 안타까운 공통점이 있습니다. 대부분의 개발자들이 자신의 이력서를 &#39;기술 스펙 명세서&#39;처럼 작성하고 있다는 것입니다. 그러나 <strong>이력서는 단순한 경력 나열이 아닌, 채용 담당자를 설득하는 제안서</strong>입니다.</p>
<h2 id="채용은-점수-게임이-아닌-매칭-게임입니다">채용은 점수 게임이 아닌 &#39;매칭&#39; 게임입니다</h2>
<p>많은 개발자들이 &#39;이런 스펙이 있으면 어떤 회사든 합격할 수 있을 거야&#39;라는 생각을 품곤 합니다. 하지만 채용 시장의 현실은 이보다 훨씬 복잡합니다. 수능처럼 특정 점수를 넘기면 원하는 곳에 모두 합격한다는 단순한 공식은 존재하지 않습니다.</p>
<p>실제 사례를 들어보겠습니다. 한 개발자(저임ㅎ)가 토스의 UX 관련 3D 컴포넌트 작업 직무에 지원했을 때 서류가 탈락했습니다. 그러나 동시에 그가 개발한 라이브러리를 보고 토스 프론트엔드 코어팀에서 채용 제의(커피챗)가 왔습니다. 같은 회사, 다른 팀에서 완전히 다른 평가를 받은 것이죠.</p>
<p><strong>핵심은 이것입니다</strong>: 이력서는 수능 점수처럼 작동하지 않습니다. 어느 기업에는 떨어졌던 원서가 다른 기업에서는 합격할 수 있습니다. 기업에도 개성이 있고, 직무에도 개성이 있으며, 당신에게도 개성이 있습니다. 이 세 가지가 일치할 때 합격의 기회가 높아집니다.</p>
<h2 id="🧠-회사는-개발-도구가-아닌-개발-팀원을-채용합니다">🧠 회사는 &#39;개발 도구&#39;가 아닌 &#39;개발 팀원&#39;을 채용합니다</h2>
<p>채용담당자의 실제 고민은 무엇일까요?</p>
<p>&quot;이 사람의 기술 스택이 정확히 우리 프로젝트와 맞는가?&quot;보다는 <strong>&quot;이 사람과 함께 일하고 싶은가?&quot;</strong> 입니다.</p>
<p>회사는 개발 도구를 뽑는 것이 아니라 개발 &#39;팀원&#39;을 뽑는 것입니다. 팀원은 코드만 작성하는 것이 아닙니다. 발표도 하고, 문서도 쓰고, 대화도 나누고, 재밌게 지내고, 기획자와 디자이너에게 설명도 잘해주는 사람입니다.</p>
<p>기술 내용에 한정하지 마세요. 당신이 개발직무로 지원했다 하더라도 마치 &quot;영업사원&quot;에 지원한다는 마음으로 자신을 셀링해야 합니다.</p>
<h2 id="📌-차별화된-자기소개로-첫인상을-결정짓자">📌 차별화된 자기소개로 첫인상을 결정짓자</h2>
<p>채용 시장에서는 수많은 이력서가 경쟁합니다. 첫 단락에서 채용 담당자의 관심을 사로잡지 못하면, 나머지 내용은 주의 깊게 읽히지 않을 가능성이 높습니다.</p>
<p>진부한 표현보다는 여러분만의 개성을 드러내세요:</p>
<ul>
<li>&quot;끊임없이 배우는 개발자&quot;와 같은 흔한 표현은 피하세요</li>
<li>&quot;기획에 개발을 타협하지 않는 개발자&quot;, &quot;라이브러리 개발 전문가&quot;와 같이 구체적이고 차별화된 정체성을 표현하세요</li>
<li>지원하는 회사의 도메인과 연결되는 자기소개는 채용 담당자의 관심을 끕니다</li>
<li>의외성 있는 표현(&quot;누구보다 게으른 개발자로서 개발 효율을 극대화&quot;)도 주목을 받을 수 있습니다</li>
</ul>
<p>2-3줄로 간결하게 자신을 어필하되, 그 짧은 문장 안에 당신만의 개성과 가치를 담아내세요. 첫 단락에서 채용 담당자의 관심을 사로잡지 못하면, 나머지 내용은 주의 깊게 읽히지 않을 가능성이 높습니다.</p>
<h2 id="🤝-소프트-스킬을-적극-어필하세요">🤝 소프트 스킬을 적극 어필하세요</h2>
<p>기술적 역량은 기본이고, 그 이상의 가치를 보여주세요. 마치 영업사원으로 지원한다는 마음가짐으로 자신의 소프트 스킬을 적극적으로 어필하세요:</p>
<p>회사 도메인과 연결되는 자신의 장점을 일목요연하게 요약하고, 최소 1개 이상의 소프트 스킬을 구체적인 사례와 함께 제시하세요:</p>
<ul>
<li>&quot;디자이너가 설명 잘해준다고 칭찬한 개발자&quot;</li>
<li>&quot;후임 멘토링을 통한 팀 온보딩 기간 단축 및 생산성 향상&quot;</li>
<li>&quot;사내 기술 세미나에서 3회 발표 경험으로 복잡한 기술 내용을 쉽게 전달하는 능력 보유&quot;</li>
<li>&quot;기술 문서 작성 능력으로 팀 내 지식 공유 및 온보딩 문서화에 기여&quot;</li>
</ul>
<p>신입이라면 대학 시절이나 부트캠프에서의 팀 프로젝트 경험을 활용하세요:</p>
<ul>
<li>&quot;학과 프로젝트에서 팀장으로서 의견 조율 및 일정 관리 역할 수행&quot;</li>
<li>&quot;부트캠프 그룹 과제에서 기술적 난관 해결을 위한 스터디 주도&quot;</li>
<li>&quot;스터디 그룹에서 격주로 기술 발표를 진행하여 설명 능력 향상&quot;</li>
</ul>
<p>기업들은 단순히 기술적 역량만 뛰어난 사람보다 팀에 조화롭게 어울리고 긍정적인 영향을 미칠 수 있는 인재를 원합니다.</p>
<h2 id="💼-비즈니스-임팩트를-강력하게-어필하세요">💼 비즈니스 임팩트를 강력하게 어필하세요</h2>
<p>당신을 뽑는 사람은 일의 결정권자일 경우가 많습니다. 이런 결정권자는 일을 정할 때 이게 비즈니스적으로 어느 정도 성과를 낼지가 항상 관심사입니다. 읽는 이의 관심사를 타깃팅하는 것이 글쓰기의 핵심입니다!</p>
<p>다음과 같이 비즈니스 임팩트를 강조하세요:</p>
<ul>
<li>&quot;페이지 로드 속도 개선&quot; → &quot;페이지 로드 시간 95% 개선으로 전환율 20% 증가 및 매출 15% 상승&quot;</li>
<li>&quot;사용자 피드백 개선&quot; → &quot;UI 개선으로 사용자 이탈률 15% 감소 및 세션 시간 20% 증가로 구매율 향상&quot;</li>
<li>&quot;서버 최적화&quot; → &quot;클라우드 비용을 월 300만원에서 80만원으로 73% 절감하여 연간 2,600만원 비용 감소&quot;</li>
</ul>
<p>사이드 프로젝트를 소개할 때도 기술적 측면보다 비즈니스적 임팩트를 강조하세요:</p>
<ul>
<li>단순히 &quot;React로 만든 쇼핑몰&quot;이 아니라 &quot;실제 사용자 100명을 확보하고 월 매출 50만원을 달성한 반응형 쇼핑몰&quot;</li>
<li>&quot;상태 관리 라이브러리 구현&quot;이 아니라 &quot;10개 이상의 프로젝트에서 채택되어 개발 시간을 30% 단축시킨 상태 관리 솔루션&quot;</li>
<li>&quot;블로그 구축&quot; 대신 &quot;월 방문자 500명을 유지하며 3개 기업의 협업 제안을 받은 개발 블로그&quot;</li>
</ul>
<h2 id="🌐-도메인-친화성으로-차별화하세요">🌐 도메인 친화성으로 차별화하세요</h2>
<p>자신의 개성을 드러내는 가장 효과적인 방법은 특정 도메인에 대한 친화성을 보여주는 것입니다. 이는 순수한 개발 영역을 넘어서는 부분입니다.</p>
<ul>
<li>평소 관심 있는 분야 (금융, 교육, 의료, 콘텐츠 등)에 대한 깊은 이해</li>
<li>이전 직종의 도메인 지식 (타 직종에서 개발로 전환했다면 이것은 놓치기 아까운 큰 자산!)</li>
<li>사이드 프로젝트의 도메인 선택 (단순 기술 과시가 아닌 특정 분야의 문제 해결)</li>
</ul>
<p>스스로에게 물어보세요: &quot;개발 직무만 제공한다면 어떤 회사든 가도 상관없나요?&quot; 아마 그렇지 않을 것입니다. 당신은 어떤 회사에 가고 싶은지, 어떤 일을 하고 싶은지 고민하다 보면 어필해야 할 부분이 더 명확해질 것입니다.</p>
<h2 id="🔍-차별화는-기술적-역량만으로는-어렵습니다">🔍 차별화는 기술적 역량만으로는 어렵습니다</h2>
<p>많은 개발자들의 이력서를 살펴보면 개별적으로는 모두 잘 작성되어 있습니다. 그런데 수십, 수백 장의 이력서를 함께 놓고 보면 특정 패턴이 보이기 시작합니다:</p>
<ul>
<li>이미지 로드 개선 (LCP)</li>
<li>무한 스크롤 구현</li>
<li>가상화 스크롤 적용</li>
<li>옵티미스틱 UI/페치 적용</li>
<li>상태 관리 라이브러리 도입</li>
</ul>
<p>이런 경험들이 적으면 나쁘다는 것이 아닙니다. 문제는 차별점이 없다는 것입니다. 100~200장의 이력서 사이에서 이런 내용만으로는 눈에 띄기 어렵습니다.</p>
<h2 id="🛠️-레거시-코드-대응-능력을-강조하세요">🛠️ 레거시 코드 대응 능력을 강조하세요</h2>
<p>대부분의 회사는 레거시 코드를 가지고 있고, 이를 개선하는 과정에 있기 때문에 레거시 마이그레이션 경험은 훌륭한 자산입니다. 다음과 같은 경험을 어필해보세요:</p>
<ul>
<li>수천 줄의 코드를 파악하고 점진적으로 개선한 사례</li>
<li>레거시 시스템을 유지하면서 새로운 기능을 안정적으로 추가한 경험</li>
<li>이 과정에서의 의사결정 과정과 트레이드오프 관리 방법</li>
</ul>
<p>기술적인 스펙보다 이러한 실무 역량은 채용 담당자들에게 훨씬 더 가치 있게 평가됩니다.</p>
<h2 id="⚖️-before--after-기술-중심-vs-가치-중심-이력서">⚖️ Before &amp; After: 기술 중심 VS 가치 중심 이력서</h2>
<h3 id="기술-중심-이력서">기술 중심 이력서:</h3>
<p>&quot;React와 TypeScript를 사용하여 웹 애플리케이션을 개발했습니다. Redux로 상태 관리를 구현하고, Styled Components로 UI를 구성했습니다. Jest와 React Testing Library로 테스트 코드를 작성했습니다.&quot;</p>
<h3 id="가치-중심-이력서">가치 중심 이력서:</h3>
<p>&quot;고객 이탈률을 15% 감소시킨 반응형 웹 애플리케이션을 개발했습니다. 5명의 개발자로 구성된 팀에서 컴포넌트 문서화를 주도하여 신규 입사자의 온보딩 시간을 1주에서 3일로 단축했습니다. 기획자 및 디자이너와의 원활한 소통으로 초기 요구사항 변경에도 불구하고 출시 일정을 준수했습니다.&quot;</p>
<p>같은 경험이라도 어떻게 표현하느냐에 따라 전혀 다른 인상을 줄 수 있습니다.</p>
<h2 id="🎯-읽는-이의-관심사를-타깃팅하세요">🎯 읽는 이의 관심사를 타깃팅하세요</h2>
<p>이력서는 당신을 위한 것이 아니라 읽는 사람을 위한 것입니다. 채용 담당자와 결정권자가 관심 있어 할 내용으로 구성하세요. 기술보다 가치를 먼저 보여주세요. 가치가 증명되어야 기술적 이야기를 들을 가치가 있다고 판단됩니다.</p>
<p>자신을 팀의 문제 해결사로 포지셔닝하세요. 이력서는 여러분의 이야기를 전달하는 도구이자, 면접 기회를 얻기 위한 첫 단계의 영업 문서임을 기억하세요.</p>
<p>좋은 이력서는 단순히 &quot;무엇을 했는지&quot;가 아니라 &quot;어떤 가치를 창출했는지&quot;, 그리고 &quot;앞으로 어떤 가치를 창출할 수 있는지&quot;를 보여줍니다.</p>
<h2 id="💡-라이브러리-심층-이해를-어필하세요">💡 라이브러리 심층 이해를 어필하세요</h2>
<p>패키지를 단순히 사용하는 것을 넘어선 경험을 어필하세요:</p>
<ul>
<li>라이브러리를 사용하면서 지원하지 않는 기능을 커스텀한 경험</li>
<li>문서에 나와있지 않아 직접 코드를 뜯어본 경험</li>
<li>오픈소스 라이브러리에 기여한 경험</li>
</ul>
<p>이런 경험은 단순한 API 사용자가 아닌 깊이 있는 개발자라는 인상을 줍니다.</p>
<h2 id="🌟-이력서-합격의-숨겨진-비밀">🌟 이력서 합격의 숨겨진 비밀</h2>
<p>여러 회사에 동일한 이력서를 보내지 마세요. 각 회사와 직무가 찾는 것이 무엇인지 연구하고, 그에 맞게 당신의 경험을 재구성하세요. 합격은 단순히 좋은 스펙이 아니라, 적절한 &#39;매칭&#39;에서 비롯됩니다.</p>
<p>이력서 합격의 진정한 비밀은 &#39;더 좋은 개발자 되기&#39;가 아니라 &#39;더 적합한 개발자 되기&#39;에 있습니다. 당신이 가진 특별한 개성을 찾고 그것을 필요로 하는 회사를 만날 때, 진정한 시너지가 발생합니다.</p>
<p>이력서는 여러분의 전체 경력과 역량을 집약한 문서가 아니라 채용 담당자를 설득하는 제안서입니다. 화려한 단어나 과장된 표현보다는 구체적이고 측정 가능한 성과, 그리고 여러분만의 차별점에 집중하세요. 특히 팀에 긍정적인 영향을 줄 수 있는 사람이라는 인상을 심어주는 것이 중요합니다.</p>
<hr>
<p>블로그 글이 도움이 되셨다면 댓글로 여러분만의 이력서 작성 팁이나 경험을 공유해주세요!</p>
<p>저는 최근에 <a href="https://resume-web.cozy-lounge.workers.dev/">이력서멘토링</a> 서비스를 운영하고 있습니다. 이 글이 도움이 되셨다면 한번 피드백 신청해 보세요.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[글을 잘쓰고 싶나요? 제 시스템 프롬프트 써보세요]]></title>
            <link>https://velog.io/@k-svelte-master/claude-writing-special-prompt</link>
            <guid>https://velog.io/@k-svelte-master/claude-writing-special-prompt</guid>
            <pubDate>Sat, 26 Apr 2025 02:44:15 GMT</pubDate>
            <description><![CDATA[<p>제가 글쓰기에 활용하는 클로드 시스템프롬프트 공유합니다. </p>
<pre><code># 창의적 문제 해결을 위한 AI 시스템 프롬프트

## 1. 역할
당신은 사용자의 글쓰기 돕는 AI max입니다. 당신의 주요 목표는 인간의 사고를 발전시키는 것입니다. 절대 바로 답변을 하지 마세요. 지속적인 대화를 통해 사용자의 생각을 확장하고 심화시켜야 합니다.
(가장 중요한 것은 사용자와의 컨텍스트 동기화입니다. 지속적인 질문과 확인 과정을 통해 사용자의 의도와 문제의 본질을 정확히 이해해야 합니다.)

## 2. 근본 행동강령
0. 절대로 먼저 답변을 하려고 하지 마세요
1. 제안이나 질문을 할 때는 항상 3가지 이하로 제한한다.
2. 사용자의 대답을 미리 번호로 된 옵션으로 제시한다.
3. 제시된 옵션 중 하나에 (추천) 키워드를 붙인다.
4. 사용자의 자유로운 의견 개진을 촉진하기 위해 &quot;아무 말이나 해도 좋습니다. 제가 정리하겠습니다.&quot;라는 문구를 적절히 사용한다.
5. 지속적인 대화를 통해 사용자의 생각을 발전시키고 구체화한다.
6. 메타인지를 촉진하는 질문을 통해 사용자의 사고 과정을 돕는다.
7. 각 응답 후 다음 문구를 추가한다: &quot;다음 단계로 넘어가고 싶으시면 말씀해 주세요.&quot;
8. 비선형적 사고를 지원하기 위해 간헐적으로 다음 문구를 사용한다: &quot;이전 주제에 대해 추가적인 의견이 있으시면 언제든 말씀해 주세요.&quot;

## 3. 문제 해결은 3단계로 지원합니다.
각 단계별로 목적과 행동 강령이 상이합니다. 단 근본 행동강령을 우선적용하고 그 이후에 단계별 행동강령을 적용합니다.

### a) 아이디어 구체화 및 컨텍스트 동기화
#### 목적:
- 사용자로부터 최대한 많은 관련 정보를 이끌어냅니다.
- 비선형적 사고를 촉진하여 다양한 아이디어를 수집합니다.
- 수집된 정보를 체계적으로 정리하여 사용자의 사고 과정을 지원합니다.

#### 행동 강령:
1. 열린 질문을 통해 사용자의 생각을 자유롭게 표현하도록 유도합니다.
   예시:
   - &quot;이 주제에 대해 떠오르는 생각들을 자유롭게 말씀해 주세요.&quot;
   - &quot;지금 생각하시는 모든 아이디어를 나열해 주시겠어요? 순서는 상관없습니다.&quot;
   - &quot;이 문제와 관련해서 어떤 경험이나 관찰이 있으셨나요? 사소한 것이라도 좋습니다.&quot;
2. 비선형적 사고를 장려하여 중구난방식 정보 전달을 해도 된다고 알립니다.
3. 관련 정보나 문서 제공을 적극적으로 요청합니다:
   - &quot;이 주제와 관련된 문서나 정보를 반드시 공유해 주세요. API 문서, 코드 스니펫, 기술 스펙 등 어떤 형태든 도움이 됩니다.&quot;
   - &quot;현재 작업 중인 코드 베이스나 관련 문서를 꼭 공유해 주세요. 이는 정확한 이해와 조언을 위해 필수적입니다.&quot;
   - &quot;관련 기술 문서나 레퍼런스 없이는 진행하기 어렵습니다. 어떤 형태로든 정보를 제공해 주시기 바랍니다.&quot;
4. 전달받은 정보를 주기적으로 요약하고 정리합니다(대신 짧게 짧게)
6. 정리된 내용을 사용자에게 전달하여 추가 의견이나 수정 사항을 요청합니다.
   예시:
   - &quot;지금까지 말씀해주신 내용을 다음과 같이 정리해 보았습니다. 빠진 부분이나 수정이 필요한 부분이 있을까요?&quot;
   - &quot;이 요약본을 보시고 추가하고 싶은 아이디어가 떠오르시나요?&quot;
   - &quot;정리된 내용 중 더 자세히 설명하고 싶은 부분이 있으신가요?&quot;

### b) 실행 계획/개요 작성 및 피드백
#### 목적:
- 사용자가 아웃라인을 먼저 작성해보도록 유도하세요. 사용자의 아웃라인을 구체화하는 게 목적입니다.
- 어느 정도 사용자의 인풋을 받으면 그 아웃라인을 바탕으로 체계적인 실행 계획 또는 개요를 작성합니다.
- 사용자의 피드백을 통해 계획을 지속적으로 개선합니다.

#### 행동 강령:
1. 사용자로 하여금 아웃라인에 대한 생각을 밝히게 유도하세요.
2. 각 아웃라인에 대한 생각을 명확히 하도록 돕습니다.
3. 사용자의 피드백을 적극적으로 요청합니다.
4. 어느 정도 사용자 입력값이 모이면 이를 바탕으로 대안을 제시해보기도 합니다.(아주 가끔)

### c) 개요별/실행 계획별 작성 및 피드백
#### 목적:
- 각 개요 항목 또는 실행 계획 단계에 대한 구체적인 내용을 작성합니다.
- 개요별로 피드백을 받습니다.
- 지속적으로 피드백을 받고, 모든 개요의 피드백이 완료되면 최종 결과물을 제시합니다.

#### 행동 강령:
1. 각 항목에 대해 피드백을 계속 요청하세요. 이것 또한 열린 질문으로 진행합니다.
2. 사용자의 의견을 적극적으로 요청하고 반영합니다.
3. 작성된 내용의 품질을 체크하고 개선점을 제안합니다.
4. 각 단계마다 반드시 추가 자료나 예시를 요청합니다. 특히 기술적인 내용에 대해서는 공식 문서나 코드 스니펫을 요구합니다.
5. 비선형적 사고를 지원하세요. 이전에 피드백 했던 개요에 대해서도 추가적인 의견이 있으면 언제든 말씀해달라고 요청합니다.

## 글쓰기 퀄리티 평가 기준
1. 제목의 효과성
   - 대상 독자가 명확히 지목되었는가?
   - 독자가 얻을 수 있는 베네핏이 드러나는가?
   - 호기심을 유발하거나, 부정적 표현, 위협적 느낌, &quot;~하지 않아도 ~할 수 있다&quot; 등의 효과적인 표현 기법을 사용했는가?
   - &#39;드디어&#39;, &#39;인기&#39;, &#39;유명&#39;, &#39;지금&#39;, &#39;최신&#39; 등의 키워드를 적절히 활용했는가?
2. 서문의 매력도
   - 베네핏을 강조하거나 제목의 궁금증을 지속시키는가?
   - 독자의 관심을 끌어들이는 힘이 있는가?
3. 본문 구성의 체계성
   a) 경험 공유
      - 대상 독자와 공감될 수 있는 실제 경험을 담았는가?
   b) 문제 상황 및 원인 분석
      - 독자가 처한 문제 상황을 명확히 제시했는가?
      - 문제의 원인을 설득력 있게 분석했는가?
   c) 해결 방안 제시
      - 구체적이고 실행 가능한 해결 방안을 제시했는가?
      - 제시된 방안이 어떻게 문제를 해결할 수 있는지 명확히 설명했는가?
   d) 베네핏 강조
      - 해결 방안이 가져다줄 구체적인 이로움을 강조했는가?
4. 독자 참여 유도
   - 댓글 달기, 액션 취하기 등 독자의 참여를 효과적으로 유도하는가?
   - &#39;지금 아니면 안 된다&#39;는 느낌을 적절히 전달하는가?
5. 전반적인 글의 흐름
   - 각 섹션 간 자연스러운 연결이 이루어졌는가?
   - 전체적으로 일관된 메시지를 전달하는가?</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[MCP의 모든 것을 알아봅시다.]]></title>
            <link>https://velog.io/@k-svelte-master/what-is-mcp</link>
            <guid>https://velog.io/@k-svelte-master/what-is-mcp</guid>
            <pubDate>Mon, 31 Mar 2025 06:39:36 GMT</pubDate>
            <description><![CDATA[<h2 id="mcp-하나면-다-된다고요-정말일까요">MCP 하나면 다 된다고요? 정말일까요?</h2>
<p>여러분은 이런 광고 문구를 본 적 있을 겁니다. &quot;이 제품 하나면 다 됩니다!&quot;, &quot;단 한 번의 클릭으로 해결!&quot;, &quot;더 이상 고민하지 마세요!&quot;... 그리고 대부분의 경우, 실제로는 그렇게 마법같은 일이 일어나지 않죠. 하지만 이번만큼은 예외인거 같습니다. 그 정체는 바로 MCP(Model Context Protocol)입니다.</p>
<p>&quot;이제 MCP로 LLM에 전달할 외부 API를 규격화하세요!&quot;</p>
<p>Anthropic 주도로 개발되고 오픈소스로 공개된 MCP는 마치 AI계의 USB-C 포트처럼 소개되고 있습니다. 다양한 기기를 표준화된 방식으로 연결하는 USB-C처럼, MCP는 LLM에 전달하는 외부 API의 규격화된 프로토콜을 제공합니다. 이 프로토콜은 <a href="https://github.com/modelcontextprotocol">https://github.com/modelcontextprotocol</a> 에서 SDK 형태로 제공되어, 누구나 MCP 서버나 클라이언트를 쉽게 구현할 수 있습니다.</p>
<h3 id="ai계의-usb-c가-등장했다-mcp로-연결하는-새로운-ai-세상">AI계의 USB-C가 등장했다! MCP로 연결하는 새로운 AI 세상</h3>
<p>온 세상이 MCP 서버를 오픈하고 있습니다. Cursor와 같은 코딩 툴에서는 MCP를 통해 로컬 파일 시스템에 접근하여 코드를 분석하고 자동으로 코드를 작성해주는 기능을 선보이고 있습니다. Claude 데스크탑앱도 MCP 연동을 통해 외부 API를 활용하도록 지원하고 있으며, Microsoft는 Playwright MCP 서버 코드를 직접 오픈하여 웹 테스트 자동화를 지원하고 있습니다. 점점 더 많은 도구들이 이 프로토콜을 채택하며 대세가 되어가고 있습니다.</p>
<p>&quot;에이, 그게 무슨 대수라고. 기존에도 API 연동 많이 했잖아요?&quot;</p>
<p>맞습니다. LLM과 외부 도구를 연결하는 방법은 이전에도 있었습니다. 하지만 각 서비스마다 API 구조가 다르고, 인증 방식이 달라 통합하는 과정이 복잡했죠. MCP의 진가는 바로 이 지점에서 빛납니다. 규격화된 프로토콜을 통해 누구나 동일한 방식으로 API를 구현하고 연결할 수 있게 된 것입니다. 개발자라면 이런 표준화의 가치를 아실 겁니다. &#39;모든 것이 연결된&#39; AI 에코시스템의 꿈이 MCP를 통해 한 걸음 더 가까워진 것입니다.</p>
<h3 id="cursor-figma에서-벌써-시작된-mcp-혁명-코드요-자동으로-뚝딱이죠">Cursor, Figma에서 벌써 시작된 MCP 혁명: &quot;코드요? 자동으로 뚝딱이죠!&quot;</h3>
<p>예를 들어보죠. Cursor에서는 MCP를 통해 다음과 같은 대화가 가능합니다:</p>
<p>&quot;내 프로젝트의 모든 Python 파일을 분석해서 중복 코드를 찾아줘&quot;
&quot;이 기능을 테스트하는 유닛 테스트를 작성해줘&quot;
&quot;이 API와 호환되는 클라이언트 코드를 자동으로 생성해줘&quot;</p>
<p>MCP 서버를 통해 AI가 로컬 파일 시스템에 안전하게 접근할 수 있게 되면서, 이제 AI는 단순히 대화 상자에 붙여넣은 코드 조각이 아니라 전체 프로젝트 구조를 이해하고 맥락에 맞는 코드를 생성할 수 있게 되었습니다. 이것이 바로 개발자들이 &quot;와, 이거 진짜 마법 같은데?&quot;라고 반응하는 이유죠.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/1b6b8c65-6fe9-4cd2-89fb-d32a92de75dc/image.png" alt="링크드인"></p>
<p><a href="https://www.linkedin.com/feed/update/urn:li:activity:7310612241492389890/?commentUrn=urn%3Ali%3Acomment%3A(ugcPost%3A7310612220298633217%2C7310813914592604160)&amp;dashCommentUrn=urn%3Ali%3Afsd_comment%3A(7310813914592604160%2Curn%3Ali%3AugcPost%3A7310612220298633217)">링크드인 글 퍼옴 - 작성자에게 허락받음 ㅎ..</a></p>
<h3 id="너무-좋아서-의심스러운-mcp-과연-실체는">너무 좋아서 의심스러운 MCP, 과연 실체는?</h3>
<p>하지만 잠깐, MCP 서버만 구현하면 LLM이 촥 하고 달라붙어서 모든 기능을 자동으로 수행할까요? 이 역할도 MCP의 몫일까요?</p>
<p>물론 그렇지 않습니다. MCP는 결국 &#39;프로토콜&#39;일 뿐입니다. USB-C 포트가 있다고 해서 모든 기기가 자동으로 모든 기능을 수행하는 것은 아니죠. 케이블은 연결만 해줄 뿐, 각 기기가 어떤 기능을 수행할지는 또 다른 문제입니다.</p>
<p>그렇다면 MCP의 진짜 가치는 무엇일까요? 왜 이것이 중요한 걸까요? 더 나아가, MCP는 앞으로의 개발 패러다임을 어떻게 바꿀까요? 모든 사람이 AI 개발자가 되는 세상이 올까요? 전통적인 프로그래밍 기술 없이도 자신만의 AI 도구를 만들고 연결할 수 있는 미래가 도래할지도 모릅니다.</p>
<p>이제부터 Model Context Protocol의 오해와 진실, 그리고 어떤 가치가 있는지 MCP의 모든 것을 하나씩 파헤쳐보며, AI의 미래가 어떻게 변화할지 함께 살펴보겠습니다.</p>
<h2 id="function-call의-사촌-mcp">Function Call의 사촌, MCP</h2>
<h3 id="잠깐만요-function-call이-뭐였죠">잠깐만요, Function Call이 뭐였죠?</h3>
<p>AI가 단순히 텍스트만 생성하던 시대는 지났습니다. 현대 LLM(Large Language Model)은 다양한 도구를 사용할 수 있는데, 이것이 바로 &#39;Function Call&#39;입니다. OpenAI가 선보인 이 개념은 AI가 특정 함수를 호출하여 외부 세계와 상호작용할 수 있게 해주었죠.</p>
<p>Function Call은 본질적으로 특별한 형태의 시스템 프롬프트입니다. 사용자의 질문이 들어오면 LLM은 이 질문이 Function Call을 호출해야 하는지 판단합니다. 만약 호출이 필요하다고 판단되면, LLM은 규격화된 인자(arguments)를 생성하고, 그렇지 않으면 일반적인 대화를 계속 진행합니다.</p>
<p>Function Call의 작동 방식을 도식화하면 다음과 같습니다:</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/3397db8e-ca3b-4341-9533-3e051df76897/image.png" alt="function"></p>
<p>간단한 예를 들어볼까요? OpenAI의 TypeScript SDK를 사용한 Function Call 예시입니다:</p>
<pre><code class="language-typescript">import OpenAI from &#39;openai&#39;;

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// 함수 정의
const functions = [
  {
    name: &#39;get_weather&#39;,
    description: &#39;특정 위치의 현재 날씨 정보를 가져옵니다&#39;,
    parameters: {
      type: &#39;object&#39;,
      properties: {
        location: {
          type: &#39;string&#39;,
          description: &#39;도시 이름, 예: 서울, 뉴욕, 런던&#39;,
        },
      },
      required: [&#39;location&#39;],
    },
  },
];

// 채팅 완성 요청
async function main() {
  const completion = await openai.chat.completions.create({
    model: &#39;gpt-4&#39;,
    messages: [
      { role: &#39;user&#39;, content: &#39;서울의 오늘 날씨는 어때?&#39; }
    ],
    tools: functions,
    tool_choice: &#39;auto&#39;,
  });

  // Function Call 결과 확인
  const message = completion.choices[0].message;

  if (message.tool_calls) {
    // AI가 함수를 호출하려고 한다면
    const functionCall = message.tool_calls[0];
    console.log(`함수 호출: ${functionCall.function.name}`);
    console.log(`매개변수: ${functionCall.function.arguments}`);

    // 여기서 실제 날씨 API를 호출하고 결과를 AI에게 돌려줄 수 있습니다
  } else {
    // 일반 텍스트 응답
    console.log(message.content);
  }
}

main();</code></pre>
<p>이 코드에서 일어나는 일을 단계별로 살펴보면:</p>
<ol>
<li>개발자는 <code>get_weather</code>라는 함수와 그 매개변수를 정의합니다.</li>
<li>사용자가 &quot;서울의 오늘 날씨는 어때?&quot;라고 질문합니다.</li>
<li>LLM은 이 질문을 분석하고 날씨 정보를 요청하는 것으로 판단합니다.</li>
<li>LLM은 <code>get_weather</code> 함수를 호출하기로 결정하고, <code>location: &quot;서울&quot;</code>이라는 인자를 생성합니다.</li>
<li>개발자가 작성한 코드는 이 함수 호출을 감지하고, 실제 날씨 API를 호출할 수 있습니다.</li>
<li>그 결과를 다시 LLM에 전달하면, LLM은 사용자에게 적절한 응답을 생성합니다.</li>
</ol>
<p>이것이 바로 Function Call의 핵심입니다. LLM은 사용자의 의도를 파악하여 적절한 함수를 호출하고, 개발자는 실제로 그 함수를 구현하여 외부 세계와의 상호작용을 가능하게 합니다.</p>
<h2 id="👋-안녕-나는-mcp야-model-context-protocol의-정체-파헤치기">👋 안녕, 나는 MCP야: Model Context Protocol의 정체 파헤치기</h2>
<p>Function Call에 익숙해지셨다면, 이제 MCP(Model Context Protocol)를 만나볼 시간입니다. MCP는 LLM과 외부 도구 간의 통신을 표준화하는 프로토콜입니다. 그런데 이 추상적인 설명만으로는 MCP의 진짜 모습을 이해하기 어렵겠죠? 직접 코드를 살펴보며 MCP의 정체를 파헤쳐 봅시다.</p>
<h3 id="json-rpc-mcp의-통신-방식">JSON-RPC: MCP의 통신 방식</h3>
<p>MCP는 JSON-RPC 2.0 프로토콜을 기반으로 합니다. JSON-RPC란 간단히 말해 JSON 형식으로 원격 프로시저 호출(RPC)을 인코딩하는 경량 프로토콜입니다. 복잡한 말처럼 들리지만, 실제로는 매우 단순합니다:</p>
<ol>
<li>클라이언트가 서버에 JSON 형식의 요청을 보냅니다</li>
<li>서버는 요청을 처리하고 JSON 형식의 응답을 반환합니다</li>
</ol>
<p>각 요청과 응답은 다음과 같은 형태를 가집니다:</p>
<p><strong>요청:</strong></p>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;unique-request-id&quot;,
  &quot;method&quot;: &quot;메서드 이름&quot;,
  &quot;params&quot;: { /* 매개변수 */ }
}</code></pre>
<p><strong>응답:</strong></p>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;unique-request-id&quot;,
  &quot;result&quot;: { /* 결과 데이터 */ }
}</code></pre>
<p>MCP는 이 JSON-RPC 형식을 사용하여 <code>tools/list</code>, <code>tools/call</code> 등의 표준화된 메서드를 정의합니다. 공식 SDK(<a href="https://modelcontextprotocol.io/introduction">https://modelcontextprotocol.io/introduction</a>)를 사용하면 쉽게 구현할 수 있습니다. 이를 통해 클라이언트와 서버는 일관된 방식으로 통신할 수 있죠.</p>
<h3 id="toolslist-요청-및-응답-예시">tools/list 요청 및 응답 예시</h3>
<p>tools/list는 MCP 서버에서 사용 가능한 도구 목록을 조회할 때 사용하는 메서드입니다. 요청과 응답 형식은 다음과 같습니다:</p>
<p><strong>요청:</strong></p>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;request-123&quot;,
  &quot;method&quot;: &quot;tools/list&quot;
}</code></pre>
<p><strong>응답:</strong></p>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;request-123&quot;,
  &quot;result&quot;: {
    &quot;tools&quot;: [
      {
        &quot;name&quot;: &quot;readFile&quot;,
        &quot;description&quot;: &quot;파일 내용을 읽어옵니다&quot;,
        &quot;inputSchema&quot;: {
          &quot;type&quot;: &quot;object&quot;,
          &quot;properties&quot;: {
            &quot;path&quot;: {
              &quot;type&quot;: &quot;string&quot;,
              &quot;description&quot;: &quot;파일 경로&quot;
            }
          },
          &quot;required&quot;: [&quot;path&quot;]
        }
      },
      {
        &quot;name&quot;: &quot;searchWeb&quot;,
        &quot;description&quot;: &quot;웹에서 정보를 검색합니다&quot;,
        &quot;inputSchema&quot;: {
          &quot;type&quot;: &quot;object&quot;,
          &quot;properties&quot;: {
            &quot;query&quot;: {
              &quot;type&quot;: &quot;string&quot;,
              &quot;description&quot;: &quot;검색 쿼리&quot;
            },
            &quot;limit&quot;: {
              &quot;type&quot;: &quot;number&quot;,
              &quot;description&quot;: &quot;최대 결과 수&quot;,
              &quot;default&quot;: 5
            }
          },
          &quot;required&quot;: [&quot;query&quot;]
        }
      }
    ]
  }
}</code></pre>
<h3 id="toolscall-요청-및-응답-예시">tools/call 요청 및 응답 예시</h3>
<p>tools/call은 특정 도구를 호출할 때 사용하는 메서드입니다. 요청과 응답 형식은 다음과 같습니다:</p>
<p><strong>요청:</strong></p>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;request-456&quot;,
  &quot;method&quot;: &quot;tools/call&quot;,
  &quot;params&quot;: {
    &quot;name&quot;: &quot;searchWeb&quot;,
    &quot;arguments&quot;: {
      &quot;query&quot;: &quot;최신 AI 기술 동향&quot;,
      &quot;limit&quot;: 3
    }
  }
}</code></pre>
<p><strong>응답:</strong></p>
<pre><code class="language-json">{
  &quot;jsonrpc&quot;: &quot;2.0&quot;,
  &quot;id&quot;: &quot;request-456&quot;,
  &quot;result&quot;: {
    &quot;content&quot;: [
      {
        &quot;type&quot;: &quot;text&quot;,
        &quot;text&quot;: &quot;최신 AI 기술 동향에 관한 검색 결과:\n1. 생성형 AI의 발전과 윤리적 고려사항\n2. 자율주행 기술의 최신 동향\n3. 의료 분야에서의 AI 활용 사례&quot;
      }
    ]
  }
}</code></pre>
<h3 id="mcp-서버와-transport-인터페이스">MCP 서버와 Transport 인터페이스</h3>
<p>MCP 서버를 직접 구현한다면 어떻게 할까요? 가장 간단한 방법은 단일 엔드포인트(예: <code>/messages</code>)를 노출하는 API를 만드는 것입니다. 이 엔드포인트는 요청 본문(body)을 해석하여 <code>method</code> 필드의 값에 따라 다른 응답을 반환합니다:</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/38c5a458-9a8c-4741-ae65-2335b06f3bf0/image.png" alt="iamge-2"></p>
<p>그런데 MCP 설계의 진정한 유연함은 기존 API를 새로 만들지 않고도 MCP 프로토콜에 맞게 사용할 수 있다는 점입니다. 이를 가능하게 하는 것이 바로 Transport 인터페이스입니다:</p>
<pre><code class="language-typescript">interface Transport {
  // 통신 시작
  start(): Promise&lt;void&gt;;

  // JSON-RPC 메시지 전송 (요청 또는 응답)
  send(message: JSONRPCMessage): Promise&lt;void&gt;;

  // 연결 종료
  close(): Promise&lt;void&gt;;

  // 연결 종료 시 콜백
  onclose?: () =&gt; void;

  // 오류 발생 시 콜백
  onerror?: (error: Error) =&gt; void;

  // 메시지 수신 시 콜백
  onmessage?: (message: JSONRPCMessage) =&gt; void;

  // 세션 ID
  sessionId?: string;
}</code></pre>
<p>이 인터페이스를 구현하면 기존 API를 MCP 프로토콜에 맞게 변환할 수 있습니다. 즉, 클라이언트는 Transport를 통해 request를 보내고, 이 Transport가 해당 요청을 적절한 API 호출로 변환하는 것입니다:</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/222294d7-db75-480a-9ad9-0b3cc53af3fe/image.png" alt="good"></p>
<p>이렇게 하면 서버를 새로 구현할 필요 없이, 클라이언트 측의 Transport만으로 MCP 프로토콜을 구현할 수 있습니다.</p>
<h3 id="mcp-sdk-직접-사용해보기">MCP SDK 직접 사용해보기</h3>
<p>MCP SDK를 사용하여 클라이언트를 초기화하고 서버와 통신하는 방법을 살펴봅시다:</p>
<pre><code class="language-typescript">import { Client } from &quot;@modelcontextprotocol/sdk/client/index.js&quot;;

// 클라이언트 초기화
const client = new Client(
  { name: &quot;my-client&quot;, version: &quot;1.0.0&quot; },
);

// 기존 엔드포인트를 MCP로 변환하는 Transport
class ExistingAPITransport implements Transport {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  onmessage?: (message: JSONRPCMessage) =&gt; void;
  onclose?: () =&gt; void;
  onerror?: (error: Error) =&gt; void;

  async start(): Promise&lt;void&gt; {
    console.log(&quot;API Transport started&quot;);
  }

  async send(message: JSONRPCMessage): Promise&lt;void&gt; {
    // 요청이 아니면 무시
    if (!(&#39;method&#39; in message)) return;

    const request = message as JSONRPCRequest;

    try {
      let response;

      // 기존 API 엔드포인트로 라우팅
      if (request.method === &#39;tools/list&#39;) {
        // /api/tools 엔드포인트로 변환
        response = await fetch(`${this.baseUrl}/api/tools`);
        const tools = await response.json();

        // MCP 형식으로 변환
        if (this.onmessage) {
          this.onmessage({
            jsonrpc: &#39;2.0&#39;,
            id: request.id,
            result: { tools }
          });
        }
      } 
      else if (request.method === &#39;tools/call&#39;) {
        const { name, arguments: args } = request.params;

        // /api/execute/[도구명] 엔드포인트로 변환
        response = await fetch(`${this.baseUrl}/api/execute/${name}`, {
          method: &#39;POST&#39;,
          headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
          body: JSON.stringify(args)
        });

        const result = await response.json();

        // MCP 형식으로 변환
        if (this.onmessage) {
          this.onmessage({
            jsonrpc: &#39;2.0&#39;,
            id: request.id,
            result: {
              content: [{ type: &#39;text&#39;, text: JSON.stringify(result) }]
            }
          });
        }
      }
    } catch (error) {
      if (this.onerror) this.onerror(error);
      if (this.onmessage) {
        this.onmessage({
          jsonrpc: &#39;2.0&#39;,
          id: request.id,
          error: {
            code: -32603,
            message: error.message
          }
        });
      }
    }
  }

  async close(): Promise&lt;void&gt; {
    if (this.onclose) this.onclose();
  }
}

// 기존 API를 가리키는 Transport 연결
const transport = new ExistingAPITransport(&quot;https://api.example.com&quot;);
await client.connect(transport);

console.log(&quot;Connected to existing API through MCP&quot;);

// tools/list 요청 예시
const toolsListResult = await client.request(
  { method: &quot;tools/list&quot; },
  ListToolsResultSchema
);
console.log(&quot;Available tools:&quot;, toolsListResult.tools);

// tools/call 요청 예시
const toolCallResult = await client.request(
  {
    method: &quot;tools/call&quot;,
    params: {
      name: &quot;searchWeb&quot;,
      arguments: {
        query: &quot;최신 AI 기술 동향&quot;,
        limit: 3
      }
    }
  },
  CallToolResultSchema
);
console.log(&quot;Search result:&quot;, toolCallResult.content[0].text);</code></pre>
<h3 id="서버-없이-로컬-서비스-활용하기">서버 없이 로컬 서비스 활용하기</h3>
<p>물론, 서버 없이 로컬에서 직접 구현체를 만들 수도 있습니다. 간단히 로컬 객체의 메서드를 MCP 도구로 노출하는 방식으로 구현할 수 있죠:</p>
<pre><code class="language-typescript">// 로컬 서비스 객체
const localServices = {
  getWeather(city) {
    return `${city}의 날씨는 맑음, 기온 22도입니다`;
  },

  translate(text, targetLang) {
    return `번역된 텍스트: ${text} (${targetLang}로)`;
  }
};

// 로컬 서비스를 MCP 도구로 변환하는 Transport
class LocalServicesTransport implements Transport {
  async start() {}
  async close() {}

  async send(message) {
    if (!(&#39;method&#39; in message)) return;
    const req = message;

    if (req.method === &#39;tools/list&#39;) {
      this.onmessage({
        jsonrpc: &#39;2.0&#39;,
        id: req.id,
        result: {
          tools: [
            {
              name: &#39;getWeather&#39;,
              description: &#39;도시의 날씨 정보 조회&#39;,
              inputSchema: {/* 스키마 정의 */}
            },
            {
              name: &#39;translate&#39;,
              description: &#39;텍스트 번역&#39;,
              inputSchema: {/* 스키마 정의 */}
            }
          ]
        }
      });
    }
    else if (req.method === &#39;tools/call&#39;) {
      const result = localServices[req.params.name](...Object.values(req.params.arguments));
      this.onmessage({
        jsonrpc: &#39;2.0&#39;,
        id: req.id,
        result: { content: [{ type: &#39;text&#39;, text: result }] }
      });
    }
  }
}

// 로컬 서비스 연결
const localTransport = new LocalServicesTransport();
await client.connect(localTransport);

// 이제 로컬 서비스를 MCP 도구로 호출할 수 있습니다
const weatherResult = await client.request({
  method: &quot;tools/call&quot;,
  params: {
    name: &quot;getWeather&quot;,
    arguments: { city: &quot;서울&quot; }
  }
});
console.log(weatherResult.content[0].text); // &quot;서울의 날씨는 맑음, 기온 22도입니다&quot;</code></pre>
<h3 id="mcp의-핵심-가치-표준화된-인터페이스">MCP의 핵심 가치: 표준화된 인터페이스</h3>
<p>MCP의 핵심 가치는 바로 여기에 있습니다. 기존에 존재하는 API나, 로컬 서비스, 또는 새로 구현한 서버 등 어떤 도구나 리소스든 Transport layer만 구현해놓으면, MCP client에서 정해진 메서드로 LLM Function Call에 필요한 내용들을 호출할 수 있습니다.</p>
<p>이것이 바로 &#39;규격화&#39;의 의미입니다. LLM이 외부 세계와 상호작용하는 방식을 일관되게 만들어 주는 것이죠. 개발자는 각 서비스마다 다른 방식의 함수 정의를 작성할 필요 없이, MCP 프로토콜만 따르면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/24d44b2e-501d-420b-9720-4d49d7692b7f/image.png" alt=""></p>
<p>Function Call을 사용할 때는 각 API마다 고유한 함수 정의를 작성해야 했지만, MCP를 사용하면 모든 도구와 리소스에 단일한 인터페이스로 접근할 수 있습니다. 이는 마치 USB 표준이 다양한 하드웨어를 연결하는 방식을 표준화한 것과 유사합니다.</p>
<p>MCP는 다음과 같은 이점을 제공합니다:</p>
<ol>
<li><strong>표준화된 인터페이스</strong>: 모든 도구와 리소스에 동일한 방식으로 접근할 수 있습니다.</li>
<li><strong>유연한 구현</strong>: 서버를 새로 만들거나, 기존 API를 변환하거나, 로컬 서비스를 활용하는 등 다양한 방식으로 구현할 수 있습니다.</li>
<li><strong>LLM 독립성</strong>: 특정 LLM 제공업체에 종속되지 않고, 다양한 LLM과 함께 사용할 수 있습니다.</li>
<li><strong>확장성</strong>: 새로운 도구나 리소스를 추가할 때도 동일한 인터페이스를 사용할 수 있습니다.</li>
</ol>
<p>이러한 이점을 통해 MCP는 LLM과 외부 도구 간의 통신을 더욱 효율적으로 만들어 줍니다. Function Call이 첫걸음이었다면, MCP는 그 다음 단계로의 도약을 의미합니다.</p>
<h2 id="mcp는-혼자서-아무것도-못해요">MCP는 혼자서 아무것도 못해요</h2>
<p>지금까지 MCP가 얼마나 멋진 프로토콜인지 살펴봤습니다. 표준화된 인터페이스로 외부 도구와 리소스에 접근하는 방법을 제공한다니, 정말 훌륭하죠? 하지만 이쯤에서 한 가지 중요한 사실을 짚고 넘어가야 합니다.</p>
<h3 id="😈-호스트-앱은-직접-구현하셔야죠">😈 호스트 앱은 직접 구현하셔야죠!</h3>
<p>이쯤 되면 눈치채셨을지도 모르겠습니다. <strong>MCP에는 LLM이 없습니다.</strong> 그저 외부 리소스를 연결하는 일관된 인터페이스를 제공할 뿐이죠. 즉, LLM과의 연결, 대화 관리, 그리고 실제 도구 호출의 로직은 모두 호스트 앱에서 직접 구현해야 합니다.</p>
<p>다시 말해, MCP는 도구를 표준화된 방식으로 노출하는 방법을 제공하지만, 그 도구를 언제, 어떻게 사용할지는 호스트 앱의 책임입니다. 이 부분이 바로 Claude Desktop, Cursor, 그리고 다양한 AI 서비스들이 독자적으로 구현해야 하는 부분입니다.</p>
<p>호스트 앱에서 구현해야 하는 주요 로직을 살펴보겠습니다:</p>
<pre><code class="language-typescript">// 기본 구조
class MCPHostApp {
  constructor() {
    this.mcpClient = new MCPClient();  // MCP 클라이언트
    this.llm = new LLMClient();        // LLM 클라이언트 (Claude, GPT 등)
  }

  async processUserQuery(query) {
    // 1. MCP 서버에서 도구 목록 가져오기
    const tools = await this.mcpClient.request({ method: &quot;tools/list&quot; });

    // 2. LLM에 도구 목록 전달하고 쿼리 처리 요청
    const llmResponse = await this.llm.processQuery(query, tools);

    // 3. LLM이 도구 호출을 요청했다면
    if (llmResponse.hasFunctionCall) {
      // 4. MCP를 통해 도구 호출
      const result = await this.mcpClient.request({
        method: &quot;tools/call&quot;,
        params: {
          name: llmResponse.functionName,
          arguments: llmResponse.functionArgs
        }
      });

      // 5. 도구 호출 결과를 LLM에 다시 전달
      return await this.llm.processFunctionResult(result);
    }

    // 도구 호출이 없다면 LLM 응답 그대로 반환
    return llmResponse.text;
  }
}</code></pre>
<p>이 단순한 코드에서도 호스트 앱이 해야 할 일이 많다는 것을 알 수 있습니다:</p>
<ol>
<li>MCP 클라이언트와 LLM 클라이언트를 모두 관리</li>
<li>MCP에서 도구 목록을 가져와 LLM에 전달</li>
<li>LLM의 도구 호출 요청을 감지하고 MCP로 전달</li>
<li>도구 호출 결과를 다시 LLM에 전달</li>
<li>최종 응답을 사용자에게 제공</li>
</ol>
<p>이것이 바로 Claude Desktop, Cursor, Visual Studio Code 확장 프로그램과 같은 AI 도구들이 모두 독자적으로 구현해야 하는 부분입니다. MCP는 단지 도구와 리소스에 접근하는 표준화된 방법만 제공할 뿐, 실제로 유용한 AI 경험을 만들기 위한 로직은 호스트 앱이 담당해야 합니다.</p>
<p>요약하자면, MCP는 혼자서는 아무것도 할 수 없습니다. 실제로 가치를 발휘하려면 잘 설계된 호스트 앱이 필요하죠. 그래서 다양한 AI 서비스들이 각자의 방식으로 호스트 앱을 구현하고 있는 것입니다.</p>
<h3 id="🧠-호스트-앱이-전부입니다-llm-워크플로우의-중요성">🧠 호스트 앱이 전부입니다: LLM 워크플로우의 중요성</h3>
<p>MCP 서버가 도구를 제공하고 LLM이 이를 호출한다면 모든 문제가 해결될까요? 단순히 Function Call 목록을 LLM에게 전달하고 호출하게 하면 모든 것을 완벽하게 수행할 수 있을까요? </p>
<p>현실은 그렇게 간단하지 않습니다. LLM의 진정한 능력을 끌어내기 위해서는 효과적인 LLM 워크플로우 설계가 필수적입니다.</p>
<p>가장 단순한 예시부터 생각해 봅시다. MCP를 통해 100개 또는 1000개의 도구를 사용할 수 있다고 가정해 보겠습니다. 이 모든 도구 정의를 한 번에 LLM에게 전달하면 어떻게 될까요?</p>
<pre><code class="language-typescript">// ❌ 비효율적인 방식: 모든 도구를 한 번에 LLM에 전달
const allTools = await mcpClient.request({ method: &quot;tools/list&quot; });
const llmResponse = await llm.processQuery(query, allTools); // 수많은 도구로 LLM이 혼란!</code></pre>
<p>이런 접근 방식은 두 가지 문제를 일으킵니다:</p>
<ol>
<li><strong>토큰 낭비</strong>: 수백 개의 도구 정의가 컨텍스트 윈도우를 차지하여 실제 사용자 쿼리를 처리할 공간이 줄어듭니다.</li>
<li><strong>선택 혼란</strong>: LLM은 너무 많은 선택지 중에서 적절한 도구를 찾는 데 어려움을 겪을 수 있습니다.</li>
</ol>
<p>효과적인 호스트 앱은 이 문제를 다음과 같이 해결할 수 있습니다:</p>
<pre><code class="language-typescript">// ✅ 효율적인 방식: 쿼리 기반으로 관련 도구만 선택하여 제공
async function processWithRelevantTools(query) {
  // 1. 모든 도구 목록 가져오기
  const allTools = await mcpClient.request({ method: &quot;tools/list&quot; });

  // 2. 쿼리 임베딩 생성
  const queryEmbedding = await generateEmbedding(query);

  // 3. 도구 설명의 임베딩과 비교하여 관련 도구만 선택
  const relevantTools = selectRelevantTools(allTools, queryEmbedding);

  // 4. 선택된 도구만 LLM에 제공
  return await llm.processQuery(query, relevantTools);
}</code></pre>
<p>이것은 호스트 앱이 단순한 중계자가 아니라 지능형 조정자로서 작동해야 함을 보여주는 한 가지 예시일 뿐입니다.</p>
<h3 id="llm-워크플로우-react-패턴과-그-너머">LLM 워크플로우: ReAct 패턴과 그 너머</h3>
<p>LLM을 운용하는 방식은 아주 다양한 방법론이 있습니다. 그중 가장 널리 알려진 패턴 중 하나는 ReAct(Reasoning + Acting)입니다.</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/65895af6-8605-440d-a10d-d062986e4b00/image.png" alt="image-5"></p>
<p>ReAct 패턴에서 LLM은:</p>
<ol>
<li><strong>추론(Reasoning)</strong>: 어떤 도구를 사용할지 결정하고 그 이유를 설명합니다.</li>
<li><strong>행동(Acting)</strong>: 선택한 도구를 호출하여 정보를 얻거나 작업을 수행합니다.</li>
<li><strong>관찰(Observation)</strong>: 도구 실행 결과를 분석하고 다음 단계를 계획합니다.</li>
</ol>
<p>이 과정은 반복적으로 수행되며, 각 단계에서 LLM은 자신의 행동을 설명하고 결과를 평가합니다.</p>
<p>그러나 ReAct는 시작일 뿐입니다. 다양한 워크플로우 패턴이 있습니다:</p>
<ul>
<li><strong>Reflection</strong>: LLM이 자신의 응답을 비판적으로 평가하고 개선하는 과정</li>
<li><strong>Human-in-the-loop</strong>: 특정 시점에서 사용자의 피드백을 받아 정확도 향상</li>
<li><strong>Multi-agent Collaboration</strong>: 여러 LLM 에이전트가 협력하여 복잡한 문제 해결</li>
</ul>
<p>Windsurf가 Cursor라는 경쟁자를 제치고 인기를 얻은 이유 중 하나는 이러한 LLM 에이전트 협업 관계를 효과적으로 설계했기 때문입니다.</p>
<h3 id="딥-리서치-검색과-반복-질문">딥 리서치: 검색과 반복 질문</h3>
<p>최근 OpenAI, Perplexity, Grok과 같은 서비스에서 볼 수 있는 딥 리서치 기능도 잘 설계된 워크플로우의 한 예입니다:</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/e25c263a-b397-4cae-9b61-f4a8fa82ebc0/image.png" alt="good"></p>
<p>이 워크플로우에서는:</p>
<ol>
<li>사용자의 질문을 기반으로 웹 검색을 수행합니다.</li>
<li>검색 결과를 분석하여 후속 질문을 생성합니다.</li>
<li>이 과정을 반복하여 점점 더 정확하고 깊이 있는 정보를 수집합니다.</li>
<li>최종적으로 모든 정보를 종합하여 사용자에게 제공합니다.</li>
</ol>
<p>이런 방식은 단순한 질의응답보다 훨씬 더 정확하고 최신 정보를 제공할 수 있습니다.</p>
<h3 id="프레임워크-워크플로우-구축의-도구들">프레임워크: 워크플로우 구축의 도구들</h3>
<p>이러한 복잡한 워크플로우를 구현하기 위한 다양한 프레임워크가 존재합니다:</p>
<ul>
<li><strong>Microsoft의 AutoGen</strong>: 여러 에이전트가 협업하는 방식으로, 각 에이전트는 고유한 시스템 프롬프트를 가지고 특정 역할을 수행합니다.</li>
<li><strong>LangGraph</strong>: 다양한 LLM 컴포넌트를 연결하여 복잡한 워크플로우를 구성할 수 있습니다.</li>
</ul>
<pre><code class="language-typescript">// AutoGen 스타일의 다중 에이전트 구현 예시 (수도 코드)
const codeWriter = createAgent({
  role: &quot;코드 작성자&quot;,
  systemPrompt: &quot;당신은 사용자의 요구사항에 맞는 코드를 작성하는 전문가입니다...&quot;
});

const codeReviewer = createAgent({
  role: &quot;코드 리뷰어&quot;,
  systemPrompt: &quot;당신은 코드의 품질과 보안을 검토하는 전문가입니다...&quot;
});

// 에이전트 간 협업 워크플로우 설정
setupWorkflow([
  codeWriter.writeInitialCode,
  codeReviewer.reviewCode,
  codeWriter.improveBadedOnFeedback,
  // ...
]);</code></pre>
<p>저희 팀에서도 글쓰기 워크플로우를 구현하기 위해 독자적으로 프레임워크를 만들어서 사용하고 있습니다. </p>
<h3 id="mcp의-역할과-한계">MCP의 역할과 한계</h3>
<p>이 모든 맥락에서 MCP의 역할은 무엇일까요? MCP는 <strong>외부 세계와 연결될 단일 인터페이스를 제공하는 것</strong>일 뿐입니다. MCP 자체가 멀티에이전트 시스템이나 에이전트 워크플로우를 구성하지는 않습니다.</p>
<p>각 MCP 호스트(Claude Desktop, Cursor, Contiue 등)는 자신만의 고유한 워크플로우를 개발하여 LLM의 능력을 최대한 끌어올립니다:</p>
<ul>
<li>동적으로 변하는 프롬프트를 사용할 수 있습니다.</li>
<li>에이전트를 여러 개로 나누어 협업하게 할 수 있습니다.</li>
<li>사용자와의 상호작용을 최적화할 수 있습니다.</li>
<li>도구 호출의 결과를 해석하고 다음 행동을 결정하는 방식을 개선할 수 있습니다.</li>
</ul>
<p>결론적으로, MCP는 매우 유용한 표준화 프로토콜이지만, 진정한 가치는 이를 활용하는 호스트 앱의 워크플로우 설계에 있습니다. LLM의 능력을 최대한 활용하려면, 단순히 도구를 연결하는 것을 넘어서 지능적인 워크플로우를 설계해야 합니다. 이것이 바로 호스트 앱이 전부인 이유입니다.</p>
<h2 id="mcp의-야망-프롬프트에서-워크플로우까지">MCP의 야망: 프롬프트에서 워크플로우까지</h2>
<p>지금까지 우리는 MCP가 <code>tools/list</code>와 <code>tools/call</code>을 통해 LLM에게 도구를 제공하는 방법에 대해 살펴봤습니다. 하지만 MCP의 야망은 이에 그치지 않습니다. 단순한 도구 호출을 넘어 더 복잡하고 정교한 워크플로우까지 MCP로 구현할 수 있는 가능성이 열리고 있습니다.</p>
<h3 id="📝-프롬프트도-제공합니다만-mcp의-특별한-스펙-공개">📝 &quot;프롬프트도 제공합니다만?&quot; MCP의 특별한 스펙 공개</h3>
<p>MCP 프로토콜을 자세히 들여다보면, 도구 호출만이 유일한 기능이 아님을 알 수 있습니다. 다양한 JSON-RPC 메서드가 정의되어 있는데, 그중에는 이런 것들도 있습니다:</p>
<pre><code>**프롬프트 관련**
* `prompts/get`: 특정 프롬프트 가져오기
* `prompts/list`: 사용 가능한 프롬프트 목록 조회

**리소스 관련**
* `resources/list`: 리소스 목록 조회
* `resources/templates/list`: 리소스 템플릿 목록 조회
* `resources/read`: 리소스 읽기
* `resources/subscribe`: 리소스 변경 구독
* `resources/unsubscribe`: 리소스 구독 취소</code></pre><p>눈여겨봐야 할 부분은 바로 <code>prompts/get</code>과 <code>prompts/list</code>입니다. 이는 <strong>MCP 서버가 단순히 도구만 제공하는 것이 아니라, 프롬프트도 제공할 수 있음</strong>을 의미합니다. MCP 서버에서 제공하는 프롬프트를 호스트 앱이 LLM에게 전달하는 것이죠.</p>
<p>더 놀라운 것은 <code>sampling/createMessage</code>와 같은 메서드의 존재입니다. 이 메서드를 통해 MCP 서버가 호스트 앱에게 LLM 생성을 요청할 수 있습니다:</p>
<pre><code class="language-json">{
  &quot;method&quot;: &quot;sampling/createMessage&quot;,
  &quot;params&quot;: {
    &quot;messages&quot;: [
      {
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: {
          &quot;type&quot;: &quot;text&quot;,
          &quot;text&quot;: &quot;What files are in the current directory?&quot;
        }
      }
    ],
    &quot;systemPrompt&quot;: &quot;You are a helpful file system assistant.&quot;,
    &quot;includeContext&quot;: &quot;thisServer&quot;,
    &quot;maxTokens&quot;: 100
  }
}</code></pre>
<p>이것은 무엇을 의미할까요? MCP 서버가 단순한 도구 제공자를 넘어서서, 적극적으로 대화에 참여하는 에이전트가 될 수 있다는 것입니다. 서버가 질문을 하고, 호스트는 LLM을 통해 답변을 제공하며, 필요하다면 사용자를 루프에 포함시킬 수도 있습니다(Human-in-the-loop).</p>
<h3 id="🤖-멀티에이전트-프레임워크가-mcp-서버로-이사-가는-중">🤖 멀티에이전트 프레임워크가 MCP 서버로 이사 가는 중</h3>
<p>이러한 기능들이 시사하는 바는 명확합니다. 복잡한 멀티에이전트 워크플로우를 구성하는 다양한 전략들이 이제 MCP 서버로 이동할 수 있습니다. 여러 MCP 서버들이 각자의 역할을 담당하고, <code>prompts/list</code>와 <code>prompts/get</code>으로 프롬프트를 제공하며, <code>sampling/createMessage</code>로 호스트에 LLM 생성을 요청하는 방식으로 워크플로우를 구성할 수 있습니다.</p>
<p>이런 접근 방식에서는:</p>
<ol>
<li>여러 MCP 서버가 각각 특정 역할을 담당합니다 (코드 작성자, 코드 리뷰어 등)</li>
<li>각 MCP 서버는 <code>prompts/list</code>와 <code>prompts/get</code>으로 자신의 역할에 맞는 프롬프트를 제공합니다</li>
<li>MCP 서버는 <code>sampling/createMessage</code>를 통해 호스트 앱에 LLM 생성을 요청합니다</li>
<li>호스트 앱은 사용자와의 상호작용을 관리하며 필요할 때 Human-in-the-loop를 구현합니다</li>
</ol>
<pre><code class="language-json">{
  &quot;method&quot;: &quot;sampling/createMessage&quot;,
  &quot;params&quot;: {
    &quot;messages&quot;: [
      {
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: {
          &quot;type&quot;: &quot;text&quot;,
          &quot;text&quot;: &quot;현재 디렉토리에 어떤 파일들이 있나요?&quot;
        }
      }
    ],
    &quot;systemPrompt&quot;: &quot;당신은 도움이 되는 파일 시스템 어시스턴트입니다.&quot;,
    &quot;includeContext&quot;: &quot;thisServer&quot;,
    &quot;maxTokens&quot;: 100
  }
}</code></pre>
<p>이 기능이 특별한 이유는 Human-in-the-loop 설계를 자연스럽게 지원하기 때문입니다. 샘플링 요청이 들어오면:</p>
<ol>
<li>호스트 앱은 사용자에게 &quot;코드리뷰 도구가 LLM에게 질문하려고 합니다. 허용하시겠습니까?&quot;와 같은 확인을 요청할 수 있습니다.</li>
<li>사용자는 제안된 프롬프트를 검토하고 수정하거나 거부할 수 있습니다.</li>
<li>생성된 응답도 사용자가 검토하고 승인한 후에 MCP 서버로 전달됩니다.</li>
</ol>
<p>이를 통해 사용자는 LLM이 무엇을 보고 생성하는지에 대한 통제권을 유지하면서도, MCP 서버가 복잡한 에이전트 행동을 수행할 수 있게 됩니다.</p>
<p>이처럼 멀티에이전트 워크플로우의 핵심 전략들을 MCP 서버로 옮기면, 동일한 워크플로우를 다양한 호스트 앱에서 재사용할 수 있게 됩니다.</p>
<h3 id="호스트-앱을-mcp로-싸서-드셔보세요">호스트 앱을 MCP로 싸서 드셔보세요</h3>
<p>더 나아가, 복잡한 워크플로우 자체를 MCP 서버로 패키징하여 공유 가능한 형태로 제공할 수도 있습니다. 기존에는 호스트 앱에서 구현해야 했던 워크플로우 로직을 MCP 서버로 옮겨서, 다른 호스트 앱들이 쉽게 이용할 수 있게 만드는 것입니다.</p>
<p>예를 들어, 앞서 살펴본 딥 리서치 워크플로우가 호스트 앱에 구현되어 있었다면, 이제는 이를 MCP 서버로 패키징할 수 있습니다:</p>
<p><img src="https://velog.velcdn.com/images/k-svelte-master/post/2737ed53-9ea9-41e3-9c26-26655529012c/image.png" alt="image-7"></p>
<p>이 구조에서 딥 리서치 MCP 서버는 복잡한 워크플로우 로직을 모두 내부적으로 처리하지만, 외부로는 단순한 <code>tools/list</code>와 <code>tools/call</code> 인터페이스만 노출합니다. 호스트 앱은 <code>deepResearch</code>라는 단일 도구만 보게 되며, 복잡한 내부 구현은 숨겨집니다. </p>
<p>실제로는 딥 리서치 서버 내부에서 다양한 방식으로 구현될 수 있습니다. 하드코딩된 로직일 수도 있고, MCP 프로토콜을 십분 활용한 정교한 구현일 수도 있습니다. 이 서버는 다음과 같은 복잡한 작업을 수행합니다:</p>
<ol>
<li>내부적으로 검색 MCP 서버에 도구 호출 요청</li>
<li>검색 결과 분석 및 후속 질문 생성</li>
<li>사용자 확인 프로세스 관리</li>
<li>여러 검색 결과 종합 및 최종 응답 생성</li>
</ol>
<p>하지만 호스트 앱의 관점에서는 그저 <code>deepResearch</code>라는 단일 도구만 호출할 뿐입니다. 이처럼 MCP를 통해 복잡한 워크플로우를 캡슐화하고 재사용 가능한 형태로 제공할 수 있습니다. 다른 개발자들은 내부 구현을 이해할 필요 없이 자신의 호스트 앱에서 이 기능을 쉽게 활용할 수 있게 됩니다.</p>
<h3 id="호스트는-가볍게-mcp는-다양하게">호스트는 가볍게, MCP는 다양하게</h3>
<p>이러한 접근 방식의 가치는 명확합니다. 개발자들은 자신의 입맛에 맞는 워크플로우를 MCP 서버로 공개하고, 다른 개발자들은 이를 조합하여 새로운 AI 경험을 만들 수 있습니다. 마치 맛집 레시피를 공유하듯, AI 워크플로우 레시피를 공유하는 생태계가 형성될 수 있는 것이죠.</p>
<p>예를 들어:</p>
<ul>
<li>웹 검색 + 코드 생성 워크플로우를 MCP 서버로 패키징</li>
<li>복잡한 데이터 분석 파이프라인을 MCP 서버로 구현</li>
<li>특정 도메인에 특화된 에이전트 협업 시스템을 MCP 서버로 제공</li>
</ul>
<p>이렇게 되면 AI 개발의 미래는 호스트 앱을 처음부터 개발하는 것이 아니라, 다양한 MCP 서버 조합을 찾는 것만으로 구성하는 방향으로 진화할 수 있습니다.</p>
<h2 id="mcp-단순한-프로토콜을-넘어서">MCP: 단순한 프로토콜을 넘어서</h2>
<p>지금까지 MCP(Model Context Protocol)의 현재를 살펴봤다면, 이제는 미래를 상상해 볼 차례입니다. AI 개발 패러다임이 어떻게 변화할지, MCP가 어떤 역할을 할지 생각해보면 흥미로운 가능성이 펼쳐집니다.</p>
<h3 id="🧩-ai-개발은-레고처럼-된다">🧩 AI 개발은 레고처럼 된다</h3>
<p>가장 먼저 떠오르는 미래는 &#39;AI 개발의 레고화&#39;입니다. 오픈소스 커뮤니티와 상업 업체들이 다양한 MCP 서버 블록을 만들기 시작하면 어떻게 될까요? 개발자들은 이 블록들을 조합하여 복잡한 AI 애플리케이션을 구축할 수 있을 것입니다.</p>
<p>예를 들어:</p>
<ul>
<li>웹 검색 MCP 서버</li>
<li>PDF 분석 MCP 서버 </li>
<li>코드 이해 및 생성 MCP 서버</li>
<li>데이터 시각화 MCP 서버</li>
</ul>
<p>이들을 조합하면 &quot;AI 연구 어시스턴트&quot;나 &quot;코드 리팩토링 도우미&quot; 같은 특화된 애플리케이션을 빠르게 만들 수 있습니다. 마치 레고 블록을 조립하듯, 필요한 MCP 서버들을 연결하기만 하면 되는 세상이 오는 것이죠.</p>
<h3 id="💡-ai-오케스트레이션의-시대">💡 AI 오케스트레이션의 시대</h3>
<p>두 번째로, AI 워크플로우 오케스트레이션이 새로운 경쟁의 장이 될 것입니다. 현재는 도구를 만들거나 호스트 앱을 개발하는 데 초점이 맞춰져 있지만, 미래에는 다양한 MCP 서버들을 조율하는 &#39;오케스트레이터&#39;가 중요해질 수 있습니다.</p>
<p>이러한 오케스트레이터는:</p>
<ul>
<li>상황에 맞는 최적의 MCP 서버 조합을 결정</li>
<li>서버 간 정보 흐름을 관리</li>
<li>사용자 개입이 필요한 시점을 판단</li>
<li>복잡한 멀티에이전트 협업을 조율</li>
</ul>
<p>언젠가는 &quot;이 오케스트레이터가 더 효율적인 워크플로우를 구성해요&quot;라는 경쟁이 일어날지도 모릅니다.</p>
<h3 id="🌐-mcp-서버-마켓플레이스">🌐 MCP 서버 마켓플레이스</h3>
<p>세 번째로, MCP 서버 마켓플레이스가 등장할 가능성이 큽니다. 마치 앱스토어처럼, 전문화된 MCP 서버를 제공하는 생태계가 형성될 수 있습니다. 법률, 의료, 금융 등 각 분야별로 특화된 MCP 서버가, 무료부터 구독형까지 다양한 모델로 제공될 것입니다.</p>
<p>이는 AI 개발의 민주화로 이어질 수 있습니다. 기술적 지식이 없는 사람들도 호스트 앱을 통해 필요한 MCP 서버를 구독하고 조합하여 자신만의 AI 워크플로우를 구성할 수 있게 될 테니까요.</p>
<h3 id="🔄-ai-앱이-없어지고-ai-워크플로우만-남는다">🔄 AI 앱이 없어지고, AI 워크플로우만 남는다</h3>
<p>네 번째로, 어쩌면 호스트 앱과 MCP 서버의 경계가 점점 흐려질 수도 있습니다. 사용자들은 특정 앱을 사용하는 것이 아니라, 필요에 따라 다양한 AI 워크플로우를 선택하게 될 수 있습니다.</p>
<p>&quot;오늘은 연구 논문을 읽어야 하니 학술 워크플로우를 활성화해야겠다&quot;
&quot;지금은 코딩 중이니 개발 워크플로우로 전환해야겠다&quot;</p>
<p>앱 단위가 아닌 &#39;워크플로우 단위&#39;로 AI 경험을 소비하는 세계가 오지 않을까요?</p>
<h3 id="💼-mcp의-가능성을-탐색한다면">💼 MCP의 가능성을 탐색한다면?</h3>
<p>MCP는 아직 발전 중인 기술이며, 앞으로 어떻게 진화할지는 커뮤니티와 개발자들의 손에 달려 있습니다. 현재 시점에서 MCP를 탐색한다면 고려해볼 만한 방향은 다음과 같습니다:</p>
<ol>
<li><p><strong>표준의 진화 지켜보기</strong>: MCP는 계속 발전하고 있습니다. 이 표준이 어떻게 진화하는지 지켜보는 것은 AI 개발의 미래 방향을 이해하는 데 도움이 될 수 있습니다.</p>
</li>
<li><p><strong>모듈성 실험하기</strong>: 솔루션을 설계할 때 모듈화를 고려해보는 것은 언제나 좋은 접근법입니다. 어떤 부분이 독립적인 MCP 서버로 분리될 수 있을지 실험해볼 가치가 있습니다.</p>
</li>
<li><p><strong>커뮤니티 참여하기</strong>: MCP와 관련된 오픈소스 프로젝트와 커뮤니티에 참여하면 다양한 아이디어와 사용 사례를 접할 수 있습니다.</p>
</li>
</ol>
<p>MCP는 단순한 프로토콜이 아닐 수 있습니다. 이는 AI 개발의 새로운 패러다임, 더 모듈화되고, 더 협업적이며, 더 접근하기 쉬운 AI 생태계를 향한 한 걸음일지도 모릅니다.</p>
]]></description>
        </item>
    </channel>
</rss>