<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://tech.socarcorp.kr/feed.xml" rel="self" type="application/atom+xml" /><link href="https://tech.socarcorp.kr/" rel="alternate" type="text/html" /><updated>2026-03-23T06:37:29+00:00</updated><id>https://tech.socarcorp.kr/feed.xml</id><title type="html">SOCAR Tech Blog</title><subtitle>쏘카 기술 블로그</subtitle><author><name>SOCAR</name></author><entry><title type="html">쏘카 디자인 시스템 2.0 개발기 2편: 기술로 굴리기(웹)</title><link href="https://tech.socarcorp.kr/fe/2026/02/24/socar-frame2-web-part2.html" rel="alternate" type="text/html" title="쏘카 디자인 시스템 2.0 개발기 2편: 기술로 굴리기(웹)" /><published>2026-02-24T15:00:00+00:00</published><updated>2026-02-24T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/fe/2026/02/24/socar-frame2-web-part2</id><content type="html" xml:base="https://tech.socarcorp.kr/fe/2026/02/24/socar-frame2-web-part2.html"><![CDATA[<p><br /></p>

<h1 id="목차">목차</h1>

<ol>
  <li><a href="#1-개요">개요</a></li>
  <li><a href="#2-컴포넌트-아키텍처">컴포넌트 아키텍처</a>
    <ol>
      <li><a href="#21-설계-목적">설계 목적</a></li>
      <li><a href="#22-Hook과-객체">Hook과 객체</a></li>
      <li><a href="#23-합성-컴포넌트와-명령형-API">합성 컴포넌트와 명령형 API</a></li>
    </ol>
  </li>
  <li><a href="#3-패키지-전략">패키지 전략</a>
    <ol>
      <li><a href="#31-트리쉐이킹">트리쉐이킹</a></li>
    </ol>
  </li>
  <li><a href="#4-AI-활용">AI 활용</a>
    <ol>
      <li><a href="#41-Instructions">Instructions</a></li>
      <li><a href="#42-Figma-MCP-with-LLM">Figma MCP with LLM</a></li>
    </ol>
  </li>
  <li><a href="#5-후기">후기</a></li>
  <li><a href="#6-공식문서">공식문서</a></li>
</ol>

<hr />

<p><br /><br /></p>

<h1 id="1-개요">1. 개요</h1>

<p>안녕하세요 쏘카 개발자 아놀드입니다.</p>

<p>이 글은 <strong>쏘카 디자인 시스템 2.0</strong> 개발기 2편으로, <strong>컴포넌트 아키텍처/패키지 전략/LLM(Large Language Model) 활용</strong>에 대해 작성하였습니다.</p>

<p>1편에서는 시스템 설계, <code class="language-plaintext highlighter-rouge">운영 프로세스</code>, <code class="language-plaintext highlighter-rouge">Figma 연동</code> 를 다뤘고, 이번 글에서는 <code class="language-plaintext highlighter-rouge">기술적 선택</code>과 <code class="language-plaintext highlighter-rouge">구현 경험</code>을 중심으로 정리했습니다. 2편은 “디자인 시스템을 코드로 구현했다”를 넘어, 서비스 환경(Next.js 13~15 혼재, SSR/CSR, 번들러 차이, 팀별 커스텀 요구)에서도 무너지지 않게 만들기 위해 노력한 <strong>구조적 선택의 기록</strong>을 다뤄보았습니다.</p>

<ul>
  <li>컴포넌트는 UI 결합도에 따라 Hook / 객체로 분리했고</li>
  <li>패키지는 소비 환경의 트리쉐이킹을 기준으로 번들 구조를 설계했으며</li>
  <li>LLM 활용은 ‘프롬프트’가 아니라 <strong>지시(Instructions) 고정</strong>으로 접근했습니다.</li>
</ul>

<h1 id="2-컴포넌트-아키텍처">2. 컴포넌트 아키텍처</h1>

<h2 id="21-설계-목적">2.1 설계 목적</h2>

<p>컴포넌트 설계에서 가장 중요하게 본 건 재사용성과 독립성이었습니다.</p>

<p>과거의 경험을 바탕으로 UI는 계속 바뀌는데 그때마다 내부 로직까지 흔들리면 유지보수 비용이 너무 커지기 때문에, 최대한 UI와 로직을 분리하는 구조를 고민했습니다.</p>

<p>이 과정에서 오픈소스 라이브러리들의 설계를 많이 참고하며 실제 서비스 운영과 유지보수에 유리한 구조를 기준으로 선택했습니다.</p>

<h2 id="22-hook과-객체">2.2 Hook과 객체</h2>

<p>구체적인 방향은 <strong>UI와 상태가 강하게 얽히는 컴포넌트</strong>와 <strong>그렇지 않은 컴포넌트</strong>를 나누는 것이었습니다.</p>

<p>기존 컴포넌트들이 재활용되기 어려웠던 가장 큰 이유가, UI 정책과 비즈니스 로직이 섞여 있던 경험이었기 때문입니다.</p>

<p>UI/UX 정책과 비즈니스 로직을 분리하지 않으면, 동일 정책의 UI를 서비스별로 다시 만들게 됩니다.
과거에 그런 경험이 있었기 때문에 구조를 먼저 정리하려 했습니다.</p>

<p>그래서 <code class="language-plaintext highlighter-rouge">“UI가 꼭 가져야 하는 상태인가, 아니면 정책 로직으로 분리 가능한가”</code>를 기준으로 구조를 나눴습니다.</p>

<h3 id="결정-규칙">결정 규칙</h3>

<ul>
  <li>상태 전이가 곧 애니메이션/제스처 제어라면 → <code class="language-plaintext highlighter-rouge">Hook 중심</code></li>
  <li>정책 로직이 UI보다 복잡하고 테스트가 중요하다면 → <code class="language-plaintext highlighter-rouge">객체(core) + Hook Adapter</code></li>
</ul>

<p>강결합되는 컴포넌트는 Hook 중심으로 설계했고, 그렇지 않은 컴포넌트는 로직을 객체로 분리한 뒤 Hook을 어댑터로 두는 방식으로 UI를 분리했습니다.</p>

<p>UI와 상태가 강하게 엮이는 대표 사례가 BottomSheet와 Accordion입니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/bottomsheet-ui.png" alt="BottomSheet 컴포넌트의 상태별 UI" /></p>

<p>BottomSheet는 <code class="language-plaintext highlighter-rouge">드래그/스냅 포인트/바운드/스크롤 잠금</code> 같은 상호작용이 UI와 분리되기 어렵고, 상태 전이가 곧 UI 애니메이션이라 Hook 중심 구조가 자연스럽다고 봤습니다.</p>

<p>따라서, 설계 단계에서 디자이너/네이티브와 충분히 논의한 끝에 상태(hidden, tip, half, max)를 정의했습니다.</p>

<p>아래 일부 발췌된 코드를 참고하면 상태가 UI에 강하게 얽혀있다는 것이 어떤 의미인지 파악할 수 있습니다.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// BottomSheet의 상태에 해당하는 case가 innerHeight와 얽혀있는 모습</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">resolveTargetY</span> <span class="o">=</span> <span class="p">({</span>
  <span class="nx">half</span><span class="p">,</span>
  <span class="nx">max</span><span class="p">,</span>
  <span class="nx">state</span><span class="p">,</span>
  <span class="nx">tip</span><span class="p">,</span>
<span class="p">}:</span> <span class="p">{</span>
  <span class="nl">half</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
  <span class="nl">max</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
  <span class="nl">state</span><span class="p">:</span> <span class="nx">BottomSheetState</span><span class="p">;</span>
  <span class="nl">tip</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
<span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">switch </span><span class="p">(</span><span class="nx">state</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">max</span><span class="dl">"</span><span class="p">:</span>
      <span class="k">return</span> <span class="nb">window</span><span class="p">.</span><span class="nx">innerHeight</span> <span class="o">-</span> <span class="nx">max</span><span class="p">;</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">tip</span><span class="dl">"</span><span class="p">:</span>
      <span class="k">return</span> <span class="nb">window</span><span class="p">.</span><span class="nx">innerHeight</span> <span class="o">-</span> <span class="nx">tip</span><span class="p">;</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">half</span><span class="dl">"</span><span class="p">:</span>
      <span class="k">return</span> <span class="nb">window</span><span class="p">.</span><span class="nx">innerHeight</span> <span class="o">-</span> <span class="nx">half</span><span class="p">;</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">hidden</span><span class="dl">"</span><span class="p">:</span>
    <span class="na">default</span><span class="p">:</span>
      <span class="k">return</span> <span class="nb">window</span><span class="p">.</span><span class="nx">innerHeight</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">};</span>
<span class="c1">// 생략..</span>

<span class="c1">//이 상태를 활용하여 animate를 시키는 메서드</span>
<span class="kd">const</span> <span class="nx">animateToState</span> <span class="o">=</span> <span class="p">(</span><span class="nx">state</span><span class="p">:</span> <span class="nx">BottomSheetState</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">normalized</span> <span class="o">=</span> <span class="nf">normalizeState</span><span class="p">(</span><span class="nx">state</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">targetY</span> <span class="o">=</span> <span class="nf">resolveTargetY</span><span class="p">({</span>
    <span class="nx">half</span><span class="p">,</span>
    <span class="nx">max</span><span class="p">,</span>
    <span class="na">state</span><span class="p">:</span> <span class="nx">normalized</span><span class="p">,</span>
    <span class="nx">tip</span><span class="p">,</span>
  <span class="p">});</span>
  <span class="nf">animate</span><span class="p">(</span><span class="nx">bottomSheetY</span><span class="p">,</span> <span class="nx">targetY</span><span class="p">,</span> <span class="nx">SPRING_TRANSITION</span><span class="p">);</span>
<span class="p">};</span>
<span class="c1">// 생략..</span>

<span class="c1">// 기타 복잡한 제어들이 상태에 연결될 수 밖에 없는 형태</span>
<span class="kd">const</span> <span class="nx">setState</span> <span class="o">=</span> <span class="p">(</span><span class="nx">next</span><span class="p">:</span> <span class="nx">BottomSheetState</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">normalizedNext</span> <span class="o">=</span> <span class="nf">normalizeState</span><span class="p">(</span><span class="nx">next</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">current</span> <span class="o">=</span> <span class="nx">activeStateRef</span><span class="p">.</span><span class="nx">current</span><span class="p">;</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">current</span> <span class="o">===</span> <span class="nx">normalizedNext</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">animateToState</span><span class="p">(</span><span class="nx">normalizedNext</span><span class="p">);</span>
    <span class="k">return</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="nx">activeStateRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="nx">normalizedNext</span><span class="p">;</span>

  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">isControlled</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">setInternalState</span><span class="p">(</span><span class="nx">normalizedNext</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="nf">animateToState</span><span class="p">(</span><span class="nx">normalizedNext</span><span class="p">);</span>
  <span class="nx">onStateChange</span><span class="p">?.(</span><span class="nx">normalizedNext</span><span class="p">);</span>
<span class="p">};</span>
</code></pre></div></div>

<p>더 많은 요구사항이 있을 경우 상태머신이나 아래 다른 예제와 같이 별도 객체로 분리하는 것을 고려해볼 수 있었으나, 현재 단계에서는 Hook으로 충분하다고 보았고 이미 서비스들에서 요구하는 많은 케이스들을 감당할 만한 정책이라고 결론냈습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/bottomsheet-hooks.png" alt="BottomSheet Hook 구성 흐름" /></p>

<p>전체 구조는 사진과 같이 <code class="language-plaintext highlighter-rouge">useBottomSheet.tsx</code>에서 다양한 Hook을 orchestration하여 활용하였습니다.</p>

<p>반대로 DatePicker나 Pattern(Carousel)은 UI보다 정책 로직이 훨씬 복잡하다고 봤습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/datepicker-ui.png" alt="DatePicker 사용자 노출 UI" /></p>

<p>예를 들어 DatePicker는 사용자에게는 start/end 선택 UI만 보입니다. 하지만 내부적으로는 날짜 계산, 라벨 처리, 비활성 정책 등 UI와 직접 연결되지 않는 로직이 많았습니다.</p>

<p>그래서 이런 로직은 아래와 같이 객체로 분리하였습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/datepicker-architecture.png" alt="DatePicker의 로직 분리 아키텍처" /></p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// core/DateManager.ts</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nc">DateManager</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">(</span><span class="nx">today</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">(),</span> <span class="nx">options</span><span class="p">:</span> <span class="nx">DateManagerOptions</span> <span class="o">=</span> <span class="p">{})</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">labelService</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">LabelService</span><span class="p">({</span>
      <span class="na">generateId</span><span class="p">:</span> <span class="p">(</span><span class="nx">date</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">generateId</span><span class="p">(</span><span class="nx">date</span><span class="p">),</span>
      <span class="na">getHolidays</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">holidays</span><span class="p">,</span>
      <span class="na">getToday</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">today</span><span class="p">,</span>
      <span class="na">isSameDate</span><span class="p">:</span> <span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">isSameDate</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">),</span>
    <span class="p">});</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">disablePolicy</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">DisablePolicy</span><span class="p">({</span>
      <span class="na">compareDates</span><span class="p">:</span> <span class="p">(</span><span class="nx">d1</span><span class="p">,</span> <span class="nx">d2</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">compareDates</span><span class="p">(</span><span class="nx">d1</span><span class="p">,</span> <span class="nx">d2</span><span class="p">),</span>
      <span class="na">getDisablePast</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">disablePast</span><span class="p">,</span>
      <span class="na">getHolidays</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">holidays</span><span class="p">,</span>
      <span class="na">getManualDisabledDates</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">manualDisabledDates</span><span class="p">,</span>
      <span class="na">getSelectableEnd</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">selectableEnd</span><span class="p">,</span>
      <span class="na">getSelectableStart</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">selectableStart</span><span class="p">,</span>
      <span class="na">getToday</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">today</span><span class="p">,</span>
      <span class="na">isSameDate</span><span class="p">:</span> <span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">isSameDate</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">),</span>
    <span class="p">});</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">gridBuilder</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CalendarGridBuilder</span><span class="p">({</span>
      <span class="na">compareDates</span><span class="p">:</span> <span class="p">(</span><span class="nx">d1</span><span class="p">,</span> <span class="nx">d2</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">compareDates</span><span class="p">(</span><span class="nx">d1</span><span class="p">,</span> <span class="nx">d2</span><span class="p">),</span>
      <span class="na">disablePolicy</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">disablePolicy</span><span class="p">,</span>
      <span class="na">labelService</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">labelService</span><span class="p">,</span>
      <span class="na">todayProvider</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">today</span><span class="p">,</span>
    <span class="p">});</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">selectionService</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">SelectionService</span><span class="p">({</span>
      <span class="na">applyMaxRangeDays</span><span class="p">:</span> <span class="p">(</span><span class="nx">target</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">applyMaxRangeDays</span><span class="p">(</span><span class="nx">target</span><span class="p">),</span>
      <span class="na">compareDates</span><span class="p">:</span> <span class="p">(</span><span class="nx">d1</span><span class="p">,</span> <span class="nx">d2</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">compareDates</span><span class="p">(</span><span class="nx">d1</span><span class="p">,</span> <span class="nx">d2</span><span class="p">),</span>
      <span class="na">disablePolicy</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">disablePolicy</span><span class="p">,</span>
      <span class="na">emitChange</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">emitChange</span><span class="p">(),</span>
      <span class="na">generateId</span><span class="p">:</span> <span class="p">(</span><span class="nx">date</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">generateId</span><span class="p">(</span><span class="nx">date</span><span class="p">),</span>
      <span class="na">getDateMap</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">dateMap</span><span class="p">,</span>
      <span class="na">getNodeById</span><span class="p">:</span> <span class="p">(</span><span class="nx">id</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">dateMap</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="nx">id</span><span class="p">),</span>
      <span class="na">isFlushApply</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">isFlushApply</span><span class="p">,</span>
      <span class="na">labelService</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">labelService</span><span class="p">,</span>
      <span class="na">resetAllSelections</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">resetAllSelections</span><span class="p">(),</span>
      <span class="na">setFlushApply</span><span class="p">:</span> <span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">this</span><span class="p">.</span><span class="nx">isFlushApply</span> <span class="o">=</span> <span class="nx">value</span><span class="p">;</span>
      <span class="p">},</span>
      <span class="na">updateDateObject</span><span class="p">:</span> <span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">updates</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">updateDateObject</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">updates</span><span class="p">),</span>
      <span class="na">withDuplicate</span><span class="p">:</span> <span class="nx">options</span><span class="p">.</span><span class="nx">withDuplicate</span> <span class="o">??</span> <span class="kc">false</span><span class="p">,</span>
    <span class="p">});</span>
  <span class="p">}</span>

  <span class="c1">// 생략...</span>

  <span class="nf">select</span><span class="p">(</span><span class="nx">date</span><span class="p">:</span> <span class="nb">Date</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">selectionService</span><span class="p">?.</span><span class="nf">select</span><span class="p">(</span><span class="nx">date</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>중간에 Hook으로 Adapter를 두어 React의 라이프사이클을 안정적으로 따르게 하였습니다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// hooks/useDateManager.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">useDateManager</span> <span class="o">=</span> <span class="p">(</span>
  <span class="nx">manager</span><span class="p">:</span> <span class="nx">DateManager</span><span class="p">,</span>
  <span class="nx">initialSetting</span><span class="p">?:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">,</span>
<span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">calendarMap</span><span class="p">,</span> <span class="nx">setCalendarMap</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="o">&lt;</span><span class="nx">CalendarMap</span> <span class="o">|</span> <span class="kc">null</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>

  <span class="nf">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nf">setCalendarMap</span><span class="p">(</span>
      <span class="k">new</span> <span class="nc">Map</span><span class="p">(</span><span class="nx">manager</span><span class="p">.</span><span class="nf">getDateGridFromMap</span><span class="p">(</span><span class="nx">manager</span><span class="p">.</span><span class="nx">dateMap</span><span class="p">,</span> <span class="nx">manager</span><span class="p">.</span><span class="nx">dateRecords</span><span class="p">)),</span>
    <span class="p">);</span>

    <span class="kd">const</span> <span class="nx">unsubscribe</span> <span class="o">=</span> <span class="nx">manager</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nf">setCalendarMap</span><span class="p">(</span>
        <span class="k">new</span> <span class="nc">Map</span><span class="p">(</span>
          <span class="nx">manager</span><span class="p">.</span><span class="nf">getDateGridFromMap</span><span class="p">(</span><span class="nx">manager</span><span class="p">.</span><span class="nx">dateMap</span><span class="p">,</span> <span class="nx">manager</span><span class="p">.</span><span class="nx">dateRecords</span><span class="p">),</span>
        <span class="p">),</span>
      <span class="p">);</span>
    <span class="p">});</span>

    <span class="nx">initialSetting</span><span class="p">?.();</span>
    <span class="k">return </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nf">unsubscribe</span><span class="p">();</span>
  <span class="p">},</span> <span class="p">[</span><span class="nx">manager</span><span class="p">]);</span>

  <span class="kd">const</span> <span class="nx">handleSelect</span> <span class="o">=</span> <span class="p">(</span><span class="na">date</span><span class="p">:</span> <span class="nb">Date</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">manager</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="nx">date</span><span class="p">);</span>

  <span class="k">return</span> <span class="p">{</span> <span class="nx">calendarMap</span><span class="p">,</span> <span class="nx">handleSelect</span> <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<p>그리고 그 결과를 UI로 emit하는 역할만 하도록 구성했습니다.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DatePicker 사용부 (stories 예시)</span>
<span class="kd">const</span> <span class="nx">managerRef</span> <span class="o">=</span> <span class="nf">useRef</span><span class="p">(</span><span class="k">new</span> <span class="nc">DateManager</span><span class="p">(</span><span class="kc">undefined</span><span class="p">,</span> <span class="p">{</span> <span class="na">withDuplicate</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}));</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">calendarMap</span><span class="p">,</span> <span class="nx">handleSelect</span> <span class="p">}</span> <span class="o">=</span> <span class="nf">useDateManager</span><span class="p">(</span><span class="nx">managerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">managerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">setMonthCount</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
  <span class="nx">managerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">setCustomLabel</span><span class="p">([</span>
    <span class="p">{</span> <span class="na">date</span><span class="p">:</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">(</span><span class="dl">"</span><span class="s2">2025-12-25</span><span class="dl">"</span><span class="p">),</span> <span class="na">labelConfig</span><span class="p">:</span> <span class="p">{</span> <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">christmas</span><span class="dl">"</span> <span class="p">}</span> <span class="p">},</span>
  <span class="p">]);</span>
<span class="p">});</span>

<span class="k">return </span><span class="p">(</span>
  <span class="p">&lt;&gt;</span>
    <span class="si">{</span><span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">calendarMap</span><span class="p">?.</span><span class="nf">entries</span><span class="p">()</span> <span class="o">??</span> <span class="p">[]).</span><span class="nf">map</span><span class="p">(([</span><span class="nx">key</span><span class="p">,</span> <span class="nx">weeks</span><span class="p">])</span> <span class="o">=&gt;</span> <span class="p">(</span>
      <span class="p">&lt;</span><span class="nc">WeekGroup</span> <span class="na">key</span><span class="p">=</span><span class="si">{</span><span class="nx">key</span><span class="si">}</span> <span class="na">weeks</span><span class="p">=</span><span class="si">{</span><span class="nx">weeks</span><span class="si">}</span> <span class="na">handleSelect</span><span class="p">=</span><span class="si">{</span><span class="nx">handleSelect</span><span class="si">}</span> <span class="p">/&gt;</span>
    <span class="p">))</span><span class="si">}</span>
  <span class="p">&lt;/&gt;</span>
<span class="p">);</span>
</code></pre></div></div>

<p>물론 이런 구조는 러닝커브와 복잡도를 높이는 단점이 있습니다.</p>

<p>하지만 테스트 가능성과 정책 재사용성 측면에서는 그 비용을 상쇄할 수 있다고 봤고, 실제로 유지보수 관점에서도 더 안정적인 구조가 된다고 느꼈습니다.</p>

<h2 id="23-합성-컴포넌트와-명령형-api">2.3 합성 컴포넌트와 명령형 API</h2>

<h3 id="합성형-컴포넌트">합성형 컴포넌트</h3>

<p>많은 UI 라이브러리에서 사용하는 JSX 합성 패턴을 그대로 가져가면서, 최대한 자율적으로 조합할 수 있게 했습니다.
특히 data attribute를 적절히 배치해 pseudo class로 커스텀 스타일링이 가능하도록 설계했습니다.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">Tab</span>
  <span class="na">className</span><span class="p">=</span><span class="err">"</span>
    <span class="err">[&amp;</span><span class="na">_</span><span class="err">[</span><span class="na">data-slot</span><span class="p">=</span><span class="na">tab-container</span><span class="err">]]:</span><span class="na">tw-bg-gray-50</span>
    <span class="err">[&amp;</span><span class="na">_</span><span class="err">[</span><span class="na">data-slot</span><span class="p">=</span><span class="na">tab-indicator</span><span class="err">]]:</span><span class="na">tw-bg-red-500</span>
    <span class="err">[&amp;</span><span class="na">_</span><span class="err">[</span><span class="na">data-slot</span><span class="p">=</span><span class="na">tabs-content-slide</span><span class="err">]]:</span><span class="na">tw-rounded-radius-300</span>
  <span class="err">"</span>
<span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">Header</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">HeaderItem</span><span class="p">&gt;</span>택시<span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">HeaderItem</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">HeaderItem</span><span class="p">&gt;</span>카페<span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">HeaderItem</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">Header</span><span class="p">&gt;</span>

  <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">Indicator</span> <span class="p">/&gt;</span>

  <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">Content</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">ContentItem</span><span class="p">&gt;</span>콘텐츠 A<span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">ContentItem</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">ContentItem</span><span class="p">&gt;</span>콘텐츠 B<span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">ContentItem</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">.</span><span class="nc">Content</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nc">Tab</span><span class="p">&gt;</span>
</code></pre></div></div>

<p>라이브러리 1차 개발이 끝나기도 전에 몇몇 서비스에서 선반영이 진행됐기 때문에, 중간중간 패치로 대응하기보다는 개발자가 스스로 조정할 수 있는 여지를 넓혀주는 쪽이 더 적합하다고 봤습니다.서비스팀이 안전하게 커스텀할 수 있도록 확장 지점을 data-attribute로 지원하며 스타일링 접근을 권장했습니다</p>

<h3 id="명령형-api">명령형 API</h3>

<p>Alert는 상태나 단계에 따라 다른 UI/로직이 필요한 경우가 많았습니다.
단계가 늘어날수록 상태 분기와 콜백 흐름이 꼬이고, 코드만 봐서는 전체 흐름을 파악하기 어려워지는 문제가 있었기 때문입니다.</p>

<p>그래서 Alert.open을 통해 Alert를 열고, 버튼 클릭 결과를 Promise로 받는 패턴을 제공했습니다.
이렇게 하면 UI 내부에서 상태를 계속 갱신하기보다, 호출부에서 결과에 따라 분기하는 흐름을 만들 수 있습니다.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span>
  <span class="nx">ActionButton</span><span class="p">,</span>
  <span class="nx">Alert</span><span class="p">,</span>
  <span class="nx">IconExclamationCircleFill</span><span class="p">,</span>
  <span class="kd">type</span> <span class="nx">OnAction</span><span class="p">,</span>
<span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@socar-inc/socar-frame-components</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">BasicAlert</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">onAction</span> <span class="p">}:</span> <span class="p">{</span> <span class="nl">onAction</span><span class="p">:</span> <span class="nx">OnAction</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">return </span><span class="p">(</span>
    <span class="p">&lt;</span><span class="nc">Alert</span> <span class="na">withDim</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">GraphicSlot</span> <span class="na">graphicHeight</span><span class="p">=</span><span class="si">{</span><span class="mi">120</span><span class="si">}</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="nc">IconExclamationCircleFill</span>
          <span class="na">className</span><span class="p">=</span><span class="s">"tw-fill-status-caution-regular"</span>
          <span class="na">size</span><span class="p">=</span><span class="si">{</span><span class="mi">48</span><span class="si">}</span>
        <span class="p">/&gt;</span>
      <span class="p">&lt;/</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">GraphicSlot</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">Title</span><span class="p">&gt;</span>알럿 타이틀<span class="p">&lt;/</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">Title</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">Body</span><span class="p">&gt;</span>상세 설명이 들어갑니다.<span class="p">&lt;/</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">Body</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">LinkButton</span>
        <span class="na">label</span><span class="p">=</span><span class="s">"버튼명"</span>
        <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nf">onAction</span><span class="p">(</span><span class="dl">"</span><span class="s2">link</span><span class="dl">"</span><span class="p">)</span><span class="si">}</span>
        <span class="na">size</span><span class="p">=</span><span class="s">"small"</span>
        <span class="na">type</span><span class="p">=</span><span class="s">"button"</span>
        <span class="na">underline</span>
        <span class="na">variant</span><span class="p">=</span><span class="s">"primary"</span>
      <span class="p">/&gt;</span>
      <span class="p">&lt;</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">ButtonSlot</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="nc">ActionButton</span>
          <span class="na">className</span><span class="p">=</span><span class="s">"tw-w-full"</span>
          <span class="na">label</span><span class="p">=</span><span class="s">"확인"</span>
          <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nf">onAction</span><span class="p">(</span><span class="dl">"</span><span class="s2">confirm</span><span class="dl">"</span><span class="p">)</span><span class="si">}</span>
          <span class="na">size</span><span class="p">=</span><span class="s">"medium"</span>
          <span class="na">type</span><span class="p">=</span><span class="s">"button"</span>
          <span class="na">variant</span><span class="p">=</span><span class="s">"primary"</span>
        <span class="p">/&gt;</span>
        <span class="p">&lt;</span><span class="nc">ActionButton</span>
          <span class="na">className</span><span class="p">=</span><span class="s">"tw-w-full"</span>
          <span class="na">label</span><span class="p">=</span><span class="s">"취소"</span>
          <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nf">onAction</span><span class="p">(</span><span class="dl">"</span><span class="s2">cancel</span><span class="dl">"</span><span class="p">)</span><span class="si">}</span>
          <span class="na">size</span><span class="p">=</span><span class="s">"medium"</span>
          <span class="na">type</span><span class="p">=</span><span class="s">"button"</span>
          <span class="na">variant</span><span class="p">=</span><span class="s">"secondary"</span>
        <span class="p">/&gt;</span>
      <span class="p">&lt;/</span><span class="nc">Alert</span><span class="p">.</span><span class="nc">ButtonSlot</span><span class="p">&gt;</span>
    <span class="p">&lt;/</span><span class="nc">Alert</span><span class="p">&gt;</span>
  <span class="p">);</span>
<span class="p">};</span>

<span class="kd">const</span> <span class="nx">handleConfirm</span> <span class="o">=</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">확인 클릭됨</span><span class="dl">"</span><span class="p">);</span>
<span class="p">};</span>

<span class="kd">const</span> <span class="nx">handleCancel</span> <span class="o">=</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">취소 클릭됨</span><span class="dl">"</span><span class="p">);</span>
<span class="p">};</span>

