<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko-KR"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://saramin.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://saramin.github.io/" rel="alternate" type="text/html" hreflang="ko-KR" /><updated>2025-11-14T17:04:22+09:00</updated><id>https://saramin.github.io/feed.xml</id><title type="html">기술블로그</title><subtitle>Saramin Tech Blog / 사람인 기술 블로그</subtitle><entry><title type="html">Shadow Dom : 중요한 건 깨지지 않는 스타일</title><link href="https://saramin.github.io/2025-11-12-shadow-dom/" rel="alternate" type="text/html" title="Shadow Dom : 중요한 건 깨지지 않는 스타일" /><published>2025-11-12T00:00:00+09:00</published><updated>2025-11-14T17:00:00+09:00</updated><id>https://saramin.github.io/shadow-dom</id><content type="html" xml:base="https://saramin.github.io/2025-11-12-shadow-dom/"><![CDATA[<p>2025년 7월 사람인의 공고/후보자 서비스의 대대적인 개편이 있었습니다.🎉<br />
후보자 서비스에서 다양한 연계 플랫폼(점핏, 코메이트, 원더풀 시니어 등)과의 연동이 가능해졌습니다.</p>

<p>문제는 여기서 시작됐는데요. 각 플랫폼이 제공하는 이력서는 PDF, HTML 등 형식도 다르고 HTML 기반이라 해도 CSS, 클래스명, 레이아웃방식이 모두 제각각입니다.<br />
이 말은 <span class="highlighting-underline--red">후보자 상세 페이지의 기존 스타일과 충돌해 깨질 수 있다는 것을 의미</span>합니다.</p>

<p>후보자 상세 페이지는 인사담당자가 후보자를 평가하기 위해 제일 많이 접근하는 핵심화면입니다.<br />
이력서가 깨지거나 읽기 어려워지면 회사는 인재를 놓치고 후보자는 합격의 기회를 놓치는 등의 악영향이 있을 수 있습니다. <span class="highlighting-underline--blue">중요한 건 깨지지 않는 스타일</span>이기에 이 문제를 해결하기 위해 <code class="language-plaintext highlighter-rouge">단단하고 안전한 캡슐화</code>가 필요했습니다.</p>

<p>그리고 그 답은, <code class="language-plaintext highlighter-rouge">Shadow Dom</code>이었습니다.</p>

<p>이 글에서는 “Shadow DOM으로 하나의 서비스에서 다양한 플랫폼의 이력서를 깨지지 않고 보여주는 방법”에 대하여 정리했습니다.</p>

<blockquote class="prompt-info">
  <p><strong>이 글로 얻을 수 있는 정보</strong></p>
  <ul>
    <li>Shadow DOM이란?</li>
    <li>Shadow DOM의 장단점</li>
    <li>Shadow DOM 내 HTML/CSS 데이터를 렌더링 하는 방법</li>
    <li>Shadow DOM 내 내/외부 스크립트를 실행시키는 방법 및 주의 사항</li>
    <li>Shadow DOM 내 스타일 적용 시 스타일 미적용 데이터 노출 현상 해결방법</li>
  </ul>
</blockquote>

<p><br />
<br /></p>
<h2 id="1-shadow-dom">1. Shadow DOM</h2>
<p>연동 플랫폼이 다양해지면서 다음과 같은 문제가 예상되는 상황이었습니다.</p>

<ul>
  <li>CSS/DOM 스코프 충돌</li>
  <li>재사용성 저하</li>
  <li>예측 불가능한 Side Effect 발생 가능성</li>
</ul>

<p>이를 근본적으로 차단하기 위해  <code class="language-plaintext highlighter-rouge">Shadow Dom</code>을 도입하게되었습니다. Shadow DOM은 CSS/DOM을 완전히 독립된 공간으로 격리해주는 기술입니다.</p>
<h3 id="1-1-shadow-dom의-구조">1-1. Shadow DOM의 구조</h3>
<p><img src="/img/shadow-dom/shadow-dom.svg" alt="Shadow DOM" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Shadow host</code>: Shadow DOM이 연결된 일반 DOM 노드</li>
  <li><code class="language-plaintext highlighter-rouge">Shadow tree</code>: Shadow DOM 내의 DOM 트리</li>
  <li><code class="language-plaintext highlighter-rouge">Shadow boundary</code>: Shadow DOM이 끝나고 일반 DOM이 시작되는 곳</li>
  <li><code class="language-plaintext highlighter-rouge">Shadow root</code>: Shadow 트리의 root 노드</li>
</ul>

<p>Shadow Dom은 Shadow host에 Shadow Tree를 만들어 Shadow root를 통하여 DOM 내부를 제어할 수 있습니다.<br />
즉 <span class="highlighting-underline--blue">Shadow root가 Shadow DOM 내에서 document 같은 역할</span>을 하게 됩니다. (이 부분은 Shadow DOM 내 제어에 큰 역할을 하게 되니 꼭 기억해주세요!)</p>
<h3 id="1-2-shadow-root-mode-open-vs-closed">1-2. Shadow root mode (open vs closed)</h3>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="kd">const</span> <span class="nx">host</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">#shadow-host</span><span class="dl">"</span><span class="p">);</span> <span class="c1">// host 선택</span>
<span class="kd">const</span> <span class="nx">shadow</span> <span class="o">=</span> <span class="nx">host</span><span class="p">.</span><span class="nf">attachShadow</span><span class="p">({</span> <span class="na">mode</span><span class="p">:</span> <span class="dl">"</span><span class="s2">open</span><span class="dl">"</span> <span class="p">});</span> <span class="c1">// shadow root 객체 생성(open)</span>
<span class="c1">// const shadow = host.attachShadow({ mode: "closed" }); // shadow root 객체 생성(closed)</span>

<span class="c1">// Shadow DOM 내 element 추가</span>
<span class="kd">const</span> <span class="nx">span</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">span</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">span</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">I'm in the shadow DOM</span><span class="dl">"</span><span class="p">;</span>
<span class="nx">shadow</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">span</span><span class="p">);</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Shadow Dom은 <code class="language-plaintext highlighter-rouge">attachShadow</code> 메서드를 이용해 Shadow root 객체를 생성하여 사용할 수 있습니다.<br />
이 때, attachShadow 메서드를 사용할 때 위과 같이 <code class="language-plaintext highlighter-rouge">{ mode: "open" | "closed" }</code>를 사용할 수 있는데요! 해당 옵션으로  외부에서 Shadow root 접근 가능 여부를 정할 수 있습니다.</p>

<p><strong>open mode 외부 접근 시도</strong><br />
<img src="/img/shadow-dom/open.png" alt="open" /></p>

<p><strong>closed mode 외부 접근 시도</strong><br />
<img src="/img/shadow-dom/closed.png" alt="closed" /></p>

<p><code class="language-plaintext highlighter-rouge">&lt;template&gt;</code>을 이용해 Shadow DOM을 구성하는 방법도 있으니, 관심이 있으시다면 아래 글을 참고해보시면 좋을 것 같습니다.</p>
<ul>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM#declaratively_with_html">Declaratively with HTML</a></li>
</ul>

<p><br />
<br /></p>
<h2 id="2-shadow-dom-내-이력서-렌더링-플로우">2. Shadow DOM 내 이력서 렌더링 플로우</h2>
<p>이력서 데이터는 HTML 문자열로 전달되기 때문에 Shadow DOM에서 렌더링하기 위한 가공 과정이 필요했습니다.<br />
전체적인 플로우는 다음과 같습니다.</p>

<ol>
  <li>String -&gt; HTML 데이터로 변경</li>
  <li>meta, link, style, body content 태그 추출 후 HTML 구조 생성</li>
  <li>내/외부 스크립트 추출</li>
  <li>Shadow DOM 생성</li>
  <li>추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입</li>
  <li>HTML 데이터 Shadow DOM 내 삽입</li>
  <li>Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC)</li>
  <li>Render 성공 후 내부 스크립트 Shadow DOM 내 삽입</li>
</ol>

<p>플로우 순서대로 간략한 예시 코드와 함께 하나씩 알아보겠습니다! 각 단계 별로 주의할 점도 함께 기재해두었으니 참고해보면 좋을 것 같습니다👍</p>
<h3 id="2-1-string---html-데이터로-변경">2-1. String -&gt; HTML 데이터로 변경</h3>
<p>먼저 <code class="language-plaintext highlighter-rouge">DOMParser</code>의 <code class="language-plaintext highlighter-rouge">parseFromString</code> 메서드를 이용해 string 데이터를 HTML로 변경해줍니다.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="c1">// HTML 파싱</span>
  <span class="kd">const</span> <span class="nx">parser</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">DOMParser</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">doc</span> <span class="o">=</span> <span class="nx">parser</span><span class="p">.</span><span class="nf">parseFromString</span><span class="p">(</span><span class="nx">resumeHtml</span><span class="p">,</span> <span class="dl">"</span><span class="s2">text/html</span><span class="dl">"</span><span class="p">);</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="2-2-meta-link-style-body-content-태그-추출-후-html-구조-생성">2-2. meta, link, style, body content 태그 추출 후 HTML 구조 생성</h3>
<p><strong>Shadow DOM 내 script를 실행시키려면 script태그를 만들어 넣어주어야하기 때문</strong>에 script를 제외한 meta, link, style, body content를 추출 후 HTML을 재구성해줍니다.</p>

<blockquote class="prompt-warning">
  <ul>
    <li>이력서 데이터에서 <code class="language-plaintext highlighter-rouge">base</code> 태그로 <code class="language-plaintext highlighter-rouge">baseURI</code>를 지정하는데, base 태그는 document 기준으로 작동하기 때문에 Shadow DOM 내부에서는 <code class="language-plaintext highlighter-rouge">상대경로 -&gt; 절대경로</code>변환이 필요합니다.<br /></li>
    <li>만약 Shadow DOM 내 적용될 Global Style이 있다면 <code class="language-plaintext highlighter-rouge">:host</code>로 정의해주면 됩니다.</li>
  </ul>
</blockquote>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
</pre></td><td class="rouge-code"><pre><span class="c1">// base url 추출</span>
<span class="kd">const</span> <span class="nx">baseElement</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">base</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">baseUrl</span> <span class="o">=</span> <span class="nx">baseElement</span><span class="p">?.</span><span class="nf">getAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">href</span><span class="dl">"</span><span class="p">)</span> <span class="o">??</span> <span class="dl">"</span><span class="s2">https://baseurl.com</span><span class="dl">"</span> <span class="p">;</span>

<span class="c1">// 메타 태그 추출</span>
<span class="kd">const</span> <span class="nx">metaTags</span> <span class="o">=</span> <span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">doc</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">meta</span><span class="dl">"</span><span class="p">))</span>
  <span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">meta</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">meta</span><span class="p">.</span><span class="nx">outerHTML</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// CSS link 태그 생성 (모든 상대 경로를 절대 경로로 변환)</span>
<span class="kd">const</span> <span class="nx">links</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">link[href]</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">linkTags</span> <span class="o">=</span> <span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">links</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">link</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="p">(</span><span class="nx">상대경로</span> <span class="o">-&gt;</span> <span class="nx">절대경로</span> <span class="nx">변환</span> <span class="nx">로직</span><span class="p">)</span>
  <span class="p">})</span>
  <span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">);</span>


<span class="c1">// 기존 스타일 태그 내용 추출</span>
<span class="kd">let</span> <span class="nx">styles</span> <span class="o">=</span> <span class="dl">""</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">styleElements</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">style</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">styleElements</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">styleElement</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">styles</span> <span class="o">+=</span> <span class="nx">styleElement</span><span class="p">.</span><span class="nx">textContent</span><span class="p">;</span>
<span class="p">});</span>


<span class="c1">// body 내용에서 script 태그 제거</span>
<span class="kd">const</span> <span class="nx">bodyElement</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nx">body</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">bodyElement</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// body에서 모든 script 태그 선택</span>
  <span class="kd">const</span> <span class="nx">scriptTags</span> <span class="o">=</span> <span class="nx">bodyElement</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">script</span><span class="dl">"</span><span class="p">);</span>

  <span class="c1">// 각 script 태그 제거 (내부 스크립트는 따로 추출)</span>
  <span class="nx">scriptTags</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">script</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">script</span><span class="p">.</span><span class="nf">remove</span><span class="p">();</span>
  <span class="p">});</span>

  <span class="c1">// 상대경로 a tag를 찾아, baseUrl을 붙인 절대 경로로 변경</span>
  <span class="kd">const</span> <span class="nx">aTags</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">a[href]</span><span class="dl">"</span><span class="p">);</span>
  <span class="nx">aTags</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">a</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="p">(</span><span class="nx">상대경로</span> <span class="o">-&gt;</span> <span class="nx">절대경로</span> <span class="nx">변환</span> <span class="nx">로직</span><span class="p">)</span>
  <span class="p">});</span>
<span class="p">}</span>

<span class="c1">// 파싱된 HTML 이력서 데이터</span>
<span class="kd">const</span> <span class="nx">parsedHtmlResumeData</span> <span class="o">=</span> <span class="s2">`
    </span><span class="p">${</span><span class="nx">metaTags</span><span class="p">}</span><span class="s2">
    </span><span class="p">${</span><span class="nx">linkTags</span><span class="p">}</span><span class="s2">
    &lt;style&gt;
      </span><span class="p">${</span><span class="nx">saraminGlobalStyle</span><span class="p">}</span><span class="s2">
      </span><span class="p">${</span><span class="nx">styles</span><span class="p">}</span><span class="s2">
    &lt;/style&gt;
    </span><span class="p">${</span><span class="nx">bodyContent</span><span class="p">}</span><span class="s2">
  `</span><span class="p">;</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="2-3-내외부-스크립트-추출">2-3. 내/외부 스크립트 추출</h3>
<p>내/외부 스크립트도 추출 시 <code class="language-plaintext highlighter-rouge">상대경로 -&gt; 절대경로</code> 변환이 필요합니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre><span class="c1">// 외부 스크립트 처리</span>
 <span class="kd">const</span> <span class="nx">externalScripts</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">script[src]</span><span class="dl">"</span><span class="p">);</span>
 <span class="kd">const</span> <span class="nx">externalScriptSrcList</span> <span class="o">=</span> <span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">externalScripts</span><span class="p">).</span><span class="nf">map</span><span class="p">((</span><span class="nx">script</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="p">(</span><span class="nx">상대경로</span> <span class="o">-&gt;</span> <span class="nx">절대경로</span> <span class="nx">변환</span> <span class="nx">로직</span><span class="p">)</span>
 <span class="p">});</span>

<span class="c1">// 내부  스크립트 태그 처리 (src 속성이 없는 스크립트)</span>
 <span class="kd">const</span> <span class="nx">inlineScripts</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">script:not([src])</span><span class="dl">"</span><span class="p">);</span>
 <span class="kd">const</span> <span class="nx">inlineScriptContents</span> <span class="o">=</span> <span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">inlineScripts</span><span class="p">)</span>
   <span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">script</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="p">(</span><span class="nx">상대경로</span> <span class="o">-&gt;</span> <span class="nx">절대경로</span> <span class="nx">변환</span> <span class="nx">로직</span><span class="p">)</span>
   <span class="p">});</span>

</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="2-4-shadow-dom-생성">2-4. Shadow DOM 생성</h3>
<p>Shadow host에 Shadow DOM을 생성합니다.<br />
(후보자 관리 페이지는 Next.js로 구성되어 있어 실제로는 <code class="language-plaintext highlighter-rouge">Ref</code>를 참고하고 있는데,  기본적으로  <code class="language-plaintext highlighter-rouge">document.querySelector</code>를 사용하시면 됩니다.)</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="kd">const</span> <span class="nx">shadowRoot</span> <span class="o">=</span>
        <span class="nx">shadowDomRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">shadowRoot</span> <span class="o">||</span>
        <span class="nx">shadowDomRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">attachShadow</span><span class="p">({</span> <span class="na">mode</span><span class="p">:</span> <span class="dl">"</span><span class="s2">closed</span><span class="dl">"</span> <span class="p">});</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<ol>
  <li>String -&gt; HTML 데이터로 변경 ✅</li>
  <li>meta, link, style, body content 태그 추출 후 HTML 구조 생성 ✅</li>
  <li>내/외부 스크립트 추출 ✅</li>
  <li>Shadow DOM 생성 ✅</li>
  <li>추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입</li>
  <li>HTML 데이터 Shadow DOM 내 삽입</li>
  <li>Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC)</li>
  <li>Render 성공 후 내부 스크립트 Shadow DOM 내 삽입</li>
</ol>

<p>여기까지가 렌더링 플로우의 반 정도 왔는데요. 대부분의 <code class="language-plaintext highlighter-rouge">Side Effect</code>가 내/외부 스크립트에 발생할 확률이 높아 앞으로가 더 중요하니 집중해서 봐주세요!</p>

<h3 id="2-5-추출한-외부-스크립트-로드-후-shadow-dom-내-삽입">2-5. 추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입</h3>
<p>이력서 내에 외부 스크립트를 사용하는 컴포넌트가 존재할 수도 있습니다. 따라서 재구성한 HTML 데이터를 Shadow DOM에 넣기 전, 외부스크립트를 먼저 불러옵니다.<br />
이 때, <code class="language-plaintext highlighter-rouge">Promise.all</code> 대신 <code class="language-plaintext highlighter-rouge">Promise.allSettled</code>를 사용했습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Promise.all</code>: 하나의 스크립트가 실패하면 전체 렌더링 실패</li>
  <li><code class="language-plaintext highlighter-rouge">Promise.allSettled</code>: 실패한 스크립트가  어도 나머지 결과를 모두 받아올 수 있어 렌더링 안정성에 적합</li>
</ul>

<blockquote class="prompt-warning">
  <p>아래 예시 코드와 같이 외부 스크립트와 프로젝트 내 라이브러리 버전 충돌이 있는 경우, 버전 동기화 작업이 필요할 수 있습니다.</p>
</blockquote>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
</pre></td><td class="rouge-code"><pre><span class="c1">// 외부 스크립트 로드 함수</span>
  <span class="kd">const</span> <span class="nx">loadExternalScript</span> <span class="o">=</span> <span class="p">(</span><span class="nx">shadowRoot</span><span class="p">:</span> <span class="nx">ShadowRoot</span><span class="p">,</span> <span class="nx">src</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">((</span><span class="nx">resolve</span><span class="p">,</span> <span class="nx">reject</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">script</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">script</span><span class="dl">"</span><span class="p">);</span>

      <span class="kd">let</span> <span class="nx">convertedSrc</span> <span class="o">=</span> <span class="nx">src</span><span class="p">;</span>

      <span class="c1">// react-pdf와 pdfjs 충돌로 인해 shadowDom 내 pdf.js/pdf.worker.js를 react-pdf와 같은 패키지를 사용하도록 변경</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">src</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="dl">"</span><span class="s2">pdf.worker.js</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
        <span class="c1">// PDF 워커 파일</span>
        <span class="nx">convertedSrc</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">URL</span><span class="p">(</span>
          <span class="dl">"</span><span class="s2">pdfjs-dist/build/pdf.worker.min.mjs</span><span class="dl">"</span><span class="p">,</span>
          <span class="k">import</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span>
        <span class="p">).</span><span class="nf">toString</span><span class="p">();</span>
        <span class="nx">script</span><span class="p">.</span><span class="kd">type</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">module</span><span class="dl">"</span><span class="p">;</span>
      <span class="p">}</span> <span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">src</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="dl">"</span><span class="s2">pdf.js</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
        <span class="c1">// PDF 메인 파일</span>
        <span class="nx">convertedSrc</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">URL</span><span class="p">(</span>
          <span class="dl">"</span><span class="s2">pdfjs-dist/build/pdf.min.mjs</span><span class="dl">"</span><span class="p">,</span>
          <span class="k">import</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span>
        <span class="p">).</span><span class="nf">toString</span><span class="p">();</span>
        <span class="nx">script</span><span class="p">.</span><span class="kd">type</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">module</span><span class="dl">"</span><span class="p">;</span>
      <span class="p">}</span>

      <span class="nx">script</span><span class="p">.</span><span class="nx">src</span> <span class="o">=</span> <span class="nx">convertedSrc</span><span class="p">;</span>

      <span class="nx">script</span><span class="p">.</span><span class="nx">onload</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nf">resolve</span><span class="p">();</span>
      <span class="p">};</span>
      <span class="nx">script</span><span class="p">.</span><span class="nx">onerror</span> <span class="o">=</span> <span class="p">(</span><span class="nx">error</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">error</span><span class="p">(</span><span class="s2">`Script error: </span><span class="p">${</span><span class="nx">convertedSrc</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
        <span class="nf">reject</span><span class="p">(</span><span class="nx">error</span><span class="p">);</span>
      <span class="p">};</span>

      <span class="nx">shadowRoot</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">script</span><span class="p">);</span>
    <span class="p">});</span>
  <span class="p">};</span>

    <span class="c1">// 스크립트 처리 (HTML 콘텐츠는 스크립트 로드 후 설정)</span>
    <span class="c1">// 모든 외부 스크립트를 병렬로 로드(외부 스크립트 중 하나가 실패해도 모든 promise의 결과를 받을 수 있게 allSettled를 사용</span>
    <span class="k">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">allSettled</span><span class="p">(</span>
      <span class="nx">externalScriptSrcList</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">src</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">loadExternalScript</span><span class="p">(</span><span class="nx">shadowRoot</span><span class="p">,</span> <span class="nx">src</span><span class="p">)),</span>
    <span class="p">);</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="2-6-html-데이터-shadow-dom-내-삽입">2-6. HTML 데이터 Shadow DOM 내 삽입</h3>
<p><code class="language-plaintext highlighter-rouge">2-2번</code>에서 만든 <code class="language-plaintext highlighter-rouge">HTML 데이터(parsedHtmlResumeData)</code>는 구성한 모양 그대로 Shadow DOM에 들어가야하기 때문에 <code class="language-plaintext highlighter-rouge">Fragment</code>로 넣어줍니다.</p>

<blockquote class="prompt-tip">
  <p><code class="language-plaintext highlighter-rouge">template.content</code>는 <code class="language-plaintext highlighter-rouge">Document Fragment</code>를 반환합니다.</p>
</blockquote>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="c1">// 외부 스크립트 처리 후 파싱된 이력서 데이터 Fragment로 추가</span>
    <span class="kd">const</span> <span class="nx">template</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">template</span><span class="dl">"</span><span class="p">);</span>
    <span class="nx">template</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="nx">parsedHtmlResumeData</span><span class="p">;</span>
    <span class="nx">shadowRoot</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">template</span><span class="p">.</span><span class="nx">content</span><span class="p">);</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="2-7-shadow-dom-내-스타일-변경-감지-후-render-성공-여부-처리-스타일-미적용-데이터-노출-현상---fouc">2-7. Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC)</h3>
<p><code class="language-plaintext highlighter-rouge">2-6번</code>에서 Shadow DOM에 삽입하면 <code class="language-plaintext highlighter-rouge">FOUC(Flash of Unstyled Content)</code>가 발생하게 됩니다. <br /></p>

<ul>
  <li>ShadowRoot.adoptedStyleSheets</li>
  <li>HTML 데이터보다 style태그 우선 삽입</li>
</ul>

<p>위와 같은 Shadow DOM 내 FOUC 해결 방법으로 해결되지 않아 <strong>DOM 트리에서 이루어지는 변경 사항을 감지할 수 있는 MutationObserver을 사용</strong>하였습니다.</p>

<blockquote class="prompt-info">
  <p><strong>FOUC(Flash of Unstyled Content)란?</strong><br />
웹 페이지가 로드될 때, 스타일이 적용되지 않은 컨텐츠가 잠시 나타났다가 이후 스타일이 적용되는 현상을 말합니다.</p>
</blockquote>

<blockquote class="prompt-info">
  <p><strong>adoptedStyleSheets란?</strong><br />
Shadow root 인터페이스의 속성으로 Shadow DOM 하위 트리에서 사용될 구성된 스타일시트의 배열을 설정합니다.</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">MutationObserver</code>를 이용한 방법을 간단히 설명하면 아래와 같습니다.</p>

<ol>
  <li>Boolean 성공 여부 상태 값에 따라 Shadow DOM display block/none 처리 (초기에는 none)</li>
  <li>MutationObserver로 스타일/클래스 변경을 감지</li>
  <li>0.3초 동안 스타일 변경이 없으면 스타일이 안정화되었다고 간주하여 성공 상태로 변경 (최대 5초 후에는 무조건 성공으로 간주)</li>
  <li>성공 여부 상태 값이 true가 되어 Shadow DOM display block으로 변경</li>
</ol>

<p>저희 팀에서는 <code class="language-plaintext highlighter-rouge">no-use-before-define lint rule</code>을 적용하기 때문에 예시 코드는 밑에서부터 번호를 따라 보시는 게 더 보기에 편할거에요!</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
</pre></td><td class="rouge-code"><pre><span class="c1">// 성공 여부 상태 값</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">isStyleSuccess</span><span class="p">,</span> <span class="nx">setIsStyleSuccess</span><span class="p">]</span> <span class="o">=</span> <span class="nf">useState</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
<span class="c1">// observer, 스타일 안정화 타이머 참조 값</span>
<span class="kd">const</span> <span class="nx">observerRef</span> <span class="o">=</span> <span class="nx">useRef</span><span class="o">&lt;</span><span class="nx">MutationObserver</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">styleStabilityTimerRef</span> <span class="o">=</span> <span class="nx">useRef</span><span class="o">&lt;</span><span class="nx">NodeJS</span><span class="p">.</span><span class="nx">Timeout</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>

<span class="cm">/**
   * 2. MutationObserver로 스타일/클래스 변경을 감지
   * shadow dom에 스타일 적용 시 스타일 적용되지 않은 데이터가 잠깐 노출되는 현상을 해결하기 위해 감지함.
   */</span>
  <span class="kd">const</span> <span class="nx">setupStyleObserver</span> <span class="o">=</span> <span class="p">(</span><span class="nx">shadowRoot</span><span class="p">:</span> <span class="nx">ShadowRoot</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="c1">// 이미 존재하는 Observer 정리</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">disconnect</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="c1">// 스타일 안정화 타이머 초기화</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">clearTimeout</span><span class="p">(</span><span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">);</span>
      <span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// MutationObserver 구성</span>
    <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">MutationObserver</span><span class="p">((</span><span class="nx">mutations</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="c1">// 스타일 변경 여부</span>
      <span class="kd">let</span> <span class="nx">isStyleChange</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>

      <span class="nx">mutations</span><span class="p">.</span><span class="nf">forEach</span><span class="p">(({</span> <span class="kd">type</span><span class="p">,</span> <span class="nx">addedNodes</span><span class="p">,</span> <span class="nx">attributeName</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="c1">// 스타일 요소 추가/제거 감지</span>
        <span class="k">if </span><span class="p">(</span><span class="kd">type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">childList</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
          <span class="c1">// 추가된 스타일 리스트</span>
          <span class="kd">const</span> <span class="nx">addedStyles</span> <span class="o">=</span> <span class="p">[...</span><span class="nx">addedNodes</span><span class="p">].</span><span class="nf">filter</span><span class="p">(</span>
            <span class="p">(</span><span class="nx">node</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">node</span><span class="p">.</span><span class="nx">nodeName</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">STYLE</span><span class="dl">"</span> <span class="o">||</span> <span class="nx">node</span><span class="p">.</span><span class="nx">nodeName</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">LINK</span><span class="dl">"</span><span class="p">,</span>
          <span class="p">);</span>

          <span class="cm">/**
           * 추가된 스타일이 있으면 변경 여부 true 처리
           * isStyleChange = addedStyles.length &gt; 0 와 같이 처리하면 된다고 생각할 수 있으나
           * mutations.forEach로 순회 시 하나라도 변경된 게 있으면 스타일 타이머를 설정해야 하기 때문에 아래와 같이 처리해야 함.
           */</span>
          <span class="k">if </span><span class="p">(</span><span class="nx">addedStyles</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="nx">isStyleChange</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
          <span class="p">}</span>
        <span class="p">}</span>

        <span class="c1">// 스타일/클래스 속성 변경 감지</span>
        <span class="k">if </span><span class="p">(</span>
          <span class="kd">type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">attributes</span><span class="dl">"</span> <span class="o">&amp;&amp;</span>
          <span class="p">(</span><span class="nx">attributeName</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">style</span><span class="dl">"</span> <span class="o">||</span> <span class="nx">attributeName</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">class</span><span class="dl">"</span><span class="p">)</span>
        <span class="p">)</span> <span class="p">{</span>
          <span class="nx">isStyleChange</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
        <span class="p">}</span>
      <span class="p">});</span>

      <span class="c1">// 스타일이 변경되지 않았으면 early return</span>
      <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">isStyleChange</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>

      <span class="c1">// 스타일 안정화 타이머 재설정</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">clearTimeout</span><span class="p">(</span><span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">);</span>
      <span class="p">}</span>

      <span class="c1">// 3. 0.3초 동안 스타일 변경이 없으면 스타일이 안정화되었다고 간주하여 성공 상태로 변경 (아래에 최대 5초 후에는 무조건 성공으로 간주)</span>
      <span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="c1">// 4. 성공 여부 상태 값이 true가 되어 Shadow DOM display block으로 변경</span>
        <span class="nf">setIsStyleSuccess</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
        <span class="c1">// Observer 정리</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">disconnect</span><span class="p">();</span>
          <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
        <span class="p">}</span>
      <span class="p">},</span> <span class="mi">300</span><span class="p">);</span>
    <span class="p">});</span> <span class="c1">// MutationObserver 구성 끝</span>

    <span class="c1">// Shadow DOM 전체와 하위 요소들의 변경 감시</span>
    <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">observe</span><span class="p">(</span><span class="nx">shadowRoot</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">childList</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="c1">// 자식 요소 추가/제거 감지</span>
      <span class="na">attributes</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="c1">// 속성 변경 감지</span>
      <span class="na">subtree</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="c1">// 하위 요소의 변경도 감지</span>
      <span class="na">attributeFilter</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">style</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">class</span><span class="dl">"</span><span class="p">],</span> <span class="c1">// 스타일, 클래스 속성만 감시</span>
    <span class="p">});</span>

    <span class="c1">// 최대 5초 후에는 무조건 성공으로 간주</span>
    <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">isStyleSuccess</span> <span class="o">&amp;&amp;</span> <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">setIsStyleSuccess</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
        <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">disconnect</span><span class="p">();</span>
        <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
      <span class="p">}</span>
    <span class="p">},</span> <span class="mi">5000</span><span class="p">);</span>
  <span class="p">};</span>


<span class="c1">// Shadow root의 스타일 변경 감지 후 Render 성공 여부 처리</span>
  <span class="nf">useLayoutEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">shadowRoot</span> <span class="o">=</span> <span class="nx">shadowRootRef</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">shadowRoot</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">setupStyleObserver</span><span class="p">(</span><span class="nx">shadowRoot</span><span class="p">);</span>

      <span class="k">return </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="c1">// Observer 정리</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nf">disconnect</span><span class="p">();</span>
          <span class="nx">observerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="c1">// 타이머 정리</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="p">{</span>
          <span class="nf">clearTimeout</span><span class="p">(</span><span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span><span class="p">);</span>
          <span class="nx">styleStabilityTimerRef</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</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> <span class="p">[</span><span class="nx">shadowRootRef</span><span class="p">.</span><span class="nx">current</span><span class="p">]);</span>

<span class="c1">// 1. Boolean 성공 여부 상태 값에 따라 Shadow DOM display block/none 처리 (초기에는 none)</span>
  <span class="nf">useLayoutEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">shadowDom</span> <span class="o">=</span> <span class="nx">shadowDomRef</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">shadowDom</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">shadowDom</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="nx">isStyleSuccess</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">block</span><span class="dl">"</span> <span class="p">:</span> <span class="dl">"</span><span class="s2">none</span><span class="dl">"</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">},</span> <span class="p">[</span><span class="nx">shadowDomRef</span><span class="p">.</span><span class="nx">current</span><span class="p">,</span> <span class="nx">isStyleSuccess</span><span class="p">]);</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="2-8-스타일-적용-후-내부-스크립트-shadow-dom-내-삽입">2-8. 스타일 적용 후 내부 스크립트 Shadow DOM 내 삽입</h3>
<p>이제 드디어 스타일 적용에 성공했습니다! 마지막으로 Shadow DOM 내부 스크립트만 적용시켜주면 되는데요.</p>

<p>혹시 아까 <span class="highlighting-underline--blue">Shadow root가 Shadow DOM 내에서 document 같은 역할을 하게 된다</span>는 말 기억하시나요?<br />
때문에 내부 스크립트에 <code class="language-plaintext highlighter-rouge">document</code>로 동작하는 로직이 있다면 <code class="language-plaintext highlighter-rouge">document -&gt; Shadow root</code>로 변환해주어야합니다.</p>

<p>이 때 다음과 같은 3가지 이슈를 주의해야합니다.</p>

<p><strong>1. Shadow root에는 body 대신 host를 사용</strong></p>

<p><strong>2. document의 생성 메서드는 Shadow root로 변환 불가</strong><br />
 <code class="language-plaintext highlighter-rouge">document.createElement()</code> 같은 생성 메서드는 Shadow root에서 사용할 수 없습니다. 따라서 탐색 메서드만 골라 변경하면 되는데, 이력서 페이지에서는 아래 탐색 메서드만 사용됩니다.</p>

<ul>
  <li>querySelector()</li>
  <li>querySelectorAll()</li>
  <li>getElementById()</li>
  <li>body</li>
</ul>

<p><strong>3. attachShadow closed 모드에서는 Shadow root 접근 불가</strong><br />
<code class="language-plaintext highlighter-rouge">attachShadow</code>에서 <code class="language-plaintext highlighter-rouge">closed mode</code>로 Shadow DOM을 구성했기 때문에 script tag를 만들어 넣어버리면 Shadow root에 접근할 수 없습니다.<br />
따라서  <code class="language-plaintext highlighter-rouge">스크립트 실행 함수(new Function)</code>를 만들어 Shadow DOM을 만들 때 받은 객체를 넣어 실행하였으니 참고해서 봐주세요.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
</pre></td><td class="rouge-code"><pre><span class="c1">// 인라인 스크립트 처리 함수</span>
  <span class="kd">const</span> <span class="nx">processInlineScripts</span> <span class="o">=</span> <span class="p">(</span>
    <span class="nx">shadowRoot</span><span class="p">:</span> <span class="nx">ShadowRoot</span><span class="p">,</span>
    <span class="nx">inlineScriptContents</span><span class="p">:</span> <span class="kr">string</span><span class="p">[],</span>
  <span class="p">):</span> <span class="k">void</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="c1">// 모든 인라인 스크립트 콘텐츠 변환 및 결합</span>
    <span class="kd">const</span> <span class="nx">scriptContents</span> <span class="o">=</span> <span class="nx">inlineScriptContents</span>
      <span class="p">.</span><span class="nf">filter</span><span class="p">((</span><span class="nx">content</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!!</span><span class="nx">content</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">content</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="c1">// 선택자 메서드 변환</span>
        <span class="kd">const</span> <span class="nx">convertedContent</span> <span class="o">=</span> <span class="nx">content</span>
          <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/document</span><span class="se">\.</span><span class="sr">querySelector/g</span><span class="p">,</span> <span class="dl">"</span><span class="s2">shadowRoot.querySelector</span><span class="dl">"</span><span class="p">)</span>
          <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/document</span><span class="se">\.</span><span class="sr">querySelectorAll/g</span><span class="p">,</span> <span class="dl">"</span><span class="s2">shadowRoot.querySelectorAll</span><span class="dl">"</span><span class="p">)</span>
          <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/document</span><span class="se">\.</span><span class="sr">getElementById/g</span><span class="p">,</span> <span class="dl">"</span><span class="s2">shadowRoot.getElementById</span><span class="dl">"</span><span class="p">)</span>
          <span class="c1">// body 참조 변환</span>
          <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/document</span><span class="se">\.</span><span class="sr">body/g</span><span class="p">,</span> <span class="dl">"</span><span class="s2">shadowRoot.host”);

        return convertedContent;
      });

    if (scriptContents.length &gt; 0) {
      // 스크립트 실행 함수 생성
      const executeScripts = new Function(</span><span class="dl">"</span><span class="nx">shadowRoot</span><span class="dl">"</span><span class="s2">, scriptContents.join(</span><span class="dl">"</span><span class="err">\</span><span class="nx">n</span><span class="dl">"</span><span class="s2">));

      // 접근자를 통해 안전하게 스크립트 실행
      try {
        executeScripts(shadowRoot);
      } catch (error) {
        console.error(</span><span class="dl">"</span><span class="nb">Error</span> <span class="nx">executing</span> <span class="na">scripts</span><span class="p">:</span><span class="dl">"</span><span class="s2">, error);
      }
    }
  };


 // 스타일 적용에 성공하면 inlineScript 붙여넣음
  useLayoutEffect(() =&gt; {
    const shadowRoot = shadowRootRef.current;
    if (isStyleSuccess &amp;&amp; shadowRoot) {
      processInlineScripts(shadowRoot, inlineScripts);
    }
  }, [isStyleSuccess]);

</span></pre></td></tr></tbody></table></code></pre></div></div>

<p>혹시 아래 예시코드와 같이 외부스크립트 내부 로직에 document가 포함되어 작동하지 않는 로직이 있다면 <strong>외부 스크립트 추출 시 걸러내어 내부 스크립트에 같이 넣어주는 것</strong>도 좋습니다!</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
</pre></td><td class="rouge-code"><pre><span class="kd">const</span> <span class="nx">loadExternalScript</span> <span class="o">=</span> <span class="p">(</span><span class="nx">shadowRoot</span><span class="p">:</span> <span class="nx">ShadowRoot</span><span class="p">,</span> <span class="nx">src</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">((</span><span class="nx">resolve</span><span class="p">,</span> <span class="nx">reject</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">script</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">script</span><span class="dl">"</span><span class="p">);</span>

      <span class="kd">let</span> <span class="nx">convertedSrc</span> <span class="o">=</span> <span class="nx">src</span><span class="p">;</span>

      <span class="err">…</span>

      <span class="cm">/**
       * document 로직이 포함된 외부 스크립트를 조회하여 내부 스크립트에 삽입
       * (해당 모듈에서 사용하는 document를 shadow root로 바꾸기 위함)
       */</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">src</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="dl">"</span><span class="s2">/external/script</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
        <span class="nf">fetch</span><span class="p">(</span><span class="nx">src</span><span class="p">,</span> <span class="p">{</span> <span class="na">credentials</span><span class="p">:</span> <span class="dl">"</span><span class="s2">include</span><span class="dl">"</span> <span class="p">})</span>
          <span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="k">async </span><span class="p">(</span><span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
            <span class="kd">const</span> <span class="nx">scriptText</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nf">text</span><span class="p">();</span>
            <span class="nf">setInlineScripts</span><span class="p">((</span><span class="nx">prev</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">[...</span><span class="nx">prev</span><span class="p">,</span> <span class="nx">scriptText</span><span class="p">]);</span>

            <span class="nf">resolve</span><span class="p">();</span>
          <span class="p">})</span>
          <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="nx">error</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">error</span><span class="p">(</span><span class="s2">`Script error: </span><span class="p">${</span><span class="nx">src</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
            <span class="nf">reject</span><span class="p">(</span><span class="nx">error</span><span class="p">);</span>
          <span class="p">});</span>

        <span class="k">return</span><span class="p">;</span>
      <span class="p">}</span>

      <span class="err">…</span>

    <span class="p">});</span>
  <span class="p">};</span>

</pre></td></tr></tbody></table></code></pre></div></div>

<p><br />
<br /></p>
<h2 id="3-shadow-dom-분리한-건-좋은데-만능은-아니다">3. Shadow DOM, 분리한 건 좋은데 만능은 아니다</h2>
<ol>
  <li>String -&gt; HTML 데이터로 변경 ✅</li>
  <li>meta, link, style, body content 태그 추출 후 HTML 구조 생성 ✅</li>
  <li>내/외부 스크립트 추출 ✅</li>
  <li>Shadow DOM 생성 ✅</li>
  <li>추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입 ✅</li>
  <li>HTML 데이터 Shadow DOM 내 삽입 ✅</li>
  <li>Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC) ✅</li>
  <li>Render 성공 후 내부 스크립트 Shadow DOM 내 삽입 ✅</li>
</ol>

<p>Shadow DOM 내 이력서 렌더링 플로우을 함께 알아봤는데요. Shadow DOM의 캡슐화로 인해 스타일 충돌 가능성을 제거할 수는 있었지만, 항상 완벽하게 각 플랫폼 별 이력서를 보여줄 수 있는 건 아닙니다.</p>
<h3 id="3-1-각-플랫폼-이력서-변경-시-side-effect-발생-가능">3-1. 각 플랫폼 이력서 변경 시 Side Effect 발생 가능</h3>
<p>사람인 서비스에서 다른 플랫폼의 이력서를 볼 수 있게 되면서 사용자 관점에서는 편리해졌지만, 개발적인 관점으로 봤을 때에는 <span class="highlighting-underline--red">플랫폼 별 이력서 수정 시 사람인 서비스도 확인해야 하는 과정이 추가</span>됩니다. 여기에는 코드는 물론이고 라이브러리 변경도 포함됩니다.<br />
실제로 기존 이력서 라이브러리 중 내부에 document를 사용하는 로직이 있어 변경하는 작업이 이루어지기도 했어요.</p>

<p>그리고 Shadow DOM은 스타일이 격리되어 있기 때문에 전역 CSS로 내부 변경은 어렵기에 외부에서 변경하고 싶다면 아래의 <code class="language-plaintext highlighter-rouge">psedo-class</code>나 <code class="language-plaintext highlighter-rouge">전용 API</code>를 사용하는 것도 좋겠네요.</p>

<ul>
  <li>:host</li>
  <li>:host()</li>
  <li>::slotted()</li>
  <li>::part()</li>
</ul>

<p>하지만 서비스 배포 시에 <code class="language-plaintext highlighter-rouge">Side Effect</code> 체크는 필연적이라 어찌보면 당연하다고 생각할 수도 있겠습니다.</p>
<h3 id="3-2-디버깅의-어려움">3-2. 디버깅의 어려움</h3>
<p>Shadow DOM은 일반 DOM과 별도의 트리를 형성합니다. Chrome DevTools에서 Elements 탭을 보면 #shadow-root 노드가 표시되고, 그 안에 별도 트리가 펼쳐지는데요. 만약 <span class="highlighting-underline--red">attachShadow가 closed라면 Shadow root에 접근하지 못해 디버깅의 어려움</span>이 있을 수 있습니다.</p>

<blockquote class="prompt-tip">
  <p>Chrome의 경우 <code class="language-plaintext highlighter-rouge">Settings &gt; Preferences &gt; Elements</code>에서 <code class="language-plaintext highlighter-rouge">Show user agent shadow DOM</code>을 체크하면 브라우저 내장 Shadow DOM(<code class="language-plaintext highlighter-rouge">&lt;video&gt;, &lt;input type="date"&gt;</code> 등)도 볼 수 있습니다.</p>
</blockquote>
<h3 id="3-3-러닝-커브">3-3. 러닝 커브</h3>
<p>만약 Shadow DOM 구성 시 template/slot이나 part를 사용한다면, 코드의 복잡성이 증가하고 <span class="highlighting-underline--red">Shadow DOM 뿐만 아니라 template/slot이나 part에 대해 학습해야 하기 때문에 러닝 커브</span>가 높아질 수도 있습니다.</p>
<h3 id="3-4-seo-shadow-dom-인덱싱-지연">3-4. SEO Shadow DOM 인덱싱 지연</h3>
<p>이력서 서비스는 기업 서비스이기에 SEO에 노출되지 않지만, SEO 노출이 필요한 컨텐츠를 Shadow DOM으로 삽입하게 되면 다음 예시와 같이 <span class="highlighting-underline--red">초기에 컨텐츠가 없기 때문에 크롤러 인덱싱 처리가 지연</span>될 수 있습니다.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="nt">&lt;body&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"product"</span><span class="nt">&gt;&lt;/div&gt;</span>

  <span class="nt">&lt;script&gt;</span>
    <span class="kd">const</span> <span class="nx">product</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">product</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">shadow</span> <span class="o">=</span> <span class="nx">product</span><span class="p">.</span><span class="nf">attachShadow</span><span class="p">({</span> <span class="na">mode</span><span class="p">:</span> <span class="dl">'</span><span class="s1">open</span><span class="dl">'</span> <span class="p">});</span>
    
    <span class="nx">shadow</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="s2">`
      &lt;h1&gt;프리미엄 무선 이어폰&lt;/h1&gt;
      &lt;p&gt;₩199,000&lt;/p&gt;
      &lt;p&gt;노이즈 캔슬링 기능&lt;/p&gt;
    `</span><span class="p">;</span>
  <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/body&gt;</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>크롤러의 인덱싱 처리는 다음과 같아집니다.</p>
<ol>
  <li>초기 HTML(&lt;div id="product"&gt;&lt;/div&gt;) ➡️ 콘텐츠 없음</li>
  <li>며칠 후 JS 실행 후 콘텐츠 발견 ➡️ 인덱싱</li>
</ol>

<p>이 문제는 <span class="highlighting-underline--blue">SSR에서 받은 데이터를 template을 사용하여 서버에서 렌더링된 데이터를 HTML에 포함시켜 다음 예시와 같이 Javascript 실행 없이 콘텐츠를 전달</span>할 수 있습니다.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="nt">&lt;body&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"blog-post"</span><span class="nt">&gt;</span>
    <span class="c">&lt;!-- 선언적 Shadow DOM --&gt;</span>
    <span class="nt">&lt;template</span> <span class="na">shadowrootmode=</span><span class="s">"open"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;article&gt;</span>
        <span class="nt">&lt;h1&gt;</span>Web Components 가이드<span class="nt">&lt;/h1&gt;</span>
        <span class="nt">&lt;p&gt;</span>Web Components는 재사용 가능한 컴포넌트를 만드는 표준 기술입니다.<span class="nt">&lt;/p&gt;</span>
        <span class="nt">&lt;p&gt;</span>Shadow DOM을 활용하면 스타일 캡슐화가 가능합니다.<span class="nt">&lt;/p&gt;</span>
      <span class="nt">&lt;/article&gt;</span>
    <span class="nt">&lt;/template&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/body&gt;</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><br />
<br /></p>
<h2 id="4-마치며">4. 마치며</h2>
<p>Shadow DOM은 다양한 외부 리소스가 섞이는 환경에서 스타일/DOM 충돌을 근본적으로 줄여주는 실용적인 해법입니다. 이번 개편에서도 Shadow DOM 덕분에 여러 플랫폼의 HTML 이력서를 안정적으로 통합해 보여줄 수 있었습니다.</p>

<p>Shadow DOM을 딥하게 사용한 예시가 많이 없어 FOUCC, document 치환, 외부 스크립트 의존성 등 크고 작은 고민과 어려움들이 많았습니다. 그럼에도 서비스 품질과 개발 경험 모두 크게 향상된 프로젝트였습니다.</p>

<p>이 글이 Shadow DOM을 실전 환경에 적용해야 하는 분들에게는 작은 도움이라도 되었으면 합니다. 긴 글 읽어주셔서 감사합니다 🙏</p>]]></content><author><name>윤창현</name></author><category term="프론트엔드" /><category term="ShadowDOM" /><summary type="html"><![CDATA[하나의 서비스에서 다양한 플랫폼의 이력서를 깨지지 않고 보여주는 방법]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://saramin.github.io/img/shadow-dom/not-broken-style.png" /><media:content medium="image" url="https://saramin.github.io/img/shadow-dom/not-broken-style.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Next.js 프로젝트의 정적 파일 배포 환경 구성</title><link href="https://saramin.github.io/2025-11-06-nextjs-static-deploy/" rel="alternate" type="text/html" title="Next.js 프로젝트의 정적 파일 배포 환경 구성" /><published>2025-11-06T00:00:00+09:00</published><updated>2025-11-06T00:00:00+09:00</updated><id>https://saramin.github.io/nextjs-static-deploy</id><content type="html" xml:base="https://saramin.github.io/2025-11-06-nextjs-static-deploy/"><![CDATA[<p>안녕하세요! 오늘은 Next.js 프로젝트에서 정적 파일(이미지, CSS 등)을 효율적으로 관리하고 배포하는 방법에 대해 공유해보려고 합니다.
특히 개발 환경과 운영 환경을 분리하여 관리하는 방법과 CDN 캐시 관리에 대해 살펴보겠습니다.</p>

<h2 id="들어가기-전에">들어가기 전에</h2>
<p>현재 사람인은 대부분의 서비스를 온프레미스 환경에 배포하고 있습니다.
그렇다보니 소개해드리는 내용은 AWS나 VERCEL에 배포하는 것과는 차이가 있습니다.</p>

<p>이점 참고 부탁드립니다.</p>

<h2 id="현재-상황과-문제점">현재 상황과 문제점</h2>

<p>현재 우리 서비스에서는 개발과 운영 환경을 구분하지 않고 <code class="language-plaintext highlighter-rouge">www.saraminimage.co.kr</code> 서버를 공동으로 사용하고 있습니다. 이로 인해 몇 가지 문제점이 발생했습니다:</p>

<h3 id="1-개발-이미지-관리의-어려움">1. 개발 이미지 관리의 어려움</h3>

<ul>
  <li><strong>FTP를 통해 이미지를 업로드할 때, 수정이 필요한 경우:</strong>
    <ul>
      <li>변경된 이미지를 다시 업로드하면 Akamai에서 퍼지(Purge)가 필요</li>
      <li>또는 파일명을 변경하여 업로드하면 기존 이미지가 가비지가 됨</li>
      <li>개발 중인 리소스가 운영 서버에 남아있어 혼란 발생</li>
    </ul>
  </li>
</ul>

<h3 id="2-리소스-관리의-비효율성">2. 리소스 관리의 비효율성</h3>

<ul>
  <li>운영 서버에 불필요한 개발 리소스가 적재됨</li>
  <li>형상 관리가 어려워 이슈 트래킹이 불가능</li>
  <li>브랜치별로 독립적인 정적 파일 관리가 불가능</li>
  <li>개발 환경에서 테스트한 이미지가 운영에 영향을 미칠 수 있음</li>
</ul>

<h3 id="3-cdn-캐시-관리의-복잡성">3. CDN 캐시 관리의 복잡성</h3>

<ul>
  <li>모든 변경사항에 대해 전체 캐시를 무효화해야 하는 경우 발생</li>
  <li>변경되지 않은 파일까지 Purge 대상이 되어 불필요한 네트워크 비용 발생</li>
  <li>개발 환경과 운영 환경의 캐시가 섞여 예상치 못한 동작 발생</li>
</ul>

<h2 id="서버-구성">서버 구성</h2>

<p>이러한 문제를 해결하기 위해 개발 환경과 운영 환경을 분리하고, CI/CD 파이프라인을 통해 정적 파일을 배포하는 새로운 구조를 설계했습니다:</p>

<h3 id="목표">목표</h3>

<ol>
  <li><strong>환경 분리</strong>: 개발과 운영 리소스가 완전히 분리되어 서로 영향을 주지 않음</li>
  <li><strong>브랜치별 독립 관리</strong>: 각 브랜치마다 고유한 경로를 사용하여 병렬 개발 가능</li>
  <li><strong>자동화된 배포</strong>: CI/CD 파이프라인을 통해 자동 배포 및 관리</li>
  <li><strong>효율적인 캐시 관리</strong>: 변경된 파일만 선별적으로 Purge 가능</li>
</ol>

<h2 id="사용-방법">사용 방법</h2>

<h3 id="로컬-개발-환경">로컬 개발 환경</h3>

<p>로컬 개발 시에는 기존과 동일하게 <code class="language-plaintext highlighter-rouge">/public/static</code> 디렉토리를 사용합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>/public/static/images  <span class="c"># 이미지 파일</span>
/public/static/css      <span class="c"># CSS 파일</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>로컬 개발 환경에서는 <code class="language-plaintext highlighter-rouge">NEXT_PUBLIC_IMAGE_URL</code>이 <code class="language-plaintext highlighter-rouge">/static</code>으로 설정되어 있어, Next.js의 기본 정적 파일 서빙 기능을 그대로 사용할 수 있습니다.</p>

<h3 id="환경변수-설정">환경변수 설정</h3>

<p>각 환경별로 다른 이미지 URL을 설정합니다:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="c"># .env.development.local (로컬 개발)</span>
<span class="nv">NEXT_PUBLIC_IMAGE_URL</span><span class="o">=</span>/static

<span class="c"># .env.development (개발)</span>
<span class="c"># __IMAGE_URL__ 는 CICD에서 https://dev.saraminimage.com/static/${SERVICE_NAME}/${CI_COMMIT_REF_SLUG} 로 치환</span>
<span class="nv">NEXT_PUBLIC_IMAGE_URL</span><span class="o">=</span>__IMAGE_URL__

<span class="c"># .env.production (운영)</span>
<span class="nv">NEXT_PUBLIC_IMAGE_URL</span><span class="o">=</span>https://static.saraminimage.co.kr/static/app

<span class="c"># 기존 saraminimage 서버 이미지 (레거시)</span>
<span class="nv">NEXT_PUBLIC_SARAMIN_IMAGE_URL</span><span class="o">=</span>https://www.saraminimage.co.kr
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="환경변수-치환-로직">환경변수 치환 로직</h4>

<p>CI/CD 파이프라인에서는 환경에 따라 자동으로 환경변수를 치환합니다:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="c1"># .gitlab/ci/extends/build.gitlab-ci.yml</span>
<span class="s">if grep -q "__IMAGE_URL__" ${CI_PROJECT_DIR}/${APP_DIR}/${SERVICE_NAME}/${ENVFILE_NAME}; then</span>
  <span class="s">sed -i "s|__IMAGE_URL__|${IMAGE_URL}|g" ${CI_PROJECT_DIR}/${APP_DIR}/${SERVICE_NAME}/${ENVFILE_NAME}</span>
  <span class="s">echo "✅ __IMAGE_URL__ 패턴을 찾아 NEXT_PUBLIC_IMAGE_URL 설정 완료"</span>
<span class="s">fi</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>이를 통해 개발 환경에서는 브랜치별로 다른 URL이 자동으로 설정되며, 운영 환경에서는 고정된 URL을 사용합니다.</p>

<h3 id="nextjs-설정">Next.js 설정</h3>

<p>정적 파일을 효율적으로 관리하기 위해 Next.js 설정을 다음과 같이 구성했습니다:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
</pre></td><td class="rouge-code"><pre><span class="c1">// next.config.mjs</span>
<span class="kd">const</span> <span class="nx">nextConfig</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">images</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">loader</span><span class="p">:</span> <span class="dl">"</span><span class="s2">custom</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">loaderFile</span><span class="p">:</span> <span class="dl">"</span><span class="s2">../../common/shared/helper/customImageLoader.ts</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">remotePatterns</span><span class="p">:</span> <span class="p">[</span>
      <span class="p">{</span>
        <span class="na">protocol</span><span class="p">:</span> <span class="dl">"</span><span class="s2">https</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">hostname</span><span class="p">:</span> <span class="dl">"</span><span class="s2">**.saraminimage.co.kr</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">port</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
        <span class="na">pathname</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="na">protocol</span><span class="p">:</span> <span class="dl">"</span><span class="s2">https</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">hostname</span><span class="p">:</span> <span class="dl">"</span><span class="s2">**.saraminbanner.co.kr</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">port</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
        <span class="na">pathname</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="na">protocol</span><span class="p">:</span> <span class="dl">"</span><span class="s2">https</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">hostname</span><span class="p">:</span> <span class="dl">"</span><span class="s2">**.saramin.co.kr</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">port</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
        <span class="na">pathname</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="na">imageSizes</span><span class="p">:</span> <span class="p">[</span><span class="mi">96</span><span class="p">],</span>
    <span class="na">deviceSizes</span><span class="p">:</span> <span class="p">[</span><span class="mi">1920</span><span class="p">],</span>
  <span class="p">},</span>
<span class="p">};</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="이미지-설정-상세-설명">이미지 설정 상세 설명</h4>

<ul>
  <li><strong>loader: “custom”</strong>: Next.js의 기본 이미지 로더 대신 커스텀 로더 사용</li>
  <li><strong>loaderFile</strong>: 커스텀 이미지 로더 파일 경로 지정</li>
  <li><strong>remotePatterns</strong>: 외부 이미지 도메인 허용 목록 (보안을 위해 명시적으로 지정)</li>
  <li><strong>imageSizes</strong>: 작은 이미지 크기 목록 (96px)</li>
  <li><strong>deviceSizes</strong>: 디바이스별 이미지 크기 목록 (1920px)</li>
</ul>

<h3 id="이미지-로더-생성">이미지 로더 생성</h3>

<p>커스텀 이미지 로더는 두 가지 이미지 소스를 지원합니다:</p>

<ol>
  <li><strong>기존 saraminimage 서버에 배포된 이미지</strong> (<code class="language-plaintext highlighter-rouge">/sri/</code> 경로)</li>
  <li><strong>새로운 static 서버로 배포될 이미지</strong> (<code class="language-plaintext highlighter-rouge">/images/</code> 경로)</li>
</ol>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
</pre></td><td class="rouge-code"><pre><span class="c1">// common/shared/helper/customImageLoader.ts</span>
<span class="kr">interface</span> <span class="nx">ParamTypes</span> <span class="p">{</span>
  <span class="nl">src</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">width</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
  <span class="nl">quality</span><span class="p">?:</span> <span class="kr">number</span> <span class="o">|</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>

<span class="cm">/**
 * 이미지 로더
 * - 기존 saraminimage 서버에 배포된 이미지
 * - static saraminimage 서버로 배포될 이미지
 * @param src 이미지 경로
 * @param width 이미지 너비
 * @param quality 이미지 품질
 * @returns 이미지 경로
 */</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">function</span> <span class="nf">customImageLoader</span><span class="p">({</span> 
  <span class="nx">src</span><span class="p">,</span> 
  <span class="nx">width</span><span class="p">,</span> 
  <span class="nx">quality</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">auto</span><span class="dl">"</span> 
<span class="p">}:</span> <span class="nx">ParamTypes</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// 기존 saraminimage 서버에 배포된 이미지</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">src</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">/sri/</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NEXT_PUBLIC_SARAMIN_IMAGE_URL</span><span class="p">}${</span><span class="nx">src</span><span class="p">}</span><span class="s2">?w=</span><span class="p">${</span><span class="nx">width</span><span class="p">}</span><span class="s2">&amp;q=</span><span class="p">${</span><span class="nx">quality</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
  <span class="p">}</span>
  
  <span class="c1">// static saraminimage 서버로 배포될 이미지</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">src</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">/images/</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NEXT_PUBLIC_IMAGE_URL</span><span class="p">}${</span><span class="nx">src</span><span class="p">}</span><span class="s2">?w=</span><span class="p">${</span><span class="nx">width</span><span class="p">}</span><span class="s2">&amp;q=</span><span class="p">${</span><span class="nx">quality</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="이미지-로더-동작-원리">이미지 로더 동작 원리</h4>

<ol>
  <li><strong>경로 기반 분기</strong>: 이미지 경로의 prefix로 어느 서버를 사용할지 결정</li>
  <li><strong>동적 URL 생성</strong>: 환경변수를 사용하여 환경별로 다른 URL 생성</li>
  <li><strong>리사이징 파라미터</strong>: <code class="language-plaintext highlighter-rouge">width</code>와 <code class="language-plaintext highlighter-rouge">quality</code> 파라미터를 쿼리스트링으로 추가</li>
  <li><strong>레거시 호환성</strong>: 기존 <code class="language-plaintext highlighter-rouge">/sri/</code> 경로를 사용하는 이미지도 계속 지원</li>
</ol>

<h3 id="이미지-사용-예시">이미지 사용 예시</h3>

<p>컴포넌트에서 이미지를 사용할 때는 다음과 같이 작성합니다:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
</pre></td><td class="rouge-code"><pre><span class="c1">// apps/business/src/app/(main)/(sub)/_components/ContentCard.tsx</span>
<span class="k">import</span> <span class="nx">Image</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">next/image</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">function</span> <span class="nf">ContentCard</span><span class="p">({</span> <span class="na">image</span><span class="p">:</span> <span class="p">{</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">width</span><span class="p">,</span> <span class="nx">height</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;</span><span class="nc">Image</span>
      <span class="na">className</span><span class="p">=</span><span class="s">"card__image"</span>
      <span class="na">alt</span><span class="p">=</span><span class="s">"title"</span>
      <span class="na">src</span><span class="p">=</span><span class="si">{</span><span class="s2">`/images/</span><span class="p">${</span><span class="nx">name</span><span class="p">}</span><span class="s2">`</span><span class="si">}</span>
      <span class="na">width</span><span class="p">=</span><span class="si">{</span><span class="nx">width</span><span class="si">}</span>
      <span class="na">height</span><span class="p">=</span><span class="si">{</span><span class="nx">height</span><span class="si">}</span>
      <span class="na">quality</span><span class="p">=</span><span class="si">{</span><span class="mi">100</span><span class="si">}</span>
    <span class="p">/&gt;</span>
  <span class="p">);</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="이미지-사용-시-주의사항">이미지 사용 시 주의사항</h4>

<ol>
  <li><strong>경로 규칙 준수</strong>: 새로 배포하는 이미지는 <code class="language-plaintext highlighter-rouge">/images/</code> 경로를 사용</li>
  <li><strong>width/height 지정</strong>: Next.js Image 최적화를 위해 명시적으로 크기 지정</li>
  <li><strong>quality 설정</strong>: 용도에 따라 적절한 quality 값 설정 (기본값: “auto”)</li>
  <li><strong>alt 텍스트</strong>: 접근성을 위해 항상 alt 속성 제공</li>
</ol>

<h2 id="cicd-파이프라인-구성">CI/CD 파이프라인 구성</h2>

<h3 id="1-환경변수-설정">1. 환경변수 설정</h3>

<p>CI/CD 파이프라인에서 환경별 이미지 URL을 설정합니다:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="c1"># .gitlab-ci.yml</span>
<span class="na">variables</span><span class="pi">:</span>
  <span class="c1"># 개발 환경: 브랜치별 경로</span>
  <span class="na">IMAGE_URL</span><span class="pi">:</span> <span class="s">https://dev.saraminimage.com/static/${SERVICE_NAME}/${CI_COMMIT_REF_SLUG}</span>
  
  <span class="c1"># 운영 환경: 고정 경로</span>
  <span class="na">IMAGE_URL</span><span class="pi">:</span> <span class="s">https://static.saraminimage.co.kr/static/${SERVICE_NAME}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="환경별-변수-설정-로직">환경별 변수 설정 로직</h4>

<p>개발 환경에서는 브랜치명을 포함한 경로를 사용하고, 운영 환경에서는 고정된 경로를 사용합니다:</p>

<ul>
  <li><strong>개발 환경</strong>: <code class="language-plaintext highlighter-rouge">/static/{서비스명}/{브랜치명}</code> - 각 브랜치별로 독립적인 공간</li>
  <li><strong>운영 환경</strong>: <code class="language-plaintext highlighter-rouge">/static/{서비스명}</code> - 서비스별로 통합된 공간</li>
</ul>

<h3 id="2-정적-파일-배포">2. 정적 파일 배포</h3>

<p>rsync를 사용하여 정적 파일을 배포합니다. rsync는 변경된 파일만 동기화하여 효율적인 배포를 가능하게 합니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</pre></td><td class="rouge-code"><pre><span class="c1"># .gitlab/ci/extends/deploy.gitlab-ci.yml</span>
<span class="na">.rsync-static</span><span class="pi">:</span> <span class="nl">&amp;rsync-static</span>
  <span class="c1"># 디렉토리 권한을 755로, 파일 권한을 644로 설정</span>
  <span class="pi">-</span> <span class="s">find apps/${SERVICE_NAME}/public/static/ -type d -exec chmod 755 {} \;</span>
  <span class="pi">-</span> <span class="s">find apps/${SERVICE_NAME}/public/static/ -type f -exec chmod 644 {} \;</span>
  
  <span class="c1"># 배포 전 디렉터리 생성</span>
  <span class="pi">-</span> <span class="s">ssh -i ~/.ssh/id_rsa ${SARAMIN_IMAGE_USER}@${IMAGE_HOST} "mkdir -p ${IMAGE_PATH}"</span>
  
  <span class="c1"># 정적파일을 rsync로 배포</span>
  <span class="pi">-</span> <span class="pi">&gt;</span>
    <span class="s">rsync -rptgoDv</span>
    <span class="s">--delete-delay</span>
    <span class="s">--size-only</span>
    <span class="s">--stats</span>
    <span class="s">apps/${SERVICE_NAME}/public/static/</span>
    <span class="s">${IMAGE_HOST}::img_data/${IMAGE_PATH}</span>
  
  <span class="c1"># 배포 후 확인</span>
  <span class="pi">-</span> <span class="s">ssh -i ~/.ssh/id_rsa ${SARAMIN_IMAGE_USER}@${IMAGE_HOST} "ls -lR ${IMAGE_PATH}"</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="rsync-옵션-설명">rsync 옵션 설명</h4>

<ul>
  <li><strong>-r</strong>: 재귀적으로 디렉토리 복사</li>
  <li><strong>-p</strong>: 파일 권한 유지</li>
  <li><strong>-t</strong>: 수정 시간 유지</li>
  <li><strong>-g</strong>: 그룹 정보 유지</li>
  <li><strong>-o</strong>: 소유자 정보 유지</li>
  <li><strong>-D</strong>: 디바이스 파일과 특수 파일 유지</li>
  <li><strong>-v</strong>: 상세 출력</li>
  <li><strong>–delete-delay</strong>: 삭제할 파일을 나중에 삭제 (동기화 중 안전성 확보)</li>
  <li><strong>–size-only</strong>: 파일 크기만 비교하여 변경 여부 판단 (빠른 동기화)</li>
  <li><strong>–stats</strong>: 통계 정보 출력</li>
</ul>

<h4 id="ssh-키-설정">SSH 키 설정</h4>

<p>정적 파일 서버에 접근하기 위해 SSH 키를 설정합니다:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="na">.sshkey-setting</span><span class="pi">:</span> <span class="nl">&amp;sshkey-setting</span>
  <span class="pi">-</span> <span class="s">mkdir -p ~/.ssh</span>
  <span class="pi">-</span> <span class="s">echo "${BUILD_SERVER_SSH_PRIVATE_KEY}" &gt; ~/.ssh/id_rsa</span>
  <span class="pi">-</span> <span class="s">chmod 600 ~/.ssh/id_rsa</span>
  <span class="pi">-</span> <span class="s">ssh-keyscan -H ${IMAGE_HOST} &gt;&gt; ~/.ssh/known_hosts</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="3-cdn-캐시-관리">3. CDN 캐시 관리</h3>

<p>운영 환경에서는 변경된 파일에 대해 Akamai Purge를 실행합니다. 전체 캐시를 무효화하는 대신, 변경된 파일만 선별적으로 Purge하여 효율성을 높입니다.</p>

<h4 id="변경-파일-추출">변경 파일 추출</h4>

<p>Git을 사용하여 변경된 파일만 추출합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
</pre></td><td class="rouge-code"><pre><span class="c">#!/bin/bash</span>
<span class="c"># .scripts/extract_changed_files.sh</span>

<span class="c"># 필요한 환경변수 체크</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$SERVICE_NAME</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"❌ Error: SERVICE_NAME is not set ❌"</span>
    <span class="nb">exit </span>1
<span class="k">fi</span>

<span class="c"># MR의 경우 target branch와의 차이, Push일 경우 커밋간 차이를 확인</span>
<span class="c"># 수정된(Modify) 파일만 리스트업</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$CI_MERGE_REQUEST_TARGET_BRANCH_NAME</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span>git diff <span class="nt">--name-only</span> <span class="nt">--diff-filter</span><span class="o">=</span>M <span class="s2">"origin/</span><span class="nv">$CI_MERGE_REQUEST_TARGET_BRANCH_NAME</span><span class="s2">...</span><span class="nv">$CI_COMMIT_SHA</span><span class="s2">"</span> <span class="s2">"apps/</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">/public/static/"</span> <span class="se">\</span>
        | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s1">'^$'</span> <span class="se">\</span>
        | <span class="nb">sed</span> <span class="s2">"s#^apps/</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">/public/static/##"</span> <span class="se">\</span>
        <span class="o">&gt;</span> rsync_changes.log
<span class="k">else
    </span>git diff <span class="nt">--name-only</span> <span class="nt">--diff-filter</span><span class="o">=</span>M <span class="s2">"</span><span class="nv">$CI_COMMIT_BEFORE_SHA</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$CI_COMMIT_SHA</span><span class="s2">"</span> <span class="s2">"apps/</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">/public/static/"</span> <span class="se">\</span>
        | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s1">'^$'</span> <span class="se">\</span>
        | <span class="nb">sed</span> <span class="s2">"s#^apps/</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">/public/static/##"</span> <span class="se">\</span>
        <span class="o">&gt;</span> rsync_changes.log
<span class="k">fi</span>

<span class="c"># 변경된 파일 유무를 체크하여 퍼지 진행</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-s</span> rsync_changes.log <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"✅ 변경된 파일에 대한 Akamai 캐시 삭제를 위해 목록을 생성합니다. ✅"</span>

    <span class="c"># 변경된 파일들의 URL 목록 생성</span>
    <span class="nv">URLS_TO_PURGE</span><span class="o">=</span><span class="si">$(</span><span class="k">while </span><span class="nb">read</span> <span class="nt">-r</span> file<span class="p">;</span> <span class="k">do
        </span><span class="nb">echo</span> <span class="s2">"</span><span class="se">\"</span><span class="s2">https://static.saraminimage.co.kr/</span><span class="k">${</span><span class="nv">IMAGE_PATH</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">file</span><span class="k">}</span><span class="se">\"</span><span class="s2">"</span>
    <span class="k">done</span> &lt; rsync_changes.log | <span class="nb">tr</span> <span class="s1">'\n'</span> <span class="s1">','</span> | <span class="nb">sed</span> <span class="s1">'s/,$//'</span><span class="si">)</span>

    <span class="c"># API body 형식으로 저장</span>
    <span class="nv">API_BODY</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> | tr -d '</span><span class="se">\n</span><span class="sh">'
{
    "objects": [</span><span class="k">${</span><span class="nv">URLS_TO_PURGE</span><span class="k">}</span><span class="sh">]
}
</span><span class="no">EOF
</span><span class="si">)</span>
    <span class="nb">export </span>API_BODY
    <span class="nb">echo</span> <span class="s2">"✅ 캐시 대상 목록 ✅"</span>
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$API_BODY</span><span class="s2">"</span>

    <span class="c"># 캐시 삭제 스크립트 실행</span>
    node ./.scripts/purge-cache.js
<span class="k">else
    </span><span class="nb">echo</span> <span class="s2">"✅ 변경된 파일이 없어 목록을 생성하지 않습니다. ✅"</span>
    <span class="nb">exit </span>0
<span class="k">fi</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="purge-스크립트">Purge 스크립트</h4>

<p>Akamai EdgeGrid API를 사용하여 캐시를 무효화합니다:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
</pre></td><td class="rouge-code"><pre><span class="c1">// .scripts/purge-cache.js</span>
<span class="kd">var</span> <span class="nx">EdgeGrid</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">"</span><span class="s2">akamai-edgegrid</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// Supply the path to your .edgerc file and name</span>
<span class="c1">// of the section with authorization to the client</span>
<span class="c1">// you are calling (default section is 'default')</span>
<span class="kd">var</span> <span class="nx">eg</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">EdgeGrid</span><span class="p">({</span>
  <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/root/.edgerc</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">section</span><span class="p">:</span> <span class="dl">"</span><span class="s2">default</span><span class="dl">"</span><span class="p">,</span>
<span class="p">});</span>

<span class="nx">eg</span><span class="p">.</span><span class="nf">auth</span><span class="p">({</span>
  <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/ccu/v3/invalidate/url/production</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">body</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">API_BODY</span><span class="p">,</span>
<span class="p">}).</span><span class="nf">send</span><span class="p">((</span><span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">error</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="s2">❌ Akamai에 Purge 요청이 실패했습니다 ❌</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="nx">error</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="s2">✅ Akamai에 Purge 요청이 성공했습니다 ✅</span><span class="dl">"</span><span class="p">);</span>
<span class="p">});</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="캐시-관리-전략">캐시 관리 전략</h4>

<ol>
  <li><strong>선택적 Purge</strong>: 변경된 파일만 Purge하여 불필요한 네트워크 비용 절감</li>
  <li><strong>자동화</strong>: CI/CD 파이프라인에서 자동으로 실행되어 수동 작업 불필요</li>
  <li><strong>Git 기반 추적</strong>: Git diff를 사용하여 변경사항을 정확히 추적</li>
  <li><strong>에러 처리</strong>: Purge 실패 시 로그를 남겨 문제 파악 가능</li>
</ol>

<h3 id="4-배포-프로세스-전체-흐름">4. 배포 프로세스 전체 흐름</h3>

<p><img src="https://saramin.github.io/img/monorepo/image_static_deploy_flow.png" alt="&lt;img src=&quot;https://saramin.github.io/img/monorepo/image_static_deploy_flow.png&quot; /&gt;" /></p>

<h2 id="실제-사용-사례">실제 사용 사례</h2>

<h3 id="케이스-1-브랜치별-독립-개발">케이스 1: 브랜치별 독립 개발</h3>

<p>새로운 feature 브랜치에서 이미지를 추가하고 테스트하는 경우:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">feature/new-design</code> 브랜치에서 작업</li>
  <li><code class="language-plaintext highlighter-rouge">/public/static/images/new-button.webp</code> 추가</li>
  <li>CI/CD가 자동으로 <code class="language-plaintext highlighter-rouge">dev.saraminimage.com/static/app/feature-new-design/images/new-button.webp</code>에 배포</li>
  <li>다른 브랜치에 영향을 주지 않고 독립적으로 테스트 가능</li>
</ol>

<h3 id="케이스-2-운영-환경-배포">케이스 2: 운영 환경 배포</h3>

<p>메인 브랜치에 머지하면 운영 환경에 배포:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">main</code> 브랜치에 머지</li>
  <li>CI/CD가 <code class="language-plaintext highlighter-rouge">static.saraminimage.co.kr/static/app/</code> 경로에 배포</li>
  <li>변경된 파일만 자동으로 Purge</li>
  <li>사용자는 즉시 최신 이미지 확인 가능</li>
</ol>

<h3 id="케이스-3-css-업데이트">케이스 3: CSS 업데이트</h3>

<p>CSS 파일이 변경된 경우:</p>

<ol>
  <li>CSS 파일 수정 및 커밋</li>
  <li>운영 환경 배포 시 커밋 해시가 버전으로 자동 설정</li>
  <li><code class="language-plaintext highlighter-rouge">template-style.css?v=abc123def</code> 형식으로 링크 생성</li>
  <li>브라우저가 자동으로 새 파일을 다운로드</li>
</ol>

<h2 id="트러블슈팅">트러블슈팅</h2>

<h3 id="문제-1-이미지가-로드되지-않음">문제 1: 이미지가 로드되지 않음</h3>

<p><strong>원인</strong>: 환경변수가 제대로 설정되지 않았거나, 이미지 경로가 잘못됨</p>

<p><strong>해결 방법</strong>:</p>
<ol>
  <li>브라우저 개발자 도구에서 네트워크 탭 확인</li>
  <li>환경변수 값 확인: <code class="language-plaintext highlighter-rouge">console.log(process.env.NEXT_PUBLIC_IMAGE_URL)</code></li>
  <li>이미지 경로가 <code class="language-plaintext highlighter-rouge">/images/</code> 또는 <code class="language-plaintext highlighter-rouge">/sri/</code>로 시작하는지 확인</li>
</ol>

<h3 id="문제-2-cdn-캐시가-갱신되지-않음">문제 2: CDN 캐시가 갱신되지 않음</h3>

<p><strong>원인</strong>: Purge가 실행되지 않았거나, 잘못된 파일 경로로 Purge 요청</p>

<p><strong>해결 방법</strong>:</p>
<ol>
  <li>CI/CD 로그에서 Purge 실행 여부 확인</li>
  <li><code class="language-plaintext highlighter-rouge">rsync_changes.log</code> 파일 내용 확인</li>
  <li>Akamai 대시보드에서 Purge 요청 상태 확인</li>
</ol>

<h3 id="문제-3-rsync-배포-실패">문제 3: rsync 배포 실패</h3>

<p><strong>원인</strong>: SSH 키 권한 문제 또는 네트워크 연결 문제</p>

<p><strong>해결 방법</strong>:</p>
<ol>
  <li>SSH 키 권한 확인: <code class="language-plaintext highlighter-rouge">chmod 600 ~/.ssh/id_rsa</code></li>
  <li>SSH 연결 테스트: <code class="language-plaintext highlighter-rouge">ssh -i ~/.ssh/id_rsa ${SARAMIN_IMAGE_USER}@${IMAGE_HOST}</code></li>
  <li>rsync dry-run으로 테스트: <code class="language-plaintext highlighter-rouge">rsync --dry-run ...</code></li>
</ol>

<h2 id="마무리">마무리</h2>

<p>이번 포스트에서는 Next.js 프로젝트의 정적 파일을 효율적으로 관리하고 배포하는 방법을 살펴보았습니다. 개발 환경과 운영 환경을 분리하고, CI/CD 파이프라인을 통해 자동화된 배포를 구현함으로써 다음과 같은 이점을 얻을 수 있습니다:</p>

<ol>
  <li><strong>개발 리소스와 운영 리소스의 명확한 분리</strong>: 환경별 독립적인 관리</li>
  <li><strong>자동화된 배포 프로세스</strong>: 수동 작업 최소화 및 오류 감소</li>
  <li><strong>효율적인 CDN 캐시 관리</strong>: 변경된 파일만 선별적으로 Purge</li>
  <li><strong>브랜치별 독립 개발</strong>: 병렬 개발 환경 지원</li>
  <li><strong>버전 관리 및 추적</strong>: Git을 통한 형상 관리</li>
</ol>

<p>이러한 구조를 통해 개발 생산성을 높이고, 운영 안정성을 확보할 수 있습니다. 감사합니다!</p>]]></content><author><name>조성창</name></author><category term="DevOps" /><category term="CI/CD" /><category term="CI/CD" /><category term="Gitlab-ci" /><category term="DevOps" /><category term="NextJs" /><summary type="html"><![CDATA[온프레미스 환경에서 정적 파일(이미지, CSS 등)을 효율적으로 관리하고 배포하는 방법]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://saramin.github.io/img/monorepo/image_nextjs.png" /><media:content medium="image" url="https://saramin.github.io/img/monorepo/image_nextjs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">배포시간 단축: 블루-그린 배포 도입기</title><link href="https://saramin.github.io/2025-11-05-blue-green/" rel="alternate" type="text/html" title="배포시간 단축: 블루-그린 배포 도입기" /><published>2025-11-05T00:00:00+09:00</published><updated>2025-11-05T17:37:54+09:00</updated><id>https://saramin.github.io/blue-green</id><content type="html" xml:base="https://saramin.github.io/2025-11-05-blue-green/"><![CDATA[<p>새로운 서비스를 개발하며 배포 과정을 자동화하는 CI/CD 구축은 필수적인 과제였습니다. 하지만 기존에 사용하던 배포 방식은 몇 가지 고질적인 문제점을 안고 있었고, 이로 인해 더 안정적이고 효율적인 배포 전략을 모색하게 되었습니다.</p>

<p>첫째, <strong>배포 중 간헐적으로 발생하는 에러</strong>가 문제였습니다. 명확한 원인 파악이 어려워 재현조차 힘든 에러는 배포의 안정성을 떨어뜨리는 주범이었습니다. <br />
둘째, <strong>배포 실패 시 전체 서버가 다운</strong>되는 치명적인 위험이 있었습니다. 이는 곧 서비스 중단으로 이어져 사용자에게 직접적인 피해를 줄 수 있는 매우 심각한 문제였습니다. <br />
마지막으로, <strong>로드 밸런서에서 서버를 제외하고 다시 연결하는 과정에서 발생하는 딜레이</strong>는 전체 배포 시간을 늘려 불필요한 비효율을 초래했습니다.</p>

<p>이러한 문제들을 해결하고 완벽한 무중단 배포를 실현하기 위해, 새로운 배포 전략인 <strong>블루-그린(Blue-Green) 배포</strong>를 도입하기로 결정했습니다. 이 글을 통해 기존 방식의 한계와 새로운 배포 전략의 도입 과정을 자세히 공유하고자 합니다.</p>

<h2 id="현재-사용-중인-배포-방식--롤링-업데이트-배포rolling-update-deployment">현재 사용 중인 배포 방식 : 롤링 업데이트 배포(Rolling Update Deployment)</h2>
<p>기존 서비스에서는 HAProxy를 통해 로드 밸런싱을 하고 있는데 서버 반을 로드밸런싱에서 제외하고 배포 후 다시연결 나머지를 제외하고 배포 후 연결하는 방식으로 배포하고 있는데 이러한 방식을 롤링 업데이트 배포라고 합니다.</p>

<h3 id="현재-사용-중인-배포-방식의-처리-흐름"><strong>현재 사용 중인 배포 방식의 처리 흐름</strong></h3>
<p><img src="/img/blue-green/deploy01.jpg" alt="/img/blue-green/deploy01.jpg" /></p>
<ol>
  <li>로드밸런서에서 서버의 반을 제외(4대인경우 2대를 제외)합니다.</li>
  <li>제외된 서버에 배포합니다.
<img src="/img/blue-green/deploy02.jpg" alt="/img/blue-green/deploy02.jpg" /></li>
  <li>배포완료 후 로드밸런서에 연결합니다.</li>
  <li>나머지 배포전인 서버를 로드밸런서에서 제외합니다.</li>
  <li>나머지 서버에 배포힙니다.
<img src="/img/blue-green/deploy03.jpg" alt="/img/blue-green/deploy03.jpg" /></li>
  <li>로드 밸런서에 연결합니다.</li>
</ol>

<h3 id="기존-배포방식의-문제점"><strong>기존 배포방식의 문제점</strong></h3>
<p>기존의 서비스들을 수차례 배포해보면서 다음과 같은 세가지 문제점을 인식하게 되었습니다.</p>

<ol>
  <li>드물게 발생하는 에러 : 배포할때만 간헐적으로 발생하는 에러가 있는데 로드 밸런서가 서버를 제외하거나 다시 연결하는 타이밍에 에러가 발생하는 듯 합니다.
    <ul>
      <li>아래와 같은 에러가 발생했습니다.
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>[2024-11-12 07:04:13.886] [ERROR] [/] - org.xnio.listener invokeChannelListener - XNIO001007: A channel event listener threw an exception
java.lang.NoClassDefFoundError: io/undertow/server/protocol/http/HttpReadListener$1
at io.undertow.server.protocol.http.HttpReadListener.sendBadRequestAndClose(HttpReadListener.java:286)
</pre></td></tr></tbody></table></code></pre></div>        </div>
        <blockquote>
          <p>에러의 원인을 AI에게 문의해보니 배포 관련으로 인한 문제 일수도 있다고 나왔습니다.</p>
          <blockquote>
            <p>제미나이 답변 내용</p>

            <p>부분적 업데이트: 롤링 업데이트 같은 배포 방식에서 기존 클래스 파일과 새로운 클래스 파일이 섞여서 로드될 때, 버전 충돌로 인해 발생할 수 있습니다.
예를 들어, 로드 밸런서에서 트래픽을 제외하기 전에 요청이 들어오거나, 오래된 애플리케이션 컨테이너가 새 클래스 파일을 잘못 캐싱했을 때 발생합니다.</p>
          </blockquote>
        </blockquote>
      </li>
    </ul>
  </li>
  <li>배포 에러 발생 시의 위험성
    <ul>
      <li>배포 하다가 에러 발생 시 로드밸런서에서 제외된 서버들은 다운된 상태이고 기동 서버가 반으로 줄어 부하가 증가합니다.</li>
      <li>배포 스크립트 문제인지 에러가 발생했음에도 계속 진행되어 서버 전체가 다운되는 문제가 발생했었습니다.
        <ul>
          <li>서버 전체가 다운되어 서비스 이용 불가가 되었습니다.</li>
        </ul>
      </li>
      <li>여기서 가장 문제는 복구하기 위해서는 소스를 기존으로 롤백하고 다시 배포를 해야 하기에 시간이 걸린다는 것이었습니다.
        <ul>
          <li>복구 시간 동안 서비스를 이용 못하게 되고 관련해서 에러가 계속 발생하여 아찔한 순간이었습니다.</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>긴 배포 시간
    <ul>
      <li>로드 밸런서에서 제외하고 다시 연결하는 과정에 시간이 소모되었습니다.</li>
      <li>서버 별로 나눠서 배포를 진행하게 되어 서버 수가 늘어날수록 배포 시간은 더 길어질 것으로 보여집니다.</li>
      <li>롤백 할때에도 다시 배포해야하기에 배포시간의 영향을 받게 됩니다.</li>
    </ul>
  </li>
</ol>

<p>위의 문제점들을 개선하고자 배포 방식 변경을 검토하게 되었습니다.</p>

<h2 id="새로운-해결책--블루-그린-배포"><strong>새로운 해결책 : 블루-그린 배포</strong></h2>
<p>기존과 다른 배포 방식으로 찾게 된 것이 블루-그린 배포였습니다.</p>

<p>블루-그린 배포는 무중단 배포기법의 하나로 블루는 구버전, 그린은 신버전으로 신버전인 그린에 배포 및 기동을 완료한 후에 트래픽을 전환하는 방식의 배포 전략입니다.</p>

<p>주요 특징에서 아래 3가지가 있었습니다.</p>
<ul>
  <li>무중단 배포 : 서비스가 중단되지 않고 새로운 버전으로 전환되어 서비스 제공에 영향을 주지 않습니다.</li>
  <li>트래픽 변경 없음 : 배포시에도 동일한 서버대 수 유지되어 서버별 부하에는 변동이 없습니다.</li>
  <li>빠른 롤백 : 문제 발생시 로드밸런서 설정을 되돌려 기존 버전으로 트래픽을 전환하는 것으로 신속한 복구가 가능합니다.</li>
</ul>

<p>위의 특징들이 기존 배포 방식의 문제점 개선에 적합하다고 생각되어 블루-그린 배포 방식을 적용하게 되었습니다.</p>

<h3 id="블루-그린-배포-도입에-중요한-포인트"><strong>블루-그린 배포 도입에 중요한 포인트</strong></h3>
<ul>
  <li>블루(Blue) 환경 : 현재 운영 중인 애플리케이션 버전이 실행 중인 환경
    <ul>
      <li>서버 내에서 고정으로 포트 2개를 지정하여 사용 중인 포트의 서비스를 블루로 지정했습니다.</li>
    </ul>
  </li>
  <li>그린(Green) 환경: 새롭게 배포될 애플리케이션 버전이 실행되는 별도의 환경
    <ul>
      <li>미사용중인 포트의 서비스를 그린으로 지정했습니다.</li>
    </ul>
  </li>
  <li>트래픽 전환 : 새로운 버전을 테스트하고 준비한 후, 트래픽을 기존 블루 환경에서 그린 환경으로 전환
    <ul>
      <li>Nginx에서 프록시 해주는 포트 번호를 바꾸는 방식으로 구현했습니다.</li>
    </ul>
  </li>
</ul>

<h3 id="블루-그린-배포-구현-방법"><strong>블루-그린 배포 구현 방법</strong></h3>
<ul>
  <li>Nginx 설정
    <ul>
      <li>서버내 로컬에서 지정한 포트의 서비스를 외부에서 호스팅 되도록 설정했습니다</li>
    </ul>
  </li>
  <li>배포 서비스 설정
    <ul>
      <li>신/구버전의 서비스가 포트가 다르게 구동되도록 설정했습니다.</li>
      <li>대상 서비스는 java spring boot 기반의 서비스로 서비스별 사용할 포트 두개는 임의로 각각 8092, 8093로 하고 설명 진행하겠습니다. (서버에서 사용중인 아닌 포트라면 어떤 번호도 문제 없습니다.)</li>
    </ul>
  </li>
  <li>배포 스크립트 작성
    <ul>
      <li>실행 중인 포트를 자동으로 확인하고, 미사용인 포트로 서비스를 배포하고 트래픽을 새로운 포트로 전환하는 스크립트를 작성하였습니다.</li>
    </ul>
  </li>
</ul>

<h3 id="구현한-블루-그린-배포처리-흐름"><strong>구현한 블루-그린 배포처리 흐름</strong></h3>
<p><img src="/img/blue-green/newdeploy01.jpg" alt="/img/blue-green/newdeploy01.jpg" /></p>
<ol>
  <li>포트 체크 : 포트의 기동 여부를 체크하여 사용하지 않는 포트를 확인합니다.</li>
  <li>그린 환경 준비 : 새 버전의 애플리케이션을 1에서 확인된 사용하지 않는 포트(8093)의 서비스에 배포하고 기동합니다.</li>
  <li>헬스 체크 : 배포 후 테스트와 검증 수행합니다.
    <ul>
      <li>문제없이 기동 되었는지와 api에 대한 호출 체크를 합니다.</li>
    </ul>
  </li>
</ol>

<p><img src="/img/blue-green/newdeploy02.jpg" alt="/img/blue-green/newdeploy02.jpg" /></p>
<ol>
  <li>트래픽 전환 : Nginx의 프록시 대상 포트를 기존포트(8092)에서 새 포트(8093)로 변경합니다.</li>
  <li>Nginx 반영 : Nginx 설정을 반영(nginx reload)합니다.</li>
  <li>블루 환경 종료 : 사용 중이었던 포트(8092)의 서비스를 기동 정지합니다.</li>
  <li>확인 : 모니터링 및 안정성 확인합니다.</li>
</ol>

<p>여기서 배포하고 문제가 발생한 경우, 정지된 포트의 서비스를 기동하고 nginx의 프록시 대상 포트를 바꿔주는 것으로 롤백이 가능합니다.</p>

<h3 id="배포스크립트를-통해-보는-배포-흐름"><strong>배포스크립트를 통해 보는 배포 흐름</strong></h3>

<ol>
  <li>빌드를 실행하여 jar파일이 생성되도록 합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>./gradlew <span class="nt">--build-cache</span> :api:bootJar
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>기동중인 포트 확인: 서비스 이름에 포트를 포함하여 서비스 활성화 확인하여 사용 중인 포트(SERVER_PORT_USE)와 미사용인 포트(SERVER_PORT)를 각각의 변수에 저장합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre><span class="c"># 서비스 이름에 포트를 포함</span>
<span class="nv">SERVICE_NAME</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">SERVICE_UNIT_NAME</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">SERVER_PORT_BLUE</span><span class="k">}</span><span class="s2">"</span>
<span class="c"># ssh를 사용해서 해당포트의 서비스가 사용중인지 체크</span>
<span class="c"># server_ip : 해당하는 서버의 ip</span>
<span class="k">if </span>ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo systemctl is-active --quiet </span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">then
</span><span class="nb">echo</span> <span class="s2">"Port </span><span class="k">${</span><span class="nv">SERVER_PORT_BLUE</span><span class="k">}</span><span class="s2"> on </span><span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span><span class="s2"> is in use."</span>
<span class="nv">SERVER_PORT</span><span class="o">=</span><span class="k">${</span><span class="nv">SERVER_PORT_GREEN</span><span class="k">}</span>
<span class="nv">SERVER_PORT_USE</span><span class="o">=</span><span class="k">${</span><span class="nv">SERVER_PORT_BLUE</span><span class="k">}</span>
<span class="k">else
</span><span class="nb">echo</span> <span class="s2">"Port </span><span class="k">${</span><span class="nv">SERVER_PORT_BLUE</span><span class="k">}</span><span class="s2"> on </span><span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span><span class="s2"> is not in use."</span>
<span class="nv">SERVER_PORT</span><span class="o">=</span><span class="k">${</span><span class="nv">SERVER_PORT_BLUE</span><span class="k">}</span>
<span class="nv">SERVER_PORT_USE</span><span class="o">=</span><span class="k">${</span><span class="nv">SERVER_PORT_GREEN</span><span class="k">}</span>
<span class="k">fi</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>기동 중이 아닌 포트의 서비스 이름을 변수에 저장합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="c"># 사용중이지 않은 포트로 서비스 이름 재설정</span>
<span class="nv">SERVICE_NAME</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">SERVICE_UNIT_NAME</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">SERVER_PORT</span><span class="k">}</span><span class="s2">"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>서비스 배포: 소스를 빌드하고 배포합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"
mkdir -p -v </span><span class="k">${</span><span class="nv">pipeline_dir</span><span class="k">}</span><span class="s2">
sudo find </span><span class="k">${</span><span class="nv">project_dir</span><span class="k">}</span><span class="s2">/ -mtime +1 -delete || ls -l </span><span class="k">${</span><span class="nv">project_dir</span><span class="k">}</span><span class="s2">/*/*
"</span>
scp <span class="nt">-P</span>포트번호 <span class="nt">-p</span> api/build/libs/<span class="k">*</span>.jar 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span>:<span class="k">${</span><span class="nv">pipeline_dir</span><span class="k">}</span>
scp <span class="nt">-P</span>포트번호 <span class="nt">-p</span> scripts/<span class="k">*</span> 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span>:<span class="k">${</span><span class="nv">project_dir</span><span class="k">}</span>
ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo cp -v </span><span class="k">${</span><span class="nv">pipeline_dir</span><span class="k">}</span><span class="s2">/*.jar /var/bootapp/</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">.jar"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>서비스 기동 : 새로운 포트의 서비스를 기동합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo systemctl restart </span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>서비스 기동 확인 :  새로운 포트의 서비스 기동이 제대로 되었는지 확인하고 실패시 에러 메시지를 출력하고 기동했던 서비스를 정지하고 배포 처리를 종료합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="c"># 기동완료되었는지 확인</span>
ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"TRACE=</span><span class="k">${</span><span class="nv">CI_DEBUG_TRACE</span><span class="k">}</span><span class="s2"> sh </span><span class="nv">$project_dir</span><span class="s2">/check-started.sh </span><span class="k">${</span><span class="nv">SERVICE_UNIT_NAME</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">SERVER_PORT</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">"</span>
<span class="o">[[</span> <span class="nv">$?</span> <span class="nt">-ne</span> 0 <span class="o">]]</span> <span class="o">&amp;&amp;</span> ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo systemctl stop </span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">exit </span>1
</pre></td></tr></tbody></table></code></pre></div>    </div>
    <ul>
      <li>기동 체크
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
</pre></td><td class="rouge-code"><pre><span class="nv">r</span><span class="o">=</span>0
<span class="k">while</span> <span class="o">[[</span> <span class="k">${</span><span class="nv">r</span><span class="k">}</span> <span class="nt">-lt</span> 60 <span class="o">]]</span> <span class="p">;</span>
<span class="k">do
 </span><span class="nv">result</span><span class="o">=</span><span class="si">$(</span><span class="nb">sudo </span>curl <span class="nt">-s</span> http://localhost:<span class="k">${</span><span class="nv">SERVER_PORT</span><span class="k">}</span>/actuator/health<span class="si">)</span>
 <span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">result</span><span class="k">}</span><span class="s2">"</span> <span class="o">==</span> <span class="k">*</span><span class="s2">"UP"</span><span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
 </span><span class="nb">echo</span> <span class="s2">"#서비스 구동 완료 - </span><span class="k">${</span><span class="nv">SERVICE_UNIT_NAME</span><span class="k">}</span><span class="s2"> "</span><span class="p">;</span> <span class="nb">exit</span> <span class="k">${</span><span class="nv">status</span><span class="k">}</span>
 <span class="k">fi</span>
 <span class="o">((</span>r++<span class="o">))</span>
 <span class="nb">sleep </span>1
<span class="k">done
</span><span class="nb">echo</span> <span class="s2">"#서비스 구동 오류 (타임아웃) - </span><span class="k">${</span><span class="nv">SERVICE_UNIT_NAME</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">status</span><span class="k">}</span><span class="s2"> "</span><span class="p">;</span> <span class="nb">exit </span>1<span class="p">;;</span>
</pre></td></tr></tbody></table></code></pre></div>        </div>
      </li>
      <li>curl로 heath체크를 하는데 대기 시간이 있으므로 반복문으로 실행했습니다.</li>
      <li>기동에 오래걸리는 서비스는 아니라서 최대 60초로 설정했습니다.</li>
    </ul>
  </li>
  <li>Nginx 설정 파일 업데이트: sed 명령어를 사용하여 Nginx 설정 파일에서 현재 사용 중인 포트(SERVER_PORT_USE)를 이번에 사용할 포트(SERVER_PORT)로 변경합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo sed -i 's/</span><span class="k">${</span><span class="nv">SERVER_PORT_USE</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">SERVER_PORT</span><span class="k">}</span><span class="s2">/g' </span><span class="k">${</span><span class="nv">NGINX_PATH</span><span class="k">}</span><span class="s2">"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
    <ul>
      <li>NGINX_PATH : nginx 설정 파일의 파일명을 포함한 경로 입니다.</li>
    </ul>
  </li>
  <li>Nginx 설정 검사 및 적용: 변경된 설정 파일이 올바른지 nginx -t로 문법 검사를 수행한 후, 성공하면 Nginx를 reload하여 새로운 포트로 프록시 되도록 업데이트합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="c"># 변경된 설정 파일 문법 검사</span>
ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo nginx -t"</span>
<span class="c"># 문법 검사 통과 시 설정을 reload, 그렇지 않으면 오류 메시지 출력</span>
<span class="k">if</span> <span class="o">[</span> <span class="nv">$?</span> <span class="nt">-eq</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then
</span><span class="nb">echo</span> <span class="s2">"Nginx configuration is valid. Reloading Nginx..."</span>
ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo nginx -s reload"</span>
<span class="nb">echo</span> <span class="s2">"Nginx successfully reloaded. Proxy pass updated to port: </span><span class="k">${</span><span class="nv">SERVER_PORT</span><span class="k">}</span><span class="s2">"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>서비스 정지: Nginx reload 후에 기존에 사용하던 포트의 서비스를 정지합니다.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">sleep </span>5
ssh <span class="nt">-p</span>포트번호 계정@<span class="k">${</span><span class="nv">server_ip</span><span class="k">}</span> <span class="s2">"sudo systemctl stop </span><span class="k">${</span><span class="nv">SERVICE_UNIT_NAME</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">SERVER_PORT_USE</span><span class="k">}</span><span class="s2">"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
    <ul>
      <li>sleep 5: 포트 전환 후에 이미 들어와 있는 호출이 있을 경우 처리할 시간을 주기 위해 딜레이를 주었습니다.</li>
    </ul>
  </li>
</ol>

<h3 id="직접-구현하며-느낀-블루-그린-배포의-장점"><strong>직접 구현하며 느낀 블루-그린 배포의 장점</strong></h3>
<ul>
  <li>배포 시간 단축: 로드밸런서의 제외/연결에 소요되는 시간 절약되어서 그런지 배포 시간이 많이 단축되었습니다.
    <ul>
      <li>배포 방식 변경 전 : 13분 30초 걸렸습니다. (평균적으로 10분 정도 걸렸습니다.)</li>
      <li>변경 후 : 1분 54초 걸렸습니다. (2분 가량 걸렸습니다.)</li>
      <li>소요 시간이 5분의 1로 단축 되었습니다.</li>
    </ul>
  </li>
  <li>빠른 롤백: 롤백이 필요한 경우 구버전 기동 후 nginx설정 변경으로 즉시 롤백 가능합니다.</li>
  <li>테스트 용이성: 포트만 달리하여 구/신버전을 동시 기동이 가능하여 버전 비교 테스트도 가능했습니다.</li>
  <li>배포 실패시 안정성: 배포 중 서버 기동 실패 발생해도 기존 서비스는 기동 중이고 nginx에서 포트 전환은 되지 않기에 서비스 이용에는 영향이 없습니다.</li>
  <li>무중단 배포: Jmeter로  api 호출 테스트를 해보았는데 단일 서버 테스트였음에도 서비스가 끊김 없이 호출되었습니다. (*부록-배포 테스트)</li>
</ul>

<h5 id="부록---배포-테스트"><strong>부록 - 배포 테스트</strong></h5>
<p>Jmeter를 통해 배포 중에 api호출시 에러가 발생하는지 확인하였습니다.</p>

<p>다음과 같은 Thread를 설정하고 기동과 함께 jmeter를 실행했습니다.
<img src="/img/blue-green/jmeter01.jpg" alt="/img/blue-green/jmeter01.jpg" /></p>
<ul>
  <li>threads : 60
    <ul>
      <li>접속 유저 수라고 보시면 됩니다.</li>
    </ul>
  </li>
  <li>period : 1
    <ul>
      <li>지정한 시간까지 threads 에 설정한 트래픽이 증가 됩니다. (설정대로면 1초가 되면 60 유저가 진입완료)</li>
    </ul>
  </li>
  <li>loop count : 20
    <ul>
      <li>반복수입니다. (설정대로면 60명의 유저가 각각 20번 호출 하는 것으로 총 1200번 호출됩니다.)</li>
    </ul>
  </li>
</ul>

<p>결과 레포트에서 에러가 발생하지 않은 것을 확인할 수 있었습니다.
<img src="/img/blue-green/jmeter02.jpg" alt="/img/blue-green/jmeter02.jpg" /></p>
<ul>
  <li>samples : 총 호출 수로 1200번 호출 되었습니다.</li>
  <li>Error% : 에러 발생 비율로 에러가 발생하지 않아서 0%입니다.</li>
</ul>

<h2 id="마무리하며-단순함-속에서-찾은-혁신">마무리하며: 단순함 속에서 찾은 혁신</h2>
<p>이번 배포 방식 개선을 통해 배포 중 <strong>발생하는 에러를 최소화</strong>하고, 배포 시간을 <strong>획기적으로 단축</strong>하는 성과를 거두었습니다.</p>

<p>특히, 복잡한 신기술이나 고비용의 툴 도입 없이 <strong>기존에 사용하던 Nginx의 설정을 활용</strong>하여 이러한 안정성과 효율을 확보했다는 것이 가장 큰 소득이었습니다. 이는 인프라 환경 변경 없이도 배포 프로세스를 개선할 수 있는 가능성이라고 생각합니다.</p>

<p>앞으로 신규 서비스는 물론, 기존 서비스에도 블루-그린 전략을 점진적으로 적용하여 전체 시스템의 무중단 가용성과 안정성을 한층 높일 계획입니다.
긴 글 읽어주셔서 감사합니다.</p>]]></content><author><name>정종현</name></author><category term="Backend/System" /><category term="CI/CD" /><summary type="html"><![CDATA[복잡한 툴 없이, Shell Script와 actuator/health로 완성한 1초 트래픽 전환 시스템]]></summary></entry><entry><title type="html">‘에러를 읽는 AI’ - Gemini와 Slack으로 만든 자동 오류 분석 시스템 Solomon</title><link href="https://saramin.github.io/2025-10-29-ai-assistant/" rel="alternate" type="text/html" title="‘에러를 읽는 AI’ - Gemini와 Slack으로 만든 자동 오류 분석 시스템 Solomon" /><published>2025-10-29T00:00:00+09:00</published><updated>2025-11-05T13:53:31+09:00</updated><id>https://saramin.github.io/ai-assistant</id><content type="html" xml:base="https://saramin.github.io/2025-10-29-ai-assistant/"><![CDATA[<h2 id="1-들어가며"><strong>1. 들어가며</strong></h2>
<p>현재 사내의 로그 수집은 <a href="/2020-04-01-heimdall">Heimdall</a>과  <a href="/2025-08-01-otel">OpenTelemetry(w. sigNoz)</a>를 기반으로 이루어지고 있습니다. <br />
오류 관제의 대부분은 Heimdall의 룰(rule) 에 따라 Slack 채널로 전달되며, 실제 장애 발생 시 관련 로그가 아래와 같이 공유 되고 있습니다. (이미지 참조)</p>
<div align="center"> <img src="/img/slack-ai-assistant/slack-image-1.png" width="600" alt="기존 오류 메시지 예시" /> </div>
<p><br /></p>

<p>그러나 기존 메시지 템플릿으로 모니터링을 진행하면서 다음과 같은 불편함이 있었습니다.</p>

<p><strong>불필요하게 긴 Stack Trace</strong> <br />
(메시지가 너무 길어 Slack 상에서 잘리는 경우 다수 발생)</p>

<p><strong>오류 원인 파악의 어려움</strong> <br />
(핵심 예외가 묻혀 한눈에 보기 힘듦)</p>

<p>이러한 이유로 “오류를 한눈에, 빠르게 파악할 수 있는 방식은 없을까?” 하는 고민이 시작되었습니다.
AI가 로그를 분석해 오류 원인을 요약하고, 핵심 정보를 바로 보여줄 수 있다면
개발자는 원인 분석에 드는 시간을 크게 줄일 수 있을 것이라 판단했습니다.</p>

<h2 id="2-스펙"><strong>2. 스펙</strong></h2>

<table>
  <thead>
    <tr>
      <th>분류</th>
      <th>기술</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>백엔드</td>
      <td>Spring Boot 3.x (JDK 21)</td>
      <td>애플리케이션 프레임워크</td>
    </tr>
    <tr>
      <td>AI 엔진</td>
      <td>Gemini API (Java SDK)</td>
      <td>오류 분석 및 답변 생성</td>
    </tr>
    <tr>
      <td>Slack 통합</td>
      <td>Slack Bolt for Java</td>
      <td>socket mode를 통한 이벤트 수신</td>
    </tr>
    <tr>
      <td>배포 환경</td>
      <td>Kubernetes</td>
      <td> </td>
    </tr>
  </tbody>
</table>

<h2 id="3-flow"><strong>3. Flow</strong></h2>
<p>🚀이번에 구축한 시스템은 오류 감지부터 자동 응답까지 <strong>완전 자동화된 파이프라인</strong>으로,
Heimdall에서 발생한 이벤트를 Slack으로 알리고, Gemini를 통해 자동으로 분석/답변을 생성하도록 설계했습니다.</p>

<div>
	<img src="/img/slack-ai-assistant/flow-image-1.png" />
</div>

<p>📡  Heimdall → Server (Webhook)
먼저 Heimdall에서 오류나 이벤트가 감지되면, 해당 내용을 Webhook을 통해 서버로 전송합니다.
이때 서버는 요청을 수신하자마자 “OK” 응답을 반환하여 Heimdall 측에서 대기 없이 빠르게 다음 작업으로 넘어갈 수 있도록 합니다.
(즉 비동기 처리 구조로 설계되어 있습니다.)</p>

<p>⚙️ Server 내부 비동기 처리</p>
<ol>
  <li>
    <p>Webhook 응답 이후, 서버는 실제 처리를 백그라운드 비동기 작업으로 수행합니다. <br />
  이 비동기 작업은 다음과 같은 순서로 진행됩니다:</p>
  </li>
  <li>
    <p>Slack 알림 전송 (post.message) <br />
 수신한 오류 메시지를 지정된 Slack 채널로 전송하여, 팀원들이 즉시 인지할 수 있도록 합니다.</p>
  </li>
  <li>
    <p>Gemini 응답 생성 (generate answer) <br />
  Slack에 전송된 메시지를 기반으로, Gemini 모델을 호출해 오류 원인이나 해결책을 자동으로 분석합니다.</p>
  </li>
  <li>
    <p>Slack 스레드 답변 (post.message(thread)) <br />
 Gemini가 생성한 분석 결과를 원본 Slack 메시지의 스레드 형태로 다시 전송합니다.
이를 통해 한 메시지 내에서 오류 내용과 분석 결과를 한눈에 확인할 수 있습니다.</p>
  </li>
</ol>

<p>💡 핵심 포인트</p>
<ul>
  <li>Webhook 즉시 응답 → Heimdall은 블로킹 없이 빠른 처리가 가능</li>
  <li>비동기 기반 설계 → Slack 전송과 Gemini 호출이 서버 응답 속도에 영향을 주지 않음</li>
  <li>Slack + Gemini 통합 → 오류 발생 → 자동 분석 → 답변 공유까지 완전 자동화</li>
  <li>스레드 방식 메시징 → Slack 내에서 관련 정보가 깔끔하게 정리됨</li>
</ul>

<h2 id="4-구현-내용"><strong>4. 구현 내용</strong></h2>
<p><strong>1. Gemini API 연동을 위한 Gradle 종속성 추가</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="n">build</span><span class="o">.</span><span class="na">gradle</span>
<span class="o">...</span>
    <span class="c1">// gemini</span>
    <span class="n">implementation</span> <span class="err">'</span><span class="n">com</span><span class="o">.</span><span class="na">google</span><span class="o">.</span><span class="na">genai</span><span class="o">:</span><span class="n">google</span><span class="o">-</span><span class="nl">genai:</span><span class="mf">1.24</span><span class="o">.</span><span class="mi">0</span><span class="err">'</span>
<span class="o">...</span>

</pre></td></tr></tbody></table></code></pre></div></div>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="n">application</span><span class="o">.</span><span class="na">yml</span>
<span class="o">...</span>
<span class="nl">genai:</span>
 <span class="nl">api:</span> <span class="err">#########################</span>  <span class="err">#</span> <span class="nl">https:</span><span class="c1">//aistudio.google.com 에서 key 발급</span>
 <span class="nl">model:</span> <span class="n">gemini</span><span class="o">-</span><span class="n">flash</span><span class="o">-</span><span class="n">lite</span><span class="o">-</span><span class="n">latest</span> <span class="err">#</span> <span class="nl">https:</span><span class="c1">//ai.google.dev/gemini-api/docs/models?hl=ko   </span>
<span class="o">...</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>👉 Google의 Gemini API를 활용하기 위한 필수 패키지 구성 및 환경 설정</p>

<p><strong>2. Slack 오류 원문 Stack Trace 간소화 로직 구현</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
</pre></td><td class="rouge-code"><pre><span class="kd">public</span> <span class="kd">class</span> <span class="nc">StacktraceTrimmer</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="kt">int</span> <span class="n">messageLimit</span> <span class="o">=</span> <span class="mi">500</span><span class="o">;</span>

    <span class="cm">/**
     * stacktrace: 전체 스택 트레이스(예: Throwable#printStackTrace 결과)
     * maxFrames: 각 예외(혹은 Caused by) 당 보여줄 최대 프레임 수
     */</span>
    <span class="kd">public</span> <span class="kd">static</span> <span class="nc">String</span> <span class="nf">extractEssential</span><span class="o">(</span><span class="nc">String</span> <span class="n">stacktrace</span><span class="o">,</span> <span class="kt">int</span> <span class="n">maxFrames</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">stacktrace</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">stacktrace</span><span class="o">.</span><span class="na">isBlank</span><span class="o">())</span>
            <span class="k">return</span> <span class="s">""</span><span class="o">;</span>

        <span class="c1">// stacktrace 가 500자 미만이면 그대로 반환</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">stacktrace</span><span class="o">.</span><span class="na">length</span><span class="o">()</span> <span class="o">&lt;</span> <span class="n">messageLimit</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">stacktrace</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="nc">String</span><span class="o">[]</span> <span class="n">lines</span> <span class="o">=</span> <span class="n">stacktrace</span><span class="o">.</span><span class="na">split</span><span class="o">(</span><span class="s">"\\r?\\n"</span><span class="o">);</span>
        <span class="nc">StringBuilder</span> <span class="n">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">StringBuilder</span><span class="o">();</span>

        <span class="nc">Pattern</span> <span class="n">exceptionLine</span> <span class="o">=</span> <span class="nc">Pattern</span><span class="o">.</span><span class="na">compile</span><span class="o">(</span><span class="s">"^[^\\s].*(?:Exception|Error|Throwable|Failure).*"</span><span class="o">);</span>
        <span class="nc">Pattern</span> <span class="n">atLine</span> <span class="o">=</span> <span class="nc">Pattern</span><span class="o">.</span><span class="na">compile</span><span class="o">(</span><span class="s">"^\\s*at\\s+(.+)$"</span><span class="o">);</span>
        <span class="nc">Pattern</span> <span class="n">causedBy</span> <span class="o">=</span> <span class="nc">Pattern</span><span class="o">.</span><span class="na">compile</span><span class="o">(</span><span class="s">"^Caused by:.*$"</span><span class="o">);</span>
        <span class="nc">Pattern</span> <span class="n">servicePattern</span> <span class="o">=</span> <span class="nc">Pattern</span><span class="o">.</span><span class="na">compile</span><span class="o">(</span><span class="s">"^\\s*Service*$"</span><span class="o">);</span>

        <span class="nc">Pattern</span> <span class="n">appPattern</span> <span class="o">=</span> <span class="kc">null</span><span class="o">;</span>

        <span class="kt">boolean</span> <span class="n">seenFirstException</span> <span class="o">=</span> <span class="kc">false</span><span class="o">;</span>
        <span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="k">while</span> <span class="o">(</span><span class="n">i</span> <span class="o">&lt;</span> <span class="n">lines</span><span class="o">.</span><span class="na">length</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">String</span> <span class="n">line</span> <span class="o">=</span> <span class="n">lines</span><span class="o">[</span><span class="n">i</span><span class="o">];</span>

            <span class="c1">// 예외/오류 첫 줄 감지</span>
            <span class="k">if</span> <span class="o">(!</span><span class="n">seenFirstException</span> <span class="o">&amp;&amp;</span> <span class="n">exceptionLine</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">line</span><span class="o">).</span><span class="na">matches</span><span class="o">())</span> <span class="o">{</span>
                <span class="n">seenFirstException</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span>
                <span class="n">out</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">line</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="sc">'\n'</span><span class="o">);</span>

                <span class="c1">// 다음 라인들에서 프레임 수집</span>
                <span class="n">i</span><span class="o">++;</span>
                <span class="n">i</span> <span class="o">=</span> <span class="n">collectFrames</span><span class="o">(</span><span class="n">lines</span><span class="o">,</span> <span class="n">i</span><span class="o">,</span> <span class="n">out</span><span class="o">,</span> <span class="n">atLine</span><span class="o">,</span> <span class="n">causedBy</span><span class="o">,</span> <span class="n">appPattern</span><span class="o">,</span> <span class="n">servicePattern</span><span class="o">,</span> <span class="n">maxFrames</span><span class="o">);</span>
                <span class="k">continue</span><span class="o">;</span>
            <span class="o">}</span>

            <span class="c1">// 만약 이미 첫 예외를 봤다면, 다른 'Caused by' 블록들도 처리</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">seenFirstException</span> <span class="o">&amp;&amp;</span> <span class="n">causedBy</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">line</span><span class="o">).</span><span class="na">matches</span><span class="o">())</span> <span class="o">{</span>
                <span class="n">out</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">line</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="sc">'\n'</span><span class="o">);</span>
                <span class="n">i</span><span class="o">++;</span>
                <span class="n">i</span> <span class="o">=</span> <span class="n">collectFrames</span><span class="o">(</span><span class="n">lines</span><span class="o">,</span> <span class="n">i</span><span class="o">,</span> <span class="n">out</span><span class="o">,</span> <span class="n">atLine</span><span class="o">,</span> <span class="n">causedBy</span><span class="o">,</span> <span class="n">appPattern</span><span class="o">,</span> <span class="n">servicePattern</span><span class="o">,</span> <span class="n">maxFrames</span><span class="o">);</span>
                <span class="k">continue</span><span class="o">;</span>
            <span class="o">}</span>

            <span class="k">if</span> <span class="o">(</span><span class="n">servicePattern</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">line</span><span class="o">).</span><span class="na">matches</span><span class="o">())</span> <span class="o">{</span>
                <span class="n">out</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">line</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="sc">'\n'</span><span class="o">);</span>
                <span class="n">i</span><span class="o">++;</span>
                <span class="n">i</span> <span class="o">=</span> <span class="n">collectFrames</span><span class="o">(</span><span class="n">lines</span><span class="o">,</span> <span class="n">i</span><span class="o">,</span> <span class="n">out</span><span class="o">,</span> <span class="n">atLine</span><span class="o">,</span> <span class="n">causedBy</span><span class="o">,</span> <span class="n">appPattern</span><span class="o">,</span> <span class="n">servicePattern</span><span class="o">,</span> <span class="n">maxFrames</span><span class="o">);</span>
                <span class="k">continue</span><span class="o">;</span>
            <span class="o">}</span>

            <span class="n">i</span><span class="o">++;</span>
        <span class="o">}</span>

        <span class="nc">String</span> <span class="n">result</span> <span class="o">=</span> <span class="n">out</span><span class="o">.</span><span class="na">toString</span><span class="o">().</span><span class="na">trim</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">result</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="c1">// 포맷이 달라서 잡히지 않을 경우: fallback으로 맨 앞 10라인 반환</span>
            <span class="nc">StringBuilder</span> <span class="n">fb</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">StringBuilder</span><span class="o">();</span>
            <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="nc">Math</span><span class="o">.</span><span class="na">min</span><span class="o">(</span><span class="mi">10</span><span class="o">,</span> <span class="n">lines</span><span class="o">.</span><span class="na">length</span><span class="o">);</span> <span class="n">k</span><span class="o">++)</span> <span class="o">{</span>
                <span class="n">fb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">lines</span><span class="o">[</span><span class="n">k</span><span class="o">]).</span><span class="na">append</span><span class="o">(</span><span class="sc">'\n'</span><span class="o">);</span>
            <span class="o">}</span>
            <span class="n">fb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"... (truncated)"</span><span class="o">);</span>
            <span class="k">return</span> <span class="n">fb</span><span class="o">.</span><span class="na">toString</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">result</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="kt">int</span> <span class="nf">collectFrames</span><span class="o">(</span><span class="nc">String</span><span class="o">[]</span> <span class="n">lines</span><span class="o">,</span>
                                     <span class="kt">int</span> <span class="n">idx</span><span class="o">,</span>
                                     <span class="nc">StringBuilder</span> <span class="n">out</span><span class="o">,</span>
                                     <span class="nc">Pattern</span> <span class="n">atLine</span><span class="o">,</span>
                                     <span class="nc">Pattern</span> <span class="n">causedBy</span><span class="o">,</span>
                                     <span class="nc">Pattern</span> <span class="n">appPattern</span><span class="o">,</span>
                                     <span class="nc">Pattern</span> <span class="n">servicePattern</span><span class="o">,</span>
                                     <span class="kt">int</span> <span class="n">maxFrames</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">appFrames</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ArrayList</span><span class="o">&lt;&gt;();</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">otherFrames</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ArrayList</span><span class="o">&lt;&gt;();</span>

        <span class="kt">int</span> <span class="n">consumed</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="k">while</span> <span class="o">(</span><span class="n">idx</span> <span class="o">+</span> <span class="n">consumed</span> <span class="o">&lt;</span> <span class="n">lines</span><span class="o">.</span><span class="na">length</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">String</span> <span class="n">l</span> <span class="o">=</span> <span class="n">lines</span><span class="o">[</span><span class="n">idx</span> <span class="o">+</span> <span class="n">consumed</span><span class="o">];</span>

            <span class="k">if</span> <span class="o">(</span><span class="n">causedBy</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">l</span><span class="o">).</span><span class="na">matches</span><span class="o">()</span> <span class="o">||</span> <span class="n">l</span><span class="o">.</span><span class="na">trim</span><span class="o">().</span><span class="na">isEmpty</span><span class="o">()</span> <span class="o">||</span> <span class="o">!</span><span class="n">atLine</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">l</span><span class="o">).</span><span class="na">matches</span><span class="o">())</span> <span class="o">{</span>
                <span class="c1">// 프레임 블록 끝(다음이 Caused by 이거나 빈줄 혹은 비-at 라인)</span>
                <span class="k">break</span><span class="o">;</span>
            <span class="o">}</span>

            <span class="c1">// at line</span>
            <span class="nc">String</span> <span class="n">frameText</span> <span class="o">=</span> <span class="n">l</span><span class="o">.</span><span class="na">trim</span><span class="o">();</span> <span class="c1">// "at com.xxx... (File.java:123)"</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">appPattern</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="n">appPattern</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">frameText</span><span class="o">).</span><span class="na">find</span><span class="o">())</span> <span class="o">{</span>
                <span class="n">appFrames</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">frameText</span><span class="o">);</span>
            <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
                <span class="n">otherFrames</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">frameText</span><span class="o">);</span>
            <span class="o">}</span>

            <span class="n">consumed</span><span class="o">++;</span>
        <span class="o">}</span>

        <span class="c1">// 우선 애플리케이션 프레임을 최대 maxFrames개 채우고, 모자르면 other로 채운다.</span>
        <span class="kt">int</span> <span class="n">remaining</span> <span class="o">=</span> <span class="n">maxFrames</span><span class="o">;</span>
        <span class="k">for</span> <span class="o">(</span><span class="nc">String</span> <span class="n">f</span> <span class="o">:</span> <span class="n">appFrames</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">remaining</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="o">)</span>
                <span class="k">break</span><span class="o">;</span>
            <span class="n">out</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"\t"</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">f</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="sc">'\n'</span><span class="o">);</span>
            <span class="n">remaining</span><span class="o">--;</span>
        <span class="o">}</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">remaining</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">for</span> <span class="o">(</span><span class="nc">String</span> <span class="n">f</span> <span class="o">:</span> <span class="n">otherFrames</span><span class="o">)</span> <span class="o">{</span>
                <span class="k">if</span> <span class="o">(</span><span class="n">remaining</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="o">)</span>
                    <span class="k">break</span><span class="o">;</span>
                <span class="n">out</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"\t"</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">f</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="sc">'\n'</span><span class="o">);</span>
                <span class="n">remaining</span><span class="o">--;</span>
            <span class="o">}</span>
        <span class="o">}</span>

        <span class="c1">// 만약 프레임이 더 있었으면 축약 표시</span>
        <span class="kt">int</span> <span class="n">totalFrames</span> <span class="o">=</span> <span class="n">appFrames</span><span class="o">.</span><span class="na">size</span><span class="o">()</span> <span class="o">+</span> <span class="n">otherFrames</span><span class="o">.</span><span class="na">size</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">totalFrames</span> <span class="o">&gt;</span> <span class="n">maxFrames</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">out</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"\t... "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">totalFrames</span> <span class="o">-</span> <span class="n">maxFrames</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="s">" more frames (truncated)\n"</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="k">return</span> <span class="n">idx</span> <span class="o">+</span> <span class="n">consumed</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>👉 불필요한 라인 제거 및 핵심 예외 위치만 추출하도록 개선</p>

<p><strong>3. Slack 오류 메시지 자동 발송 모듈 개발</strong></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
</pre></td><td class="rouge-code"><pre><span class="nc">SlackService</span><span class="o">.</span><span class="na">java</span>
<span class="o">...</span>
   <span class="cm">/**
	 * https://docs.slack.dev/reference/methods/chat.postMessage/
	 */</span>
    <span class="kd">private</span> <span class="nc">SlackPostMessageResponse</span> <span class="nf">sendSlack</span><span class="o">(</span><span class="nc">ObjectNode</span> <span class="n">requestBody</span><span class="o">,</span> <span class="kt">boolean</span> <span class="n">isErrorHelper</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">webClient</span>
                <span class="o">.</span><span class="na">post</span><span class="o">()</span>
                <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="nc">String</span><span class="o">.</span><span class="na">format</span><span class="o">(</span><span class="s">"%s/%s"</span><span class="o">,</span> <span class="s">"https://slack.com/api/"</span><span class="o">,</span> <span class="no">SLACK_POST_MESSAGE_URI</span><span class="o">))</span>
                <span class="o">.</span><span class="na">header</span><span class="o">(</span><span class="s">"Authorization"</span><span class="o">,</span> <span class="nc">String</span><span class="o">.</span><span class="na">format</span><span class="o">(</span><span class="s">"Bearer %s"</span><span class="o">,</span> <span class="n">botToken</span><span class="o">)</span>
                <span class="o">.</span><span class="na">bodyValue</span><span class="o">(</span><span class="n">requestBody</span><span class="o">)</span>
                <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
                <span class="o">.</span><span class="na">bodyToMono</span><span class="o">(</span><span class="nc">SlackPostMessageResponse</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
                <span class="o">.</span><span class="na">timeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">120</span><span class="o">))</span> <span class="c1">// 타임아웃 설정</span>
                <span class="o">.</span><span class="na">block</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">...</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>👉 Heimdall Webhook으로 전달된 오류 로그를 Slack 채널로 즉시 전송</p>

<p><strong>4. Gemini 기반 오류 분석 및 답변 생성 기능</strong></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
</pre></td><td class="rouge-code"><pre><span class="nc">GeminiService</span><span class="o">.</span><span class="na">java</span>

    <span class="cm">/**
     * gemini api 통한 답변 생성
     * @param errorMessage
     * @return
     */</span>
    <span class="kd">public</span> <span class="nc">String</span> <span class="nf">generateAnswer</span><span class="o">(</span><span class="nc">String</span> <span class="n">errorMessage</span><span class="o">,</span> <span class="nc">String</span> <span class="n">sessionId</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">question</span> <span class="o">=</span>
                <span class="sh">"""
                너는 SpringBoot/Java 디버깅 전용 AI 비서다. 답변은 항상 아래 순서·형식으로, 각 문장(항목)은 100자 이내로 작성해라.
                
                1.오류에 대한 원인
                • 핵심키워드 : 각 키워드별 간단 해설 (한 줄, 100자 이내)
                
                2.오류 발생 지점
                • 파일명 : 몇번째 라인에서 오류 발생했는지 (패키지명 생략, 사용자 파일만 기재)
                
                3.해결 방안
                • 해결 방법 (오류 메시지의 정보만으로 구체적 조치 제시)
                • 해결 예시 (실제 적용 가능한 코드/명령/설정 + 시나리오: 상황→문제→해결 흐름)
                
                출력 규칙: 슬랙 스타일 사용 — 강조/헤더는 * 한 개만, 리스트는 • 사용 (반드시 지킬 것)
                절대 일반론으로 끝내지 말고, 바로 적용 가능한 실전 해법으로 마무리하라. :
                """</span> <span class="o">+</span> <span class="n">errorMessage</span><span class="o">;</span>

        <span class="nc">GenerateContentResponse</span> <span class="n">responseBody</span><span class="o">;</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="nc">Chat</span> <span class="n">chat</span> <span class="o">=</span> <span class="n">geminiClient</span><span class="o">.</span><span class="na">chats</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="n">model</span><span class="o">);</span>
            <span class="n">responseBody</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="na">sendMessage</span><span class="o">(</span><span class="n">question</span><span class="o">);</span>

            <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"Gemini API 응답 수신 완료"</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">ClientException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Gemini API 호출 중 오류 발생 : {}"</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">());</span>
            <span class="k">return</span> <span class="s">"오류에 대한 답변 작성이 불가능해요"</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">responseBody</span><span class="o">.</span><span class="na">text</span><span class="o">()</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="s">""</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">responseBody</span><span class="o">.</span><span class="na">text</span><span class="o">().</span><span class="na">replace</span><span class="o">(</span><span class="s">"```java"</span><span class="o">,</span> <span class="s">"```"</span><span class="o">);</span>
    <span class="o">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>👉 전송된 오류 로그를 Gemini에 전달하여 핵심 원인과 요약 메시지 생성</p>

<p><strong>5. Slack 스레드 내 AI 답변 자동 등록 기능</strong></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre>    <span class="kd">private</span> <span class="nc">SlackPostMessageResponse</span> <span class="nf">sendSlack</span><span class="o">(</span><span class="nc">ObjectNode</span> <span class="n">requestBody</span><span class="o">,</span> <span class="nc">String</span> <span class="n">threadTs</span><span class="o">,</span> <span class="nc">String</span> <span class="n">channelId</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">requestBody</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="s">"channel"</span><span class="o">,</span> <span class="n">channelId</span><span class="o">);</span>
        <span class="n">requestBody</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="s">"mrkdwn"</span><span class="o">,</span> <span class="kc">true</span><span class="o">);</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">threadTs</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">requestBody</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="s">"thread_ts"</span><span class="o">,</span> <span class="n">threadTs</span><span class="o">);</span> <span class="c1">// 3번의 SlackPostMessageResponse 응답값에 있는 thread_ts</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="nf">sendSlack</span><span class="o">(</span><span class="n">requestBody</span><span class="o">,</span> <span class="n">threadTs</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">);</span> <span class="c1">// 3번 로직 재활용</span>
    <span class="o">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>👉 AI가 생성한 분석 결과를 원본 오류 메시지의 스레드로 자동 등록하여,
개발자가 Slack 내에서 바로 원인과 해결 방향을 확인 가능</p>

<h2 id="5-중간-결과"><strong>5. 중간 결과</strong></h2>
<div style="text-align: center;" align="left">
  <img src="/img/slack-ai-assistant/slack-image-1.png" width="300" height="400" alt="개선 전 오류 메시지" style="margin-right: 10px;height:210px" align="left" />
  <img src="/img/slack-ai-assistant/slack-image-4.png" width="300" height="400" alt="개선 후 오류 메시지" style="margin-left: 10px;height:210px" align="left" />
</div>

<div align="center">
	<sub>👈 <b>개선 전 (Full Stack Trace) │ 개선 후 (요약 + 파일 위치 표시)</b> 👉</sub>
</div>

<p><br /></p>

<div style="text-align: center;" align="left">
		  <img src="/img/slack-ai-assistant/slack-image-2.png" alt="개선 후 오류 메시지" align="left" />
	</div>

<div align="center">
	<sub><b>gemini API를 활용한 AI 답변</b></sub>
</div>

<p>🔍 개선 전 (Before)</p>
<ul>
  <li>Slack 메시지로 전체 스택 트레이스가 그대로 전달됨</li>
  <li>로그가 길어 오류의 핵심 원인 파악에 시간 소요</li>
  <li>동일한 오류라도 스택 포맷에 따라 확인이 어려움</li>
</ul>

<p>✅ 개선 후 (After)</p>
<ul>
  <li>핵심 오류 메시지와 발생 파일·라인 정보만 추출</li>
  <li>메시지 길이 단축 → 가독성 향상</li>
  <li>Slack에서 바로 오류 원인과 위치 해결 방안 확인 가능</li>
</ul>

<p>💡 결과적으로, 단순히 메시지 포맷을 개선한 것만으로도
개발자가 오류를 분석하고 대응하는 속도가 눈에 띄게 향상되었습니다.</p>
<h2 id="6-운영하면서-느낀-한계점"><strong>6. 운영하면서 느낀 한계점</strong></h2>
<p>Solomon AI Assistant를 약 한 달간 운영해보면서 한 가지 불편함을 경험했습니다.
오류 답변에 대해 추가 질문을 하고 싶을 때가 있었지만, 시스템상 답변을 이어서 받을 수 없어 결국 다른 AI 툴을 통해 추가 질문을 진행해야 했습니다.</p>

<h2 id="7-개선-사항"><strong>7. 개선 사항</strong></h2>
<p>이후 확인해본 결과 Slack Event Subscription을 통해 Bot과 상호 작용이 가능하였고 추가로 기존 질문에 대한 세션을 유지하고자 캐시에 채팅 세션을 담아서 해당 채팅에 대한 세션이 있으면 해당 채팅세션을 통한 질문을 하여 기존 질문까지 포함하여 좀 더 정확한 답변을 도출되게 하고자 하였습니다.</p>

<div style="text-align: center;" align="left">
		  <img src="/img/slack-ai-assistant/flow-image-3.png" alt="개선 후 플로우" align="left" />
	</div>

<div align="center">
	<sub><b>개선사항 반영 플로우</b></sub>
</div>
<h2 id="8-구현-내용"><strong>8. 구현 내용</strong></h2>
<p><strong>1. Slack Event Subscription 설정 및 Bot 이벤트 핸들러 구현  (w. Slack Socket Mode)</strong></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
</pre></td><td class="rouge-code"><pre><span class="c1">// Bot 이벤트 핸들러 구현에는 크게 2가지 방법이 있습니다.</span>
<span class="c1">// 1. Event Subscription URL 등록</span>
<span class="c1">// 2. Slack Socket Mode</span>
<span class="c1">// 해당 서비스 환경에서는 url이 외부에 오픈되어있지 않기에 Slack Socket Mode를 사용했습니다.</span>
<span class="nd">@Slf4j</span>
<span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SlackSocketModeListener</span> <span class="kd">implements</span> <span class="nc">CommandLineRunner</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">App</span> <span class="n">slackApp</span><span class="o">;</span>

    <span class="nd">@Value</span><span class="o">(</span><span class="s">"${slack.app.token}"</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">String</span> <span class="n">slackAppToken</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">run</span><span class="o">(</span><span class="nc">String</span><span class="o">...</span> <span class="n">args</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"✅ Slack Bolt App, Socket Mode로 연결을 시작합니다..."</span><span class="o">);</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="nc">SocketModeApp</span> <span class="n">socketApp</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">SocketModeApp</span><span class="o">(</span><span class="n">slackAppToken</span><span class="o">,</span> <span class="n">slackApp</span><span class="o">);</span>
            <span class="n">socketApp</span><span class="o">.</span><span class="na">startAsync</span><span class="o">();</span> <span class="c1">// 기본 start를 할 경우 k8s 환경에서 liveness probe 및 readiness probe 'out of service' 발생</span>
            <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"✅ Slack Bolt App, Socket Mode 연결이 완료되었습니다."</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"Slack Socket Mode fail Reason :{}"</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">());</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
</pre></td><td class="rouge-code"><pre><span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">GeminiSlackThreadHandler</span> <span class="o">{</span>
    <span class="c1">// U로 시작하고 0-9, A-Z로 구성된 ID 태그를 찾는 정규 표현식</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Pattern</span> <span class="no">USER_MENTION_PATTERN</span> <span class="o">=</span> <span class="nc">Pattern</span><span class="o">.</span><span class="na">compile</span><span class="o">(</span><span class="s">"&lt;@[UWEF][A-Z0-9]+&gt;"</span><span class="o">);</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">processedEvents</span> <span class="o">=</span> <span class="nc">Collections</span><span class="o">.</span><span class="na">newSetFromMap</span><span class="o">(</span><span class="k">new</span> <span class="nc">ConcurrentHashMap</span><span class="o">&lt;&gt;());</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">SlackService</span> <span class="n">slackService</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">GeminiService</span> <span class="n">geminiService</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">GeminiSlackThreadHandler</span><span class="o">(</span><span class="nc">App</span> <span class="n">app</span><span class="o">,</span> <span class="nc">SlackService</span> <span class="n">slackService</span><span class="o">,</span> <span class="nc">GeminiService</span> <span class="n">geminiService</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">slackService</span> <span class="o">=</span> <span class="n">slackService</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">geminiService</span> <span class="o">=</span> <span class="n">geminiService</span><span class="o">;</span>
        <span class="n">registerAppMention</span><span class="o">(</span><span class="n">app</span><span class="o">);</span>
    <span class="o">}</span>

   <span class="c1">// Slack Bot 멘션 이벤트</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">registerAppMention</span><span class="o">(</span><span class="nc">App</span> <span class="n">app</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">app</span><span class="o">.</span><span class="na">event</span><span class="o">(</span><span class="nc">AppMentionEvent</span><span class="o">.</span><span class="na">class</span><span class="o">,</span> <span class="o">(</span><span class="n">req</span><span class="o">,</span> <span class="n">ctx</span><span class="o">)</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="nc">AppMentionEvent</span> <span class="n">event</span> <span class="o">=</span> <span class="n">req</span><span class="o">.</span><span class="na">getEvent</span><span class="o">();</span>
            <span class="nc">Response</span> <span class="n">ackResponse</span> <span class="o">=</span> <span class="n">ctx</span><span class="o">.</span><span class="na">ack</span><span class="o">();</span>

            <span class="nc">String</span> <span class="n">rawText</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="na">getText</span><span class="o">();</span>
            <span class="nc">String</span> <span class="n">cleanedText</span> <span class="o">=</span> <span class="no">USER_MENTION_PATTERN</span><span class="o">.</span><span class="na">matcher</span><span class="o">(</span><span class="n">rawText</span><span class="o">).</span><span class="na">replaceAll</span><span class="o">(</span><span class="s">""</span><span class="o">).</span><span class="na">trim</span><span class="o">();</span>

            <span class="nc">String</span> <span class="n">threadTs</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="na">getThreadTs</span><span class="o">()</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">event</span><span class="o">.</span><span class="na">getThreadTs</span><span class="o">()</span> <span class="o">:</span> <span class="n">event</span><span class="o">.</span><span class="na">getEventTs</span><span class="o">();</span> <span class="c1">// 스레드 내에서 멘션을 걸었다면 해당 스레드에 추가 답변 아니면 채널에 답변</span>

            <span class="nc">String</span> <span class="n">answer</span> <span class="o">=</span> <span class="n">geminiService</span><span class="o">.</span><span class="na">generateAnswer</span><span class="o">(</span><span class="n">cleanedText</span><span class="o">,</span> <span class="n">threadTs</span><span class="o">);</span> <span class="c1">// 4. Gemini 기반 오류 분석 및 답변 생성 기능 로직 재사용</span>
						
            <span class="k">if</span> <span class="o">(</span><span class="n">answer</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                <span class="k">return</span> <span class="n">ackResponse</span><span class="o">;</span>
            <span class="o">}</span>

            <span class="n">slackService</span><span class="o">.</span><span class="na">postMessageToSlack</span><span class="o">(</span><span class="n">answer</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"```java"</span><span class="o">,</span> <span class="s">"```"</span><span class="o">),</span> <span class="n">threadTs</span><span class="o">,</span> <span class="n">event</span><span class="o">.</span><span class="na">getChannel</span><span class="o">());</span> <span class="c1">// 3. Slack 오류 메시지 자동 발송 모듈 개발 로직 재사용</span>
            <span class="k">return</span> <span class="n">ackResponse</span><span class="o">;</span>
        <span class="o">});</span>
    <span class="o">}</span>
<span class="o">}</span>		
</pre></td></tr></tbody></table></code></pre></div></div>
<p><strong>2. 채팅 세션 캐싱 로직 구현 (우선은 Caffeine 캐시 사용)</strong></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
</pre></td><td class="rouge-code"><pre><span class="nd">@Service</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ChatCacheManagerService</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Client</span> <span class="n">geminiClient</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">CacheManager</span> <span class="n">cacheManager</span><span class="o">;</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">String</span> <span class="no">CACHE_NAME</span>  <span class="o">=</span> <span class="s">"chatSessionCache"</span><span class="o">;</span>

    <span class="nd">@Value</span><span class="o">(</span><span class="s">"${genai.model}"</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">String</span> <span class="n">model</span><span class="o">;</span>

   <span class="c1">// 기존에 발송한 내역이 있다면 해당 chat 세션 사용 그렇지 않다면 새로운 세션 사용 </span>
    <span class="nd">@Cacheable</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">CACHE_NAME</span><span class="o">,</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"#sessionId"</span><span class="o">)</span> <span class="c1">// 여기서 sessionId는 slack thread_ts</span>
    <span class="kd">public</span> <span class="nc">Chat</span> <span class="nf">getOrCreateChatSession</span><span class="o">(</span><span class="nc">String</span> <span class="n">sessionId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">geminiClient</span><span class="o">.</span><span class="na">chats</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="n">model</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">isChatCached</span><span class="o">(</span><span class="nc">String</span> <span class="n">sessionId</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Cache</span> <span class="n">cache</span> <span class="o">=</span> <span class="n">cacheManager</span><span class="o">.</span><span class="na">getCache</span><span class="o">(</span><span class="no">CACHE_NAME</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">cache</span> <span class="o">==</span>  <span class="kc">null</span><span class="o">)</span>  <span class="o">{</span>
            <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">cache</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">sessionId</span><span class="o">)</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>3. 추가 질문 시 기존 세션 조회 및 답변 연결 로직 적용</strong></p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
</pre></td><td class="rouge-code"><pre>   <span class="c1">// gemini api 통한 답변 생성</span>
    <span class="kd">public</span> <span class="nc">String</span> <span class="nf">generateAnswer</span><span class="o">(</span><span class="nc">String</span> <span class="n">errorMessage</span><span class="o">,</span> <span class="nc">String</span> <span class="n">sessionId</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">question</span> <span class="o">=</span>
                <span class="sh">"""
                너는 SpringBoot/Java 디버깅 전용 AI 비서다. 답변은 항상 아래 순서·형식으로, 각 문장(항목)은 100자 이내로 작성해라.
                
                1.오류에 대한 원인
                • 핵심키워드 : 각 키워드별 간단 해설 (한 줄, 100자 이내)
                
                2.오류 발생 지점
                • 파일명 : 몇번째 라인에서 오류 발생했는지 (패키지명 생략, 사용자 파일만 기재)
                
                3.해결 방안
                • 해결 방법 (오류 메시지의 정보만으로 구체적 조치 제시)
                • 해결 예시 (실제 적용 가능한 코드/명령/설정 + 시나리오: 상황→문제→해결 흐름)
                
                출력 규칙: 슬랙 스타일 사용 — 강조/헤더는 * 한 개만, 리스트는 • 사용 (반드시 지킬 것)
                절대 일반론으로 끝내지 말고, 바로 적용 가능한 실전 해법으로 마무리하라. :
                """</span> <span class="o">+</span> <span class="n">errorMessage</span><span class="o">;</span>

       <span class="c1">// 해당 세션에 맞는 key가 존재한다면 이전 질문에 대한 세션이 존재하여 다른 프롬프트 사용</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">chatCacheManagerService</span><span class="o">.</span><span class="na">isChatCached</span><span class="o">(</span><span class="n">sessionId</span><span class="o">))</span>  <span class="o">{</span>
            <span class="n">question</span> <span class="o">=</span> <span class="sh">"""
                    질문에 대한 내용을 700자 이내로 작성해라
                    출력 규칙: 슬랙 스타일 사용 — 강조/헤더는 * 한 개만, 리스트는 • 사용 (반드시 지킬 것)
                    추가로 개발 외 다른 질문을 하면 답변하지 말 것 :
                   """</span> <span class="o">+</span> <span class="n">errorMessage</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="nc">GenerateContentResponse</span> <span class="n">responseBody</span><span class="o">;</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="nc">Chat</span> <span class="n">chat</span> <span class="o">=</span> <span class="n">chatCacheManagerService</span><span class="o">.</span><span class="na">getOrCreateChatSession</span><span class="o">(</span><span class="n">sessionId</span><span class="o">);</span> <span class="c1">// 기존 세션 사용 or 새로 생성 및 캐시 저장</span>
            <span class="n">responseBody</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="na">sendMessage</span><span class="o">(</span><span class="n">question</span><span class="o">);</span>

            <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"Gemini API 응답 수신 완료"</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">ClientException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Gemini API 호출 중 오류 발생 : {}"</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">());</span>
            <span class="k">return</span> <span class="s">"오류에 대한 답변 작성이 불가능해요"</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">responseBody</span><span class="o">.</span><span class="na">text</span><span class="o">()</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="s">""</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">responseBody</span><span class="o">.</span><span class="na">text</span><span class="o">().</span><span class="na">replace</span><span class="o">(</span><span class="s">"```java"</span><span class="o">,</span> <span class="s">"```"</span><span class="o">);</span> <span class="c1">// 이후  slackService.postMessageToSlack() 통해 슬랙에 답변 발송</span>
    <span class="o">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="9-결과"><strong>9. 결과</strong></h2>
<div style="text-align: center;" align="left">
  <img src="/img/slack-ai-assistant/slack-image-5.png" width="300" height="400" alt="개선 후 오류 메시지1" style="margin-right: 10px;height:210px" align="left" />
  <img src="/img/slack-ai-assistant/slack-image-6.png" width="300" height="400" alt="개선 후 오류 메시지2" style="margin-left: 10px;height:210px" align="left" />
</div>

<div align="center">
	<sub>Solomon 앱이 이전 질문을 기억해서 간단한 질문에도 이전 질문 코드를 활용해서 답변을 해줌</sub>
</div>

<h2 id="10-마치며"><strong>10. 마치며</strong></h2>
<p>현재 서비스를 운영하면서 아쉬운 점이 있다면, MCP를 활용해 실제 코드 기반으로 오류 메시지를 분석하고 보다 정확한 답변을 제공하지 못한 점입니다. <br />
추후 여건이 마련되면 MCP를 도입하고 그 과정과 효과를 다시 블로그에서 소개할 수 있으면 좋겠습니다.</p>

<p>끝까지 읽어주셔서 진심으로 감사합니다. :)</p>]]></content><author><name>이원영</name></author><category term="Backend/System" /><category term="AI" /><category term="K8S" /><category term="DevOps" /><category term="Monitoring" /><category term="Heimdall" /><category term="Opentelemetry" /><category term="Gemini" /><category term="Slack" /><category term="SlackBot" /><summary type="html"><![CDATA[Spring Boot 기반으로 AI를 활용한 오류 분석 효율화를 개선한 사례]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://saramin.github.io/img/slack-ai-assistant/thumbnail.png" /><media:content medium="image" url="https://saramin.github.io/img/slack-ai-assistant/thumbnail.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">챗봇 서비스 구축기</title><link href="https://saramin.github.io/2025-09-08-link/" rel="alternate" type="text/html" title="챗봇 서비스 구축기" /><published>2025-09-08T00:00:00+09:00</published><updated>2025-09-08T00:00:00+09:00</updated><id>https://saramin.github.io/link</id><content type="html" xml:base="https://saramin.github.io/2025-09-08-link/"><![CDATA[<h2 id="1-들어가며"><strong>1. 들어가며</strong></h2>

<p>기존 챗봇 서비스는 정해진 규칙에 따라 답변하는 ‘룰 기반(Rule-based)’ 방식이 대부분이었습니다.
그러던 중 OpenAI의 ChatGPT가 등장하며 큰 변화의 바람을 몰고 왔습니다.</p>

<p>저희는 이 기술을 활용해 상담사를 통해서만 가능했던 문의 대응을 자동화할 수 있지 않을까 하고 생각했습니다.
저희 목표는 수년간 축적된 사람인의 도움말, 채용 공고, 기업 정보와 같은 내부 데이터를 LLM과 결합하여 <u>사용자의 1차적인 문의를 해결</u>하는 것이었습니다.</p>

<p>이번 글에서는 위의 목표를 달성하기 위해 구현한 챗봇 서비스 개발 내용을 공유하고자 합니다.</p>

<h2 id="2-챗봇-서비스-전체-아키텍처"><strong>2. 챗봇 서비스 전체 아키텍처</strong></h2>

<p>저희가 구축한 챗봇 서비스는 아래와 같이 구성되어 있으며, 사용자의 질문에 답변하기까지 크게 8단계를 거칩니다.</p>

<p><img src="/img/link/image.png" alt="/img/link/image.png" /></p>

<ol>
  <li><u>사용자 질문</u>이 채팅 시스템을 통해 Bot 시스템에 전달됩니다 (①).</li>
  <li>Bot 시스템은 먼저 Function Calling을 통해 OpenAI에 <u>질문의 의도</u> 파악과 컨텐츠 조회를 위한 필요한 <u>검색 파라미터를 추출</u>합니다 (②).</li>
  <li>추출된 정보를 바탕으로 <u>검색 서버</u>(bot 시스템 내부에 있음)를 연동하여 HTTP API나 Vector DB에서 질문과 관련된 <u>컨텐츠들을 조회</u>합니다 (③, ④).</li>
  <li>조회된 콘텐츠와 원본 질문, 그리고 미리 정의된 프롬프트를 조합하여 다시 OpenAI에 전달하여 <u>최종 답변을 생성</u>하도록 요청합니다 (⑤, ⑥).</li>
  <li>생성된 답변은 사용자에게 전달되고(⑦), 전체 대화 내용은 이력으로 저장됩니다(⑧).</li>
</ol>

<h4 id="2-1-function-calling"><strong>2-1. Function Calling</strong></h4>

<p>아키텍에서 핵심 적인 기술인 ‘Function Calling’에 대해 잠시 추가적으로 말씀드리겠습니다.</p>

<p>Function Calling은 LLM이 대화의 맥락을 이해하고, 스스로 판단하여 미리 정의된 <u>외부 함수(Function)를 호출하도록 요청</u>하는 기능입니다. 
이를 통해 LLM은 <u>학습 데이터에 없는 실시간 정보나 내부 시스템 데이터에 접근</u>할 수 있게 됩니다.</p>

<p>개발자가 호출 가능한 함수 목록과 각 함수의 역할, 필요한 파라미터를 JSON 스키마 형태로 LLM에게 제공하면, 
LLM은 사용자 질문에 가장 적합한 함수가 무엇인지 판단하고 해당 함수를 호출하는데 필요한 파라미터(argument)를 담은 JSON 객체를 생성하여 반환합니다.</p>

<p>Function Calling은 아래의 흐름으로 이루어 지게 됩니다.</p>

<p><img src="/img/link/image%201.png" alt="/img/link/image.png" /></p>
<center>▶Function Calling 흐름도  - 출처 : <a href="https://platform.openai.com/docs/guides/function-calling">OpenAI Function Calling 가이드</a> ◀</center>

<ol>
  <li>
    <p>호출할 수 있는 도구로 모델에게 요청을 보냅니다.</p>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</pre></td><td class="rouge-code"><pre> <span class="n">tools</span> <span class="o">=</span> <span class="p">[{</span>
     <span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">function</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">get_weather</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">description</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Get current temperature for provided coordinates in celsius.</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">parameters</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span>
             <span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">object</span><span class="sh">"</span><span class="p">,</span>
             <span class="sh">"</span><span class="s">properties</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span>
                     <span class="sh">"</span><span class="s">latitude</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span><span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">number</span><span class="sh">"</span><span class="p">},</span>
                     <span class="sh">"</span><span class="s">longitude</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span><span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">number</span><span class="sh">"</span><span class="p">}</span>
             <span class="p">},</span>
             <span class="sh">"</span><span class="s">required</span><span class="sh">"</span><span class="p">:</span> <span class="p">[</span><span class="sh">"</span><span class="s">latitude</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">longitude</span><span class="sh">"</span><span class="p">],</span>
             <span class="sh">"</span><span class="s">additionalProperties</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span>
     <span class="p">},</span>
     <span class="sh">"</span><span class="s">strict</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span>
 <span class="p">}]</span>

 <span class="n">input_messages</span> <span class="o">=</span> <span class="p">[{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">What</span><span class="sh">'</span><span class="s">s the weather like in Paris today?</span><span class="sh">"</span><span class="p">}]</span>

 <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">responses</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">model</span><span class="o">=</span><span class="sh">"</span><span class="s">gpt-4.1</span><span class="sh">"</span><span class="p">,</span> <span class="nb">input</span><span class="o">=</span><span class="n">input_messages</span><span class="p">,</span> <span class="n">tools</span><span class="o">=</span><span class="n">tools</span><span class="p">,)</span>

</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>
    <p>모델로부터 도구 호출을(type: funcation call) 받습니다.</p>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre> <span class="p">[{</span>
     <span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">function_call</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">fc_12345xyz</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">call_id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">call_12345xyz</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">get_weather</span><span class="sh">"</span><span class="p">,</span>
     <span class="sh">"</span><span class="s">arguments</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">{</span><span class="se">\"</span><span class="s">latitude</span><span class="se">\"</span><span class="s">:48.8566,</span><span class="se">\"</span><span class="s">longitude</span><span class="se">\"</span><span class="s">:2.3522}</span><span class="sh">"</span>
 <span class="p">}]</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>
    <p>도구 호출의 데이터를 이용하여 애플리케이션 측에서 코드 실행(실제 함수 호출) 합니다.</p>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre> <span class="n">tool_call</span> <span class="o">=</span> <span class="n">response</span><span class="p">.</span><span class="n">output</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
 <span class="n">args</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">tool_call</span><span class="p">.</span><span class="n">arguments</span><span class="p">)</span>

 <span class="n">result</span> <span class="o">=</span> <span class="nf">get_weather</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="sh">"</span><span class="s">latitude</span><span class="sh">"</span><span class="p">],</span> <span class="n">args</span><span class="p">[</span><span class="sh">"</span><span class="s">longitude</span><span class="sh">"</span><span class="p">])</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>
    <p>실행한 코드 결과(함수 반환값)를 사용하여 모델에 두 번째 요청을 보냅니다.</p>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre> <span class="n">input_messages</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">tool_call</span><span class="p">)</span>  <span class="c1"># append model's function call message
</span> <span class="c1"># append result message
</span> <span class="n">input_messages</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span>                               
         <span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">function_call_output</span><span class="sh">"</span><span class="p">,</span>
         <span class="sh">"</span><span class="s">call_id</span><span class="sh">"</span><span class="p">:</span> <span class="n">tool_call</span><span class="p">.</span><span class="n">call_id</span><span class="p">,</span>
         <span class="sh">"</span><span class="s">output</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
 <span class="p">})</span>

 <span class="n">response_2</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">responses</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">model</span><span class="o">=</span><span class="sh">"</span><span class="s">gpt-4.1</span><span class="sh">"</span><span class="p">,</span> <span class="nb">input</span><span class="o">=</span><span class="n">input_messages</span><span class="p">,</span><span class="n">tools</span><span class="o">=</span><span class="n">tools</span><span class="p">,)</span>
 <span class="nf">print</span><span class="p">(</span><span class="n">response_2</span><span class="p">.</span><span class="n">output_text</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>
    <p>모델로부터 최종 응답을 받습니다.</p>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre> <span class="sh">"</span><span class="s">The current temperature in Paris is 14°C (57.2°F).</span><span class="sh">"</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
</ol>

<h2 id="3-rag-기반-답변-생성기-구현"><strong>3. RAG 기반 답변 생성기 구현</strong></h2>
<h4 id="31-콘텐츠-특성에-맞는-rag-파이프라인-설계"><strong>3.1. 콘텐츠 특성에 맞는 RAG 파이프라인 설계</strong></h4>

<p>LLM은 매우 강력한 도구지만, 비즈니스에 바로 적용하기에는 몇 가지 한계를 가지고 있습니다.</p>

<p>흔히 알려진 문제점으로는 아래와 같은 것들이 있습니다.</p>

<ul>
  <li><strong>환각(Hallucination):</strong> 근거 없는 정보를 사실처럼 생성합니다.</li>
  <li><strong>정보의 시의성:</strong> 모델의 학습 시점 데이터에만 의존하여 최신 정보를 반영하지 못합니다.</li>
  <li><strong>출처의 신뢰성:</strong> 답변이 어떤 정보에 기반했는지 확인할 수 없습니다.</li>
</ul>

<p>‘사람인 온라인 상담사’를 목표로 하는 저희는 이러한 단점들을 없애고, 
도메인에 특화된 정확하고 최신화된 정보를 제공하는 것이 무엇보다 중요했습니다.</p>

<p>이 문제 해결을 위해 <strong>검색 증강 생성(Retrieval-Augmented Generation, RAG)</strong> 기술을 도입했습니다.</p>

<p>RAG는 LLM이 답변을 생성하기 전, 신뢰할 수 있는 외부 데이터베이스에서 관련 정보를 먼저 검색하고 참조하는 방식입니다.</p>

<p><img src="/img/link/image_aws.png" alt="/img/link/image_aws.png" /></p>
<center>▶RAG 개념도 - 출처 : <a href="https://aws.amazon.com/what-is/retrieval-augmented-generation/">AWS 자료</a> ◀</center>

<p>RAG를 도입함으로써 저희는 다음과 같은 이점을 얻을 수 있었습니다.</p>

<ul>
  <li><strong>비용 효율적인 구현:</strong> LLM 모델 자체를 재 학습 시킬 필요 없이 RAG를 위한 데이터베이스만 최신으로 유지하면 되기에 비용 효율적입니다.</li>
  <li><strong>최신 정보 반영:</strong> 특정 시점 까지의 데이터로 학습된 LLM과 달리 RAG는 실시간으로 업데이트되는 최신 데이터까지 답변에 활용할 수 있습니다.</li>
  <li><strong>사용자 신뢰 강화:</strong> 답변의 근거가 된 출처를 함께 제시할 수 있어 사용자가 정보를 신뢰하고 직접 확인할 수 있습니다.  (저희는 서비스 내부에서 사용하는 챗봇으로 별도로 출처 정보는 제공하지 않습니다.)</li>
  <li><strong>개발자의 통제권 확보:</strong> 정보 소스를 직접 제어하기 때문에 잘못된 정보를 수정하거나 새로운 요구사항을 반영하기 쉽습니다.</li>
</ul>

<p><em><strong>✅ 1단계: VectorDB를 활용한 기본 RAG 구현</strong></em></p>

<p>첫 번째 접근으로 저희는 일반적인 RAG 방식과 같이 <strong>VectorDB를 구축</strong>하여 정보를 검색하는 방식을 채택했습니다.</p>

<p>사람인 도움말, 내부 FAQ, 상품 정보 등의 정제된 데이터를 VectorDB에 저장하고, 변경 사항은 실시간으로 동기화 되도록 구성했습니다.</p>

<p><img src="/img/link/image%203.png" alt="/img/link/image.png" /><br /></p>
<center>▶VectorDB를 활용한 RAG◀</center>

<p><img src="/img/link/image%204.png" alt="/img/link/image.png" /><br /></p>
<center>▶컨텐츠에 대한 백터화 및 질문 관련 컨텐츠 검색 방법◀</center>

<p>사람인이 보유한 컨텐츠를 분석 했을 때 컨텐츠의 길이가 길지 않다는 점에 착안하여, 문서를 단순히 분할(split)하는 대신 <u>'제목'과 '내용'을 한 쌍</u>으로 다루기로 했습니다.</p>

<p>그리고 제목과 내용에 대해 각각 별도의 임베딩을 생성하여 저장했습니다.</p>

<p>PoC 과정에서 사용자의 질문 의도와 가장 관련성이 높은 문서를 찾을 때 토큰이 많은 ‘내용’ 본문보다 핵심 의미가 압축된 ‘제목’이 있는 경우  정확도가 더 높다는 사실을 확인 했기 때문입니다.</p>

<p>실제로 검색 시 제목과 내용의 유사도 가중치를 5:5로 동일하게 설정했을 때, 500여 개의 테스트 질문에 대해 정답 문서를 찾아내는 정확도가 87%로 가장 높게 나타났습니다.</p>

<p>최근에는 사람인 일부 도움말 콘텐츠가 Notion으로 관리되고 있는 상황을 반영하여, Notion API를 통해 해당 컨텐츠를 수집하고 벡터화하는 기능을 확장 적용하였습니다. 
이를 통해 RAG를 위한 데이터를 좀 더 다양한 채널에서 수집 할 수 있도록 확장 하였습니다.</p>

<p><em><strong>✅ 2단계: 실시간 API 연동으로 RAG 확장</strong></em></p>

<p>VectorDB 방식은 매우 효과적이었지만, 사람인의 모든 컨텐츠를 VectorDB에 담는 것은 시간과 비용 측면에서 현실적인 한계가 있었습니다.</p>

<p>그리고 이미 잘 구축된 컨텐츠 제공 API가 있다면 활용하는 것이 더 좋은 방안이라고 생각했습니다.</p>

<p>그래서 저희는 기존에 사용하던 <strong>내부 컨텐츠 검색 API를 RAG 파이프라인에 통합</strong>하는 두 번째 단계를 진행했습니다.</p>

<p>이는 검증된 내부 리소스를 재활용하는 동시에, 별도의 데이터 적재 과정이 필요 없기 때문에 전체 아키텍처를 단순화하는 이점이 있었습니다.</p>

<p><img src="/img/link/image%205.png" alt="/img/link/image.png" /><br /></p>
<center>▶HTTP API를 활용한 RAG◀</center>

<p>컨텐츠 검색 API를 동적으로 호출하기 위해 LLM의 <u>'Function Calling' 기능을 활용</u>했습니다. 
사용자의 질문을 LLM이 분석하여 어떤 API를 호출하고 어떤 파라미터를 넘겨야 할지 정의 하도록 하였습니다.</p>

<p>또한, API로부터 받은 응답 데이터를 정해진 형식으로 추출하기 위해 <u>'추출 스키마(Extraction Schema)'를 정의</u>하고,
이를 통해 API에서 LLM에 전달 해야 할 데이터를 필터링(비공개 정보 필터링, 텍스트 정보 추출 등)하고 LLM에 전달하는 정보를 제어 할 수 있도록 했습니다.</p>

<h4 id="32-프롬프트-엔지니어링을-통한-답변-품질-관리"><strong>3.2. 프롬프트 엔지니어링을 통한 답변 품질 관리</strong></h4>

<p>프롬프트 엔지니어링에는 <strong>“쓰레기를 넣으면 쓰레기가 나온다(GIGO)”</strong>는 유명한 말이 있습니다.</p>

<p>저희는 이 말이 챗봇의 답변 품질과 직결되는 가장 중요한 부분이라고 생각했습니다.</p>

<p>저희 챗봇은 범용적인 대화가 아닌, 명확한 원칙을 따라야 했습니다.</p>

<ol>
  <li>사람인 <u>컨텐츠에 기반</u>해 정확히 답변</li>
  <li>정보가 없으면 <u>임의로 답변하면 안됨</u></li>
  <li>서비스<u> 범위를 벗어난 질문에는 답변하면 안됨</u></li>
</ol>

<p>이러한 원칙을 LLM에게 명확히 전달하기 위해 저희는 몇 가지 규칙을 적용했습니다.</p>

<ul>
  <li>요청을 짧고 명료하게 작성</li>
  <li>원하는 결과물의 예시를 전달</li>
  <li>구역을 나누어 설명</li>
  <li>해야 하는 작업에 대해서 순서대로 정리</li>
</ul>

<p><img src="/img/link/image%206.png" alt="/img/link/image.png" /></p>

<p>물론 서비스 오픈 초기 부터 프롬프트가 완벽한 것은 아니었습니다.</p>

<p>지속적인 모니터링을 통해 유사 서비스에 대한 답변을 막거나, 불필요한 정보는 노출하지 않도록 하는 등의 안전장치를 프롬프트에 추가하며 완성도를 높여갔습니다.</p>

<p>프롬프트 작성을 더 깊이 이해하는 데 도움이 될 만한 자료를 추가로 공유합니다.</p>
<blockquote>
  <p>▶ 참고 : 구글의 Prompt 작성 백서 -  <a href="https://www.youtube.com/watch?v=EZqY_mnHfTI">Prompt Engineering에 대한 모든 기법 소개</a></p>

  <p><strong>1. 구체적인 예시를 포함하세요 (Few-shot Prompting)</strong> : 
3~5개 정도의 답변 예시를 프롬프트에 포함하면, LLM이 맥락을 더 잘 파악하고 유사한 품질의 결과물을 생성합니다.</p>

  <p><strong>2. 간결하게 작성하세요</strong> : 
지시는 복잡하지 않고 명료해야 합니다. 너무 많은 내용을 한 번에 전달하면 LLM이 핵심을 놓칠 수 있습니다.</p>

  <p><strong>3. 명령형 어조를 사용하세요</strong> : 
‘~해야 돼’와 같은 서술형보다 ‘~해줘’ 형태의 직접적인 명령이 더 효과적입니다. ‘분석해’, ‘분류해’, ‘비교해’ 와 같이 명확한 동사를 사용하는 것이 좋습니다.</p>

  <p><strong>4. 출력(Output) 형식을 명시하세요</strong> : 
JSON, Markdown, 글머리 기호 등 원하는 결과물의 형식을 구체적으로 지정하면 그대로 출력해 줄 확률이 높아집니다.</p>

  <p><strong>5. ‘해야 할 일’에 집중하세요</strong> : 
‘~ 하지 말아’라는 부정적인 제약보다는 ‘~해야 해’라는 긍정적인 지시가 더 좋은 결과를 만듭니다.</p>
</blockquote>

<p>글을 쓰면서 이전에 작성된 저희 프롬프트도 다시 한번 점검 해야 하겠다는 생각이 듭니다. 😅</p>

<h4 id="33-다중-제목을-활용한-검색-개선"><strong>3.3. 다중 제목을 활용한 검색 개선</strong></h4>

<p>컨텐츠의 ‘제목’을 포함하여 검색하는 방식은 RAG 성능 확보에 도움이 되었습니다.</p>

<p>하지만 운영 과정에서 사용자들이 같은 의도를 다른 단어로 표현할 때 컨텐츠를 잘못 검색하는 문제들을 발견했습니다.</p>

<p>예를 들어, 시스템에는 <em>‘이력서 등록 방법’</em> 이라는 도움말이 있지만, 많은 사용자는 <em>‘이력서 작성 방법’</em> 이라고 질문했습니다.</p>

<p>이 미묘한 차이로 인해 챗봇은 적절한 도움말을 찾지 못하고(이력서와 관련된 도움말들이 많아서 ‘등록 방법’ 컨텐츠가 후순위로 밀림) 답변을 하지 못했습니다.</p>

<p>이 문제를 해결하기 위해 하나의 콘텐츠가 여러 개의 제목을 가질 수 있도록 하는 ‘다중 제목(Multi-subject)’ 기능을 추가 하였습나다.</p>

<p>사용자의 질문에 정답 컨텐츠를 찾을 수 있는 방법을 여러 개 만들어 주는 것입니다.</p>

<p><img src="/img/link/image%207.png" alt="/img/link/image.png" /></p>
<center>▶ 다중 제목 설정을 위한 Admin 화면 ◀</center>

<p>구체적으로는 아래 세 가지 유형의 제목을 등록 하여 사용 할 수 있습니다.</p>

<ul>
  <li><strong>제목:</strong> 컨텐츠의 원래 제목입니다.</li>
  <li><strong>유사 제목:</strong> 운영자가 직접 ‘이력서 작성 방법’과 같은 <u>유사 제목을 수동으로 추가</u>합니다.</li>
  <li><strong>요약 제목:</strong> LLM이 콘텐츠 본문을 분석하여 핵심 내용을 담은 새로운 <u>제목을 자동으로 생성</u>합니다.</li>
</ul>

<p>이 방식을 통해 검색의 유연성과 정확도를 높일 수 있었습니다.</p>

<h4 id="34-langgraph로-복잡한-rag-파이프라인-제어하기"><strong>3.4. LangGraph로 복잡한 RAG 파이프라인 제어하기</strong></h4>
<p>많은 LLM 활용 서비스들이 사용하고 있고, 저희도 서비스를 구현하면서 사용했던 프레임워크인 LangGraph에 대해서 말씀 드리겠습니다.</p>

<p>▶ LangGraph란?</p>

<p>LangGraph는 복잡한 LLM 워크플로우를 <strong>‘그래프(Graph)’</strong> 형태로 구성하여, <strong>상태를 기반으로 작업을 제어</strong>할 수 있도록 해주는 LangChain의 프레임워크 입니다.</p>

<p>LangGraph는 크게 3가지 요소로 구성됩니다.</p>

<ul>
  <li>
    <p><strong>State (상태)</strong></p>

    <p>그래프의 전체 생명 주기 동안 <u>공유되는 데이터</u> 입니다.</p>

    <p>모든 작업(Node)의 입출력은 이 State로 이루어집니다.</p>
  </li>
  <li>
    <p><strong>Nodes (노드)</strong></p>

    <p><u>실제 로직을 처리 하는 함수</u>입니다.</p>

    <p>하나의 node는 현재 State를 입력으로 받아 로직 처리 후, 그 결과를 다시 State에 반영하여 반환하게 됩니다.</p>
  </li>
  <li>
    <p><strong>Edges (엣지)</strong></p>

    <p>node간 연결을 통해 <u>흐름을 제어</u>하는 방법 입니다.</p>

    <p>특정 node의 작업이 끝난 후, Graph에 정의된 edge를 기반으로 다음에 어떤 node를 호출할지 결정됩니다.
  edge를 이용하여 단순 연결 및 조건부 분기, 순환(Cycle) 구조를 만들 수 있습니다.</p>
  </li>
</ul>

<p>▶ 도입 계기</p>

<p>저희는 초기의 RAG 파이프라인을 ‘질문 → Function Call → 컨텐츠 검색 → 답변’으로 이어지는 <u>순차적인 구조로 개발</u> 하였습니다.</p>

<p>하지만 이 방식은 고도화를 진행 하면서 여러가지 한계에 부딪혔습니다.</p>

<ul>
  <li>
    <p><strong>복잡한 질문 처리 불가</strong></p>

    <p>하나의 질문에 여러 컨텐츠(예: 공고와 기업 정보)가 필요할 때, 반복적인 Function call과 컨텐츠 검색을 처리 하기 어려웠습니다.</p>
  </li>
  <li>
    <p><strong>병렬 작업의 한계</strong></p>

    <p>답변을 생성하면서 동시에 다른 작업(ex.질문을 분류하고 연관된 링크 정보 추출)을 처리 하는 병렬적인 구현이 복잡 했습니다.</p>
  </li>
  <li>
    <p><strong>오류 처리의 복잡성</strong></p>

    <p>로직 처리를 위한 각 단계들이 분리 되어 있어 LLM 연동 실패시 재시도를 위한 로직 처리가 복잡했습니다.</p>
  </li>
  <li>
    <p><strong>코드의 중복 발생</strong></p>

    <p>응답 타입(일반, 스트림)에 따라 유사한 로직을 가진 코드가 중복으로 생성되었습니다.</p>
  </li>
</ul>

<p>이러한 문제들을 해결하기 위해 상태 기반의 분기 처리 및 순환 처리가 가능한 LangGraph를 이용하였습니다.</p>

<p>▶ 적용 사례 및 도입 효과</p>

<p><img src="/img/link/image_langgraph.png" alt="/img/link/image_langgraph.png" /></p>
<center>▶챗봇 서비스를 위한 Graph 시각화 ◀</center>

<p>LangGraph를 도입하여 RAG 파이프라인을 더욱 유연하게 만들 수 있었습니다.</p>

<p>주요 적용 사례와 효과는 다음과 같습니다.</p>

<ul>
  <li>
    <p><strong>조건부 분기를 통한 효율적인 흐름 제어</strong></p>

    <p>Conditional Edge를 활용하여 유효하지 않은 질문(짧은 질문, 비속어/욕설 등)은 LLM 호출 없이 고정 답변으로 처리하고,
  의도가 있는 질문만 LLM을 연동하도록 흐름을 효과적으로 분기 처리 했습니다.</p>
  </li>
  <li>
    <p><strong>기능별 노드화를 통한 모듈성 확보</strong></p>

    <p>Function Calling, 프롬프트 조회, LLM 호출 등 유사한 기능들을 Node로 모듈화 하여 코드 재사용성을 높이고,
  필요에 따라 반복적인 처리가 가능 하도록 순환 구조를 만들었습니다.</p>
  </li>
  <li>
    <p><strong>Subgraph를 활용한 복잡도 관리</strong></p>

    <p>전체 워크플로우 내부에 LLM을 연동을 통한 답변 부분을 독립적인 Subgraph로 생성하여 전체 그래프의 복잡도를 낮추었습니다.</p>
  </li>
  <li>
    <p><strong>손쉬운 병렬 처리 구현</strong></p>

    <p>‘답변을 생성 하기 위한 프로세스’와 ‘질문을 분류 하여 연관된 링크 정보를 탐색’하는 프로세스를 Graph의 Map Reduce 방식을 기반으로 손쉽게 병렬로 처리 할 수 있도록 하였습니다.</p>
  </li>
</ul>

<h2 id="4-안정적인-운영과-확장을-위한-시스템-설계"><strong>4. 안정적인 운영과 확장을 위한 시스템 설계</strong></h2>
<h4 id="41-독립적인-bot-관리-시스템-구축"><strong>4.1. 독립적인 Bot 관리 시스템 구축</strong></h4>

<p>저희 챗봇 서비스는 사용자와의 상호작용을 담당하는 ‘채팅 시스템’과 질문에 대해서 LLM을 연동해 답변 하는 ‘Bot 시스템’으로 분리하여 설계하였습니다.</p>

<p>특히 Bot 시스템은 <u>여러 서비스의 다양한 Bot을 독립적으로 생성하고 운영</u>할 수 있는 ‘멀티테넌시(Multi-tenancy)’ 구조를 채택했습니다.</p>

<p>Admin을 통해 각 Bot이 참조할 데이터 소스(검색 서버), 데이터 소스별 프롬프트, LLM 모델 정보까지 모든 것을 동적으로 설정 할 수 있습니다.</p>

<p>현재 ‘사람인 Bot’, ‘노무 상담 Bot’, ‘비긴즈 서비스 Bot’, ‘Komate Bot’ 등 4종류의 Bot 을 서비스 하고 있으며, Rest API를 통해 Biz Logic에서도 Bot 시스템을 연동 가능 합니다.</p>

<p><img src="/img/link/image%209.png" alt="/img/link/image.png" /></p>
<center>▶ Admin을 통한 Multi Bot 서비스 ◀</center>

<table>
  <thead>
    <tr>
      <th><strong>Bot</strong></th>
      <th><strong>컨텐츠 연동 타입</strong></th>
      <th><strong>검색 가능 컨텐츠 항목</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>사람인 Bot</td>
      <td>Vector DB 타입</td>
      <td>• 사람인 도움말 컨텐츠<br />• 상품 정보 컨텐츠</td>
    </tr>
    <tr>
      <td> </td>
      <td>HTTP  타입</td>
      <td>• 공고 컨텐츠<br />• 추천 공고 컨텐츠<br />• 기업정보 컨텐츠<br />• 모바일 메뉴 컨텐츠</td>
    </tr>
    <tr>
      <td>Komate Bot</td>
      <td>Vector DB 타입</td>
      <td>• Komate 도움말 컨텐츠</td>
    </tr>
    <tr>
      <td>노무 상담 Bot</td>
      <td>HTTP  타입</td>
      <td>• 인사/노무 정보 컨텐츠</td>
    </tr>
    <tr>
      <td>비긴즈 Bot</td>
      <td>Vector DB 타입</td>
      <td>• Begins FAQ 컨텐츠</td>
    </tr>
  </tbody>
</table>

<p>더 나아가, 하나의 Bot이 여러 데이터 소스(검색 서버)를 동시에 참조하도록 설정할 수도 있습니다.</p>

<p>이 경우, 저희는 LLM의 <u>Function Calling을 활용해 사용자의 질문 의도를 파악</u>하고, 가장 적합한 데이터 소스(검색 서버)를 스스로 판단하여 검색하도록 구현했습니다.</p>

<p><img src="/img/link/image%2010.png" alt="/img/link/image.png" /></p>
<center>▶ Function Calling을 이용한 데이터 소스 선정 ◀</center>

<p>이러한 아키텍처 덕분에 저희는 높은 확장성과 유연성을 확보할 수 있었습니다.</p>

<p>새로운 서비스에 챗봇이 필요할 경우, 이제는 별도의 개발 리소스 투입 없이 <u>Admin 설정 만으로 신규 Bot을 생성</u>하고 즉시 서비스에 투입할 수 있습니다.</p>

<h4 id="42-모니터링을-통한-데이터-선순환-구조-마련"><strong>4.2. 모니터링을 통한 데이터 선순환 구조 마련</strong></h4>

<p>챗봇 서비스의 지속적인 개선을 위해 저희는 모든 질문/답변 데이터를 이력으로 저장하여 관리 하고 있습니다.</p>

<p>사용자의 질문과 챗봇의 답변은 물론, 참조한 콘텐츠, 사용된 프롬프트, 처리 소요 시간 등 다양한 메타 정보까지 함께 저장하여 분석의 기반으로 사용하고 있습니다.</p>

<p>이렇게 수집된 데이터는 서비스의 부하 상태나 답변 품질을 측정하는 기술적인 모니터링 뿐만 아니라, 
사용자의 질문 유형과 주요 관심사를 파악하는 데에도 유용하게 활용됩니다.</p>

<p><img src="/img/link/image%2011.png" alt="/img/link/image.png" /></p>
<center>▶ 모니터링을 통한 컨텐츠 개선 ◀</center>

<p>특히 저희는 이 데이터를 ‘고객 지원팀’과 함께 분석합니다.</p>

<p>고객 지원팀은 “답변에 활용된 콘텐츠가 부족하지 않은지?”, “사용자들이 가장 궁금해하는 내용은 무엇인지?” 와 같은 질문을 던지며 사용자의 진짜 의도를 파악하고 콘텐츠의 빈틈을 찾아냅니다.</p>

<p>분석을 통해 얻은 인사이트를 바탕으로 기존 도움말 <u>콘텐츠를 보강하거나 신규로 등록</u>하여 서비스 오픈 이후 <strong>약 20%의 콘텐츠를 개선</strong>하는 효과를 얻었습니다.</p>

<h2 id="5-마치며">5. <strong>마치며…</strong></h2>
<p>챗봇 프로젝트를 시작할 때는 ‘LLM이 모든 것을 알아서 해결해 줄 것’이라는 막연한 기대감이 있었습니다.</p>

<p>하지만 실제 개발 과정은 환각(Hallucination) 현상을 제어하고, 한정된 데이터에서 가장 정확한 정보를 찾아내기 위한 현실적인 과제들의 연속이었습니다.</p>

<p>결국 LLM이라는 강력한 기술을 그대로 사용하기보다, <strong>RAG 아키텍처</strong>와 컨텐츠에 맞는 <strong>프롬프트</strong>를 통해 서비스에 맞는 데이터를 제공하고 기준을 세우는 과정이 무엇보다 중요하다고 생각하게 되었습니다.</p>

<p>현재 서비스에 머무르지 않고 앞으로도 팀원들과 함께 사용자들이 더 잘 사용할 수 있도록 고도화 해 나갈 계획입니다.</p>

<p>LLM 기반 서비스를 처음 시작하는 분들께 작은 도움이라도 되기를 바랍니다.</p>

<p>읽어주셔서 감사합니다.</p>]]></content><author><name>임재호</name></author><category term="Backend/System" /><category term="챗봇" /><category term="LLM" /><category term="GPT" /><summary type="html"><![CDATA[사람인 데이터를 활용하여 구축한 LLM 기반의 챗봇 서비스의 개발 내용을 공유 합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://saramin.github.io/img/link/Gemini_Generated_Image_90ruz890ruz890ru.png" /><media:content medium="image" url="https://saramin.github.io/img/link/Gemini_Generated_Image_90ruz890ruz890ru.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">표준을 통한 마이크로 서비스의 Observability 구축기</title><link href="https://saramin.github.io/2025-08-01-otel/" rel="alternate" type="text/html" title="표준을 통한 마이크로 서비스의 Observability 구축기" /><published>2025-08-01T00:00:00+09:00</published><updated>2025-09-04T10:45:55+09:00</updated><id>https://saramin.github.io/otel</id><content type="html" xml:base="https://saramin.github.io/2025-08-01-otel/"><![CDATA[<p>저희는 Kubernetes 환경에서 동작하는 서비스의 증가와 최근 k8s 환경에서 대규모 서비스 오픈을 진행 했으며, 이에 대비하여 어떻게 마이크로 서비스에서 가시성을 확보할지, 또 문제가 생겼을 경우 어떻게 쉽게 문제를 확인하고 추적 할지에 대해 고민하게 되었습니다. <br /><br />
그 결과, <b>OpenTelemetry와 SigNoz 조합</b>을 활용한 Observability 환경을 구축하게 되었으며, 그 경험을 공유하고자 합니다.</p>

<p><br /></p>
<h1 id="시작하게-된-배경">시작하게 된 배경</h1>
<p><a href="/img/opentelemetry/img00.png"><img src="/img/opentelemetry/img00.png" /></a><br />
<br />
<b>기존 모니터링 환경 문제점</b> ❗</p>
<ul>
  <li>모니터링 툴이 분산되어 있어 유지보수가 어렵고 관리 포인트가 많음</li>
  <li>문제 추적이 복잡하고 시간이 많이 소요됨 ⏳</li>
  <li>각기 다른 툴을 별도로 학습하고 운영해야 하는 부담이 있음
<br /></li>
</ul>

<p>대부분의 회사가 위와 같은 모습일 거라고 생각합니다. <br />
이런 환경이 점점 많아지면 유지보수가 어려워지고 관리 포인트가 증가할 뿐만아니라 복잡성까지 가중 되어 퇴근하기가 더욱 어려워집니다.<br />
물론 각자 특화된 모습이 있겠지만 모두를 관리하는 입장과 이용하는 입장 양면에서, 좀 더 편하고 쉽게 문제 추적 및 모니터링 할 수 있었으면 좋겠다에서 시작했습니다.
<br />
<br /></p>
<h1 id="why-opentelemetry">Why OpenTelemetry?🤔</h1>

<p><a href="/img/opentelemetry/img0.png"><img src="/img/opentelemetry/img0.png" /></a><br />
<strong>OpenTelemetry의 Architecture</strong><br />
<br />
Observability의 핵심인 Metric, Log, Trace를 통합 해주는데 이것은 프로세스와 아키텍쳐를 단순화 해주면서 셋 간의 상호연관 분석을 가능하게 해줍니다.</p>

<p><br />
<a href="/img/opentelemetry/img2.png"><img src="/img/opentelemetry/img2.png" /></a><br /></p>

<p><br />
<b>Telemetry 측정과 수집에 대해 표준화로 여러 Visualize Backend 도구들과 통합</b>될 수 있도록 합니다. 그리고 여전히 OpenTelemetry는 CNCF 프로젝트 중 Kubernetes 다음으로 2위로 달리고 있습니다.<br />
이렇게 표준화 및 다양한 관찰 도구 사용이 가능해짐으로 인해 <b>Vendor Lock-In 이슈가 없다</b>는 점과 <b>저렴한 운영 비용</b> 또한 장점이 됩니다. <br />
실제로 DataDog, NewRelic, Dynatrace 등 운영 비용은 상당하기 때문에 비용 압력으로 관찰 대상을 포기하기 쉽습니다.<br /></p>

<p><br />
OpenTelemetry가 분산 추적을 가능하게 하는 개념은 바로 Context Propagation을 통해 진행 됩니다. 해당 기능을 통해 Signal들이 어디에서 생성되었는지에 관계 없이 서로 연관지을 수 있습니다.<br />
이는 Tracing에만 국한되지 않고 시스템 전반에 걸쳐 서비스 간의 인과관계를 나타내는 정보를 구성 할 수 있게 해줍니다. Context Propagation을 이해 하려면 두 가지 핵심 Context와 Propagation 개념을 알아야 합니다.<br /></p>

<p><strong>Context</strong><br />
컨텍스트는 한 신호를 다른 신호와 연관 지을 수 있도록, 송신 서비스와 수신 서비스(또는 실행 단위)에 필요한 정보를 담고 있는 객체입니다.<br />
예를 들어, 서비스 A가 서비스 B를 호출할 때, A의 스팬(span) ID가 컨텍스트 안에 포함되어 있으면, B에서 생성되는 다음 스팬은 A의 스팬을 부모 스팬으로 사용하게 됩니다.<br />
또한 컨텍스트에 포함된 트레이스 ID(trace ID) 는 B에서 생성되는 스팬에도 동일하게 사용되어, A의 스팬과 B의 스팬이 같은 트레이스(trace) 내에 있다는 것을 의미합니다.<br /></p>

<p><strong>Propagation</strong><br />
전파(Propagation)는 <b>Context</b>를 서비스나 프로세스 간에 이동시키는 메커니즘입니다.<br />
Context 객체를 직렬화 또는 역직렬화하여 필요한 정보를 다른 서비스로 전달합니다.<br /></p>

<p>전파(Propagation)는 일반적으로 자동화된 계측 라이브러리(instrumentation library) 가 처리하며, 사용자가 명시적으로 다룰 필요는 없습니다.<br />
하지만 수동으로 컨텍스트를 전파해야 할 경우에는 Propagators API 를 사용할 수 있습니다.<br /></p>

<p>OpenTelemetry는 몇 가지 공식 전파자(propagator)를 제공합니다.<br />
기본 전파자는 W3C의 TraceContext 명세에서 정의된 HTTP 헤더를 사용합니다.<br /></p>

<p><br /></p>
<h1 id="opentelemetry-collector">OpenTelemetry Collector</h1>
<p>Otel Collector는 벤더 중립적인 방식으로 Telemetry 데이터를 Receive, Process, Exporter 기능을 제공합니다. Otel Collector 하나로 다른 Agent나 Collector를 운영하거나 유지보수 할 필요가 없어집니다.<br />
또한 해당 Collector는 확장성이 뛰어나고, Jaeger, Prometheus, Fluent Bit 등과 같은 오픈소스 가시성 데이터 포맷을 사용하여 하나 이상의 오픈소스 또는 상용 백엔드로 데이터를 전송 할 수 있습니다.<br /></p>

<p><a href="/img/opentelemetry/img1.png"><img src="/img/opentelemetry/img1.png" /></a><br />
<strong>OpenTelemetry Collector Pipeline</strong><br /></p>

<p><br />
파이프라인은 Collector 내에서 수신 -&gt; 처리 -&gt; 내보내기까지의 Flow를 정의 합니다. 해당 파이프라인은 M,L,T 모두 처리 할 수 있습니다.<br />
각 파이프라인은 구성에 따라 특정 데이터 유형을 처리하도록 정의되며, 파이프라인에 포함된 수신기(Receivers), 처리기(Processors), 내보내기(Exporters) 모두 해당 데이터 유형을 지원해야 합니다.<br />
그렇지 않으면 Collector가 설정을 로드할 때 pipeline.ErrSignalNotSupported 예외가 발생합니다.<br />
<br /></p>

<p><br />
<strong>Receivers</strong><br />
수신기는 일반적으로 네트워크 포트를 리슨하며 텔레메트리 데이터를 수신합니다. 또한 스크래퍼처럼 데이터를 능동적으로 수집할 수도 있습니다.<br />
보통 하나의 수신기는 하나의 파이프라인에만 데이터를 전송하도록 구성되지만, 동일한 수신기를 여러 파이프라인에 연결하여 동일한 수신 데이터를 여러 파이프라인으로 전송할 수도 있습니다.<br />
그리고 Receiver는 공식으로 지원하는 Receiver 뿐 아니라, Community의 Custom Receiver도 지원 합니다.</p>

<blockquote>
  <p><strong>Note</strong><br />
공식 Receiver : https://opentelemetry.io/docs/platforms/kubernetes/collector/components/<br />
Community Custom Receiver : https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver<br /></p>
</blockquote>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="na">receivers</span><span class="pi">:</span>
  <span class="na">otlp</span><span class="pi">:</span>
    <span class="na">protocols</span><span class="pi">:</span>
      <span class="na">grpc</span><span class="pi">:</span>
        <span class="na">endpoint</span><span class="pi">:</span> <span class="s">localhost:4317</span>

<span class="na">service</span><span class="pi">:</span>
  <span class="na">pipelines</span><span class="pi">:</span>
    <span class="na">traces</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">memory_limiter</span><span class="pi">,</span> <span class="nv">batch</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
    <span class="na">traces/2</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">transform</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>위의 구성에서는 otlp 수신기가 수신한 데이터를 traces 파이프라인과 traces/2 파이프라인 모두에 전송합니다.<br />
구성에서 traces/2와 같이 type[/name] 형식의 복합 키 이름을 사용할 수 있습니다.<br /></p>

<p>Collector가 이 구성을 로드하면, 수신기 하나가 생성되고 fan-out consumer를 통해 데이터를 두 파이프라인으로 분기합니다.<br /></p>

<p><br /></p>

<p><strong>Processor</strong> <br />
들어온 데이터들을 어떻게 처리 할지 정의 할 수 있으며, 거기에는 데이터를 변환하거나 수정하는 부분도 포함됩니다.<br />
프로세서 안에는 여러 작업을 포함 시킬 수 있습니다. 예를 들면 batch, memory limiter, filter, transform 등 또한 여러 파이프라인에서 사용 할 수 있습니다.<br /></p>

<p>예를 들어 batch 프로세서를 여러 파이프라인에서 사용하는 경우:<br /></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre><span class="na">processors</span><span class="pi">:</span>
  <span class="na">batch</span><span class="pi">:</span>

<span class="na">service</span><span class="pi">:</span>
  <span class="na">pipelines</span><span class="pi">:</span>
    <span class="na">traces</span><span class="pi">:</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">batch</span><span class="pi">]</span>
    <span class="na">logs</span><span class="pi">:</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">batch</span><span class="pi">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>각 파이프라인은 batch 프로세서의 자신만의 인스턴스를 가지지만, 구성은 동일하게 적용됩니다.<br />
또한 여러 프로세서를 같이 사용할 때 순서도 매우 중요합니다. 처리 순서에 따라 데이터 흐름, 성능, 필터링 및 리소스 사용에 직접적인 영향을 미치기 때문입니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>      <span class="na">pipelines</span><span class="pi">:</span>
        <span class="na">traces</span><span class="pi">:</span>
          <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
          <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">memory_limiter</span><span class="pi">,</span> <span class="nv">filter/ottl</span><span class="pi">,</span> <span class="nv">metricspan</span><span class="pi">,</span> <span class="nv">batch</span><span class="pi">]</span>
          <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">clickhousetraces</span><span class="pi">,</span> <span class="nv">metadataexporter</span><span class="pi">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>저희는 memory_limiter, filter, add metric, batch 등을 포함시켰습니다.<br />
간단하게 역할을 정리 해드리면</p>

<table>
  <thead>
    <tr>
      <th>Processor</th>
      <th style="text-align: center">역할 및 특징</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">memory_limiter</code></td>
      <td style="text-align: center">Collector 프로세스가 사용하는 메모리를 제한하고 과다 사용시(spike) 지연 및 용량제한 등 안정성 확보</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">filter</code></td>
      <td style="text-align: center">특정 조건에 맞는 트레이스/메트릭/로그를 포함하거나 제외하는 역할</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">metricspan</code></td>
      <td style="text-align: center">생성된 Span에 Metric을 연결시키는 역할</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">batch</code></td>
      <td style="text-align: center">데이터를 일정량 모아서 한꺼번에 내보내서 네트워크 효율향상 및 레이턴시 감소</td>
    </tr>
  </tbody>
</table>

<p><em>프로세서 순서</em> <br />
memory_limiter로 가장 먼저 앞에 두어서 Collector 메모리 사용량을 모니터링 및 제한 —&gt;<br /> filter를 통해 필요없는 데이터를 조기에 제거하여 후단 처리량 감소 —&gt; <br />생성된 Span에 메트릭데이터 연결 —&gt; <br />batch를 마지막에 두어 남은 데이터를 묶어서 네트워크나 Exporter에 효율적으로 전송<br />
상황별 예외사항이 있을 수 있으며 각 환경에 맞게 적절하게 순서나 프로세서를 추가하여 운영하면 좋을 것 같습니다.</p>

<blockquote>
  <p><strong>Note</strong> <br />
공식 및 Community에서 제공하는 Processor list : https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor <br /></p>
</blockquote>

<p><br />
<strong>Exporter</strong> <br />
Exporters는 일반적으로 수신한 데이터를 네트워크의 목적지로 전달합니다. 그러나 debug exporter처럼 데이터를 로컬 로그 등 다른 위치로 보낼 수도 있습니다.<br />
동일한 타입의 exporter를 여러 개 정의하여 각각 다른 목적지로 전송할 수 있습니다</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
</pre></td><td class="rouge-code"><pre>    <span class="na">exporters</span><span class="pi">:</span>
      <span class="na">otlphttp/backend</span><span class="pi">:</span>
        <span class="na">endpoint</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://BACKEND로</span><span class="nv"> </span><span class="s">보낼</span><span class="nv"> </span><span class="s">주소"</span>
      <span class="na">kafka/goodman</span><span class="pi">:</span>
        <span class="na">brokers</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">IP:PORT</span>
          <span class="pi">-</span> <span class="s">IP:PORT</span>
          <span class="pi">-</span> <span class="s">IP:PORT</span>
        <span class="na">topic</span><span class="pi">:</span> <span class="s">goodboy</span>
        <span class="na">protocol_version</span><span class="pi">:</span> <span class="s">2.0.0</span>
        <span class="na">encoding</span><span class="pi">:</span> <span class="s">otlp_json</span>
        <span class="na">auth</span><span class="pi">:</span>
          <span class="na">sasl</span><span class="pi">:</span>
            <span class="na">mechanism</span><span class="pi">:</span> <span class="s">SCRAM-SHA-512</span>
            <span class="na">username</span><span class="pi">:</span> <span class="s">log</span>
            <span class="na">password</span><span class="pi">:</span> <span class="s2">"</span><span class="s">logs"</span>
      <span class="na">elasticsearch/logs</span><span class="pi">:</span>
        <span class="na">endpoints</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">IP:PORT</span>
          <span class="pi">-</span> <span class="s">IP:PORT</span>
          <span class="pi">-</span> <span class="s">IP:PORT</span>
        <span class="na">logs_index</span><span class="pi">:</span> <span class="s2">"</span><span class="s">logs-ingress_nginx.access-default"</span>
        <span class="na">tls</span><span class="pi">:</span>
          <span class="na">ca_file</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/etc/certs/ca.crt"</span>
        <span class="na">auth</span><span class="pi">:</span>
          <span class="na">authenticator</span><span class="pi">:</span> <span class="s">basicauth/es</span>
    <span class="na">service</span><span class="pi">:</span>
      <span class="na">pipelines</span><span class="pi">:</span>
        <span class="na">logs</span><span class="pi">:</span>
          <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">zipkin</span><span class="pi">]</span>
          <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">memory_limiter</span><span class="pi">]</span>
          <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlphttp/backend</span><span class="pi">,</span> <span class="nv">kafka/goodman</span><span class="pi">,</span> <span class="nv">elasticsearch/logs</span><span class="pi">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p><br />
이런식으로 exporter를 여러개 정의하여 여러 곳으로 보낼 수 있습니다.<br />
Backend가 OTLP를 지원할 경우 기본 OTLP Exporter를 직접 보낼 수 있지만, Elasticsearch와 같이 OTLP를 지원하지 않는 경우 관련 Exporter를 사용해야 합니다.<br />
<br /></p>

<blockquote>
  <p><strong>Note</strong> <br />
공식 및 Community에서 제공하는 Exporter list : https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter  <br /></p>
</blockquote>

<p>이렇게 OpenTelemetry Collector의 대표 3가지 구성요소에 구성하였습니다.<br />
<br />
저희는 우선적으로 Kubernetes 환경에서의 Observability 확보를 진행하였으며 k8s 환경에서는 Auto-Instrumentation을 지원하고 있기 때문에 쉽게 반영할 수 있었습니다.<br />
Auto-Instrumentation은 Deployment에 Inject가 정의되면 Pod가 Initialize할 때 Init Container를 동작 시킴으로서 관련 agent를 설치시키고 그런 후 Application 파드가 올라오는 형태입니다.<br />
이렇게 진행되기 위해서는 Instrumentation CR이 설치되어야 합니다. Auto-Instrumentation 적용 순서 입니다.<br /></p>
<ul>
  <li>해당 k8s cluster에 CertManager, OpenTelemetry Operator 설치<br /></li>
  <li>Instrumentation Manifest 적용<br /></li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">opentelemetry.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Instrumentation</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="pi">{</span><span class="nv">이름</span><span class="pi">}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">exporter</span><span class="pi">:</span>
    <span class="na">endpoint</span><span class="pi">:</span> <span class="s">http://collector로 보낼주소</span> <span class="c1"># 데이터 EndPoint</span>
  <span class="na">propagators</span><span class="pi">:</span>                                   <span class="c1"># Context Progation에 대한 정의</span>
    <span class="pi">-</span> <span class="s">tracecontext</span>
    <span class="pi">-</span> <span class="s">baggage</span>
  <span class="na">sampler</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">parentbased_traceidratio</span>        <span class="c1"># MLT에 대한 Sampling 비율 (0.0 ~ 1) (1이면 100%로 수집한다)</span>
    <span class="na">argument</span><span class="pi">:</span> <span class="s1">'</span><span class="s">1'</span>
  <span class="na">java</span><span class="pi">:</span>                                   <span class="c1"># 각 언어에 맞게 선언</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">OTEL_SERVICE_NAME</span>                   <span class="c1"># 서비스명 지정(커밋 ID가 붙지 않도록) 합니다.. UI에서 서비스명으로 filter 걸어서 보기 좋습니다.</span>
        <span class="na">value</span><span class="pi">:</span> <span class="pi">{</span><span class="nv">서비스명</span><span class="pi">}</span>                         <span class="c1"># 예 saramin-prod</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">OTEL_TRACES_EXPORTER</span>                <span class="c1"># console로 하면 standardout으로 전달되는 모습을 로그로 볼 수 있습니다(트러블슈팅시 용이) -&gt; 수집된게 확인되면 console은 다시 빼는게 좋습니다.</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">console,otlp</span>                       <span class="c1"># otlp가 실제로 backend로 전달하는 설정</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">OTEL_METRICS_EXPORTER</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">console,otlp</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">OTEL_LOGS_EXPORTER</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">console,otlp</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">OTEL_EXPORTER_OTLP_PROTOCOL</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">http/protobuf</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">OTEL_INSTRUMENTATION_COMMON_DEFAULT_ENABLED</span>  <span class="c1"># 모든 계측을 활성화 (많은 데이터가 들어가게 되므로 사용하시다 익숙해지면 제외할 필요가 있습니다.)</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<ul>
  <li>Deployment에 Inject 추가<br /></li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">items</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
  <span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
  <span class="na">metadata</span><span class="pi">:</span>
    <span class="na">annotations</span><span class="pi">:</span>                       <span class="c1"># 여기가 아닙니다.</span>
    <span class="na">labels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">saramin-java</span>
      <span class="na">app.kubernetes.io/instance</span><span class="pi">:</span> <span class="s">otel</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">saramin-node-webapp</span>
    <span class="na">namespace</span><span class="pi">:</span> <span class="s">otel</span>
  <span class="na">spec</span><span class="pi">:</span>
    <span class="na">minReadySeconds</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">progressDeadlineSeconds</span><span class="pi">:</span> <span class="m">120</span>
    <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
    <span class="na">revisionHistoryLimit</span><span class="pi">:</span> <span class="m">2</span>
    <span class="na">selector</span><span class="pi">:</span>
      <span class="na">matchLabels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">saramin-java</span>
    <span class="na">strategy</span><span class="pi">:</span>
      <span class="na">rollingUpdate</span><span class="pi">:</span>
        <span class="na">maxSurge</span><span class="pi">:</span> <span class="s">25%</span>
        <span class="na">maxUnavailable</span><span class="pi">:</span> <span class="s">25%</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">RollingUpdate</span>
    <span class="na">template</span><span class="pi">:</span>
      <span class="na">metadata</span><span class="pi">:</span>         <span class="c1"># Template.metadata.annotations에 라인추가 </span>
        <span class="na">annotations</span><span class="pi">:</span>    <span class="c1"># 각 언어에 맞는 inject를 아래처럼 true로 해줍니다.</span>
          <span class="na">instrumentation.opentelemetry.io/inject-java</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
        <span class="na">creationTimestamp</span><span class="pi">:</span> <span class="kc">null</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<blockquote>
  <p><strong>Note</strong> <br />
.NET: instrumentation.opentelemetry.io/inject-dotnet: “true” <br />
Deno: instrumentation.opentelemetry.io/inject-sdk: “true” <br />
Go: instrumentation.opentelemetry.io/inject-go: “true” <br />
Java: instrumentation.opentelemetry.io/inject-java: “true” <br />
Node.js: instrumentation.opentelemetry.io/inject-nodejs: “true” <br />
Python: instrumentation.opentelemetry.io/inject-python: “true” <br /></p>

  <p>아래 형태로도 사용 가능합니다. <br /></p>

  <p>“true” - 현재 네임스페이스에서 기본 이름으로 Instrumentation 리소스를 주입합니다. <br />
“my-instrumentation” - 현재 네임스페이스에 “my-instrumentation”이라는 이름의 Instrumentation CR 인스턴스를 주입합니다. <br />
“my-other-namespace/my-instrumentation” - 다른 네임스페이스 “my-other-namespace”에서 “my-instrumentation”이라는 이름의 Instrumentation CR 인스턴스를 주입합니다. <br />
“false” - 주입하지 않습니다. <br /></p>
</blockquote>

<p>자 이렇게 OpenTelemetry가 구성하였습니다. 그런데 M,L,T를 Exporter로 어디로 보내는걸로 구성했을까요?<br />
OpenSource Observability 하면 떠오르는게 LGTM이 유명할 것으로 알고 있습니다.<br />
<a href="/img/opentelemetry/img3.png"><img src="/img/opentelemetry/img3.png" /></a> <br />
<br />
위와 같은 아키텍쳐로 구성하여 파일럿 해보았지만, 이 마저도 복잡성을 증가 시키거나 유지보수 하는데 많은 시간을 할애 할 수 있을 것 같다고 판단 했습니다.<br />
또한 각각에 OpenSource가 독립된 Helm Chart이며 각 구성요소를 새로 배워야 한다는 점입니다.<br />
<br />
그래서 저희는 SigNoz라는 OpenSource를 이용하여 Observability Backend로 구성하였습니다.<br />
<br />
<a href="/img/opentelemetry/img4.png"><img src="/img/opentelemetry/img4.png" /></a> <br /></p>

<p>SigNoz 하나로 통합 관리가 가능해졌고, <br />
무엇보다도 운영 편의성과 유지보수 효율성이 크게 개선되었습니다.<br />
SigNoz는 Signal + Noise의 합성어라고 합니다. 수많은 신호로 부터 오는 노이즈를 줄이겠다라는 의미로 만들어진 것 같습니다.<br /></p>

<ul>
  <li>Metric, Log, Trace를 단일 도구로 수집 및 시각화 가능</li>
  <li>OpenTelemetry 기반으로 쉽게 연동</li>
  <li>ClickHouse로 대용량 데이터 처리</li>
  <li>직관적 UI로 사용 편리</li>
  <li>활발한 개발과 커뮤니티 활동 👍</li>
</ul>

<p><a href="/img/opentelemetry/img5.png"><img src="/img/opentelemetry/img5.png" /></a> <br /></p>

<p>단일 도구로 MLT 모두 수집 및 모니터링 할 수 있는 도구 입니다. 여러 거대 벤더사(Datadog, NewRelic 등) 대체제로 떠오르고 있습니다.<br />
SigNoz Collector로 데이터를 받고 Exporter로 ClickHouse로 전달하여 해당 데이터를 SigNoz Binary를 통해 확인하는 구조입니다.<br />
<br />
또한 여러 경쟁사들을 조사했습니다.</p>

<p><a href="/img/opentelemetry/img6.png"><img src="/img/opentelemetry/img6.png" /></a> <br /></p>

<p>일단 출시일에 비해 가장 활발하게 Release 되고 있고 다른것에 비해 Contributor수도 많다고 생각했습니다.<br /></p>

<h1 id="마무리">마무리</h1>
<p>현재 여러 모니터링 툴이 있습니다. <br />Sentry, Prometheus, Elastic APM, ElasticSearch, NewRelic 등등…<br />
각각의 모니터링 툴마다 특성과 개성이 있기에 모두 안 볼수는 없겠지만, <br /><b>OpenTelemetry + SigNoz 조합</b>으로 점진적으로 좁혀지지 않을까 싶습니다.<br />
앞으로도 Log 및 Trace 보관 전략과 OpenTelemetry Collector, ClickHouse 등 고민사항과<br /> 고도화 할 내용들이 남았지만 목표 달성을 위해 팀원들과 함께 진행하고 있습니다.<br />
구축기가 많이 부족하지만 긴 글 읽어주셔서 감사합니다.<br /></p>]]></content><author><name>박형규</name></author><category term="DevOps" /><category term="Kubernetes" /><category term="K8S" /><category term="Opentelemetry" /><category term="Observability" /><category term="DevOps" /><summary type="html"><![CDATA[저희는 Kubernetes 환경에서 동작하는 서비스의 증가와 최근 k8s 환경에서 대규모 서비스 오픈을 진행 했으며, 이에 대비하여 어떻게 마이크로 서비스에서 가시성을 확보할지, 또 문제가 생겼을 경우 어떻게 쉽게 문제를 확인하고 추적 할지에 대해 고민하게 되었습니다. 그 결과, OpenTelemetry와 SigNoz 조합을 활용한 Observability 환경을 구축하게 되었으며, 그 경험을 공유하고자 합니다.]]></summary></entry><entry><title type="html">Karpenter 파일럿</title><link href="https://saramin.github.io/2024-06-26-karpenter/" rel="alternate" type="text/html" title="Karpenter 파일럿" /><published>2024-06-26T00:00:00+09:00</published><updated>2024-06-26T00:00:00+09:00</updated><id>https://saramin.github.io/karpenter</id><content type="html" xml:base="https://saramin.github.io/2024-06-26-karpenter/"><![CDATA[<p>지난 포스팅과<a href="https://saramin.github.io/2023-12-06-slislo">(사이트 신뢰성에 대한 지표는 어떻게 구성할까?)</a> 다르게 이번엔,<br />
AWS EKS 환경을 좀 더 안정적이며 확장성 있게 운영하기 위해 고민하고 테스트 했던 내용에 대해 공유 드리고자 합니다.</p>

<p>사람인은 K8S 플랫폼으로 On-Premise가 주이고 최근 서비스는 AWS EKS를 사용하고 있습니다.<br />
초기 EKS를 구축 했을 때 CA를 사용하지 않고 Auto Scaling Group을 이용하여 명시적으로 관리 했었습니다.</p>

<p>하지만 거기서 오는 문제점들이 하나씩 발견 되기 시작 했습니다.</p>

<p><strong>문제점.</strong></p>
<ul>
  <li>신규 서비스로 낮은 인스턴스 타입 또는 적은 인스턴스의 RI 설정</li>
  <li>신규 버전에 대해 배포시 많은 리소스 요청으로 인해 Node의 <code class="language-plaintext highlighter-rouge">Not Ready</code> 상태가 자주 발생</li>
  <li>Auto Scaling Group이 있지만 제대로 감지가 되지 않고, 속도가 느리기 때문에 Node가 죽기 전에 <code class="language-plaintext highlighter-rouge">확장 불가</code></li>
  <li>문제 발생 할 때 마다 <code class="language-plaintext highlighter-rouge">수동</code>으로 노드그룹 수치를 변경하여 축소와 확장으로 임시 조치</li>
</ul>

<p>문제 발생 할 때 마다 항상 대응 할 수 없고, On-Demand 비용 증가와 안정성 결여로 많은 문제점이 존재 했습니다.</p>

<hr />

<h1 id="karpenter란">Karpenter란?</h1>
<p><a href="/img/karpenter/image1.png"><img src="/img/karpenter/image1.png" /></a></p>

<p>Autoscaling에서도 여러 종류가 있는데,<br />
먼저 Pod Autoscaling으로는 수평적으로 Scale-out 해주는 HPA가 있고 수직으로 Scale-up 해주는 VPA가 있습니다.<br />
Node Autoscaling으로는 CA와 Karpenter가 있는데 Karpenter는 CA와 달리 노드그룹과 같은 추가 오케스트레이션 메커니즘 없이 인스턴스를 직접 관리합니다.</p>

<h1 id="karpenter-테스트-케이스">Karpenter 테스트 케이스</h1>
<p>Karpenter에 대해 검증을 하기 위해 먼저 케이스를 작성 한다음 케이스에 맞게 테스트를 진행 했습니다.</p>

<p><a href="/img/karpenter/image2.png"><img src="/img/karpenter/image2.png" /></a></p>

<h1 id="karpenter-안정적으로-운영하기">Karpenter 안정적으로 운영하기</h1>
<h2 id="taint--toleration">Taint &amp; Toleration</h2>
<blockquote>

  <ul>
    <li>노드 어피니티는 노드 셋을 (기본 설정 또는 어려운 요구 사항으로) 끌어들이는 파드의 속성이다. 테인트 는 그 반대로, 노드가 파드 셋을 제외시킬 수 있다.</li>
    <li>톨러레이션 은 파드에 적용된다. 톨러레이션을 통해 스케줄러는 그와 일치하는 테인트가 있는 파드를 스케줄할 수 있다. 톨러레이션은 스케줄을 허용하지만 보장하지는 않는다. 스케줄러는 그 기능의 일부로서 다른 매개변수를 고려한다.</li>
    <li>테인트와 톨러레이션은 함께 작동하여 파드가 부적절한 노드에 스케줄되지 않게 한다. 하나 이상의 테인트가 노드에 적용되는데, 이것은 노드가 테인트를 용인하지 않는 파드를 수용해서는 안 된다는 것을 나타낸다.</li>
  </ul>
</blockquote>

<p>Karpenter를 안정적으로 운영하기 위해 첫번째로 해야 할 부분이 아무래도 Karpenter Controller를 분리 하는 것일 거 같습니다. 
Karpenter가 생성한 노드에 자기 자신이 동작하고 있다면 나중에 노드를 줄이게 될 때 Karpenter가 죽게 되는 상황이 발생 할 것 입니다. 
ASG(Auto Scaling Group)에서 동작하는 노드들에 대해서는 Karpenter Controller, CoreDNS 등 과 같은 Pod가 동작하도록 Taint &amp; Toleration설정을 하였습니다.</p>

<p>ASG 노드에 Taint 설정을 하고, 거기서 동작하는 Pod에 대해 Toleration 설정을 하여 키밸류가 일치 해야만 Running 될 수 있도록 했습니다.</p>

<h3 id="pod-toleration">Pod Toleration</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="na">tolerations</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">eks</span>
    <span class="s">value</span><span class="err">:</span> <span class="s">asg</span>
    <span class="s">effect</span><span class="err">:</span> <span class="s">NoSchedule</span>
    <span class="s">operator</span><span class="err">:</span> <span class="s">Equal</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="다중-node-pool-운영시-weight-설정">다중 Node Pool 운영시 Weight 설정</h2>
<p>RI 계약이 On-demand 3대로 되어 있기 때문에 ASG 노드 3대에서 1대를 줄이고, Karpenter에서 On-demand를 1대 생성하도록 했습니다. (검증 겸)</p>
<h3 id="spot-instance-nodepool">Spot Instance Nodepool</h3>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">karpenter.sh/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">NodePool</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">annotations</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">spot-instance</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">disruption</span><span class="pi">:</span>
    <span class="na">budgets</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">nodes</span><span class="pi">:</span> <span class="s">1%</span>
    <span class="na">consolidationPolicy</span><span class="pi">:</span> <span class="s">WhenUnderutilized</span>
    <span class="na">expireAfter</span><span class="pi">:</span> <span class="s">Never</span>
  <span class="na">weight</span><span class="pi">:</span> <span class="m">50</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="on-demand-instance-nodepool">On-demand Instance Nodepool</h3>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">karpenter.sh/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">NodePool</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">annotations</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">ondemand-instance</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">disruption</span><span class="pi">:</span>
    <span class="na">budgets</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">nodes</span><span class="pi">:</span> <span class="s">1%</span>
    <span class="na">consolidateAfter</span><span class="pi">:</span> <span class="s">Never</span>
    <span class="na">consolidationPolicy</span><span class="pi">:</span> <span class="s">WhenEmpty</span>
    <span class="na">expireAfter</span><span class="pi">:</span> <span class="s">Never</span>
  <span class="na">weight</span><span class="pi">:</span> <span class="m">1</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>같은 Node Pool에 <code class="language-plaintext highlighter-rouge">karpenter.sh/capacity-type</code>에 spot 과 on-demand를 같이 넣으면 우선순위로 어차피 Spot을 먼저 할당 하겠지만,<br />
노드풀은 나눠놓은 이유는 disruption을 다르게 가져가기 위해서 입니다. on-demand에서는 파드가 완전히 없기 전까지 disruption 하지 않도록 <code class="language-plaintext highlighter-rouge">consolidationPolicy</code>를 WhenEmpty로 설정 하였습니다.<br />
그리고 weight를 on-demand를 낮도록 설정하여 spot을 먼저 할당 받도록 했습니다.</p>

<p><a href="/img/karpenter/image3.png"><img src="/img/karpenter/image3.png" /></a></p>

<p>매칭 되는 Spot Instance가 없으면 사진과 같이 인스턴스가 없다고 나타납니다.</p>

<p><a href="/img/karpenter/image4.png"><img src="/img/karpenter/image4.png" /></a></p>

<p>매칭 되는 Spot Instance가 없으면 on-demand의 Nodepool에서 할당 받게 됩니다.</p>

<h2 id="각-노드에-파드-하나씩-러닝-되도록-분배처리-하기-topologyspreadconstraints">각 노드에 파드 하나씩 러닝 되도록 분배처리 하기 (TopologySpreadConstraints)</h2>

<blockquote>
  <p>사용자는 토폴로지 분배 제약 조건 을 사용하여 지역(region), 존(zone), 노드 및 기타 사용자 정의 토폴로지 도메인과 같이 장애 도메인으로 설정된 클러스터에 걸쳐 파드가 분배되는 방식을 제어할 수 있다. 이를 통해 고가용성뿐만 아니라 효율적인 리소스 활용의 목적을 이루는 데에도 도움이 된다</p>
</blockquote>

<p>파드를 리전, 존, 노드 등의 기준으로 파드를 균등하게 배포 될 수 있도록 하기 위해 설정을 해주었습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="na">spec</span><span class="pi">:</span>
  <span class="na">topologySpreadConstraints</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">maxSkew</span><span class="pi">:</span> <span class="m">1</span>
      <span class="na">topologyKey</span><span class="pi">:</span> <span class="s2">"</span><span class="s">topology.kubernetes.io/zone"</span>
      <span class="na">whenUnsatisfiable</span><span class="pi">:</span> <span class="s">DoNotSchedule</span>
      <span class="na">labelSelector</span><span class="pi">:</span>
        <span class="na">matchLabels</span><span class="pi">:</span>
          <span class="na">app</span><span class="pi">:</span> <span class="s">${CI_ENVIRONMENT_SLUG}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>maxSkew: 노드간 파드 개수 차이가 최대 1개까지 허용<br />
topologyKey: 이 키를 가진 노드는 동일한 토폴로지에 있는 것으로 간주 합니다.<br />
matchLabels: 일치하는 파드를 찾는데 사용 됩니다.</p>

<p>이러한 설정으로 각 AZ에 파드가 분배 될 수 있도록 합니다.</p>

<h2 id="pod-qos">Pod QoS</h2>
<p>Karpenter는 Utilization을 토대로 노드를 할당 해주지 않습니다.<br />
이것은 Kubernetes Schdeduler가 Request 기반으로 동작하기 때문에 이에 맞게 동작하도록 되어 있습니다.<br />
그래서 최대한 Utilization에 맞게 Request를 주는 것이 좋습니다. <br />
다만 Java의 경우 Initializing 때 실제로 사용하는 리소스보다 많이 잡아 먹습니다.<br />
Metric을 보고 Request를 잘 조절 해주면 Karpenter가 빈패킹 단계에서 좀 더 정확하게 인스턴스 타입을 선별 할 수 있도록 합니다.<br />
최대한 게런티 파드로 구성하도록 했습니다.</p>

<h2 id="kubelet-세팅">Kubelet 세팅</h2>
<p>Karpenter에서 생성할 노드에서 동작한 Kubelet에 대한 설정도 진행 할 수 있습니다.<br />
이 부분 Karpenter의 영역이라기 보단 EKS에서도 많은 분들이 기본적으로 설정 해두는 부분 입니다.</p>

<p>먼저 kubelet의 Reserved 영역을 지정하는 것 입니다.</p>

<p><a href="/img/karpenter/image5.png"><img src="/img/karpenter/image5.png" /></a></p>

<p>Application에서 리소스를 100% 다 잡아 먹는다 하더라도 Reserved 영역을 설정하여 Node <code class="language-plaintext highlighter-rouge">Not Ready</code>와 같은 상황에 안빠지도록 안전장치를 추가 했습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>  <span class="na">kubeReserved</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s">200m</span>
    <span class="na">ephemeral-storage</span><span class="pi">:</span> <span class="s">3Gi</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s">100Mi</span>
  <span class="na">systemReserved</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s">100m</span>
    <span class="na">ephemeral-storage</span><span class="pi">:</span> <span class="s">1Gi</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s">100Mi</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>이렇게 설정하여 인스턴스 리소스를 어플리케이션이 모두 할당해서 Hang 걸리는 일이 없도록 하였습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="na">imageGCHighThresholdPercent</span><span class="pi">:</span> <span class="m">80</span>
<span class="na">imageGCLowThresholdPercent</span><span class="pi">:</span> <span class="m">60</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>해당 인스턴스의 공간으로 인한 DiskPresure가 발생할 수 있기 때문에 Image Garbage Collection을 위한 WaterMark 설정을 하였습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="na">evictionHard</span><span class="pi">:</span>
  <span class="na">memory.available</span><span class="pi">:</span> <span class="s">500Mi</span>
  <span class="na">nodefs.available</span><span class="pi">:</span> <span class="s">10%</span>
  <span class="na">nodefs.inodesFree</span><span class="pi">:</span> <span class="s">5%</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>eviction에 대한 처리 방식인데, evictionSoft도 있지만 아무래도 eviction 시킬 때에는 노드의 자원 등 위급할 때 배출 하는거라<br />
Hard가 나은 결과로 나왔습니다. 여러 안전장치를 통해 eviction 되는 경우는 거의 줄었습니다.</p>

<h2 id="개발-환경의-경우-업무시간-외에는-karpenter-노드가-중지되도록주말-공휴일-전사휴무일">개발 환경의 경우 업무시간 외에는 Karpenter 노드가 중지되도록(주말, 공휴일, 전사휴무일)</h2>
<p>안정성과 확장성을 위해 사용하지만 Spot Instance를 쉽게 이용 할 수 있다는 점에서 비용적인 측면도 무시 할 수 없어서<br />
효율을 극대화 하기 위해 비업무시간에는 Karpenter 노드가 중지되도록 CronJob과 Python code를 걸었습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">batch/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">CronJob</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">karpenter-deprovisioning-cronjob</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">karpenter</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">timeZone</span><span class="pi">:</span> <span class="s">Asia/Seoul</span>
  <span class="na">schedule</span><span class="pi">:</span> <span class="s2">"</span><span class="s">00</span><span class="nv"> </span><span class="s">22</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">1-5"</span>
  <span class="na">startingDeadlineSeconds</span><span class="pi">:</span> <span class="m">60</span>
  <span class="na">successfulJobsHistoryLimit</span><span class="pi">:</span> <span class="m">2</span>
  <span class="na">failedJobsHistoryLimit</span><span class="pi">:</span> <span class="m">2</span>
  <span class="na">jobTemplate</span><span class="pi">:</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">tolerations</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">eks</span>
            <span class="na">value</span><span class="pi">:</span> <span class="s">asg</span>
            <span class="na">operator</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Equal"</span>
            <span class="na">effect</span><span class="pi">:</span> <span class="s">NoSchedule</span>
          <span class="na">restartPolicy</span><span class="pi">:</span> <span class="s">Never</span>
          <span class="na">serviceAccountName</span><span class="pi">:</span> <span class="s">karpenter</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">memory-downsizing-spot</span>
            <span class="na">image</span><span class="pi">:</span> <span class="s">bitnami/kubectl</span>
            <span class="na">imagePullPolicy</span><span class="pi">:</span> <span class="s">IfNotPresent</span>
            <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">/bin/sh"</span><span class="pi">]</span>
            <span class="na">args</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">-c"</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">kubectl</span><span class="nv"> </span><span class="s">patch</span><span class="nv"> </span><span class="s">nodepools.karpenter.sh</span><span class="nv"> </span><span class="s">spot-instance</span><span class="nv"> </span><span class="s">--type='json'</span><span class="nv"> </span><span class="s">-p='[{</span><span class="se">\"</span><span class="s">op</span><span class="se">\"</span><span class="s">:</span><span class="nv"> </span><span class="se">\"</span><span class="s">replace</span><span class="se">\"</span><span class="s">,</span><span class="nv"> </span><span class="se">\"</span><span class="s">path</span><span class="se">\"</span><span class="s">:</span><span class="se">\"</span><span class="s">/spec/limits/memory</span><span class="se">\"</span><span class="s">,</span><span class="nv"> </span><span class="se">\"</span><span class="s">value</span><span class="se">\"</span><span class="s">:</span><span class="se">\"</span><span class="s">0</span><span class="se">\"</span><span class="s">}]'"</span>
          <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">delete-karpenter-node</span>
            <span class="na">image</span><span class="pi">:</span> <span class="s">bitnami/kubectl</span>
            <span class="na">imagePullPolicy</span><span class="pi">:</span> <span class="s">IfNotPresent</span>
            <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">/bin/sh"</span><span class="pi">]</span>
            <span class="na">args</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">-c"</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">sleep</span><span class="nv"> </span><span class="s">10</span><span class="nv"> </span><span class="s">&amp;&amp;</span><span class="nv"> </span><span class="s">kubectl</span><span class="nv"> </span><span class="s">delete</span><span class="nv"> </span><span class="s">nodes</span><span class="nv"> </span><span class="s">-l</span><span class="nv"> </span><span class="s">karpenter.sh/nodepool=spot-instance</span><span class="nv"> </span><span class="s">--force</span><span class="nv"> </span><span class="s">--grace-period=0"</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>22시 이후에는 Karpenter 노드에 대해 memory limit을 0으로 설정 하도록 했습니다.<br />
Karpenter는 limit의 수치가 설정 되어 있으면 무한정 노드를 만들어 낼 일이 없게 됩니다.<br />
해당 limit 수치를 0으로 설정을 변경하고 노드를 delete 하도록 했습니다.</p>

<p>그러면 ASG노드에 Taint &amp; Tolerations에 의해 모든 Application Pod들은 Pending 상태에서 대기하게 됩니다.</p>

<p><a href="/img/karpenter/image6.png"><img src="/img/karpenter/image6.png" /></a> 
(ASG노드 2대만 동작하고 있고 Karpenter 노드들은 모두 종료 되고 App Pod들은 Pending으로 보인다.)</p>

<p>그리고 자동으로 업무시간 07시에는 해당 limit 값을 원복 하면 Karpenter는 동작하게 되면서, Application Pod들이 새로 배포하지 않아도 다시 동작하게 되죠</p>

<h2 id="requirement">Requirement</h2>
<p>Spot Instance를 할당 할 때 instance-cpu와 instance-memory 기준을 잡고 운영 했지만,<br />
과한 인스턴스를 할당 받는다거나, Spot 중에서도 비용이 비싼 인스턴스를 할당 받는 경우가 있었습니다.<br />
또한 07시에 모든 Application Pod가 expanding window algorithm에 의해 최대 10초까지 보류중인 파드들을 단일 배치로 구성 해버리기 때문에<br />
덩치큰 Spot Instance 노드 하나에 모든 Pod를 다 동작시키게 됩니다. 오히려 그게 비용이 저렴 할 수도 있지만<br />
Spot Instance가 Interruption 되버리면 반대로 모든 Pod가 중지 될 수도 있기 때문에 instance-type으로 저희가 원하는 타입으로 리스트업해서 할당 받을 수 있도록 했습니다.<br />
instance-type는 Spot Instance 중에서도 평균적으로 가격이 가장 낮게 책정 되는 Instance Type 위주로 선별 하였습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">node.kubernetes.io/instance-type</span>
  <span class="na">operator</span><span class="pi">:</span> <span class="s">In</span>
  <span class="na">values</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">r6g.xlarge"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">m6i.xlarge"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">m6g.xlarge"</span> <span class="nv">....</span> <span class="pi">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>instance-type으로 기준을 잡게 되면 15개 이상으로 리스트업 하셔야 합니다.</p>

<h2 id="간단하게-log로-보는-karpenter-수행-절차">간단하게 Log로 보는 Karpenter 수행 절차</h2>
<p>Karpenter Log를 보면 비슷한 패턴으로 로그가 떨어지는 것을 볼 수 있습니다.</p>

<p>이벤트 <code class="language-plaintext highlighter-rouge">found provisionable pod</code> 가 발생 하면</p>

<table>
  <thead>
    <tr>
      <th>노드 생성 단계 순</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">computed new nodeclaim</code></td>
      <td>파드 개수에 맞게 노드를 몇개 만들지 선별</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create nodeclaim</code></td>
      <td>노드 요청서(fit에 맞는 노드 타입 리스트업)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">launched</code></td>
      <td>nodeclaim 중 알맞은 ec2 생성</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">registered</code></td>
      <td>클러스터에 등록</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ready</code></td>
      <td>ready 상태로 되면</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">intialized</code></td>
      <td>최종 완료</td>
    </tr>
  </tbody>
</table>

<p>노드 삭제 단계</p>
<ul>
  <li>노드가 비어있어 노드를 제거 할수있는 경우</li>
  <li>파드가 줄었거나 더 저렴한 노드로 변경 할 수 있을 경우</li>
</ul>

<table>
  <thead>
    <tr>
      <th>노드 삭제 단계</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">disrupting</code></td>
      <td>node에 finalizer를 지정하고 노드를 제거 절차가 진행되는동안 제거 방지</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tained node</code></td>
      <td>스케쥴되지 않도록 disruption:noschedule이라는 taint를 건다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deleted node</code></td>
      <td>노드 삭제 명령을 수행하고 controller가 정상적으로 종료될때까지 기다림</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deleted nodeclaim</code></td>
      <td>최종 완료 되면 nodeclaim 삭제</td>
    </tr>
  </tbody>
</table>

<h2 id="번외">번외</h2>
<h3 id="descheduler">Descheduler</h3>

<p>Descheduler과 Karpenter를 같이 사용하면 시너지가 있지 않을까 싶어서 테스트 해보았습니다.<br />
Descheduler에는 크게 기능이 LowNodeUtilization과 HighNodeUtilization이 있습니다.<br />
<code class="language-plaintext highlighter-rouge">LowNodeUtilization</code>은 노드간 리소스 사용량을 좀 더 촘촘하게 해서 리소스를 균등하게 사용 할 수 있도록 합니다.<br />
<code class="language-plaintext highlighter-rouge">HighNodeUtilization</code>은 리소스 사용량에 따라 노드를 비워주도록 합니다.</p>

<p>Karpenter <code class="language-plaintext highlighter-rouge">consolidationPolicy</code>에는 WhenEmpty라는 정책이 있어서 Descheduler가 노드를 비워주면 Karpenter는 WhenEmpty에 의해 동작하여 노드를 제거 해주지 않을까 싶었습니다.<br />
결론적으로 말씀 드리면 Descheduler의 HighNodeUtilization가 Bin-Packing 전략을 사용하기 위해선 Kubernetes Scheduler의 MostAllocated 전략과 함께 사용 되어야 하는데 EKS에서는 기본적으로 ScoringStrategy가 LeastAllocated를 사용합니다.<br />
HighNodeUtilization + LeastAllocated 조합으로는 동작하지 않았습니다.</p>

<blockquote>
  <p>This strategy must be used with the scheduler scoring strategy MostAllocated.</p>
</blockquote>

<h1 id="마무리하면서">마무리하면서..</h1>

<p>Karpenter는 단순한 메커니즘으로 동작합니다. <code class="language-plaintext highlighter-rouge">Pod가 Pending 되면 노드를 늘려준다.</code><br />
하지만 그 단순한 메커니즘은 시스템에 많은 영향을 끼치기 때문에 안정적으로 운영하기가 매우 까다롭습니다.<br />
그럼에도 불구하고 Karpenter를 사용하는 이유는 강력한 퍼포먼스에 있다고 생각합니다.<br />
노드가 부족할 때 10초만에 생성되고 Ready까지 40초 미만이면 완료 됩니다. 더군다나 Spot 인스턴스 사용을 할 수 있도록 하여 비용적인 측면까지 잡아갈 수 있도록 하였습니다.<br />
Production에 적용하기 전에는 많은 전략 및 테스트를 거쳐서 적용 시키기 바랍니다.<br />
해당 포스팅으로 많은 정보 얻어가셨으면 좋겠습니다.  긴 글 읽어주셔서 감사합니다.</p>]]></content><author><name>박형규</name></author><category term="DevOps" /><category term="인프라" /><category term="Kubernetes" /><category term="K8S" /><category term="DevOps" /><category term="AWS" /><summary type="html"><![CDATA[지난 포스팅과(사이트 신뢰성에 대한 지표는 어떻게 구성할까?) 다르게 이번엔, AWS EKS 환경을 좀 더 안정적이며 확장성 있게 운영하기 위해 고민하고 테스트 했던 내용에 대해 공유 드리고자 합니다. 사람인은 K8S 플랫폼으로 On-Premise가 주이고 최근 서비스는 AWS EKS를 사용하고 있습니다. 초기 EKS를 구축 했을 때 CA를 사용하지 않고 Auto Scaling Group을 이용하여 명시적으로 관리 했었습니다. 하지만 거기서 오는 문제점들이 하나씩 발견 되기 시작 했습니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://saramin.github.io/img/karpenter/image0.png" /><media:content medium="image" url="https://saramin.github.io/img/karpenter/image0.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">통합된 개발과 배포 : Monorepo와 GitOps의 매력적인 조합</title><link href="https://saramin.github.io/2024-04-08-monorepo-gitops/" rel="alternate" type="text/html" title="통합된 개발과 배포 : Monorepo와 GitOps의 매력적인 조합" /><published>2024-04-08T00:00:00+09:00</published><updated>2025-11-10T09:11:44+09:00</updated><id>https://saramin.github.io/monorepo-gitops</id><content type="html" xml:base="https://saramin.github.io/2024-04-08-monorepo-gitops/"><![CDATA[<p>사람인에선 서비스의 레거시 영역을 점진적으로 개선해 나가고 있습니다.</p>

<p>그동안 FE개발팀은 긱이나 멘토링 같은 버티컬 서비스의 FE개발을 진행해왔는데,
작년부터 주요서비스의 FE분리를 시작하면서 FE 영역의 아키텍쳐에 대한 고민을 했었습니다.</p>

<p>그 결과 Monorepo를 적용하기로 하였고 첫번째 서비스가 배포를 앞두고 있습니다.</p>

<p>그 과정에서 배포 환경에 대한 고민도 같이 하게 되었고 GitOps를 도입하게 되었는데 그 과정을 소개해보려고 합니다.</p>

<p><strong>FE개발팀 파이프라인</strong></p>

<p>FE개발팀이 운영하던 서비스들은 일반적인 파이프라인의 모습을 가지고 있었습니다.</p>

<p>기능 개발을 위해 <code class="language-plaintext highlighter-rouge">feature</code> 브랜치를 생성하고 개발코드 커밋들을 푸시하게 되면 <code class="language-plaintext highlighter-rouge">review</code>서버에서 <code class="language-plaintext highlighter-rouge">next build</code>와 <code class="language-plaintext highlighter-rouge">next start</code>를 진행합니다.</p>

<p>개발이 완료된 뒤 기능 QA를 <code class="language-plaintext highlighter-rouge">review</code>서버에서 1차로 진행한 뒤 <code class="language-plaintext highlighter-rouge">develop</code> 브랜치에 <code class="language-plaintext highlighter-rouge">merge</code>를 하게 됩니다.
그럼 <code class="language-plaintext highlighter-rouge">dev</code>서버에 배포가 되고 여기서 최종 QA를 진행한 뒤 운영환경으로 배포를 결정하게 됩니다.</p>

<p>운영 배포를 위해 <code class="language-plaintext highlighter-rouge">main</code> 브랜치에 <code class="language-plaintext highlighter-rouge">merge</code>를 하게 되면 앞선 브랜치들과 동일하게 진행해야 하는데요 운영환경이다 보니 웹서버가 여러대이고, 이에 맞게 배포를 위해 서버 하나씩 <code class="language-plaintext highlighter-rouge">LB</code>를 내리고 <code class="language-plaintext highlighter-rouge">build</code>와 <code class="language-plaintext highlighter-rouge">deploy</code>를 진행한 뒤 <code class="language-plaintext highlighter-rouge">pm2 reload</code>, <code class="language-plaintext highlighter-rouge">LB</code>를 다시 올리는 과정을 거치게 됩니다.</p>

<p><strong>그 과정에서 아쉬움</strong></p>

<p>사람인엔 <code class="language-plaintext highlighter-rouge">DevOps</code>를 전담하는 조직이 없다보니 SRE팀에 서버를 요청한 뒤 개발자가 주도적으로 환경을 구축해야 합니다.</p>

<p>그렇다 보니 FE개발자들 입장에선 <code class="language-plaintext highlighter-rouge">local</code>에서 개발할때와 비슷한 방식으로 환경을 구성하게 되는데요.
<code class="language-plaintext highlighter-rouge">next build</code> &gt; <code class="language-plaintext highlighter-rouge">dist</code> 산출물을 서버에 배포한 뒤 <code class="language-plaintext highlighter-rouge">next start</code>를 통해 <code class="language-plaintext highlighter-rouge">localhost</code>를 띄우고, <code class="language-plaintext highlighter-rouge">nginx</code>에서 <code class="language-plaintext highlighter-rouge">proxy</code>로 연결하는 방식으로요.</p>

<p><code class="language-plaintext highlighter-rouge">dev</code>나 <code class="language-plaintext highlighter-rouge">prod</code> 환경은 매칭되는 브랜치가 각각 <code class="language-plaintext highlighter-rouge">develop</code>, <code class="language-plaintext highlighter-rouge">main</code>로 고정되어 있기 때문에 괜찮았는데, <code class="language-plaintext highlighter-rouge">feature</code>, <code class="language-plaintext highlighter-rouge">hotfix</code> 브랜치는 유동적으로 운영해야 하다 보니 웹서버와 브랜치별를 매칭해주기엔 어려움이 있었습니다.
그렇다보니 <code class="language-plaintext highlighter-rouge">review</code> 서버를 3개정도 구축해놓고, 필요할때마다 해당 서버에 배포해서 사용었했습니다.</p>

<p>결국 이런 과정을 좀더 유연하게 구성하려면 컨테이너 기반의 배포환경이 필연적이라고 판단했고, 도입을 검토하게 되었습니다.</p>

<h1 id="gitops">GitOps</h1>
<blockquote>
  <p>DevOps의 실천 방법 중 하나로 애플리케이션의 배포와 운영에 관련된 모든 요소들을 Git에서 관리(Operation) 한다는 뜻</p>
</blockquote>

<p>때는 작년 12월 어느 추운날, IT연구소에선 TechDay를 진행하게 되었는데 팀별로 1년동안 진행했던 프로젝트중 성과있는 것들을 발표하는 시간이였습니다.
이날 SRE팀에서는 GitOps에 대해서 발표를 하였는데 전 눈이 번쩍 뜨였습니다.</p>

<p>“우리가 GitOps 환경을 구축했어! 드루와~”</p>

<p>마치 들어오라고 손짓을 하는 느낌을 받았고, 전 겁도 없이 덮썩 물었습니다.</p>

<p><del>도커 이미지만 우리가 빌드하면 나머진 자동으로 배포될것 같았거든요.</del>
<del>(중간 과정 생략…..할많하않…..)</del></p>

<p>새로 구축된 GitOps 배포 환경을 소개해보려 합니다.</p>

<p><a href="https://saramin.github.io/img/monorepo/image1.png"><img src="https://saramin.github.io/img/monorepo/image1.png" /></a></p>
<h2 id="1-image-build">1. Image build</h2>

<h3 id="dockerfile-작성하기">Dockerfile 작성하기</h3>

<p>최초 <code class="language-plaintext highlighter-rouge">Dockerfile</code>을 생성해야 하는데, 일반적으로 프로젝트 루트에 생성합니다.</p>

<p>하지만 현재 진행중인 프로젝트는 <code class="language-plaintext highlighter-rouge">turborepo</code>를 사용하여 <code class="language-plaintext highlighter-rouge">monorepo</code>로 구성했고,
이 안에서 <code class="language-plaintext highlighter-rouge">/apps</code> 폴더 밑에 두기로 했습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre>📦monoprepo
 ┣ 📦apps
 ┃ ┣ 📦web1
 ┃ ┣ 📦web2
 ┃ ┗ 📜Dockerfile <span class="c"># 요기</span>
 ┣ 📦packages
 ┃ ┣ 📦eslint-config-custom
 ┃ ┣ 📦shared
 ┃ ┣ 📦tsconfig
 ┃ ┗ 📜package.json
 ┣ 📜package.json
 ┣ 📜pnpm-workspace.yaml
 ┗ 📜turbo.json
</pre></td></tr></tbody></table></code></pre></div></div>
<p>이후 생성할 프로젝트들은 계속해서 유사한 스택으로 개발할 것 같았기에 같은 파일을 사용하기 위함이였습니다.</p>

<p>이제 Dockerfile을 작성해야 하는데, 먼저 가이드를 살펴봅니다.
https://turbo.build/repo/docs/handbook/deploying-with-docker</p>

<p>여긴 <code class="language-plaintext highlighter-rouge">yarn</code> 기반으로 작성되어있네요? 전 <code class="language-plaintext highlighter-rouge">pnpm</code>을 사용하고 있는데 말이죠.
때문에 첫번째 <code class="language-plaintext highlighter-rouge">stage</code>에 <code class="language-plaintext highlighter-rouge">pnpm</code> 설치 구문을 추가해줘야 합니다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>RUN npm install -g pnpm
# 또는
RUN corepack enable
</pre></td></tr></tbody></table></code></pre></div></div>
<p>그리고 빌드될 이미지는 나름의 최적화를 거쳐 빌드시간 등을 효율적으로 관리해야 하는데,
여기서 이미지 경량화를 위한 방법중 <code class="language-plaintext highlighter-rouge">Multi Stage</code>에 대해 알아보겠습니다.</p>

<blockquote>
  <p>Multi Stage 란?</p>
  <ul>
    <li>컨테이너 이미지를 만드는 과정엔 필요한 리소스이지만 최종 이미지에선 필요없는 리소스를 제거할 수록 단계를 나눠 이미지를 만드는 방법
      <ul>
        <li>예) <code class="language-plaintext highlighter-rouge">node_modules</code>는 빌드할땐 필요하지만 최종 이미지엔 필요없다</li>
      </ul>
    </li>
    <li>배포될 이미지 용량을 줄여 전체적인 배포과정의 시간을 줄일 수 있다</li>
  </ul>
</blockquote>

<p>그래서 아래와 같이 <code class="language-plaintext highlighter-rouge">stage</code>를 분리해보았습니다.</p>

<h3 id="dockerfile">Dockerfile</h3>
<h4 id="base-stage">base stage</h4>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
</pre></td><td class="rouge-code"><pre><span class="c">#### alpine stage ####</span>
FROM node:20-alpine AS alpine

<span class="c">#### base stage ####</span>
FROM alpine as base
  <span class="c"># 필요한 구성요소 설치를 위해 `libc6-compat` 추가</span>
  RUN apk add <span class="nt">--no-cache</span> libc6-compat
  RUN apk update

  <span class="c"># DOckerfile을 사용할때 주입할 변수</span>
  ARG SERVICE_NAME
  ARG SERVER_ENV

  <span class="c"># 외부에서 주입된 값이나 새롭게 정의한 값을 위한 ENV값 정의</span>
  ENV <span class="nv">APP_NAME</span><span class="o">=</span><span class="nv">$SERVICE_NAME</span>
  ENV <span class="nv">APP_ENV</span><span class="o">=</span><span class="nv">$SERVER_ENV</span>
  ENV <span class="nv">PNPM_HOME</span><span class="o">=</span><span class="s2">"/pnpm"</span>
  ENV <span class="nv">PATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PNPM_HOME</span><span class="s2">:</span><span class="nv">$PATH</span><span class="s2">"</span>
  
  <span class="c"># pnpm 설치를 위해 패키지 매니저의 버전관리 도구 이용</span>
  RUN corepack <span class="nb">enable</span>
  
  <span class="c"># turbo 설치</span>
  RUN pnpm <span class="nb">install </span>turbo <span class="nt">--global</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="builder-stage">builder stage</h4>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
</pre></td><td class="rouge-code"><pre>FROM base AS builder
  <span class="c"># Set working directory</span>
  WORKDIR /app
  <span class="c"># RUN pwd</span>
  <span class="c"># RUN ls -alRr</span>
  
  <span class="c"># Host의 파일을 WORKDIR로 복사</span>
  COPY <span class="nb">.</span> <span class="nb">.</span>
  
  <span class="c"># turborepo의 강력한 기능인 prune 실행</span>
  RUN turbo prune <span class="nt">--scope</span><span class="o">=</span><span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span> <span class="nt">--docker</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<blockquote>
  <p>turbo prune 이란 모노레포를 가지치기 위한 기능</p>
  <ul>
    <li><code class="language-plaintext highlighter-rouge">monorepo</code>는 <code class="language-plaintext highlighter-rouge">root</code>에서 있는 <code class="language-plaintext highlighter-rouge">pnpm-lock.yaml</code>파일로 모든 패키지를 관리</li>
    <li>특정 프로젝트에서만 필요로 하는 <code class="language-plaintext highlighter-rouge">package</code> 설치 파일을 생성하기 위함</li>
  </ul>
</blockquote>

<h4 id="installer-stage">installer stage</h4>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre>FROM base AS installer
  WORKDIR /app

  <span class="c"># turbo prune 으로 추출된 /out 폴더를 WORKDIR로 복사</span>
  COPY .gitignore .gitignore
  COPY <span class="nt">--from</span><span class="o">=</span>builder /app/out/json/ <span class="nb">.</span>
  COPY <span class="nt">--from</span><span class="o">=</span>builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
  COPY <span class="nt">--from</span><span class="o">=</span>builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
  
  <span class="c"># 프로젝트 소스들을 WORKDIR로 복사</span>
  COPY <span class="nt">--from</span><span class="o">=</span>builder /app/out/full/ <span class="nb">.</span>
  
  <span class="c"># next build</span>
  RUN turbo run build:<span class="k">${</span><span class="nv">APP_ENV</span><span class="k">}</span> <span class="nt">--filter</span><span class="o">=</span><span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="runner-stage">runner stage</h4>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
</pre></td><td class="rouge-code"><pre>FROM base AS runner
  WORKDIR /app
  
  <span class="c"># 불필요한 root 권한 획득을 위해 별도 USER 생성</span>
  RUN addgroup <span class="nt">--system</span> <span class="nt">--gid</span> 1001 nodejs
  RUN adduser <span class="nt">--system</span> <span class="nt">--uid</span> 1001 nextjs
  USER nextjs

  COPY <span class="nt">--from</span><span class="o">=</span>installer /app/apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/next.config.js <span class="nb">.</span>
  COPY <span class="nt">--from</span><span class="o">=</span>installer /app/apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/package.json <span class="nb">.</span>
  
  COPY <span class="nt">--from</span><span class="o">=</span>installer <span class="nt">--chown</span><span class="o">=</span>nextjs:nodejs /app/apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/.next/standalone ./
  COPY <span class="nt">--from</span><span class="o">=</span>installer <span class="nt">--chown</span><span class="o">=</span>nextjs:nodejs /app/apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/.next/static ./apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/.next/static
  COPY <span class="nt">--from</span><span class="o">=</span>installer <span class="nt">--chown</span><span class="o">=</span>nextjs:nodejs /app/apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/public ./apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/public

  EXPOSE 3000
  ENV PORT 3000
  
  CMD node apps/<span class="k">${</span><span class="nv">APP_NAME</span><span class="k">}</span>/server.js
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="gitlab-ci-구성">Gitlab-ci 구성</h3>
<h4 id="build-과정-설계">build 과정 설계</h4>
<p>이제 <code class="language-plaintext highlighter-rouge">build</code>를 위한 ci스크립트 작성을 해봅니다.
우선 일반적인 파이프라인은 아래와 같습니다.</p>
<ul>
  <li>소스코드를 <code class="language-plaintext highlighter-rouge">push</code>하면 해당 프로젝트에 연결된 <code class="language-plaintext highlighter-rouge">gitlab-runner</code>가 동작
    <ul>
      <li>동작 초기엔 소스코드 전체를 내려받기 때문에 추가로 <code class="language-plaintext highlighter-rouge">clone</code>을 할 필요가 없음</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">node package</code>를 설치하고 <code class="language-plaintext highlighter-rouge">next build</code> 진행</li>
  <li><code class="language-plaintext highlighter-rouge">next start</code>를 통해 프로젝트 구동</li>
</ul>

<p>하지만 dockerfile을 제작할때 위와 같은 과정을 작성했기 때문에 실제 ci스크립트에선 작성하지 않고 바로 컨테이너 이미지를 빌드하도록 작성합니다.</p>

<p>보통은 <code class="language-plaintext highlighter-rouge">Docker Daemon</code>을 통해 빌드를 진행하지만 여기선 <code class="language-plaintext highlighter-rouge">kaniko</code>를 가지고 빌드를 진행합니다.
이유는 아래와 같습니다. (인프라팀에서 추천해준 도구)</p>

<ul>
  <li>현재 gitlab-runner는 컨테이너 안에서 돌고 있고 이 안에서 이미지를 빌드해야 하는데, 노드가 분산된 환경에서 Darmon을 띄우는건 리소스 차원에서 비효율적</li>
  <li><code class="language-plaintext highlighter-rouge">Docker Daemon</code>은 Host에 대한 ` Root Privilege`를 요구하기 떄문에 보안적으로 취약</li>
  <li><code class="language-plaintext highlighter-rouge">Daemon</code>이 필요 없고 Host의 <code class="language-plaintext highlighter-rouge">root</code>권한을 요구하지 않는 도구중 <code class="language-plaintext highlighter-rouge">kaniko</code>를 선정</li>
  <li><code class="language-plaintext highlighter-rouge">kaniko</code>는 <code class="language-plaintext highlighter-rouge">Snapshot</code> 기능을 통해 이미지 빌드 과정의 각 Layer를 캐싱함</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">kaniko</code>로 빌드를 진행할땐 <code class="language-plaintext highlighter-rouge">pod.yaml</code>파일을 생성하여 선언적 방식으로 할 수 있으나 우린 <code class="language-plaintext highlighter-rouge">GitOps</code>를 구성하고 있기 때문에 <code class="language-plaintext highlighter-rouge">gitlab-ci.yml</code> 에 명령적 방식으로 소스코드를 작성해야 합니다.
해서 컨테이너 이미지가 저장될 곳을 정하고, 관련 정보를 <code class="language-plaintext highlighter-rouge">gitlab</code> 변수로 지정하기로 합니다.</p>

<h4 id="컨테이너-이미지-저장소-설정">컨테이너 이미지 저장소 설정</h4>
<blockquote>
  <p>Harbor란? 컨테이너 이미지를 프라이빗하게 저장하기 위한 공간</p>
</blockquote>

<p>사내 인프라인 <code class="language-plaintext highlighter-rouge">harbor</code>를 이용합니다.</p>

<p><code class="language-plaintext highlighter-rouge">harbor</code>에 프로젝트를 생성하고, 여기서 활동활 <code class="language-plaintext highlighter-rouge">bot</code>을 생성한 뒤 해당 정보를 <code class="language-plaintext highlighter-rouge">gitlab</code> 프로젝트 변수에 저장하였습니다.</p>

<p>이 변수는 컨테이너 이미지를 빌드할때 캐싱하고, 이미지를 <code class="language-plaintext highlighter-rouge">push</code>할때 사용하게 됩니다.</p>

<h4 id="ci-스크립트-작성">CI 스크립트 작성</h4>
<p>사용된 변수는 아래와 같습니다.</p>

<ul>
  <li>사용자 정의 변수
    <ul>
      <li>REGISTRY : harbor url</li>
      <li>REGISTRY_USER : username</li>
      <li>REGISTRY_PASSWORD : userpassword</li>
      <li>PROJECT_DIR : 저장소가 복제되고 실행되는 경로</li>
      <li>SERVICE_NAME : <code class="language-plaintext highlighter-rouge">monorepo</code>에서 실제 프로젝트 위치</li>
    </ul>
  </li>
  <li>Gitlab 내부 변수
    <ul>
      <li>COMMIT_REF_SLUG : 개발서버 서브도메인</li>
      <li>COMMIT_SHORT_SHA : <code class="language-plaintext highlighter-rouge">commit message</code>의 <code class="language-plaintext highlighter-rouge">hash</code>값중 8자리로 이미지의 <code class="language-plaintext highlighter-rouge">tag</code>로 지정하기 위함</li>
      <li>CI_COMMIT_BRANCH : commit이 발생한 브랜치 이름</li>
      <li>CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : <code class="language-plaintext highlighter-rouge">Merge Request</code> 대상 브랜치 이름, <code class="language-plaintext highlighter-rouge">MR</code>이 생성되었을때만 실행하기 위해 사용</li>
    </ul>
  </li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
</pre></td><td class="rouge-code"><pre><span class="c"># Extend - 컨테이너 이미지 생성</span>
.docker-build:
  image:
    <span class="c"># 구글에서 제공하는 kaniko 이미지 사용</span>
    <span class="c">#   debug 태그의 이미지를 사용해야 파이프라인에 로그 출력</span>
    name: gcr.io/kaniko-project/executor:debug
   
    <span class="c"># entrypoint를 override 하지 않으면 script들이 실행되지 않는다고 함</span>
    entrypoint: <span class="o">[</span><span class="s2">""</span><span class="o">]</span>
    
  before_script:
    <span class="c"># harbor registry 접속 정보 저장</span>
    - <span class="nb">mkdir</span> <span class="nt">-p</span> /kaniko/.docker   
    - <span class="nb">echo</span> <span class="s2">"{</span><span class="se">\"</span><span class="s2">auths</span><span class="se">\"</span><span class="s2">:{</span><span class="se">\"</span><span class="nv">$REGISTRY</span><span class="s2">/fe</span><span class="se">\"</span><span class="s2">:{</span><span class="se">\"</span><span class="s2">username</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="nv">$REGISTRY_USER</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">password</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="nv">$REGISTRY_PASSWORD</span><span class="se">\"</span><span class="s2">}}}"</span> <span class="o">&gt;</span> /kaniko/.docker/config.json

  script:
    <span class="c"># kaniko로 컨테이너 이미지 빌드</span>
    <span class="c">#   tag: latest, hash 2개의 이미지 생성</span>
    <span class="c">#   context : 프로젝트 루트로 docker파일 내에서 명령어를 실행할 위치</span>
    <span class="c">#   dockerfile : Dockerfile 위치</span>
    <span class="c">#   destination : 이미지가 push될 경로와 파일명</span>
    <span class="c">#   --dockerfile : Dockerfile 위치</span>
    <span class="c">#   --destination : 이미지가 push될 경로와 파일명</span>
    <span class="c">#   --cache : 각각의 Layer들을 캐싱</span>
    <span class="c">#   --build-arg SERVICE_NAME=$SERVICE_NAME : monorepo의 서비스명을 주입</span>
    <span class="c">#   --build-arg SERVER_ENV=$SERVER_ENV : 환경변수 주입</span>
    - <span class="o">&gt;</span>
      /kaniko/executor
      <span class="nt">--context</span> <span class="s2">"</span><span class="nv">$PROJECT_DIR</span><span class="s2">"</span>
      <span class="nt">--dockerfile</span> <span class="s2">"</span><span class="nv">$PROJECT_DIR</span><span class="s2">/apps/Dockerfile"</span>
      <span class="nt">--destination</span> <span class="nv">$REGISTRY</span>/fe/apps/<span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span>:<span class="nv">$COMMIT_SHORT_SHA</span>
      <span class="nt">--destination</span> <span class="nv">$REGISTRY</span>/fe/apps/<span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span>:latest
      <span class="nt">--cache</span>
      <span class="nt">--build-arg</span> <span class="nv">SERVICE_NAME</span><span class="o">=</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span>
      <span class="nt">--build-arg</span> <span class="nv">SERVER_ENV</span><span class="o">=</span><span class="k">${</span><span class="nv">SERVER_ENV</span><span class="k">}</span>

<span class="c"># Extend - review deploy rules</span>
.rules-only-develop:
  rules:
    - <span class="k">if</span>: <span class="nv">$CI_COMMIT_REF_NAME</span> <span class="o">==</span> <span class="s2">"develop"</span>
      changes:
        paths:
          - <span class="s1">'.gitlab/ci/**'</span>
          - <span class="s1">'apps/Dockerfile'</span>
          - <span class="s1">'apps/${SERVICE_NAME}/**/*'</span>
          - packages/shared/<span class="k">**</span>/<span class="k">*</span>

<span class="c"># Stage - 컨테이너 이미지 빌드</span>
<span class="s2">"build/web1"</span>:
  stage: build
  interruptible: <span class="nb">true
  </span>extends:
    - .docker-build
    - .rules-only-develop
  tags: <span class="o">[</span>fe-dev]
  variables:
    SERVICE_NAME: web1
    SERVER_ENV: dev
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="harbor-policy-설정">Harbor Policy 설정</h4>
<p>위와 같이 설정했을 경우 <code class="language-plaintext highlighter-rouge">harbor</code>의 저장소엔 이미지들이 계속해서 쌓이게 됩니다.
적절히 유지되도록 <code class="language-plaintext highlighter-rouge">policy</code> 정책 설정을 해줍니다.
<a href="https://saramin.github.io/img/monorepo/image2.png"><img src="https://saramin.github.io/img/monorepo/image2.png" /></a></p>

<p>Harbor &gt; 프로젝트 내에 Policy 탭에 들어가면 <code class="language-plaintext highlighter-rouge">ADD RULE</code> 버튼을 눌러 정책들을 추가해줄 수 있는데, 아래와 같이 설정했습니다.</p>
<ul>
  <li>/web1/develop : 가장 최근 push된 이미지 10개</li>
  <li>/web1/feature*
    <ul>
      <li>최근 push된 이미지 5개</li>
      <li>지난 7일 이내 push된 이미지</li>
    </ul>
  </li>
</ul>

<h2 id="2-chart-override">2. Chart override</h2>

<blockquote>
  <p>선언적 vs 명령적
생성할 리소스를 미리 정의할 것인가, 아님 즉각적인 명령을 통해 진행할 것인가</p>
</blockquote>

<p>개인적으론 추후 여러가지의 애플리케이션 배포를 위해선 선언적 방식으로 진행하는게 관리하기 용이할것 같다는 판단을 하였습니다.</p>

<h3 id="kubernetes-manifest-template">Kubernetes Manifest Template</h3>
<p>리소스를 선언적 방식으로 생성하기 위해선 템플릿을 미리 만들어야 합니다.
인프라팀에선 <code class="language-plaintext highlighter-rouge">kustomize</code>와 <code class="language-plaintext highlighter-rouge">Helm chart</code> 두가지 방식을 추천해주었고, 난 <code class="language-plaintext highlighter-rouge">Helm chart</code> 방식을 선택하였습니다.</p>

<p>템플릿 방식이냐 상속방식이냐 차이인데, 템플릿을 만들어 놓고 root의 value 파일을 통해 여러가지 환경이나 애플리케이션을 대응할 수 있는 방식이 맘에 들어 선택하였습니다. 물론 러닝커브는 더 높다고 합니다.</p>

<h3 id="helm-chart-template">Helm Chart Template</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre>📦helm
 ┣ 📂sample
 ┃ ┗ 📜values.yaml
 ┣ 📂templates
 ┃ ┣ 📜deployment.yaml
 ┃ ┣ 📜hpa.yaml
 ┃ ┣ 📜ingress.yaml
 ┃ ┣ 📜secrets.yaml
 ┃ ┣ 📜service.yaml
 ┃ ┣ 📜serviceaccount.yaml
 ┃ ┗ 📜_helpers.tpl
 ┣ 📜Chart.yaml
 ┗ 📜values.yaml
</pre></td></tr></tbody></table></code></pre></div></div>
<p>위는 현재 구성된 <code class="language-plaintext highlighter-rouge">Helm chart</code> 템플릿의 모습입니다.</p>

<p>최초 <code class="language-plaintext highlighter-rouge">helm create mychart</code> 명령어를 통해 템플릿을 생성한 뒤에 내 입맛에 맞게 조금 변형을 하였습니다.</p>

<p>변형한 부분은 다음과 같습니다.</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
</pre></td><td class="rouge-code"><pre><span class="c1"># values.yaml</span>
<span class="c1">#  컨테이너별 리소스 정의</span>
<span class="na">resources</span><span class="pi">:</span>
  <span class="na">limits</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s">100m</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s">128Mi</span>
  <span class="na">requests</span><span class="pi">:</span>
    <span class="na">cpu</span><span class="pi">:</span> <span class="s">100m</span>
    <span class="na">memory</span><span class="pi">:</span> <span class="s">128Mi</span> 
<span class="c1">#  FE개발 노드로만 배포되도록 label 정의</span>
<span class="na">affinity</span><span class="pi">:</span>
  <span class="na">nodeAffinity</span><span class="pi">:</span>
      <span class="c1"># required</span>
      <span class="na">requiredDuringSchedulingIgnoredDuringExecution</span><span class="pi">:</span>
        <span class="na">nodeSelectorTerms</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">matchExpressions</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">key</span><span class="pi">:</span> <span class="s">node</span>
            <span class="na">operator</span><span class="pi">:</span> <span class="s">In</span>
            <span class="na">values</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="s">sri-fe</span>
            
<span class="c1"># deployment.yaml</span>
<span class="c1">#  컨테이너 헬스체크, 비정상이라고 판단되면 재시작</span>
<span class="c1">#  initialDelaySeconds: 컨테이너 기동 이후 설정값 만큼 대기</span>
<span class="c1">#  periodSeconds: 주기</span>
<span class="na">livenessProbe</span><span class="pi">:</span>
  <span class="na">initialDelaySeconds</span><span class="pi">:</span> <span class="m">15</span>
  <span class="na">failureThreshold </span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">periodSeconds </span><span class="pi">:</span> <span class="m">10</span>
  <span class="na">tcpSocket</span><span class="pi">:</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">3000</span>
<span class="c1">#  컨테이너 헬스체크, 비정상이라고 판단되면 서비스에서 제외</span>
<span class="na">readinessProbe</span><span class="pi">:</span>
  <span class="na">initialDelaySeconds</span><span class="pi">:</span> <span class="m">15</span>
  <span class="na">failureThreshold </span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">periodSeconds </span><span class="pi">:</span> <span class="m">10</span>
  <span class="na">tcpSocket</span><span class="pi">:</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">3000</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>기본적인 부분은 <a href="https://helm.sh/ko/docs/chart_template_guide/getting_started/">가이드문서</a>를 참고하면 될것 같고,</p>

<p>전 환경별, 서비스별로 분리된 values 파일을 생성하기 위해 <code class="language-plaintext highlighter-rouge">sample/</code> 폴더를 생성해서 초기 <code class="language-plaintext highlighter-rouge">value.yaml</code> 파일을 위치해 두었습니다.</p>

<p>이후 파이프라인이 동작할때 해당 파일을 기준으로 변형해서 배포하도록 구성하였습니다.</p>

<h3 id="gitlab-ci-구성-1">Gitlab-ci 구성</h3>
<h4 id="설계">설계</h4>
<p>위에서 작성된 <code class="language-plaintext highlighter-rouge">helm chart</code>를 어떻게 사용하여 컨테이너 이미지를 <code class="language-plaintext highlighter-rouge">k8s</code> 에 배포할 것인지에 대한 부분입니다.</p>

<p>우선 <code class="language-plaintext highlighter-rouge">Helm chart</code>는 별도의 repo로 분리를 하였습니다.</p>

<p><code class="language-plaintext highlighter-rouge">ArgoCD</code>는 구독중인 <code class="language-plaintext highlighter-rouge">k8s Manifest yaml</code>의 변화를 통해 작동하게 되는데 프로젝트 소스코드의 변화엔 작동하지 않게 하기 위함이였다. 물론 <code class="language-plaintext highlighter-rouge">repo</code>안에서 폴더를 구분하여 관리할 순 있겠으나 실제 <code class="language-plaintext highlighter-rouge">value.yaml</code>을 까보면 브랜치와 HEAD 위치까지 설정해줘야 하기 때문에 불필요한 정보는 거를 필요가 있었습니다.</p>

<p>파이프라인이 동작하면 <code class="language-plaintext highlighter-rouge">helmchart</code> repo를 내려받고 그 안에서 <code class="language-plaintext highlighter-rouge">value.yaml</code> 파일을 가공한 뒤 이걸 <code class="language-plaintext highlighter-rouge">ArgoCD</code>에서 감지할 수 있게 하려고 합니다.</p>

<h4 id="스크립트-작성">스크립트 작성</h4>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
</pre></td><td class="rouge-code"><pre><span class="c"># Anchor - helmcahrt values 생성(업그레이드)</span>
.upgrade-yaml: &amp;upgrade-yaml
  - <span class="nb">cp</span> ./sample/values-<span class="k">${</span><span class="nv">ENV_NAME</span><span class="k">}</span>.yaml ./sample/<span class="nv">$VALUES_PATH</span>
  - <span class="o">&gt;</span>
    yq <span class="nt">-i</span> <span class="s1">'
    .nameOverride = "'</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span>-<span class="nv">$CI_COMMIT_REF_SLUG</span><span class="s1">'" |
    .fullnameOverride = "'</span><span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span>-<span class="nv">$CI_COMMIT_REF_SLUG</span><span class="s1">'" |
    .image.repository = "'</span><span class="nv">$CI_REGISTRY</span>/fe/<span class="k">${</span><span class="nv">APP_DIR</span><span class="k">}</span>/<span class="k">${</span><span class="nv">SERVICE_NAME</span><span class="k">}</span>/<span class="nv">$CI_COMMIT_REF_SLUG</span><span class="s1">'" |
    .image.tag = "'</span><span class="nv">$CI_COMMIT_SHORT_SHA</span><span class="s1">'" |
    .imagePullSecrets[0].name = "'</span><span class="nv">$CI_COMMIT_REF_SLUG</span><span class="s1">'" |
    .secretesName = "'</span><span class="nv">$CI_COMMIT_REF_SLUG</span><span class="s1">'" |
    .imageCredentials.registry = "'</span><span class="k">${</span><span class="nv">CI_REGISTRY</span><span class="k">}</span><span class="s1">'" |
    .imageCredentials.username = "'</span><span class="k">${</span><span class="nv">CI_REGISTRY_USER</span><span class="k">}</span><span class="s1">'" |
    .imageCredentials.password = "'</span><span class="k">${</span><span class="nv">CI_REGISTRY_PASSWORD</span><span class="k">}</span><span class="s1">'" |
    .imageCredentials.email = "'</span><span class="k">${</span><span class="nv">GITLAB_USER_EMAIL</span><span class="k">}</span><span class="s1">'" |
    .ingress.hosts[0].host = "'</span><span class="k">${</span><span class="nv">SERVICE_URL</span><span class="k">}</span><span class="s1">'"
    '</span> ./sample/<span class="nv">$VALUES_PATH</span>
  - <span class="nb">cat</span> ./sample/<span class="nv">$VALUES_PATH</span>
  - <span class="nb">mv</span> ./sample/<span class="nv">$VALUES_PATH</span> ./<span class="nv">$VALUES_PATH</span>
  - git add <span class="nb">.</span>
  - git commit <span class="nt">-m</span> <span class="s2">"[skip ci] </span><span class="nv">$CI_COMMIT_MESSAGE</span><span class="s2">"</span>
  - git push <span class="nt">-u</span> origin main
</pre></td></tr></tbody></table></code></pre></div></div>
<p>위 코드는 파이프라인 스크립트중 <code class="language-plaintext highlighter-rouge">chart override</code>를 하기 위한 Anchor 부분으로 내용은 아래와 같습니다.</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">./sample</code> 폴더의 values 파일을 목적에 맞는 파일명으로 복사한 뒤 yaml 파일을 알맞게 수정</li>
  <li><code class="language-plaintext highlighter-rouge">.image.repository</code>와 <code class="language-plaintext highlighter-rouge">.image.tag</code>를 새로 생성된 컨테이너 정보에 맞게 변경</li>
  <li><code class="language-plaintext highlighter-rouge">.secretesName</code>에는 repository에 접근하기 위한 인증정보의 이름을 정의</li>
  <li><code class="language-plaintext highlighter-rouge">.imageCredentials.*</code>은 인증정보에 필요한 값을 주입</li>
  <li>변경된 내역을 chart repo에 push</li>
  <li>(주의) 최초 value 파일을 root로 복사한뒤 수정하지 않고 원 위치에서 복사&amp;수정 한 뒤 복사하는 이유는 해당파일을 <code class="language-plaintext highlighter-rouge">ArgoCD</code>가 구독하고 있기 때문이고, 하나의 구문으로 <code class="language-plaintext highlighter-rouge">create</code>와 <code class="language-plaintext highlighter-rouge">upsert</code>를 같이 하기 위함</li>
</ul>

<p>위와 같이 작성한 구문을 공통화시켜서 review 서버를 생성했을때도 사용할 수 있도록 하였습니다.</p>

<h2 id="3-app-create--update">3. App create &amp; update</h2>
<p>자 이제 <code class="language-plaintext highlighter-rouge">ArgoCD</code>에 <code class="language-plaintext highlighter-rouge">application</code>을 생성할 차례입니다.</p>

<p>기본적으로 <code class="language-plaintext highlighter-rouge">application</code>은 <code class="language-plaintext highlighter-rouge">ArgoCD</code> 대시보드에서 생성&amp;관리 등이 가능합니다.
그렇지만 <code class="language-plaintext highlighter-rouge">GitOps</code>로 구성하고 있기 때문에 <code class="language-plaintext highlighter-rouge">cli</code>를 통해 생성해야 합니다.</p>

<p><code class="language-plaintext highlighter-rouge">application</code>, <code class="language-plaintext highlighter-rouge">application set</code> 2가지 방식으로 생성할 수 있지만, 현재는 front 단독 서비스만 배포하려 하기 때문에 <code class="language-plaintext highlighter-rouge">application</code> 생성방식으로 진행했습니다.</p>

<h3 id="applicationyaml">application.yaml</h3>
<p>해당 파일은 <code class="language-plaintext highlighter-rouge">application</code>을 선언적인 방식으로 배포하기 위해 생성하였고 <code class="language-plaintext highlighter-rouge">HelmChart</code> 와 같은 repo에 위치하였습니다.</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">argoproj.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Application</span>
<span class="na">metadata</span><span class="pi">:</span> <span class="c1"># 기본 정보</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">web1-develop"</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">labels</span><span class="pi">:</span> <span class="c1"># 필요시 생성, 여기선 환경별 구분을 식별하기 위해 생성</span>
    <span class="na">branch</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="c1"># project : ArgoCD에서 생성한 프로젝트 이름</span>
  <span class="na">project</span><span class="pi">:</span> <span class="s">frontend</span>
  
  <span class="c1"># source : application manifests, helmchart repo 정보</span>
  <span class="na">source</span><span class="pi">:</span>
    <span class="na">repoURL</span><span class="pi">:</span> <span class="s1">'</span><span class="s">${helm</span><span class="nv"> </span><span class="s">chart</span><span class="nv"> </span><span class="s">repo</span><span class="nv"> </span><span class="s">url}'</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">.</span>
    <span class="na">targetRevision</span><span class="pi">:</span> <span class="s">HEAD</span>
    <span class="na">helm</span><span class="pi">:</span>
      <span class="na">valueFiles</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s2">"</span><span class="s">"</span>

  <span class="c1"># Destination cluster and namespace to deploy the application</span>
  <span class="na">destination</span><span class="pi">:</span>
    <span class="na">server</span><span class="pi">:</span> <span class="s1">'</span><span class="s">${kube</span><span class="nv"> </span><span class="s">cluster</span><span class="nv"> </span><span class="s">url}'</span>
    <span class="na">namespace</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  
  <span class="c1"># Sync policy</span>
  <span class="na">syncPolicy</span><span class="pi">:</span>
    <span class="na">automated</span><span class="pi">:</span>
      <span class="na">prune</span><span class="pi">:</span> <span class="kc">true</span>  <span class="c1"># git에서 리소스를 삭제하면 kube에서도 삭제되도록 </span>
    <span class="na">syncOptions</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">CreateNamespace=true</span>
      <span class="pi">-</span> <span class="s">PruneLast=true</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="gitlab-ci-구성-2">Gitlab-ci 구성</h3>
<h4 id="설계-1">설계</h4>
<p><code class="language-plaintext highlighter-rouge">applicatioin.yaml</code> 파일은 위 <code class="language-plaintext highlighter-rouge">helm chart</code> 배포할때와 유사한 과정으로 진행합니다.</p>

<p>파이프라인이 동작하면 앞에서 내려받은 소스들에서 <code class="language-plaintext highlighter-rouge">applicatioin.yaml</code> 파일을 사용하여 <code class="language-plaintext highlighter-rouge">app create</code>를 진행합니다.</p>

<h4 id="스크립트-작성-1">스크립트 작성</h4>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="c1"># Anchor - argocd application 생성(업그레이드)</span>
<span class="na">.create-argocd-app</span><span class="pi">:</span> <span class="nl">&amp;create-argocd-app</span>
  <span class="pi">-</span> <span class="pi">&gt;</span>
    <span class="s">argocd app create web1-${ENV_NAME}</span>
    <span class="s">--server $ARGOCD_SERVER</span>
    <span class="s">--grpc-web</span>
    <span class="s">--auth-token $ARGOCD_TOKEN</span>
    <span class="s">--file ./argocd/application-${ENV_NAME}.yaml</span>
    <span class="s">--name ${SERVICE_NAME}-$CI_COMMIT_REF_SLUG</span>
    <span class="s">--label branch=$CI_COMMIT_REF_SLUG</span>
    <span class="s">--values $VALUES_PATH</span>
    <span class="s">--dest-namespace ${SERVICE_NAME}</span>
    <span class="s">--upsert</span>
    <span class="s">--loglevel debug</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p>위 코드는 ArgoCD에 app create &amp; update를 하는 부분이다.
flags 값들을 살펴보면 아래와 같습니다</p>
<ul>
  <li>application.yaml 파일을 사용하기 위해 <code class="language-plaintext highlighter-rouge">app create</code> 명령어 뒤에 <code class="language-plaintext highlighter-rouge">app.name</code>을 명시해주고 <code class="language-plaintext highlighter-rouge">--file</code> 에 경로를 추가</li>
  <li><code class="language-plaintext highlighter-rouge">--server</code>, <code class="language-plaintext highlighter-rouge">-auth-token</code> 정보를 넣어줘서 ArgoCD 접근을 허용</li>
  <li><code class="language-plaintext highlighter-rouge">--name</code>, <code class="language-plaintext highlighter-rouge">--label</code> 값을 통해 환경에 맞는 <code class="language-plaintext highlighter-rouge">app.name</code>을 재정의</li>
  <li><code class="language-plaintext highlighter-rouge">--values</code> 는 yaml 파일명</li>
  <li><code class="language-plaintext highlighter-rouge">--dest-namespace</code>를 추가하여 사용한 클러스터가 생성한 서비스명을 명시해주고</li>
  <li><code class="language-plaintext highlighter-rouge">--upsert</code> 명령어를 통해 생성된 app 이라면 update를 진행토록 정의</li>
</ul>

<p>여기까지 진행하면 git의 역할은 끝났습니다.</p>

<p>이후는 ArgoCD 에서 진행상황을 볼 수 있고, 실제 컨테이너가 적용되는 시점은 ArgoCD 내부에 정의된 주기(기본 3min)마다 <code class="language-plaintext highlighter-rouge">value</code>파일을 체크해서 컨테이너 이미지의 <code class="language-plaintext highlighter-rouge">tag</code>가 변경되면 새로 이미지를 받아서 <code class="language-plaintext highlighter-rouge">pod</code>를 재구성하면서 <code class="language-plaintext highlighter-rouge">deploy</code>를 진행하게 됩니다.</p>

<h3 id="끝맺으며">끝맺으며</h3>
<p>지금까지 Monorepo와 GitOps 가 결합된 배포환경을 소개해 드렸습니다.</p>

<p>개인적으론 막연하게 알고 있던 docker, k8s, harbor..
그리고 이번에 처음 접한 kaniko, ArgoCD 등에 대해서 조금은 알게 된 것 같습니다.</p>

<p>이 포스팅에 디테일하게 기술하진 못했지만 처음 한번만 구성해놓으면 이후 서비스가 추가 될때마다 여기 연동만 시켜도 배포될 수 있도록 모듈화 작업도 같이 진행을 했습니다.</p>

<p>gitlab-ci를 통해 일반적인 파이프라인을 구성해본건 햇수로 7년째가 되었는데, 모듈화 과정을 진행하려다 보니 이렇게까지 디테일하게 파고 든건 또 처음이였던 같습니다.</p>

<p>이젠 어느 누가 담당해도 잘 운영될 수 있도록 환경을 만드는 숙제가 남아있습니다.
전반적인 내용을 팀원들에게 리뷰하고 메뉴얼 잘 만들어 놓으면 되지 않을까 싶습니다.</p>

<p>지금까지 읽어주셔서 감사합니다.</p>

<p><strong>스페셜 땡스 진주, 형규</strong></p>]]></content><author><name>조성창</name></author><category term="DevOps" /><category term="CI/CD" /><category term="GitOps" /><category term="MonoRepo" /><summary type="html"><![CDATA[Monorepo와 GitOps, 그리고 그 안에 NextJs]]></summary></entry><entry><title type="html">Vue3, Composition API와 Pinia를 이용한 상태관리 (2)</title><link href="https://saramin.github.io/2024-03-25-vue3-composition-api-pinia-2/" rel="alternate" type="text/html" title="Vue3, Composition API와 Pinia를 이용한 상태관리 (2)" /><published>2024-03-25T00:00:00+09:00</published><updated>2024-03-25T00:00:00+09:00</updated><id>https://saramin.github.io/vue3-composition-api-pinia-2</id><content type="html" xml:base="https://saramin.github.io/2024-03-25-vue3-composition-api-pinia-2/"><![CDATA[<p>이번 포스팅은 <a href="https://saramin.github.io/2023-06-27-vue3-composition-api-pinia-1/" target="_blank">Vue3, Composition API와 Pinia를 이용한 상태관리 (1)</a> 글의 후편입니다. 
이전 포스팅에서 Composition API, Pinia에 대한 이론적인 설명을 다루었다면 <strong><em>이번 포스팅에서는 실제로 Pinia를 어떤 방식으로 적용했고 어떤 작업 결과를 냈는지 다루려합니다.</em></strong></p>

<p>글의 목차는 아래와 같습니다. <br />
03. 적용 결과: 인재풀에서의 Pinia<br />
04. 느낀점: 이론적 장단점과 내가 느낀 실제</p>

<p>Vue3가 적용된 <code class="language-plaintext highlighter-rouge">인재풀</code>서비스 런칭으로 약 1년 정도의 시간이 지나면서 당시 프로젝트 작업자 외에 많은 팀원들이 Vue3를 직접 경험할 수 있었습니다. 프로젝트 작업시의 관점 뿐만 아니라 서비스 운영, 유지보수 관점에서 느낀 <code class="language-plaintext highlighter-rouge">Vue3</code>, <code class="language-plaintext highlighter-rouge">Composition API</code>, <code class="language-plaintext highlighter-rouge">Pinia</code>의 장단점에 대해 이야기하고자 합니다.</p>

<p>컴포넌트 구성 방식을 좌우하는 주요 기능인 <code class="language-plaintext highlighter-rouge">Composition API</code>와 <code class="language-plaintext highlighter-rouge">&lt;script setup&gt;</code>에 대해 설명하고 본격적으로 <code class="language-plaintext highlighter-rouge">Pinia</code>가 무엇인지  마지막으로 실제 사람인 인재풀에서는 어떤 부분에 어떻게 적용되었고 그 과정을 통해 느낀점을 이야기해보겠습니다.</p>

<h1 id="3-적용-결과-인재풀에서의-pinia">3. 적용 결과: 인재풀에서의 Pinia</h1>

<p>설명에 앞서 사람인 인재풀 서비스에 대한 이해가 필요합니다. 인재풀은 기업 회원에게 제공되고 있는 서비스로 아래 화면과 같이 좌측 검색 필터를 활용해 원하는 인재를 검색할 수 있는 서비스입니다. 사용자가 원하는 이력서 항목 조건을 설정하면 그에 맞는 인재 결과를 제공해주고 있습니다.
<a href="https://saramin.github.io/img/vue3/talentpool_screenshot.png"><img src="https://saramin.github.io/img/vue3/talentpool_screenshot.png" /></a></p>

<p>인재풀 검색 필터 기능은 경력, 지역, 직무, 학력, 연봉, … 등 총 17가지 항목의 조건 변경이 가능합니다. 다양한 세부 항목이 존재하는 만큼 필요로하는 데이터가 복잡하며 데이터 변경이 잦은 영역입니다. 이러한 기능적 특징 때문에 검색 필터 영역을 단일 컴포넌트로 관리할 경우 코드 복잡도가 올라갈 수 있는 상황이었습니다. 따라서 검색 필터 영역은 <code class="language-plaintext highlighter-rouge">Filter</code>라는 상위 컴포넌트 아래 항목 단위로 분리된 자식 컴포넌트를 갖도록 구성했습니다.
<a href="https://saramin.github.io/img/vue3/talentpool_component.png"><img src="https://saramin.github.io/img/vue3/talentpool_component.png" /></a></p>

<p>인재 검색 기능은 아래와 같이 동작하게 됩니다.
<a href="https://saramin.github.io/img/vue3/talentpool_search.png"><img src="https://saramin.github.io/img/vue3/talentpool_search.png" /></a></p>

<p>모든 검색 필터 조건 정보를 담은 JSON 형태의 데이터가 인재 검색 API 요청 파라미터 정보로 활용됩니다. 검색 필터의 세부 항목에서 변경이 발생하게 되면 해당 항목 컴포넌트 내부에서 검색 필터 데이터 변경, 검색 리스트 API 재호출이 순차적으로 수행되게 됩니다. 이때 필터 데이터 변경, API 재호출 동작은 각기 다른 컴포넌트 내부 동작에 의해 발생하지만 최종적으로 활용되는 데이터는 검색 필터 조건 정보를 담은 공통의 데이터 입니다.</p>

<p>활용되는 필터 데이터는 공통의 데이터이기 때문에 어떤 컴포넌트에서 해당 데이터를 참조하던 동일한 상태여야합니다. 활용되는 공통 데이터의 구조가 복잡하다는 점과 각기 다른 컴포넌트에서 공통의 데이터 상태가 공유되는 프로젝트 특성상 상태관리 라이브러리 활용의 장점을 경험하기에 적합했고 <code class="language-plaintext highlighter-rouge">Pinia</code>라이브러리 적용을 결정하게 되었습니다. (물론 검색 필터 기능 외에 다른 기능을 위해서도 스토어를 정의하며 상태 관리 라이브러리를 활용했습니다.)</p>

<p>이번 프로젝트에서는 모든 작업자들이 <code class="language-plaintext highlighter-rouge">Pinia</code>를 처음 접하는 상황이었기에 공식 문서를 적극 활용해 공식 문서에서 제공해주는 기본 구조를 기반으로 *<code class="language-plaintext highlighter-rouge">store</code>를 생성해주었습니다.</p>

<blockquote>
  <p>스토어란? <br />
스토어는 컴포넌트 트리에 바인딩되지 않은 상태 및 처리해야 할 일의 로직을 가지는 독립적인 것입니다.  즉, 전역 상태를 호스팅합니다. 항상 존재하고 모두가 읽고 쓸 수 있는 컴포넌트와 비슷합니다. state, getters, actions라는 세 가지 개념이 있으며, 이러한 개념은 컴포넌트의 data, computed, methods와 동일하다고 가정해도 무방합니다.</p>
</blockquote>

<p>실제 프로젝트 적용 예시를 인재풀 프로젝트 일부를 통해 설명하겠습니다.</p>

<p>인재풀 검색 기능을 위해 정의된 검색 조건 스토어 코드 일부입니다.</p>
<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
</pre></td><td class="rouge-code"><pre>export const useSearchConditionsStore = defineStore("searchConditions", () =&gt; {
      // ref()는 state 속성
      const conditions = ref(deepCopy(defaultFilterState));
 
      // getters
      const getFilters = computed(() =&gt; conditions.value);

      // actions
      const updateFilters = (filters) =&gt; {};
 
      const resetFilters = () =&gt; {};

      return {
          conditions,
          getFilters,
          updateFilters,
          resetFilters
      };
});
</pre></td></tr></tbody></table></code></pre></div></div>
<p>스토어를 정의하는 문법에는 <code class="language-plaintext highlighter-rouge">옵션 스토어</code> 방식과 <code class="language-plaintext highlighter-rouge">셋업 스토어</code>방식이 있습니다. 프로젝트 전반적으로 <code class="language-plaintext highlighter-rouge">Composition API</code>를 활용하고 있기 때문에 <code class="language-plaintext highlighter-rouge">Composition API</code>의 <code class="language-plaintext highlighter-rouge">setup</code> 함수와 유사한 형태인 <code class="language-plaintext highlighter-rouge">셋업 스토어</code> 방식을 활용해 스토어를 정의해주었습니다.</p>

<p>이렇게 정의된 스토어의 상태값은 각각의 필터 항목 컴포넌트에서 접근 가능하며 어느 곳에서 접근해도 동일한 상태를 유지하는 공통의 상태 값이 됩니다. 사용 예시로 경력 필터 영역을 살펴보겠습니다.</p>
<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
</pre></td><td class="rouge-code"><pre><span class="nt">&lt;</span><span class="k">script</span> <span class="na">setup</span><span class="nt">&gt;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">storeToRefs</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">pinia</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">useSearchConditionsStore</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@/stores/modules/SearchConditions</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">searchCondition</span> <span class="o">=</span> <span class="nf">useSearchConditionsStore</span><span class="p">();</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">getFilters</span> <span class="p">}</span> <span class="o">=</span> <span class="nf">storeToRefs</span><span class="p">(</span><span class="nx">searchCondition</span><span class="p">);</span>
<span class="nt">&lt;/</span><span class="k">script</span><span class="nt">&gt;</span>

<span class="nt">&lt;</span><span class="k">template</span><span class="nt">&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"talent_filter_item"</span><span class="nt">&gt;</span>
           <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"talent_filter_tit"</span><span class="nt">&gt;</span>경력<span class="nt">&lt;/span&gt;</span>
           <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"filter_input"</span><span class="nt">&gt;</span>
              <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"InpBox SizeS from_to"</span><span class="nt">&gt;</span>
                 <span class="nt">&lt;select</span>
                     <span class="na">id=</span><span class="s">"career_min"</span>
                     <span class="na">name=</span><span class="s">"career_min"</span>
                     <span class="na">v-model=</span><span class="s">"getFilters.career_min"</span>
										 <span class="nt">&gt;</span>
                        <span class="nt">&lt;option</span> <span class="na">:value=</span><span class="s">"''"</span><span class="nt">&gt;</span>선택<span class="nt">&lt;/option&gt;</span>
                        <span class="nt">&lt;option</span> <span class="na">:value=</span><span class="s">"0"</span><span class="nt">&gt;</span>신입<span class="nt">&lt;/option&gt;</span>
                        <span class="nt">&lt;option</span> <span class="na">:value=</span><span class="s">"n"</span> <span class="na">v-for=</span><span class="s">"n in 20"</span><span class="nt">&gt;</span>년 이상<span class="nt">&lt;/option&gt;</span>
                    <span class="nt">&lt;/select&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
            <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"txt_from_to"</span><span class="nt">&gt;</span>~<span class="nt">&lt;/span&gt;</span>
            <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"InpBox SizeS from_to"</span><span class="nt">&gt;</span>
                <span class="nt">&lt;select</span>
                    <span class="na">id=</span><span class="s">"career_max"</span>
                    <span class="na">name=</span><span class="s">"career_max"</span>
                    <span class="na">v-model=</span><span class="s">"getFilters.career_max"</span>
                <span class="nt">&gt;</span>
                    <span class="nt">&lt;option</span> <span class="na">:value=</span><span class="s">"''"</span><span class="nt">&gt;</span>선택<span class="nt">&lt;/option&gt;</span>
                    <span class="nt">&lt;option</span> <span class="na">:value=</span><span class="s">"0"</span><span class="nt">&gt;</span>신입<span class="nt">&lt;/option&gt;</span>
                    <span class="nt">&lt;option</span> <span class="na">:value=</span><span class="s">"n"</span> <span class="na">v-for=</span><span class="s">"n in 20"</span><span class="nt">&gt;</span>년 이하<span class="nt">&lt;/option&gt;</span>
                <span class="nt">&lt;/select&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/</span><span class="k">template</span><span class="nt">&gt;</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>경력 구간 입력을 위한 셀렉트 박스에 검색조건 상태값을 양방향 바인딩해주면 셀렉트 박스 동작이 발생했을 때 검색 조건 스토어 상태값에 변경을 발생시킬 수 있습니다. 
그리고 변경이 발생할 때 마다 변경된 조건에 맞게 검색 리스트가 재호출 되어야 합니다.</p>

<p>아래는 검색 조건 상태 변화를 감지해 결과 리스트를 재호출하고 리스트 결과를 렌더해주는 코드 일부입니다.</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
</pre></td><td class="rouge-code"><pre><span class="nt">&lt;</span><span class="k">script</span> <span class="na">setup</span><span class="nt">&gt;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">ref</span><span class="p">,</span> <span class="nx">watch</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">vue</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">useSearchConditionsStore</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@/stores/modules/SearchConditions</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">let</span> <span class="nx">recommendList</span> <span class="o">=</span> <span class="nf">ref</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>

<span class="c1">// store 선언</span>
<span class="kd">const</span> <span class="nx">conditionsStore</span> <span class="o">=</span> <span class="nf">useSearchConditionsStore</span><span class="p">();</span>

<span class="c1">// 반응형 상태</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">getFilters</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">conditionsStore</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">getSearchTalentList</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">recommendList</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">conditionsStore</span><span class="p">.</span><span class="nf">getSearchList</span><span class="p">(</span><span class="nf">makeParams</span><span class="p">());</span>
<span class="p">};</span>

<span class="c1">// 필터값 변경 감지</span>
<span class="nf">watch</span><span class="p">(</span><span class="nx">getFilters</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
     <span class="c1">// API 호출</span>
     <span class="nf">getSearchTalentList</span><span class="p">();</span>
 <span class="p">},</span>
 <span class="p">{</span> <span class="na">deep</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="c1">// 해당 개체의 모든 중첩 속성을 탐색</span>
<span class="p">);</span>
<span class="nt">&lt;/</span><span class="k">script</span><span class="nt">&gt;</span>

<span class="nt">&lt;</span><span class="k">template</span><span class="nt">&gt;</span>
     <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"talent_list_wrap"</span><span class="nt">&gt;</span>
          <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"talent_list"</span><span class="nt">&gt;</span>
          <span class="c">&lt;!-- 리스트 or empty case --&gt;</span>
          <span class="nt">&lt;List.TalentList</span> <span class="na">v-if=</span><span class="s">"recommendList?.length &gt; 0"</span> <span class="na">:talentList=</span><span class="s">"recommendList"</span> <span class="nt">/&gt;</span>
          <span class="nt">&lt;List.NoResult</span> <span class="na">v-else</span> <span class="nt">/&gt;</span>
          <span class="nt">&lt;/div&gt;</span>
     <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/</span><span class="k">template</span><span class="nt">&gt;</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">watch</code>함수를 통해 검색 조건 상태의 변경을 감지합니다. 이때 검색 조건의 데이터 구조가 중첩된 속성을 갖고 있기 때문에 내부 값의 변경까지 감지해내기 위해서 deep 속성을 활용합니다.</p>

<p>간단한 코드 예시로 전체적인 검색조건 변경과 그로 인한 리스트 재호출 과정을 저희 프로젝트에 어떻게 구현했는지 살펴보았습니다. 
상태 관리 라이브러리의 활용으로 각기 다른 컴포넌트에서도 공통 데이터에 손 쉽게 접근 할 수 있었으며 발생하는 이벤트, 데이터의 변화에 의한 이후 로직을 간단하게 구현할 수 있었습니다.</p>

<p>※ 위 예시 코드들은 간단한 설명을 위해 상태 관리 라이브러리 사용과 관련된 기본적인 흐름이 보이는 코드의 일부분만 발췌한 것이라는 점 참고부탁드립니다.</p>

<h1 id="4--느낀점-이론적-장단점과-내가-느낀-실제">4.  느낀점: 이론적 장단점과 내가 느낀 실제</h1>
<p><code class="language-plaintext highlighter-rouge">Pinia</code>의 공식 문서에서 크게 다섯가지 특징이 있다고  말합니다. 앞선 포스팅에서도 <code class="language-plaintext highlighter-rouge">Pinia</code>의 주요 특징에 대해 언급한 바 있습니다.</p>
<ul>
  <li>가볍고 직관적인 API</li>
  <li>TypeScript와의 호환성</li>
  <li>컴포넌트별 상태 관리로 상태관리 편리성</li>
  <li>유연한 상태 관리</li>
  <li>성능 최적화에 용이함</li>
</ul>

<p>공식 문서에서 말하는 특징들을 실제 작업을 진행하면서도 느낄 수 있었습니다.  저희 프로젝트에서는 아쉽게도 TypeScript를 활용하고 있지 않아 두번째 특징을 느낄 순 없었지만 그 외의 주요 특징에 대해서는 모두 공감할 수 있었습니다.</p>

<p>그러나 프로젝트 초기 작업자의 입장에서는 사용상의 단점을 크게 느끼지 못했습니다. 아마  無의 상태에서 나름의 학습 결과들을 가지고 프로젝트를 쌓아 올려간 것이기에 사용상의 장점이 더 크게 와닿았기 때문이라고 생각합니다. 프로젝트 런칭 이후 약 1년 가량의 시간이 지나면서 초기 작업자 외 팀원들도 Vue3, Pinia를 학습하고 사용하게 되었습니다. 
프로젝트 초기 작업자의 관점이 아닌 프로젝트 유지보수 관점에서의 팀원분들의 Vue3 사용 경험을 조사해보았습니다.</p>
<h2 id="4-1-프로젝트-런칭-후-작업자들의-작업-소감">4-1. 프로젝트 런칭 후 작업자들의 작업 소감</h2>
<p>총 네가지 항목에 대한 작업자들의 의견을 조사했습니다.</p>

<ol>
  <li>Vue2와 다른 Vue3만의 특징 적용에 어려움이 있었나요?</li>
  <li>Composition API 방식에 대해 어떻게 생각하시나요?</li>
  <li>Pinia 상태관리 라이브러리 적용에 대해 어떻게 생각하시나요?</li>
  <li>Vue3 사용 및 인재풀 프로젝트에 대한 생각</li>
</ol>

<p>인재풀과 관련된 작업을 진행한 경험이 있는 7명의 작업자분들이 답변을 해주셨습니다.</p>

<h4 id="1-vue2와-다른-vue3만의-특징-적용에-어려움이-있었나요">1. Vue2와 다른 Vue3만의 특징 적용에 어려움이 있었나요?</h4>
<p>약 70%의 작업자가 ‘적응하는 것에 큰 어려움이 없었다’ 라고 답했으며 각 15%의 작업자가 ‘적응하는데 시간이 걸렸다’, ‘Vue2 경험이 없어 잘 모르겠다’라고 답했습니다.</p>

<h4 id="2-composition-api-방식에-대해-어떻게-생각하시나요">2. Composition API 방식에 대해 어떻게 생각하시나요?</h4>
<p>약 70%의 작업자가 ‘Composition API가 Options API 방식보다 좋다’고 답했으며 나머지 30% 작업자가 ‘비슷한 것 같다’, ‘잘 모르겠다’고 답했습니다.</p>

<h4 id="3-pinia-상태관리-라이브러리-적용에-대해-어떻게-생각하시나요">3. Pinia 상태관리 라이브러리 적용에 대해 어떻게 생각하시나요?</h4>
<p>약 70% 작업자가 ‘Vuex보다 쉽게 느껴진다’고 답했으며 각 15%의 작업자가 ‘Pinia가 react의 상태관리 툴과 비슷하다’, ‘잘 모르겠다’고 답했습니다.</p>

<h4 id="4-vue3-사용-및-인재풀-프로젝트에-대한-생각">4. Vue3 사용 및 인재풀 프로젝트에 대한 생각</h4>
<p>설문 응답에 공통적으로 언급된 장단점은 아래와 같습니다.</p>
<h4 id="장점">장점</h4>
<ul>
  <li>코드의 가독성이 좋아짐</li>
  <li><code class="language-plaintext highlighter-rouge">Composition API</code> 사용으로 인해 코드 작성이 간결해짐</li>
  <li><code class="language-plaintext highlighter-rouge">SFC</code> 사용으로 컴포넌트 정의가 명확해짐</li>
  <li>코드 재사용성 증가</li>
  <li>데이터 처리의 편리성 (반응형 데이터 처리, 전역 데이터 처리, 양방향 통신)</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;Teleport&gt;</code>를 활용한 간편한 모달 기능 구현</li>
</ul>

<h4 id="단점">단점</h4>
<ul>
  <li>Vue3에 익숙해지기까지 시간이 필요</li>
  <li>Vue3의 최대 이점 중 하나인 Typescript 미사용</li>
</ul>

<p>단순히 Vue3 사용 뿐만 아니라 Composition API, Pinia 사용으로 느낀점, 새롭게 구성된 인재풀 프로젝트 자체에 대해 느낀점을 응답해주셨습니다.</p>

<p>이런 공통적인 장단점 외에 프로젝트에 대한 피드백 내용들도 있었습니다.</p>
<h4 id="개선점">개선점</h4>
<ul>
  <li>불필요한 상태 변경, 동작 발생 부분에 대한 개선</li>
  <li>코드 스타일 통일 (<code class="language-plaintext highlighter-rouge">Composition API</code>, <code class="language-plaintext highlighter-rouge">Options API</code> 사용 혼재)</li>
  <li><code class="language-plaintext highlighter-rouge">watch</code> 사용으로 인한 중복 api 호출 개선</li>
  <li>axios 대신 vue-query 도입</li>
</ul>

<p>프로젝트 초기 작업자들 또한 새롭게 Vue3를 학습하고 적용했기 때문에 상태관리와 프로젝트 구조에 대한 이해가 부족한 부분이 있지 않았나 싶습니다. 
또한 프로젝트 런칭 이후 새로 적용된 기술에 대한 공유가 부족해 <code class="language-plaintext highlighter-rouge">Composition API</code> 적용에 어려움이 있어 코드 스타일이 혼재된 것으로 보여 아쉬운 부분입니다. 
프로젝트 런칭 후 시간이 지나면서 저도 같은 부분에 대한 개선이 필요하다고 느꼈기 때문에 개선점들은 차차 해결해 나가도록 하겠습니다.</p>

<p>위 설문 내용은 Vue3를 사용할 때 뿐만 아니라 프로젝트 내에 신기술 적용에 앞서 고려할 부분에 대해 참고할 자료가 될 수 있을것이라 생각이 듭니다. 
특히 혼재된 코드 스타일 문제가 신기술 적용 전 고려할 주요한 점 한가지를 일깨워주는 것 같습니다. 신기술 적용 후 적용된 기술에 대한 문서화 혹은 기술 전파 과정이 미흡할 때 이와 같은 문제가 발생할 수 있다고 생각합니다. 새로운 기술 도입에 앞서 런칭 후 어떤식으로 기술을 전파할지에 대해서 미리 고려해 둔다면 신기술 적용 후 더 안정적인 프로젝트 유지 보수가 가능할 것 같습니다.</p>

<h2 id="4-2-개인적-소감">4-2. 개인적 소감</h2>
<p>2022년 11월 인재풀 서비스 개편 프로젝트를 계기로 Vue3를 처음 사람인 서비스에 적용하게 되었습니다. 
Vue.js로 구성된 기존 운영 서비스들은 모두 Vue2를 기반으로 하고 있어 그동안 Vue3를 실무에 적용할 기회가 없었습니다. 
Vue3를 처음 적용하는 프로젝트인 만큼 Vue3가 갖는 특장점을 최대한 활용해보자는 작업자들간의 공통된 목표로 <code class="language-plaintext highlighter-rouge">Composition API</code>, <code class="language-plaintext highlighter-rouge">Pinia</code>, <code class="language-plaintext highlighter-rouge">Vite</code>를 적용해 작업을 진행했습니다.</p>

<p>공식 문서를 제외하고 <code class="language-plaintext highlighter-rouge">Pinia</code>에 대한 참고 자료가 많지 않아 어려움이 많았지만 <code class="language-plaintext highlighter-rouge">Pinia</code>를 사용한 상태 관리 경험은 꽤나 의미있었습니다. 
<code class="language-plaintext highlighter-rouge">Vuex</code>에 익숙해져있었기 때문에 작업 초반에는 낯설게 느껴졌으나 <code class="language-plaintext highlighter-rouge">Pinia</code> 활용에 충분히 익숙해진 뒤에는 <code class="language-plaintext highlighter-rouge">Pinia</code>의 장점을 잘 느낄 수 있었습니다. 
<code class="language-plaintext highlighter-rouge">Pinia</code>의 장점 중 하나인 직관적인 코드 구조덕에 기술을 익히고 활용하기까지 시간이 오래 걸리지 않았고 개인적으로 <code class="language-plaintext highlighter-rouge">Pinia</code>에 적응한 후에는 <code class="language-plaintext highlighter-rouge">Vuex</code>를 익히고 적응할 때 보다 작업 효율이 향상 되었다고 느꼈습니다. 
이 부분은 개인적 취향이기 때문에 저와 잘 맞는 라이브러리라고 느꼈습니다. 그리고 <code class="language-plaintext highlighter-rouge">Pinia</code>의 함수 기반 컴포넌트 단위 구성 또한 개인적으로 <code class="language-plaintext highlighter-rouge">Vuex</code>보다 협업자들간의 작업 파악에 더 용이하다고 느꼈습니다. 
이론적으로 말하는 실제 장점을 프로젝트 진행 과정 중에 충분히 느낄 수 있었습니다. 아무래도 프로젝트 초기 구축부터 작업에 참여해와서인지 사용상의 단점보다 장점들이 더 크게 와닿았던 듯 합니다.</p>

<p>이번 Vue3 적용기에는 다루지 못했으나 장점 중 하나로 언급된 <code class="language-plaintext highlighter-rouge">&lt;Teleport&gt;</code>기능을 통해 모달 기능 구현에 있어 작업 효율이 매우 향상되었습니다. 
간단하면서도 유용한 이 기능에 대해 다음 포스팅에서 다루면 좋을 것 같다는 생각이 듭니다.</p>

<p>실제 프로젝트보다 매우 간략하게 요약된 예시 코드 안에서 적용 사례를 설명하다보니 부족한 부분이 있을 수 있지만, 그럼에도 불구하고 긴 글 읽어주시며 저희의 Vue3 적용기에 관심 가져주셔서 감사합니다.</p>

<blockquote>
  <p>참고문헌</p>
  <ul>
    <li><a href="https://v3-docs.vuejs-korea.org/guide/introduction.html">Vue.js 공식 문서</a></li>
    <li><a href="https://pinia.vuejs.kr/introduction">Pinia 공식 문서</a></li>
  </ul>
</blockquote>]]></content><author><name>노혜민</name></author><category term="프론트엔드" /><summary type="html"><![CDATA[이번 포스팅은 Vue3, Composition API와 Pinia를 이용한 상태관리 (1) 글의 후편입니다. 이전 포스팅에서 Composition API, Pinia에 대한 이론적인 설명을 다루었다면 이번 포스팅에서는 실제로 Pinia를 어떤 방식으로 적용했고 어떤 작업 결과를 냈는지 다루려합니다. 글의 목차는 아래와 같습니다. 03. 적용 결과: 인재풀에서의 Pinia 04. 느낀점: 이론적 장단점과 내가 느낀 실제]]></summary></entry><entry><title type="html">React + TypeScript 전환기 (Feat. msw)</title><link href="https://saramin.github.io/2024-03-12-comment-for-typescript-react-conversion/" rel="alternate" type="text/html" title="React + TypeScript 전환기 (Feat. msw)" /><published>2024-03-12T00:00:00+09:00</published><updated>2024-03-12T00:00:00+09:00</updated><id>https://saramin.github.io/comment-for-typescript-react-conversion</id><content type="html" xml:base="https://saramin.github.io/2024-03-12-comment-for-typescript-react-conversion/"><![CDATA[<style>
	  .fig-caption {display:block; margin-top:0px; padding:5px; font-size:0.85em; font-style: italic; color: #555}
</style>

<p>사람인 FE 개발팀에서는 기존의 사람인 서비스를 점진적으로 FE 분리 전환을 진행 중에 있는데요, 최근 사람인 서비스 중 신입·인턴 채용달력 모바일 서비스(이하 채용달력)를 React + TypeScript(이하 TS)로 전환하게 되었습니다.</p>

<p>React + TS로의 전환은 제 개인적으로도 제법 작지 않은 도전이기도 했습니다. <br />
지금까지는 Vue로 개발해 왔었고, 표준 기술로 선택 한 React, TS, React Query 등은 프로덕트 레벨에 적용하는 첫 사례에 해당했거든요.</p>

<p>특히 학습한 것을 조금씩 서비스에 반영하며 점진적으로 개선해 가는 것이 아니라, 이번처럼 제한 된 시간 안에  그 동안 학습해 온 것을 한 순간에 쏟아내어 온통 새로운 기술 스택으로 서비스를 완성해 가야 할 때는 상당히 적지 않은 부담을 느끼며 새로운 기술 스택에 적절한 설계와 방법론을 고민해야 하기 때문에 제법 도전이 될 수 밖에 없는 것 같습니다.</p>

<p>아직은 많은 시행착오를 겪지 못했고 새롭게 익힌 기술 스택에 대해 경험과 지식이 충분하게 쌓여있지 않기에 미흡한 부분도 있고 이것저것 시도해보고 있는 것들도 더러 있기 때문에, 이 글에서 소개하는 것들이 어쩌면 다소 적절하지 않거나 미흡한 방법을 수도 있습니다. 혹 그러한 것들이 눈에 뜨인다면 학습 직후의 것이라 아직은 다듬어지지 못한 거친 돌이라 너그러이 생각해주시면 좋을 것 같습니다.</p>
<h2 id="ts-도입">TS 도입</h2>
<p>FE 개발팀에서는 작년 KPI의 일환으로 팀 내 기술 표준을 정립하면서 TS를 도입하기로 결정했었는데요, 채용 달력이 TS를 도입하기로 결정하고 학습 이후에 적용한 첫 케이스이기도 했습니다.</p>

<p>개인적으로 팀 개발 프로세스에서 다음의 사항들을 문제라고 인식했었는데요.</p>

<ul>
  <li>팀 내 개발 프로세스에서 테스트가 작성되고 있지 않고, 테스트를 도입하기에는 아직 역량이 충분하지 않다.</li>
  <li>코드 리뷰 단계에서 예외 처리 누락이나 오탈자로 인한 오류가 종종 발견되고, 자주 발견되는 만큼 코드 리뷰에서 검증해야 할 사항이 많아진다.</li>
  <li>컴포넌트, 함수 등에 대한 문서화가 이루어지지 않아 코드 레벨에서 사용법을 확인하거나 스토리북에서 사용할 컴포넌트를 찾아다니느라 낮은 생산성이 지속된다.</li>
</ul>

<p>이러한 문제를 당장 해결하는데에는 TS가 가장 좋은 대안이라고 판단했기 때문에, TS 도입에 적극 지지하는 입장이기도 했습니다.</p>
<h3 id="type-관리-전략">Type 관리 전략</h3>

<p>채용 달력 서비스에서는 몇 번의 시행착오를 거쳐 type을 크게 두 가지 카테고리로 나누어 관리하도록 작성되었는데요.</p>

<p>컴포넌트 props 및 컴포넌트에 종속되어 있는 types는 각 컴포넌트 디렉토리 안에 위치하여 local types로 관리하고, 그 외 Router, API fetcher, React Query, utils, 3rd party 등에 필요한 types는 types 디렉토리 안에 위치하여 global types로 관리하도록 작성되었습니다.</p>

<figure>
     <img src="/img/typescript/type-directory-strategy.png" alt="프로젝트 디렉토리 스크린샷" />
	    <figcaption class="fig-caption">좌: Badge 컴포넌트 디렉토리, 우: types 디렉토리</figcaption>
</figure>

<p>초기에는 type 정의를 각 컴포넌트 및 모듈 파일에 직접 작성하고 있었는데, 다음의 상황들을 만나게 되었습니다.</p>

<ul>
  <li>import 되는 컴포넌트 및 모듈 개수 만큼 type import문이 작성되면서 import문이 화면 절반 이상을 차지하는 상황이 발생한다.</li>
  <li>문서화를 위해 props type에 작성된 JSDoc 어노테이션으로 인해 type 정의 부분이 화면 한 페이지를 차지할 만큼 길어지는 상황이 발생한다.</li>
  <li>단일 파일에 여러 개의 함수 등이 작성되는 경우, 연관된 type과 코드를 가까이 배치시키면 경계를 명확히 하기 위한 추가 장치가 필요해지는 문제가 발생하고, type 정의와 코드 모음을 분리하여 배치시키면 수정 시 종종 위치 찾기를 위해 스크롤 이동으로 인한 스위칭 비용이 높아지는 문제가 발생한다.</li>
</ul>

<p>우선 첫 번째 문제를 해결하기 위해 모든 type을 global type으로 변경해 봤는데</p>

<p>이번에는 storybook에서 정의된 props들을 표현하지 못하는 문제가 발생했습니다. 
storybook github issue에서 local types만 지원하고 있다는 내용이 발견되어 컴포넌트에 한하여 local로 사용하게 되었습니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="k">import</span> <span class="kd">type</span> <span class="p">{</span> <span class="nx">HTMLAttributes</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">react</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">let</span> <span class="nx">VALID_HEADING_LEVEL</span><span class="p">:</span> <span class="k">readonly</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">5</span><span class="p">,</span> <span class="mi">6</span><span class="p">];</span>

<span class="kr">declare</span> <span class="nb">global</span> <span class="p">{</span>
  <span class="kd">type</span> <span class="nx">HeadingLevel</span> <span class="o">=</span> <span class="p">(</span><span class="k">typeof</span> <span class="nx">VALID_HEADING_LEVEL</span><span class="p">)[</span><span class="kr">number</span><span class="p">];</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">HeadingProps</span> <span class="kd">extends</span> <span class="nx">HTMLAttributes</span><span class="o">&lt;</span><span class="nx">HTMLHeadingElement</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="cm">/**
    * the level of heading
    */</span>
  <span class="na">level</span><span class="p">:</span> <span class="nx">HeadingLevel</span><span class="p">;</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p class="fig-caption">
    Heading 컴포넌트에 정의한 type 사례.<br /> 컴포넌트를 벗어나 다른 곳에서 쓰일 소지가 있는 것들은 global로 정의 됩니다.
</p>

<p>두 번째와 세번째 문제를 해결하기 위해 타입 정의 부분만 별도 파일로 분리하여 types 디렉토리에서 관리하고 있는데, 스크린샷에서 보이듯 관심사별로 묶어서 타입을 모아두는 방식을 택하고 있습니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
</pre></td><td class="rouge-code"><pre><span class="c1">// types/services.d.ts</span>
<span class="kr">declare</span> <span class="kr">interface</span> <span class="nx">SaraminApiError</span> <span class="p">{</span>
  <span class="nl">message</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">code</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>

<span class="kr">declare</span> <span class="kr">interface</span> <span class="nx">SaraminApiResponse</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="na">success</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">;</span>
  <span class="nl">error</span><span class="p">:</span> <span class="nx">SaraminApiError</span> <span class="o">|</span> <span class="kc">null</span><span class="p">;</span>
  <span class="nl">data</span><span class="p">:</span> <span class="nx">T</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// src/services/CalendarApis.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">getThemes</span> <span class="o">=</span> <span class="p">(</span>
  <span class="nx">params</span><span class="p">?:</span> <span class="nx">object</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">SaraminApiResponse</span><span class="o">&lt;</span><span class="nx">ThemesResponse</span><span class="o">&gt;&gt;</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="p">...</span> <span class="p">};</span>
 
<span class="c1">// src/hooks/useCalendarApis.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">useGetThemes</span> <span class="o">=</span> <span class="p">(</span>
  <span class="nx">params</span><span class="p">?:</span> <span class="nx">object</span><span class="p">,</span>
  <span class="nx">config</span><span class="p">?:</span> <span class="nb">Omit</span><span class="o">&lt;</span>
    <span class="nx">UseQueryOptions</span><span class="o">&lt;</span>
      <span class="nx">SaraminApiResponse</span><span class="o">&lt;</span><span class="nx">ThemesResponse</span><span class="o">&gt;</span><span class="p">,</span>
      <span class="nx">AxiosError</span><span class="o">&lt;</span><span class="nx">SaraminApiResponse</span><span class="o">&lt;</span><span class="kc">null</span><span class="o">&gt;&gt;</span><span class="p">,</span>
      <span class="nx">ThemesTransform</span>
    <span class="o">&gt;</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">queryKey</span><span class="dl">"</span>
  <span class="o">&gt;</span><span class="p">,</span>
<span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p class="fig-caption">
    API 응답 type 사례. 이렇게 정의해두면 어느 API Fetcher, React Query hooks 등에서든 import 없이 바로 사용할 수도 있고, 인텔리센스에서도 즉시 확인 할 수 있었습니다.
</p>

<p>컴포넌트도 types 디렉토리에 위치시켜 관리하도록 시도해보았는데, 컴포넌트별로 작성하게 되면 types 내의 파일이 방대해지도 하고 일부 파일은 3~4줄로 작성이 그치는 경우도 있었고, 아토믹 시스템에서의 수준별로 묶어도 봤지만 특정 컴포넌트에서만 요구되는 literal type이나 네이밍이 겹치는 것을 피하기 위한 네이밍 규격을 추가해야 하는 등의 고려사항이 더 발생하여 결국 아직까지는 썩 와닿는 좋은 방법을 찾지 못해 결국 컴포넌트에 필요한 types는 컴포넌트 디렉토리에서 관리하도록 구성되었습니다.</p>

<h3 id="ts를-도입하면서-어려웠던-점">TS를 도입하면서 어려웠던 점</h3>

<p>TS를 도입하기 전에는 다소 어렵지 않을까 막연한 부담이 있었는데요, 막상 적용을 시도해보니 IDE와 린트가 워낙 잘 받쳐줘서 생각보다 어려움은 없었습니다.</p>

<p>내부에서도 TS관련된 린트를 타이트하게 잡으면 TS에 제법 많은 시간을 할애하게 되서 일정에 영향을 줄 수 있을 거라는 조언도 있었는데, 운이 좋아서일지는 모르겠지만 의외로 TS도입으로 인해서 일정이 영향을 받는 일은 없었습니다.</p>

<p>떠도는 이야기로 TS를 도입하게 되면 type을 정의하느라 더 많은 키보드 타이핑을 해야 하고 그래서 더 많은 시간이 소비된다는 이야기들이 지금도 있는 것으로 아는데, 오히려 자잘한 오타로 인한 오류나 이벤트 핸들러 속에 숨어 발견 되기 어려운 오류를 방지하기 위해 눈을 부릅뜨고 코드를 리뷰해야 하거나 뒤늦게 오류를 해결하느라 소비하는 시간 등을 따지면 오히려 더 많은 시간이 절약되지 않았나 싶습니다.</p>

<p>다만 TS를 도입하면서 어려웠다고 느꼈던 건 외부 라이브러리를 사용할 때였는데요.</p>

<figure>
     <img src="/img/typescript/what-the.png" alt="제법 많은 양의 오류 안내 설명 화면 캡쳐" />
	    <figcaption class="fig-caption">타입을 정의해 가는 중, 처음에는 무척이나 당황하게하는 어마무시한 양으로 느껴지는 오류 안내 메세지였…</figcaption>
</figure>

<p>특히 React Query를 사용하면서 Custom hook을 만들어 적용할 때 요구되는 type을 작성하느라 진땀을 뺐던 것 같습니다 😂 TypeScript를 적용하던 초기에는 type이 일치하지 않다는 메세지를 올바르게 해석해내지 못하고 헤메이느라 시간을 좀 더 잡아먹기도 했는데 프로젝트 후반부에 들어서는 어느 정도 익숙해졌는지 라이브러리에 정의 된 type을 추적해가며 해결할 수 있게 되었습니다.</p>

<figure>
     <img src="/img/typescript/dts-for-tanstack.png" alt="라이브러리의 type 정의 파일을 찾아서 이해할 수 있게 되었다" />
</figure>

<p>이러한 것만 아니면 TS를 도입하는데 초기에는 큰 어려움은 없다고 생각하는데요, 막연히 어려울 것이라는 걱정 때문에 미루고 있는 분이 계시다면 도입 시도를 적극 추천합니다.</p>

<h3 id="ts도입으로-얻은-좋은-점">TS도입으로 얻은 좋은 점</h3>
<p>개인적으로 TS도입 이전에 비해 더 만족감을 느끼는 부분들을 몇 가지 소개해보자면</p>
<ul>
  <li>
    <p>코드리뷰에서 불필요한 리소스가 조금 더 줄었다</p>

    <figure>
     <img src="/img/typescript/code-review-space.png" alt="띄어쓰기 오류에 대한 코드 리뷰 화면 캡쳐" />
       <figcaption class="fig-caption">띄어쓰기로 인한 오류. 눈을 크게 뜨고 봐야 한다</figcaption>
  </figure>

    <p>정적 타입을 사용하지 않던 이전 환경에서 코드리뷰를 하면 혹시나 숨어 있을지 모를 누락된 예외 처리, 오탈자로 인한 오류까지도 함께 검사해야 하거나 꼼꼼히 살펴보지 않으면 리뷰어 조차도 놓치기 쉽기 때문에 제법 많은 리소스가 발생되었는데 정적 검사가 이를 대신하여 경고를 띄워 리뷰 전 단계에서 해결 할 수 있게 되어 더 본질적인 것에 집중하여 리뷰가 가능해졌습니다.</p>

    <figure>
     <img src="/img/typescript/code-review-null-exception.png" alt="[API가 예외를 내려주지 않으면 발견하기 쉽지 않은 오류 코드 리뷰 화면 캡쳐" />
       <figcaption class="fig-caption">API가 완성되기 전까지는 알아차리기 힘들지도 모를 그런 오류. 하지만 TS였다면?</figcaption>
  </figure>

    <p>때로는 컴포넌트를 먼저 개발하고 이후에 API fetcher를 개발하면서 가공된 데이터에서 예외가 발생하거나 타입의 불일치로 일어나는 문제를 실제로 구동해볼 때에야 확인할 수 있거나 리뷰 시 관련 파일을 열어두고 비교해야 했었는데 TS에서 미리 API 응답 데이터의 타입을 정의해두고 시작하면서 이러한 문제 역시 사전에 방지할 수 있게 되면서 리소스가 좀 더 절약될 수 있었습니다.</p>
  </li>
  <li>
    <p>개발 속도가 더 빨라졌다</p>

    <p>이전에는 컴포넌트를 가져다 쓰려면 컴포넌트의 필수 prop이 무엇인지 혹은 사용 가능한 값을 확인 하기 위해 화면 한 쪽에 컴포넌트 코드를 열어두거나 storybook을 띄워두고 찾아다니거나 해야 했지만, TS도입 이후로는 IDE의 인텔리센스 지원으로 손 쉽게 처리할 수 있게 되어 체감되는 개발 속도 향상이 제법 높게 느껴졌습니다.</p>

    <figure>
   <img src="/img/typescript/vscode-intelisense.jpeg" alt="[Visual Studio Code intelisense 화면 캡쳐" />
     <figcaption class="fig-caption">인텔리센스에서 필수 속성의 누락 및 사용 가능한 속성을 알려주고, 누락된 속성 추가 기능으로 필수 속성을 한 번에 삽입할 수 있습니다.</figcaption>
</figure>
  </li>
  <li>
    <p>가독성이 좋아졌다</p>

    <p>단순히 작성된 코드를 알아보기 쉬워진 것이 아니라, 컴포넌트나 함수를 어떻게 사용해야 할지 이해가 더 쉬워졌습니다.</p>

    <p>간혹 overloading을 사용하여 함수를 작성할 경우가 있는데, 특히 파라미터 순서에 따라 타입이 다른 경우에는 파라미터 이름만으로 가독성을 지원하는데 한계가 있는 경우가 있었는데, TS를 통한 overloading 표현으로 IDE가 적절하게 지원해주면서 함수를 이해시키기 위한 문서를 추가로 작성하거나 문서가 없는 함수를 이해하기 위해 코드 내부를 들여다보아야 하는 일을 줄일 수 있게 되었습니다.</p>

    <div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
</pre></td><td class="rouge-code"><pre><span class="cm">/**
  * date format의 문자열, 날짜 객체로도 비교 가능하고,
  * 주어진 연월 또는 연월일로도 비교 가능한 함수를 만들다보니,
  * 시간이 흐른 뒤, 이게 뭐하던 건지 파악하려면 작성한 나도 코드를 따라가며 해석해야 했다.
  */</span>
<span class="kd">function</span> <span class="nf">isSameDay</span><span class="p">(</span><span class="nx">other</span><span class="p">,</span> <span class="nx">year</span><span class="p">,</span> <span class="nx">month</span><span class="p">,</span> <span class="nx">date</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="k">typeof</span> <span class="nx">year</span> <span class="o">===</span> <span class="s2">`string`</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">other</span><span class="p">.</span><span class="nf">setHours</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">===</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">(</span><span class="nx">year</span><span class="p">).</span><span class="nf">setHours</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">year</span> <span class="k">instanceof</span> <span class="nb">Date</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">other</span><span class="p">.</span><span class="nf">setHours</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">===</span> <span class="nx">year</span><span class="p">.</span><span class="nf">setHours</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">if </span><span class="p">(</span><span class="k">typeof</span> <span class="nx">year</span> <span class="o">===</span> <span class="s2">`number`</span> <span class="o">&amp;&amp;</span> <span class="k">typeof</span> <span class="nx">month</span> <span class="o">===</span> <span class="s2">`number`</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return </span><span class="p">(</span>
      <span class="nx">other</span><span class="p">.</span><span class="nf">setHours</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">===</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">(</span><span class="nx">year</span><span class="p">,</span> <span class="nx">month</span><span class="p">,</span> <span class="nx">date</span> <span class="o">||</span> <span class="mi">1</span><span class="p">).</span><span class="nf">setHours</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    <span class="p">);</span>
  <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
<span class="p">}</span>
    
<span class="cm">/**
  * type과 함께 overload로 표현하면서 평안이 찾아왔다
  */</span>
<span class="kd">function</span> <span class="nf">isSameDay</span><span class="p">(</span><span class="nx">other</span><span class="p">:</span> <span class="nb">Date</span><span class="p">,</span> <span class="nx">date</span><span class="p">:</span> <span class="kr">string</span> <span class="o">|</span> <span class="nb">Date</span><span class="p">):</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="kd">function</span> <span class="nf">isSameDay</span><span class="p">(</span><span class="nx">other</span><span class="p">:</span> <span class="nb">Date</span><span class="p">,</span> <span class="nx">year</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">month</span><span class="p">:</span> <span class="kr">number</span><span class="p">):</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="kd">function</span> <span class="nf">isSameDay</span><span class="p">(</span><span class="nx">other</span><span class="p">:</span> <span class="nb">Date</span><span class="p">,</span> <span class="nx">year</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">month</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">date</span><span class="p">:</span> <span class="kr">number</span><span class="p">):</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="kd">function</span> <span class="nf">isSameDay</span><span class="p">(</span>
  <span class="nx">other</span><span class="p">:</span> <span class="nb">Date</span><span class="p">,</span>
  <span class="nx">year</span><span class="p">:</span> <span class="kr">number</span> <span class="o">|</span> <span class="kr">string</span> <span class="o">|</span> <span class="nb">Date</span><span class="p">,</span>
  <span class="nx">month</span><span class="p">?:</span> <span class="kr">number</span><span class="p">,</span>
  <span class="nx">date</span><span class="p">?:</span> <span class="kr">number</span><span class="p">,</span>
<span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>

    <p>이 외에도 리팩토링이 매우 수월해진 점, 설계가 용이해진 점 등 여러 가지 좋은 점들이 있었는데요 지면상 여기까지만… 😅
    아직 TS 도입을 고려만하고 할까말까 고민중이라면 어서 도입하고 평안을 얻으시길 기원합니다.</p>
  </li>
</ul>

<h2 id="msw-도입">MSW 도입</h2>
<p>채용 달력에서는 API를 mocking하기 위해서 msw(mock service worker)를 사용하고 있습니다.  <br />
msw는 이름에서 볼 수 있듯 service worker를 이용하여 모의(mock)를 도와주는 라이브러리로, service worker가 HTTP 요청을 가로채 등록해 둔 모의 응답을 전달해주는 기능을 합니다.</p>

<figure>
     <img src="/img/typescript/msw-on-dev-tools-network.png" alt="크롬 개발자도구 네트워크 탭 화면 캡쳐" />
	   <figcaption class="fig-caption">msw가 네트워크를 가로채 실제 API처럼 작동해줍니다.</figcaption>
</figure>

<p>Storybook과도 통합 가능하고, request에 따라서 응답을 변경할 수도 있기 때문에 프론트엔드 개발에서는 매우 유용한 라이브러리입니다.</p>

<p>참고로, 프로젝트에 msw를 도입한 2월 기준으로 msw-storybook-addon이 아직 msw 1.x 버전만 지원하고 있기 때문에 msw 역시 1.x 버전을 사용해야 합니다. 그렇지 않으면 저처럼 2.x로 모든 설정을 끝낸 후에 다시 1.x로 다운그레이드 하는 불상사가 발생하게 될거에요 🤣
적용 방법 및 Storybook과의 통합 방법은 <a href="https://v1.mswjs.io/docs/">msw 공식 문서</a>와 <a href="https://storybook.js.org/addons/msw-storybook-addon/">storybook addon 문서</a>에 잘 정리되어 있어 그대로 따라하기만 해도 되기 때문에 굳이 여기에서까지 작성하는 것은 피하겠습니다.</p>

<p>채용달력에서는 이미 만들어진 API를 사용하는 부분도 있고, 앞으로 수정 될 응답을 사용해야 하는 경우도 있었는데요, msw를 통해 실제 API를 호출하여 작동하는 것과 같은 효과를 가질 수 있기 때문에 저희처럼 API에서 응답되는 실제 데이터를 미리 넣어두고 사용하거나 앞으로 생성 될 혹은 변경 될 규격에 맞춰서 데이터를 넣어두고 개발하면 API 개발자의 개발 완료 시점을 기다리지 않아도 되게 되었습니다. <del>(아, 물론 이제는 API가 먼저 나와야 할 수 있어요 같은 핑계를 못대게 되는 읍! 읍!…)</del></p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
</pre></td><td class="rouge-code"><pre><span class="kd">const</span> <span class="nx">recruitSchedule</span><span class="p">:</span> <span class="nx">SaraminApiResponse</span><span class="o">&lt;</span><span class="nx">ScheduleRecruitsResponse</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">success</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">error</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
  <span class="na">data</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">list</span><span class="p">:</span> <span class="p">{</span>
      <span class="dl">"</span><span class="s2">20240119</span><span class="dl">"</span><span class="p">:</span> <span class="p">[</span>
        <span class="p">{</span>
          <span class="na">company_nm</span><span class="p">:</span> <span class="dl">"</span><span class="s2">사람인</span><span class="dl">"</span><span class="p">,</span>
          <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">사람인 FE개발팀 신입 채용</span><span class="dl">"</span><span class="p">,</span>
          <span class="p">...,</span>
          <span class="na">company_logo_url</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
          <span class="na">meta_tag</span><span class="p">:</span> <span class="dl">"</span><span class="s2">서울 구로구 · 무관</span><span class="dl">"</span><span class="p">,</span> <span class="c1">// 추가될 데이터 </span>
        <span class="p">},</span>
        <span class="p">{</span>
          <span class="na">company_nm</span><span class="p">:</span> <span class="dl">"</span><span class="s2">사람인</span><span class="dl">"</span><span class="p">,</span>
          <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">사람인 FE개발팀 신입 채용 2</span><span class="dl">"</span><span class="p">,</span>
          <span class="p">...,</span>
          <span class="na">company_logo_url</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
          <span class="na">meta_tag</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span> <span class="c1">// 빈 문자열로 올지도 모르니까 일단...</span>
        <span class="p">},</span>
        <span class="p">{</span>
          <span class="na">company_nm</span><span class="p">:</span> <span class="dl">"</span><span class="s2">사람인</span><span class="dl">"</span><span class="p">,</span>
          <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">사람인 FE개발팀 신입 채용 3</span><span class="dl">"</span><span class="p">,</span>
          <span class="p">...,</span>
          <span class="na">company_logo_url</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span>
          <span class="na">meta_tag</span><span class="p">:</span> <span class="dl">""</span><span class="p">,</span> <span class="c1">// 혹시 property를 빼고 오는 경우가 있을 수도 있으니...</span>
        <span class="p">},</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>개인적으로 msw 도입에의 가장 큰 효과는 API의 실패 케이스를 미리 대응할 수 있는 점일 것 같습니다. API 응답에 따른 처리가 필요한 기능을 만들 경우, 성공 케이스에만 대응하고 실패 케이스를 놓치게 되거나 혹은 실패 상황을 만들기 어려워서 상상 속에서 개발하는 상황을 종종 보아왔는데요. msw를 도입하면서 응답이 늦는 경우, 특정 HTTP status를 대응해야 하는 경우, 오류 상황을 재현하는 스토리를 작성해야 하는 경우 등을 모두 과거에 비해 훨씬 더 손 쉽게 만들어내어 개발 할 수 있게  있게 되었습니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
</pre></td><td class="rouge-code"><pre><span class="k">import</span> <span class="p">{</span> <span class="nx">rest</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">msw</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="p">...,</span> <span class="nx">recruitSchedule</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@mocks/data/calendar</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">handlers</span> <span class="o">=</span> <span class="p">[</span>
  <span class="p">...,</span>
  <span class="c1">// request를 가로채 parameter에 따라 다른 데이터를 줄 수도 있다</span>
  <span class="nx">rest</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="s2">`/calendar/recruits/schedule`</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">monthParam</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">url</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">month</span><span class="dl">"</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">start</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">url</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">startDate</span><span class="dl">"</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">end</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">url</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">endDate</span><span class="dl">"</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">getMonthlyResult</span> <span class="o">=</span> <span class="p">(</span><span class="na">month</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span>
      <span class="nb">Object</span><span class="p">.</span><span class="nf">entries</span><span class="p">(</span><span class="nx">recruitSchedule</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">list</span><span class="p">).</span><span class="nf">filter</span><span class="p">(([</span><span class="nx">date</span><span class="p">])</span> <span class="o">=&gt;</span>
        <span class="nx">date</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">month</span><span class="p">),</span>
      <span class="p">)</span> <span class="o">||</span> <span class="p">[];</span>
    <span class="kd">const</span> <span class="nx">getWeeklyResult</span> <span class="o">=</span> <span class="p">(</span><span class="na">startDate</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="na">endDate</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span>
      <span class="nb">Object</span><span class="p">.</span><span class="nf">entries</span><span class="p">(</span><span class="nx">recruitSchedule</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">list</span><span class="p">).</span><span class="nf">filter</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="nx">date</span> <span class="o">&gt;=</span> <span class="nx">startDate</span> <span class="o">&amp;&amp;</span> <span class="nx">date</span> <span class="o">&lt;=</span> <span class="nx">endDate</span><span class="p">,</span>
      <span class="p">)</span> <span class="o">||</span> <span class="p">[];</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">monthParam</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="p">(</span><span class="nx">start</span> <span class="o">&amp;&amp;</span> <span class="nx">end</span><span class="p">))</span> <span class="k">return</span> <span class="nf">res</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">500</span><span class="p">),</span> <span class="p">...);</span>
	
    <span class="k">return</span> <span class="nf">res</span><span class="p">(</span>
      <span class="nx">ctx</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">200</span><span class="p">),</span>
      <span class="nx">ctx</span><span class="p">.</span><span class="nf">json</span><span class="p">({</span>
        <span class="p">...</span><span class="nx">recruitSchedule</span><span class="p">,</span>
        <span class="na">data</span><span class="p">:</span> <span class="p">{</span>
          <span class="p">...</span><span class="nx">recruitSchedule</span><span class="p">.</span><span class="nx">data</span><span class="p">,</span>
          <span class="na">list</span><span class="p">:</span> <span class="nb">Object</span><span class="p">.</span><span class="nf">fromEntries</span><span class="p">(</span>
            <span class="nx">monthParam</span> <span class="p">?</span> <span class="nf">getMonthlyResult</span><span class="p">(</span><span class="nx">monthParam</span><span class="p">)</span> <span class="p">:</span> <span class="nf">getWeeklyResult</span><span class="p">(</span><span class="nx">start</span><span class="p">,</span> <span class="nx">end</span><span class="p">),</span>
          <span class="p">),</span>
        <span class="p">},</span>
      <span class="p">}),</span>
    <span class="p">);</span>
  <span class="p">}),</span>
  <span class="p">...</span>
<span class="p">];</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="hoc-패턴-적용">HOC 패턴 적용</h2>
<p>혹시 사람인 신입·인턴 채용달력 페이지를 보셨나요? 여러 페이지로 구성되어 있지만 화면 구성은 결국 주간/월간뷰의 차이를 제외하고는 나머지 구성은 항상 동일하게 되어 있는데요.</p>

<figure>
     <img src="/img/typescript/calendar-screenshot.jpeg" alt="사람인 채용달력 주간뷰 및 월간뷰 화면 캡쳐" />
</figure>

<p>월간 달력이나 주간 달력이냐를 제외하면 나머지 부분은 동일한 형태를 유지합니다. 
대략 코드 형태로 보자면</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
</pre></td><td class="rouge-code"><pre><span class="c1">// 월간뷰 페이지 구조 </span>
<span class="nx">fetchDatas</span><span class="p">...</span>

<span class="nx">handle</span> <span class="nx">user</span> <span class="nx">interaction</span><span class="p">...</span>

<span class="o">&lt;</span><span class="nx">Themes</span> <span class="nx">themes</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{</span> <span class="p">...</span> <span class="p">}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">DateController</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">NavigationTabBar</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">subscribeList</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">MonthlyCalendar</span>  <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">scheduleRecruitList</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">Toolbar</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">Recruitment</span> <span class="nx">scheduleRecruitmentMap</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">Notice</span> <span class="o">/&gt;</span>

<span class="c1">// 주간뷰 페이지 구조</span>

<span class="nx">fetchDatas</span><span class="p">...</span>

<span class="nx">handle</span> <span class="nx">user</span> <span class="nx">interaction</span><span class="p">...</span>

<span class="nx">handle</span> <span class="nx">weeklyCalendar</span> <span class="nx">interaction</span><span class="p">...</span>

<span class="o">&lt;</span><span class="nx">Themes</span> <span class="nx">themes</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{</span> <span class="p">...</span> <span class="p">}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">DateController</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">NavigationTabBar</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">subscribeList</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">WeeklyCalendar</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">scheduleRecruitList</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">Toolbar</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">Recruitment</span> <span class="nx">scheduleRecruitmentMap</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span><span class="o">&lt;</span><span class="nx">MonthController</span> <span class="o">/&gt;</span>
<span class="o">&lt;</span><span class="nx">Notice</span> <span class="o">/&gt;</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p class="fig-caption">
동일한 로직과 동일한 컴포넌트가 겹치는 부분이 많았다. 이걸 페이지마다 다 따로 작성하는게 과연 적절한가?
</p>

<p>이러한 형태가 되었는데 처음에는 월간 템플릿과 주간 템플릿을 각각 작성하고, 주간 템플릿에만 요구되는 추가 UI 기능들을 작성하는 방향을 떠올렸습니다.</p>

<p>하지만 이내 나머지 영역이 모두 동일한 비지니스 로직을 가지고 동일한 기능을 가지는데 굳이 분리해서 관리해야 할 이유가 있을까하는 의문이 들기 시작했습니다. 동일한 부분을 각각 작성하는 순간 이후 유지보수가 발생 할 경우 양 파일 모두에 반영하고 코드 리뷰 시 리뷰어도 양쪽의 로직이 동일하게 적용되었는지 혹 빠뜨린 부분은 없는지도 리뷰해야 하기 때문에 오히려 품질을 유지하는 것을 더 어렵게 할 것이라는 생각이 들었고 변경 사항을 추적하는 것 역시 다소 복잡해질 소지가 있다는 판단이 들었습니다.</p>

<p>재사용 문제를 해결하기 위한 장치로 hook이 있지만, 이 경우에서는 하나의 hook이 서로 다른 관심사를 가지는 여러 컴포넌트를 반환하게 하는 형태가 되고 오히려 유지보수를 더 어렵게 만들 소지가 높을 것 같아 해결 방법에서 제외하였고, 이전에 글로만 봐왔던 High Order Component(HOC) 패턴을 사용하기로 했습니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
</pre></td><td class="rouge-code"><pre><span class="c1">// src/hocs/withCalendarCommon.tsx</span>
<span class="kd">const</span> <span class="nx">CalendarCommon</span> <span class="o">=</span> <span class="o">&lt;</span><span class="nx">P</span> <span class="kd">extends</span> <span class="nx">ComponentProp</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">Component</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="na">props</span><span class="p">:</span> <span class="nx">P</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">fetchDatas</span><span class="p">...</span>
	  
    <span class="nx">handle</span> <span class="nx">use</span> <span class="nx">interaction</span><span class="p">...</span>
	  
    <span class="k">return </span><span class="p">(</span>
      <span class="o">&lt;&gt;</span>
        <span class="o">&lt;</span><span class="nx">Themes</span> <span class="nx">themes</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{</span> <span class="p">...</span> <span class="p">}</span> <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="nx">DateController</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="nx">NavigationTabBar</span> <span class="nx">date</span><span class="o">=</span><span class="p">{...}</span> <span class="nx">subscribeList</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="nx">Component</span> <span class="p">{...</span><span class="nx">props</span><span class="p">}</span> <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="nx">Toolbar</span> <span class="nx">onClickXXX</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="nx">Recruitment</span> <span class="nx">scheduleRecruitmentMap</span><span class="o">=</span><span class="p">{...}</span> <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="nx">MonthController</span> <span class="o">/&gt;</span>
        <span class="o">&lt;</span><span class="nx">Notice</span> <span class="o">/&gt;</span>
      <span class="o">&lt;</span><span class="sr">/</span><span class="err">&gt;
</span>    <span class="p">);</span>
  <span class="p">};</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
</pre></td><td class="rouge-code"><pre><span class="c1">// src/pages/theme.tsx</span>

<span class="k">import</span> <span class="nx">MonthlyTemplate</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@components/Templates/Monthly</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">WeeklyTemplate</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@components/Templates/Weekly</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">withCalendarCommon</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@hocs/withCalendarCommon</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">ThemePage</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="p">{</span> <span class="nx">week</span> <span class="p">}</span> <span class="o">=</span> <span class="nf">useParams</span><span class="p">();</span>
  <span class="c1">// something to do only on theme page ...</span>

  <span class="kd">const</span> <span class="nx">Component</span> <span class="o">=</span> <span class="nx">week</span>
    <span class="p">?</span> <span class="nf">withCalendarCommon</span><span class="p">(</span><span class="nx">WeeklyTemplate</span><span class="p">)</span>
    <span class="p">:</span> <span class="nf">withCalendarCommon</span><span class="p">(</span><span class="nx">MonthlyTemplate</span><span class="p">);</span>

  <span class="k">return</span> <span class="o">&lt;</span><span class="nx">Component</span> <span class="o">/&gt;</span><span class="p">;</span>
<span class="p">};</span>

<span class="k">export</span> <span class="k">default</span> <span class="nx">ThemePage</span><span class="p">;</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p class="fig-caption">
상이한 부분만 템플릿화 하여 동일한 로직을 사용하는 부분은 HOC 적용
</p>

<p>이러한 식으로 동일한 비지니스 로직과 동일한 UI 기능 및 동일하게 사용되는 컴포넌트를 더 이상 두 벌에서 관리하는 문제를 해결할 수 있을 것으로 보여졌습니다.</p>

<p>다만 HOC의 사용이 많아질 경우에는 props 추척이 복잡해질 수 있기 때문에 HOC가 중첩되는 일이 없도록 규칙을 정리하고 현재는 여기에만 사용하고 있습니다.</p>

<h2 id="마무리하며">마무리하며</h2>

<p>완전히 새로운 기술 스택을 이용하여 프로덕트를 만드는 건 처음 프론트엔드 개발자로 발걸음을 떼었던 때와 유사한, 물론 그 때보다는 덜했지만, 지금까지 학습한 것만으로 충분히 해결 될 수 있을까? 새로운 것 옆에 새로운 것 옆에 새로운 것들의 조합에서 문제가 발생했을 때 빠르게 원인을 찾아서 해결할 수 있을까? 등 묵직한 부담을 어깨에 올려두고 낯섦과 마주해보았습니다.</p>

<p>일부 어려울 것이라고 막연한 생각들로 새로운 것을 잘 할 수 있을까하는 막연했던 부담들은 우려했던 것 보다 더 많이 어렵지 않게 다가와 부담을 지워낼 수 있었고, 뒤로 갈 수록 ‘아 이렇게 하면 더 좋았겠구나’ 싶은 부분도 조금씩 보이기 시작하고 있습니다.</p>

<p>이제 한 걸음 더 나아가 더 좋은 방법론을 고민해보고 더 딥다이빙할 시간을 맞이할 준비를 해봐야 겠습니다.</p>

<p>긴 글 읽어주셔서 감사합니다.</p>]]></content><author><name>지성봉</name></author><category term="프론트엔드" /><summary type="html"><![CDATA[]]></summary></entry></feed>