<span class="kd">const</span> <span class="nx">handleLink</span> <span class="o">=</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">링크 클릭됨</span><span class="dl">"</span><span class="p">);</span>
<span class="p">};</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">DefaultAlertExample</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">handleClick</span> <span class="o">=</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Alert</span><span class="p">.</span><span class="nf">open</span><span class="p">((</span><span class="nx">onAction</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">(</span>
      <span class="p">&lt;</span><span class="nc">BasicAlert</span> <span class="na">onAction</span><span class="p">=</span><span class="si">{</span><span class="nx">onAction</span><span class="si">}</span> <span class="p">/&gt;</span>
    <span class="p">));</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">result</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">confirm</span><span class="dl">"</span><span class="p">)</span> <span class="k">await</span> <span class="nf">handleConfirm</span><span class="p">();</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">result</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">cancel</span><span class="dl">"</span><span class="p">)</span> <span class="k">await</span> <span class="nf">handleCancel</span><span class="p">();</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">result</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">link</span><span class="dl">"</span><span class="p">)</span> <span class="k">await</span> <span class="nf">handleLink</span><span class="p">();</span>
  <span class="p">};</span>

  <span class="k">return </span><span class="p">(</span>
    <span class="p">&lt;&gt;</span>
      <span class="p">&lt;</span><span class="nc">ActionButton</span>
        <span class="na">label</span><span class="p">=</span><span class="s">"Default Alert 열기"</span>
        <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="nx">handleClick</span><span class="si">}</span>
        <span class="na">size</span><span class="p">=</span><span class="s">"medium"</span>
        <span class="na">type</span><span class="p">=</span><span class="s">"button"</span>
        <span class="na">variant</span><span class="p">=</span><span class="s">"primary"</span>
      <span class="p">/&gt;</span>
    <span class="p">&lt;/&gt;</span>
  <span class="p">);</span>
<span class="p">};</span>
</code></pre></div></div>

<p>물론 단계가 많아지면 여전히 콜백/분기 코드가 길어질 수 있습니다.</p>

<p>하지만 상태를 계속 바꾸면서 UI를 조작하는 방식보다, UI를 열고 결과에 따라 처리하는 흐름이 더 명확하다고 느꼈습니다.</p>

<h1 id="3-패키지-전략">3. 패키지 전략</h1>

<h2 id="31-트리쉐이킹">3.1 트리쉐이킹</h2>

<p>다른 UI 라이브러리들과 유사하게 <strong>데드 코드를 제거</strong>할 수 있는 환경을 제공하였습니다.</p>

<p>초기에는 <a href="https://tsup.egoist.dev">tsup</a> 기반 번들링으로 개발했는데, 모듈 구조가 유지되지 않아 트리쉐이킹에 불리한 점이 있었습니다.</p>

<p>그래서 components 패키지는 <a href="https://rollupjs.org">rollup</a>으로 전환했고, preserveModules / preserveModulesRoot를 통해 서비스 번들러가 트리쉐이킹을 위한 폴더 Graph 생성에 유리한 구조로 변경하였습니다.</p>

<p>rollup으로 변경한 또 다른 이유는 SSR(Server-Side Rendering)에 대한 대응이 있었습니다.</p>

<p>쏘카는 현재 <a href="https://nextjs.org/docs">Next.js</a> 13~15 버전들이 혼재되어 서비스에서 활용되고 있었고, 13버전의 기본 CSR(Client-Side Rendering) 지원 형태와 다르게 14~15로 변경되며 <code class="language-plaintext highlighter-rouge">use client</code>의 상단부 선언에 대한 니즈가 발생하였습니다.</p>

<p>이 부분을 tsup으로 처리하는 것보다 rollup의 banner 옵션으로 각 출력 모듈 파일 상단에 ‘use client’를 주입하는 방식이 훨씬 단순하다고 봤습니다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>
<span class="kd">const</span> <span class="nx">outputBase</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">dir</span><span class="p">:</span> <span class="dl">'</span><span class="s1">dist/src</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">preserveModules</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">preserveModulesRoot</span><span class="p">:</span> <span class="dl">'</span><span class="s1">src</span><span class="dl">'</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">...</span>

<span class="k">export</span> <span class="k">default</span> <span class="nf">defineConfig</span><span class="p">([</span>
  <span class="p">{</span>
    <span class="nx">external</span><span class="p">,</span>
    <span class="nx">input</span><span class="p">,</span>
    <span class="na">output</span><span class="p">:</span> <span class="p">[</span>
      <span class="p">{</span>
        <span class="p">...</span><span class="nx">outputBase</span><span class="p">,</span>
        <span class="na">banner</span><span class="p">:</span> <span class="nx">useClientBanner</span><span class="p">,</span>
        <span class="na">entryFileNames</span><span class="p">:</span> <span class="dl">'</span><span class="s1">[name].js</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">format</span><span class="p">:</span> <span class="dl">'</span><span class="s1">esm</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">},</span>
      <span class="p">{</span>
        <span class="p">...</span><span class="nx">outputBase</span><span class="p">,</span>
        <span class="na">banner</span><span class="p">:</span> <span class="nx">useClientBanner</span><span class="p">,</span>
        <span class="na">entryFileNames</span><span class="p">:</span> <span class="dl">'</span><span class="s1">[name].cjs</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">exports</span><span class="p">:</span> <span class="dl">'</span><span class="s1">named</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">format</span><span class="p">:</span> <span class="dl">'</span><span class="s1">cjs</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">},</span>
    <span class="p">],</span>
    <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span>
      <span class="nf">esbuild</span><span class="p">({</span>
        <span class="na">jsx</span><span class="p">:</span> <span class="dl">'</span><span class="s1">automatic</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">minify</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
        <span class="na">target</span><span class="p">:</span> <span class="dl">'</span><span class="s1">es2018</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">tsconfig</span><span class="p">:</span> <span class="dl">'</span><span class="s1">tsconfig.json</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">}),</span>
      <span class="nf">json</span><span class="p">(),</span>
    <span class="p">],</span>
    <span class="na">treeshake</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="p">...</span>
<span class="p">)]</span>
</code></pre></div></div>

<p>이렇게 useClientBanner와 preserveModules를 적용한 뒤, 특정 서비스 기준으로 번들 전후 비교를 진행했습니다.</p>

<p>여기서 external은 rollup 번들 단계에서 공통 의존성을 분리해 서비스 번들러의 중복 청크 생성을 줄이기 위한 설정입니다.</p>

<p>결과는 아래와 같이 유의미한 성과를 가져왔습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/tree-shake.png" alt="트리쉐이킹 적용 전후 번들 사이즈 비교" /></p>

<ul>
  <li>공통 static/chunk의 용량을 <strong>1.45mb → 567.07kb</strong> 로 <strong>약 61%의 감소</strong></li>
  <li>first load js는 <strong>373kb → 248kb</strong>로 <strong>33%의 청크 번들 감소</strong></li>
  <li>external의 효과로 <strong>pages_app-xxx.js로 생기는 큰 청크 제거</strong></li>
  <li>preserveModules를 통해 <strong>트리쉐이킹 친화적 구조 생성</strong></li>
  <li>실제 pages와 연결되어 있는 chunk 파일 확인 시 <strong>필요한 컴포넌트만 node_modules 안에 빌드</strong>된 것 확인</li>
</ul>

<h1 id="4-ai-활용">4. AI 활용</h1>

<h2 id="41-instructions">4.1 Instructions</h2>

<p>LLM을 잘 활용하려면 사전에 정리된 내용을 명확히 전달하는 것이 중요하다고 생각했습니다.
이 글에서 말하는 Instructions는 LLM에 제공되는 지시 문서입니다. 의도된 결과의 정확도를 높이기 위한 사전 정의 문서로 이해하면 됩니다.
이 Instructions에는 <a href="https://agents.md/">AGENTS.md</a>와 <a href="https://llmstxt.org/">llms.txt</a>의 개념을 활용했습니다.
AI를 실무에 쓰려면 “문서가 있다” 수준을 넘어 UI 라이브러리 사용법, Figma 설계 규칙, 예외 처리 기준까지 한 번에 전달돼야 한다고 봤습니다.</p>

<p>그래서 내부 공식 문서 사이트를 통해 정적인 가이드를 제공했고, LLM이 빠르게 맥락을 잡을 수 있도록 <code class="language-plaintext highlighter-rouge">llms.txt</code>를 운영했습니다.</p>

<p><code class="language-plaintext highlighter-rouge">llms.txt</code>는 전체 덤프(<code class="language-plaintext highlighter-rouge">llms-full.txt</code>), 컴포넌트 인덱스(<code class="language-plaintext highlighter-rouge">index.txt</code>)로 연결되어 있어, LLM이 필요한 정보를 단계적으로 찾을 수 있게 구성됩니다.
즉, 문서를 “사람이 읽기 쉬운 형태”로만 두지 않고, LLM이 바로 소비할 수 있는 구조로 별도 정리해 둔 셈입니다.</p>

<p><code class="language-plaintext highlighter-rouge">AGENTS.md</code>에 이 모든 것을 기재할 수도 있으나 각 파일 별 영역을 나누기 위해 이 개념을 나눠 진행하였습니다.</p>

<p>그리하여 실제 서비스 레포에서는 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>를 참고 해 라이브러리 사용 규칙과 원칙을 명확히 전달했습니다.</p>

<p>이 작업은 1편에서 언급한 말한 <code class="language-plaintext highlighter-rouge">디자이너 → 개발자(라이브러리) → 개발자(서비스)</code> 흐름에서 중간 커뮤니케이션 비용을 줄이기 위한 핵심 축이라고 봤습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/agent-template.png" alt="AGENTS.md 템플릿 예시" /></p>

<p>디자이너 의도 파악 비용, 개발자 간 UI 정책 확인 비용, PM의 정책 검증 비용을 줄이려면 사전에 합의된 규칙이 문서로 고정되어야 했습니다.</p>

<p>합의된 UI/UX규칙을 명세로 정리해 활용한 뒤, AI로 테스트 코드를 작성하는 과정이 훨씬 수월해지는 걸 경험했습니다.
그 과정에서 규칙의 중요성을 한층 더 체감했습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/agent.png" alt="사내 AGENTS.md 운영 사례" /></p>

<p>그래서 UI 라이브러리를 “단순 라이브러리”가 아니라 시스템으로 작동하게 만들기 위한 기반 문서로 <code class="language-plaintext highlighter-rouge">llms.txt</code>와 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>를 함께 운영하게 되었습니다.</p>

<h2 id="42-figma-mcp-with-llm">4.2 Figma MCP with LLM</h2>

<p>사내 LLM 서비스에 Figma MCP(Model Context Protocol, <a href="https://modelcontextprotocol.io/">공식 사이트</a>)를 접목하면, 에디터 내에서 특정 노드 ID를 기준으로 <code class="language-plaintext highlighter-rouge">디자이너 → 개발자(서비스)</code> 흐름을 더 직접적으로 만들 수 있습니다.</p>

<p>즉, 디자이너가 그린 결과물을 개발자가 에디터 안에서 바로 코드로 확인하거나 수정하는 방식으로 연결될 수 있습니다. 다만 이 과정에서 결과 품질은 Instructions와 Figma 설계 방식에 매우 민감하다는 걸 확인했습니다.
같은 디자인이라도 Instructions에 어떤 지시가 들어가 있는지, Figma 안에서 슬롯/상태가 어떻게 정의되어 있는지, hidden 노드가 어떤 방식으로 처리되어 있는지에 따라 결과가 크게 달라졌습니다.</p>

<table>
  <thead>
    <tr>
      <th>Instructions 개선 전 결과</th>
      <th>Instructions 개선 후 결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><img src="/img/2026-02-24-socar-frame2-web/ui-first.png" alt="ui-first" /></td>
      <td><img src="/img/2026-02-24-socar-frame2-web/ui-second.png" alt="ui-second" /></td>
    </tr>
  </tbody>
</table>

<p>Instructions를 개선한 뒤, 리뷰에서 반복되던 결점(토큰/슬롯/예외 처리)이 크게 줄고 일관성을 갖게 됐습니다. 그래서 이 부분은 단발성으로 끝나는 작업이 아니라, 사례를 계속 쌓고 Instructions와 Figma 설계 규칙을 지속적으로 보완해 나가야 하는 영역이라고 봤습니다.</p>

<p>또한 개발자의 세세한 리뷰를 반영하거나 비즈니스 로직 반영 기준을 정책화하는 것을 추후 개선점으로 가져갈 예정입니다.</p>

<h1 id="5-후기">5. 후기</h1>

<p>개인적으로 가장 큰 수확은 컴포넌트 설계 방식과 번들 구조에 대해 깊게 고민해 본 경험이었습니다.</p>

<p>BottomSheet처럼 UI와 상태가 강결합된 컴포넌트와, DatePicker처럼 로직을 분리해야 하는 컴포넌트를 나누어 설계하면서 재사용성과 테스트 가능성에 대한 관점을 다시 정리할 수 있었습니다.</p>

<p>LLM 친화적인 환경을 만들기 위한 고민은 AI 시대에 의미 있는 경험이었습니다.
AGENTS.md와 llms.txt 같은 instruction 체계를 어떻게 구성해야 “문서가 아닌 실질적인 가이드”가 되는지, 그리고 Figma 설계 규칙과 어떻게 맞물려야 하는지 시행착오를 겪었습니다.
이 과정에서 <strong>“기술보다 먼저 합의되어야 하는 규칙”</strong>이 얼마나 중요한지도 체감했습니다.</p>

<p>아직 <code class="language-plaintext highlighter-rouge">쏘카프레임 2.0</code>에는 상위 버전 대응을 위한 의존 패키지 업그레이드, AI·Figma 플러그인 보강 등 남은 과제가 있습니다.
그럼에도 1차 UI 개발과 시스템 기반을 마련한 것은 큰 진전이었고, 쏘카의 클라이언트 개발이 어떤 방향으로 디자인 시스템을 고민했는지 공유할 수 있어 의미 있는 기록이라고 생각합니다.</p>

<h2 id="6-공식-문서">6 공식 문서</h2>

<p>관심 있으신 분들의 참고를 위해 <a href="https://socarframe.socar.kr/">디자인시스템 공식문서</a>를 공유드립니다.</p>

<p>라이브러리 패키지는 private이지만, 설계 원칙/사용 규칙/문서 구조는 외부 공개 가능한 범위에서 공유합니다. (보안/내부 의존성이 있는 세부 구현은 비공개 처리 되었습니다.)</p>]]></content><author><name>아놀드</name></author><category term="fe" /><category term="design-system" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">쏘카 디자인 시스템 2.0 개발기 1편: 시스템으로 굴리기(웹)</title><link href="https://tech.socarcorp.kr/fe/2026/02/23/socar-frame2-web.html" rel="alternate" type="text/html" title="쏘카 디자인 시스템 2.0 개발기 1편: 시스템으로 굴리기(웹)" /><published>2026-02-23T15:00:00+00:00</published><updated>2026-02-23T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/fe/2026/02/23/socar-frame2-web</id><content type="html" xml:base="https://tech.socarcorp.kr/fe/2026/02/23/socar-frame2-web.html"><![CDATA[<p><br /></p>

<h1 id="목차">목차</h1>

<ol>
  <li><a href="#1-개요">개요</a>
    <ol>
      <li><a href="#11-시스템의-정의와-범위">시스템의 정의와 범위</a></li>
    </ol>
  </li>
  <li><a href="#2-시스템-설계">시스템 설계</a>
    <ol>
      <li><a href="#21-설계-목적">설계 목적</a></li>
      <li><a href="#22-Figma-Plugin">Figma Plugin</a></li>
      <li><a href="#23-Figma-Code-Connect">Figma Code Connect</a></li>
    </ol>
  </li>
  <li><a href="#3-운영-프로세스">운영 프로세스</a>
    <ol>
      <li><a href="#31-운영-원칙">운영 원칙</a></li>
      <li><a href="#32-릴리스와-피드백-루프">릴리스와 피드백 루프</a></li>
      <li><a href="#33-직군-합의와-품질-관리">직군 합의와 품질 관리</a></li>
    </ol>
  </li>
  <li><a href="#4-후기">후기</a></li>
</ol>

<hr />

<p><br /><br /></p>

<h1 id="1-개요">1. 개요</h1>

<p>안녕하세요 쏘카 개발자 아놀드입니다.</p>

<p>쏘카에서 장기간의 프로젝트인 쏘카프레임 2.0 개발에 참여하며 고민하고 개발하였던 이야기를 해보려 합니다. 이 글에서 쏘카프레임은 쏘카의 사내 디자인 시스템이며 프론트엔드 관점의 <strong>시스템 설계/연동/운영</strong>에 관해 적어보겠습니다.</p>

<p>1편에서는 시스템 설계, 운영 프로세스 등을 다루고, 2편에서는 컴포넌트 아키텍처, 패키지 전략, AI 활용을 중심으로 정리하며 총 2편으로 글을 적어보려합니다.</p>

<p>쏘카에는 기존의 UI 라이브러리인 <strong>쏘카프레임</strong>이 존재하였습니다.</p>

<p>기존 UI 라이브러리는 디자인 시스템을 표방했습니다. 하지만 입사 당시 이미 라이브러리의 역할분담이 모호한 상태였습니다. 유지보수가 거의 되지 않았고, 각 서비스별로 파편화된 UI/UX 개발이 이뤄지고 있는 상황이었습니다.</p>

<p>이로 인해 사내 디자이너와 FE(Frontend) 개발로 이어지는 제품 개발 프로세스의 일관성과 효율성이 떨어졌습니다. 이를 해결하기 위해 쏘카프레임 2.0 프로젝트가 시작되었습니다.</p>

<h2 id="11-시스템의-정의와-범위">1.1 시스템의 정의와 범위</h2>

<p>이 글에서 말하는 “시스템”은 <strong>컴포넌트 UI 라이브러리</strong> 이상의 개념을 내포하고 있습니다.</p>

<p>UI 라이브러리 위에 <strong>정책(규칙), 연동(디자인-코드), 운영(릴리스/검증)</strong>이 얹혀 있어 실제로 반복 가능한 생산 체계가 된다고 판단하여 구현물과 함께 <strong>합의된 규칙과 운영 방식까지 포함</strong>되는 것을 시스템으로 정의했습니다.</p>

<p>간단히 아래 4가지가 필요하다고 정의했습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[디자인 시스템]
  ├─ 규칙/정책 (토큰/상태/예외 처리 기준)
  ├─ 컴포넌트 라이브러리 (재사용 가능한 UI)
  ├─ 연동 체계 (Figma ↔ Code)
  └─ 운영 프로세스 (릴리스/검증/피드백)
</code></pre></div></div>

<p>범위 또한 시스템 개발이 진행되며 확실히 잡혔습니다. 디자인 토큰, 컴포넌트, 문서, 연동 규칙은 시스템의 범위로 포함했지만, 서비스별 UI 정책과 화면 구성은 각 서비스의 책임으로 분리했습니다.</p>

<p>이렇게 경계를 정해야 역할이 섞이지 않고, 시스템이 “공통 기반”으로 기능할 수 있다고 봤습니다.</p>

<h1 id="2-시스템-설계">2. 시스템 설계</h1>

<h2 id="21-설계-목적">2.1 설계 목적</h2>

<p>기존의 사례가 존재했기 때문에 변화에 대응하고 확장 가능한 UI 라이브러리가 포함된 시스템을 만드는 것이 첫번째 목표였습니다. (확장 가능한 UI 라이브러리에 대한 내용은 2편에서 자세히 다루도록 하겠습니다.)</p>

<p>두번째 목표는 정의한 <code class="language-plaintext highlighter-rouge">연동체계</code> 강화를 통한 기존의 업무방식의 효율성을 높이는 것 이었습니다.</p>

<p>쏘카는 사내 디자인 툴로 Figma를 활용하고 있고 디자인 산출물을 개발자가 한땀한땀 구현으로 옮기는 것이 기존 업무 방식이었습니다.
구체적으로는 <code class="language-plaintext highlighter-rouge">디자이너 → 개발자(라이브러리)</code> 전달 과정과 <code class="language-plaintext highlighter-rouge">개발자(라이브러리) → 개발자(서비스)</code> 전달 과정을 최적화하는 데 초점을 두었습니다.</p>

<p>이 과정은 Figma의 지원 기능을 적극 활용해 설계했습니다.</p>

<p>아래는 전체 업무 흐름을 기반으로 시스템을 통한 <code class="language-plaintext highlighter-rouge">연동체계</code> 강화 목표 지점을 나타낸 다이어그램입니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/architect-target.png" alt="설계결과" /></p>

<h2 id="22-figma-plugin">2.2 Figma Plugin</h2>

<p><code class="language-plaintext highlighter-rouge">디자이너 → 개발자(라이브러리)</code> 방향으로 소통하는 과정의 최적화 중 일부는 정적인 에셋의 처리였습니다.</p>

<p>구상은 아래 그림과 같았고 이를 위해 <a href="https://www.figma.com/developers/plugins">Figma Plugin</a>을 활용했습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/socar-frame2.png" alt="Figma Plugin을 통한 에셋 배포 자동화 흐름도" /></p>

<p>기존에는 아이콘·스페이싱 토큰이 슬랙 → 담당 개발자 수동 PR(Pull Request) 생성 → 검증 → 배포로 이어져 리드타임이 길었고 이를 줄이기 위해 Figma 내부 플러그인을 도입해 디자이너가 바로 PR을 생성하도록 했습니다.
현재는 PAT(Personal Access Token) 기반으로 인증을 처리합니다.</p>

<p>운영상 보안·권한 문제는 남아 있어, 별도 인증 서버 도입을 후속 과제로 검토 중입니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/figma-plugin.png" alt="Figma 내 아이콘 PR 생성 플러그인 UI" /></p>

<p><img src="/img/2026-02-24-socar-frame2-web/icon-pr.png" alt="생성된 아이콘 PR 예시" /></p>

<p>이 외에도 토큰과 같은 foundation 레벨의 요소가 추가될 때는 위와 같은 프로세스를 확장시켜 활용할 수 있을 것으로 보고 있습니다.</p>

<h2 id="23-figma-code-connect">2.3 Figma Code Connect</h2>

<p><code class="language-plaintext highlighter-rouge">개발자(라이브러리) → 개발자(서비스)</code> 방향으로 소통하는 과정의 최적화 중 일부는 UI 라이브러리 코드 사용법입니다.</p>

<p>기존에는 <a href="https://storybook.js.org/">Storybook</a>을 통해 UI를 확인했습니다.
이후 라이브러리를 포함한 레포지토리 내에서 코드를 확인해 실제 서비스에 구현하는 방식이었습니다.
하지만 <a href="https://www.figma.com/developers/api#code-connect">Figma Code Connect</a>를 잘 활용한다면 이 과정을 간소화할 수 있게 됩니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/figma-code-connect-code.png" alt="Figma Code Connect 적용 시 노출되는 코드" /></p>

<p>Figma Code Connect를 적용하면 디자이너가 선택한 노드에 대해 대응되는 UI 라이브러리 코드가 즉시 노출돼 사용 흐름이 단축됩니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/figma-code-connect.png" alt="Figma에서 컴포넌트 선택 시 연결된 코드 확인" /></p>

<p>사진과 같이 특정 UI를 클릭하면 그에 맞는 UI 라이브러리 코드가 나타나게 됩니다.</p>

<p>다만 DatePicker처럼 상태/변형이 많은 컴포넌트는 매핑이 쉽지 않았습니다.
이 과정에서 서로의 설계 방식에 대한 이해가 충분하지 않아 적지 않은 러닝커브가 있었습니다.</p>

<p>디자인 직군은 Figma의 Variant를 활용해 컴포넌트의 노출 여부를 토글하는 방식이 익숙했습니다.
시스템에 있는 컴포넌트를 복사해 Variant를 변경한 뒤 서비스에 적용하는 방식을 주로 사용하셨지만 Slot 형태를 쓰면 복사할 때마다 Slot에 넣을 컴포넌트를 변경해야 하는 번거로움이 있어 자주 쓰지 않는 방식이었습니다.</p>

<p>반면 개발은 합성 컴포넌트 구조에서 어떤 컴포넌트를 하위에 두더라도 유연하게 대응하는 것이 우선순위였기에, 작업 방식의 차이가 존재했습니다.</p>

<p>하지만 합성 컴포넌트 구조에서 이 방식을 그대로 적용하면 Code Connect를 위한 코드와 디자인의 정합성이 떨어진다고 판단했습니다.
그래서 Slot 개념을 활용하기로 합의했고, 이를 통해 확장성을 확보했습니다.</p>

<p>서로 다른 업무 방식의 차이로 인해 현재는 디자인 시스템 영역에서만 이 방식을 사용하고 있습니다.</p>

<p>특정 Slot 안에 정의된 children이 자유롭게 들어갈 수 있도록 개선한 예시로 설명해보겠습니다</p>

<p>아래 사진은 Figma 내에서 하위 UI들이 Main Component로 정의되어 있습니다.
<img src="/img/2026-02-24-socar-frame2-web/datepicker-figma.png" alt="DatePicker의 Main Component 구성" /></p>

<p>아래 보라색 텍스트는 Main Component로 정의된 컴포넌트를 Slot 형태의 children에 넣은 예시입니다. 이를 통해 다른 Main Component 내에서 유연하게 연결할 수 있습니다.</p>

<p><img src="/img/2026-02-24-socar-frame2-web/datepicker-figma-slot.png" alt="Slot 구조로 연결된 DatePicker 구성" /></p>

<p>위 형태를 기반으로 Code Connect를 할 수 있게 되었습니다.
<img src="/img/2026-02-24-socar-frame2-web/datepicker-figma-slot-code.png" alt="Slot 구조에 매핑된 코드 예시" /></p>

<p>결과적으로 합성 컴포넌트 형태와 Figma 설계 형태가 더 유사해졌습니다.</p>

<p>2.1~2.3 의 과정을 통해 <code class="language-plaintext highlighter-rouge">연동체계</code> 라는 항목을 강화하며 시스템으로서 생산성 증대의 기반을 닦을 수 있게 되었습니다.</p>

<h1 id="3-운영-프로세스">3. 운영 프로세스</h1>

<h2 id="31-운영-원칙">3.1 운영 원칙</h2>

<p>디자인 시스템 개발은 단기간에 끝나는 일이 아니라, 장기 프로젝트로 진행되었습니다.
파트별로 담당 팀/인원이 배정되었고, 그때그때 우선순위가 바뀌는 일도 많았습니다.
다만 큰 흐름은 <code class="language-plaintext highlighter-rouge">정기 회의 → UI/UX 정책 합의 → 구현 → 검증</code> 구조를 유지했습니다.</p>

<h2 id="32-릴리스와-피드백-루프">3.2 릴리스와 피드백 루프</h2>

<p>먼저 어떤 UI를 먼저 내릴지 우선순위를 정했고, N차 개발 마일스톤을 나눠서 진행했습니다.
이 과정에서 <strong>“구현이 완료된 컴포넌트는 최대한 빠르게 서비스에 반영해야 한다”</strong>는 요구가 있었기 때문에, 일부 컴포넌트는 <code class="language-plaintext highlighter-rouge">부분 적용 → 피드백 → 수정</code>의 흐름으로 운영되기도 했습니다.</p>

<h2 id="33-직군-합의와-품질-관리">3.3 직군 합의와 품질 관리</h2>

<p>그 과정에서 OS별 구현 방식 차이(모션, 스크롤, 터치/제스처 등)나 구조적으로 피할 수 없는 한계도 확인했습니다.
그리고 비개발 직군인 디자이너와 개발 직군 사이에서 Code Connect 매핑 규칙을 맞춰나가는 과정이 생각보다 큰 챌린지였습니다.
컴포넌트 슬롯 구조나 상태 정의가 Figma 설계와 맞아야 했기 때문에, 단순히 “연결”이 아니라 서로의 설계 방식 자체를 합의하는 과정이 필요했습니다.</p>

<p>이런 상황 속에서 각 직군의 컨텍스트를 맞추고 시스템의 일관성을 유지하기 위해, 큰 틀의 정책은 합의한 뒤 담당 개발자가 구현하고 QC(Quality Check)를 거쳐 배포 전략을 결정하는 구조를 유지했습니다.</p>

<h1 id="4-후기">4. 후기</h1>

<p>각 파트별로 정말 많은 이야기를 나눌 수 있는 주제들이지만, 이 글에서는 시스템으로 작동하기 위한 개념화를 통한 설계 및 운영 관점 위주로 공유했습니다.
세부 구현까지 모두 담기엔 부족하지만, 그만큼 넓게 봤던 고민과 경험을 기록해 두고 싶었습니다.</p>

<p>특히 Figma 연동을 통해 <code class="language-plaintext highlighter-rouge">디자이너 → 개발자(라이브러리)</code> 흐름과 <code class="language-plaintext highlighter-rouge">개발자(라이브러리) → 개발자(서비스)</code> 흐름을 동시에 정렬하려 했던 시도가 가장 큰 전환점이었습니다.
단순한 도구 연동을 넘어, <strong>설계 방식과 운영 규칙을 합의</strong>하는 과정이 시스템의 기반이 된다고 느꼈습니다.</p>

<p>2편에서는 컴포넌트 아키텍처, 패키지 전략, AI 활용 사례를 중심으로 좀 더 기술적인 내용을 정리해 보겠습니다.</p>]]></content><author><name>아놀드</name></author><category term="fe" /><category term="design-system" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">팀 레거시 개선 (3) 쏘카존 관리 시스템 - 6년간 진행된 팀 레거시 코드 및 문서 개선기</title><link href="https://tech.socarcorp.kr/dev/2026/02/13/legacy-code-and-docs-improvement.html" rel="alternate" type="text/html" title="팀 레거시 개선 (3) 쏘카존 관리 시스템 - 6년간 진행된 팀 레거시 코드 및 문서 개선기" /><published>2026-02-13T15:00:00+00:00</published><updated>2026-02-13T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/dev/2026/02/13/legacy-code-and-docs-improvement</id><content type="html" xml:base="https://tech.socarcorp.kr/dev/2026/02/13/legacy-code-and-docs-improvement.html"><![CDATA[<h2 id="목차">목차</h2>

<ol>
  <li><a href="#1-소개">소개</a></li>
  <li><a href="#2-개선-목적">개선 목적</a></li>
  <li><a href="#3-테스트-코드-0에서-시작하다">테스트 코드 0%에서 시작하다</a></li>
  <li><a href="#4-신뢰-기반-이원화--기술-얘기인-척하는-조직-문화-얘기">신뢰 기반 이원화 — 기술 얘기인 척하는 조직 문화 얘기</a></li>
  <li><a href="#5-서비스를-멈추지-않고-테이블을-고치는-법">서비스를 멈추지 않고 테이블을 고치는 법</a></li>
  <li><a href="#6-tl이-되고-시작한-문서화">TL이 되고 시작한 문서화</a>
    <ul>
      <li>6.1 <a href="#6-1-postman-팀-워크스페이스">Postman 팀 워크스페이스</a></li>
      <li>6.2 <a href="#6-2-10개-시스템-erd-작성">10개 시스템 ERD 작성</a></li>
    </ul>
  </li>
  <li><a href="#7-마치며">마치며</a></li>
</ol>

<hr />

<h2 id="1-소개">1. 소개</h2>

<p>안녕하세요. 쏘카 자산개발(Asset)팀 백엔드 개발자 원스톤입니다.</p>

<p>저는 자산개발팀에서 존 관리 시스템을 포함한 다수의 내부 시스템을 담당하며, 쏘카 존과 차량 도메인을 개발하고 있습니다. 이 글에서는 6년 동안 비즈니스를 멈추지 않으면서 레거시 코드와 테이블 구조를 점진적으로 개선해 온 경험, 그리고 TL이 된 이후 팀 전체의 장애 대응 역량을 끌어올리기 위해 진행한 문서화 작업에 대해 공유하려 합니다.</p>

<hr />

<h2 id="2-개선-목적">2. 개선 목적</h2>

<p>처음부터 계획이 있었던 건 아닙니다.</p>

<p>비즈니스 요구사항을 처리하다가 테이블 하나 고치고, 클래스 하나 분리하고, 그게 쌓이다 보니 6년이 됐습니다. 6년 동안 20여 개 테이블, 400여 개 컬럼 구조를 바꿨습니다. 시스템의 어디를 건드리면 어디가 흔들리는지 알았기 때문에, 비즈니스를 멈추지 않고 조금씩 안전하게 개선할 수 있었습니다.</p>

<p>같은 시기에 만들어진 다른 시스템들은 별도 고도화 프로젝트 기간이 필요했습니다. <strong>우리 팀의 시스템은 아직 현역입니다.</strong></p>

<p>이 글은 거창한 마이그레이션 후기가 아닙니다. <strong>“멈추지 않고 고쳐온 기록”</strong>에 가깝습니다. 정리하면, 해결하고자 했던 핵심 문제는 다음과 같습니다.</p>

<ul>
  <li><strong>테이블 구조 노후화</strong>: 초기 설계의 한계로 불필요한 컬럼과 비정규화된 구조가 누적</li>
  <li><strong>서비스 간 결합도</strong>: 하나의 클래스가 너무 많은 책임을 가지고 있어 변경 비용이 높음 (이 주제는 <a href="/dev/2024/07/23/legacy-car-relocation.html">차량재배치 레거시 개선기</a>에서 상세히 다뤘습니다)</li>
  <li><strong>빌드 환경 노후화</strong>: 프로젝트마다 빌드 설정이 다르고 표준화되지 않아 유지보수 비용이 높음 (이 주제는 <a href="/dev/2024/02/12/legacy-gradle-build-script.html">레거시 Gradle 빌드 스크립트 개선기</a>에서 상세히 다뤘습니다)</li>
  <li><strong>지식의 속인화</strong>: 시스템 구조를 아는 사람이 한정되어, 장애 대응과 협업에 병목 발생</li>
</ul>

<p>이 글에서는 <strong>테이블 구조 노후화</strong>와 <strong>지식의 속인화</strong> 문제를 중심으로 다룹니다.</p>

<hr />

<h2 id="3-테스트-코드-0에서-시작하다">3. 테스트 코드 0%에서 시작하다</h2>

<p>처음 입사했을 때, 테스트 코드 커버리지는 0%였습니다. 팀 내에서도 테스트 추가가 필요하다는 논의가 있었고, 저도 동의했습니다. 그래서 테스트 케이스를 작성하기 시작했습니다.</p>

<p>그런데 문제가 있었습니다. <strong>한 테이블의 컬럼이 70~100개</strong>였습니다. 테스트 케이스 하나를 만들려면 entity를 세팅하는 것만으로도 한 시간 이상이 걸렸습니다. 존 관리 시스템은 상당히 큰 프로젝트였고, 이 규모에서 컬럼 70개짜리 entity를 하나하나 세팅하는 건 현실적이지 않았습니다.</p>

<p>이 문제의 원인을 찾기 위해 ERD 구조를 파악하기 시작했습니다. 그 과정에서 <strong>현실의 업무 흐름과 코드, ERD 구성이 일치하지 않는다</strong>는 것을 알게 됐습니다. ERD를 보고 업무 프로세스를 파악하는 것이 불가능한 상태였습니다.</p>

<h3 id="하나의-테이블에-모든-것이-담겨-있었다">하나의 테이블에 모든 것이 담겨 있었다</h3>

<p>예를 들어, 매매계약서, 근로계약서, 용역계약서가 있다고 가정하겠습니다. 이 세 가지는 서로 다른 정보를 담는 전혀 다른 문서입니다. 그런데 <strong>“계약서”라는 이름의 하나의 테이블</strong>에 모든 정보가 담겨 있었습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>계약서 테이블 (contract) — 컬럼 약 80개

type = '매매계약서'일 경우:
  매매_금액 = 50000000
  매매_대상 = '토지'
  근로_시작일 = NULL      ← 의미 없는 NULL
  근로_급여 = NULL         ← 의미 없는 NULL
  용역_기간 = NULL         ← 의미 없는 NULL
  용역_단가 = NULL         ← 의미 없는 NULL
  ...

type = '근로계약서'일 경우:
  매매_금액 = NULL         ← 의미 없는 NULL
  매매_대상 = NULL         ← 의미 없는 NULL
  근로_시작일 = '2020-01-01'
  근로_급여 = 3000000
  용역_기간 = NULL         ← 의미 없는 NULL
  용역_단가 = NULL         ← 의미 없는 NULL
  ...
</code></pre></div></div>

<p>type에 따라 나머지 컬럼 대부분이 NULL로 채워지는 구조였습니다. 혹시 성능을 위해 의도적으로 역정규화한 것은 아닌지 검토해 봤습니다. 조회 부하가 역정규화를 해야 할 정도로 심한가? <strong>타당한 이유가 없었습니다.</strong> 단지 초기 설계 시점에 하나의 테이블로 만들었고, 이후 요구사항이 늘어나면서 컬럼이 계속 추가된 결과였습니다.</p>

<h3 id="하나의-연결이-여러-의미를-가지고-있었다">하나의 연결이 여러 의미를 가지고 있었다</h3>

<p>비슷한 문제가 테이블 관계에도 있었습니다. A 테이블과 B 테이블이 M:N 관계로 연결되어 있었는데, B 테이블의 <code class="language-plaintext highlighter-rouge">type</code> 컬럼 값에 따라 역할이 완전히 달랐습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A 테이블 ──── M:N ──── B 테이블 (type 컬럼)

type = 'PARKING'인 경우:
  → A 테이블의 데이터를 묶어주는 비즈니스 로직으로 사용

type = 'CLEANING'인 경우:
  → A 테이블의 추가 메타 정보로 사용
</code></pre></div></div>

<p>같은 M:N 관계인데, <code class="language-plaintext highlighter-rouge">type</code>에 따라 “데이터를 묶는 것”과 “메타 정보를 붙이는 것”이라는 전혀 다른 역할을 하고 있었습니다. ERD만 보면 하나의 연결이 여러 가지로 해석될 수 있었고, <code class="language-plaintext highlighter-rouge">type</code>이 어떻게 사용되는지 아는 사람만 비즈니스를 파악할 수 있었습니다.</p>

<p>이런 구조는 테이블만 보고 비즈니스를 이해하는 것을 불가능하게 만들었습니다. 결국 역할별로 테이블을 분리하여, <strong>ERD만으로도 비즈니스 흐름이 읽히는 구조</strong>로 개선했습니다.</p>

<p>이미 상당히 커져버린 시스템의 유지보수를 위해서는 <strong>ERD 개선과 코드 개선이 함께 이루어져야 한다</strong>고 판단했습니다.</p>

<p>저는 <strong>비즈니스, 코드, ERD는 일맥상통해야 한다</strong>고 생각합니다. ERD를 보면 비즈니스가 읽혀야 하고, 코드를 보면 ERD가 그려져야 합니다. 이 셋이 어긋나는 순간, 구조를 모두 알고 있는 한 사람에게 시스템이 종속됩니다. 그 사람이 빠지면 아무도 시스템을 건드리지 못하게 됩니다.</p>

<h3 id="현재-상태">현재 상태</h3>

<p>6년간의 개선을 거쳐, 현재 테스트 케이스는 350개, 테스트 커버리지는 약 10%입니다.</p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th style="text-align: center">입사 시점</th>
      <th style="text-align: center">현재</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>테스트 케이스</td>
      <td style="text-align: center">0개</td>
      <td style="text-align: center">350개</td>
    </tr>
    <tr>
      <td>테스트 커버리지</td>
      <td style="text-align: center">0%</td>
      <td style="text-align: center">약 10%</td>
    </tr>
    <tr>
      <td>테이블당 평균 컬럼 수</td>
      <td style="text-align: center">70~100개</td>
      <td style="text-align: center">20~30개</td>
    </tr>
  </tbody>
</table>

<p>숫자만 보면 아직 부족합니다. 하지만 테스트를 작성할 수 있는 구조로 바꾸는 것이 먼저였고, 그 과정이 곧 이 글에서 다루는 ERD 개선과 코드 개선이었습니다.</p>

<blockquote>
  <p>테스트 코드를 짜려다 보니 ERD가 문제였고, ERD를 고치려다 보니 코드도 함께 고쳐야 했습니다. <strong>테스트 0%가 6년간의 개선을 시작하게 만든 출발점</strong>이었습니다.</p>
</blockquote>

<hr />

<h2 id="4-신뢰-기반-이원화--기술-얘기인-척하는-조직-문화-얘기">4. 신뢰 기반 이원화 — 기술 얘기인 척하는 조직 문화 얘기</h2>

<p>레거시 개선에서 가장 어려운 건 기술이 아닙니다. <strong>허락</strong>입니다.</p>

<p>테이블 구조를 바꾼다는 건, 중간 상태가 존재한다는 뜻입니다. 기존 테이블과 새 테이블이 공존하는 이원화 기간이 반드시 생깁니다. 이 기간 동안 데이터 정합성을 보장해야 하고, 롤백 플랜도 있어야 합니다.</p>

<p>문제는 이 이원화 기간에 담당자가 퇴사하면 <strong>ERD가 반쪽짜리로 남는다</strong>는 것입니다. 회사 입장에서는 리스크입니다. 그런데도 진행하게 해줬습니다.</p>

<p>이건 두 가지가 전제되어야 가능합니다.</p>

<ul>
  <li><strong>담당자의 신뢰</strong>: “이 사람이 시작하면 끝낸다”는 신뢰</li>
  <li><strong>조직의 용기</strong>: 리스크를 알면서도 장기적 개선을 선택하는 의사결정</li>
</ul>

<h3 id="이원화를-시작할-때마다-남긴-세-가지-문서">이원화를 시작할 때마다 남긴 세 가지 문서</h3>

<p>저는 이원화를 시작할 때마다 다음 세 가지를 문서로 남겼습니다.</p>

<table>
  <thead>
    <tr>
      <th>문서</th>
      <th>목적</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>현재 상태</strong></td>
      <td>기존 테이블 구조와 의존 관계</td>
    </tr>
    <tr>
      <td><strong>목표 상태</strong></td>
      <td>변경 후 구조</td>
    </tr>
    <tr>
      <td><strong>롤백 방법</strong></td>
      <td>담당자가 없어도 되돌릴 수 있는 절차</td>
    </tr>
  </tbody>
</table>

<p>이 문서가 있으면, 이원화 도중에 누가 빠져도 다른 사람이 이어받거나 되돌릴 수 있습니다. 조직 입장에서는 리스크가 줄고, 허락의 근거가 됩니다.</p>

<blockquote>
  <p>레거시 개선은 기술력만으로 되지 않습니다. 조직이 허락해야 하고, 그 허락은 신뢰에서 나옵니다. 신뢰는 <strong>“완료”의 반복</strong>에서 나옵니다.</p>
</blockquote>

<hr />

<h2 id="5-서비스를-멈추지-않고-테이블을-고치는-법">5. 서비스를 멈추지 않고 테이블을 고치는 법</h2>

<p>“운영 중인 테이블 컬럼을 삭제해도 괜찮나요?”</p>

<p>괜찮았습니다. 단, <strong>순서</strong>가 있습니다.</p>

<h3 id="원칙-코드-먼저-스키마-나중">원칙: 코드 먼저, 스키마 나중</h3>

<p>컬럼을 삭제하고 싶다면, 삭제하기 <strong>전에</strong> 그 컬럼을 참조하는 코드를 모두 제거해야 합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Phase 1: 코드에서 해당 컬럼 참조 제거 → 배포
Phase 2: 안전 확인
Phase 3: ALTER TABLE ... DROP COLUMN
</code></pre></div></div>

<p>Phase 2에서 확인한 것은 다음 세 가지입니다.</p>

<ol>
  <li><strong>SELECT * 제거 확인</strong>: JPA와 Exposed에서 해당 컬럼을 포함하는 <code class="language-plaintext highlighter-rouge">SELECT *</code> 쿼리가 더 이상 발생하지 않는지 확인</li>
  <li><strong>이원화 데이터 정합성 확인</strong>: 기존 컬럼과 새 컬럼(또는 새 테이블)에 데이터가 이중으로 정상 적재되고 있는지 확인</li>
  <li><strong>코드 참조 완전 제거 확인</strong>: 코드에서 기존 컬럼을 직접 참조하는 곳이 없는지 전수 확인</li>
</ol>

<p>이 세 가지가 모두 충족된 시점에 Phase 3을 진행했습니다. Phase 3 시점에는 이미 해당 컬럼을 읽는 쿼리가 없습니다. 그래서 안전합니다.</p>

<p>변경 예시)</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Phase 1 — 코드 변경</span>
<span class="c1">-- 변경 전: 해당 컬럼을 SELECT에 포함</span>
<span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">legacy_flag</span> <span class="k">FROM</span> <span class="n">zones</span><span class="p">;</span>

<span class="c1">-- 변경 후: 해당 컬럼 참조 제거</span>
<span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span> <span class="k">FROM</span> <span class="n">zones</span><span class="p">;</span>
<span class="c1">-- → 배포 후 모니터링</span>

<span class="c1">-- Phase 3 — 스키마 변경 (코드에서 더 이상 참조하지 않는 것을 확인한 뒤)</span>
<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">zones</span> <span class="k">DROP</span> <span class="k">COLUMN</span> <span class="n">legacy_flag</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="mysql-80의-online-ddl">MySQL 8.0의 Online DDL</h3>

<p>MySQL 8.0에서 컬럼 삭제 시 내부적으로는 다음과 같은 일이 일어납니다.</p>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>동작</th>
      <th>서비스 영향</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MDL 획득</td>
      <td>메타데이터 락 (짧게)</td>
      <td>수 ms 수준, 체감 불가</td>
    </tr>
    <tr>
      <td>테이블 리빌드</td>
      <td>데이터를 새 구조로 복사</td>
      <td><strong>SELECT, INSERT, UPDATE 모두 가능</strong></td>
    </tr>
    <tr>
      <td>MDL 해제</td>
      <td>메타데이터 갱신</td>
      <td>수 ms 수준, 체감 불가</td>
    </tr>
  </tbody>
</table>

<p>Table Lock이 아니라 <strong>Metadata Lock(MDL)</strong> 입니다. 그리고 리빌드 중에도 Online DDL이 지원되므로 읽기/쓰기가 모두 가능합니다.</p>

<blockquote>
  <p><strong>Tip.</strong> MDL(Metadata Lock)이란?
MySQL에서 테이블 구조(메타데이터)를 변경할 때 잠깐 거는 락입니다. 데이터 자체를 잠그는 Table Lock과 달리, 구조 변경이 진행 중일 때 다른 DDL이 동시에 실행되지 않도록 보호하는 역할을 합니다. 보통 수 ms 수준으로 매우 짧습니다.</p>
</blockquote>

<p>데이터 규모가 크지 않았기 때문에 리빌드 시간도 짧았고, 해당 컬럼을 참조하는 쿼리가 이미 없었기 때문에 MDL이 잠깐 걸려도 서비스 영향은 없었습니다.</p>

<p>정리하면, 컬럼 삭제가 안전했던 이유는 다음과 같습니다.</p>

<ol>
  <li>코드에서 이미 참조를 제거한 상태</li>
  <li>Online DDL로 리빌드 중에도 서비스 정상</li>
  <li>데이터 규모가 작아 리빌드 시간이 짧음</li>
  <li>MDL 구간이 짧아 실질적 영향 없음</li>
</ol>

<blockquote>
  <p>핵심은 DDL 자체가 안전한 게 아니라, <strong>DDL을 실행해도 안전한 상태를 먼저 만든 것</strong>입니다.</p>
</blockquote>

<hr />

<h2 id="6-tl이-되고-시작한-문서화">6. TL이 되고 시작한 문서화</h2>

<p>팀 리드가 되고 나서 가장 먼저 느낀 문제가 있었습니다.</p>

<p><strong>가용 인원이 있는데도, 장애 판단이 느리다.</strong></p>

<p>우리 팀은 도메인이 많았습니다. 10개의 시스템을 담당하고 있었고, 각 시스템의 맥락을 전부 아는 사람은 없었습니다. 외부 채널링 시스템에서 동기화 이슈가 터져도, 담당자가 아니면 “이게 뭔지”부터 파악해야 했습니다.</p>

<p>이 문제를 해결하기 위해 두 가지를 진행했습니다.</p>

<h3 id="6-1-postman-팀-워크스페이스">6-1. Postman 팀 워크스페이스</h3>

<p>가장 먼저 한 건 Postman에 팀 워크스페이스를 만든 것입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Postman Team Workspace
├── 시스템 A - Collections
│   ├── 동기화 API
│   ├── 상태 조회 API
│   └── 수동 보정 API
├── 시스템 B - Collections
│   ├── ...
└── Docs
    ├── 장애 시 확인 순서
    ├── 각 API 사용 시나리오
    └── 환경별 변수 설정
</code></pre></div></div>

<p>단순히 API를 모아둔 게 아닙니다. <strong>“이 장애가 발생하면 이 API를 이 순서로 호출하라”</strong>는 대응 시나리오를 Docs에 함께 기록했습니다.</p>

<p>변경 예시)</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 변경 전: 장애 발생 시 흐름
장애 인지 → 담당자 찾기 → 담당자에게 상황 설명 → 담당자가 판단 → 대응
(소요 시간: 담당자 가용 여부에 따라 수십 분 ~ 수 시간)

// 변경 후: 장애 발생 시 흐름
장애 인지 → Postman Docs 확인 → 가용 인원이 직접 대응
(소요 시간: 수 분)
</code></pre></div></div>

<p>효과는 명확했습니다. 외부 시스템 동기화 이슈가 발생했을 때, 담당자가 아닌 가용 인원이 Postman을 열고 Docs를 보고 직접 대응할 수 있게 됐습니다.</p>

<blockquote>
  <p>장애 대응의 병목이 <strong>“사람”</strong> 에서 <strong>“문서”</strong> 로 바뀌었습니다.</p>
</blockquote>

<h3 id="6-2-10개-시스템-erd-작성">6-2. 10개 시스템 ERD 작성</h3>

<p>우리 팀이 담당하는 10개 시스템에는 FK가 없었습니다. ERD도 없었습니다.</p>

<p>테이블 간의 관계는 코드를 읽어야만 알 수 있었고, 그마저도 히스토리를 아는 사람에게 물어봐야 정확했습니다. 비즈니스 논의를 할 때 PM이나 사업 담당자에게 데이터 구조를 설명하려면 매번 화이트보드에 그려야 했습니다.</p>

<p>10개 시스템의 ERD를 모두 작성해서 Confluence에 올렸습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Confluence
└── 시스템 ERD
    ├── 시스템 A - ERD (테이블 관계도 + 주요 컬럼 설명)
    ├── 시스템 B - ERD
    ├── ...
    └── 시스템 J - ERD
</code></pre></div></div>

<p>변경 예시)</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 변경 전: 데이터 구조 확인 방법
"이 데이터 어디에 있어요?" → 담당자에게 질문 → 담당자가 코드를 읽고 답변
(담당자 부재 시 확인 불가)

// 변경 후: 데이터 구조 확인 방법
"이 데이터 어디에 있어요?" → Confluence ERD 링크 공유
(누구나, 언제든 확인 가능)
</code></pre></div></div>

<p>기대한 건 개발팀 내부 공유였는데, 실제로는 PM과 사업 담당자가 더 많이 활용했습니다. <strong>“이 데이터가 어디에 있어요?”</strong> 라는 질문에 링크 하나로 답할 수 있게 됐습니다.</p>

<hr />

<h2 id="7-마치며">7. 마치며</h2>

<p>6년 동안 한 일을 한 문장으로 줄이면 이렇습니다.</p>

<blockquote>
  <p><strong>비즈니스를 멈추지 않으면서, 매일 조금씩 어제보다 나은 구조로 만들었습니다.</strong></p>
</blockquote>

<p>고도화 프로젝트를 따로 잡지 않았습니다. 비즈니스 요구사항을 처리하면서 “이왕 건드리는 김에” 조금씩 고쳤습니다. 그게 6년 동안 쌓이니 시스템이 됐습니다.</p>

<p>되돌아보면 중요했던 건 네 가지입니다.</p>

<ol>
  <li><strong>구조가 먼저</strong>: 테스트를 짤 수 있는 구조를 만드는 것이 테스트보다 먼저였다</li>
  <li><strong>조직의 신뢰</strong>: 이원화 리스크를 감수하고 진행하게 해준 의사결정</li>
  <li><strong>안전한 순서</strong>: 코드 먼저, 스키마 나중. 상태를 안전하게 만든 후 변경</li>
  <li><strong>문서화</strong>: 지식을 사람에서 분리해서, 누구나 대응할 수 있는 구조</li>
</ol>

<p>레거시는 한 번에 고치는 게 아닙니다. 매일 고치는 것입니다.</p>]]></content><author><name>원스톤</name></author><category term="dev" /><category term="legacy" /><category term="refactoring" /><category term="documentation" /><category term="database" /><category term="testing" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">Node.js 컨테이너, 왜 깔끔하게 안 죽을까? (feat. Graceful shutdown)</title><link href="https://tech.socarcorp.kr/dev/2026/01/19/nodejs-graceful-shutdown.html" rel="alternate" type="text/html" title="Node.js 컨테이너, 왜 깔끔하게 안 죽을까? (feat. Graceful shutdown)" /><published>2026-01-19T15:00:00+00:00</published><updated>2026-01-19T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/dev/2026/01/19/nodejs-graceful-shutdown</id><content type="html" xml:base="https://tech.socarcorp.kr/dev/2026/01/19/nodejs-graceful-shutdown.html"><![CDATA[<blockquote>
  <p>그레이스풀 셧다운(Graceful Shutdown)이란 서버가 종료 요청을 받았을 때, 진행 중인 작업을 안전하게 마무리하고 리소스를 정리한 뒤 종료하는 방식입니다.</p>
</blockquote>

<p>이 글에서는 Node.js 컨테이너 환경에서 graceful shutdown이 제대로 동작하지 않는 원인을 분석하고, Linux PID 1 메커니즘과 이벤트 루프 관점에서 해결 방법을 다룹니다.</p>

<p>배치 컨슈머 앱을 운영하다 보면 “분명 종료 시그널 넣었는데 왜 안 죽지?”라는 상황을 한 번쯤 겪게 됩니다.</p>

<p>저희 팀 역시 최근 이 문제로 꽤 고생했습니다. 처음에는 단순히 시그널 처리 문제라고 생각했지만, 파고들다 보니 <strong>Linux 커널의 PID 1 보호 메커니즘</strong>과 <strong>Node.js 이벤트 루프 동작 방식</strong>이 함께 얽힌 문제였습니다. 그 과정에서 겪은 삽질을 정리해봅니다.</p>

<hr />

<h2 id="0-배경">0. 배경</h2>

<p>모두의주차장 서비스에서는 여러 배치 컨슈머 앱을 운영하고 있습니다.</p>

<p>운영 중, 배치 job이 아예 실행되지 않은 것은 아니지만 <strong>배치는 도는 것처럼 보이는데 일부 작업만 반영되지 않은 채 끝난 것처럼 보이는 케이스</strong>가 간헐적으로 발견되었습니다.</p>

<p>처음에는 배치 로직이나 트랜잭션 문제를 의심했습니다. 하지만 원인이 깔끔하게 재현되지 않았고, 상황에 따라 증상도 달랐습니다. 이 과정에서 배치 코드 자체뿐 아니라, <strong>배치가 실행되는 동안 프로세스가 어떻게 종료되는지</strong>도 함께 살펴볼 필요가 있겠다는 생각이 들었습니다.</p>

<p>배포 타이밍, 종료 시그널, graceful shutdown 역시 <strong>가능성 있는 원인 중 하나로 열어두고</strong> 고민을 시작했습니다.</p>

<p>이때부터 고민의 방향이 바뀌었습니다.</p>

<ul>
  <li>배치 컨슈머에서 트랜잭션은 어디까지 보장해야 할까?</li>
  <li>배치가 실행 중일 때 새로운 배포가 나가면, 어디까지를 정상 종료로 봐야 할까?</li>
  <li>Kubernetes 환경에서 말하는 graceful shutdown은 실제로 어떤 의미일까?</li>
</ul>

<p>단순히 “SIGTERM을 잘 받게 하자”는 문제는 아니라는 판단이 들었고, 결국 <strong>프로세스 종료 과정을 처음부터 다시 이해해볼 필요가 있다</strong>고 느꼈습니다.</p>

<hr />

<h2 id="1-내-앱은-왜-시그널을-무시할까">1. 내 앱은 왜 시그널을 무시할까?</h2>

<p>Kubernetes는 Pod를 종료할 때 먼저 SIGTERM을 보냅니다. 애플리케이션은 이 신호를 받고 하던 작업을 마무리해야 하지만, 제 경우에는 배치가 계속 실행되다가 결국 SIGKILL로 강제 종료되고 있었습니다.</p>

<h3 id="흔한-오해">흔한 오해</h3>

<p>구글링을 해보면 “Node.js는 PID 1 역할을 하도록 설계되지 않아서 시그널을 못 받는다”라는 설명을 자주 볼 수 있습니다. 하지만 이는 절반만 맞는 이야기입니다.</p>

<p>실제로는 <strong>Linux 커널이 PID 1 프로세스를 특별하게 보호</strong>합니다. 일반 프로세스(PID ≥ 2)는 시그널 핸들러가 없으면 커널의 기본 동작에 따라 종료됩니다. 하지만 PID 1은 핸들러가 없을 경우 시그널을 무시합니다. 이는 “Global init gets no signals it doesn’t want”라는 커널 설계 원칙 때문입니다.</p>

<p>NestJS에서 <a href="https://docs.nestjs.com/fundamentals/lifecycle-events#application-shutdown"><code class="language-plaintext highlighter-rouge">app.enableShutdownHooks()</code></a>를 통해 시그널 핸들러를 등록했다면 PID 1이라도 SIGTERM을 받을 수는 있습니다. 다만 실제 문제는 <strong>좀비 프로세스 정리(reaping)</strong>와 <strong>자식/손자 프로세스에 대한 시그널 전파를 Node.js가 책임지지 않는다는 점</strong>이었습니다.</p>

<h3 id="해결-dumb-init-도입">해결: dumb-init 도입</h3>

<p>결국 시그널 전달과 프로세스 관리는 전문 init 시스템에 맡기는 것이 표준적인 접근이었습니다. <a href="https://github.com/Yelp/dumb-init">dumb-init</a>은 컨테이너 환경에서 PID 1로 동작하며 시그널 전파와 좀비 프로세스 정리를 담당합니다.</p>

<p><strong>dumb-init을 활용한 Dockerfile 예시:</strong></p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Dockerfile - dumb-init을 PID 1로 설정하여 시그널 전달 보장</span>
<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> dumb-init

<span class="k">ENTRYPOINT</span><span class="s"> ["/usr/bin/dumb-init", "--"]</span>
<span class="k">CMD</span><span class="s"> ["node", "dist/main"]</span>
</code></pre></div></div>

<hr />

<h2 id="2-2분-타임아웃-걸었는데-왜-5분을-버티지">2. 2분 타임아웃 걸었는데 왜 5분을 버티지?</h2>

<p>dumb-init을 적용한 뒤 시그널은 정상적으로 전달되기 시작했습니다. 하지만 또 다른 문제가 드러났습니다.</p>

<p><code class="language-plaintext highlighter-rouge">onModuleDestroy</code> 훅에서 <code class="language-plaintext highlighter-rouge">Promise.race</code>를 사용해 최대 2분까지만 기다리도록 구현했습니다. 그런데 실제로는 배치가 끝날 때까지 약 5분 동안 Pod가 종료되지 않았습니다.</p>

<h3 id="이벤트-루프의-문제">이벤트 루프의 문제</h3>

<p><a href="https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick">Node.js 프로세스는 이벤트 루프가 완전히 비워져야 종료</a>됩니다. 상황을 정리하면 다음과 같았습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Promise.race</code>에서 타임아웃이 먼저 완료되어 훅 함수는 return됨</li>
  <li>하지만 패배한 <code class="language-plaintext highlighter-rouge">batchPromise</code>는 취소되지 않음</li>
  <li>내부의 <code class="language-plaintext highlighter-rouge">await sleep(10000)</code> 같은 비동기 작업이 이벤트 루프에 계속 남아 있음</li>
  <li>Node.js는 “아직 처리할 작업이 남아 있다”고 판단하고 종료를 미룸</li>
</ul>

<p><strong>타임아웃 후에도 프로세스가 종료되지 않는 로그 예시:</strong></p>

<pre><code class="language-log">16:07:20  K8s SIGTERM 수신 -&gt; onModuleDestroy 호출
16:09:20  2분 타임아웃 발생 -&gt; 훅 함수 종료 (return)
16:09:26  (종료되어야 하는데) 배치 작업 계속 진행 중... Iteration 15...
16:11:56  5분 경과, 배치가 다 끝나서야 프로세스 종료
</code></pre>

<p>함수가 끝났다고 해서, 프로세스가 종료되는 것은 아니었습니다.</p>

<hr />

<h2 id="3-abortcontroller로-강제-중단해야-할까">3. AbortController로 강제 중단해야 할까?</h2>

<p>타임아웃 이후에도 배치가 계속 실행되는 상황을 보며 비동기 작업 자체를 강제로 중단해야 하는지 고민했습니다.</p>

<p><code class="language-plaintext highlighter-rouge">AbortController</code>를 쓰면 백그라운드에서 돌고 있는 루프를 명시적으로 멈출 수 있거든요.</p>

<p><strong>검토했으나 미채택한 AbortController 패턴:</strong></p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="nf">doBatch</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">job</span> <span class="k">of</span> <span class="nx">jobs</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">abortController</span><span class="p">.</span><span class="nx">signal</span><span class="p">.</span><span class="nx">aborted</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">작업 중단 요청 수신</span><span class="dl">'</span><span class="p">);</span>
      <span class="k">break</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">await</span> <span class="nf">perform</span><span class="p">(</span><span class="nx">job</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="k">async</span> <span class="nf">onModuleDestroy</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">race</span><span class="p">([</span><span class="nx">waitAll</span><span class="p">,</span> <span class="nx">timeoutPromise</span><span class="p">]);</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">result</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">timeout</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">abortController</span><span class="p">.</span><span class="nf">abort</span><span class="p">();</span> <span class="c1">// 중단 신호 전송</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>다만 최종적으로는 이 방식을 도입하지 않았습니다.</strong></p>

<ul>
  <li>모든 루프와 await 지점마다 중단 체크가 필요해 코드 복잡도가 증가함</li>
  <li>DB 트랜잭션 등 외부 라이브러리가 중단을 안전하게 처리하지 못할 가능성</li>
  <li>데이터 정합성 측면에서 오히려 더 위험해질 수 있음</li>
</ul>

<p>결국 이미 시작된 배치는 끝까지 보장하고, 그 이후는 Kubernetes의 종료 정책에 맡기는 방향을 선택했습니다.</p>

<hr />

<h2 id="4-최종-버전">4. 최종 버전</h2>

<h3 id="애플리케이션-레벨-셧다운-훅--타임아웃">애플리케이션 레벨: 셧다운 훅 + 타임아웃</h3>

<p><strong>최종 적용한 NestJS 셧다운 훅:</strong></p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// main.ts</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">enableShutdownHooks</span><span class="p">();</span>

<span class="c1">// batch.service.ts</span>
<span class="k">async</span> <span class="nf">onModuleDestroy</span><span class="p">()</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="s2">`onModuleDestroy 호출됨 (PID: </span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">pid</span><span class="p">}</span><span class="s2">)`</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">waitAll</span> <span class="o">=</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">allSettled</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">runningBatches</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">timeoutPromise</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="o">=&gt;</span>
    <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nf">resolve</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeout</span><span class="dl">'</span><span class="p">),</span> <span class="mi">120000</span><span class="p">)</span>
  <span class="p">);</span>

  <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">race</span><span class="p">([</span><span class="nx">waitAll</span><span class="p">,</span> <span class="nx">timeoutPromise</span><span class="p">]);</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">result</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">timeout</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Graceful Shutdown 타임아웃 - 배치 완료 대기 중</span><span class="dl">'</span><span class="p">);</span>
    <span class="c1">// 정리가 불가능하면 process.exit(1)로 강제 종료할 수도 있음</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="인프라-레벨-k8s-grace-period-조정">인프라 레벨: K8s Grace Period 조정</h3>

<p>애플리케이션 타임아웃보다 <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination"><code class="language-plaintext highlighter-rouge">terminationGracePeriodSeconds</code></a>를 더 길게 설정했습니다.</p>

<p><strong>앱 타임아웃(2분) &lt; K8s Grace Period(3분)</strong></p>

<p><strong>앱 타임아웃보다 길게 설정한 K8s Grace Period:</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># deployment.yaml</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">my-app</span>
      <span class="na">terminationGracePeriodSeconds</span><span class="pi">:</span> <span class="m">180</span>
</code></pre></div></div>

<hr />

<h2 id="정리">정리</h2>

<p>이번 이슈를 통해 확인한 것은 단순합니다. 종료 훅을 추가했는지보다 중요한 것은, <strong>종료 시점에 이벤트 루프에 어떤 작업이 남아 있는지를 이해하고 있는지</strong>였습니다.</p>

<p>Node.js 이벤트 루프, PID 1 프로세스, Kubernetes 종료 정책은 서로 맞물려 동작합니다. 이 중 하나라도 놓치면 “종료됐다고 생각했지만 실제로는 살아 있는” 상태가 만들어질 수 있습니다.</p>

<p>모든 상황을 코드로 통제하려 하기보다는, 문제의 성격과 서비스 요구사항을 기준으로 <strong>애플리케이션과 인프라의 책임을 나누는 선택</strong>이 더 현실적이었습니다.</p>

<hr />

<h3 id="핵심-요약">핵심 요약</h3>

<ul>
  <li><strong>Init 프로세스 사용</strong>: <a href="https://github.com/Yelp/dumb-init">dumb-init</a>이나 <a href="https://github.com/krallin/tini">tini</a>로 시그널 전달 및 좀비 프로세스 방지</li>
  <li><strong>Node 직접 실행</strong>: <code class="language-plaintext highlighter-rouge">npm start</code> 대신 <code class="language-plaintext highlighter-rouge">node dist/main</code>으로 (시그널 전달 방해 방지)</li>
  <li><strong>이벤트 루프 이해</strong>: return만으로 프로세스가 종료되지 않음. <a href="https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick">비동기 작업이 남았으면 끝날 때까지 살아있음</a></li>
  <li><strong>설정 동기화</strong>: 앱 타임아웃보다 K8s <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination">terminationGracePeriodSeconds</a>를 더 길게</li>
</ul>

<p>결국 종료 훅을 넣는 것보다 중요한 건, <strong>내 앱의 비동기 작업이 이벤트 루프를 얼마나 점유하고 있는지 이해하고 제어하는 것</strong>이었습니다. 개발적인 관점 너머, 여러가지 기술적인 해결책들이 있을때, 오버엔지니어링 보다는 이 문제의 배경이 무엇이고, 풀고자 하는 문제점이 무엇인지 정확하게 파악 후 해결책을 선택하는 것 또한 중요하다고 생각합니다.</p>]]></content><author><name>sebastian, ruru</name></author><category term="dev" /><category term="nodejs" /><category term="kubernetes" /><category term="graceful-shutdown" /><category term="nestjs" /><summary type="html"><![CDATA[그레이스풀 셧다운(Graceful Shutdown)이란 서버가 종료 요청을 받았을 때, 진행 중인 작업을 안전하게 마무리하고 리소스를 정리한 뒤 종료하는 방식입니다.]]></summary></entry><entry><title type="html">쏘카프레임 - 블루투스 모듈</title><link href="https://tech.socarcorp.kr/socarframe/2026/01/08/socarframe-bluetooth.html" rel="alternate" type="text/html" title="쏘카프레임 - 블루투스 모듈" /><published>2026-01-08T06:00:00+00:00</published><updated>2026-01-08T06:00:00+00:00</updated><id>https://tech.socarcorp.kr/socarframe/2026/01/08/socarframe-bluetooth</id><content type="html" xml:base="https://tech.socarcorp.kr/socarframe/2026/01/08/socarframe-bluetooth.html"><![CDATA[<h2 id="쏘카는-어떻게-앱으로-차-문을-열까">쏘카는 어떻게 앱으로 차 문을 열까</h2>

<p><img src="/img/2026-01-08-socarframe-bluetooth/smartkey.png" width="80%" /></p>

<p>쏘카는 앱에서 다음과 같은 기능들을 제공합니다.</p>

<ul>
  <li>
    <p>쏘카 차량 제어 (문 열기 / 문 잠금 / 비상등 켜기 / 경적 울리기 등) 🚙</p>
  </li>
  <li>
    <p>일레클 제어 (잠금 / 해제 / 반납 등) ⚡️</p>
  </li>
  <li>
    <p>따릉이 제어 (잠금 / 해제 / 반납 등) 🚲</p>
  </li>
</ul>

<p>이러한 것들이 가능한 이유는 각 이동수단 내부에 데이터 송수신이 가능한 단말이 숨어있기 때문입니다. 이 단말들이 앱과 데이터를 송수신하는 방법에는 서버를 통한 방법, 블루투스를 통한 방법, 총 두 가지가 있으며 쏘카는 이 두 가지 방법을 적절히 혼합해서 사용하고 있습니다.</p>

<p>예를들어, 쏘카 차량 내부에는 데이터 송수신 단말인 STS(Socar Telematics System) 가 존재합니다. 이 단말이 서버나 블루투스로부터 “차 문을 열어라” 라는 명령 데이터를 받게 되면, 정합성 및 보안성을 검사한 뒤 차 문을 열어주게 되는 흐름입니다. 일반적으로 블루투스 통신이 서버 통신보다 신뢰성이 있기 때문에, 블루투스 통신이 가능한 상황이라면 먼저 블루투스 통신을 사용하며, 그렇지 않은 경우 서버 통신을 사용합니다.</p>

<p>이러한 비즈니스 로직 맥락 안에서, 쏘카의 모바일 개발자들은 여러 기능에 대한 블루투스 코드를 작성하게 됩니다. 이 글에서는 쏘카, 일레클, 따릉이 피쳐에서 모두 활용하는 쏘카프레임 - 블루투스 모듈에 대해 소개하려 합니다.</p>

<h2 id="쏘카프레임---블루투스-모듈-탄생-배경">쏘카프레임 - 블루투스 모듈 탄생 배경</h2>

<p>Android, iOS 플랫폼이 제공하는 블루투스 개발 환경은 본질적으로 블루투스 하드웨어 추상화 계층에 대응하는 것이기 때문에, 기대되는 동작 방식이나 운용 방식이 존재하기 마련입니다. 하지만 각 플랫폼이 제공하는 블루투스 API 는 하드웨어의 개념적 구조에 맞지 않게 파편화 되어있으며, 서비스 요구 사항에 대응하기 어렵습니다. 쏘카프레임 블루투스 모듈은 파편화된 것을 다시 하나로 추상화하고 서비스 요구 사항 대응 능력을 확보할 수 있도록 설계했습니다. 파편화는 크게 2가지 이야기로 정리할 수 있습니다.</p>

<p><strong>1. 하드웨어 맥락(Context)을 응집하지 못하는 플랫폼 API</strong></p>

<p>어떤 블루투스 기기(DeviceX)가 있을 때, 사람은 그 DeviceX 의 고유한 식별 정보와 통신 규격, 그리고 수행 가능한 동작 등을 하나의 덩어리로 인식하게 됩니다. 예를들어 쏘카의 STS 단말기라면, CarID 를 포함한 식별 정보와 핸드셰이크 규칙, 차량 상태 리포팅 기능 등의 세계관이 하나의 클래스 안에 응집되어 있기를 기대할 것입니다. 하지만 모바일 플랫폼이 제공하는 API(iOS: CBPeripheral, Android: BluetoothDevice)는 이러한 맥락을 담지 못하는 Low-Level 인터페이스에 불과합니다. 개발자는 흩어진 스캔, 연결, 송수신 Delegate 콜백들과 의미가 부재된 Raw Data 속에서 기기의 맥락을 직접 재구성해야 하는 어려움이 있습니다.</p>

<p>쏘카프레임은 이를 해소하기 위해 플랫폼 API 위에 새로운 추상화 계층을 쌓아 올려, 기기의 정체성에 대한 정보를 담을 수 있도록 제공합니다. 결과적으로 앱 개발자들은 비즈니스 로직을 작성할 때 기기 클래스가 직접 제공하는 고유 인터페이스들을 참고해서 서비스 구현에만 집중한 코드를 작성할 수 있습니다. 또한, 새로운 기기가 도입되더라도 기존 구조를 해치지 않고 유연하게 확장할 수 있는 토대를 마련합니다.</p>

<p><strong>2. iOS 와 Android 플랫폼 코드 차이</strong></p>

<p>iOS 의 CoreBluetooth 와 android.bluetooth 사이에도 동작 차이가 존재합니다. 한 가지 예로 주변 기기를 스캔하는 코드 동작성에 차이가 있는데, iOS 의 <a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanager/scanforperipherals(withservices:options:)">scanForPeripherals(withServices:options:)</a> 는 스캔 도중 새로운 스캔을 허용하지 않는 반면, Android 의 <a href="https://developer.android.com/develop/connectivity/bluetooth/ble/find-ble-devices?hl=ko">startScan(filters, settings, callback)</a> 은 이를 허용합니다.</p>

<p>CoreBluetooth 가 가진 단일 스캔 정책 제약을 모듈 레벨에서 해결하기 위해, 쏘카프레임은 가상 스캔 매니저를 도입했습니다. 이 매니저는 앱 내 다양한 스캔 요청을 논리적으로 병합하여 OS 에는 단 하나의 최적화된 요청만 전달합니다. 이러한 동작 파편화 해소를 통해 플랫폼을 넘나든 원활한 소통 기반을 마련하며, 새로운 서비스 요구사항이 들어왔을 때의 시나리오 설계 안정성도 확보합니다.</p>

<p><img src="/img/2026-01-08-socarframe-bluetooth/github_pr_comment.png" width="80%" /></p>

<h2 id="블루투스-모듈-구조">블루투스 모듈 구조</h2>

<p>쏘카프레임의 블루투스 모듈은 표준 블루투스 규격 코드 위에 추상화 계층을 쌓아올립니다.</p>

<blockquote>
  <p>“문 열기 명령을 보내줘”</p>
</blockquote>

<p>기존 플랫폼 코드는 이 간단한 한마디를 하기 위해 기기를 스캔하고, 찾고, 연결하고, 끊어지면 다시 찾고, 에러를 처리하는 코드를 작성해야 합니다. 만약 기기마다 연결되었을 때 수행해야 할 루틴과 Read/Write 규칙이 다르다면 그에 따른 분기 코드도 작성해야합니다. 쏘카프레임에서는 이러한 코드들에 대해 화면 개발자들이 신경 쓸 필요가 없도록 지원합니다.</p>

<p>쏘카프레임은 블루투스 연결 관리자를 BluetoothHost 로, 블루투스 연결 대상을 BluetoothRemote 로 추상화합니다. (이하 호스트 = BluetoothHost, 리모트 = BluetoothRemote)</p>

<p>호스트는 기본적인 블루투스 매니저 역할뿐 아니라 리모트 목록을 관리하고 리모트의 생명주기 이벤트 콜백을 호출하는 역할을 합니다. 앱에서 다루고 싶은 리모트를 호스트에 등록하면 가상 스캔 매니저를 통한 스캔이 시작됩니다. 스캔에 성공하면 그 리모트의 생성 생명주기 콜백을 호출하며, 연결을 끊을때는 종료 생명주기 콜백을 호출합니다. STS, 일레클, 따릉이 모두 리모트로 추상화될 수 있기 때문에, 추상화된 생명주기 이벤트를 호출하기만 하면 각 기기에 맞는 루틴이 시작됩니다.</p>

<p><img src="/img/2026-01-08-socarframe-bluetooth/host_remote.png" width="80%" /></p>

<p>리모트 객체의 추상화는 기기 확장성을 보장하는 핵심이 됩니다. 쏘카에 새로운 블루투스 기기를 도입하더라도, 정의된 리모트 인터페이스를 따르기만 하면 기존 비즈니스 로직의 수정없이 유연하게 시스템을 확장할 수 있습니다.</p>

<p>또한 단순한 기기 수평 확장을 넘어, 다형성을 통한 수직적 확장까지 포용합니다. 예를 들어, 연결 상태 관리가 필수적인 기기와 그렇지 않은 기기를 구분하기 위해 BluetoothRemote 위에 연결 생명주기 제어 기능을 얹은 ConnectfulRemote 레이어를 파생시킬 수 있습니다. 필요하다면 이외의 사용자 정의 신규 레이어를 얼마든지 정의할 수도 있습니다.</p>

<p>리모트를 추상화했다면 호스트가 원하는 리모트를 정확히 찾아내기 위해 기기 스펙을 명시한 명세서가 있어야 합니다. 쏘카프레임은 기기의 정보를 “정적인 정체성”과 “동적인 행동 지침”으로 분리해 관리하도록 하며, 그것을 각각 BluetoothSpec 과 BluetoothHandle 로 정의합니다. 이 두 클래스는 호스트에게 무엇을(Identity) 찾고 그것을 어떻게(Behavior) 다뤄야 하는지를 알려주는 설계도 역할을 수행합니다.</p>

<p>정확한 기기 탐색을 위해서는 호스트에게 “CarID 가 abc123 인 STS 를 찾아” 혹은 “BikeID 가 xyz456 인 일레클을 찾아” 라고 구체적인 정보를 전달해야 합니다. CarID, BikeID, UUID 등은 기기의 정적인 정체성 정보이며, 쏘카프레임은 이러한 것들을 묶어 BluetoothSpec 으로 정의합니다.</p>

<p>반면, 실제 통신 과정에서 비즈니스 요구사항에 따라 가변적인 행동들이 존재합니다. STS 에서는 연결 유지를 위한 주기적 aliveMessage 전송 로직과, 보안 강화를 위한 커맨드 재발급(issueCommands) 로직 등이 그 예가 될 수 있습니다. 이러한 동적인 정보를 BluetoothHandle 로 분리하여 관리합니다.</p>

<p>결과적으로 이 두 관심사를 분리함으로써, 쏘카앱은 차량과의 물리적인 연결을 끊지 않고도 동적인 비즈니스 로직을 실시간으로 교체할 수 있는 유연성을 확보합니다. BluetoothSpec 이 같으면 같은 기기로 간주할 수 있기 때문입니다.</p>

<p>이러한 쏘카프레임의 구조 안에서, “문 열기 명령을 보내줘”라는 요구사항은 더 이상 복잡한 절차의 나열이 아닌 명확한 의도의 선언으로 바뀌게 됩니다. 다음은 쏘카프레임을 사용하지 않았을 때 앱 단에서 작성하게 되는 의사 코드(Pseudo-code) 예시입니다. 예시를 들기 위해 간략화된 의사 코드로, 실제 서비스와 디테일은 상이할 수 있습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 플랫폼 코드만 사용
class BluetoothManager {

    // 1. 스캔 시작
    function startScan(device) {
        scan(device.spec)
    }
    
    // 2. 연결 성립 콜백: 기기마다 다른 초기화 루틴
    function didConnect(device) {
        switch (device.type) {
            case STS:        startAliveMessage(); break;
            case ELECLE:     performHandshake();  break;
            case SEOUL_BIKE: checkFirmware();     break;
            // KICKBOARD 추가하려면 여기 코드 수정 필요
        }
    }

    // 3. 문 열기 명령 요청 시: 기기마다 다른 프로토콜
    function requestUnlock() {
        switch (device.type) {
            case STS:        writeBytes([0x01, 0xA0]); break;
            case ELECLE:     writeJSON("{cmd:unlock}"); break;
            // KICKBOARD 추가하려면 여기 코드 수정 필요
        }
    }

    // 4. 응답 수신 콜백: 기기마다 다른 파싱 규칙
    function didReceive(data) {
        switch (device.type) {
            case STS:        if (data[0] == 0x01) success(); break;
            case ELECLE:     if (json(data).ok)   success(); break;
            case SEOUL_BIKE: if (crcCheck(data))  success(); break;
            // KICKBOARD 추가하려면 여기 코드 수정 필요
        }
    }
    
    // 5. 에러 콜백: 기기마다 다른 재시도 정책
    function handleError(error) {
         switch (device.type) {
            case STS:        retry(3); break; // 3번 재시도
            case ELECLE:     disconnectImmediately(); break; // 즉시 끊기
            // KICKBOARD 추가하려면 여기 코드 수정 필요
         }
    }
}
</code></pre></div></div>

<p>앱 단에서 스캔, 연결, 명령 송수신의 모든 처리를 감당해야 합니다. 기기의 정체성이 덩어리로 인식되지 않고, 플랫폼이 분산시킨 콜백들에 파편화되어 있습니다. 새로운 기기 확장에 유리한 구조도 아닙니다. 모듈로 분리하지 않았기 때문에 쏘카 유니버스의 다른 앱에서 똑같이 STS 를 사용하고 싶어졌을 때 이 코드를 다른 프로젝트에 중복 작성하여 관리 포인트가 늘어나게 됩니다.</p>

<p>반대로, 다음은 쏘카프레임을 사용했을 때 앱 단에서 작성하게 되는 의사 코드 예시입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 1. 모듈에 정의된 BluetoothHandle 사용
// 핸들과 스펙은 포함관계
let handleSts = BluetoothHandleSts(specSts, startAliveMessage())
let handleElecle = BluetoothHandleElecle(specElecle, performHandshake())
let handleSeoulBike = BluetoothHandleSeoulBike(specSeoulBike, checkFirmware())
// KICKBOARD 추가하려면 모듈에 추가하고 이곳에 선언

// 2. 호스트에 등록 및 스캔 시작
function register() {
    // 호스트에 등록하면 곧바로 가상 스캐너 시작
    // 호스트가 리모트의 생명주기 콜백(onCreate/onConnect/onFishish)을 알아서 수행
    bluetoothHost.addHandle(handleSts)
}

// 3. 문 열기 명령 요청
function requestUnlock() {
    bluetoothHost
        .checkIsConnected(handleSts) // 연결 상태 체크
        .getBluetoothRemote() // 리모트 획득
        .request(commandUnlock) // 리모트에게 명령
        .catchError() // 리모트가 밖으로 뱉어준 에러 처리
}
</code></pre></div></div>
<p>기기 종류에 따른 모든 처리가 BluetoothRemote, BluetoothHandle 안에 캡슐화되어 있으며, 앱 단에서는 이것을 신경 쓸 필요가 없습니다. 기기의 정체성이 하나의 클래스 내에 응집되어 인식되며, 플랫폼이 분산시킨 콜백에 파편화되지 않습니다. 새로운 기기 확장성이 확보됩니다. 각 종류의 BluetoothHandle 과 BluetoothRemote 는 모듈 단에 선언되어 있기 때문에 쏘카 유니버스의 다른 앱에서 재활용 가능합니다.</p>

<h2 id="블루투스-모듈의-가치">블루투스 모듈의 가치</h2>

<p>이렇게 쏘카는 기존의 CoreBluetooth 및 Android Bluetooth API 위에 새로운 추상화 계층인 쏘카프레임을 구축했습니다. 이 아키텍처의 도입을 통해 확보한 실효적 가치는 다음 5가지로 요약할 수 있습니다.</p>

<p><strong>1. 수평, 수직 확장성 확보</strong><br />
BluetoothRemote 의 다형성 덕분에, 새로운 블루투스 기기가 도입되더라도 기존 BluetoothHost 코드를 수정할 필요없이 새로운 구현체만 추가하는 수평적 확장성을 제공합니다. 또한, 단순한 통신 기능을 넘어, 연결 유지나 보안 기능이 필요한 경우, 상속을 통해 새로운 계층을 쌓아 올려 고도화할 수 있습니다. 이는 서비스가 성장하며 다양한 상황을 마주하는 시점에도 아키텍처가 무너지지 않고 견고하게 버틸 수 있는 기반이 됩니다.</p>

<p><strong>2. 인프라와 비즈니스 영역 분리</strong><br />
앱 비즈니스 계층이 하드웨어 복잡성으로부터 분리됩니다. 앱 개발자는 이제 스캔, 연결 수립, 세션 유지, 기기 고유의 규칙들에 대해 신경 쓸 필요가 없어졌습니다. 이 모든 복잡한 절차들은 모두 모듈 내부로 은닉되었습니다. 수십 줄에 달하던 블루투스 하드웨어에 대한 중복 로직이 사라지고, 기기의 종류에 따른 분기 코드를 흩어져 있는 콜백에 정의할 필요가 없어졌습니다. 개발자는 오직 어떤 기기에 어떤 명령을 내릴 것인가라는 비즈니스 의도만 작성하게 됩니다.</p>

<p><strong>3. 비즈니스 유연성 확보</strong><br />
iOS 와 Android 플랫폼 간의 아키텍처를 통일함으로써, 비즈니스 요구사항에 좀 더 유연하고 유리하게 대응할 수 있습니다. 두 플랫폼이 동일한 구조와 로직을 공유하므로, 의사 결정 속도를 높이며 함께 구조를 설계할 수 있습니다. 또한, 신규 기능 개발 시 한쪽 OS 의 구현 난이도 종속성으로부터 벗어나게 되고 일정 산정에 유리해집니다.</p>

<p><strong>4. 테스트 용이성</strong><br />
플랫폼 제공 블루투스 코드는 하드웨어와 강하게 결합되어 있기 때문에 테스트 코드를 작성하기 어렵습니다. Mocking 이 어려우며 시뮬레이터에서는 테스트를 할 수 없습니다. 쏘카프레임은 새로운 추상화 계층을 쌓았기 때문에 하드웨어 의존성을 끊어내고 Mock 객체를 구현하기 용이해집니다. 물리적으로 재현하기 힘든 예외 상황(연결 끊김, 패킷 유실 등)을 코드로 검증할 수 있게 되어 시스템 안정성을 확보할 수 있게 됩니다.</p>

<p><strong>5. 오픈 소스로의 가능성</strong><br />
블루투스 모듈은 순수 플랫폼 로직(BluetoothCore)과 쏘카 전용 비즈니스 로직(BluetoothCommon)으로 두 개의 계층으로 분리되어있습니다. BluetoothHost, BluetoothRemote, BluetoothHandle, BluetoothSpec 등은 BluetoothCore 계층에 존재하며, 이것을 채택한 BluetoothRemoteSts, BluetoothHandleSts 등은 BluetoothCommon 계층에 존재합니다. 이러한 모듈화는 향후 BluetoothCore 를 오픈소스로 공개하여 기술 커뮤니티에 기여할 수 있는 가능성을 열어둡니다. 이는 쏘카의 기술이 사내 환경에 머물지 않고, 외부 개발자들과의 상호작용을 통해 더 견고하게 진화하며 선한 영향력을 퍼뜨릴 수 있는 생태계를 마련하는 데 의의가 있습니다.</p>

<h2 id="reference">Reference</h2>

<ul>
  <li>
    <p><a href="https://developer.android.com/develop/connectivity/bluetooth?hl=ko&amp;_gl=1*1ob5auz*_up*MQ..*_ga*Njk1OTYyMjE5LjE3Njc5NDIyNTY.*_ga_6HH9YJMN9M*czE3Njc5NDIyNTYkbzEkZzAkdDE3Njc5NDIyNTYkajYwJGwwJGgxODI3MTM0NjA1">android.bluetooth</a></p>
  </li>
  <li>
    <p><a href="https://developer.apple.com/documentation/corebluetooth">CoreBluetooth</a></p>
  </li>
</ul>]]></content><author><name>아벨</name></author><category term="socarframe" /><category term="socarframe" /><category term="mobile" /><category term="app" /><summary type="html"><![CDATA[쏘카는 어떻게 앱으로 차 문을 열까]]></summary></entry><entry><title type="html">쏘카프레임 - 앱 프레임워크와 개발자 경험</title><link href="https://tech.socarcorp.kr/socarframe/2026/01/07/socarframe-introduce.html" rel="alternate" type="text/html" title="쏘카프레임 - 앱 프레임워크와 개발자 경험" /><published>2026-01-07T06:00:00+00:00</published><updated>2026-01-07T06:00:00+00:00</updated><id>https://tech.socarcorp.kr/socarframe/2026/01/07/socarframe-introduce</id><content type="html" xml:base="https://tech.socarcorp.kr/socarframe/2026/01/07/socarframe-introduce.html"><![CDATA[<h2 id="안녕하세요-모바일-개발팀입니다">안녕하세요? 모바일 개발팀입니다</h2>

<p>쏘카 앱을 개발하는 모바일 개발자 아릉입니다. 쏘카는 2019년경 앱의 모든 코드를 새로 만드는 개편을 했습니다. 그때 이후로 지금까지 이어온 개발 철학과 그 실행에 대해서 소개하고, 또 이후 여러 글을 통해 그 과정에서 배우게 된 것들에 관해서도 이야기하려고 합니다.</p>

<h2 id="생산성">생산성</h2>

<blockquote>
  <p>모든 사람이 자유롭고 행복하게 이동하는 세상을 만듭니다.</p>
</blockquote>

<p>이것은 쏘카를 표현하는 문구입니다. 우리 팀은 이 비전을 실현하는 과정에서 개발팀의 행복과 성장에도 큰 가치를 두고 있습니다. 쏘카 앱 개편 당시, 더 나은 시스템을 구축하면서 동시에 개발자도 행복하게 코딩할 수 있는 환경을 조성하고자 했습니다. 회사와 구성원의 win-win을 추구해야 지속 가능하고 안정적인 미래가 열린다고 보았기 때문입니다. 그렇기 때문에 이 추구는 단순하게 기분이 좋거나, 여유로운 일정에 대한 것만은 아니었습니다.</p>

<p>잉여 곡물의 증가에 따라 문명이 발전할 수 있었듯이, 이러한 조직이 질적으로 개선되기 위해서는 잉여 시간을 확보하는 것이 필수적이라고 판단했습니다. 잉여 시간은 인프라 개선, 라이브러리 도입, 새로운 기술 학습 등 생산성 향상을 위한 재투자로 이어지고, 이는 다시 잉여 시간이 증가하는 선순환을 창출합니다. 그런데 대개 시장은 경쟁적이기 때문에, 시간 자원의 외연이 확장되기를 기다리고 있을 수는 없습니다. 따라서 조직 내부에서 잉여 시간을 얻기 위해서는 생산성을 향상하는 데에 집중해야 합니다.</p>

<h2 id="개발자-경험-developer-experience">개발자 경험 Developer Experience</h2>

<p>쏘카프레임 앱 프레임워크는 Android, iOS 네이티브 개발시 동일한 추상화 위에서 앱을 개발하기 위한 개발 프레임워크입니다. 개발자의 사고방식을 가이드하고, 논리를 규격화하는 것을 추구하고 있습니다. 플랫폼과 다양한 라이브러리를 동일한 기조로 래핑하여 사용성을 통일하고, 예외 처리를 위한 정규화된 방법을 제공합니다. 또한, 엄격한 코딩 컨벤션과 제식화된 기반을 통해 코드의 정합성을 높이고, 마치 한 사람이 작성한 듯이 코드의 일관성을 유지합니다. 이는 인지적 축약을 유도하여 생각을 할 때 헤매지 않게 하고, 사람의 버릇을 형성해서 실수를 없애고자 하는 의도가 반영되어 있습니다.</p>

<p>이를 통해 개발 과정에서 발생할 수 있는 불확실성을 최소화하고, 선택의 문제를 매번 새롭게 고민하지 않게 하며, 개발자가 콘텐츠를 조달하는 데 집중할 수 있도록 지원합니다. 모든 구현 철학과 택틱이 섬세하게 정립한 결과, 단순히 개발자의 가독성을 확보하는 것을 넘어, 서비스의 조달 시간을 단축하고 전형적 문제가 발생할 가능성을 차단합니다. 또 모든 팀원이 모든 부분을 동일한 사고 방식으로 수정할 수 있기 때문에 서로의 실수를 바로잡기에도 유리해지며, 위임이 편하기 때문에 일손 운용에도 유리합니다.</p>

<h2 id="플랫폼의-경계를-극복하기">플랫폼의 경계를 극복하기</h2>

<p>쏘카프레임 앱 프레임워크는 Android와 iOS 양 플랫폼이 공유할 수 있는 추상화의 경계를 드러내도록 만들어졌습니다. 그래서 한 쪽의 개발 경험만을 가진 사람이 다른 쪽의 코드를 봤을 때, 플랫폼 환경의 올바른 모듈적 구성을 이해하기 쉽고, 좀 더 빠르게 상대측의 코드를 이해할 수 있습니다. 현실적으로는, 서로 다른 프로젝트를 먼저 시작하고 나중에 교차 개발을 하는 방식을 통해 거시적 로드맵의 시간을 단축할 수 있기를 기대하고 있습니다. 이는 당연히, 크로스플랫폼 개발 환경을 사용하는 것에 비해서는 빠르지 않습니다. 대신, 기존에 네이티브 개발로 시작한 서비스에서 쉽게 채택할 수 있는 방법이고, 플랫폼 통합적 퀄리티와 장기적 안정성을 잃지 않는 장점이 있습니다.</p>

<p>높은 수준의 추상화 레이어를 쌓았으며, 넓은 커버리지를 제공하려 합니다. 필요할 법한 대부분의 요소에 대해 라이브러리나 택틱이 만들어져 있기 때문에, 뭐가 되고 뭐가 안 되는지 매번 새롭게 조사할 필요가 없습니다. 없는 건 할 법하지 않으니까 없는 것이고, 되는 건 매뉴얼에 있습니다.</p>

<p>또 쏘카프레임을 구성하면서 신경을 쓴 부분 중 하나가, 화면만 웹으로 갈아끼운 것처럼 만들 수 있고, 또 필요에 따라 어느 웹 화면을 다시 네이티브로 구현해도 문제가 없도록 하는 것이었습니다. 이것은 앱 개발자만이 아니라, 웹 개발자까지 포함해서 일손을 효율적으로 활용하는 데에 도움이 될 것입니다. 동시에 서비스 유동성과 UI 완성도 간에 선택 가능성을 넓히기 위한 것이기도 합니다.</p>

<h2 id="개인차와-성장">개인차와 성장</h2>

<p>앱 개발은 백엔드 개발에 비해 플랫폼에 대한 지식과 맥락적 이해가 중요한 역할을 합니다. 그런데 어느 플랫폼이든 유저에게 닿고자 하는 동일한 이상을 위하여 스택을 쌓아 올리는 것이고, 또 한 시대의 기술적 방향성도 완전히 다를 수는 없습니다. 그렇기 때문에 구체적 플랫폼이 달라도 모바일 OS라는 정체성을 가진 한 큰 맥락은 같을 수밖에 없습니다. 그렇지만 누구나 그런 공통점을 잘 발견하고 추상화할 수 있는 능력을 갖추고 있는 상태는 아닙니다. 주니어 개발자에게 단기간에 플랫폼 지식을 주입하여 성장을 기대하는 것은 불확실하고 비효율적이며, 제품 안정성에도 부정적인 영향을 미칠 수 있습니다. 따라서 우리의 프레임워크를 만들 때, 콘텐츠를 구현하는 사람에게는 플랫폼 지식의 영향을 최소화하고, 라이브러리를 개발하는 사람은 기존에 만들어진 주변 라이브러리의 설계와 철학으로부터 도움을 받을 수 있도록 했습니다.</p>

<p>이런 환경 속에서 주니어의 경우 초기 진입 장벽이 높아지지만, 난이도가 올라가는 게 아니라 배울 양이 많아지는 것입니다. 쏘카프레임 영역의 코드는, 플랫폼에 대한 아주 정확한 설명서이기도 합니다. 주니어는 비즈니스 기능을 개발하면서 이 설명서를 계속 접하게 되고, 그러면서 점점 플랫폼이나 환경의 진실한 모습을 이해해갑니다. 시니어는 플랫폼이나 환경의 변화를 미리 쏘카프레임에 반영해두고, 또 앞으로 필요할 것으로 여겨지는 기반 기능을 개발합니다. 대체로 할 것, 배울 것, 미리 해둘 것이 항상 존재합니다. 이것들을 명확하게 나눠 진행할 수 있다는 점이, 일손을 효율적으로 운용하는 데에 도움이 됩니다.</p>

<h2 id="앱-프레임워크가-가져오는-효과">앱 프레임워크가 가져오는 효과</h2>

<p>쏘카프레임 앱 프레임워크가 가져오는 효과들을 나열하면 다음과 같습니다.</p>

<ul>
  <li>
    <p>제품 안정성 향상: 정형화된 코드는 자잘한 실수를 예방하고 제품 안정성을 향상합니다.</p>
  </li>
  <li>
    <p>개발 속도 향상: 불필요한 고민을 줄이고 콘텐츠 구현에 집중하여 개발 속도를 높였습니다.</p>
  </li>
  <li>
    <p>스트레스 감소: 예측할 수 있는 개발 환경은 스트레스를 줄이고, 개발자가 업무에 몰입할 수 있도록 지원합니다.</p>
  </li>
  <li>
    <p>기획 피드백 속도: 넓은 커버리지를 제공하기 때문에 대부분의 구현안을 세부 사항까지 빠르게 검토할 수 있습니다.</p>
  </li>
  <li>
    <p>인적 구성에 유리함: 주니어가 빠르게 배우고 콘텐츠 개발에 기여할 수 있게 합니다. 소수의 시니어가 큰 영향력을 행사할 수 있습니다.</p>
  </li>
  <li>
    <p>개발자 성장 촉진: 프레임워크를 이해하고 기여하며 플랫폼에 대한 이해와 개인의 설계 능력을 향상합니다. 누적된 논리 기반 위에서 사고의 품질을 높일 수 있습니다.</p>
  </li>
  <li>
    <p>효율적인 시간 활용: 콘텐츠 개발 일정에 여유가 있을 때는 프레임워크를 개선하여 끊임없는 발전을 도모합니다.</p>
  </li>
  <li>
    <p>AI 코드 완성의 정확성: 인간이 배우기 좋은 코드 택틱은 AI 입장에서도 배우기 좋습니다. 일관된 기존 코드는 훌륭한 트레이닝 셋이 됩니다.</p>
  </li>
</ul>

<h2 id="실제로-행동하기">실제로 행동하기</h2>

<p>가장 큰 어려움은, 이런 의도에 걸맞게 실제로 행동하는 것입니다. 특정 아키텍처를 사용한다고 말하는 팀들도 앱 내의 모든 코드가 단일 규격을 따르는 경우는 드뭅니다. 예외 없이 모든 코드가 지침을 따르기 위해서는 사람들에게 정규화된 코딩의 이점을 이해시킬 필요가 있습니다.</p>

<p>또, 실제로는 한 벌의 프레임워크 세트를 만들어 뒀다고 해서 그것을 영구히 사용할 수는 없습니다. 모바일 그룹은 현재 프레임워크의 효율성에 안주하지 않고, 미래를 위한 준비에도 힘쓰고 있습니다. 구성원들의 아키텍처 이해도를 높이고, 변화하는 기술 트렌드에 대응하여 다음 세대의 프레임워크를 구축하는 것도 준비하고 있습니다.</p>]]></content><author><name>아릉</name></author><category term="socarframe" /><category term="socarframe" /><category term="mobile" /><category term="app" /><summary type="html"><![CDATA[안녕하세요? 모바일 개발팀입니다]]></summary></entry><entry><title type="html">FE Core팀의 CI 속도전: 캐시 전략을 활용한 병렬 빌드</title><link href="https://tech.socarcorp.kr/fe/2025/06/10/monorepo-ci-cd-pipeline.html" rel="alternate" type="text/html" title="FE Core팀의 CI 속도전: 캐시 전략을 활용한 병렬 빌드" /><published>2025-06-10T15:00:00+00:00</published><updated>2025-06-10T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/fe/2025/06/10/monorepo-ci-cd-pipeline</id><content type="html" xml:base="https://tech.socarcorp.kr/fe/2025/06/10/monorepo-ci-cd-pipeline.html"><![CDATA[<p><br /></p>

<h1 id="목차">목차</h1>

<ol>
  <li><a href="#1-개요">개요</a></li>
  <li><a href="#2-기존-파이프라인-구조와-한계">기존 파이프라인 구조와 한계</a>
    <ol>
      <li><a href="#21-monorepo-환경의-ci-요구사항">Monorepo 환경의 CI 요구사항</a></li>
      <li><a href="#22-빌드-시간-및-신뢰성-이슈">빌드 시간 및 신뢰성 이슈</a></li>
    </ol>
  </li>
  <li><a href="#3-개선-전략-및-구현">개선 전략 및 구현</a>
    <ol>
      <li><a href="#31-runner-사양-개선">Runner 사양 개선</a></li>
      <li><a href="#32-병렬-빌드matrix-도입">병렬 빌드(Matrix) 도입</a></li>
      <li><a href="#33-캐시를-활용한-빌드-최적화">캐시를 활용한 빌드 최적화</a></li>
      <li><a href="#34-빌드-검증-단계-분리">빌드 검증 단계 분리</a></li>
    </ol>
  </li>
  <li><a href="#4-결과">결과</a></li>
  <li><a href="#5-후기">후기</a></li>
</ol>

<hr />

<p><br /><br /></p>

<h1 id="1-개요">1. 개요</h1>

<p>안녕하세요 FE Core팀 아놀드입니다.</p>

<p>FE Core팀은 최근 배포 빈도와 변경사항이 많은 monorepo 환경을 관리하며, 효율적이고 안정적인 CI 파이프라인 구축을 목표로 다양한 전략을 도입하였습니다.</p>

<p>본 글에서는 실제로 적용한 빌드 성능 개선, 캐시 활용 방안, 그리고 각 전략의 효과를 정리하였습니다.</p>

<h1 id="2-기존-파이프라인-구조와-한계">2. 기존 파이프라인 구조와 한계</h1>

<h2 id="21-monorepo-환경의-ci-요구사항">2.1 Monorepo 환경의 CI 요구사항</h2>

<p>현재 turborepo 기반의 mono repository에는 30여 개의 독립적인 상용 프로젝트가 공존하고 있습니다.<br />
각 프로젝트는 별도의 팀에서 운영하며, 배포 일정과 서비스 특성이 상이합니다. 이로 인해, main 브랜치(main branch)로의 병합이 자주 발생하며, <code class="language-plaintext highlighter-rouge">pnpm-lock.yaml</code> 파일의 잦은 변경이 수반됩니다.</p>

<p>패키지 의존성 변동을 최소화하기 위해 core 라이브러리 버전을 고정하고 caret(캐럿) 범위 사용을 제한하였으나, 하위 패키지의 caret 사용까지 완전히 통제하는 데에는 한계가 있었습니다. 결과적으로 main 브랜치에 변경이 발생하면, 의존성 변경 여부에 따라 모든 프로젝트에 대한 빌드 및 안정성 검증이 필요합니다.</p>

<h2 id="22-빌드-시간-및-신뢰성-이슈">2.2 빌드 시간 및 신뢰성 이슈</h2>

<p>CI 파이프라인은 아래와 같은 구조로 운영되고 있었습니다.</p>

<ul>
  <li>Turborepo 원격 캐시 서버를 별도 운영(Kubernetes 기반)</li>
  <li><code class="language-plaintext highlighter-rouge">dorny/paths-filter@v3</code>를 활용하여 불필요한 빌드를 최소화</li>
</ul>

<p>그럼에도 불구하고 캐시 미적중 시 30개 이상의 Next.js 앱 전체 빌드에 평균 20분 이상이 소요되었습니다. 여러 워크플로우가 동시에 실행되는 경우, 브랜치 병합 대기 시간은 30분 이상으로 증가하였습니다.</p>

<p>또한, 빌드가 길어질 경우 워크플로우가 중단되거나, <code class="language-plaintext highlighter-rouge">Error: The operation was canceled.</code>와 같은 오류가 빈번하게 발생하였습니다.</p>

<p>아래 이미지는 실제로 파이프라인 실행 시간이 32분을 넘긴 사례입니다. 이처럼 빌드 확인 단계에서 워크플로우가 강제 종료되는 일이 반복되었습니다.</p>

<p><img src="/img/2025-06-11-monorepo-ci-cd-pipeline/runtime.png" alt="runtime.png" /></p>

<h1 id="3-개선-전략-및-구현">3. 개선 전략 및 구현</h1>

<p>빌드 단계를 생략할 수 없으므로, <strong>빌드 시간 단축</strong>이 최우선 과제로 선정되었습니다.<br />
아래와 같은 방향으로 개선을 추진하였습니다.</p>

<h2 id="31-runner-사양-개선">3.1 Runner 사양 개선</h2>

<p>우선, 기존에 사용하던 Ubuntu Runner의 메모리와 코어 수를 상향 조정하였습니다. 이를 통해 파이프라인 전체 실행 시간은 약 20분에서 10분대로 단축되었으며, 중단 오류 없이 안정적으로 빌드가 완료되었습니다.</p>

<ul>
  <li>기존 Runner</li>
</ul>

<p><img src="/img/2025-06-11-monorepo-ci-cd-pipeline/originrun.png" alt="originrun.png" /></p>

<ul>
  <li>변경된 Runner</li>
</ul>

<p><img src="/img/2025-06-11-monorepo-ci-cd-pipeline/changerun.png" alt="changerun.png" /></p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">구분</th>
      <th style="text-align: center">Runner 사양 변경 전</th>
      <th style="text-align: center">Runner 사양 변경 후</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">평균 빌드 시간</td>
      <td style="text-align: center">20분대</td>
      <td style="text-align: center">10분대</td>
    </tr>
  </tbody>
</table>

<h2 id="32-병렬-빌드matrix-도입">3.2 병렬 빌드(Matrix) 도입</h2>

<p>Runner 성능 개선 이후에도 단일 프로세스에서 30개 프로젝트 전체를 빌드하는 데 10분 이상이 소요되었습니다. 따라서, <a href="https://docs.github.com/ko/enterprise-cloud@latest/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow"><strong>GitHub Workflow의 Matrix 전략</strong></a>을 도입하여 각 프로젝트의 빌드를 병렬로 수행하도록 워크플로우를 개편하였습니다.</p>

<p>Matrix 전략은 다음과 같은 이점을 제공합니다.</p>

<ul>
  <li>프로젝트별 빌드를 동시에 진행(병렬화)</li>
  <li>각 빌드 성공 여부 개별 확인</li>
  <li>캐시 서버를 활용한 효율적 리소스 분배</li>
</ul>

<p><strong>구현 예시</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="na">generate-matrix</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-22.04-cores</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">matrix</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">저장소 체크아웃</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">빌드 대상 패키지 목록 생성</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">set-matrix</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">echo "::set-output name=matrix::$(bash .github/scripts/get-packages.sh)"</span>

  <span class="na">build</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">generate-matrix</span><span class="pi">]</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-22.04-cores</span>
    <span class="na">strategy</span><span class="pi">:</span>
      <span class="na">matrix</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">fail-fast</span><span class="pi">:</span> <span class="kc">false</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">빌드</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm build --filter=$...</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th style="text-align: center">구분</th>
      <th style="text-align: center">전체 빌드</th>
      <th style="text-align: center">Matrix 적용 후</th>
      <th style="text-align: center">변화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">캐시 미적중 시</td>
      <td style="text-align: center">10m27s</td>
      <td style="text-align: center">8m6s</td>
      <td style="text-align: center">-2m21s(-22.5%)</td>
    </tr>
    <tr>
      <td style="text-align: center">캐시 적중 시</td>
      <td style="text-align: center">1m14s</td>
      <td style="text-align: center">6m57s</td>
      <td style="text-align: center">+5m43s(463.5%)</td>
    </tr>
  </tbody>
</table>

<p>※ <strong>캐시 적중 시에는 병렬화 오버헤드로 인해 빌드 시간이 증가하는 단점이 있었습니다.</strong></p>

<p>Matrix 전략 적용 후, 각 프로젝트의 빌드 성공 여부를 별도로 확인할 수 있고
아래와 같이 status check가 표시됩니다.</p>

<p><img src="/img/2025-06-11-monorepo-ci-cd-pipeline/matrix.png" alt="matrix.png" /></p>

<h2 id="33-캐시-최적화">3.3 캐시 최적화</h2>

<p>Matrix 병렬 빌드 도입 후, 캐시 적중 시 오히려 시간이 늘어나는 현상을 해결하기 위해 <code class="language-plaintext highlighter-rouge">turborepo</code>의 <a href="https://turborepo.com/docs/crafting-your-repository/caching#using-dry-runs"><strong>dry-run</strong></a> 기능을 활용하였습니다.</p>

<p><img src="/img/2025-06-11-monorepo-ci-cd-pipeline/idea.png" alt="idea.png" /></p>

<p><code class="language-plaintext highlighter-rouge">turbo run build --dry-run</code> 명령을 통해 모든 패키지의 캐시 상태를 사전 점검하고, 위 사진의 내용과 같은 구조로 오버헤드를 최소화하였습니다.</p>

<ul>
  <li>
    <p>모든 패키지가 캐시된 경우 빌드 단계를 건너뜀</p>
  </li>
  <li>
    <p>캐시 미적중 패키지만 matrix 대상으로 빌드 실행</p>
  </li>
</ul>

<p>프로젝트별 캐시 여부를 확인하여 상황에 따라 빌드 프로세스를 분기하는 구조를 아래와 같이 구현하였습니다.</p>

<p><strong>구현 예시</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">turborepo 캐시 확인</span>
  <span class="na">id</span><span class="pi">:</span> <span class="s">check-cache</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">./.github/scripts/check-turborepo-cache.sh</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">빌드 스킵</span>
  <span class="na">if</span><span class="pi">:</span> <span class="s">$</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">echo "모든 빌드 대상이 캐시되어 있어, 진행하지 않습니다."</span>
</code></pre></div></div>

<p>이를 통해 캐시 적중 시 시간이 늘어나는 현상을 아래와 같이 해결할 수 있게 되었습니다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">구분</th>
      <th style="text-align: center">전체 빌드</th>
      <th style="text-align: center">Matrix+Dry 적용 후</th>
      <th style="text-align: center">변화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">캐시 미적중 시</td>
      <td style="text-align: center">10m27s</td>
      <td style="text-align: center">5m29s</td>
      <td style="text-align: center">-4m58s(-47.5%)</td>
    </tr>
    <tr>
      <td style="text-align: center">캐시 적중 시</td>
      <td style="text-align: center">1m14s</td>
      <td style="text-align: center">1m11s</td>
      <td style="text-align: center">-3s(-4%)</td>
    </tr>
  </tbody>
</table>

<h2 id="34-빌드-검증-단계-분리">3.4 빌드 검증 단계 분리</h2>

<p>Workflow Matrix를 활용하면, 개별 빌드 결과가 각각의 <code class="language-plaintext highlighter-rouge">status check</code>로 기록됩니다.
브랜치 보호 정책을 단일 status로 관리하기 위해, 빌드 완료 후 추가 검증 단계를 도입하였습니다.</p>

<p><strong>구현 예시</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">verify-build</span><span class="pi">:</span>
<span class="na">needs</span><span class="pi">:</span> <span class="s">build-matrix</span>
<span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-22.04-4-cores</span>
<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">모든 빌드 성공 확인</span>
<span class="na">run</span><span class="pi">:</span> <span class="s">echo "모든 build-matrix 작업이 성공적으로 완료되었습니다."</span>
</code></pre></div></div>

<p><br /></p>
<h1 id="4-결과">4. 결과</h1>

<p>Runner 사양 개선, 병렬 빌드(Matrix) 도입, 캐시 상태 사전 점검, 빌드 검증 단계 분리 등 여러 전략을 조합함으로써,
전체 CI 파이프라인 빌드 시간을 최대 <strong>84%</strong>까지 단축할 수 있었습니다.</p>

<ul>
  <li>
    <p>빌드 미적중 시 30분대 → 5분대 단축</p>
  </li>
  <li>
    <p>캐시 적중 시 오버헤드 최소화 및 불필요 빌드 방지</p>
  </li>
  <li>
    <p>브랜치 보호 정책을 단일 status로 관리</p>
  </li>
</ul>

<p>특히, 프로젝트 수가 증가하더라도 빌드 속도와 안정성의 하락 없이 효율적으로 대응할 수 있는 구조를 마련하였습니다.</p>

<h1 id="5-후기">5. 후기</h1>

<p>이번 CI 파이프라인 개선 작업은 단순한 빌드 속도 향상을 넘어,
효율적인 자동화와 체계적인 캐시 전략의 중요성을 다시 한 번 실감하는 계기가 되었습니다.</p>

<p>여러 실험과 시행착오를 통해 실제로 체감할 수 있는 성능 개선 효과를 얻었으며,
이 경험을 바탕으로 앞으로도 더 나은 개발 환경을 만들어 나가고자 합니다.</p>

<p>앞으로 프로젝트 규모가 커지더라도 안정적이고 유연하게 대응할 수 있도록, 지속적으로 자동화와 최적화 방안을 탐구할 계획입니다.</p>]]></content><author><name>아놀드</name></author><category term="fe" /><category term="github workflow" /><category term="turborepo" /><category term="monorepository" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">자동화는 처음이라: 실험과 실패, 그리고 성장</title><link href="https://tech.socarcorp.kr/qa/2025/06/01/qa-automation-environment.html" rel="alternate" type="text/html" title="자동화는 처음이라: 실험과 실패, 그리고 성장" /><published>2025-06-01T15:00:00+00:00</published><updated>2025-06-01T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/qa/2025/06/01/qa-automation-environment</id><content type="html" xml:base="https://tech.socarcorp.kr/qa/2025/06/01/qa-automation-environment.html"><![CDATA[<h1 id="1-qa-자동화의-필요성">1. QA 자동화의 필요성</h1>

<p>QA는 프로젝트에 참여하면 PM, 디자이너, 개발자뿐만 아니라 테스터인 외주 인력과 함께 다양한 유형(스모크 테스트, 기능 테스트, 리그레션 테스트, 확인 테스트 등)의 테스트를 진행하고 sign off를 하게 됩니다. 최근 QA팀은 외주 인력을 활용한 수동 테스트에 의존하여 많은 리소스를 소모하고 있었습니다. 이는 테스트 실행 속도가 느리고, 인력 관리의 어려움으로 인해 효율적인 테스트 환경을 구축하는 데 한계가 있었습니다. 이에 따라, 자동화 테스트를 도입하여 리소스를 절감하고 효율성을 높이는 것이 중요한 목표로 설정되었습니다.</p>

<p>자동화 테스트에는 크게 E2E 테스트와 API 테스트 정도로 많이 활용되는데요. E2E 테스트는 전체 시스템의 동작을 점검하고 실제 사용자 흐름을 검증하지만, API 테스트는 서버 간의 데이터 통신만 검사하는 방식입니다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th><strong>E2E 테스트</strong></th>
      <th><strong>API 테스트</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>목적</strong></td>
      <td>전체 시스템의 기능을 사용자 관점에서 테스트</td>
      <td>API 요청과 응답이 올바르게 처리되는지 확인</td>
    </tr>
    <tr>
      <td><strong>범위</strong></td>
      <td>UI + 백엔드 시스템 전체</td>
      <td>백엔드의 API만 테스트</td>
    </tr>
    <tr>
      <td><strong>검증 항목</strong></td>
      <td>UI 상호작용, 비즈니스 로직, 사용자 흐름</td>
      <td>요청/응답, 상태 코드, 응답 데이터</td>
    </tr>
    <tr>
      <td><strong>실행 시간</strong></td>
      <td>시간이 오래 걸리고 복잡함</td>
      <td>빠르고 간단함</td>
    </tr>
    <tr>
      <td><strong>도구</strong></td>
      <td>Selenium, Cypress, Playwright 등</td>
      <td>Postman, SoapUI 등</td>
    </tr>
  </tbody>
</table>

<p>검증 자체의 효과는 E2E 테스트가 좋을 순 있지만, 화면의 변동성이 크다면 유지보수에 리소스가 많이 투자되는 번거로움이 존재합니다. 반면에 API는 한번 구현해두면 변동성이 적다는 장점이 있습니다. 그래서 “API를 활용하여 간단한 플로우부터 구현해보자!” 라는 아이디어를 가지고 자동화를 시작하게 되었습니다.</p>

<p><br /></p>

<h1 id="2-초기-자동화-아키텍처">2. 초기 자동화 아키텍처</h1>

<p>초기에는 사내에 기존 자동화 히스토리를 참고하여 Jenkins 기반으로 구축하기로 하였습니다. 초기 모델은 Zephyr에서 테스트해야 할 항목을 제공하고, 그에 맞게 Postman Script를 실행하여 얻은 결과를 Slack으로 공유하는 방식으로 구성하였습니다.</p>

<p><br /></p>
<div style="display: flex; gap: 10px;">
  <img src="/img/2025-06-02-qa-automation-environment/automation_flow_1.jpg" style="width: 48%;" />
  <img src="/img/2025-06-02-qa-automation-environment/automation_flow.png" style="width: 48%;" />
</div>

<h2 id="21-jenkins의-문제">2.1 Jenkins의 문제</h2>

<p>Jenkins를 직접 로컬에 설치해보고 EC2 환경에 구축해보면서 몇 가지 단점이 있었습니다.</p>

<ol>
  <li>직접 Jenkins 서버 환경을 구축하고 관리해야 하는 어려움</li>
  <li>AWS EC2 환경에 대한 지식이 필요하여 러닝 커브가 있음</li>
  <li>설정이 복잡하여 GitHub Actions 같은 간단한 YAML 기반의 CI/CD보다 접근성이 낮음</li>
</ol>

<p>이런 단점을 보완하고자 이전에 웹 환경의 E2E 자동화를 했던 경험을 바탕으로 GitHub Actions를 구현하여 제안을 해보게 되었습니다.</p>

<p><br /></p>

<h1 id="3-github-actions-도입-과정">3. GitHub Actions 도입 과정</h1>

<p>도입에 앞서 기존 구조가 달라지기 때문에 팀원들을 설득해야 했습니다. 그래서 구조를 다시 잡고 PoC 형태로 간단하게 구현하여 팀원들에게 공유하였습니다.</p>

<p>구현 과정은 다음과 같습니다:</p>

<h2 id="31-자동화-아키텍처">3.1 자동화 아키텍처</h2>

<p><br /></p>

<p><img src="/img/2025-06-02-qa-automation-environment/automation_architecture.png" alt="자동화_플로우" /></p>

<p><br /></p>

<h2 id="32-자동화-poc">3.2 자동화 PoC</h2>

<h4 id="1-로그인-회원가입-관련-postman-api-script-작성">1) 로그인, 회원가입 관련 Postman API Script 작성</h4>
<ul>
  <li>
    <p>ID, PW 올바르게 입력한 경우 → 200  ⇒ 성공
<img src="/img/2025-06-02-qa-automation-environment/script_1.png" alt="자동화_플로우" /></p>
  </li>
  <li>
    <p>올바르지 않은 PW 입력한 경우, 올바르지 않은 ID 입력한 경우 → 400 ⇒ 성공
<img src="/img/2025-06-02-qa-automation-environment/script_2.png" alt="자동화_플로우" />
<img src="/img/2025-06-02-qa-automation-environment/script_3.png" alt="자동화_플로우" /></p>
  </li>
</ul>

<p><br /></p>

<h4 id="2-postman-script-파일-github-merge">2) postman script 파일 github merge</h4>
<ul>
  <li>테스트용으로 feat/slack-api 브랜치 → main 브랜치에 merge</li>
</ul>

<p><br /></p>

<h4 id="3-collections을-github-공용-runner에서-실행">3) collections을 github 공용 runner에서 실행</h4>
<ul>
  <li>
    <p>github actions workflow 사용을 위한 .yml 파일 생성 
<img src="/img/2025-06-02-qa-automation-environment/github_1.png" alt="자동화_플로우" /></p>
  </li>
  <li>
    <p>github에서 제공하는 cloud runner에서 newman 실행
<img src="/img/2025-06-02-qa-automation-environment/github_2.png" alt="자동화_플로우" /></p>
  </li>
</ul>

<p><br /></p>

<h4 id="4-수행한-결과를-slack-채널에-알림을-보냄">4) 수행한 결과를 slack 채널에 알림을 보냄</h4>
<p style="width: 60%; display: block; margin: 0 auto;"><img src="/img/2025-06-02-qa-automation-environment/newman_1.png" alt="slack 알림" /></p>

<p><br /></p>

<h2 id="33-jenkins와-github-actions-비교">3.3 Jenkins와 GitHub Actions 비교</h2>
<p>Jenkins는 많은 기업에서 CI/CD 도구로 사용되어 온 대표적인 자동화 도구입니다. 하지만, Jenkins를 EC2에 구축하고 관리하는 방식은 여러 가지 문제를 안고 있었습니다. 서버의 안정성을 보장하기 위해 추가적인 관리 리소스가 필요했고, 플러그인 업데이트나 서버 유지보수, 백업 작업 등을 수시로 해야 했습니다. 이로 인해 운영의 복잡성이 증가했으며, 각종 플러그인 간의 호환성 문제도 발생할 수 있었습니다.</p>

<p>반면, GitHub Actions는 GitHub 플랫폼 내에서 제공되는 CI/CD 서비스로, 서버 관리의 부담을 덜어주는 장점이 있었습니다. 별도의 서버를 구축할 필요 없이 GitHub의 리소스를 그대로 활용할 수 있으며, 코드와 CI/CD 파이프라인을 하나의 저장소 내에서 관리할 수 있어 관리의 효율성이 높았습니다.</p>

<p><br /></p>

<h2 id="34-github-actions-도입-전략과-팀-온보딩-경험">3.4 GitHub Actions 도입 전략과 팀 온보딩 경험</h2>
<p>API 자동화 프로젝트를 진행하면서 GitHub Actions를 도입해 자동화 효율을 높이는 동시에, 팀원들과의 협업과 온보딩을 통해 팀 전체의 역량을 함께 끌어올렸습니다.</p>

<p>우선 GitHub Actions 도입을 위해 두 가지 주요 전략을 설정했습니다.</p>

<blockquote>
  <p>💡 <strong>전략</strong></p>
  <ol>
    <li>기존 Jenkins 기반의 스크립트를 GitHub Actions 환경에 맞게 YAML 형식으로 수정하고, 저장소에 푸시한 후 반복적인 검증을 통해 점진적으로 이식해 나갔습니다.</li>
    <li>GitHub Actions의 스케줄 기능을 활용해 테스트를 정기적으로 실행하도록 설정함으로써, 수동 테스트의 부담을 줄이고 QA팀이 자동화된 테스트 결과를 주기적으로 확인할 수 있도록 했습니다.</li>
  </ol>
</blockquote>

<p>도입 과정에서 팀원들이 Git과 GitHub Actions 사용에 어려움을 겪었기 때문에, 이를 해결하기 위해 직접 온보딩 세션을 진행했습니다. 세션에서는 Git과 GitHub의 기본 개념을 실습 중심으로 설명하며, GitHub Actions를 활용한 API 자동화 워크플로우 작성 방법을 공유했습니다. 또한, Postman 환경 변수를 GitHub Actions에서 적용하는 방법, GitHub Secrets 관련 오류 해결법 등 실무에서 바로 활용할 수 있는 내용을 포함하여 팀원들이 독립적으로 자동화 작업을 수행할 수 있도록 도왔습니다.</p>

<p>이러한 전략적 도입과 적극적인 온보딩을 통해 팀원들은 GitHub Actions 기반의 워크플로우를 독립적으로 작성하고 관리할 수 있는 역량을 갖추게 되었고, 결과적으로 팀 전체의 자동화 생산성을 크게 향상시킬 수 있었습니다.</p>

<p><br /></p>

<h1 id="4-github-actions-도입-성과와-postman-자동화의-한계">4. GitHub Actions 도입 성과와 Postman 자동화의 한계</h1>
<p>GitHub Actions 도입을 통해 얻은 가장 큰 성과 중 하나는 작업 기간의 대폭 단축이었습니다. Jenkins를 사용할 경우 새로운 환경 구축에 약 4개월이 소요될 것으로 예상되었지만, GitHub Actions는 간결한 구성 요소와 직관적인 설정 방식 덕분에 단 1개월 만에 환경을 구축할 수 있었습니다. 작업 기간을 약 75% 단축함으로써, 팀의 생산성과 효율성을 크게 향상시킬 수 있었습니다.</p>

<p>또한, GitHub Actions 기반의 CI/CD 파이프라인을 통해 API 테스트 자동화 기반을 성공적으로 구축했습니다. 이를 바탕으로 향후에는 배포 주기에 맞춰 테스트가 자동으로 수행되는 체계를 갖출 수 있는 기반을 마련했습니다.</p>

<p>하지만 GitHub Actions와 Postman을 활용한 자동화가 모든 상황에서 이상적인 것은 아니었습니다. 실제로 도입 후 특정 도메인 서버 구조에 적용해보려 했을 때 예상치 못한 구조적 한계에 직면했습니다. 해당 도메인은 gRPC와 REST API가 혼합된 서버 아키텍처였고, 우리가 구축한 Postman 기반 스크립트는 REST API 호출에 최적화되어 있었습니다. 이로 인해 gRPC 호출이 포함된 전체 흐름의 시나리오 구현에는 어려움이 있었습니다.</p>

<p>결과적으로, 기존 스크립트만으로는 플로우 전반의 유효성 검증이나 상태 전이 기반 테스트 한계가 있었고, 보다 복잡한 테스트 환경이 필요하다는 결론에 도달하게 되었습니다.</p>

<p><br /></p>

<h1 id="5-실패를-통해-얻은-방향-전환">5. 실패를 통해 얻은 방향 전환</h1>
<p>이 경험은 단순히 도입을 보류하는 데 그치지 않았습니다. 오히려 자동화 전략을 재정립하는 계기가 되었습니다. 전 구간 자동화는 어렵더라도, 테스트 데이터 생성, 사전 조건 세팅, 반복 작업 자동화 등 효율성을 높일 수 있는 현실적인 영역부터 자동화를 적용하고 있습니다.</p>

<p>현재는 각 도메인별로 작은 단위부터 자동화를 점진적으로 확장하며, 실제 테스트 환경에서 바로 활용할 수 있는 실용적인 자동화 도구들을 구축해가고 있습니다.</p>

<p>완벽한 자동화보다는 지속 가능한 자동화, 지금 당장 효과를 줄 수 있는 부분부터 개선해나가는 것이 현재 우리가 택한 방향이며, 앞으로도 더 나은 테스트 환경을 위해 끊임없이 개선해나갈 예정입니다.</p>]]></content><author><name>시오</name></author><category term="qa" /><category term="qa" /><category term="automation" /><summary type="html"><![CDATA[1. QA 자동화의 필요성]]></summary></entry><entry><title type="html">로그 파이프라인 개선기 - 기존 파이프라인 문제 정의 및 해결 방안 적용</title><link href="https://tech.socarcorp.kr/data/2025/02/25/log-pipeline-revamp.html" rel="alternate" type="text/html" title="로그 파이프라인 개선기 - 기존 파이프라인 문제 정의 및 해결 방안 적용" /><published>2025-02-25T15:00:00+00:00</published><updated>2025-02-25T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/data/2025/02/25/log-pipeline-revamp</id><content type="html" xml:base="https://tech.socarcorp.kr/data/2025/02/25/log-pipeline-revamp.html"><![CDATA[<h1 id="1-들어가며">1. 들어가며</h1>

<p>안녕하세요. 쏘카 데이터엔지니어링팀 삐약, 루디입니다.</p>

<p>내용을 시작하기에 앞서, 저희 팀의 업무와 역할에 대해 간략히 소개해 드리겠습니다.</p>

<p>데이터엔지니어링팀은 신뢰할 수 있는 데이터를 쏘카 구성원들이 안정적으로 활용할 수 있도록 기반을 마련하고, 이를 실제 비즈니스에 적용할 수 있는 서비스를 개발하며 환경을 구축하고 있습니다. 데이터 마트 관리, 데이터 인프라 구축, 그리고 데이터 제품(Data as a Product) 개발 등 폭넓은 업무를 수행하고 있습니다.</p>

<p>특히 주요 업무로는 배치 및 실시간 스트리밍 파이프라인을 설계하고 개발하여, 쏘카의 모든 서비스에서 발생하는 데이터를 비즈니스 분석에 효과적으로 활용할 수 있도록 지원하는 역할을 하고 있습니다.</p>

<p>이번 글에서는 저희 팀이 관리 및 운영하는 데이터 파이프라인 중, 비즈니스 의사 결정 시 지표로 사용되는 서버 로그를 데이터 웨어하우스로 사용하고 있는 BigQuery에 적재하는 <strong>로그 파이프라인 개선 과정</strong>을 소개드리고자 합니다</p>

<p>개선을 하게 된 가장 주요 이유 중 하나는 데이터 스키마 변경으로 인해 겪는 어려움 이었습니다. 이를 해결하기 위해 데이터 컨트랙트를 도입하게 되었고, 이 과정에서 얻은 경험을 나누고자 합니다. 이번 시리즈는 비슷한 문제를 겪고 계신 분들께 도움이 되길 바랍니다.</p>

<h3 id="이-글이-유용한-대상">이 글이 유용한 대상</h3>

<ul>
  <li>데이터 파이프라인을 구축하거나 개선하고자 하는 데이터 엔지니어</li>
  <li>데이터 컨트랙트를 도입하려는 개발자</li>
  <li>데이터 엔지니어의 업무에 대해 궁금한 분</li>
</ul>

<p><br /></p>
<h1 id="2-기존-로그-파이프라인-현황">2. 기존 로그 파이프라인 현황</h1>

<p>기존 파이프라인을 설명하기에 앞서, 아키텍처의 문제를 더 명확히 이해하기 위해 원본 데이터의 구조와 요구사항을 먼저 살펴보겠습니다.</p>

<h2 id="2-1-원본-데이터-구조-및-요구사항">2-1. 원본 데이터 구조 및 요구사항</h2>

<p>원본 데이터에서 하나의 로그 파일은 다음과 같은 형식으로 제공됩니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="p">...</span>
 <span class="p">...</span>
 <span class="p">...</span>
<span class="p">{</span><span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">TYPE101</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">logDetails</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span><span class="dl">"</span><span class="s2">timeMs</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1704067200000</span><span class="p">,</span> <span class="dl">"</span><span class="s2">field1</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">$TrIN@</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">field2</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1234-5678</span><span class="dl">"</span><span class="p">,</span> <span class="p">...}}</span>
<span class="p">{</span><span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">TYPE102</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">logDetails</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span><span class="dl">"</span><span class="s2">timeMs</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1704070800000</span><span class="p">,</span> <span class="dl">"</span><span class="s2">field3</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span><span class="dl">"</span><span class="s2">key1</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">value1</span><span class="dl">"</span><span class="p">},</span> <span class="dl">"</span><span class="s2">field4</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">abcd-efgh-ijkl</span><span class="dl">"</span><span class="p">,</span> <span class="p">...}}</span>
<span class="p">{</span><span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">TYPE103</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">logDetails</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span><span class="dl">"</span><span class="s2">timeMs</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1704070800000</span><span class="p">,</span> <span class="dl">"</span><span class="s2">field5</span><span class="dl">"</span><span class="p">:</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">],</span> <span class="dl">"</span><span class="s2">field6</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">{'key2':{'key3':'value2'}}</span><span class="dl">"</span><span class="p">,</span> <span class="p">...}}</span>
 <span class="p">...</span>
 <span class="p">...</span>
 <span class="p">...</span>
</code></pre></div></div>

<p>하나의 파일에는 여러 종류의 로그 데이터가 섞여 있으며, 모든 데이터에는 로그의 종류(type)와 생성 시간(timeMs) 같은 공통 필드가 포함되어 있습니다. 또한 각 로그 타입마다 고유한 필드도 존재합니다.</p>

<p>파이프라인의 요구사항은 아래와 같습니다.</p>

<ol>
  <li><strong>BigQuery 테이블화</strong>: 로그 데이터는 타입별로 구분된 BigQuery 테이블에 저장되어야 하며, 이를 통해 조회 및 분석이 가능해야 합니다.</li>
  <li><strong>특정 타입 데이터 적재</strong>: 요청한 타입의 데이터만 BigQuery에 적재해야합니다.</li>
  <li><strong>배치성 처리</strong>: 데이터 처리와 적재는 최소 2시간 이내에 이루어져야 하며, 가능하면 더 빠르게 처리되어야 합니다.</li>
</ol>

<p>이러한 데이터 구조와 요구사항을 바탕으로, 기존 파이프라인이 어떻게 구성되어 있는지 살펴보겠습니다.</p>

<h2 id="2-2-기존-파이프라인-아키텍처">2-2. 기존 파이프라인 아키텍처</h2>

<p>우선 어떻게 데이터 파이프라인을 개선할 것인가에 대한 질문에 대한 답을 하기에 앞서 기존 아키텍처에 대하여 이해하고 어떤 부분에서의 문제가 발생하고 있는지의 파악이 필요합니다.</p>

<p>기존 파이프라인 설계 당시의 상황과 고려 사항을 살펴보면, 이미 Amazon Kinesis Data Stream(KDS)과 Firehose를 통해 AWS S3에 데이터가 적재되고 있는 환경이었습니다. 더불어 로그 데이터는 주로 분석 용도로 활용될 예정이었기에 실시간성에 대한 요구사항은 크지 않았습니다.
이러한 배경에서 기존 인프라를 최대한 활용하면서도 효율적인 데이터 처리가 가능한 구조를 고민하였고, 다음과 같은 요구사항을 결정하였습니다.</p>

<ol>
  <li>S3에 저장된 데이터를 시작점으로 하는 파이프라인 구성</li>
  <li>배치 처리에 특화된 Airflow를 활용한 데이터 분류 및 적재</li>
  <li>GCP의 GCS를 중간 저장소로 활용하여 타입별 데이터 분류</li>
  <li>최종적으로 BigQuery 테이블 형태로 데이터 구조화</li>
</ol>

<p>아래는 기존 파이프라인 구조입니다.
<br />
<br />
<br />
<img src="/img/2025-02-26-log-pipeline-revamp/기존_파이프라인.svg" alt="기존_파이프라인.svg" style="transform: scale(1.2); transform-origin: center; display: block; margin: 0 auto;" />
<br />
<br />
위 요구사항에 따라 구축한 기존 로그 수집 파이프라인의 주요 흐름은 다음과 같습니다.</p>

<ol>
  <li>로그 생성 및 KDS로 로그 데이터를 전송합니다.
    <ul>
      <li>서버에서 생성된 로그는 실시간으로 KDS에 프로듀스됩니다.</li>
    </ul>
  </li>
  <li>Firehose를 통한 데이터를 저장합니다.
    <ul>
      <li>KDS의 데이터를 Firehose가 읽어 Amazon S3 버킷에 적재 일자 기준 파티셔닝 하여 저장합니다.</li>
    </ul>
  </li>
  <li>Airflow를 통한 로그 타입 분류 및 BigQuery에 데이터를 적재합니다.
    <ul>
      <li><strong>분류기</strong>: Google Cloud의 GKE 클러스터에서 실행 중인 Airflow가 주기적으로 S3 버킷의 데이터를 읽어 type별로 분류한 후, Google Cloud Storage(GCS)에 timeMs 값에 따라 일자별로 파티셔닝 하여 저장합니다.</li>
      <li><strong>적재기</strong>: 분류기에서 타입, 시간 별 분류 이후 요청한 로그 타입에 대해서 GCS의 데이터를 BigQuery 테이블에 적재합니다.</li>
    </ul>
  </li>
</ol>

<p>이러한 파이프라인 구조는 안정적으로 운영되어 왔으나, <strong>시간이 지남에 따라 여러 가지 한계점과 문제들</strong>이 드러나기 시작했습니다. 아래에서 이러한 문제점들을 자세히 살펴보겠습니다.</p>

<h2 id="2-3-기존-로그-파이프라인-문제점">2-3. 기존 로그 파이프라인 문제점</h2>

<h3 id="분류-작업의-비효율성"><strong>분류 작업의 비효율성</strong></h3>
<p><br />
<img src="/img/2025-02-26-log-pipeline-revamp/분류_작업의_비효율성.png" alt="분류 작업의 비효율성" style="transform: scale(1.23); transform-origin: center; display: block; margin: 0 auto;" />
<br /></p>

<p>서버 로그는 하나의 파일에 다양한 유형의 데이터를 포함하고 있었고, 이를 적재하기 위해 반드시 분류 작업을 거쳐야 했습니다. S3에 적재된 분류되지 않은 파일을 분류기를 통해 유형별로 분류한 후 GCS에 적재하는 과정에서 많은 시간이 소요되었습니다.</p>

<p>특히, 분류 작업은 모든 유형의 데이터를 처리하고 적재 단계에서만 실제 필요한 데이터가 BigQuery에 적재되었습니다. 하나의 파일에 모든 로그를 처리하고 있는 상황에서 분류기에서 특정 type만 선택적으로 적재하기 어려웠기에 모든 유형의 로그를 분류하다 보니 S3와 GCS에 분류 전후의 중복된 데이터가 저장되는 비효율적인 문제가 발생했습니다.</p>

<p>또한 분류 작업에서 문제가 발생할 경우 모든 타입의 서버 로그 데이터를 적재할 수 없게 되어 단일 장애 지점이 발생하였습니다.</p>

<h3 id="데이터-신선도-부족"><strong>데이터 신선도 부족</strong></h3>
<p><br />
<img src="/img/2025-02-26-log-pipeline-revamp/데이터_신선도의_부족.png" alt="데이터_신선도의_부족.svg" style="transform: scale(1.15); transform-origin: center; display: block; margin: 0 auto;" />
<br />
<br /></p>

<p>데이터의 신선도가 부족하다는 점도 개선이 필요한 부분이었습니다. 즉각적인 비즈니스 전략 수립을 위해 실시간성 로그 데이터의 수요가 증가함에도 불구하고 현재 파이프라인은 Airflow 배치 스케줄링 방식을 사용하고 있었기 때문에 실시간 데이터가 필요한 상황에서 적절히 대응하지 못했습니다. 이에 따라 데이터 사용자가 즉각적으로 서버 로그 데이터를 확인하기 어려운 비효율적인 상황이 자주 발생했습니다.</p>

<h3 id="스키마-변경과-관리-부재"><strong>스키마 변경과 관리 부재</strong></h3>

<p>서버 로그의 스키마 변경에 대한 이력 관리 체계가 부재했던 점은 주요한 문제 중 하나였습니다. 기존에는 데이터 생산자와 소비자가 공통으로 사용하는 Schema Registry와 같은 스키마 저장소가 없었기 때문에, 생산자가 스키마를 변경하고 소비자가 이를 사용하는 과정에서 문제가 발생하곤 했습니다. 특히, 스키마가 혼재된 데이터를 처리해야 할 경우 임시 적재기를 사용하는 비효율적인 방식을 사용하고 BigQuery에 수기로 스키마를 업데이트 하는 등 유지보수의 큰 오버헤드가 팀 내 병목을 발생시켰습니다. 이러한 문제들은 데이터의 안정성과 활용도를 저하시켰고, 운영 부담을 가중시키는 요인이 되었습니다.</p>

<div style="display: flex; justify-content: center; align-items: center; gap: 20px;">
    <!-- 왼쪽 이미지 및 설명 -->
    <div style="flex: 1; text-align: center;">
        <img src="/img/2025-02-26-log-pipeline-revamp/수기_1.png" alt="수기_1" style="width: 100%; max-width: 900px; height: auto;" />
        <p style="text-align: center; font-size: 0.8em; font-style: italic;">
            수기로 관리되던 기존 로그 BigQuery 스키마
        </p>
    </div>

    <!-- 오른쪽 이미지 및 설명 -->
    <div style="flex: 1; text-align: center;">
        <img src="/img/2025-02-26-log-pipeline-revamp/수기_2.png" alt="수기_2" style="width: 100%; max-width: 300px; height: auto;" />
        <p style="text-align: center; font-size: 0.8em; font-style: italic;">
            로그 BigQuery 스키마 예시
        </p>
    </div>
</div>

<h2 id="2-4-해결-방안-모색">2-4. 해결 방안 모색</h2>

<p>위에서 언급한 주요 문제점들은 서로 긴밀하게 연결되어 있었습니다. 분류 작업의 비효율성은 데이터 신선도에 직접적인 영향을 미쳤고, 스키마 관리 체계의 부재는 분류 작업의 복잡성을 더욱 가중시켰습니다. 이러한 상황을 개선하기 위해 저희 팀은 파이프라인의 전면적인 개선이 필요하다고 판단했습니다.</p>

<p>따라서 기존 파이프라인의 문제들을 해결하기 위해 아래와 같은 해결방안을 세웠습니다.</p>

<table>
  <thead>
    <tr>
      <th><strong>문제점</strong></th>
      <th><strong>해결 방안</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>분류 작업의 비효율성</td>
      <td>S3와 GCS 중 단일 원본 데이터 저장소를 설정하고, 특정 유형의 로그만 적재 가능하도록 개선</td>
    </tr>
    <tr>
      <td>데이터 신선도의 부족</td>
      <td>기존 배치 파이프라인에서 실시간 처리 가능한 스트리밍 파이프라인으로 전환</td>
    </tr>
    <tr>
      <td>스키마 변경과 관리의 부재</td>
      <td>로그 데이터 스키마를 통합 관리할 수 있는 체계를 구축</td>
    </tr>
  </tbody>
</table>

<p><br /></p>
<h1 id="3-파이프라인-문제-해결-방안">3. 파이프라인 문제 해결 방안</h1>

<h2 id="3-1-분류-작업-효율화">3-1. 분류 작업 효율화</h2>

<p>기존의 KDS를 Managed Streaming for Apache Kafka(MSK)로 전환하고, 커스텀 Kafka Consumer를 도입하여 실시간 데이터 분류 시스템을 구축하고자 하였습니다. MSK를 선택한 주요 이유는 Schema Registry를 통해 메시지 스키마를 체계적으로 저장하고 관리할 수 있다는 점이었습니다. Kafka Consumer를 통해 특정 타입의 로그만 선별적으로 분류 및 적재하고자 하였으며, 분류한 데이터를 GCS에 직접 적재하도록 설계하여 기존에 발생하였던 S3와 GCS 간의 중복 저장 문제를 해소하고자 하였습니다.</p>

<p>더불어 Custom Consumer에서의 다양한 사용자 정의 설정을 지원하기에 분류 작업의 유연성을 높이고, 체계적인 모니터링 시스템을 구축하여 운영 안정성을 강화하고자 하였습니다.</p>

<h2 id="3-2-데이터-신선도-개선">3-2. 데이터 신선도 개선</h2>

<p>실시간 스트리밍 파이프라인을 구축하여 분류 작업의 효율성을 높임과 동시에 실시간 데이터 처리가 가능하게 하여 데이터 신선도를 개선하고자 하였습니다.
Kafka Consumer가 수집된 데이터를 실시간으로 처리하고 GCS에 즉시 적재하며, BigQuery 외부 테이블과 연동하여 사용자들이 필요한 시점에 즉각적으로 데이터를 활용할 수 있는 환경을 제공하고자 하였습니다. 이를 통해 데이터의 생산부터 소비까지의 시간 간격을 최소화하여 데이터의 실시간성을 확보하고자 하였습니다.</p>

<h2 id="3-3-스키마-통합-관리">3-3. 스키마 통합 관리</h2>

<p>데이터 컨트랙트를 도입하여 파이프라인 전반의 스키마 변경 관리를 체계화하고자 하였습니다. Kafka Schema Registry를 활용해 검증된 스키마만 데이터 생산과 소비 과정에서 사용되도록 함으로써 데이터 품질과 안정성을 확보하고자 하였습니다. 또한, 스키마를 중앙 집중적으로 관리하고 모든 스키마 변경 시 필수적으로 리뷰 프로세스를 거치도록 하여 변경으로 인한 잠재적 문제를 사전에 방지할 수 있는 구조를 구상하고자 하였습니다.</p>

<p><img src="/img/2025-02-26-log-pipeline-revamp/스키마_통합관리.svg" alt="스키마_통합관리.svg" /></p>

<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px;">

💡 <strong>데이터 컨트랙트란?</strong>
<br />
데이터 컨트랙트는 <strong>데이터 생성자와 소비자 간의 명시적인 합의</strong>로, 데이터의 형식과 구조에 대한 명확한 규칙을 정의하여, 데이터 파이프라인 전반의 품질, 신뢰성, 일관성을 보장합니다. 

데이터 컨트랙트는 다음과 같은 세 가지 핵심 요소로 구성됩니다.

<ul style="padding-left: 20px; list-style-type: disc;">
    <li>생산자와 소비자 간의 문화적 합의</li>
    <li>코드/템플릿을 통한 명시적인 스키마 정의 (Protocol Buffer, Schema Registry)</li>
    <li>Producer, Consumer를 포함한 파이프라인 전반에서의 실제 적용</li>
</ul>

</div>

<p>위 해결방안을 이루기 위해 최종적으로 아래와 같은 기술 스택을 선택하였습니다. 각 기술에 대한 자세한 내용 및 신규 아키텍처에 대해서는 아래에서 다룰 예정입니다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>기존</th>
      <th><strong>변경</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>분산 메시지 플랫폼</td>
      <td>Kinesis Data Stream (KDS)</td>
      <td>Managed Streaming for Apache Kafka (MSK)</td>
    </tr>
    <tr>
      <td>메시지 소비</td>
      <td>Firehose</td>
      <td>Kafka Consumer</td>
    </tr>
    <tr>
      <td>데이터 가공</td>
      <td>Airflow</td>
      <td>Kafka Consumer</td>
    </tr>
    <tr>
      <td>스키마 통합 관리</td>
      <td>X</td>
      <td>1차: Protobuf<br />2차: Schema Registry</td>
    </tr>
  </tbody>
</table>

<p><br /></p>

<h1 id="4-새로운-아키텍처">4. 새로운 아키텍처</h1>

<h2 id="4-1-새로운-아키텍처-개요">4-1. 새로운 아키텍처 개요</h2>

<p>지금까지 기존 파이프라인의 문제점과 각 문제를 해결하기 위해 했던 고민 과정에 관해 설명해드렸습니다. 이를 기반으로, 기존의 데이터 흐름에서 발생하던 비효율성과 복잡성을 해결하기 위해 새롭게 만든 아키텍처에 대한 소개를 이어가보겠습니다.</p>

<p>가장 큰 변화는 역시 데이터 생산자/소비자가 같은 <strong><em>단일 데이터 소스(Single Source of Truth, SSoT)</em></strong>로 부터 데이터를 생산 및 소비한다는 점입니다. 이를 통해 데이터의 일관성을 유지하고, 한층 더 견고한 파이프라인을 유지할 수 있게 됩니다. 또한 기존에 오래 걸리던 분류 작업을 Consumer 단에서 바로 해결이 가능하니 배치 파이프라인을 유지할 필요가 없어졌고 그 결과 데이터 유저는 준실시간으로 데이터를 사용할 수 있게 되었습니다.</p>

<p>아래에서 아키텍처 다이어그램과 함께 각 컴포넌트에 대한 설명과 데이터 흐름에 대한 더 자세한 설명을 이어가겠습니다.</p>

<h2 id="4-2-주요-컴포넌트">4-2. 주요 컴포넌트</h2>
<p><br />
<br />
<img src="/img/2025-02-26-log-pipeline-revamp/컴포넌트.png" alt="컴포넌트.png" style="transform: scale(1.3); transform-origin: center; display: block; margin: 0 auto;" />
<br />
<br /></p>

<h3 id="protobuf"><strong>Protobuf</strong></h3>

<p><code class="language-plaintext highlighter-rouge">Protobuf</code>는 Google에서 개발한 데이터 직렬화 포맷으로, <strong>구조화된 데이터의 효율적이고 빠른 처리</strong>를 지원합니다. JSON이나 XML보다 데이터 크기가 작고 처리 속도가 빨라, 서비스 간 데이터 교환에 적합합니다. 데이터 생산 및 소비 과정에서 주요 스키마 포맷으로 활용되며, 파이프라인 전반에서 일관된 데이터 처리를 가능하게 합니다.</p>

<h3 id="buf">Buf</h3>

<p><code class="language-plaintext highlighter-rouge">Buf</code>는 Protobuf 기반의 데이터 스키마를 중앙에서 효율적으로 관리하고 최적화하는 <strong>스키마 관리 및 검증 도구</strong>입니다. Protobuf 스키마는 Protobuf 통합 레포지토리에 저장되며, Buf를 활용하여 <strong>스키마 린팅, 포맷팅, 코드 생성, 호환성 검증</strong> 등의 작업을 자동화하여 일관성과 품질을 보장합니다. 또한, 파이썬 및 자바 클래스 컴파일, BigQuery 스키마 생성과 같은 다양한 플러그인을 지원하여 <strong>다양한 언어 및 서비스 간 통합 스키마 관리</strong>를 가능하게 합니다. Buf 에 대한 더 자세한 내용은 공식문서 <a href="https://buf.build/docs/ecosystem/">링크</a>를 참고 바랍니다.</p>

<h3 id="msk-managed-streaming-for-apache-kafka"><strong>MSK (Managed Streaming for Apache Kafka)</strong></h3>

<p>Kafka는 분산 스트리밍 플랫폼으로, 실시간 데이터를 확장성 있게 처리할 수 있습니다. AWS 의 MSK 는 Kafka를 완전 관리형 서비스로 제공하여 운영 부담을 크게 줄여줍니다.</p>

<p><code class="language-plaintext highlighter-rouge">MSK</code>는 쏘카 내부적으로 많은 팀에서 안정적으로 사용되고 있는 서비스로, 새로운 파이프라인으로 전환 시 부담이 적었습니다. 또한, 오픈 소스 Kafka 라이브러리와 Schema Registry와의 높은 호환성 덕분에 데이터 처리 파이프라인을 유연하게 설계할 수 있었습니다.</p>

<h3 id="kafka-schema-registry">Kafka Schema Registry</h3>

<p><code class="language-plaintext highlighter-rouge">Schema Registry</code>는 Kafka와 통합되어 데이터를 효율적으로 관리하는 <strong>중앙화된 스키마 관리 서비스</strong>입니다. Kafka에서 전송되는 데이터의 구조를 정의하고, 데이터 생산자와 소비자 간 <strong>스키마 일관성을 보장</strong>합니다. 대표적으로 Avro, Json, Protobuf 세 가지 직렬화 포맷을 지원하는데 데이터의 직렬화 및 역직렬화 시 스키마 호환성을 검증하며, 데이터 <strong>스키마 변경으로 인한 문제를 사전에 방지</strong>하는데 도움을 줍니다.</p>

<h3 id="consumer-커스텀-파이썬-애플리케이션"><strong>Consumer (커스텀 파이썬 애플리케이션)</strong></h3>

<p>일반적으로 Kafka 의 토픽에 저장된 데이터를 읽어와 처리하는 애플리케이션을 <code class="language-plaintext highlighter-rouge">Consumer</code> 라고 합니다. MSK 의 Consumer로 많이 쓰이는 Firehose, Flink, Kafka-connect 등이 있지만 데이터 분류와 필드/타입 파티셔닝과 같은 맞춤형 데이터 처리 요구사항을 충족하기 위해 파이썬 Confluent kafka 라이브러리로 Consumer를 자체 개발하였습니다.</p>

<h3 id="gcs">GCS</h3>

<p><code class="language-plaintext highlighter-rouge">GCS</code>는 Consumer가 MSK에서 수집한 메시지를 안정적으로 저장하는 <strong>원천 데이터 저장소</strong>입니다. 또한, BigQuery 외부 테이블의 <strong>소스(Source) 역할</strong>을 수행하며, 이를 통해 유저들에게 실시간에 가까운 데이터 접근을 제공합니다. GCS를 활용함으로써 데이터의 보존, 관리, 및 확장성을 효과적으로 보장할 수 있습니다.</p>

<h3 id="bigquery"><strong>BigQuery</strong></h3>

<p><code class="language-plaintext highlighter-rouge">BigQuery</code>는 Google Cloud에서 제공하는 <strong>완전 관리형 데이터 웨어하우스</strong>로, 쏘카 전사적으로 활용되는 주요 분석 플랫폼입니다. BigQuery에서 제공하는 <strong>뷰 테이블(View Table)</strong> 기능을 통해 이용자들에게 데이터를 제공하게 됩니다. 해당 뷰 테이블은 <strong>GCS 외부 테이블</strong>과 <strong>BigQuery 내부 테이블</strong>을 결합하여 구축되며, 이를 통해 사용자는 데이터의 <strong>신선도와 안정성</strong>을 동시에 확보할 수 있습니다.</p>

<h2 id="4-3-데이터-처리-흐름"><strong>4-3. 데이터 처리 흐름</strong></h2>
<p><br />
<br />
<img src="/img/2025-02-26-log-pipeline-revamp/처리흐름.png" alt="처리흐름" style="transform: scale(1.3); transform-origin: center; display: block; margin: 0 auto;" />
<br />
<br /></p>

<p>기본적으로 로그가 발생하면 쏘카 내부 서버에서 MSK로 로그데이터를 보내게 되고 최종적으로 BigQuery로 적재되는 구조입니다. 여기서 중요한 점은 <strong>각각의 서비스에서 데이터를 처리할 때 같은 소스를 바라봄으로써 데이터 일관성을 유지</strong>한다는 점입니다.</p>

<h3 id="스키마-업데이트와-통합-관리-0">스키마 업데이트와 통합 관리 (0)</h3>

<p>스키마 업데이트는 데이터 생산자와 소비자 모두 PR 리뷰 과정을 거칩니다. 이 과정에서 Github Actions 를 통해 기본적인 린팅, 포맷팅, 호환성검사가 진행되며 문제가 있을 시 조기에 발견이 가능합니다. 이후 변경 사항이 문제를 일으키지 않는지 개발 환경에서 테스트를 진행하게 됩니다.</p>

<h3 id="bigquery-스키마-생성-1">BigQuery 스키마 생성 (1)</h3>

<p>Buf 에서 제공하는 <a href="https://buf.build/googlecloudplatform/bq-schema?version=v2.0.1">플러그인</a> (<code class="language-plaintext highlighter-rouge">googlecloudplatform/bq-schema</code>)을 활용하여 BigQuery 테이블 스키마를 손쉽게 생성할 수 있습니다. BigQuery는 업데이트된 스키마를 기반으로 기존 테이블 스키마를 수정(Alter Column)합니다. 여기서 업데이트된 BigQuery 스키마가 기존 BigQuery 스키마와 호환 되지 않는다면, 다시 (0)으로 돌아가 스키마 업데이트에 대해 다시 논의합니다.</p>

<h3 id="파이썬-클래스-생성-및-배포-2">파이썬 클래스 생성 및 배포 (2)</h3>

<p>Buf 에서 제공하는 파이썬 클래스 생성 <a href="https://buf.build/protocolbuffers/python?version=v29.3">플러그인</a> (<code class="language-plaintext highlighter-rouge">protocolbuffers/python</code>)을 사용하여 스키마 변경 시 최신 파이썬 클래스를 생성합니다. 이후 새로운 버전의 파이썬 클래스를 포함한 Consumer Pod을 재배포합니다.</p>

<h3 id="자바-클래스-생성-및-배포-3">자바 클래스 생성 및 배포 (3)</h3>

<p>자바 스프링으로 구현된 Producer 서버는 Gradle 플러그인을 활용하여, Protobuf 통합 레포지토리의 스키마를 자바 클래스로 컴파일하여 사용합니다. Protobuf 스키마 업데이트되어 버전이 변경되면, 해당 버전에 맞춰 Producer 를 재배포합니다.</p>

<h3 id="데이터-생성-4">데이터 생성 (4)</h3>

<p>서버(Producer)는 로그 데이터를 (Confluent Kafka 라이브러리의 ProtobufSerializer를 통해 메세지를 Protobuf 포맷으로 직렬화한 뒤 MSK에 전송하며, 동시에 Schema Registry에 스키마를 등록합니다. MSK에 데이터를 보낼 때, auto_register_schema 인자에 True를 주게 되면, 자동으로 Kafka Schema Registry에 스키마를 등록할 수 있습니다. (이 부분은 선택사항이며 스키마 배포 전략을 어떻게 가져가냐에 따라 달라질 수 있습니다)</p>

<h3 id="데이터-소비-5">데이터 소비 (5)</h3>

<p>Consumer 애플리케이션은 Confluent Kafka 라이브러리의 ProtobufDeserializer를 활용하여 MSK로부터 데이터를 역직렬화하여 메시지를 읽어옵니다. 수신된 메시지는 애플리케이션의 비즈니스 로직에 따라 필요한 형태로 분류 및 가공됩니다.</p>

<p>데이터 처리 중 문제가 있는 경우, 해당 데이터를 MSK의 DLQ로 전송하고 에러 메시지와 함께 나중에 재처리합니다.</p>

<h3 id="데이터-gcs-적재-6">데이터 GCS 적재 (6)</h3>

<p>Consumer 애플리케이션이 데이터를 필요한 형태로 분류하고 가공한 이후, 특정 기준(예: 날짜컬럼, 특정 컬럼 등)으로 파티셔닝한 후, GCS에 저장합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gs://log/type<span class="o">=</span>CREATE_SOCAR/ymd<span class="o">=</span>2024-12-31/hour<span class="o">=</span>23/uuid.json
</code></pre></div></div>

<p><img src="/img/2025-02-26-log-pipeline-revamp/gcs.png" alt="gcs.png" /></p>
<p style="text-align: center; font-size: 0.8em; font-style: italic;">
    각 타입별 로그가 ymd/hour 기준으로 파티셔닝되어 적재
</p>

<h3 id="최종-bigquery-데이터-적재-7">최종 BigQuery 데이터 적재 (7)</h3>

<p>실시간으로 GCS에 적재된 데이터는 BigQuery External Table로 구성되며, 이는 GCS를 소스로 참조합니다. 그러나 External Table의 쿼리 성능이 상대적으로 느리기 때문에, 매일 일 배치 작업을 통해 해당 데이터를 BigQuery 내부 테이블로 적재합니다.</p>

<p>최종 사용자는 <strong>BigQuery External Table과 BigQuery 내부 테이블을 BigQuery View Table</strong>을 통해 데이터를 확인합니다. 이 구조를 통해 유저는 데이터를 BigQuery 에서 준실시간으로 확인할 수 있습니다.</p>

<p><br /></p>

<h1 id="5-기술스택-선정-및-구현-과정에서의-고민">5. 기술스택 선정 및 구현 과정에서의 고민</h1>

<p>위에서 전반적인 아키텍처 내용과 구현의 결과물을 소개해 드렸습니다.
아래에서는 이를 구현하는 과정에서 팀내에서 겪었던 기술스택 선정에 대한 <strong>고민과 예상치 못했던 시행착오</strong>에 대해서 좀 더 공유해보겠습니다.</p>

<h2 id="5-1-커스텀-consumer-를-개발하기까지">5-1. 커스텀 Consumer 를 개발하기까지</h2>

<p>분류 작업의 효율화 및 신선도 개선을 위해 로그파일 저장소를 <strong>[S3+GCS] → [GCS] 단일 저장으로 변경하고 Airflow 스케줄링을 통한 배치작업 대신 MSK + Consumer 조합</strong>으로, 준실시간으로 분류와 적재를 동시에 할 수 있게끔 아키텍처의 변화를 주었다고 위에서 간략하게 설명드렸습니다. 아래는 Consumer 기술스택 선정 과정에 있어서 검토했던 내용을 공유해 드리겠습니다.</p>

<p><strong>Firehose 를 계속해서 사용해볼까?</strong><span style="display: block; margin-top: -0.5px;"></span>
개선과정에서 첫번째로 결정된 사항은 Schema Registry 사용을 통한 스키마 관리였습니다. 여기서 자연스럽게 메세징 플랫폼은 KDS 대신 MSK로 정해졌습니다.</p>

<p>다음 단계는 메시지 소비를 위한 기술 선택이었는데, 기존에 사용하던 Firehose를 계속 사용할지, 아니면 새로운 대안을 도입할지 두 가지 선택지가 있었습니다.</p>

<p>Firehose는 <code class="language-plaintext highlighter-rouge">Dynamic Partitioning</code> 기능을 제공하여 메시지의 특정 필드를 파싱해 파티션키로 사용할 수 있습니다. 하지만 MSK를 Firehose의 소스로 선택할 경우 <strong>Dynamic Partitioning 기능을 사용할 수 없다는 제약</strong>이 있었습니다. 특히 <strong>메시지 타입별 분류가 필수적인 상황</strong>에서 이 기능의 부재는 큰 단점으로 작용했습니다.</p>

<p><img src="/img/2025-02-26-log-pipeline-revamp/dp.png" alt="dp.png" /></p>
<p style="text-align: center; font-size: 0.8em; font-style: italic;">
    Firehose 의 소스로 MSK 선택 시, Dynamic Partitioning 기능 사용 불가
</p>

<p>또한, Firehose 만으로는 메시지 변환(Transformation)에 한계가 있어 메시지 변환 레이어로 AWS Lambda를 추가해야 했습니다. Firehose 가 AWS Lambda 에게 넘겨준 값(kafkaRecordValue=base64 인코딩 문자열) 을 다시 디코딩하고 Protobuf 역직렬화 하는 일련의 과정들을 포함해 운영 리소스 증가와 오버엔지니어링이라는 생각을 하였습니다.</p>

<p><strong>Kafka-connect 는 어떨까?</strong><span style="display: block; margin-top: -0.5px;"></span>많은 기업에서 Kafka Consumer 로 <code class="language-plaintext highlighter-rouge">Kafka-connect</code> 를 사용합니다. 대표적으로 Confluent Kafka-connect 혹은 AWS MSK Connect 를 많이 사용합니다. MSK를 쓰다보니 같은 관리형 서비스로 MSK Connect 를 사용하려 했지만 불친절한 에러로깅으로 셋업 과정이 쉽지 않았습니다.</p>

<p>Kafka-connect 를 이용한 단순적재는 오픈소스로 나온 Sink Connector(S3, GCS, MySQL, …) 를 사용하면 편리합니다. Confluent 사에서 제공하는 오픈소스 플러그인(Kafka-connect-protobuf-converter 등) 을 받아서 Avro 혹은 Protobuf 메세지 등 상황에 맞게 사용할 수도 있습니다. 또한 Kafka-connect 자체적으로 SMT (Single Message Transformation) 도 지원하기에 간단한 데이터변환은 적용하기에 나쁘지 않습니다. 하지만 여러 테스트 결과 원하는 대로 메세지 파싱이 되지 않아서 결국 선택은 하지 않았습니다.</p>

<p>저희가 사용했던 Protobuf 스키마 파일을 예시로 보여드리면서 보다 상세한 설명을 해드리도록 하겠습니다. 기본적으로 저희가 사용하기로 결정한 로그 스키마 형태는 아래와 같습니다.</p>

<div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">syntax</span> <span class="o">=</span> <span class="s">"proto3"</span><span class="p">;</span>
<span class="k">import</span> <span class="n">CreateSocar</span><span class="p">,</span> <span class="n">DeleteSocar</span><span class="p">,</span> <span class="n">UpdateSocar</span><span class="p">,</span> <span class="o">...</span><span class="p">;</span>
<span class="kd">message</span> <span class="nc">Log</span> <span class="p">{</span>
  <span class="n">Type</span> <span class="na">type</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
  <span class="n">google.protobuf.Timestamp</span> <span class="na">timestamp</span> <span class="o">=</span> <span class="mi">99</span><span class="p">;</span>
  
  <span class="kd">enum</span> <span class="n">Type</span> <span class="p">{</span>
	  <span class="o">...</span>
    <span class="na">CREATE_SOCAR</span> <span class="o">=</span> <span class="mi">18</span><span class="p">;</span>
    <span class="na">DELETE_SOCAR</span> <span class="o">=</span> <span class="mi">19</span><span class="p">;</span>
    <span class="na">UPDATE_SOCAR</span> <span class="o">=</span> <span class="mi">20</span><span class="p">;</span>
    <span class="o">...</span>
  <span class="p">}</span>

  <span class="k">oneof</span> <span class="n">data</span> <span class="p">{</span>
	  <span class="o">...</span>
    <span class="n">CreateSocar</span> <span class="na">create_socar</span> <span class="o">=</span> <span class="mi">3</span><span class="p">;</span>
    <span class="n">DeleteSocar</span> <span class="na">delete_socar</span> <span class="o">=</span> <span class="mi">4</span><span class="p">;</span>
    <span class="n">UpdateSocar</span> <span class="na">update_socar</span> <span class="o">=</span> <span class="mi">5</span><span class="p">;</span>
    <span class="o">...</span>
  <span class="p">}</span>

<span class="p">}</span>
</code></pre></div></div>

<p><strong>Kafka 토픽당 스키마는 1대1 대응</strong>이기에, 수백개의 로그 종류별로 토픽을 생성하여 관리하기에는 오버엔지니어링이라 판단했습니다. 우회책으로 Protobuf 의 <code class="language-plaintext highlighter-rouge">oneof</code> 기능을 이용해서 <strong>1개의 Protobuf 스키마로 n개의 Protobuf 스키마</strong>를 검증할 수 있게 해주었습니다. (로그타입을 유저, 운전, 예약 등 대분류로 나누어서 토픽의 개수를 최소화 할 수 있었습니다).</p>

<p>CreateSocar 로그를 생성하거나 DeleteSocar 로그를 생성하거나 상관없이 위 ‘Log’ Protobuf 스키마를 사용해서 로그를 생성하면 됩니다 (물론 CreateSocar 로그를 생성하려면 CreateSocar 스키마에 맞는 데이터를 만들어줘야 합니다). 위 스키마를 사용하여 CREATE_SOCAR 혹은 DELETE_SOCAR 라는 type의 로그를 생성했을 때 기대했던 로그 파일 결과물은 아래와 같습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
	<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">CREATE_SOCAR</span><span class="dl">"</span><span class="p">,</span>
	<span class="dl">"</span><span class="s2">timestamp</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1725428910094</span>
	<span class="dl">"</span><span class="s2">create_socar</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
                <span class="p">...</span>
		<span class="dl">"</span><span class="s2">socar_id</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">45525545</span><span class="dl">"</span><span class="p">,</span>
		<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">소나타</span><span class="dl">"</span><span class="p">,</span>
		<span class="p">...</span>
	<span class="p">},</span>
	<span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
	<span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DELETE_SOCAR</span><span class="dl">"</span><span class="p">,</span>
	<span class="dl">"</span><span class="s2">timestamp</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1725428910095</span>
	<span class="dl">"</span><span class="s2">delete_socar</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
	        <span class="p">...</span>
		<span class="dl">"</span><span class="s2">socar_id</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">45525546</span><span class="dl">"</span><span class="p">,</span>
		<span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">아반떼</span><span class="dl">"</span><span class="p">,</span>
		<span class="p">...</span>
	<span class="p">},</span>
	<span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>위와 같이 공통필드(type, timestamp) 를 제외한 각 type에 해당하는 데이터가 type 이름과 같은 필드(create_socar, delete_socar)에 들어오는 포맷을 원했습니다.</p>

<p>하지만 <code class="language-plaintext highlighter-rouge">kafka-connect-protobuf-converter:7.2.2</code> 버전의 커넥터 플러그인을 사용한 결과 아래와 같이 MSK로부터 데이터를 읽을 때, data 필드에 one of 의 값으로 주어질 수 있는 모든 필드에 대해서 <strong>불필요한 값이 생성되는 문제</strong>가 있었습니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
    <span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DELETE_SOCAR</span><span class="dl">"</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">timestamp</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1725428910094</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">data_0</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
        <span class="p">...</span>
        <span class="dl">"</span><span class="s2">create_socar</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
        <span class="dl">"</span><span class="s2">delete_socar</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
            <span class="dl">"</span><span class="s2">socar_id</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">45525546</span><span class="dl">"</span><span class="p">,</span>
            <span class="dl">"</span><span class="s2">name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">김쏘카</span><span class="dl">"</span><span class="p">,</span>
        <span class="p">},</span>
        <span class="dl">"</span><span class="s2">update_socar</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
        <span class="p">...</span>
    <span class="p">},</span>
    <span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>위와 같은 부분과 더불어 n개 필드를 활용한 파티션키 생성과 같은 요구사항을 별도의 Transformation 로직으로 처리하려면 Java/Kotlin 으로 구현을 해야하는데요, 팀 내 Java/Kotlin 언어에 대한 경험치가 높지 않아 개발 및 유지보수가 힘들다고 판단했습니다.</p>

<p><strong>우리가 자체 Consumer를 개발하자!</strong><span style="display: block; margin-top: -0.5px;"></span>
다행히 Kafka 는 다양한 언어에 대한 SDK를 지원하기에 직접 파이썬 Consumer 를 만드는 것이 개발 및 유지보수를 생각하면 낫다고 판단하여 진행하게 되었습니다.</p>

<p>파이썬으로 kafka 메세지를 consume 하려면 보통 confluent-kafka 혹은 kafka-python 으로 많이 구현합니다. 저희는 MSK 와의 호환성과 <strong>Schema Registry 지원 기능을 고려하여 confluent-kafka 를 선택</strong>하였습니다 (kafka-python 은 Schema Registry 미지원).</p>

<p>다양한 상황을 고려하고자 다음과 같은 config 값을 설정할 수 있는 커스텀 Consumer 를 개발하였습니다.</p>

<ol>
  <li><strong>버퍼 크기 (buffer size)</strong>
    <ul>
      <li>소비 성능과 메모리 사용량을 균형 있게 조정하기 위해 버퍼 크기를 설정할 수 있도록 구성했습니다. 이를 통해 대량의 데이터를 가지고 있는 토픽도 안정성 있게 처리할 수 있습니다.</li>
      <li>예: <code class="language-plaintext highlighter-rouge">100</code> 이면, 버퍼에 100개의 메세지가 차면 로그 데이터를 적재</li>
    </ul>
  </li>
  <li><strong>버퍼 간격 (buffer interval)</strong>
    <ul>
      <li>메시지를 일정 주기마다 처리할 수 있도록 버퍼 간격을 설정하였습니다. 이를 통해 지연 시간과 처리량 간의 균형을 맞출 수 있습니다.</li>
      <li>예: <code class="language-plaintext highlighter-rouge">300</code> 이면, 300초에 한번 버퍼에 있는 로그데이터를 적재</li>
    </ul>
  </li>
  <li><strong>Dead Letter Queue (DLQ) 설정</strong>
    <ul>
      <li>오류 발생 시 메시지를 별도의 토픽(Dead Letter Queue)에 적재할 수 있도록 설정하였습니다. DLQ를 활용하여 장애 상황을 모니터링하고 빠르게 대응할 수 있습니다. 또한 범용성을 위해 AWS MSK 말고 GCP PubSub 도 인자를 받을 수 있게 기능구현을 하였습니다.</li>
      <li>예: 오류(역직렬화, 파티셔닝키생성, 잘못된 적재 디렉토리 등) 가 발생하면 해당 오류를 메세지 헤더에 넣어서 DLQ로 전송</li>
    </ul>
  </li>
  <li><strong>파티션 키 (Partition Key) 전략</strong>
    <ul>
      <li>특정 n개의 컬럼을 조합하여 Kafka 파티셔닝 키로 설정할 수 있도록 구현하였습니다. 이를 통해 원하는 방식으로 파티션키를 생성할 수 있습니다.</li>
      <li>예: <code class="language-plaintext highlighter-rouge">type,</code> <code class="language-plaintext highlighter-rouge">timestamp</code> 등의 필드를 인자로 주면 <code class="language-plaintext highlighter-rouge">type=CREATE_MEMBER/ymd=2025-01-01/hour=02</code> 와 같은 파티셔닝키 생성 가능</li>
    </ul>
  </li>
  <li><strong>적재 대상 선택 옵션</strong>
    <ul>
      <li>수집한 데이터를 어디에 적재할지 선택할 수 있도록 옵션을 제공하였습니다.</li>
      <li>예: S3, GCS 등의 대상별 설정을 동적으로 적용</li>
    </ul>
  </li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">MSKConsumer</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">config</span><span class="p">:</span> <span class="n">MSKConsumerConfig</span><span class="p">,</span> <span class="n">deserializer</span><span class="p">:</span> <span class="n">MessageDeserializer</span><span class="p">,</span> <span class="n">dlq_handler</span><span class="p">:</span> <span class="n">DLQHandler</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">aws_region</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">aws_region</span>
        <span class="n">self</span><span class="p">.</span><span class="n">aws_role_arn</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">aws_role_arn</span>
        <span class="n">self</span><span class="p">.</span><span class="n">bootstrap_servers</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">bootstrap_servers</span>
        <span class="n">self</span><span class="p">.</span><span class="n">topic</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">topic</span>
        <span class="n">self</span><span class="p">.</span><span class="n">group_id</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">group_id</span>
        <span class="n">self</span><span class="p">.</span><span class="n">consumer</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_get_consumer</span><span class="p">()</span>

        <span class="n">self</span><span class="p">.</span><span class="n">deserializer</span> <span class="o">=</span> <span class="n">deserializer</span>
        <span class="n">self</span><span class="p">.</span><span class="n">dlq_handler</span> <span class="o">=</span> <span class="n">dlq_handler</span>

        <span class="n">self</span><span class="p">.</span><span class="n">messages</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">SuccessMessage</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="n">self</span><span class="p">.</span><span class="n">error_messages</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">ErrorMessage</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="n">self</span><span class="p">.</span><span class="n">buffer_size</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">buffer_size</span>
        <span class="n">self</span><span class="p">.</span><span class="n">buffer_interval</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">buffer_interval</span>
        <span class="n">self</span><span class="p">.</span><span class="n">last_flush_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span>

        <span class="n">self</span><span class="p">.</span><span class="n">partition_timestamp_field</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">partition_timestamp_field</span>
        <span class="n">self</span><span class="p">.</span><span class="n">partition_value</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">partition_value</span>
        <span class="n">self</span><span class="p">.</span><span class="n">partition_field</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">partition_field</span>

        <span class="n">self</span><span class="p">.</span><span class="n">gcs_bucket</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">gcs_bucket</span>
        <span class="n">self</span><span class="p">.</span><span class="n">gcs_client</span> <span class="o">=</span> <span class="n">storage</span><span class="p">.</span><span class="n">Client</span><span class="p">.</span><span class="nf">from_service_account_json</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="n">google_application_credentials</span><span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">bucket</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">gcs_client</span><span class="p">.</span><span class="nf">bucket</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">gcs_bucket</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="5-2-protobuf-메세지-검증">5-2. Protobuf 메세지 검증</h2>

<p>공통적인 Protobuf 통합 레포지토리를 바라보고, buf 플러그인을 통해 각 서비스에 사용되는 스키마를 생성함으로써, 스키마 통합관리를 한다고 말씀드렸습니다. 아래에서는 confluent-kafka 파이썬 SDK 를 사용한 <strong>Protobuf 메세지 역직렬화 과정</strong>에서 겪었던 시행착오에 대해 공유해보도록 하겠습니다.</p>

<p><strong>Deserializer 에 대해서</strong><span style="display: block; margin-top: -0.5px;"></span>
Kafka 환경에서 Schema Registry를 활용하는 기본적인 방식은 다음과 같습니다. Consumer가 Kafka로부터 메시지를 읽을 때, 해당 메시지에는 스키마 정보와 직렬화된 데이터가 포함되어 있습니다. Consumer는 Schema Registry를 통해 이 스키마 정보를 조회하고, 이를 바탕으로 역직렬화를 수행합니다.</p>

<p>조금 더 구체적으로 설명하면, Consumer는 <code class="language-plaintext highlighter-rouge">Deserializer</code> 객체를 사용하여 메시지를 해석합니다. 이 객체는 선언 시에 <code class="language-plaintext highlighter-rouge">schema_registry_client</code>와 <code class="language-plaintext highlighter-rouge">schema_str/message_type</code> 인자를 받습니다.</p>

<ul>
  <li>schema_registry_client는 Consumer가 읽은 메시지에 포함된 <code class="language-plaintext highlighter-rouge">schema_id</code>를 Schema Registry 를 통해 조회하고 검증하는 데 사용됩니다.</li>
  <li>schema_str/message_type은 Schema Registry에 <code class="language-plaintext highlighter-rouge">schema_id</code>로 등록된 스키마와 호환되는지 교차 검증에 쓰이며, 메시지를 해석하는 역할을 합니다.</li>
</ul>

<p>여기서 메세지에 포함된 schema_id 에 해당하는 스키마를 <code class="language-plaintext highlighter-rouge">Writer Schema</code>라고 하고, Deserializer 선언 시 전달하는 schema_str/message_type을 <code class="language-plaintext highlighter-rouge">Reader Schema</code>라고 부릅니다.</p>

<p>Writer Schema와 Reader Schema 간의 교차 검증을 통해 <strong>메시지의 스키마 정합성 및 호환성</strong>(Backward, Forward, Full 호환성 등)을 유지할 수 있습니다. 이는 데이터 포맷의 일관성을 보장하고, 시스템 간 호환성을 높이는 중요한 역할을 합니다.
<span style="display: block; margin-top: 40px;"></span>
<strong>confluent-kafka ProtobufDeserializer 파이썬 라이브러리의 한계</strong><span style="display: block; margin-top: -0.5px;"></span>
confluent-kafka 에서 제공하는 <code class="language-plaintext highlighter-rouge">ProtobufDeserializer</code> 클래스는 Schema Registry에 대한 정보를 인자로 받지 않습니다. 이로 인해 Writer Schema와의 교차 검증이 불가능하며, Reader Schema로 메시지를 해석하기만 할 수 있습니다. 반면, AvroDeserializer와 JSONDeserializer는 Schema Registry 인자를 받아 Writer Schema와의 교차 검증을 지원합니다. 아래에서 <strong>Read Schema 와 Writer Schema 와의 교차 검증이 없다면 어떤 문제</strong>가 야기될 수 있는지 살펴보겠습니다.</p>

<p>Protobuf는 데이터를 <code class="language-plaintext highlighter-rouge">Binary Wire Format</code>으로 직렬화하며, 다음과 같은 정보만 포함됩니다.</p>
<ul>
  <li>필드 번호 (Tag Number)</li>
  <li>데이터 타입 (Wire Type)</li>
</ul>

<p>예를 들어, 아래와 같은 Protobuf 스키마와 메시지가 있다고 가정합니다.</p>

<div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">message</span> <span class="nc">Person</span> <span class="p">{</span>
    <span class="kt">string</span> <span class="na">name</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="kt">int32</span> <span class="na">age</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Alice"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"age"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>이 메시지가 <strong>Key-Value</strong> 형태의 <strong>Wire Format</strong>으로 직렬화되면 다음과 같습니다.</p>
<div class="language-r highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="m">0</span><span class="n">a</span><span class="w"> </span><span class="m">05</span><span class="w"> </span><span class="m">41</span><span class="w"> </span><span class="m">6</span><span class="n">c</span><span class="w"> </span><span class="m">69</span><span class="w"> </span><span class="m">63</span><span class="w"> </span><span class="m">65</span><span class="w"> </span><span class="m">10</span><span class="w"> </span><span class="m">1</span><span class="n">e</span><span class="w">
</span></code></pre></div></div>
<table style="border-collapse: collapse; width: 80%; text-align: left; margin: 20px 0; font-size: 14px;">
    <thead>
        <tr>
            <th style="border-bottom: 2px solid #ccc; padding: 10px;">Byte</th>
            <th style="border-bottom: 2px solid #ccc; padding: 10px;">설명</th>
            <th style="border-bottom: 2px solid #ccc; padding: 10px;">세부 내용</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td style="border-bottom: 1px solid #eee; padding: 8px;"><strong>0a</strong></td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">Key</td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">
                <strong>1 &lt;&lt; 3 | 2</strong>, Tag Number=1, Wire Type=2
            </td>
        </tr>
        <tr>
            <td style="border-bottom: 1px solid #eee; padding: 8px;"><strong>05</strong></td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">Value의 길이</td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;"><strong>5 bytes</strong></td>
        </tr>
        <tr>
            <td style="border-bottom: 1px solid #eee; padding: 8px;"><strong>41 6c 69 63 65</strong></td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">Value</td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">
                "Alice"의 UTF-8 인코딩
            </td>
        </tr>
        <tr>
            <td style="border-bottom: 1px solid #eee; padding: 8px;"><strong>10</strong></td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">Key</td>
            <td style="border-bottom: 1px solid #eee; padding: 8px;">
                <strong>2 &lt;&lt; 3 | 0</strong>, Tag Number=2, Wire Type=0
            </td>
        </tr>
        <tr>
            <td style="padding: 8px;"><strong>1e</strong></td>
            <td style="padding: 8px;">Value</td>
            <td style="padding: 8px;">30의 Varint 인코딩</td>
        </tr>
    </tbody>
</table>

<p>이때, Protobuf는 다음과 같은 정보를 포함하지 않습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">필드 이름</code> (name, age)</li>
  <li><code class="language-plaintext highlighter-rouge">메시지 구조</code> (Person 메시지 타입)</li>
  <li><code class="language-plaintext highlighter-rouge">전체 스키마 정의</code> (message Person { … })</li>
</ul>

<p>따라서 <code class="language-plaintext highlighter-rouge">필드 번호 (Tag Number)</code> 와 <code class="language-plaintext highlighter-rouge">데이터 타입 (Wire Type)</code> 으로 필드를 매핑하기 때문에 다음과 같은 한계가 있습니다.</p>

<ul>
  <li>필드 이름이 달라도 문제 없이 역직렬화가 가능합니다.</li>
  <li>필드 순서가 달라도 영향을 받지 않습니다.</li>
  <li>메시지 구조가 달라도 오류가 발생하지 않습니다.</li>
</ul>

<p>예를 들어, 아래와 같이 Producer와 Consumer의 스키마가 다를 경우에도 오류 없이 메시지를 해석합니다.</p>

<ul>
  <li>
    <p><strong>Producer</strong></p>

    <div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="kd">message</span> <span class="nc">Person</span> <span class="p">{</span>
    <span class="kt">string</span> <span class="na">name</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="kt">int32</span> <span class="na">age</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
  <span class="p">}</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>Consumer</strong></p>

    <div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="kd">message</span> <span class="nc">User</span> <span class="p">{</span>
    <span class="kt">string</span> <span class="na">username</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="kt">int32</span> <span class="na">years</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
  <span class="p">}</span>
</code></pre></div>    </div>
  </li>
</ul>

<p>Consumer는 <code class="language-plaintext highlighter-rouge">Tag Number=1</code>을 <code class="language-plaintext highlighter-rouge">username</code>으로, <code class="language-plaintext highlighter-rouge">Tag Number=2</code>를 <code class="language-plaintext highlighter-rouge">years</code>로 해석하게 됩니다. <strong>즉, 필드 이름과 메시지 구조가 달라도 데이터 해석에는 영향이 없지만, 이는 데이터 정합성 검증의 부재를 의미</strong>합니다.</p>

<p>또한, Writer Schema가 없기 때문에 Schema Registry에서 지원하는 스키마 호환성 검사 기능을 활용하기 어렵습니다. 이로 인해 Producer와 Consumer 간에 사용하는 <strong>스키마의 일관성 유지와 버전 관리가 복잡해질 수 있으며, 스키마 변경 시 호환성 문제</strong>가 발생할 가능성도 높아집니다.</p>

<p>이러한 문제를 해결하기 위해 리서치한 결과, <code class="language-plaintext highlighter-rouge">confluent-kafka Java SDK</code>는 ProtobufDeserializer가 Schema Registry와 연동되어 Writer Schema와 Reader Schema 간의 교차 검증 및 호환성 검사를 지원함을 확인했습니다. 이 내용은 개선 사항 백로그에 추가했으며, Java SDK 적용 후 보다 체계적인 스키마 관리가 가능할 것으로 기대됩니다.</p>

<p><br /></p>

<h1 id="6-마무리">6. 마무리</h1>

<p>이번 로그 파이프라인 개선 작업은 기존의 비효율성을 해결하고 비즈니스 요구사항에 효과적으로 대응하기 위해 진행되었습니다.</p>

<p>그 결과, 데이터 신선도가 기존 1~2시간에서 준실시간(약 3분)으로 단축되었고, 분류 작업의 비효율성을 해소하면서 데이터 적재 속도와 안정성이 크게 향상되었습니다.</p>

<p>또한, 데이터 생산자와 소비자가 같은 스키마를 공유하며 관리하게 됨으로써 데이터 품질과 일관성이 강화되었습니다.</p>

<p>하지만 여전히 해결해야 할 과제가 남아 있습니다. 현재 Consumer에서 Schema Registry를 통한 검증 로직이 완벽하지 않아 잘못된 메세지 데이터에 대한 에러 감지가 쉽지 않습니다. 이는 운영 효율성을 저하시킬 수 있으며, 이를 개선하기 위해 Schema Registry 를 활용한 검증이 반드시 필요합니다. 또한, 데이터 파이프라인 신뢰성을 높이기 위해 모니터링 및 알림 시스템을 강화해야 합니다.</p>

<p>새로운 파이프라인은 실제 비즈니스에 성공적으로 적용되고 있습니다. 특히, 최근 쏘카와 네이버의 제휴 서비스에서 발생하는 로그는 신규 로그 파이프라인을 통해 적재 중이며 현재 안정적으로 운영 중에 있습니다.</p>

<p>이 경험을 통해 신규 아키텍처의 안정성과 유연성을 검증할 수 있었으며, 향후 유사한 실시간 로그 처리 환경에 적용할 수 있는 가능성을 확인했습니다.</p>

<p><img src="/img/2025-02-26-log-pipeline-revamp/naver-socar.png" alt="naver-socar.png" /></p>
<p style="text-align: center; font-size: 0.8em; font-style: italic;">
    네이버 맵에서 쏘카 예약하기
</p>

<p>이번 글에서는 기존 파이프라인의 문제점과 이를 해결하기 위한 아키텍처 개선 과정을 다뤘습니다. 다음에는 Schema Registry 검증 로직 보완과 모니터링 시스템 강화를 통해 추가적인 개선을 할 예정입니다. 읽어주셔서 감사합니다.</p>

<p><br />
<br /></p>]]></content><author><name>bbiyak, rudy</name></author><category term="data" /><category term="data engineering" /><category term="data contract" /><summary type="html"><![CDATA[1. 들어가며]]></summary></entry><entry><title type="html">Data Product (3) 데이터로 실제 운영 효율화가 가능할까?</title><link href="https://tech.socarcorp.kr/data/2025/02/11/weather-wash.html" rel="alternate" type="text/html" title="Data Product (3) 데이터로 실제 운영 효율화가 가능할까?" /><published>2025-02-11T15:00:00+00:00</published><updated>2025-02-11T15:00:00+00:00</updated><id>https://tech.socarcorp.kr/data/2025/02/11/weather-wash</id><content type="html" xml:base="https://tech.socarcorp.kr/data/2025/02/11/weather-wash.html"><![CDATA[<ol>
  <li><a href="#1-들어가며">들어가며</a></li>
  <li><a href="#2-날씨-기반-세차-운영-최적화">날씨 기반 세차 운영 최적화</a>
    <ul>
      <li><a href="#21-기존-세차-오퍼레이션과-개선-필요성">2.1 기존 세차 오퍼레이션과 개선 필요성</a></li>
      <li><a href="#22-날씨-데이터-수집">2.2 날씨 데이터 수집</a></li>
    </ul>
  </li>
  <li><a href="#3-데이터-분석-및-운영-적용">데이터 분석 및 운영 적용</a>
    <ul>
      <li><a href="#31-분석을-위한-데이터-상세-정의">3.1 분석을 위한 데이터 상세 정의</a></li>
      <li><a href="#32-날씨와-차량-오염의-상관관계-분석">3.2 날씨와 차량 오염의 상관관계 분석</a></li>
      <li><a href="#33-운영-적용을-위한-시뮬레이션">3.3 운영 적용을 위한 시뮬레이션</a></li>
    </ul>
  </li>
  <li><a href="#4-운영-적용-결과-및-인사이트">운영 적용 결과 및 인사이트</a>
    <ul>
      <li><a href="#41-날씨-기반-세차-운영-정책-적용-결과">4.1 날씨 기반 세차 운영 정책 적용 결과</a></li>
      <li><a href="#42-맥락-해석이-필요한-데이터의-활용">4.2 맥락 해석이 필요한 데이터의 활용</a></li>
    </ul>
  </li>
</ol>

<h2 id="1-들어가며">1. 들어가며</h2>

<p>차를 가진 분이라면 세차한 직후에 비가 내려 속상했던 적이 한 번쯤 있으실 겁니다. 우리는 비나 눈은 세차 효과를 무력화하는 강력한 변수임을 경험적으로 잘 알고 있는데요. 만약 수 만대의 차량이 전국 곳곳에 흩어져 있다면 이 문제를 어떻게 관리해야 할까요? 쏘카는 운영 효율화를 위한 주요 과제로서 언제 세차를 하는게 효과적일지 데이터로 판단하고 있습니다.</p>

<p>기존 쏘카의 ‘세차 오퍼레이션’은 차량 오염 여부를 기준으로 세차를 요청하는 방식이었습니다. 비나 눈이 오더라도 차량이 오염되었으면 세차를 요청하는 구조였기에, 강수 예보를 반영하지 않아 불필요한 세차가 발생할 가능성이 있었습니다. 물론, 세차된 차량을 제공하는 것이 고객 경험을 향상시킬 수 있습니다. 하지만 비효율적인 세차 요청은 비용 증가로 이어지고, 이는 서비스 가격 인상으로 연결될 수 있습니다. 따라서 제한된 비용을 효율적으로 활용하기 위해 날씨 데이터를 반영한 최적의 세차 요청 로직을 설계했습니다.</p>

<p>*참고 : 용어 설명</p>

<table>
  <thead>
    <tr>
      <th>강수량</th>
      <th>강우량</th>
      <th>강설량</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>비 뿐만 아니라 눈, 우박, 안개비 등 모든 형태의 기상 현상을 포함한 총 강수의 양</td>
      <td>일정 기간 동안 특정 지역에 내린 비의 총량. 일반적으로 밀리미터(mm) 단위로 측정</td>
      <td>일정 기간 동안 내린 눈의 높이를 의미하며, cm(센티미터) 단위로 <br /> 적설량 : 쌓인 눈의 총 깊이</td>
    </tr>
  </tbody>
</table>

<style>
  table {
    width: 100%;
    border-collapse: collapse;
  }
  th, td {
    width: 33.33%;
    border: 1px solid black;
    padding: 10px;
  }
</style>

<h2 id="2-날씨-기반-세차-운영-최적화">2. 날씨 기반 세차 운영 최적화</h2>

<h3 id="21-기존-세차-오퍼레이션과-개선-필요성">2.1 기존 세차 오퍼레이션과 개선 필요성</h3>

<p>쏘카는 기존에도 AI를 활용한 ‘세차 오퍼레이션’을 운영하고 있었습니다. 차량 오염 여부를 판단하는 AI 모델을 적용해, 고객이 업로드한 차량 이미지를 분석한 후 오염 상태로 분류된 차량에 대해 자동으로 세차를 요청하는 방식이었습니다. 해당 내용은 <a href="https://tech.socarcorp.kr/data/2024/03/11/ai-car-wash.html">Data Product (2) AI(데이터)로 실제 운영 효율화가 가능할까?</a>에서 자세히 확인할 수 있습니다.</p>

<figure>
  <img src="/img/2025-02-12-weather-wash/세차_아키텍처.png" alt="wash_oper_arch" style="width: 80%; height: auto;" />
  <p style="text-align: center; color: #646f7c;">‘세차 오퍼레이션’ 아키텍쳐</p>
</figure>

<p>하지만 기존 방식은 날씨 요인을 고려하지 않아 불필요한 세차 요청이 발생하는 문제가 있었습니다. 현장에서 비나 눈이 차량 오염에 미치는 영향에 대한 경험적 인식은 있었지만, 이를 정량적으로 분석해 운영 정책으로 반영한 사례는 없었습니다. 세차 최적화를 위해 강수 예보를 반영하면 운영 비용 절감이 가능할 것으로 기대되었고, 날씨와 차량 오염 간의 관계를 검증하여 이를 반영하는 것이 핵심 목표였습니다.</p>

<h3 id="22-날씨-데이터-수집">2.2 날씨 데이터 수집</h3>

<p>날씨 데이터를 활용하여 운영 프로세스를 개선하려면, 단순한 실험적 분석이 아니라 안정적이고 주기적인 데이터 수집과 정제가 필수적입니다. 기존에도 기상청 API를 통해 날씨 데이터를 적재했지만, 데이터 적재 실패가 빈번했고 원인 분석도 어려웠습니다. 이는 외부 데이터 자체의 문제뿐만 아니라, 급하게 요구되는 특정 데이터만 단편적으로 적재한 결과 데이터 관리가 체계적이지 않았기 때문입니다.</p>

<p>이를 해결하기 위해 데이터 사용자들과 논의하여, 활용 목적에 맞춰 데이터셋을 재구성하고 ‘단기 예보’ 데이터를 중심으로 날씨 데이터를 정리했습니다. 이후 데이터 엔지니어링팀이 다양한 적재 방식을 시도하며 안정적인 적재 환경을 구축했습니다.</p>

<h2 id="3-데이터-분석-및-운영-적용">3. 데이터 분석 및 운영 적용</h2>

<h3 id="31-분석을-위한-데이터-상세-정의">3.1 분석을 위한 데이터 상세 정의</h3>

<p>날씨 데이터는 직관적으로 이해하기 쉬우나, 실제 운영 적용을 위해서는 명확한 기준이 필요합니다. 예를 들어 ‘비가 온다’는 정보도 개인이 우산을 챙길지 결정하는 것과 야구 경기가 취소될지를 판단하는 맥락에서는 서로 다른 기준이 적용됩니다.</p>

<p>또한, 날씨 정보는 행정구역 단위로 제공되지만, 실제 기상 현상은 행정 경계를 따르지 않기에 ‘서울의 날씨’가 서울 내 모든 차량에 동일하게 적용된다고 볼 수 없습니다. 따라서 데이터 활용 목적에 맞춰 최적의 기준을 설정해야 합니다. 운영 단위가 너무 넓으면 날씨의 영향을 제대로 반영하기 어렵고, 너무 좁으면 관리 부담이 커지기 때문입니다.</p>

<p><code class="language-plaintext highlighter-rouge">우리가 알고있는 ‘서울 날씨’는 실제로는 '중구 을지로'의 날씨입니다.</code>
<img src="/img/2025-02-12-weather-wash/날씨_지도.png" alt="날씨_지도.png" /></p>

<p><code class="language-plaintext highlighter-rouge">요즘 같이 국지성 호우가 잦아지는 환경에서는 지역을 구분하는 기준이 더욱 고민되었습니다.</code>
<img src="/img/2025-02-12-weather-wash/날씨_하늘.png" alt="날씨_하늘.png" /></p>

<p>결론적으로 날씨 데이터를 세차 요청 로직에 적용하기 위해, 전국의 종관기상관측소(ASOS)를 기준으로 특정 시점의 차량 위치와 가장 가까운 기상관측소의 데이터를 연결하는 방식을 채택했습니다. 기상 상태를 대표하는 지역 단위의 선정이 쉽지 않고 또 전국 각지의 행정구역의 경계를 그대로 위치 정보로 변환하여, 이를 차량의 위치 정보와 결합하는 것은 매우 방대한 데이터 처리를 필요로 하기 때문입니다. 차량과 가장 가까운 지점의 기상 정보를 반영하는 방식은 이러한 문제를 해결하면서도 차량과 가장 가까운 지점의 기상 정보를 반영하는 데 가장 합리적이라고 판단했습니다.</p>
<figure style="display: flex; justify-content: center; gap: 10px;">
  <img src="/img/2025-02-12-weather-wash/날씨_한반도_왼.png" alt="날씨_한반도_왼" style="width: 50%; height: auto;" />
  <img src="/img/2025-02-12-weather-wash/날씨_한반도_오.png" alt="날씨_한반도_오" style="width: 50%; height: auto;" />
</figure>

<p><code class="language-plaintext highlighter-rouge">종관기상관측소(ASOS) 기준으로 반경 최대 32km이내의 차량 추출해 적용한다면 데이터가 누락되는 구간없이 차와 날씨를 연결할 수 있다고 판단했습니다.</code></p>

<h3 id="32-날씨와-차량-오염의-상관관계-분석">3.2 날씨와 차량 오염의 상관관계 분석</h3>

<p>날씨가 차량 오염에 미치는 영향을 검증하기 위해, 강수량과 차량 오염도를 비교하는 분석을 진행했습니다. 분석에 있어 큰 고민은 강수량을 누적값으로 볼지, 특정 시간대의 값으로 볼지, 그리고 강수의 영향을 받는 시간 범위를 어떻게 설정할지였습니다.</p>

<p>단순히 하루 총 강수량을 기준으로 하면 특정 시간대의 집중 호우 영향을 놓칠 수 있고, 반대로 특정 시간대만 고려하면 지속적인 강수 효과를 반영하기 어렵습니다. 이를 해결하기 위해 강수량의 누적값과 시간당 최대값을 비교하며 차량 오염 변화를 분석한 결과, 시간당 최대 강수량이 차량 오염을 활용하는 것이 가장 적절하다는 결론을 내렸습니다.</p>

<p>이에 따라 날씨와 차량을 연결하는데 아래와 같은 기준을 설정했습니다.</p>

<ul>
  <li><strong>‘비를 맞았다’의 정의</strong>
    <ul>
      <li>가장 가까운 종관기상관측소 기준, 하루 중 시간당 강우량이 3mm 이상인 지역에 ±2시간 이내 존재한 차량</li>
    </ul>
  </li>
  <li><strong>‘눈을 맞았다’의 정의</strong>
    <ul>
      <li>가장 가까운 종관기상관측소 기준, 하루 중 시간당 적설량이 1cm 이상인 지역에 ±2시간 이내 존재한 차량</li>
    </ul>
  </li>
</ul>

<p>이 기준을 통해 강수량이 차량 오염에 미치는 실질적인 영향을 반영할 수 있었습니다. 분석 결과, 눈을 맞은 차량은 오염될 확률이 높아 세차 요청 로직을 유지하는 것이 타당했습니다. 반면, <strong>강우량이 일정 수준 이상일 경우, 차량이 외부 존에 있는 경우 차량 외관이 자연적으로 세척되는 경향이 나타났으며, 이에 따라 해당 차량의 세차 요청을 보류하는 것이 운영적으로 더 효율적이라는 결론을 도출했습니다.</strong></p>

<p><code class="language-plaintext highlighter-rouge">'비를 맞은' 차량은 외관이 세척되는 효과가 있어, 세차 필요 여부를 다시 고려하게 됐습니다.</code>
<img src="/img/2025-02-12-weather-wash/비_맞은_차량.png" alt="비_맞은_차량.png" /></p>

<p><code class="language-plaintext highlighter-rouge">‘눈을 맞은' 차량은 오염되는 경향이 있어, 기존 오염 판단에 따라 그대로 세차를 요청할 필요가 있었습니다.</code>
<img src="/img/2025-02-12-weather-wash/눈맞은차.jpg" alt="눈맞은차.jpg" /></p>

<h3 id="33-운영-적용을-위한-시뮬레이션">3.3 운영 적용을 위한 시뮬레이션</h3>

<p><strong>관측치와 예측치의 비교</strong></p>

<p>분석 결과를 운영에 반영하기 위해서는, 다시 관측된 강수량 데이터가 아니라 예측치인 일기 예보 데이터를 활용하는 방식으로 로직을 최적화해야 했습니다. 오늘 세차를 요청할지 말지는 예보를 기준으로 사전에 판단되어야 하기 때문입니다.</p>

<p>먼저 기존 ‘세차 오퍼레이션’의 운영 시점에 맞춰, 새벽 5시에 업데이트된 단기 예보를 기준으로 당일 6시~23시의 강수량과 강수 확률을 활용하는 방식이 가장 적절하다고 판단했습니다.</p>

<p><strong>단기 예보</strong></p>

<table>
  <thead>
    <tr>
      <th><strong>예보 기간</strong></th>
      <th><strong>발표 주기</strong></th>
      <th><strong>내용</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>발표 시점으로부터 3일 이내</td>
      <td>하루 8회 <br /> (02, 05, 08, 11, 14, 17, 20, 23시)</td>
      <td>정시 기온, 최고·최저기온, 강수형태, 강수확률, 강수량, 적설, 하늘상태, 풍향, 풍속, 습도, 파고 등 12개 요소를 1시간 단위로 제공</td>
    </tr>
  </tbody>
</table>

<p>앞선 분석을 통해 시간당 3mm 이상의 강수량이 차량 오염에 영향을 미친다는 기준을 설정했지만, 실제 운영에서 활용하기 위해서는 더 정밀한 조건이 필요했습니다. 이에 따라 시간당 강수량(Nmm)과 강수 확률(N%)을 조정하며, 관측된 날씨 데이터와 예측된 데이터가 얼마나 일치하는지 검토했습니다. 또한, 단순히 예측 정확도를 확인하는 것을 넘어 각 기준에 따라 세차 요청을 보류할 경우 실제 운영 비용 절감 효과가 얼마나 발생하는지도 확인했습니다. 이 분석을 통해 예측 정확도와 기대 효과 규모의 최적점이 되는 시간당 강수량(Nmm)과 강수 확률(N%)을 정의했습니다.</p>

<p><strong>운영 적용을 위한 시뮬레이션</strong></p>

<p>추가로 실제 운영에 반영하기 전, 새로운 정책 적용이 세차 요청 프로세스에 미치는 영향을 정량적으로 검토할 필요가 있었습니다. 특히, 연속적인 강수 이벤트로 인해 세차 요청이 한꺼번에 몰리는 현상이 발생하는지 확인하는 것이 중요했습니다.</p>

<p>비가 오는 동안 세차 요청을 지연시키고, 날씨가 맑아지면 기 발행된 세차 요청을 수행하는 방식으로 시뮬레이션을 진행했습니다.</p>

<p><img src="/img/2025-02-12-weather-wash/운영_시뮬레이션.png" alt="운영_시뮬레이션.png" /></p>

<p>이를 전국 데이터를 기반으로 비가 자주 내린 한 달 동안 적용한 결과, 강수 예보를 반영했을 때 세차 요청 건수가 감소하는 효과가 명확히 확인되었습니다. 또한, 강수 종료 후 요청이 한꺼번에 몰려 운영 부담이 커지는 현상은 발생하지 않았습니다. 이는 세차 요청이 매일 모든 차량에 대해 발생하지 않고, 기존에도 처리되지 않은 요청이 반복적으로 발생하는 구조 때문인 것으로 파악했습니다.</p>

<p><img src="/img/2025-02-12-weather-wash/운영_시뮬레이션2.png" alt="운영_시뮬레이션2.png" /></p>

<h2 id="4-운영-적용-결과-및-인사이트">4. 운영 적용 결과 및 인사이트</h2>

<h3 id="41-날씨-기반-세차-운영-정책-적용-결과">4.1 날씨 기반 세차 운영 정책 적용 결과</h3>

<p>최종적으로, 새벽 5시 기준 업데이트된 단기 예보 데이터를 기반으로 당일 6시~23시의 시간별 강수량과 강수 확률이 특정 기준을 초과하는 경우, 해당 지역의 차량 외부 세차 요청을 보류하는 로직을 추가했습니다. 이 로직은 1년 이상 안정적으로 운영되었으며, 기존 운영 조건들보다 운영 효율화에 강력한 영향을 미치는 변수로 확인되었습니다. 또한, 이를 통한 비용 절감 효과 역시 확인할 수 있었습니다.</p>

<h3 id="42-맥락-해석이-필요한-데이터의-활용">4.2 맥락 해석이 필요한 데이터의 활용</h3>

<p>비 오는 날 세차가 비효율적이라는 것은 상식처럼 여겨집니다. 그러나 이를 데이터 기반 의사결정으로 전환하려면, 직관적인 개념을 데이터로 해석할 수 있는 기준을 설정하고, 이를 정량적으로 검증하는 과정이 필요합니다.</p>

<p>날씨 데이터와 차량 위치 데이터는 개별적으로는 정형화된 정보지만, 실제 세차 운영 의사결정에 활용하려면 여러 변수를 종합적으 고려하는 맥락적 해석이 필요합니다. 예를 들어, ‘오늘 서울에 비가 온다’는 단순한 정보처럼 보이지만, 이를 유의미한 기준으로 변환하려면 강수량, 강수 확률, 지역별 차이, 시간대 등 다양한 요소를 함께 고려해야 합니다. 본 프로젝트에서는 이러한 복합적인 변수를 통합하여 의사결정 기준을 수립하고, 이를 바탕으로 정형화된 결과를 도출하는 것이 핵심이었습니다. 이를 통해 내부 이해관계자들과 공감대를 형성하고, 운영에 실질적으로 적용할 수 있도록 설득력을 높였습니다.</p>

<p>또한, 데이터 기반으로 문제를 구조화했기 때문에 적용 이후에도 지속적인 개선이 가능했습니다. 새로운 정보가 추가되거나 전략적 의사결정이 필요할 때, 설정한 기준을 조정하여 예상되는 영향을 평가하고 최적의 결정을 내릴 수 있었습니다. 단순한 데이터 분석에 그치는 것이 아니라, 이를 실제 운영에 적용하고 목표하는 결과를 도출할 수 있는지 검증하는 과정까지 포함하는 것이 중요했습니다.</p>

<p>이처럼 현실의 문제를 데이터로 해결하려면, 단순한 데이터 수집과 분석을 넘어 다양한 변수를 고려하여 의미를 해석하고 설득력 있는 기준을 마련해야 합니다. 또한, 이를 바탕으로 최적화된 의사결정 체계를 구축하는 것이 필수적임을 다시 한번 확인할 수 있었습니다.</p>]]></content><author><name>햇님</name></author><category term="data" /><category term="data product" /><category term="data engineering" /><category term="data science" /><summary type="html"><![CDATA[들어가며 날씨 기반 세차 운영 최적화 2.1 기존 세차 오퍼레이션과 개선 필요성 2.2 날씨 데이터 수집 데이터 분석 및 운영 적용 3.1 분석을 위한 데이터 상세 정의 3.2 날씨와 차량 오염의 상관관계 분석 3.3 운영 적용을 위한 시뮬레이션 운영 적용 결과 및 인사이트 4.1 날씨 기반 세차 운영 정책 적용 결과 4.2 맥락 해석이 필요한 데이터의 활용]]></summary></entry></feed>