<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dev_hyun.log</title>
        <link>https://velog.io/</link>
        <description>공룡, 다람쥐 그리고 돌고래!</description>
        <lastBuildDate>Sat, 14 Feb 2026 04:50:24 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dev_hyun.log</title>
            <url>https://velog.velcdn.com/images/dev_hyun/profile/3f0bdf07-232d-43bf-90e9-5a43a418f8c4/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dev_hyun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_hyun" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[macOS에서 명령어로 Antigravity IDE 실행하기]]></title>
            <link>https://velog.io/@dev_hyun/macOS%EC%97%90%EC%84%9C-%EB%AA%85%EB%A0%B9%EC%96%B4%EB%A1%9C-Antigravity-IDE-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyun/macOS%EC%97%90%EC%84%9C-%EB%AA%85%EB%A0%B9%EC%96%B4%EB%A1%9C-Antigravity-IDE-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Feb 2026 04:50:24 GMT</pubDate>
            <description><![CDATA[<h2 id="1-code는-왜-vs-code를-실행하는가">1. code는 왜 VS Code를 실행하는가</h2>
<p>터미널에서 <code>code</code>를 입력하면 기본적으로 Visual Studio Code가 실행된다. 이는 단순히 “IDE 이름이 code이기 때문”이 아니라, macOS에서 code라는 실행 파일이 실제로 존재하고, 쉘이 <code>$PATH</code> 순서에 따라 해당 실행 파일을 찾기 때문이다.</p>
<p><code>which code</code>를 실행했을 때 <code>/opt/homebrew/bin/code</code>가 반환되었다는 것은, 현재 시스템에서 <code>code</code>라는 이름의 실행 파일이 Homebrew 경로에 존재한다는 의미다. Apple Silicon 환경에서 Homebrew 기본 경로는 <code>/opt/homebrew</code>이며, 이 디렉토리는 일반적으로 <code>$PATH</code>의 앞쪽에 위치한다. 따라서 동일한 이름의 실행 파일이 다른 디렉토리에 존재하더라도, 쉘은 가장 먼저 발견되는 <code>/opt/homebrew/bin/code</code>를 실행한다.</p>
<p>이때 code는 단순한 텍스트 명령어가 아니라, VS Code 앱을 호출하는 CLI 런처(wrapper) 스크립트이다. 즉, <code>code .</code>는 현재 디렉토리를 VS Code 앱에 전달하는 역할을 수행한다.</p>
<h2 id="2-antigravity는-cli를-지원하는가">2. Antigravity는 CLI를 지원하는가</h2>
<p>Antigravity IDE가 CLI를 제공하는지 확인하는 과정에서 다음을 확인했다.</p>
<pre><code>which antigravity
/Users/hyundo/.antigravity/antigravity/bin/antigravity</code></pre><p>그리고:</p>
<pre><code>antigravity --help</code></pre><p>정상적으로 버전 정보가 출력되었다는 것은 Antigravity가 독립적인 CLI 실행 파일을 제공하고 있음을 의미한다.</p>
<p>즉, VS Code처럼 별도의 CLI 런처를 가지고 있으며, PATH에 이미 추가되어 있다면 터미널에서 직접 실행이 가능하다.</p>
<h2 id="3-그러나-왜-현재-디렉토리가-열리지-않는가">3. 그러나 왜 현재 디렉토리가 열리지 않는가</h2>
<p>여기서 핵심적인 차이가 드러난다.</p>
<p>VS Code의 code는 기본적으로 인자를 통해 디렉토리를 전달하도록 설계되어 있다. 그러나 Antigravity의 CLI는 기본 동작이 “앱 실행 또는 기존 인스턴스 활성화”일 가능성이 있다.</p>
<p>macOS GUI 애플리케이션은 일반적으로 이미 실행 중이면 새로운 인스턴스를 띄우지 않고 기존 인스턴스를 포커스한다. 이 과정에서 CLI가 경로 인자를 명확히 처리하지 않으면, 현재 디렉토리를 열지 않고 기존 창만 활성화하는 현상이 발생한다.</p>
<p>따라서 단순히:</p>
<pre><code class="language-bash">antigravity</code></pre>
<p>를 실행하는 것과</p>
<pre><code class="language-bash">antigravity .</code></pre>
<p>를 실행하는 것은 의미가 다르다. 대부분의 IDE는 <code>.</code> 인자를 통해 현재 디렉토리를 열도록 구현되어 있으므로, 반드시 경로 인자를 명시해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[How to start Apache Camel]]></title>
            <link>https://velog.io/@dev_hyun/How-to-start-Apache-Camel</link>
            <guid>https://velog.io/@dev_hyun/How-to-start-Apache-Camel</guid>
            <pubDate>Mon, 23 Dec 2024 02:12:51 GMT</pubDate>
            <description><![CDATA[<h2 id="apache-camel-은-무엇인🍊">Apache Camel 은 무엇인🍊?</h2>
<p>Apache Camel은 <code>통합 프레임워크</code>로, 여러 시스템 간의 메시지 라우팅 및 처리 로직을 정의하고 실행하기 위해 사용된다.</p>
<p>서로 다른 시스템 간 데이터를 통합하고 처리할 수 있도록 <code>Component</code> 와 <code>Java APIs</code> 들의 집합으로 구성되어 있다. 즉, Camel 은 서로 다른 애플리케이션을 연결하는 매게체 역할을 한다.</p>
<p>약 320개 이상의 <code>Component</code> 를 제공하며 이를 통해 웹 서비스, 파일 읽기 및 쓰기, 타 앱과 상호작용 등이 가능하다. Camel은 Java 용 파이프라인을 생성할 수 있는 도구라고 비유할 수 있다. 특정 지점에서 데이터를 가져와 다른 지점으로 연결하며, 그 과정에서 데이터를 변경 및 변환하거나 또 다른 파이프를 통해 전송할 수도 있다.</p>
<h2 id="어떤-상황에서-사용되는가">어떤 상황에서 사용되는가?</h2>
<p>데이터를 A에서 B로 이동해야 하는 거의 모든 경우에 Camel이 사용될 수 있다. 가령 다음과 같은 예시가 있다.</p>
<ol>
<li>FTP 서버에서 문서를 가져와 특정 부서로 이메일 보내기</li>
<li>디스크 폴더에서 파일을 가져와 Google Drive 같은 특정 저장소에 적재하기</li>
<li>메시지 큐에서 메시지를 가져와 웹 서비스로 보내기</li>
<li>DB에서 특정 데이터를 검색할 수 있는 웹 서비스 만들기</li>
</ol>
<p>APIM(API 관리) 솔루션에 조금 더 초점을 맞추자면 다음과 같은 예시가 있다.</p>
<ol>
<li>메시지 라우팅: 클라이언트 요청을 적절한 서비스(애플리케이션)
로 전달</li>
<li>로깅 및 모니터링: API 호출 추적</li>
<li>보안 처리: 인증 및 권한 부여</li>
<li>데이터 변환: JSON ↔ XML 변환 등</li>
</ol>
<h2 id="camel-파이프라인">Camel 파이프라인</h2>
<p>다음 다이어그램은 Camel의 핵심 개념 중 일부를 보여준다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/8a9ba797-1757-4f3a-a533-a07343886056/image.png" alt="https://tomd.xyz/camel-tutorial/"></p>
<p><strong>주요 키워드</strong></p>
<ul>
<li>Camel Context : 라우트가 동작하는 컨테이너</li>
<li>Route: 데이터 흐름을 정의하는 경로입니다. Camel DSL을 사용해 정의합니다.</li>
<li>Endpoint : 메시지를 송수신하는 지점</li>
<li>EIP (Enterprise Integration Patterns): 메시지 라우팅과 변환을 다루는 설계 패턴.<ul>
<li>Camel은 소프트웨어 통합에 관한 서적인 <code>Enterprise Integration Patterns</code> 에서 영감을 많이 받아, 재사용 가능한 패턴을 차용해 개발되었다. 시스템 간 메시지 라우팅 및 처리를 다루는 디자인 패턴의 모음이다.</li>
</ul>
</li>
<li>Component: 외부 시스템과의 통신을 처리하는 Camel의 플러그인.<ul>
<li>예: HTTP, FTP, JMS, Kafka 등.</li>
</ul>
</li>
<li>Processor &amp; Transformer: 메시지 처리 및 변환 역할을 합니다.</li>
</ul>
<h3 id="route--데이터가-이동하는-경로를-정의">Route : 데이터가 이동하는 경로를 정의</h3>
<p>Camel의 가장 기본적인 개념은 <code>Route</code> 이다. Camel을 구성하는 객체로, 데이터를 A에서 B로 이동시킬 수 있다. 이때, 각 지점인 A, B를 <code>Endpoint</code> 이라 지칭한다. Route는 각 Endpoint 경로를 정의할 때 사용된다. Java 또는 Xml 구문을 통해 Route를 생성할 수 있다. </p>
<pre><code class="language-java">from(&quot;sourceEndpoint&quot;)
    .to(&quot;destinationEndpoint&quot;);</code></pre>
<p>다음은 이전 폴더에 위치한 파일들을 특정 폴더로 이동시키는 예제코드이다.</p>
<pre><code class="language-java">from(&quot;file:home/customers/new&quot;)
    .to(&quot;file:home/customers/old&quot;);</code></pre>
<p><strong>Producer &amp; Consumer (생산자와 소비자)</strong>
Route, Endpoint, Component에서 Producer와 Consumer 개념이 사용된다.</p>
<p>Producer</p>
<ul>
<li>데이터를 보내는 역할 (to() 메서드로 지정)</li>
<li>엔드포인트 관점에서, 메시지가 전달되는 목적지</li>
<li>컴포넌트 관점에서, 디스크의 파일에 쓰거나 메시지 대기열에 적재하는 등 무언가를 작성하도록 구성된 컴포넌트이다.</li>
</ul>
<p>Consumer</p>
<ul>
<li>데이터를 받는 역할 (from() 메서드로 지정)</li>
<li>엔드포인트 관점에서, 메시지가 들어오는 시작점</li>
<li>컴포넌트 관점에서, 디스크에서 파일을 읽거나 REST 요청을 수신하는 등 무언가를 읽도록 구성된 컴포넌트이다.</li>
</ul>
<p><strong>DSL</strong></p>
<p>Apache Camel은 다양한 형태의 DSL(Domain Specific Language)을 이용해서 라우트들을 정의한다. Spring 애플리케이션에서 DSL의 주요 두 가지 형식은 Java DSL과 Spring XML DSL로 정의된다. 다음은 RouteBuilder 클래스를 사용하는 Java DSL로 정의된 라우트 예제이다.</p>
<pre><code>RouteBuilder builder = new RouteBuilder(){
    @Override
    public void configure() throws Exception{
        // Route Definition in Java DSL for
        // moving file from jms queue to file system.
        from(&quot;jms:queue:mySQueue&quot;).to(&quot;file://mysrc&quot;);
    }
}</code></pre><h3 id="endpoint--외부-시스템과-상호작용하는-지점">Endpoint : 외부 시스템과 상호작용하는 지점</h3>
<p>Endpoint는 Camel 이 다른 시스템과 메시지를 교환하는 인터페이스 이다. 라우트에서 이동하는 단계이다.(?)
여러 방법으로 엔드포인트를 선언할 수 있찌만, 가장 대표적인 방법은 다음과 같이 URI 처럼 보이는 구문을 사용해 선언한다.</p>
<pre><code class="language-text">URI 형식: &lt;component&gt;:&lt;specific-uri-options&gt;</code></pre>
<ul>
<li>component : 엔드포인트가 참조</li>
<li>options : 엔드포인트가 필요로 하는 설정</li>
</ul>
<p><strong>예제</strong></p>
<ul>
<li><code>prefix:mainpart?option1=xxx&amp;option2=xxx...</code></li>
<li><code>file:inputFolder</code> (파일 디렉토리)</li>
<li><code>http://example.com</code> (HTTP 요청)</li>
<li><code>jms:queue:orders</code> (JMS 큐)</li>
</ul>
<pre><code class="language-java">from(&quot;timer:foo?period=1000&quot;) // 1초마다 타이머 이벤트 생성
    .setBody(constant(&quot;Hello, Camel&quot;))
    .to(&quot;log:info&quot;); // 로그 출력</code></pre>
<h3 id="component--엔드포인트를-만들기-위한-도구">Component : 엔드포인트를 만들기 위한 도구</h3>
<p>Component는 특정 프로토콜이나 기술을 다루기 위해 Camel에 플러그인처럼 추가되는 모듈입니다. 엔드포인트를 생성할 수 있도록 컴포넌트 라이브러리를 제공한다. 컴포넌트는 디스크에 있는 파일, 사서함, Dropbox나 트위터 같은 앱 등 <code>외부 시스템과의 통신(연결)할 수 있는 플러그</code>와 같은 역할이다.</p>
<p>예를들어, 애플리케이션에 데이터를 저장하거나 가져오는 작업이 필요하다고 가정하자. Component는 이미 이러한 작업을 대신 해주는 기능을 제공한다. 따라서 파일을 읽거나 웹 서비스를 호출하기 위해 직접 코드를 작성하여 구현하는데 시간을 소모할 필요가 없다. 단지, 적절한 컴포넌트를 찾아서 사용하면 된다. 가장 일반적인 컴포넌트들은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>목적</th>
<th>Endpoint URI</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP</td>
<td>HTTP 요청 처리</td>
<td>http:</td>
</tr>
<tr>
<td>File</td>
<td>파일 읽기 및 쓰기</td>
<td>file:</td>
</tr>
<tr>
<td>REST</td>
<td>RESTful 서비스 구현</td>
<td>rest:</td>
</tr>
<tr>
<td>JMS</td>
<td>메시지 큐와의 통신 (예: ActiveMQ, RabbitMQ)</td>
<td>jms:</td>
</tr>
<tr>
<td>Direct</td>
<td>for joining your Camel routes together</td>
<td>direct:</td>
</tr>
<tr>
<td>Salesforce</td>
<td>for getting data in and out of Salesforce</td>
<td>salesforce:</td>
</tr>
</tbody></table>
<pre><code class="language-java">from(&quot;file:inputFolder&quot;)
    .to(&quot;http://example.com/api/upload&quot;);</code></pre>
<h3 id="eipenterprise-integration-pattern--시스템-간-메시지-라우팅-및-처리">EIP(Enterprise Integration Pattern) : 시스템 간 메시지 라우팅 및 처리</h3>
<p>먼저, <code>Enterprise Integration</code> 가 무엇인지 용어 정리를 하자면, 조직이 핵심 <strong>비즈니스 프로세스를 관리하고 통합하는</strong> 데 사용하는 포괄적인 소프트웨어 플랫폼으로 정의된다. 쉽게 말하면 <strong>기업 내의 다양한 시스템들을 효과적으로 연결하고 통신하게 만드는 방법론</strong> 이다. </p>
<p>실무적 상황을 예로 들어보자면, 금융권 기업은 예금 업무 시스템, 대출 상품 관리 시스템, 인터넷뱅킹 시스템 등 다양한 시스템을 가지고 있다. 이러한 시스템들이 서로 실시간으로 정보를 주고받아야 원활한 은행 업무가 가능하다. 이를 위해 중앙 허브 역할을 하는 통합 계층(E.g. APIM)을 두는데, 이때, Apache Camel과 같은 통합 엔진을 활용한다. 기업은 이를 통해 안정성과 확정성을 유지하면서, 다양한 애플리케이션과 시스템을 연결할 수 있는 일관된 방법을 제공받을 수 있다.</p>
<p>앞서 언급한 엔터프라이즈 통합 패턴이라는 책에 정의된 패턴에 따라 메시지를 처리한다. 메시지에서 변환, 분할 및 로깅과 같은 몇 가지 일반적인 작업을 수행하려는 경우 EIP를 사용한다. 다음은 Camel의 몇 가지 일반적인 EIP이다.</p>
<table>
<thead>
<tr>
<th>EIP</th>
<th>역할</th>
<th>Java 문법</th>
</tr>
</thead>
<tbody><tr>
<td>Splitter</td>
<td>Splits a message into multiple parts</td>
<td>.split()</td>
</tr>
<tr>
<td>Aggregator</td>
<td>Combines several messages into one message</td>
<td>.aggregate()</td>
</tr>
<tr>
<td>Log</td>
<td>Writes a simple log message</td>
<td>.log()</td>
</tr>
<tr>
<td>Marshal</td>
<td>Converts an object into a text or binary format</td>
<td>.marshal()</td>
</tr>
<tr>
<td>From*</td>
<td>Receives a message from an endpoint</td>
<td>.from()</td>
</tr>
<tr>
<td>To*</td>
<td>Sends a message to an endpoint</td>
<td>.to()</td>
</tr>
</tbody></table>
<p>예제</p>
<p>Content-Based Routing: 메시지 내용에 따라 다른 라우트를 선택</p>
<pre><code class="language-java">from(&quot;direct:start&quot;)
    .choice()
      .when(simple(&quot;${body} contains &#39;urgent&#39;&quot;))
        .to(&quot;jms:queue:urgent&quot;)
      .otherwise()
        .to(&quot;jms:queue:normal&quot;);</code></pre>
<p>Message Transformation: 메시지 변환</p>
<pre><code class="language-java">from(&quot;direct:start&quot;)
    .transform().simple(&quot;Modified: ${body}&quot;);</code></pre>
<p>Split/Aggregate: 메시지를 분할하거나 합침</p>
<pre><code class="language-java">from(&quot;file:inputFolder&quot;)
    .split(body().tokenize(&quot;\n&quot;))
    .to(&quot;direct:processLine&quot;);</code></pre>
<h3 id="processor--transformer">Processor &amp; Transformer</h3>
<p><strong>Processor</strong></p>
<p>메시지의 내용을 직접적으로 처리하여 비지니스로직을 구현할 수 있는 인터페이스이다.
메시지가 Camel 라우트를 통해 이동할 때, 중간 단계에서 Processor를 처리하여 메시지를 변환하거나 조작을 할 수 있다.</p>
<p>processor의 역할</p>
<ul>
<li>메시지 내용 변경 : 메시지의 본문, 헤더 또는 기타 속성을 변경할 수 있다.</li>
<li>비지니스 로직 추가 :  라우팅 중간에 특정 비지니스 로직을 추가하여 메시지를 변환하거나 필터링할 수 있습니다.</li>
</ul>
<p>Processor 또한 RouterBuilder처럼 클래스에서 상속을 받아 사용하며 apiCreator의 경우 apiService에서 제공하는 기능들 별로 processor를 상속하여 기능들을 구현하였다.
ex) parsingProcessor, convertProcessor</p>
<p><strong>Transformer</strong></p>
<h3 id="camel-context--컨테이너">Camel Context : 컨테이너</h3>
<p>모든 Camel 생성자의 런타임 컨테이너이며, 라우팅 규칙에 따라 수행된다.</p>
<h3 id="종합-정리">종합 정리</h3>
<p>주요 구성 요소들을 종합적으로 정리하자면, Camel의 키워드들은 아래와 같은 관계로 동작한다.</p>
<ul>
<li>Route는 메시지의 이동 경로를 정의하며, Endpoint를 통해 데이터를 송수신한다.</li>
<li>Component는 다양한 시스템과의 통신을 지원하며, EIP를 통해 메시지 처리 로직을 설계합니다.</li>
<li>모든 작업은 Camel Context에 의해 관리됩니다.</li>
</ul>
<p>참고 링크</p>
<ul>
<li><a href="https://tomd.xyz/camel-tutorial/">초보자를 위한 카멜 튜토리얼</a></li>
</ul>
<h2 id="camel-demo-project-for-study">Camel demo project for Study</h2>
<p><a href="https://www.udemy.com/share/1040Sc3@oS9ZY8GTjkMTGe7AEo1QJ8LqsN70jcT_R8FlINqJhaDJhXIJurs6tWzZpxbP3A7xeA==/">Udemy - Learn Apache Camel 강의</a>를 통해 학습한 내용을 정리했다. 2024년 연말 기준 가장 최신의 학습자료이다.</p>
<h3 id="routebuilder">RouteBuilder</h3>
<pre><code class="language-java">public class CustomRouter extends RouteBuilder {</code></pre>
<ul>
<li>route 구축을 위해 RouteBuilder를 상속받는다.</li>
</ul>
<pre><code class="language-java">    @Override
    public void configure() throws Exception {
        from(&quot;timer:first-timer&quot;)
            .transform().constant(&quot;Time now is &quot; + java.time.LocalTime.now()) // 상수를 사용하고 있기 때문에 동일한 메시지를 계속 전송
            .to(&quot;log:first-timer&quot;); // log endpoint
    }</code></pre>
<ul>
<li><code>from()</code>: 메시지를 수신하는 endpoint</li>
<li><code>transform()</code> : Camel은 EIP(Enterprise Integration Patterns)의 메시지 변형을 지원한다.</li>
<li><code>constant()</code> : 상수 사용<ul>
<li>상수를 사용하기 때문에 <code>LocalTime.now()</code> 메서드를 호출하더라도, 최초 호출 시 사용된 동일한 메시지가 전송된다.</li>
</ul>
</li>
<li><code>to()</code> : 메시지를 송신하는 endpoint</li>
</ul>
<h3 id="tranform-with-spring-bean--메시지를-동적으로-관리하기">tranform() with Spring Bean : 메시지를 동적으로 관리하기</h3>
<p>위 예제에서는 상수를 사용하기 때문에, 메시지를 정적으로 전달한다. 이를 동적으로 관리하기 위해 Spring Bean 을 사용해 리팩토링 해보자.</p>
<pre><code class="language-java">@Component
class GetCurrentTimeBean {

    public String getCurrentTime() {
        return &quot;Time now is &quot; + java.time.LocalTime.now();
    }

}</code></pre>
<ul>
<li>Bean으로 등록할 클래스를 정의한다.</li>
</ul>
<pre><code class="language-java">@Override
public void configure() throws Exception {
        from(&quot;timer:second-timer&quot;)
            .bean(&quot;getCurrentTime&quot;)
            .to(&quot;log:second-timer&quot;);
}
</code></pre>
<ul>
<li>메시지를 수신할 때 마다 getCurrentTimeBean의 getCurrentTime 메소드가 호출된다.</li>
</ul>
<p>그러나, 위와 같이 Bean 메서드 이름을 직접 문자열로 지정하는 것은 권장되지 않는 방법이다. 메서드 이름을 문자열로 지정하면 IDE에서 메서드 이름을 변경할 때 참조를 찾지 못해 오류 발생의 원인이 되며, 여러 개의 오버로드된 메서드가 있을 경우, Camel이 어떤 메서드를 호출할지 모호하다. 따라서 다음과 같은 방식이 권장된다.</p>
<pre><code class="language-java">.bean(MyBean.class, &quot;getCurrentTime&quot;)

// 또는 의존성 주입을 받은 경우

.bean(myBean, &quot;getCurrentTime&quot;)</code></pre>
<p>만약, Bean에 단일 메서드만 있는 경우, <code>@Handler</code> 어노테이션을 사용한 방식을 권장한다. 가장 명확하며, 메서드 이름을 문자열로 하드코딩하지 않아도 되며, 메서드 명이 변경될 때 자동으로 모든 참조를 찾아 변경된다.</p>
<pre><code class="language-java">@Component
class GetCurrentTimeBean {

    @Handler
    public String getCurrentTime() {
        return &quot;Time now is &quot; + java.time.LocalTime.now();
    }

}</code></pre>
<pre><code class="language-java">from(&quot;timer:second-timer&quot;)
    .bean(MyBean.class)  // 메서드 이름 지정 불필요
    .to(&quot;log:second-timer&quot;);</code></pre>
<h3 id="processing처리--transformation변환">Processing(처리) &amp; Transformation(변환)</h3>
<p>Processing</p>
<ul>
<li>메시지를 받았을 때 메시지 본문 자체를 변경하지 않는 작업이나 조작을 하고 싶을 때</li>
</ul>
<p>Transformation</p>
<ul>
<li>메시지 본문 자체를 변경하는 작업이나 조작을 하는 경우</li>
</ul>
<p>중요한 차이점은 메서드의 반환 타입이 있는가 이다.</p>
<p>bean을 사용해 둘다 구현 가능
또는 processor(), transform()을 사용할 것.</p>
<pre><code class="language-java">// Processor를 상속받아 사용하는 방법
class SimpleLoggingProcessor implements Processor {

    private Logger logger = LoggerFactory.getLogger(SimpleLoggingProcessor.class);

    @Override
    public void process(Exchange exchange) throws Exception {
        logger.info(&quot;SimpleLoggingProcessor {}&quot;, exchange.getMessage().getBody());
    }

}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024 연말 회고, 내년에는 비버가 되고 싶다.]]></title>
            <link>https://velog.io/@dev_hyun/2024-%EC%97%B0%EB%A7%90-%ED%9A%8C%EA%B3%A0-%EB%82%B4%EB%85%84%EC%97%90%EB%8A%94-%EB%B9%84%EB%B2%84%EA%B0%80-%EB%90%98%EA%B3%A0-%EC%8B%B6%EB%8B%A4</link>
            <guid>https://velog.io/@dev_hyun/2024-%EC%97%B0%EB%A7%90-%ED%9A%8C%EA%B3%A0-%EB%82%B4%EB%85%84%EC%97%90%EB%8A%94-%EB%B9%84%EB%B2%84%EA%B0%80-%EB%90%98%EA%B3%A0-%EC%8B%B6%EB%8B%A4</guid>
            <pubDate>Sat, 21 Dec 2024 02:24:29 GMT</pubDate>
            <description><![CDATA[<h2 id="회고의-목적">회고의 목적</h2>
<p>회고는 단순히 올해 있었던 일을 돌아보는 것이 아닌, 성장을 위한 과정이라고 한다. 따라서 자신을 평가하기보다, 어떤 상황에서 적절한 행동을 할 수 있다는 기대와 신념을 갖는 즉, 효능감을 느끼는 방향으로 회고를 진행하는 것이 중요하다고 한다.</p>
<p>회고를 바라보는 이러한 관점은 <code>내가 더 원하는 방향으로 나아가기 위한 목표와 방법을 찾는 도구로 사용하라는 의미.</code> 뿐만 아니라 <code>나 자신과의 대화/소통을 통해, 스스로에게 믿음을 가질 수 있는, 나를 돌보는 시간.</code> 을 갖는 것을 목적으로 한다고 느꼈다.</p>
<p>이러한 회고의 목적을 기억하면서 회고를 돕는 질문에 답을 하고, 최종적으로 <code>KPT+IE</code>를 작성해보려고 한다.</p>
<h2 id="2024년의-나는-2023년의-나와-어떻게-달랐나요">2024년의 <code>나</code>는 2023년의 <code>나</code>와 어떻게 달랐나요?</h2>
<blockquote>
<p>올해의 나는, 치열하게 보내온 날이 늘어난 한 해였다. 크고 작은 일들이 가득했으며, 많은 사람들과 의미있는 관계를 형성하기도 했다. 4개의 삶의 주요 부문에서 올해의 나는 과거의 나와 비교했을 때 무엇이 달라졌는지 톺아보자.</p>
</blockquote>
<p><strong>직업/커리어</strong></p>
<p>: 가장 큰 변화는 백엔드 개발자 커리어를 시작하게 된 것이다. 22년도 상반기에 국비교육과정을 첫 시작으로, 24년도 하반기에 APIM 솔루션 기업과 함께 하게 되었다. 작은 기업이지만 월급 밀릴 일 없는 회사이고, 사람으로 인해 스트레스 받을 일은 없을 것 같다.</p>
<p>: 디자이너, 기획자, 백엔드 및 프론트 개발자가 모여 6주간 사이드프로젝트를 진행하는 대외활동에 참가하여 1위를 수상했다. 정해진 기간동안 몰입하여 결과물을 성공적으로 산출했다는 것에 성취감을 가진 것은 물론이고, 이 결과물과 경험을 기반으로 만든 포트폴리오가 좋은 평가를 다수 받았다는 점에 정말 감사하다. 단순히 정해진 기간동안만 프로젝트를 수행한 것이 아니라, 수료 이후에도 개선과 유지보수 기간을 가짐으로써 개발 역량을 강화할 수 있었다.</p>
<p>: 군 복무와 병행하면서 방통대 컴퓨터과학과를 병행했었고, 올해 8월 매우 우수한 학점으로 졸업하게 되었다. 비록 일반적인 정규 4년제와는 차이가 확연하지만, 컴퓨터 전공에 대한 흥미나 갈증을 어느정도 충족하였으며 성취감 또한 높았다. 다소 아쉬운 점을 꼽자면, 방통대 내에서 더 적극적으로 오프라인 모임에 참여해보면 어땠을까 생각이 든다.</p>
<p><strong>관계/소통</strong></p>
<p>: 약 2년여 간의 군 복무를 올 하반기에 무사히 끝맺었다. 난생 처음으로 70여명이 넘는 직원들과 관계를 맺고 소통했다. 이렇게 많은 사람들과 소통해본적이 없거니와 끊임 없이 낯선 사람(민원인)에게 대응해야 하는 점이 처음에는 정말 힘들었다. 하지만 정말 감사하게도 나의 담당자 이셨던 대리님 덕분에 어떻게 소통해야 하는지 많이 배워갔다. 마치 사회로 던저지기 전에 복무지에서 작은 사회를 경험할 수 있었다. 감사하다는 표현으로도 부족할 만큼 본받고 싶은 분을 알게되었다. 어찌 생각하면, 그저 잠시 스쳐지나가는 관계가 될 수 있음에도, 친동생처럼 도와주고 조언해주셨다. 이 기간에 깨달았던 관계/소통에 대한 관점이 올 한해 새로운 사람들과도 좋은 관계를 시작할 수 있는 초석이 되었다.</p>
<p><strong>건강/삶의 균형</strong></p>
<p>: 회의감에서 시작된 사소한(?) 행동이 어느새 습관으로 이어졌고, 그것이 나도 모르는 사이에 육체적 건강에 악영향을 미치고 있다. 처음에는 단순히 스트레스를 해소하거나 상황을 모면하기 위한 선택이었지만, 반복되다 보니 그 행동이 이제는 일상의 일부처럼 느껴진다. 이를 자각한 후, 문제를 해결하기 위해 벗어나려고 시도하고 있다. 변화는 쉽지 않지만, 나의 의식으로 스스로를 해치는 모습이 나약해보이기도 하고, 이 습관을 고쳐 더 나은 모습을 보이려는 의지가 습관을 버티게 한다.</p>
<p>: 올 한해 나는 정신적으로는 하루하루를 루틴에 맞춰 살아가며, 나름 건강한 균형을 유지하고 있다고 생각한다. 일정한 기상 시간과 식사 시간, 그리고 일과 후 짧게라도 휴식 시간을 가지는 등의 적절한 밸런스가 기둥이 되고 있다. 그러나 주변에서는 내가 스스로를 지나치게 몰아붙이고 있는 건 아닌지 걱정하는 목소리가 많다. 특히 너무 많은 일들을 동시에 처리하거나, 목표를 향해 쉬지 않는 모습을 보고, &quot;스스로를 너무 몰아부치는 것 같다&quot;, &quot;이러다 번아웃이 올 수 있다&quot;는 조언을 자주 듣는다. 그들의 우려가 과장된 것은 아니라고 생각하지만, 현재로서는 내가 감당할 수 있는 수준이라고 믿고 있다. 다만 이러한 루틴 속에서도 스스로를 돌아보고, 지친 마음이 있을 땐 한 발 물러서 휴식하는 자세를 잊지 않으려 한다.</p>
<p>: 스트레스가 심한 날은, 개발을 이어가거나 혼자 생각의 늪에 빠져버리기보다, 잠시 멈춰 밤 공기를 마시며 산책을 하곤 했다. 고요한 밤하늘과 선선한 공기를 느끼며 걷다 보면, 복잡했던 머릿속이 서서히 정리되고 무거웠던 마음도 한결 가벼워진다. 이런 산책은 단순한 행동 그 이상으로, 나에게는 생각의 흐름을 차단하고 리셋하는 중요한 시간이 된다. 힘든 날일수록 이런 나아지기 위한 노력이 얼마나 큰 위안을 주는지 깨닫게 된다. 앞으로는 산책뿐만 아니라, 다양한 방식으로 스트레스를 해소하며 더 균형 잡힌 삶을 만들어가고자 한다. 이 모든 과정이 결국 나의 건강과 삶의 질을 한 단계 끌어올리는 기반이 될 것이라고 믿는다.</p>
<p><strong>취미</strong></p>
<p>: 새로운 시도를 할 여유가 없었던 것 같다. 스트레스 관리를 위한 산책을 제외하면 즐겁게 취미생활을 즐겼던 기억이 없다. 사람들과 수다 떠는 것도 취미가 될 수 있다면, 올해 가장 많이 했던 취미 중 하나일 것이다. 업무나 학업에 대한 긴장감을 잠시 내려놓고, 가까운 사람들과 가벼운 이야기를 나누는 시간이 나름의 소소한 힐링이었다. 하지만 이 역시도 정기적인 활동이라기보다는 간헐적으로 이루어졌기에, 나만의 시간을 온전히 즐길 수 있는 취미를 새롭게 개발할 필요성을 느꼈다. 새로운 시도를 통해 삶에 소소한 즐거움을 더하고, 일과 삶의 균형을 맞추는 데 더 나은 도움을 줄 수 있을 거라고 기대한다.</p>
<h2 id="2025년에-이루고-싶은-목표가-있다면">2025년에 이루고 싶은 목표가 있다면?</h2>
<p>커리어</p>
<ul>
<li>사내 기술 세미나 개최</li>
<li>개인 또는 팀 프로젝트 실 운영</li>
<li>자사 솔루션 코드를 막힘 없이 읽기</li>
</ul>
<p>인간관계</p>
<ul>
<li>상처가 아닌 행복이 될 수 있는 관계를 맺기</li>
</ul>
<p>내면</p>
<ul>
<li>몰아부치던 나를 내려놓기</li>
<li>성급한 마음을 줄이고 여유를 더하기</li>
</ul>
<h2 id="내년이-어떤-한-해-였으면-하는-지-동물로-표현한다면">내년이 어떤 한 해 였으면 하는 지 동물로 표현한다면?</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/4af9d7da-68b3-47c4-aaf3-88cb00eb71e5/image.png" alt=""></p>
<p>비버는 <code>Busy as a beaver</code> 라는 관용 표현이 있을 만큼 부지런함, 열정적, 성실함을 상징하는 동물이라고 한다. 생애 내내 끊임없이 거대한 댐(집)을 건설한다. 이뿐일까, 비버는 물 속과 땅 위에서 모두 자유자재로 활약가능하다. 뒷 발의 물갈퀴로 물 속에서 빠르게 헤엄칠 수 있고, 육지에서는 앞니를 이용해 두꺼운 나뭇가지도 자를 수 있다. 물과 육지는 매우 다른 환경임에도 비버는 어디에서도 잘 적응하며 환경알 잘 활용할 줄 아는 똑똑한 동물이다. 이보다 더 이상적인 동물을 찾기도 쉽지 않을 것 같다.</p>
<p>내년 한 해는 비버처럼 부지런히 움직이며 다양한 성취를 얻고 싶다. 그리고 여러 자원을 잘 활용해가며 회사 업무에도 잘 적응하는 25년이 되었으면 하는 바람으로 비버를 선택했다.</p>
<h2 id="kpt--ie">KPT + IE</h2>
<p>KPT 회고에서 확장해서 Insight, Emotion을 추가함
K : 유지하고 싶은 것
I : Insight, 깨달음
P : 개선하고 싶은 것
E : Emotion, 감정 점수와 그 이유
T : 시도하고 싶은 것</p>
<hr>
<h3 id="k-keep-유지하고-싶은-것"><strong>K: Keep (유지하고 싶은 것)</strong></h3>
<p>1) 꾸준한 학습과 성장 습관</p>
<ul>
<li>2024년 한 해 동안 목표 지향적인 태도로 꾸준히 학습하며 성장한 점은 정말 값진 성과였다. </li>
</ul>
<p>2) 산책과 스트레스 해소 루틴</p>
<ul>
<li>스트레스가 많은 날 산책을 통해 생각의 흐름을 차단하고 마음의 안정을 되찾는 루틴을 만들어냈다. 이는 단순한 습관 그 이상으로, 몸과 마음을 재정비할 수 있는 시간이었다.</li>
</ul>
<p>3) 관계에서의 진심 어린 노력</p>
<ul>
<li>관계 속에서 배운 소통의 기술과 배려심은 다른 환경에서도 적용 가능한 중요한 자질이다.</li>
</ul>
<h3 id="p-problem-개선하고-싶은-것"><strong>P: Problem (개선하고 싶은 것)</strong></h3>
<p>1) 건강을 해치는 습관 극복</p>
<ul>
<li>무심코 반복했던 나쁜 습관이 육체적 건강에 악영향을 미치고 있다는 것을 자각했지만, 여전히 완전히 벗어나지 못하고 있다.</li>
</ul>
<p>2) 스스로를 지나치게 몰아붙이는 태도</p>
<ul>
<li>주변의 우려처럼, 과도한 몰입으로 인해 장기적으로 번아웃 위험이 존재할 수 있다. 목표에만 집중하기보다 과정에서의 여유를 찾고, 스스로를 격려하며 완급 조절을 하는 법을 배워야 한다. 나 자신을 위로하고 잘 하고 있다고 칭찬할 줄 알아야 한다는 조언을 받아들여보려 한다.</li>
</ul>
<h3 id="t-try-시도하고-싶은-것"><strong>T: Try (시도하고 싶은 것)</strong></h3>
<p>1) 기술 세미나 발표 준비</p>
<ul>
<li>사내에서 기술 세미나를 개최하여 현재 배우고 있는 기술을 동료들과 공유하고, 나의 전문성을 더 깊게 다져보는 기회를 만들고자 한다. 이를 통해 발표력과 문제 해결 능력을 강화할 수 있을 것이다.</li>
</ul>
<p>2) 효율적인 시간 관리</p>
<ul>
<li>지나친 몰입을 완화하기 위해 일정 관리와 시간 사용을 보다 체계적으로 개선하려 한다. 업무 시간과 휴식 시간을 명확히 구분하고, 불필요한 에너지 소모를 줄이는 데 집중할 계획이다.</li>
</ul>
<p>3) 개발 외적인 스트레스 해소 방법 확장</p>
<ul>
<li>산책 외에도 독서, 취미 활동 등을 통해 스트레스를 해소하며 삶의 질을 높이고 균형을 유지하고자 한다.</li>
</ul>
<h3 id="i-insight-깨달음"><strong>I: Insight (깨달음)</strong></h3>
<p>인간 관계에서 얻는 에너지의 중요성</p>
<h3 id="e-emotion-감정-점수와-그-이유"><strong>E: Emotion (감정 점수와 그 이유)</strong></h3>
<ul>
<li>감정 점수: 9/10</li>
<li>이유: 올해는 큰 성과와 커리어의 성장도 많았으며, 힘들었던 순간들도 분명 있었지만, 내가 해낸 많은 성과와 관계들이 긍정적인 기억으로 남아있다. 더 잘 하고 싶다는 의지와 함께 새로운 한 해에 대한 기대감이 공존한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[GCP VM SSH 접속오류 해결과정]]></title>
            <link>https://velog.io/@dev_hyun/GCP-VM-SSH-%EC%A0%91%EC%86%8D%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@dev_hyun/GCP-VM-SSH-%EC%A0%91%EC%86%8D%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sun, 24 Nov 2024 08:06:10 GMT</pubDate>
            <description><![CDATA[<h2 id="😨-문제-상황">😨 문제 상황</h2>
<p>오프라인 매칭 플랫폼 <code>밥풀</code>의 모니터링 서버는 구글 클라우드 VM 위에 구성되어 있습니다.
그러던 어느날, 그동안 잘 접속되던 Grafana 대시보드에 로그인을 시도하니, <code>500 Internal Server Error</code> 가 응답되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/043cc662-573b-4685-9f4b-78d92ae91bd7/image.png" alt="그라파나_로그인_오류">
당시 화면 캡쳐가 없어서 유사한 이미지를 가져왔습니다. 위 Unknown 메시지 대신 <code>Internal Server Error</code> 메시지와 함께 500 응답이 내려왔습니다.</p>
<p>원인 분석을 위해 <strong>GCP를 통한 SSH 접속을 시도</strong>했습니다. 그런데! 아래와 같이 SSH 인증 실패 <code>SSH authentication has failed</code>가 발생하면서 연결되지 않는 문제가 발생했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/e03fda54-6bf5-42a8-9c1e-4dfcb57891e2/image.png" alt="gcp+ssh+authentication+has+failed"></p>
<h2 id="🩺-문제-진단">🩺 문제 진단</h2>
<p>문제해결 버튼을 클릭하면, VM은 정상 상태라는 응답 뿐 이었습니다. 원인을 파악하기 위해 <code>VM 인스턴스 → 로깅</code> 페이지에 진입했고, 아래와 같이 ssh 키가 만료되었다는 오류 메시지를 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/fd6a22e6-4759-4397-805f-2518ea806e9c/image.png" alt=""></p>
<h3 id="시도-ssh-키-재생성-및-메타데이터-등록-해결x">시도) SSH 키 재생성 및 메타데이터 등록 (해결X)</h3>
<p>최초에 SSH 키를 생성할 때, 만료기한을 따로 생성해둔 적이 없었던 것으로 기억하지만, expired 되었다고 하니 새로 생성을 시도했습니다. 키 생성, 설정, 접속 방법은 다음과 같습니다.</p>
<p><strong>1. SSH 키 생성</strong></p>
<ul>
<li>운영체제에 따라 터미널 또는 명령 프롬프트에 다음 명령어를 입력<pre><code class="language-bash">ssh-keygen -t rsa -f ~/.ssh/[KEY_FILENAME] -C [USERNAME]</code></pre>
<ul>
<li><code>KEY_FILENAME</code> : 생성할 키 파일의 이름 (예: gcp_key)</li>
<li><code>USERNAME</code>: 사용자 이름 (예: hyun)<ul>
<li>유의) GCP 사용자 이름과, 입력하는 이름이 일치해야 접속이 가능한다는 커뮤니티 글을 검색을 통해 발견했었습니다.</li>
</ul>
</li>
<li>비밀번호 입력 요청이 나오면, 선택적으로 입력하거나 그냥 Enter를 눌러 넘어갈 수 있습니다.</li>
</ul>
</li>
<li>위 명령어는 두 개의 파일을 생성합니다: [KEY_FILENAME] (비공개 키), [KEY_FILENAME].pub (공개 키)</li>
</ul>
<p><strong>2. GCP에 SSH 키 등록</strong></p>
<ul>
<li>콘솔 왼쪽 메뉴에서 &quot;컴퓨팅 엔진&quot; &gt; &quot;메타데이터&quot;로 이동합니다.</li>
<li>&quot;SSH 키&quot; 탭에서 &quot;SSH 키 추가&quot; 버튼을 클릭합니다.</li>
<li>로컬에서 생성한 [KEY_FILENAME].pub 파일의 내용을 복사하여 붙여넣습니다.</li>
</ul>
<p><strong>3. 서버 인스턴스에 접속</strong></p>
<ul>
<li>GCP 콘솔에서 해당 인스턴스의 외부 IP 주소를 확인합니다.</li>
<li>터미널에서 다음 명령어를 실행합니다:<pre><code class="language-bash">ssh -i ~/.ssh/[KEY_FILENAME] [USERNAME]@[외부IP]</code></pre>
<ul>
<li><code>KEY_FILENAME</code> : 앞서 생성한 비공개 키 파일의 경로</li>
<li><code>USERNAME</code> : SSH 키 생성 시 사용한 사용자 이름</li>
<li><code>외부IP</code> : GCP 인스턴스의 외부 IP 주소</li>
<li>처음 접속 시 호스트 인증 메시지가 나타날 수 있습니다. &quot;yes&quot;를 입력하여 계속 진행합니다.</li>
</ul>
</li>
</ul>
<hr>
<p>하지만, 위 방법은 저에게는 해결되지 않은 시도였습니다. 동일하게 SSH 접속 시도에 실패했습니다.</p>
<h3 id="진단-성공-부팅디스크-용량이-꽉-참">진단 성공) 부팅디스크 용량이 꽉 참</h3>
<p>이번에는 <code>직렬포트</code> 출력 페이지에 진입했고, 이 곳에서 해당 문제의 원인을 찾을 수 있었습니다. (직렬포트는 해당하는 VM 인스턴스 후 로그 부분에서 클릭을 통해 확인할 수 있습니다.)</p>
<p>이전에 <code>gcp authentication has failed</code> 또는 <code>gcp ssh Permission denied (publickey)</code> 와 같은 키워드로 검색하는 과정에서 드물게 디스크 용량이 꽉 차서 SSH 접속이 불가했다는 내용을 보았으나, 한 번도 용량이 찼던 경험이 없었고, expired 라는 로그 메시지에 꽂혀, 디스크 용량에 대해서는 차순위로 미뤄뒀었습니다. 그런데 직렬포트 출력 페이지를 확인해보니 아래와 같이 <code>No space left on device</code> 메시지를 확인할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/f13f1b20-6c69-48a7-9eb9-ed574299f462/image.png" alt="직렬포트_로그메시지"></p>
<p>GCP 는 SSH 접속 외에도 gcloud CLI 라는 shell 을 지원합니다. 브라우저 기반의 명령줄 인터페이스로, 구글 클라우드에서 돌아가는 컴퓨팅 리소스에 접근할 수 있습니다. gcloud를 통해서도 접속을 시도하면, 아래 캡쳐와 같이 <code>디스크에 공간이 없어 Login Service 가 정상적으로 동작하지 못한다</code> 는 것을 확인 할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/2434a310-d247-4651-9c40-91bd9d1655be/image.png" alt="gcloud_메시지"></p>
<p>이로써, 약 2시간의 삽질 끝에 드디어 <code>디스크의 용량이 꽉 찼기 때문</code> 이라는 원인을 파악했습니다.</p>
<h2 id="🏃♂️➡️-시도--부팅디스크-용량-늘리기">🏃‍♂️‍➡️ 시도 : 부팅디스크 용량 늘리기</h2>
<p>그저 간단하게 <code>Snapshot을 생성 → 해당 스냅샷으로 새로운 디스크를 생성 → 생성과정에서 기존 디스크보다 큰 용량을 가진 디스크를 생성 → VM에 새로 생성한 디스크를 연결</code> 과정을 통해 VM에 정상 접속되는 행복회로를 머릿속에서 그렸으나.. 쉽게 해결되지 않았습니다.</p>
<p>블로그를 참고해 제가 수행한 과정은 다음과 같습니다. 참고한 링크는 <a href="https://ballpen.blog/gcp-%EB%B6%80%ED%8C%85-%EB%94%94%EC%8A%A4%ED%81%AC-%EC%9A%A9%EB%9F%89/">이곳(GCP 부팅 디스크 용량 늘리기 : 스냅샷 활용)</a> 입니다.</p>
<ol>
<li><p>스냅샷 생성</p>
<ul>
<li>GCP 콘솔 왼쪽 메뉴에서 &quot;Compute Engine&quot; &gt; &quot;스냅샷&quot;</li>
<li>&quot;스냅샷 만들기&quot; 버튼을 클릭합니다.<ul>
<li>이름: 원하는 이름 입력</li>
<li>소스 디스크: 현재 VM 인스턴스의 디스크 선택</li>
<li>유형: 스냅샷</li>
<li>위치: VM 인스턴스와 동일한 리전 선택</li>
</ul>
</li>
<li>생성</li>
</ul>
</li>
<li><p>새로운 부팅 디스크 생성</p>
<ul>
<li>&quot;Compute Engine&quot; &gt; &quot;디스크&quot;</li>
<li>&quot;디스크 만들기&quot; 버튼을 클릭합니다.<ul>
<li>이름: 원하는 이름 입력</li>
<li>위치 및 리전: 기존 디스크와 동일하게 설정</li>
<li>디스크 소스 유형: &quot;스냅샷&quot; 선택</li>
<li>소스 스냅샷: 이전 단계에서 생성한 스냅샷 선택</li>
<li>디스크 유형: 필요에 따라 선택 (예: 표준 영구 디스크)</li>
<li>크기: 기존 디스크 크기보다 큰 크기로 설정 (예: 30GB -&gt; 40GB)</li>
</ul>
</li>
<li>생성</li>
</ul>
</li>
<li><p>VM 인스턴스에 새 부팅 디스크 연결</p>
<ul>
<li>&quot;Compute Engine&quot; &gt; &quot;VM 인스턴스&quot;</li>
<li>대상 VM 인스턴스를 선택하고 &quot;중지&quot; 버튼을 클릭하여 인스턴스를 중지합니다.</li>
<li>&quot;수정&quot; &gt; &quot;부팅 디스크 분리&quot; &gt;&quot;부팅 디스크 구성&quot;<ul>
<li>&quot;기존 디스크&quot; 탭을 선택하고, 새로 생성한 디스크를 선택합니다.</li>
<li>&quot;선택&quot; 버튼을 클릭합니다.</li>
</ul>
</li>
<li>수정 완료</li>
</ul>
</li>
<li><p>VM 인스턴스 재시작 및 확인</p>
<ul>
<li>해당 인스턴스를 &quot;시작/재개&quot;</li>
<li>SSH 접속 시도.</li>
</ul>
</li>
</ol>
<h3 id="시도-결과">시도 결과</h3>
<p>인스턴스 수정에서 디스크 사이즈를 기존 30G 에서 40G 으로 업데이트 하고 VM을 재시작했으나, 동일하게 SSH 접속이 불가했습니다.</p>
<p>원인은, 자동 파티션 확장을 위한 여유 디스크 공간이 없었기 때문입니다. 즉, 디스크의 크기를 늘려준 후, VM을 재시작할 경우 자동 파티션 확장을 위해 서비스가 동작해야 합니다. 그런데, 해당 서비스가 동작하는 과정에서 필요한 로그파일을 생성하거나 파일 쓰기 작업이 수행되기 위한 여유 공간이 조금도 없었기에 자동확장이 되지 않았음을 추정할 수 있었습니다.</p>
<p>그러니, 여유 공간이 조금이라도 있었다면 파티션 자동확장이 되어 위 과정을 끝으로 문제가 해결되었을 것입니다.</p>
<h2 id="🌱-해결-임시-vm에-mount해서-용량-줄이기">🌱 해결) 임시 VM에 Mount해서 용량 줄이기</h2>
<blockquote>
<p>전체 해결 과정을 크게 4단계로 요약하자면 다음과 같습니다.</br>
<strong>1. 용량 늘린 부팅디스크 만들기
2. 임시 VM 만들어, 추가 디스크로 붙이기
3. Mount 하여 대용량 파일 줄이기
4. 원래 VM에 재연결 하기</strong></p>
</blockquote>
<p>위 단계들을 </p>
<ol>
<li><p><strong>문제가 있는 VM 중지</strong></p>
</li>
<li><p><strong>원래VM에서 부팅 디스크를 분리</strong></p>
<ul>
<li>GCP 콘솔에서 &quot;Compute Engine&quot; &gt; &quot;VM 인스턴스&quot;로 이동.</li>
<li>문제가 있는 VM을 클릭 → 상세 페이지 → &quot;디스크&quot; 섹션의 부팅 디스크 영역.</li>
<li>디스크 옆의 더보기 메뉴(세 개의 점)를 클릭하고 &quot;분리&quot;를 선택.</li>
</ul>
</li>
<li><p><strong>디스크 크기 증가시키기</strong></p>
<ul>
<li>&quot;Compute Engine&quot; → &quot;디스크&quot; → 분리한 디스크.</li>
<li>상단의 &quot;디스크 크기 조정&quot; → 원래 크기보다 큰 크기(예: 50GB)를 입력 후 완료.</li>
</ul>
</li>
<li><p><strong>임시 VM 생성</strong></p>
<ul>
<li>&quot;Compute Engine&quot; &gt; &quot;VM 인스턴스&quot; &gt; &quot;인스턴스 만들기&quot;로 이동.</li>
<li>새로운 임시 VM을 생성, 크기가 증가된 문제의 디스크를 이 VM에 추가 디스크로 연결.<ul>
<li>임시 VM의 이름을 입력합니다(예: temp-instance).</li>
<li>리전과 영역을 원래 VM과 동일하게 선택.</li>
<li>임시 VM 사양: Ubuntu 20.04, 10GB 부팅 디스크</li>
</ul>
</li>
<li>&quot;새 디스크 추가&quot;를 클릭합니다.</li>
<li>&quot;기존 디스크&quot;를 선택하고, 크기를 조정한 문제의 디스크를 찾아 선택합니다.</li>
<li>&quot;모드&quot; : &quot;읽기/쓰기&quot;(default)로 설정합니다. 설정을 완료하면 아래 이미지와 같이 표시됩니다.</li>
<li>&quot;만들기&quot; 버튼을 클릭하여 임시 VM을 생성합니다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/84f8ddce-5daf-4115-b20c-a04fb71fdd27/image.png" alt="gcp_vm_생성"></li>
</ul>
</li>
<li><p><strong>디스크 확인</strong></p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/c1a52bfc-50a8-4e55-b56a-5afb7cb9fc09/image.png" alt=""></p>
<ol start="6">
<li><strong>마운트 하기</strong></li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/97138758-433d-4363-b2c7-8f3d997d9e43/image.png" alt=""></p>
<ol start="7">
<li><strong>큰 파일/폴더 식별</strong></li>
</ol>
<pre><code class="language-bash">du -sh /mnt/disk/*</code></pre>
<ul>
<li><code>/mnt/disk/var</code> 폴더의 크기가 가장 컸다.</li>
</ul>
<p><code>sudo du -h /mnt/disk/var | sort -rh | head -n 20</code> 명령어로 <code>/mnt/disk/var</code> 디렉토리와 그 하위 디렉토리의 크기를 계산하고, 크기 순으로 정렬한 후 상위 20개를 보여줍니다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/1a090704-197d-4277-8506-f87960abc8db/image.png" alt=""></p>
<ol start="8">
<li><strong>로그 파일 용량 줄이기</strong></li>
</ol>
<pre><code class="language-bash">sudo truncate -s 0 /mnt/disk/var/lib/docker/containers/9d18351b34b0ba2a9fa8c56e3be6e3defb08a7c891dd6c4f550f721c57fb24de/*-json.log</code></pre>
<ul>
<li>마운트된 디스크에서 가장 큰 공간을 차지하는 파일들을 확인했습니다.</li>
<li>Docker 관련 파일, 특히 컨테이너 로그 파일이 가장 많은 공간(약 19GB)을 차지하고 있었습니다.</li>
<li>불필요한 로그 파일을 정리하여 상당한 디스크 공간을 확보했습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/0b5ca75d-6741-411a-9ad2-fc45f6bb1e83/image.png" alt=""></p>
<p><code>umount /mnt/newdisk</code>
마운트 해제</p>
<ol start="9">
<li><strong>임시 VM 중지, 디스크 분리</strong></li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/17b413f3-f929-4242-b672-b9f475c11365/image.png" alt=""></p>
<ol start="10">
<li><p><strong>원래 VM에 디스크 연결</strong></p>
<ul>
<li>정리 작업 후, 임시 VM에서 디스크를 분리하고 원래 VM에 다시 연결했습니다.</li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/832b138d-ee43-4105-a9c2-1ff6969d5b9c/image.png" alt=""></p>
<ol start="11">
<li><strong>원래 VM SSH 접속</strong><ul>
<li>원래 VM을 재시작하고 SSH 접속을 시도했습니다.</li>
<li>성공적으로 접속되었으며, df -h 명령어로 디스크 사용량을 확인했습니다.</li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/1439420d-ab16-4d80-b83d-dcc5a069cef6/image.png" alt=""></p>
<ol start="12">
<li><strong>용량이 성공적으로 늘어났는지 확인하기</strong></li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/fac99381-2212-4ad9-8f0a-a66297ba0e9b/image.png" alt=""></p>
<ul>
<li>자동으로 파티션이 확장되었다. </li>
</ul>
<ol start="13">
<li><strong>임시 VM 삭제</strong></li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/7a20ccb7-e8ce-44c6-af1a-0ef4ed32f51f/image.png" alt=""></p>
<h2 id="💉-추가-조치">💉 추가 조치</h2>
<p>도커 로그 파일의 용량이 끝없이 늘어났던 이번 이슈를 재발방지 하기 위해 Docker 데몬 설정을 적용했습니다. 모든 컨테이너에 대해 기본 설정을 적용하려면, <code>etc/docker/daemon.json</code> 파일을 수정해야 합니다. 이 json-file 도커 로깅 드라이버가 기본적으로 최대 용량이 정해져있지 않기 때문에 만약 이번 모니터링 서버와 같이 로그가 많이 남는 서비스라면 저장 공간이 부족해지는 일이 발생할 수도 있습니다. 다음 명령어를 통해 <code>daemon.json</code> 파일 수정을 위한 vi 에디터를 실행합니다.</p>
<pre><code class="language-bash">sudo vi /etc/docker/daemon.json</code></pre>
<p>아래와 같은 설정을 적용해주었습니다. <code>max-size</code>를 통해 로그 파일 크기가 5 기가에 도달하였을 때, 로그는 종료되지 않고 lotation 됩니다. 즉, 새로운 로그는 기존 파일을 덮어쓰지 않고 새 파일에 기록됩니다. 또한 <code>max-file</code>을 통해 파일의 개수가 이미 3개인 경우 가장 오래된 로그파일이 삭제되고 새 파일이 생성됩니다.</p>
<pre><code class="language-json">{
  &quot;log-driver&quot;: &quot;json-file&quot;,
  &quot;log-opts&quot;: {
    &quot;max-size&quot;: &quot;5G&quot;,
    &quot;max-file&quot;: &quot;3&quot;
  }
}</code></pre>
<p>설정이 완료되면 Docker 데몬 재시작을 해야 합니다.</p>
<p>이 방식으로 로그는 계속 기록되며, 지정된 크기와 파일 수를 유지합니다. 따라서 애플리케이션은 중단 없이 계속 실행되고 로그도 계속 생성됩니다. 단, 오래된 로그는 자동으로 삭제되므로 중요한 로그는 별도로 백업하는 것이 좋을 것 같다고 판단했습니다.</p>
<h2 id="🙆🏻♂️-끝맺음">🙆🏻‍♂️ 끝맺음</h2>
<p>이 과정을 통해 디스크 공간 부족 문제를 해결하고 VM의 정상적인 작동을 복구할 수 있었습니다.
향후 유사한 문제를 방지하기 위해 정기적인 로그 관리와 디스크 사용량 모니터링의 중요성을 인식하게 되었습니다.</p>
<p>참고한 링크</p>
<ul>
<li>🌟 <a href="https://cloud.google.com/compute/docs/disks/recover-vm?hl=kodf">Compute Engine 공식 가이드::손상되었거나 전체 디스크가 있는 VM 복구</a></li>
<li><a href="https://hyungin0505.tistory.com/m/61">[GCP] SSH authentication has failed 디스크 용량 부족으로 인한 SSH 접속 실패 해결</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[H2 데이터베이스 Unknown data type 트러블슈팅]]></title>
            <link>https://velog.io/@dev_hyun/H2-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-unknown-data-type-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@dev_hyun/H2-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-unknown-data-type-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Sun, 04 Aug 2024 08:18:38 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>H2 데이터베이스는 Java 기반 프로젝트에서 자주 사용되는 경량 임베디드 데이터베이스입니다. 특히 테스트 환경에서 MySQL을 대체하여 사용되곤 합니다. 하지만 MySQL과 H2 사이의 미묘한 차이점으로 인해 예상치 못한 문제에 직면하였습니다. 이 글에서는 MySQL 문법으로 작성된 SQL 쿼리를 H2 데이터베이스에서 실행할 때 발생한 문제와 그 해결 과정을 공유하고자 합니다.</p>
<h2 id="문제-발생">문제 발생</h2>
<p>테스트 환경에서 H2 데이터베이스를 사용하여 MySQL용으로 작성된 쿼리를 실행하던 중, 다음과 같은 오류가 발생했습니다.</p>
<pre><code class="language-bash">Caused by: org.h2.jdbc.JdbcSQLNonTransientException: Unknown data type: &quot;?, ?&quot;; SQL statement:</code></pre>
<h2 id="원인--prepared-statement-와-unknown-data-type-오류">원인 : Prepared Statement 와 Unknown data type 오류</h2>
<p>문제의 원인은 H2 데이터베이스가 Prepared Statement를 처리하는 방식에 있었습니다. 사용된 쿼리는 다음과 같았습니다</p>
<pre><code>&lt;insert id=&quot;savePossibleDateTimeListWhereNotExist&quot;&gt;
        INSERT INTO t_possible_datetime (possible_datetime_id, possible_datetime, possible_datetime_status, user_id)
        SELECT possibleDateTimeId, possibleDateTime, possibleDateTimeStatus, userId
        FROM (
        &lt;foreach collection=&quot;possibleDateTimeList&quot; item=&quot;possibleDateTime&quot; separator=&quot; UNION ALL &quot;&gt;
            SELECT #{possibleDateTime.possibleDateTimeId} AS possibleDateTimeId,
                   #{possibleDateTime.possibleDateTime} AS possibleDateTime,
                   #{possibleDateTime.possibleDateTimeStatus} AS possibleDateTimeStatus,
                   #{possibleDateTime.userId} AS userId
        &lt;/foreach&gt;
        ) AS new_values
        WHERE NOT EXISTS (
            SELECT 1
            FROM t_possible_datetime
            WHERE user_id = new_values.userId
            AND possible_datetime = new_values.possibleDateTime
        );
    &lt;/insert&gt;
</code></pre><p>H2 데이터베이스는 MySQL과 달리 <code>Prepared Statement</code>를 즉시 컴파일하려고 시도합니다. 이 과정에서 플레이스홀더(<code>?</code>)의 데이터 타입을 즉시 결정하려고 하는데, 위 쿼리에서는 UNION ALL 구문 내의 값들에 대한 명시적인 데이터 타입이 없어 문제가 발생했습니다.</p>
<h2 id="해결-방법--명시적-캐스팅">해결 방법 : 명시적 캐스팅</h2>
<p>문제를 해결하기 위해 각 플레이스홀더에 명시적인 캐스팅을 적용했습니다. </p>
<p>H2 데이터베이스에서 CAST 함수는 다음과 같은 문법을 사용합니다</p>
<pre><code class="language-sql">CAST(expression AS data_type)</code></pre>
<p>지원하는 데이터 타입은 <a href="https://www.h2database.com/html/datatypes.html">해당 공식문서 링크</a>에서 확인할 수 있으며, 제가 사용한 타입은 <code>INT</code>, <code>TIMESTAMP</code>, <code>VARCHAR</code> 입니다.</p>
<p>수정된 쿼리는 다음과 같습니다</p>
<pre><code class="language-mysql">&lt;insert id=&quot;savePossibleDateTimeListWhereNotExist&quot;&gt;
    INSERT INTO t_possible_datetime (possible_datetime_id, possible_datetime, possible_datetime_status, user_id)
    SELECT possibleDateTimeId, possibleDateTime, possibleDateTimeStatus, userId
    FROM (
        &lt;foreach collection=&quot;possibleDateTimeList&quot; item=&quot;possibleDateTime&quot; separator=&quot; UNION ALL &quot;&gt;
            SELECT CAST(#{possibleDateTime.possibleDateTimeId} AS INT) AS possibleDateTimeId,
                   CAST(#{possibleDateTime.possibleDateTime} AS TIMESTAMP) AS possibleDateTime,
                   CAST(#{possibleDateTime.possibleDateTimeStatus} AS VARCHAR) AS possibleDateTimeStatus,
                   CAST(#{possibleDateTime.userId} AS INT) AS userId
        &lt;/foreach&gt;
    ) AS new_values
    WHERE NOT EXISTS (
        SELECT 1
        FROM t_possible_datetime
        WHERE user_id = new_values.userId
        AND possible_datetime = new_values.possibleDateTime
    );
&lt;/insert&gt;
</code></pre>
<p>이렇게 명시적 캐스팅을 사용함으로써 H2 데이터베이스가 각 플레이스홀더의 데이터 타입을 정확히 인식할 수 있게 되었고, 쿼리가 성공적으로 실행되었습니다.</p>
<p>참고 링크 : </p>
<ul>
<li><a href="https://github.com/h2database/h2database/issues/3253">https://github.com/h2database/h2database/issues/3253</a></li>
<li><a href="https://github.com/h2database/h2database/issues/1383">https://github.com/h2database/h2database/issues/1383</a></li>
</ul>
<h2 id="h2-데이터베이스의-mysql-모드-한계">H2 데이터베이스의 MySQL 모드 한계</h2>
<p>H2 데이터베이스는 MySQL 호환 모드를 제공하지만, 100% 완벽한 호환성을 보장하지는 않습니다. 이번 사례에서 볼 수 있듯이, 특히 복잡한 쿼리나 특정 MySQL 고유 기능을 사용할 때 예상치 못한 문제가 발생할 수 있습니다.</p>
<p>Spring Data JPA와 같은 ORM을 사용하면 데이터베이스 종속성을 줄일 수 있겠지만, 매퍼방식을 사용할 경우 해결되지 않습니다.</p>
<p>대안으로 <code>TestContainers</code> 사용을 고려해보면 좋을 것 같다는 생각으로 해당 트러블 슈팅을 마무리했습니다. TestContainers 는 테스트 실행 시 Docker 컨테이너를 사용하여 실제 MySQL 인스턴스를 제공하여 테스트의 격리성과 재현성을 높일 수 있다는 장점이 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클라이언트의 올바른 IP를 로그로 출력하기]]></title>
            <link>https://velog.io/@dev_hyun/%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EC%9D%98-%EC%98%AC%EB%B0%94%EB%A5%B8-IP%EB%A5%BC-%EB%A1%9C%EA%B7%B8%EB%A1%9C-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyun/%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EC%9D%98-%EC%98%AC%EB%B0%94%EB%A5%B8-IP%EB%A5%BC-%EB%A1%9C%EA%B7%B8%EB%A1%9C-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 12 Jun 2024 07:49:10 GMT</pubDate>
            <description><![CDATA[<h2 id="동일한-ip가-로그로-출력되는-문제">동일한 IP가 로그로 출력되는 문제</h2>
<p>로그 포맷이라고 부를 수 있을 최소 요건을 갖춘 것 같아, 로그 기능을 적용한 버전을 재배포 하고 결과를 살펴봤습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/cbc6ac11-f8f0-4eca-8562-c310215771f6/image.png" alt="로그+출력포맷+캡처"></p>
<p>확실히 정돈된 형식으로 로그가 쌓이는 모습에 기쁨도 잠시,, 무언가 이상한 점이 눈에 띄었습니다. 바로, 어떤 클라이언트가 요청을 전송하든 <code>CLIENT IP:</code> 속성에 동일한 IP가 출력되고 있었습니다. 원인을 찾아가 보았습니다.</p>
<h3 id="원인은-마지막-프록시-서버">원인은 마지막 프록시 서버</h3>
<p>먼저 클라이언트의 IP를 포함해 로그를 출력하는 코드를 살펴보면 Spring 서버는 로깅 처리를 위해 생성한 <code>MdcLoggingFilter</code> 필터에서 <code>HttpServletRequest</code> 으로부터 <code>getRemoteAddr()</code> 메서드를 통해 클라이언트의 IP를 추출합니다. 사용된 <code>HttpServletRequest.getRemoteAddr()</code> 메서드는 Spring 애플리케이션에 요청을 전달한 마지막 프록시의 IP 주소를 반환합니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/46304072-9d11-4b26-9b50-d340ae82e7e5/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>출처 : <a href="https://docs.oracle.com/javaee%2F7%2Fapi%2F%2F/javax/servlet/ServletRequest.html#getRemoteAddr--">ServletRequest 공식 문서</a></td>
</tr>
</tbody></table>
<p>따라서 해당 프로젝트의 아키텍처 상 <code>HttpServletRequest.getRemoteAddr()</code> 메서드는 프록시 역할을 하는 Nginx 컨테이너의 IP를 반환하던 것 이었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/1428df32-4d34-4f0e-a8a8-c84ac391c128/image.png" alt="client+proxy+webserver">
그림으로 보면 <code>getRemoteAddr()</code>는 빨간색 reverse proxy 서버의 IP를 반환합니다.</p>
<p>정말 그러한지 궁금하니 SSH 프로토콜로 접속해 <code>docker inspect {nginx 컨테이너 명}</code> 명령어를 수행하고, <code>Networks</code> 에 속한 <code>IPAddress</code> 를 살펴보았더니 아래와 같이 정말 문제의 IP를 확인할 수 있었습니다. (참고한 링크 : <a href="https://ahndrenaline.tistory.com/entry/%EB%8F%84%EC%BB%A4-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B8%B0%EB%B3%B8-2-Networking-tutorials">도커 네트워크 기본</a>)</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/23b80836-80d8-4eab-b259-a0688c8b97b3/image.png" alt="도커+nginx+컨테이너의+ip"></p>
<h2 id="해결-과정">해결 과정</h2>
<h3 id="x-forwarded-for">X-Forwarded-For</h3>
<p>이러한 문제를 해결하기 위해 사실상 표준헤더인 <code>X-Forwarded-For</code> 를 사용했습니다.</p>
<p><code>X-Forwarded-For</code> 의 구조는 다음과 같이, 클라이언트 IP와 거쳐온 프록시 IP들이 콤마로 구분되어 나열됩니다.</p>
<pre><code>X-Forwarded-For: &lt;client&gt;, &lt;proxy1&gt;, &lt;proxy2&gt;</code></pre><h3 id="nginx-설정">Nginx 설정</h3>
<p>Nginx 설정을 통해 HTTP Header 에 클라이언트의 진짜 IP 정보를 추가하고, Spring 서버의 <code>MdcLoggingFilter</code> 필터에서 추가한 헤더로부터 IP를 추출하도록 수정하면 됩니다.</p>
<p><strong>Nginx 헤더 설정</strong></p>
<pre><code>location /{URL패턴} {
        ...
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
        ...
    }</code></pre><p><code>$remote_addr</code></p>
<ul>
<li>Nginx에 직접 연결된 클라이언트의 IP 주소를 나타냅니다. 프록시 서버가 없는 경우 실제 클라이언트의 IP가 되지만, 프록시 서버를 통해 요청이 오는 경우 마지막 프록시 서버의 IP가 됩니다.</li>
</ul>
<p><code>$proxy_add_x_forwarded_for</code></p>
<ul>
<li>기존의 X-Forwarded-For 헤더 값에 $remote_addr를 추가합니다. X-Forwarded-For 헤더가 없는 경우 $remote_addr와 동일한 값을 가집니다. 여러 프록시를 거치는 경우, 이 헤더는 클라이언트의 원래 IP와 중간 프록시들의 IP 목록을 콤마로 구분하여 포함하게 됩니다.</li>
</ul>
<p><strong>ClientIPResolver 유틸 클래스</strong></p>
<p>Nginx 설정으로 고려해 요청에 대한 로그를 작성할 때 클라이언트의 올바른 IP를 추출하기 위해 <code>ClientIPResolver</code> 클래스를 다음과 같이 만들었습니다.</p>
<pre><code class="language-java">@Slf4j
public class ClientIPResolver {

    private static final List&lt;String&gt; localhostList = List.of(&quot;...&quot;);

    /**
     * &quot;x-forwarded-for&quot;, &quot;x-real-ip&quot; 헤더를 통해 클라이언트 IP를 반환한다.
     * IP를 찾을 수 없는 경우 &quot;unknown&quot; 문자열을 반환한다.
     * @param httpServletRequest
     * @return 클라이언트 IP
     */
    public static String getClientIP(HttpServletRequest httpServletRequest) {
        String clientIP = null;

        String xff = httpServletRequest.getHeader(&quot;X-Forwarded-For&quot;);
        if (StringUtils.hasText(xff)){
            List&lt;String&gt; xffList = Arrays.asList(xff.split(&quot;,&quot;));
            clientIP = xffList.get(0).trim();
        }

        if (!StringUtils.hasText(clientIP)) {
            clientIP = httpServletRequest.getHeader(&quot;x-real-ip&quot;);
        }

        if (!StringUtils.hasText(clientIP) &amp;&amp; StringUtils.hasText(httpServletRequest.getHeader(&quot;host&quot;))) {
            clientIP = localhostList.contains(httpServletRequest.getHeader(&quot;host&quot;)) ? &quot;localhost&quot; : null;
        }

        if (!StringUtils.hasText(clientIP) &amp;&amp; StringUtils.hasText(httpServletRequest.getRemoteAddr())) {
            clientIP = httpServletRequest.getRemoteAddr();
        }

        if (!StringUtils.hasText(clientIP)) {
            clientIP = &quot;unknown&quot;;
        }

        return clientIP;
    }

}</code></pre>
<p>코드를 살펴보면 <code>X-Forwarded-For</code> 헤더에 담긴 속성 값이 존재한다면, 첫 번째 IP를 클라이언트의 IP으로 추정하며, <code>X-Forwarded-For</code> 헤더가 존재하지 않는다면 <code>x-real-ip</code> 헤더에 담긴 값을 클라이언트의 IP으로 추정합니다.</p>
<p>하지만 이 방식은 잠재적으로 올바른 클라이언트의 IP를 출력하지 않을 가능성을 내포하고 있었습니다.</p>
<h3 id="첫-번째-ip를-클라이언트-ip로-간주하면-안되는-이유">첫 번째 IP를 클라이언트 IP로 간주하면 안되는 이유</h3>
<p><code>X-Forwarded-For</code> 헤더의 인덱스 0 즉, 첫 번째 IP를 항상 클라이언트 IP가 아닐 가능성이 있습니다.</p>
<p>가령, 중간 프록시 서버가 X-Forwarded-For 헤더를 수정하거나 새로 추가할 수 있으며, 악의적인 사용자가 X-Forwarded-For 헤더를 조작하여 가짜 IP를 추가할 수 있습니다.</p>
<p>따라서 더 안전한 방법은 신뢰할 수 있는 프록시 목록을 유지하고, X-Forwarded-For 헤더를 뒤에서부터 앞으로 순회하며 첫 번째로 나타나는 신뢰할 수 없는 IP를 클라이언트 IP로 간주하는 것이 권장됩니다.</p>
<p><strong>신뢰할 수 있는 프록시 IP란</strong></p>
<p>서버 관리자가 알고 있고 신뢰하는 프록시 서버의 IP 주소를 말합니다. 예를들어, <code>로드 밸런서</code> , <code>리버스 프록시 서버</code>, <code>CDN 제공업체의 프록시 서버</code> 등을 의미합니다. 이러한 프록시들은 <code>X-Forwarded-For</code> 헤더를 올바르게 처리하고 클라이언트 IP를 정확히 전달할 것으로 기대됩니다.</p>
<p>여러 프록시를 거치는 경우 실제 클라이언트 IP가 아닌 중간 프록시의 IP를 반환할 수 있는 문제를 내포하고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/49560873-82eb-49b8-8253-2507473eb84e/image.png" alt="mermaid+uml+ip+찾는과정"></p>
<h3 id="x-forwarded-for-탐색-로직-개선"><code>X-Forwarded-For</code> 탐색 로직 개선</h3>
<p>위에서 고려한 프로세스를 ClientIpResolver 에 적용해</p>
<pre><code class="language-java">public class ClientIPResolver {

    private static final List&lt;String&gt; TRUSTED_PROXIES = Arrays.asList(&quot;10.0.0.1&quot;, &quot;192.168.1.1&quot;); // 신뢰할 수 있는 프록시 IP 목록

    public static String getClientIP(HttpServletRequest httpServletRequest) {
        String xForwardedFor = httpServletRequest.getHeader(&quot;X-Forwarded-For&quot;);
        if (StringUtils.hasText(xForwardedFor)) {
            return getClientIPFromXForwardedFor(xForwardedFor);
        }

        String xRealIP = httpServletRequest.getHeader(&quot;X-Real-IP&quot;);
        if (StringUtils.hasText(xRealIP)) {
            return xRealIP;
        }

        return httpServletRequest.getRemoteAddr();
    }

    private static String getClientIPFromXForwardedFor(String xForwardedFor) {
        String[] ips = xForwardedFor.split(&quot;,&quot;);

        // 뒤에서부터 순회하며 신뢰할 수 없는 첫 번째 IP를 찾음
        for (int i = ips.length - 1; i &gt;= 0; i--) {
            String ip = ips[i].trim();
            if (!TRUSTED_PROXIES.contains(ip)) {
                return ip;
            }
        }

        // 모든 IP가 신뢰할 수 있는 경우, 맨 앞의 IP 반환
        return ips[0].trim();
    }
}</code></pre>
<p>개선된 버전은 다음과 같은 방식으로 작동합니다:</p>
<ol>
<li>X-Forwarded-For 헤더가 있는 경우, 이를 우선적으로 처리합니다.</li>
<li>X-Forwarded-For 헤더 처리 시:<ul>
<li>헤더 값을 콤마로 분리하여 IP 목록을 얻습니다.</li>
<li>목록의 뒤에서부터 앞으로 순회하며, 신뢰할 수 없는 첫 번째 IP를 찾습니다.</li>
<li>이 IP가 실제 클라이언트의 IP로 간주됩니다.</li>
<li>만약 모든 IP가 신뢰할 수 있는 프록시라면, 목록의 첫 번째 IP를 반환합니다.</li>
</ul>
</li>
<li>X-Forwarded-For 헤더가 없는 경우, X-Real-IP 헤더를 확인합니다.</li>
<li>두 헤더 모두 없는 경우, getRemoteAddr()를 사용합니다.</li>
</ol>
<p>단, 신뢰할 수 있는 프록시 목록(TRUSTED_PROXIES)을 정확히 관리해야 하며, 이는 네트워크 구성에 따라 적절히 설정해야 합니다.</p>
<p>참고 링크 : <a href="https://news.hada.io/topic?id=6098">https://news.hada.io/topic?id=6098</a>
참고 링크 : <a href="https://kkang-joo.tistory.com/42">https://kkang-joo.tistory.com/42</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] 인터넷 네트워크]]></title>
            <link>https://velog.io/@dev_hyun/Network-%EC%9D%B8%ED%84%B0%EB%84%B7-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC</link>
            <guid>https://velog.io/@dev_hyun/Network-%EC%9D%B8%ED%84%B0%EB%84%B7-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC</guid>
            <pubDate>Wed, 01 May 2024 14:05:38 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><strong>인터넷 통신</strong></p>
<ul>
<li>클라이언트와 서버 사이에는 물리적 거리가 존재하고, 그 사이의 인터넷망을 거쳐야 통신할 수 있다.</li>
<li>인터넷 망은 복잡하다. 수많은 중간 노드(서버)를 거쳐야 한다. 김영한 강사님의 <code>모든 개발자를 위한 HTTP 웹 기본 지식 강의</code> <a href="https://www.inflearn.com/course/http-%EC%9B%B9-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC">(링크)</a>는 어떤 규칙으로, 어떤 방법으로 목적지에 도착할 수 있는지에 대한 물음으로 시작된다.</li>
</ul>
<p><strong>프로토콜</strong></p>
<ul>
<li>프로토콜이란, 서로간에 데이터를 주고받기 위해 정해놓은 통신 규약(약속)이다.</li>
</ul>
<hr>
</br>

<h2 id="ip-인터넷-프로토콜">IP (인터넷 프로토콜)</h2>
<h3 id="역할">역할</h3>
<ul>
<li>IP(Internet Protocol의 약자)는 지정한 IP 주소 (ex. 100.100.100.1) 에 데이터를 전달할 수 있도록 정해놓은 규칙.</li>
<li>이때, IP Packet (패킷) 이라는 통신 단위에 묶어 데이터를 전달.</li>
</ul>
<h3 id="ip의-한계">IP의 한계</h3>
<p><strong>1. Connectionless 비연결성</strong></p>
<ul>
<li>Packet을 받을 대상이 없거나 불능 상태여도 일단 패킷은 전송되고, 클라이언트는 서버의 상태를 알 수 없다.</li>
<li>전송 전에 미리 연결을 설정하지 않는 방식으로, 대상 서버가 패킷을 받을 수 있는 상태인지 모른다.</li>
</ul>
<p><strong>2. Unreliability 비신뢰성</strong></p>
<ul>
<li>Packet이 중간에 소실되거나, 도착시 순서를 보장할 수 없다.</li>
</ul>
<p><strong>3. 프로그램 구분</strong></p>
<ul>
<li>같은 IP에서 여러 애플리케이션을 동시에 사용하는 상황에서 애플리케이션을 구분할 수 없다.</li>
</ul>
<p><strong>4. 노드 경로</strong></p>
<ul>
<li>요청과 응답이 반드시 동일한 경로(노드)를 지나가지는 않는다.</li>
<li><table>
<thead>
<tr>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/4e27fa93-1ce0-4aec-b304-208a9e98ac6a" alt="image"></th>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/0d4e2420-1df8-422e-be1d-c916b2632641" alt="image"></th>
</tr>
</thead>
<tbody><tr>
<td>이미지 출처:[인프런] 모든 개발자를 위한 HTTP 웹 기본 지식</td>
<td></td>
</tr>
</tbody></table>
</li>
</ul>
<hr>
</br>

<h2 id="ip-packet-패킷">IP Packet 패킷</h2>
<h3 id="특징">특징</h3>
<ul>
<li>정보 기술에서 Packet 방식의 컴퓨터 네트워크가 전달하는 데이터의 형식화된 블록이다.</li>
<li>네트워크 계층에서 사용되는 프로토콜 데이터 단위이다.</li>
</ul>
<h3 id="ip-패킷-구조">IP 패킷 구조</h3>
<table>
<thead>
<tr>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/05fb5173-f49e-48e9-aab3-b11db2502c63" alt="image"></th>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/28642789-a285-4a89-9455-cd418ecb9919" alt="image"></th>
</tr>
</thead>
</table>
<ul>
<li>주목할 점은 전송 데이터를 감싸고 있는 IP 패킷에 출발지 IP와 목적지 IP가 포함되어 있다는 것.</li>
</ul>
<p>이미지 출처:</p>
<ul>
<li><a href="https://www.khanacademy.org/computing/computers-and-internet/xcae6f4a7ff015e7d:the-internet/xcae6f4a7ff015e7d:routing-with-redundancy/a/ip-packets">https://www.khanacademy.org/computing/computers-and-internet/xcae6f4a7ff015e7d:the-internet/xcae6f4a7ff015e7d:routing-with-redundancy/a/ip-packets</a></li>
<li><a href="http://www.incodom.kr/Internet_Protocol_Suite/Packet">http://www.incodom.kr/Internet_Protocol_Suite/Packet</a></li>
</ul>
<blockquote>
<p>Q) 왜 큰 데이터는 패킷 단위로 쪼개서 전송하는가?</br>
A) 도로(네트워크)에 대형 화물차(큰 데이터)가 모든 차선(대역폭)을 차지(점유)하면 정체가 일어나는 것과 같은 원리.</br>
<em>출처 : 서적, 모두의 네트워크</em></p>
</blockquote>
<hr>
</br>

<h2 id="tcp-프로토콜">TCP 프로토콜</h2>
<h3 id="정의">정의</h3>
<ul>
<li>Transmission Control Protocol (전송 제어 프로토콜)의 약자로, IP 프로토콜의 한계를 해결하기 위해 탄생했다.</li>
</ul>
<h3 id="tcp-3가지-대표-특징">TCP 3가지 대표 특징</h3>
<ul>
<li>3가지 특징 덕분에 TCP는 신뢰할 수 있는 프로토콜로서 현재는 대부분의 애플리케이션에서 TCP 프로토콜을 사용된다.</li>
</ul>
<h3 id="1-연결지향-3-way-handshake">1. 연결지향 (3 way handshake)</h3>
<table>
<thead>
<tr>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/15f5290c-7a8d-4340-bf87-0d3dcd82026e" alt="image"></th>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/bfb04bde-c21c-4b1c-8299-c29fca024741" alt="image"></th>
</tr>
</thead>
<tbody><tr>
<td>이미지 출처:<a href="https://workat.tech/core-cs/tutorial/tcp-three-way-handshake-in-computer-networks-yoo7331910lh">https://workat.tech/core-cs/tutorial/tcp-three-way-handshake-in-computer-networks-yoo7331910lh</a></td>
<td></td>
</tr>
</tbody></table>
<p>TCP 3 way handshake 를 통해 선 연결 / 후 전송
SYN : synchronize sequence numbers의 약자, 접속 요청
ACK : acknowledgment 의 약자, 요청 수락
최근, 마지막 세번 째 순서에서 ACK와 함께 데이터도 함께 전송이 가능해졌다.</p>
<p>이때, 연결은 물리적 연결이 아닌 논리적(가상)연결을 의미한다. 이 논리적 연결은 직접 선으로 연결한 것이 아닌 수많은 노드들을 거쳐 연결된 것.</p>
<p>3-way-handshake 단계에서 TCP/IP 패킷이 전송되지만, 헤더 부분만 전송된다. 이 헤더 부분에는 SYN, ACK등을 포함해서 보낼 수 있다. 하단 TCP/IP 패킷 이미지의 헤더 부분에 포함되어 있는것을 확인할 수 있다.</p>
<p>양쪽 모두 Data를 전송할 준비가 되었다는 것을 보장하고, 실제로 Data전달이 시작하기전에 한쪽이 다른 쪽이 준비되었다는 것을 알 수 있도록 한다.
양쪽 모두 상대편에 대한 초기 순차일련변호를 얻을 수 있도록 한다.</p>
<p>4-Way handshake는 세션을 종료하기 위해 수행되는 절차.</p>
<h3 id="2-데이터-전달-보증">2. 데이터 전달 보증</h3>
<ul>
<li>클라이언트가 서버에게 데이터를 성공적으로 전송하면, 서버는 클라이언트에게 응답을 한다.</li>
<li>덕분에, 메시지 누락 시 클라이언트가 확인 가능</li>
</ul>
<h3 id="3-순서-보장">3. 순서 보장</h3>
<ul>
<li>클라이언트가 전송한 패킷 순서와 서버가 받은 패킷 순서가 다른 경우, 서버는 클라이언트에게 순서가 다른 패킷부터 재전송을 요청</li>
</ul>
<blockquote>
<p>Q) 왜 패킷의 순서는 보장되어야 할까?</br>
A) 데이터를 패킷 단위로 쪼개어 보냈기 때문에, 쪼갠 순서대로 나열해야 데이터의 본래 모습으로 되돌릴 수 있다.</br>
<em>참고 : [서적] 모두의 네트워크</em></p>
</blockquote>
<h2 id="tcp-segment">TCP Segment</h2>
<p><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/da0df042-860c-4d83-b35e-b7d190df0db0" alt="image">이미지 출처:<a href="https://www.khanacademy.org/computing/computers-and-internet/xcae6f4a7ff015e7d:the-internet">https://www.khanacademy.org/computing/computers-and-internet/xcae6f4a7ff015e7d:the-internet</a></p>
<p><strong>특징</strong></p>
<ul>
<li>TCP의 전송 단위는 Packet이 아니라 Segment이다. (Packet은 L3계층의 IP Packet 에서 사용)</li>
<li>TCP 정보가 추가되면서 IP 패킷의 한계점이 해결되었다.</li>
<li>TCP 정보에는, 출발지 Port 번호, 목적지 Port 번호를 포함하여 전송제어, 순서, 검증정보 등이 포함되어 있다.</li>
<li>Segment는 TCP 프로토콜의 데이터 단위이다.</li>
</ul>
<hr>
</br>

<h2 id="인터넷-프로토콜-스택의-4계층">인터넷 프로토콜 스택의 4계층</h2>
<table>
<thead>
<tr>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/2ed7be88-0384-4083-8f69-decac37dd6ce" alt="image"></th>
<th><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/4f99c597-2022-4429-b639-78f041142f94" alt="image"></th>
</tr>
</thead>
<tbody><tr>
<td>Protocol Suite</td>
<td>Protocol Stack</td>
</tr>
</tbody></table>
<p>이미지 출처</p>
<ul>
<li>crnetpackets.com</li>
<li>ko.wikipedia.org/wiki/인터넷_프로토콜_스위트</li>
</ul>
<blockquote>
<p>Q) 왜 layer 명칭이 이미지 마다 다를까? suite 와 stack의 차이는 무엇인가? </br>
A) Internet Protocol suite (IP suite) is the standard network model and communication protocol stack used on the Internet and on most other computer networks.(출처:<a href="https://www.techtarget.com/whatis/definition/Internet-Protocol-suite-IP-suite">https://www.techtarget.com/whatis/definition/Internet-Protocol-suite-IP-suite</a>)</br>
해석 : 인터넷 프로토콜 스위트()는 인터넷과 대부분의 다른 컴퓨터 네트워크에서 사용되는 표준 네트워크 모델 및 통신 프로토콜 스택이다.</br>
The protocol stack or network stack is an implementation of a computer networking protocol suite or protocol family.
Some of these terms are used interchangeably but strictly speaking, the suite is the definition of the communication protocols, and the stack is the software implementation of them.(출처: <a href="https://en.wikipedia.org/wiki/Protocol_stack">https://en.wikipedia.org/wiki/Protocol_stack</a>)</br>
해석 : 엄밀히 말하면 suite 은 통신 프로토콜 의 정의 이고 stack 은 해당 프로토콜의 소프트웨어 구현이다.</p>
</blockquote>
<h3 id="계층-및-종류">계층 및 종류</h3>
<table>
<thead>
<tr>
<th align="center">계층</th>
<th align="center">종류</th>
</tr>
</thead>
<tbody><tr>
<td align="center">Application Layer 애플리케이션 계층</td>
<td align="center">HTTP, FTP</td>
</tr>
<tr>
<td align="center">Transport Layer 전송계층</td>
<td align="center">TCP, UDP</td>
</tr>
<tr>
<td align="center">Internet Layer 인터넷 계층</td>
<td align="center">IP</td>
</tr>
<tr>
<td align="center">Network Access Layer 네트워크 인터페이스 계층</td>
<td align="center">*Ethernet (Mac 주소와 같은 물리적인 정보들이 포함되어 있다.), LAN 드라이버 등</td>
</tr>
<tr>
<td align="center">- IP 프로토콜에서 중간에 패킷이 손실되고 순서가 상이한 문제들을 TCP 프로토콜을 통해 보완</td>
<td align="center"></td>
</tr>
</tbody></table>
<h3 id="흐름">흐름</h3>
<p>A가 B에게 메시지를 전송하는 상황을 가정해보자. 다음은 A의 프로토콜 계층에서 나타나는 흐름이다</p>
<ol>
<li>애플리케이션(프로그램)에서 &#39;Hello&#39; 메시지 생성</li>
<li>해당 메시지를 Socket 라이브러리를 통해 OS 에게 전달</li>
<li>OS는 메시지를 TCP 정보로 감쌈 (실제 메시지 + TCP 정보)</li>
<li>OS는 TCP로 감싸진 패킷을 IP 패킷으로 다시 감쌈 (실제 메시지 + TCP 정보 + IP 정보)</li>
<li>이를 네트워크 인터페이스에게 전달</li>
<li>네트워크 인터페이스에서 LAN 카드를 통해 실제로 전송될 때 Ethernet Frame으로 감싸서 전송한다. (실제 메시지 + TCP 정보 + IP 정보 + 이더넷 프레임)</li>
</ol>
<hr>
</br>

<h2 id="udp">UDP</h2>
<h3 id="특징-1">특징</h3>
<ul>
<li>TCP와 같은 계층</li>
<li>3 way handshake / 데이터 전달 보증 / 순서 보장이 없다. <ul>
<li>따라서, UDP는 하얀 도화지에 비유된다. 애플리케이션에서 추가 작업(데이터 순서를 보장하지 않는 문제를 해결하기 위한)이 필요하다.</li>
</ul>
</li>
<li>대신 단순하고, 상대적으로 속도가 빠르다 (TCP 3WAY 가 없기 때문)</li>
<li>PORT + 체크섬 추가됨</li>
</ul>
<h3 id="활용-상황">활용 상황</h3>
<ul>
<li>TCP는 그대로 쓰고, UDP를 통해 최적화</li>
<li>최근에는 HTTP3 스펙에서 UDP 프로토콜을 사용하며 각광받고 있다.</li>
</ul>
<hr>
</br>

<h2 id="port">PORT</h2>
<h3 id="역할-1">역할</h3>
<ul>
<li>같은 IP 내에서 여러 애플리케이션을 사용할 때, 각 애플리케이션을 구분하기 위한 방법</li>
<li>E.g.) 웹 브라우저 + 게임 + 카톡 등</li>
</ul>
<p><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/cb3165aa-af5f-461c-afe0-52594a83dea8" alt="image"></p>
<ul>
<li>오버워치(온라인게임)가 필요로 하는 포트번호는 무엇일까 궁굼해서 구글링 한 결과 80, 442, 1119, 3724, 6113 이 필요하다는 것을 알게 되었다. 하나의 프로그램 내에서 게임 을 포함한 여러 기능(보이스채팅 등)을 위해 필요한 서로 다른 포트가 있다는 점이 흥미로웠다.</li>
</ul>
<h3 id="특징-2">특징</h3>
<ul>
<li>강의에서 비유하기를 IP는 아파트 브랜드, Port는 동호수</li>
<li>0 ~ 65535 할당 가능 범위 (0~1023이 잘 알려진 포트번호로서 많이 사용중이니, 가급적 사용하지 않는 것을 권장)</li>
</ul>
<p><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/75415540-0cd1-4234-94dc-fd6ef3784d2d" alt="image">이미지 출처:<a href="https://martinnoh.tistory.com/entry/Well-Known-PORT">https://martinnoh.tistory.com/entry/Well-Known-PORT</a></p>
<h3 id="예시">예시</h3>
<ul>
<li>클라이언트와 서버가 존재한다. 서버에 요청하는 IP에는 HTTPS의 경우 443 포트가 지정되어 있다. 반면, 클라이언트가 A웹 사이트와 B웹 사이트를 동시에 사용하고 있다면, A와 B 각각을 위한 임의의 포트 2개가 할당되어 연결된다.</li>
<li>즉, 클라이언트의 포트는 남는 포트 중 랜덤으로 할당된다.</li>
</ul>
<hr>
</br>

<h2 id="dns">DNS</h2>
<h3 id="특징-3">특징</h3>
<ul>
<li>IP는 자주 변경될 수 있고, 기억하기 어렵다. 때문에 Domain Name System(도메인 네임 시스템)이 도입하여 해결.</li>
<li>도메인 명을 IP 주소로 변환</li>
<li>비유하자면 전화번호부 이다. (전화번호 단축키에 비유하는 것이 더 와닿았다.)</li>
</ul>
<h3 id="흐름-1">흐름</h3>
<p><img src="https://github.com/proHyundo/backend-cs-study/assets/128882585/ff5f85df-9945-4ab5-93d9-09f0770b6cc1" alt="image"> 이미지출처:<a href="https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&amp;blogId=kyhslam&amp;logNo=221527956339">https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&amp;blogId=kyhslam&amp;logNo=221527956339</a></p>
<ol>
<li>클라이언트가 DNS 서버에 도메인 명에 대한 IP를 요청</li>
<li>DNS 서버는 해당 도메인 명에 대한 IP 주소를 클라이언트에 전달</li>
<li>클라이언트는 해당 IP주소로 서버에 접근</li>
<li>해당 서버는 클라이언트에게 HTTP 응답</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[일일 요청 수 2000건→110건, 봇 접근 제한하기]]></title>
            <link>https://velog.io/@dev_hyun/%EC%9D%BC%EC%9D%BC-%EC%9A%94%EC%B2%AD-%EC%88%98-2000%EA%B1%B4-%EB%B4%87-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-%EB%B0%A9%EC%96%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyun/%EC%9D%BC%EC%9D%BC-%EC%9A%94%EC%B2%AD-%EC%88%98-2000%EA%B1%B4-%EB%B4%87-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-%EB%B0%A9%EC%96%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 29 Apr 2024 01:04:45 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-발단--aws-과금-발생">📌 발단 : AWS 과금 발생</h2>
<p><code>밥풀</code> 프로젝트의 인프라 작업에 대한 책임감에 의해 프로젝트를 진행하는 동안 주기적으로 AWS Billings 서비스를 팔로잉 하고 있었습니다. 그러던 어느날 <code>Simple Storage Service</code>에서 <code>0.01 $</code> 가 과금 예정임을 발견했습니다. 얼마 되지 않는 비용도 지출이기 때문에 빠른 시일내에 해결해야 하는 이슈라 판단했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/a0857444-fc9d-4c06-bf0e-3ca5e709c511/image.png" alt="2주만에-요청수-3만건"></p>
<p>상세 내역을 펼쳐보니 과도하게 많은 요청수가 집계되어 있었습니다. 프로젝트를 홍보를 통해 사용자를 모집하긴 했었으나 대다수가 팀원들의 지인이었으며 사용자가 100명 미만이었습니다. 그럼에도 운영서버를 가동한지 2주일 만에 <code>AWS Free Tier</code>에서 무료로 제공해주는 20,000건의 요청을 모두 소진 후 추가로 약 30,000건의 요청이 집계된 것은 문제가 있음을 인지했습니다.</p>
<h3 id="요청의-정체는-봇-이었습니다">요청의 정체는 봇 이었습니다.</h3>
<p>원인을 파악하기 위해 <code>Proxy Server</code> 역할을 하고 있는 <code>Nginx</code> 도커 컨테이너의 <code>log</code> 를 출력해본 결과 정말 다양한 요청들이 쌓이고 있었습니다. 문제가 되는 요청의 약 80% 이상이 <code>GET /**</code> 요청이었으며, 아래와 같이 <code>.env</code>, <code>/Core/Skin/Login.aspx</code> 등의 정적자원을 취득해가려는 시도가 과반수 이상이었습니다.</p>
<p>(IP 세부정보를 조회해본 결과 파리/독일/중국 등 다양한 국가에서 요청되는 점, 그리고 서울에 위치한 구글 검색엔진에서도 요청을 전송했다는 점이 신기했습니다.)</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/da75e227-a423-4972-9414-26b649335c3c/image.png" alt="nginx-log"></p>
<p>구글링 해본 결과 이들의 정체는 <code>Bot</code> 이었습니다. 이들이 요청을 전송하는 이유는 웹을 스캔하고, 검색엔진 노출을 위한 것으로 매우 흔한 상황이라고 합니다. 하지만, 만일 자원이 공개적으로 액세스 가능했더라면 보안상 위험할 수 있다고 합니다. 뿐만 아니라 서버에 부하를 일으켜 일반 사용자에게 부정적 경험을 제공할 수 있고 저의 상황처럼 예상하지 못한 비용이 발생할 수 있습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/e0b49851-e120-469e-9579-cb031c9e2acc/image.png" alt="maliciousscary_requests_to_my_backend_server"></th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://www.reddit.com/r/webdev/comments/14vl8uj/maliciousscary_requests_to_my_backend_server/">이미지 출처 : maliciousscary_requests_to_my_backend_server</a></td>
</tr>
</tbody></table>
<h2 id="구조-파악-및-대안-탐색">구조 파악 및 대안 탐색</h2>
<p>아키텍처의 어떤 영역에 대안을 적용할 수 있을지 파악해야 봇 요청을 차단하는 적절한 방법을 찾을 수 있을 것이라 생각해, 먼저 밥풀 프로젝트의 아키텍처를 다시 살펴보았습니다. 밥풀 프로젝트의 클라우드 아키텍처는 아래와 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/588031a4-7cd7-4e60-bfbf-4d73ada98e10/image.png" alt=""></p>
<p>클라이언트가 요청할 수 있는 도메인은 프론트 영역인 루트 도메인 <code>https://example.com</code> 또는 백엔드 API 서버인 서브 도메인 <code>https://api.example.com</code> 입니다. 도메인의 구입은 CloudFlare에서 했으나, AWS 프리티어 계정을 사용하는 동안 ACM을 비롯해 여러 AWS 서비스들을 프로젝트에 활용해볼 목적으로 네임서버를 Route53에 두고 있습니다.
클라이언트가 프론트 도메인으로 페이지를 요청하면 Route53, CloudFront를 거쳐 S3으로부터 React로 빌드된 정적 자원을 응답받습니다. 백엔드 API 요청은 Route53에서 EC2, Nginx를 거쳐 Spring 서버로 부터 응답받습니다.</p>
<p>아키텍처를 살펴보아 크게 (1) 두 도메인의 공통적인 진입점에서 또는 (2) 프론트/백엔드 각 진입점에서 원하지 않는 요청을 제한할 수 있지 않을까 예상했습니다. 어느 부분에 도구를 적용해야 할지 범위를 정했으니, 어떤 도구가 주어질 수 있는지 살펴보았습니다.</p>
<h2 id="🤖🙅🏻♀️-cloudflare으로-front-방어">🤖🙅🏻‍♀️ CloudFlare으로 Front 방어</h2>
<p>최종적으로는 CloudFlare에서 제공하는 서비스를 적용했습니다만, 다른 대안들은 무엇이 있고 CloudFlare를 선택하게 된 과정을 함께 기록했습니다.</p>
<h3 id="대안-1-aws-waf-bot-control">대안 1) AWS WAF Bot Control</h3>
<p>아키텍처 리마인드 과정에서 알 수 있듯 밥풀 프로젝트는 다수의 AWS 서비스를 사용하고 있습니다. 따라서 봇을 방어하는 솔루션도 AWS 에서 제공하는 서비스를 활용하는 대안을 가장 먼저 떠올렸고, <code>WAF Bot Control</code> <a href="https://docs.aws.amazon.com/ko_kr/waf/latest/developerguide/waf-bot-control.html">(공식 홈페이지 링크)</a> 서비스를 찾았습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/247efebe-7d00-40f2-9811-7c7f5d5c4bf5/image.png" alt="AWS WAF Bot Control"></th>
</tr>
</thead>
<tbody><tr>
<td>이미지 출처 : <a href="https://aws.amazon.com/ko/waf/features/bot-control/">https://aws.amazon.com/ko/waf/features/bot-control/</a></td>
</tr>
</tbody></table>
<p>공식 홈페이지 링크의 설명에 의하면 <code>WAF Bot Control</code> 서비스는 봇 트래픽에 대한 제어를 손쉽게 차단할 수 있으며, 검색 엔진과 같은 일부 봇을 허용할 수도 있습니다. 두 도메인의 공통적인 진입점에 해당 서비스를 도입하면 가장 이상적이라 생각되었습니다. 다만 현재 아키텍처 구성 상 Route53 은 트래픽을 리다이렉트 해주는 역할을 하기 때문에 WAF를 이곳에 적용할 수는 없습니다. 서비스 개요를 읽어보면 <code>CloudFront</code>, <code>Load Balancer</code>, <code>API Gateway</code> 그리고 <code>AppSync</code>에 적용할 수 있습니다. 따라서 <code>API Gateway</code> 를 밥풀 아키텍처에 추가하는 방향을 고려했습니다.</p>
<p>하지만, 문제는 서비스 비용 입니다. 프리티어 계정 기준으로 알려진 봇에 대해 월 1,000만 건의 요청을 무료로 처리할 수 있으나, 이를 적용하기 위해 <code>Web ACL</code> 을 먼저 생성해야 하고, 해당 서비스는 월 <code>5$</code> 비용이 부과됩니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/14f9fb7c-5301-49d2-8ee5-8291466fb79a/image.png" alt="이미지 출처 : https://aws.amazon.com/ko/waf/pricing/"></th>
</tr>
</thead>
<tbody><tr>
<td>이미지 출처 : <a href="https://aws.amazon.com/ko/waf/pricing/">https://aws.amazon.com/ko/waf/pricing/</a></td>
</tr>
</tbody></table>
<p>현재 봇으로 인해 발생되는 비용이 월 <code>0.01 $</code> 인데 <code>500배</code> 비용 상승의 AWS WAF 서비스를 적용하는 것은 적합해 보이지 않았습니다. 만일 밥풀 서비스가 수익을 창출할 만큼 규모가 있거나, 다른 대안을 발견하지 못했다면 <code>WAF Bot Control</code> 적용을 긍정적으로 생각했을 것입니다.</p>
<h3 id="대안-2-aws-cloudfront-지리적-접근-제한">대안 2) AWS CloudFront 지리적 접근 제한</h3>
<p>&#39;aws bot control&#39;, &#39;bot prevent&#39; 등의 키워드로 더 검색해본 결과 비용이 발생하지 않는(프리티어 기준) 서비스 <code>CloudFront Geo Restriction 지리적 제한</code> 을 발견했습니다. <a href="https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html#georestrictions-cloudfront">(AWS AmazonCloudFront georestrictions 공식문서 링크)</a></p>
<p>CloudFront는 일반적으로 사용자의 지리적 위치와 상관없이 클라이언트가 요청한 정적 자원을 응답하는데, 해당 기능을 활성화 할 경우 허용목록/거부목록을 통해 요청을 전송한 클라이언트의 국가에 따라 <code>HTTP 403 Forbidden</code> 상태 코드를 응답할 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/32a3bd9e-f51a-46b4-852a-505482b18aba/image.png" alt="cloudfront-지리적접근제한"></p>
<p>설정하는 방법은 위 캡처와 같이 <code>CloudFront 대시보드 → 배포 → 보안 탭</code>에서 허용 국가 목록 또는 거부 국가 목록을 설정하면 쉽고 빠르게 적용 됩니다.</p>
<p>해당 기능이 검색엔진, 크롤링 Bot 등의 요청을 제한하는데 특화된 기능은 아니지만 다음과 같은 이유로 문제를 해결하기 위한 하나의 대안으로써는 충분하다고 판단합니다.</p>
<ol>
<li>대부분의 봇 요청이 해외 IP 에서 접근했으며 </li>
<li>간편하게 설정 가능하며 </li>
<li>추가 비용이 발생하지 않고 </li>
<li>밥풀 플랫폼은 국내 사용자만 타겟팅 하여 서비스하기 때문</li>
</ol>
<p>그럼에도 해당 방법을 최종 선택하지 않았던 이유는 (1) 오직 bot에 의한 요청만 제한하고 싶었으며 (2) 밥풀 플랫폼을 포함해 조직이 서비스를 제공하는 범위가 해외로 확장될 경우 해당 기능을 사용하지 못해 결국 동일한 문제를 다시 해결해야 했기 때문입니다.</p>
<h3 id="선택-cloudflare-underattack-mode">선택) CloudFlare UnderAttack Mode</h3>
<p>AWS에서 제공하는 서비스에서는 조건(무료 + 봇 차단에 특화)에 부합하는 기능은 존재하지 않았습니다. 대신 bot 요청을 제한하는 방법을 찾는 사람들이 보편적으로 선택하는 방법으로 CloudFlare의 <code>UnderAttack Mode</code> 서비스를 적용하고 있음을 알게 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/c6b945ef-253c-44c1-b659-880263044484/image.png" alt="cloudflare-security-bots"></p>
<h2 id="👩🏻💻-적용-결과-before--日-100여건">👩🏻‍💻 적용 결과 Before &rarr; 日 100여건</h2>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/dev_hyun/post/129b9330-2dfc-463e-8d99-a622ddfa537c/image.png" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/dev_hyun/post/41bf4eb2-e355-4599-8aa5-08aad799836f/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">지난 24시간 요청 수 약 100여건</td>
<td align="center">차단한 봇 요청 약 90여건</td>
</tr>
</tbody></table>
<p>가장 최근 릴리즈가 배포된지 시간이 꽤 흐른 이후라서 평소보다 적은 요청 수와 봇 차단 수가 집계되는 것을 감안하여도, 일 요청 약 2,000건 에서 100 여건으로 개선된 것은 목표를 충분히 달성했다고 판단할 수 있었습니다.</p>
<hr>
<br />

<h2 id="🤖🙅🏻♂️-nginx-설정으로-backend-방어">🤖🙅🏻‍♂️ Nginx 설정으로 Backend 방어</h2>
<p>프론트 영역에는 <code>CloudFlare UnderAttack Mode</code> 만 적용되어 있고, 백엔드는 별도의 크롤링 봇 차단 설정이 적용되어 있지 않은 상태에서의 지난 24시간 동안의 요청 수는 약 300 건 입니다(좌측 캡처 참고). 모니터링 서버를 구축한 이후로 일일 요청 수 추이는 우측 캡처와 같이 하루 약 300~400 건의 요청이 집계된 것을 확인할 수 있습니다. (특이점으로 2000건이 넘는 봇 요청이 집계된 몇몇 건은 제외.)</p>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/dev_hyun/post/dfc613a4-5a87-4419-ba9e-06491c867bb8/image.png" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/dev_hyun/post/d00963c5-34e5-4bed-b36e-2ac16304379b/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">지난 24시간 동안의 요청 수</td>
<td align="center">일일 요청 수 추이</td>
</tr>
</tbody></table>
<p>이전 문단에서 밥풀 프로젝트의 아키텍처를 살펴봤을 때, 백엔드 서버로 전달된 요청은 Proxy 서버 역할을 수행중인 Nginx가 가장 먼저 맞닿게 됩니다. 따라서 크롤링 봇을 차단하는 방법으로 Nginx에 몇몇 설정을 적용하여 해결할 수 있을 것이라 판단해 아래와 같은 대안들을 찾게 되었습니다.</p>
<h3 id="대안-1-ip-기반-지리적-차단">대안 1) IP 기반 지리적 차단</h3>
<p>앞서 AWS에서 제공하는 <code>IP 기반 지리적 접근 제한 서비스</code>와 유사한 동작을 Nginx 에도 적용할 수 있습니다. 아래 두 가지 방법이 있지만, 역시 국내에서만 서비스하는 기업이 아니라면 적절하지 않은 방법이라고 판단했습니다. 따라서 아래와 같은 방법이 있다는 것만 알아두고, 프로젝트에는 적용 하지 않기로 결정했습니다.</p>
<h4 id="cidr-활용-특정-국가-ip-대역폭-차단">CIDR 활용 특정 국가 IP 대역폭 차단</h4>
<p><a href="https://elfinlas.github.io/nginx/230401_defense_attacker/">Nginx에서 특정 IP 접근 금지 시키기 (With Url 문자열 접근 막기)</a></p>
<h4 id="geoip-모듈-활용">geoip 모듈 활용</h4>
<p><code>--with-http_geoip_module</code> : 지리적 위치를 알아내는 GeoIP모듈인 ngx_http_geoip_module 모듈을 포함합니다.libgeoip라이브러리 필요합니다.</p>
<p><a href="https://nginx.org/en/docs/http/ngx_http_geoip_module.html">Module ngx_http_geoip_module</a>
<a href="https://stackoverflow.com/questions/45434292/installing-geoip-module-for-latest-nginx-for-docker">Installing GeoIP module for latest NGINX for Docker</a></p>
<pre><code class="language-bash">http {
  geoip_country /usr/share/GeoIP/GeoIP.dat;
  map $geoip_country_code $allowed_country {
    default no;
    KR yes;
  }

  server {
    location / {
      if ($allowed_country = no) {
        return 403;
      }
  }
}
출처: https://archijude.tistory.com/528 [글을 잠깐 혼자 써봤던 진성 프로그래머:티스토리]</code></pre>
<h3 id="대안-2-robottxt-파일-작성하기">대안 2) robot.txt 파일 작성하기</h3>
<p>robots.txt 는 크롤링 봇, 검색엔진 등에게 접근 가능/불가능한 경로를 안내하기 위한 파일 입니다. 파일을 구성하는 속성은 크게 <code>User-agent</code>, <code>Allow</code>, <code>Disallow</code> 그리고 <code>Sitemap</code> 네 가지가 있으며 각 역할은 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/76dfb7a2-c6fa-46d5-a9f5-d160f6132c3a/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>이미지 출처 : <a href="https://seo.tbwakorea.com/blog/robots-txt-complete-guide/">https://seo.tbwakorea.com/blog/robots-txt-complete-guide/</a></td>
</tr>
</tbody></table>
<p>이에 기반하여 구글 애드센스, 페이스북, 구글, 네이버, 빙, 야후, 다음 검색엔진을 제외하고 모두 제한하도록 다음과 같이 파일을 작성했습니다. 작성한 파일은 웹 사이트의 root 디렉토리에 매핑하여 <code>https://example.com/robots.txt</code> 요청 시 해당 파일을 응답하도록 설정합니다. 정상적인 봇은 원칙적으로는 작성된 안내에 따라 동작할 것입니다.</p>
<pre><code class="language-txt">User-agent: *
Disallow: /

User-agent: Googlebot
Allow: /

User-agent: Mediapartners-Google
Allow: /

User-agent: Yeti
Allow: /

User-agent: Bingbot
Allow: /

User-agent: facebot
Allow: /

User-agent: Slurp
Allow: /

User-agent: Daum
Allow: /</code></pre>
<h3 id="적용-1-특정-ip-차단하기">적용 1) 특정 IP 차단하기</h3>
<p>geo
http_x_forward_for
remote_addr</p>
<p>클라이언트의 HTTP 요청은 Gateway, LoadBalancer, Proxy 등의 서버들을 경유하여 밥풀 서버에 도착할 수 있습니다. 따라서 <code>$remote_addr</code> 에는 경유한 서버의 마지막 IP가 저장되어 있습니다.</p>
<p>이때, 최초의 클라이언트 IP는 <code>$x-forwarded-for</code> 헤더에 저장됩니다. (참고 링크 : <a href="https://wiki.tistory.com/entry/nginx-ingress-ip-config">https://wiki.tistory.com/entry/nginx-ingress-ip-config</a>)</p>
<p>http_x_forward_for 를 사용하려 했으나, &quot;-&quot; 처럼 세팅 되어 있던데?</p>
<h3 id="적용-2-특정-키워드-차단하기">적용 2) 특정 키워드 차단하기</h3>
<pre><code>map $request_uri $bad_uri {
    default 0;
    ~*(wp-includes|wlwmanifest|xmlrpc|wordpress|administrator|wp-admin|wp-login|owa|a2billing) 1;
    ~*(fgt_lang|flu|stalker_portal|streaming|system_api|exporttool|ecp|vendor|LogService|invoke|phpinfo) 1;
    ~*(Autodiscover|console|eval-stdin|staging|magento|demo|rss|root|mifs|git|graphql|sidekiq|c99|GponForm) 1;
    ~*(header-rollup-554|fckeditor|ajax|misc|plugins|execute-solution|wp-content|php|telescope) 1;
    ~*(idx_config|DS_Store|nginx|wp-json|ads|humans|exec|level|monitoring|configprops|balancer) 1;
    ~*(meta-data|web_shell_cmd|latest|remote|_asterisk|bash|Bind|binding|appxz|bankCheck|GetAllGameCategory) 1;
    ~*(exchangerateuserconfig|exchange_article|kline_week|anquan|dns-query|nsepa_setup|java_script|gemini-iptv) 1;
    ~*(j_spring_security_check|wps|cgi|asmx|HNAP1|sdk|evox) 1;
    ~*(_ignition|alvzpxkr|ALFA_DATA|wp-plain) 1;
    ~*(ldap|jndi|dns|securityscan|rmi|ldaps|iiop|corba|nds|nis) 1; # log4j
}</code></pre><p><code>elfinlas</code> 님의 블로그를 참고해 설정파일을 생성했습니다. 모니터링 서버에서 <code>actuator</code> uri 를 사용하고 있어 제거하고, 로그에서 확인할 수 있는 일부 키워드를 추가했습니다. (참고 블로그 <a href="https://elfinlas.github.io/nginx/230401_defense_attacker/">링크</a>))</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/ff79d288-453b-4ec5-9ff5-d0021a739115/image.png" alt=""></p>
<p>설정이 올바르게 적용되어 특정 키워드를 감지하면 444 상태코드를 응답했음을 확인할 수 있습니다. 그러나 100% 만족스럽지는 않습니다. 처음 2개의 GET 요청과 마지막 GET 요청에 대해서는 키워드가 필터링 되지 않아 결국 서버에게 전달되었기 때문입니다. 이에 nginx 설정을 다음과 같이 수정했습니다.</p>
<pre><code>nginx 수정된 app.conf</code></pre><p>백엔드 서버가 허용하는 <code>/api/**</code> 를 포함한 URI 패턴을 제외하고 모두 차단되도록 수정했습니다. </p>
<h3 id="적용-3-불분명한-user-agent-차단하기">적용 3) 불분명한 User Agent 차단하기</h3>
<p>nginx 도커 볼륨 마운트 되어 있는 경로에 <code>nginx-bots-prevention.conf</code> 파일을 생성.</p>
<p><code>/home/사용자명/nginx/nginx-bots-prevention.conf</code></p>
<pre><code class="language-bash"># Rate limiting settings
limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;

# Deny known bad user agents
map $http_user_agent $blocked {
    default 0;
    ~*(?:bot|crawl|spider|baidu|yandex|bing|msnbot|curl|wget|python) 1;
}
</code></pre>
<h3 id="적용-테스트">적용 테스트</h3>
<p>설정이 올바르게 적용되었는지 테스트 했습니다. VPN 확장 프로그램을 통해 IP를 <code>178.xxx.xxx.xx</code> 으로 변경 후 밥풀 백엔드 도메인으로 GET 요청을 전송했습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/3b576464-85ec-413f-b889-d50ac1e147fe/image.PNG" alt="vpn-ip-changed"></th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/dev_hyun/post/737bc3b4-3dfe-4ded-94b5-1a31eac56781/image.png" alt="nginx-log"></td>
</tr>
</tbody></table>
<p>Nginx 로그를 확인하니 HTTP 444 응답이 의도대로 내려왔으며, 제한하지 않은 IP에 대해서는 200 status code가 응답되는 것을 확인했습니다. </p>
<h2 id="trouble-shooting-emerg-invalid-condition">Trouble Shooting) <code>[emerg] invalid condition</code></h2>
<p>Nginx 설정 과정 중 다음과 같은 에러를 경험했습니다.</p>
<pre><code class="language-bash">[emerg] 7#7: invalid condition &quot;$bad_ip&quot; in /etc/nginx/conf.d/custom.conf:8
nginx: [emerg] invalid condition &quot;$bad_ip&quot; in /etc/nginx/conf.d/custom.conf:8</code></pre>
<p>1) include 위치는 http scope에</p>
<p>2) if 문 내부에 표현식은 1개만</p>
<pre><code># 잘못된 구문
if ($bad_ip || $bad_bot || $bad_uri) {
    return 444;
}

# 수정된 구문
if ($bad_ip) {
    return 444;
}

if ($bad_bot) {
    return 444;
}

if ($bad_uri) {
    return 444;
}</code></pre><p>3) 컨테이너를 stop, up 하는 것이 아니라, nginx를 reload 하기</p>
<pre><code class="language-bash">docker exec &lt;nginx-container-name-or-id&gt; nginx -s reload</code></pre>
<p>4) nginx 명령어</p>
<pre><code>#
nginx -T

# conf 문법 검사
nginx -t

# nginx 재실행
nginx -s reload</code></pre><h2 id="👨🏻💻-적용-결과-before--日-10여건">👨🏻‍💻 적용 결과 Before &rarr; 日 10여건</h2>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/dev_hyun/post/b05b26dc-1417-4765-a1e3-004c326940db/image.png" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/dev_hyun/post/d4e01d48-a071-4518-965f-f3b75619ba02/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">지난 24시간 요청 수</td>
<td align="center">일일 요청 수 추이</td>
</tr>
</tbody></table>
<p>봇 차단을 위한 Nginx 설정을 마치고 경과를 지켜본 결과, 백엔드 서버로의 올바르지 않은 요청 이슈가 성공적으로 개선되었음을 확인할 수 있었습니다. 평균 300건 이상의 봇 요청이 10건 미만으로 줄어들었습니다.(14건 중 9건은 실 사용자의 요청 수) 이렇게 적용을 마치고 나니 마치 모기에 대한 농담처럼 밥풀 서버에 관심을 주던 봇들이 사라지니 뭔가 허전한 마음이 들기도 합니다. 🤣</p>
<p>추가 개선의 여지는 남아있습니다. 백엔드 영역에서 Spring 서버로 전달되는 올바르지 않은 요청은 Nginx 설정에 의해 모두 차단되지만, Nginx는 여전히 봇들에 대한 트래픽을 부담해야하는 것은 동일합니다. 이 문제를 해결하기 위해 인프라 관점에서 EC2 서버 앞에 게이트웨이 또는 라우터를 배치하여 서버로 향하는 원치 않는 트래픽을 차단하는 것이 필요해 보였습니다만, 문제 해결 범위를 벗어났으며 목표로 하는 만큼 개선이 되었다고 판단하여 이는 다음 번 과제로 남겨두기로 결정했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[슬로우 페이징 쿼리 27→0.9초 개선 과정 ]]></title>
            <link>https://velog.io/@dev_hyun/%ED%8E%98%EC%9D%B4%EC%A7%95-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5%EC%9D%84-%EA%B0%9C%EC%84%A0%ED%95%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@dev_hyun/%ED%8E%98%EC%9D%B4%EC%A7%95-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5%EC%9D%84-%EA%B0%9C%EC%84%A0%ED%95%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Fri, 26 Apr 2024 05:06:11 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><code>밥풀</code> 서비스는 <code>밥 약속 신청</code>을 통해 관심사와 목표를 공유하는 사람들과 일대일로 대화 할 수 있도록 기회를 만드는 플랫폼 입니다. 3주가 되지 않는 MVP 개발 기간에는 성능을 고려할 여력 없이 우선 빠른 구현에 집중했습니다. 별도의 유지보수 기간에 사용자들이 가장 자주 호출하는 <code>프로필 페이징</code> API의 응답속도가 상대적으로 느리다는 것을 파악하고, <strong>쿼리를 개선하는 것을 목표로 작업</strong>했습니다. 하지만 그 과정이 순탄치 못했습니다. </p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/7dde8085-33f8-4cd3-8da6-21108fe0d357/image.png" alt=""></p>
<p>더미데이터 삽입 부터, <strong>쿼리의 실행계획을 분석하고, 다양한 인덱스를 적용</strong>해가며 목표로 하는 쿼리 응답시간(<code>1초 이내</code>)이 도출될 때 까지 2~3주가 소요되었습니다. Real MySQL 8.0 서적, 검색, 유료 해결 서비스(크몽), LLM 등 다양한 루트를 활용해가며 성공적으로 쿼리를 개선한 고군분투의 과정을 기록했습니다.  </p>
<h2 id="🎯-슬로우-쿼리-확인하기">🎯 슬로우 쿼리 확인하기</h2>
<p>먼저, 문제되는 슬로우 쿼리를 확인하기 위해서는, 수행되는 쿼리에 대한 통계 데이터가 쌓이도록 MySQL 설정이 필요합니다. 설정을 하지 않을 경우 슬로우쿼리를 확인하는 조회 쿼리가 정상적으로 동작하지 않거나, 아래와 같은 오류를 만나볼 수 있습니다.</p>
<pre><code class="language-bash">You do not have the SUPER privilege and binary logging is enabled
(you *might* want to use the less safe log_bin_trust_function_creators variable)</code></pre>
<p>이는 AWS RDS 생성시 기본 설정으로 시스템 액세스(SUPER 권한)를 제공하지 않기 때문입니다. 바이너리 로깅을 켜면 log_bin_trust_function_creators를 DB 인스턴스의 사용자 지정 데이터베이스(DB) 파라미터 그룹에서 true로 설정해야 했습니다. 설정 방법은 아래 이미지와 같습니다. (참고 링크 : <a href="https://repost.aws/ko/knowledge-center/rds-mysql-functions">AWS 공식문서</a>)</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/f053aa8d-2fc1-4fdf-a236-8265af239b47/image.png" alt="aws+rds+log_bin_trust_function_creators+설정"></p>
<p>설정을 완료했다면 아래의 쿼리를 활용해 슬로우 쿼리를 조회할 수 있습니다.</p>
<pre><code class="language-sql">## 성능 개선 대상 식별
SELECT DIGEST_TEXT AS query,
             IF(SUM_NO_GOOD_INDEX_USED &gt; 0 OR SUM_NO_INDEX_USED &gt; 0, &#39;*&#39;, &#39;&#39;) AS full_scan,
             COUNT_STAR AS exec_count,
             SUM_ERRORS AS err_count,
             SUM_WARNINGS AS warn_count,
             SEC_TO_TIME(SUM_TIMER_WAIT/1000000000000) AS exec_time_total,
             SEC_TO_TIME(MAX_TIMER_WAIT/1000000000000) AS exec_time_max,
             SEC_TO_TIME(AVG_TIMER_WAIT/1000000000000) AS exec_time_avg_ms,
             SUM_ROWS_SENT AS rows_sent,
             ROUND(SUM_ROWS_SENT / COUNT_STAR) AS rows_sent_avg, SUM_ROWS_EXAMINED AS rows_scanned,
             DIGEST AS digest
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = &#39;babpool_pro_v1_db&#39;
  AND COUNT_STAR &gt; 50
  AND DIGEST_TEXT LIKE &#39;SELECT%&#39;  -- 조회 쿼리만 한정해 식별하고 싶은 경우
  AND DIGEST_TEXT NOT LIKE &#39;SELECT @@SESSION%&#39;
  AND DIGEST_TEXT NOT LIKE &#39;SELECT QUERY%&#39;
ORDER BY AVG_TIMER_WAIT DESC;</code></pre>
<ul>
<li>데이터베이스에서 실행된 모든 쿼리에 대한 포괄적인 성능 정보를 제공합니다.</li>
<li>실행 통계: <ul>
<li><code>exec_count: 쿼리의 총 실행 횟수</code>, <code>err_count: 오류 발생 횟수</code>, <code>warn_count: 경고 발생 횟수</code></li>
</ul>
</li>
<li>시간 관련 메트릭 : <ul>
<li><code>exec_time_total: 총 실행 시간</code>, <code>exec_time_max: 최대 실행 시간</code>, <code>exec_time_avg_ms: 평균 실행 시간</code></li>
</ul>
</li>
<li>데이터 처리량 : <ul>
<li><code>rows_sent: 총 반환된 행 수</code>, <code>rows_sent_avg: 쿼리당 평균 반환 행 수</code>, <code>rows_scanned: 총 검사된 행 수</code></li>
</ul>
</li>
</ul>
<h3 id="쿼리-개선-대상의-당위성-찾기">쿼리 개선 대상의 당위성 찾기</h3>
<p>서비스 특성 상, 사용자들이 가장 많이 요청하는 데이터는 <code>프로필 페이징</code>을 위한 조회 결과일 것입니다. 그러나 이를 위해 4개의 테이블이 JOIN 되어야 하기 때문에 매우 낮은 성능을 보일 것이라 예측했습니다. 현재 운영 DB 에서 일정 횟수 이상 호출 되었으며 슬로우 쿼리를 찾기 위해 위의 식별쿼리를 수행했을 때, 출력된 결과는 이러한 예측을 증명했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/0291da80-96b5-4171-a83c-453a46c3ce2e/image.png" alt="슬로우쿼리+식별결과"></p>
<p>해당 4개의 쿼리는 각각 &quot;특정 키워드와 특정 사용자 등급을 가진 프로필만 선택&quot;, &quot;모든 활성 프로필을 선택 (가장 광범위한 결과)&quot;, &quot;특정 키워드를 가진 모든 활성 프로필을 선택&quot;, &quot;특정 사용자 등급을 가진 모든 활성 프로필을 선택&quot; 으로 모두 프로필 페이징을 처리하기 위한 쿼리 입니다. 이로써 해당 프로필 페이징 쿼리가 전체 쿼리 중 가장 많이 호출되며, 평균 쿼리 수행 시간이 가장 느린 <code>슬로우 쿼리</code> 임을 확인 했습니다.</p>
<p>개선할 대상을 찾았으니, 개선을 위해 필요한 환경을 세팅하고 유의미한 성능비교를 위해 더미데이터를 삽입했습니다.</p>
<h2 id="🔗-도커-mysql-컨테이너으로-테스트-환경-세팅하기">🔗 도커 MySQL 컨테이너으로 테스트 환경 세팅하기</h2>
<p>해당 프로젝트는 AWS Freetier RDS를 사용하고 있어, 로컬 환경 처럼 멀티 코어나 빵빵한 램을 할당해 이들을 실행할 수 있는 환경이 못 됩니다. 때문에, 로컬에서 수행하는 성능 테스트 및 쿼리 개선 작업을 운영 환경과 유사하게 조성할 필요가 있었습니다. <code>t2.micro</code> 와 완벽하게 동일한 환경은 아니지만 가능한 유사하게 MySQL 도커 컨테이너를 메모리 1GB, CPU 1코어로 제한하여 실행하기로 결정했습니다. </p>
<table>
<thead>
<tr>
<th>제한하려는 자원</th>
<th>명령어</th>
<th>단위</th>
</tr>
</thead>
<tbody><tr>
<td>메모리</td>
<td>--memory</td>
<td>m(megabyte), g(gigabyte)</td>
</tr>
<tr>
<td>swap 메모리</td>
<td>--memory-swap</td>
<td>m(megabyte), g(gigabyte)</td>
</tr>
<tr>
<td>CPU(상대적)</td>
<td>--cpu-shares</td>
<td>1024(1cpu)</td>
</tr>
<tr>
<td>CPU(절대적)</td>
<td>--cpus</td>
<td></td>
</tr>
<tr>
<td>CPU (호스트에 CPU가 여러 개 있을 때)</td>
<td>--cpuset-cpu</td>
<td>사용하고 싶은 특정 cpu (0부터 시작합니다.)</td>
</tr>
<tr>
<td>디스크 I/O 속도</td>
<td>--device-write-bps, --device-read-bps</td>
<td>kb, mb, gb</td>
</tr>
<tr>
<td>참고 링크 : <a href="https://yscho03.tistory.com/300">docker 컨테이너 자원 할당 제한</a></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>1) 이미지 다운로드</p>
<pre><code class="language-bash">$ docker pull mysql:latest</code></pre>
<p>2) MySQL 컨테이너 실행(자원 할당 제한)</p>
<pre><code class="language-bash">$ docker run --name {컨테이너명} \
    -e MYSQL_ROOT_PASSWORD={root패스워드} \
    -d \
    --cpus=1 \
    --cpuset-cpus=&quot;0&quot; \
    --memory=1g \
    --memory-swap=2g
    -p 3305:3306 \
    --device-write-bps /dev/sda:10mb
    --device-read-bps /dev/sda:10mb
    mysql</code></pre>
<ul>
<li><code>--cpu-shares</code> 는 다른 컨테이너의 상태에 따라 실제 CPU 사용량이 변할 수 있지만, <code>--cpus</code>는 항상 일정한 최대 CPU 사용량을 보장합니다. 따라서 저는 <code>--cpus</code> 옵션을 사용했습니다.</li>
<li>메모리 스왑 설정은 &quot;시스템의 메모리 크기가 2 GB 까지라면 그 크기의 2 배의 swap 공간을 권고&quot;하고 있음을 고려하여 2기가로 설정했습니다. (참고 링크 : <a href="https://access.redhat.com/ko/solutions/744483">레드헷 공식 문서</a>)</li>
</ul>
<p>3) 컨테이너 리소스 사용량 통계 확인</p>
<pre><code class="language-bash">docker stats {컨테이너명}
-- 출력 &gt; 
CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT   MEM %     NET I/O       BLOCK I/O   PIDS
93ff05457d44   mysql-cpu1-memory1   0.48%     388.4MiB / 1GiB     37.93%    1.02kB / 0B   0B / 0B     37</code></pre>
<h2 id="📦-더미데이터-batch-insert">📦 더미데이터 Batch Insert</h2>
<h3 id="더미데이터-삽입-방법-결정">더미데이터 삽입 방법 결정</h3>
<p>유의미한 성능 비교를 위해서 테이블당 최소 100만 건의 데이터를 삽입하고자 했습니다. 세 가지 대안이 떠올랐습니다.</p>
<p>(1) <code>mockaroo</code> 사이트에서 <code>.sql</code> 파일 생성</p>
<ul>
<li><code>mockaroo</code> 서비스의 경우 무료 계정에서 sql 파일 당 1000건의 행까지만 생성 가능하기 때문에 가장 먼저 제외되었습니다.</li>
</ul>
<p>(2) 프로시저 작성</p>
<ul>
<li>예를들어 다음과 같이 프로시저를 작성해 원하는 만큼 행의 크기를 지정하고, 간편하게 쿼리를 삽입할 수 있습니다.</li>
<li>그러나, 보다 복잡한 관계(테이블 간의 개연성, 균등하게 분포된 데이터)를 고려하며 데이터를 삽입하기 위해서는 프로시저 보다 애플리케이션을 구현하는 것이 더욱 용이할 것으로 판단했습니다.</li>
</ul>
<pre><code class="language-sql">DELIMITER //
CREATE PROCEDURE InsertTestData(IN numRows INT)
BEGIN
  DECLARE i INT;
  SET i = 1;
  START TRANSACTION;
  WHILE i &lt;= numRows DO
    INSERT INTO t_possible_date (profile_id, possible_date) VALUES (i, DATE_ADD(CURDATE(), INTERVAL i DAY));
    INSERT INTO t_possible_time (possible_date_id, possible_time_start) VALUES (i, i);
    INSERT INTO t_appointment (appointment_receiver_id, possible_time_id, appointment_status) VALUES (i, i, &#39;PENDING&#39;);
    SET i = i + 1;
  END WHILE;
  COMMIT;
END //
DELIMITER ;

CALL InsertTestData(1000000); -- 100만 건 삽입</code></pre>
<p>(3) 애플리케이션 구현</p>
<p>페이징 쿼리 개선을 테스트하기 위해 필요한 테이블은 4개 이며, 테이블마다 최소 100만 건의 데이터를 삽입해야 하는데, 반복문을 사용해 매 행마다 삽입 쿼리를 수행하는 것은 매우 비효율적이라고 판단했습니다. 따라서, <code>Batch Insert</code>를 사용하기로 결정했습니다.</p>
<blockquote>
<p>해당 프로젝트는 <code>MyBatis</code> 사용하기 때문에 <code>JPA Entity</code>의 <code>PK Generate startegy</code>가 <code>IDENTITY</code> 일 때 <code>Batch Insert</code>를 비활성화 하는 문제에 대해 다루지 않겠습니다. 만약 해당 이슈를 겪는다면 <code>JdbcTemplate</code> 클래스의 <code>batchUpdate()</code> 메서드를 오버라이딩하여 해결할 수 있습니다.</p>
</blockquote>
<h3 id="jdbc-batch-insert">JDBC Batch Insert</h3>
<p><code>Batch Insert</code>란 밑에 예제 쿼리와 같이 insert rows 여러 개를 연결해서 한 번에 입력하는 작업입니다. 가령 1000개의 삽입 문으로 구성된 배치가 있는 경우 데이터베이스는 여전히 1000개의 문을 각각 개별적으로 구문 분석, 컴파일 및 실행해야 합니다. 반면 일괄 삽입(Batch Insert)을 사용하면 여러 삽입 문을 단일 SQL 문으로 결합합니다.</p>
<pre><code class="language-sql"># 개별 삽입 쿼리
INSERT INTO table VALUES (1, &quot;hello&quot;);
INSERT INTO table VALUES (2, &quot;world&quot;);
INSERT INTO table VALUES (3, &quot;!&quot;);

# Batch Insert
INSERT INTO table VALUES (1, &quot;hello&quot;), (2, &quot;world&quot;), (3, &quot;!&quot;);</code></pre>
<p>정리하자면 Batch Insert 는 다음과 같은 특징을 가지고 있습니다.</p>
<ul>
<li>쿼리를 메모리에 저장하였다가 한번에 쿼리를 보내는 형태이다. </li>
<li>여러 작업을 일괄 처리로 함께 실행한다.</li>
<li>하나의 트랜잭션으로 묶인다.</li>
<li>데이터 베이스 호출 횟수가 줄어든다.</li>
</ul>
<p>주의할 점은, MySQL에서 Bulk Insert를 사용하려면, DB URL 설정에 <code>rewriteBatchedStatements=true</code> 파라미터를 추가해야 합니다. <code>true</code>로 설정하지 않으면 Insert 쿼리가 여전히 단건으로 수행됩니다.</p>
<pre><code class="language-yml">spring:
  datasource:
    url: jdbc:mysql://localhost:3306/batch_test?&amp;rewriteBatchedStatements=true&amp;profileSQL=true&amp;logger=Slf4JLogger&amp;maxQuerySizeToLog=999999
    driver-class-name: com.mysql.cj.jdbc.Driver</code></pre>
<ul>
<li><code>rewriteBatchedStatements</code> <ul>
<li>파라미터의 기본값은 false으로, 이 상태에서는 배치 작업을 수행해도 각 INSERT 문이 개별적으로 실행됩니다.</li>
</ul>
</li>
<li><code>postfileSQL=true</code><ul>
<li>Driver에 전송하는 쿼리를 출력합니다.</li>
</ul>
</li>
<li><code>logger=Slf4JLogger</code><ul>
<li>Driver에서 쿼리 출력 시 사용할 로거를 설정합니다.</li>
</ul>
</li>
<li><code>maxQuerySizeToLog=999999</code><ul>
<li>출력할 쿼리 길이. MySQL 드라이버는 기본값이 0으로 지정되어 있어 값을 설정하지 않을 경우 쿼리가 출력되지 않습니다.</li>
</ul>
</li>
</ul>
<p>(참고 링크 : <a href="https://dkswnkk.tistory.com/682">Spring JDBC를 사용하여 Batch Insert 수행하기</a>)</p>
<h3 id="jdbc-batch-insert-코드-작성"><code>JDBC Batch Insert</code> 코드 작성</h3>
<p>PrepareStatement를 이용하여 addBatch를 사용하는 방식입니다.</p>
<pre><code class="language-java">public void batchInsertUserAccount(int numberOfRows) {
        try (Connection connection = dataSource.getConnection();
             PreparedStatement preparedStatement = connection.prepareStatement(INSERT_USER_ACCOUNT_SQL)) {

            connection.setAutoCommit(false);
            Random random = new Random();

            for (int i = 0; i &lt; numberOfRows; i++) {
                preparedStatement.setLong(1, i + 100000000000000001L);
                preparedStatement.setString(2, &quot;user&quot; + i + &quot;@example.com&quot;);
                preparedStatement.setString(3, STATUSES[random.nextInt(STATUSES.length)]);
                preparedStatement.setString(4, &quot;ROLE_USER&quot;);
                preparedStatement.setString(5, GRADES[random.nextInt(GRADES.length)]);
                preparedStatement.setString(6, &quot;nickname&quot; + i);
                preparedStatement.setDate(7, Date.valueOf(java.time.LocalDate.now()));
                preparedStatement.setDate(8, Date.valueOf(LocalDateTime.now().plusMinutes(random.nextInt(43200)).toLocalDate()));

                preparedStatement.addBatch();
                preparedStatement.clearParameters();

                if (i % BATCH_SIZE == 0) {
                    preparedStatement.executeBatch();
                    connection.commit();
                    log.info(&quot;i 번째 배치 실행! : {}, Committed.&quot;, i);
                }
            }

            preparedStatement.executeBatch();
            connection.commit();
            System.out.println(&quot;t_user_account 테이블 배치 삽입 성공!&quot;);

        } catch (SQLException e) {
            log.error(&quot;배치 삽입 중 오류 발생&quot;, e);
            try {
                connection.rollback();
            } catch (SQLException rollbackEx) {
                log.error(&quot;롤백 중 오류 발생&quot;, rollbackEx);
            }
        }
    }</code></pre>
<h3 id="mysql-dump-exportimport">MySQL Dump Export/Import</h3>
<p>공들여 삽입한 원본 더미 데이터를 백업해두기 위해 덤프 파일을 생성할 필요가 있었습니다.
터미널을 열어 다음 명령어들을 통해 덤프파일을 생성하거나 덤프파일로 데이터를 가져올 수 있습니다.</p>
<p><strong>데이터 Export</strong></p>
<pre><code>  # Docker MySQL의 sh 접속
  docker exec -it [Container-ID] sh

  # MySQL 데이터 Dump
  mysqldump -uroot -p [Database-Name] &gt; /tmp/[File-Name].sql

  # 해당 경로에 생성 확인
  ls -al /tmp

  # Docker Container 밖으로 파일 복사
  docker cp [Container-ID]:/tmp/[File-Name].sql [PC의 저장할 경로]</code></pre><p><strong>데이터 Import</strong></p>
<pre><code>  # PC의 SQL File을 Docker 안으로 복사
  docker cp [PC의 SQL 파일 경로] [Container-ID]:/tmp

  # Docker MySQL의 sh 접속
  docker exec -it [Container-ID] sh

  # MySQL 데이터 Import
  mysql -uroot -p [Database-Name] &lt; /tmp/[File-Name].sql</code></pre><h2 id="🤝🏻-요구사항-분석">🤝🏻 요구사항 분석</h2>
<p>프로필 리스트 페이지 구현을 위한 기획자의 요구사항은 다음과 같습니다</p>
<ol>
<li><p>필요 데이터:</p>
<ul>
<li>프로필 식별 값, 사용자 식별 값, 프로필 이미지 URL, 프로필 자기소개, 프로필 수정일자, 해당 사용자의 관심 키워드 집합</li>
</ul>
</li>
<li><p>필터 조건:</p>
<ul>
<li>1개 이상의 관심 키워드 중 하나라도 만족하는 프로필</li>
<li>1개 이상의 사용자 학년 중 하나라도 만족하는 프로필</li>
</ul>
</li>
<li><p>정렬 기준: 최근 프로필 수정일자 순</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/647c31cc-683b-4f50-b141-585f5875a82f/image.png" alt="페이징에+필요한+4개의+테이블+erd"></p>
<p>이러한 요구사항을 충족하기 위해 다음 4개의 테이블(<code>t_profile, t_user_account, t_m_user_keyword, t_keyword</code>)을 JOIN하는 복잡한 쿼리가 필요했으며, 다음 사항들을 고려해야 했습니다.</p>
<ul>
<li>4개 테이블의 INNER JOIN</li>
<li>서브쿼리를 통한 관심 키워드 정보 추출</li>
<li>WHERE 절에서 키워드 ID와 사용자 학년 필터링</li>
<li>GROUP BY를 통한 중복 제거</li>
<li>ORDER BY로 최근 수정일자 순 정렬</li>
<li>LIMIT OFFSET을 이용한 페이지네이션</li>
</ul>
<pre><code>SELECT profile.profile_id, profile.user_id, profile.profile_image_url, profile.profile_intro, profile.profile_contents,
        profile.profile_modify_date,
        (
        SELECT GROUP_CONCAT(k.keyword_name)
            FROM t_m_user_keyword muk
            INNER JOIN t_keyword k ON muk.keyword_id = k.keyword_id
        WHERE muk.user_id = profile.user_id
            GROUP BY muk.user_id
        ) AS keyword_info, account.user_grade, account.user_nick_name
FROM t_profile as profile
    INNER JOIN t_user_account account ON profile.user_id = account.user_id
    INNER JOIN t_m_user_keyword muk ON profile.user_id = muk.user_id
    INNER JOIN t_keyword k ON muk.keyword_id = k.keyword_id
WHERE  muk.keyword_id IN
      (598340246278506499, 598340246278506500, 598340246278506501, 598340246278506502,
       598340246282702823, 598340246282702824, 598340246282702825, 598340246282702826,
       598340246282702827, 598340246286893150, 598340246286893151,
       598340246286893152, 598340246286893153, 598340246291091010, 598340246299478613)
    AND account.user_grade IN
        (&#39;FIRST_GRADE&#39;, &#39;SECOND_GRADE&#39;, &#39;THIRD_GRADE&#39;, &#39;FOURTH_GRADE&#39;, &#39;GRADUATE&#39;)
    AND profile.profile_active_flag = 1
GROUP BY profile.profile_id
ORDER BY profile.profile_modify_date DESC
LIMIT 10 OFFSET 0;</code></pre><h3 id="성능-이슈-발견">성능 이슈 발견</h3>
<p>데이터 규모가 작을 때는 크게 와닿지 않았으나, 많은 데이터를 삽입한 환경(100만 ~ 200만 건)에서 심각한 성능 저하를 확인할 수 있었습니다:</p>
<ul>
<li>첫 페이지 응답 시간: 약 25-27초</li>
<li>10페이지 응답 시간: 약 4분 10초대</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/b6f0d64e-d3a9-4149-9a98-fba5ce5d8938/image.PNG" alt="원본쿼리_1페이지_26초"></p>
<p>첫페이지 임에도 너무 긴 시간이 소요되었으며, 사용자가 페이지 후반부로 갈 수록 LIMIT OFFSET 절의 특징 때문에 기하급수적으로 응답속도가 저하되어 10페이지 기준 4분 ~ 4분 10초 가량의 시간이 소요되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/3a4fcc9b-9e4a-404a-9b1c-64969ea13e56/image.png" alt="원본쿼리_10페이지_4분"></p>
<h2 id="🧐-실행계획-분석">🧐 실행계획 분석</h2>
<p>쿼리가 느린 원인을 분석하기 위해 <code>Explain</code> 명령어를 사용해 실행 계획을 분석했습니다. MySQL 서버는 쿼리의 실행계획을 수립할 때 사용 가능한 인덱스들로부터 조건절에 일치하는 레코드 건수를 파악해 최적의 실행계획을 선택합니다. 이 명령어로 옵티마이저가 클라이언트가 MySQL 서버에 요청한 SQL문을 어떻게 데이터를 불러올 것인지에 관해 수립한 실행 계획을 확인할 수 있습니다. 이 실행 계획 정보를 잘 활용하면 SQL 튜닝에 대한 힌트를 얻을 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/c81dea68-8098-443c-b354-0a770f4882c3/image.png" alt="실행계획결과"></p>
<p>다음은 주요한 항목 위주로 총 6개의 실행계획 결과 필드를 해석한 내용입니다.</p>
<p><strong>먼저, 첫 번째 필드 입니다.</strong></p>
<pre><code class="language-text">1,PRIMARY,profile,,index,&quot;PRIMARY,t_profile_t_user_account_user_id_fk&quot;,PRIMARY,8,,991674,10,Using where; Using temporary; Using filesort</code></pre>
<ul>
<li><code>id : 1</code> &amp; <code>select_type: PRIMARY</code><ul>
<li>메인 쿼리임을 의미합니다.</li>
</ul>
</li>
<li><code>type: index</code> &amp; <code>key: PRIMARY</code><ul>
<li>인덱스라고 표기되어 있어 &quot;효율적으로 인덱스를 사용하고 있다&quot; 라고 오해했던 type 입니다. <code>index</code> type은 인덱스를 처음부터 끝까지 읽는 <code>Index Full Scan</code>으로, 풀 테이블 스캔과 동일하게 테이블의 모든 행을 읽습니다. 단지, 읽어들이는 크기가 일반적으로 작기 때문에 <code>ALL</code> 과 비교하였을 때 조금 더 빠르게 처리됩니다. 결과적으로 테이블의 모든 행을 읽어야 하기 때문에 성능저하가 발생되는 지점으로 판단했습니다.</li>
<li><code>key: PRIMARY</code>에서 profile 테이블의 기본 키(PRIMARY KEY) 인덱스를 사용하고 있음을 나타냅니다.</li>
</ul>
</li>
<li><code>rows: 991,674</code> &amp; <code>filtered: 10</code><ul>
<li>profile 테이블에서 약99만개의 행을 읽을 것이라 추정하며, WHERE 조건을 적용 후에 약 10%의 행이 남을 것으로 예상되고 있음을 나타냅니다.</li>
</ul>
</li>
<li><code>extra: Using where; Using temporary; Using filesort</code><ul>
<li>extra 라는 단어에서 주는 &#39;별 거 없을 거 같은&#39; 느낌과는 다르게 성능에 대한 상당히 중요한 정보를 제공하는 속성이었습니다.</li>
<li>먼저 <code>Using where</code> 를 살펴보자면, 가장 흔하게 나타날 수 있는 코멘트로 스토리지 엔진(여기서는 InnoDB)으로부터 받은 레코드를 MySQL 엔진이 별도의 필터링(profile.profile_active_flag = 1)을 처리하였기 때문에 나타났습니다. 위에서 <code>filtered: 10</code> 결과 값을 떠올려보면 최종적으로 쿼리에 일치하는 레코드는 9.9만여건 밖에 안 되지만, 스토리지 엔진은 99만건의 레코드를 읽은 것입니다. 읽은 레코드의 10%만 사용하고 있기 때문에 매우매우 비효율적인 과정이라고 볼 수 있습니다.</li>
<li>다음으로 <code>Using temprorary</code> 코멘트는 ORDER BY 정렬과 GROUP BY 그룹화를 위해 중간결과를 담아두기 위해 임시테이블을 사용했기 때문에 나타난 결과 입니다. 만약 ORDER BY 와 GROUP BY 에서 사용한 컬럼이 동일했다면 나타나지 않았을 것 입니다.</li>
<li>마지막으로 <code>Using filesort</code> 코멘트는 ORDER BY를 처리하기 위해 사용할 적절한 인덱스가 존재하지 않아, 조회된 레코드를 정렬해야 했기 때문에 나타난 코멘트 입니다. 해당 코멘트가 출력된다면 이 쿼리는 많은 부하를 일으키고 있다는 의미입니다. 따라서 가능하다면 적절한 인덱스를 생성하는 것이 필요하다고 판단했습니다.</li>
</ul>
</li>
</ul>
<p><strong>두 번째 필드의 실행계획 입니다.</strong></p>
<pre><code class="language-text">1,PRIMARY,account,,eq_ref,PRIMARY,PRIMARY,8,profile.user_id,1,30,Using where</code></pre>
<ul>
<li><code>type: eq_ref</code><ul>
<li>type 유형 중 효율적인 조인 유형입니다. profile 테이블의 각 행에 대해 account 테이블에서 유니크 인덱스를 사용하여 반드시 단 1건만 존재하는 행을 읽습니다.</li>
</ul>
</li>
<li><code>rows: 1</code> &amp; <code>filtered: 30</code> &amp; <code>Extra: Using where</code><ul>
<li>각 profile 행에 대해 account 테이블에서 1 행만 읽으며, WHERE 조건<code>account.user_grade IN (&#39;FIRST_GRADE&#39;, &#39;SECOND_GRADE&#39;, &#39;GRADUATE&#39;)</code>을 적용한 후 약 30%의 행이 필터를 통과합니다. 때문에 <code>Using where</code> 코멘트가 나타났습니다.</li>
</ul>
</li>
</ul>
<p><strong>세 번째 필드의 실행계획 입니다.</strong></p>
<pre><code class="language-text">1,PRIMARY,muk,,ref,&quot;t_m_user_keyword_t_keyword_keyword_id_fk,t_m_user_keyword_t_user_account_user_id_fk&quot;,t_m_user_keyword_t_user_account_user_id_fk,8,profile.user_id,5,100,Using where</code></pre>
<ul>
<li><code>ref: profile.user_id</code><ul>
<li>ref 칼럼의 값을 통해 muk.user_id = profile.user_id로 조인되고 있음을 확인할 수 있습니다. </li>
</ul>
</li>
<li><code>type : ref</code> &amp; <code>rows: 5</code><ul>
<li>유니크하지 않은 인덱스를 사용하여 조인되며, 각 profile.user_id에 대해 muk 테이블에서 약 5개의 행이 평균적으로 일치하고 있음을 알 수 있습니다.</li>
<li>인덱스의 종류와 관계없이 Equal Join 으로 조회할 때 ref 타입이 사용됩니다. eq_ref보다는 빠르지 않지만, 동등 조건으로만 비교되므로 빠른 조회 방법 중 하나이기 때문에 문제가 없다고 판단했습니다.</li>
</ul>
</li>
</ul>
<p><strong>네 번째 필드의 실행계획 입니다.</strong></p>
<pre><code class="language-text">1,PRIMARY,k,,eq_ref,PRIMARY,PRIMARY,8,muk.keyword_id,1,100,Using index</code></pre>
<ul>
<li><code>type: eq_ref</code> &amp; <code>ref:muk.keyword_id</code><ul>
<li>k 테이블이 muk과 keyword_id으로 조인하고 있습니다.</li>
<li>유니크 인덱스를 사용하여 조인하기 때문에 문제 없다고 판단했습니다.</li>
</ul>
</li>
<li><code>Extra: Using index</code><ul>
<li>해당 코멘트는 인덱스만 읽어서 쿼리를 처리할 수 있을 때 표시됩니다. <code>커버링 인덱스</code> 라고도 부릅니다. 필요한 컬럼이 모두 인덱스에 있기 때문에 데이터 파일을 읽어 올 필요가 없습니다. 매우 빠른 속도로 처리되는 부분임을 나타냅니다.</li>
</ul>
</li>
</ul>
<p><strong>(서브쿼리)다섯 번째 필드의 실행계획 입니다.</strong></p>
<pre><code class="language-text">2,DEPENDENT SUBQUERY,muk,,ref,&quot;t_m_user_keyword_t_keyword_keyword_id_fk,t_m_user_keyword_t_user_account_user_id_fk&quot;,t_m_user_keyword_t_user_account_user_id_fk,8,func,5,100,</code></pre>
<ul>
<li><code>id: 2</code> &amp; <code>select_type: DEPENDENT SUBQUERY</code><ul>
<li>서브쿼리 이며, 외부쿼리에 종속되어 있음을 알 수 있습니다.</li>
</ul>
</li>
<li>`ref: func``<ul>
<li>참조용으로 사용되는 값인 <code>profile.user_id</code> 를 그대로 사용하는 것이 아니라 일련의 연산을 거쳐서 참조하고 있음을 의미합니다. <code>GROUP_CONCAT()</code> 함수를 사용하고 있기 때문에 나타났습니다.</li>
</ul>
</li>
</ul>
<p><strong>(서브쿼리)여섯 번째 필드의 실행계획 입니다.</strong></p>
<pre><code class="language-text">2,DEPENDENT SUBQUERY,k,,eq_ref,PRIMARY,PRIMARY,8,muk.keyword_id,1,100,</code></pre>
<ul>
<li><code>type: eq_ref</code><ul>
<li>해당 필드도, 유니크 인덱스를 사용하여 조인하기 때문에 문제 없다고 판단했습니다.</li>
</ul>
</li>
</ul>
<h2 id="🚀-페이징-쿼리-개선하기-27s-→-09s">🚀 페이징 쿼리 개선하기 (27s → 0.9s)</h2>
<p>위의 실행계획 분석 결과를 바탕으로 쿼리를 개선할 방법을 찾던 중, <code>커버링 인덱스</code> 적용을 먼저 시도해보았습니다.</p>
<h3 id="커버링-인덱스">커버링 인덱스</h3>
<p>인덱스는 데이터를 효율적으로 찾는 방법이지만, MySQL의 경우 인덱스안에 포함된 데이터를 사용할 수 있으므로 이를 잘 활용한다면 실제 데이터까지 접근할 필요가 전혀 없습니다. 이처럼 쿼리를 충족시키는 데 필요한 모든 데이터를 갖고 있는 인덱스를 커버링 인덱스 (Covering Index 혹은 Covered Index) 라고합니다.</p>
<p>다시말해, SELECT, WHERE, ORDER BY, GROUP BY 등에 사용되는 모든 컬럼이 인덱스의 구성요소인 경우를 얘기합니다. 커버링 인덱스가 적용되면 아래와 같이 EXPLAIN 결과 (실행 계획) 의 Extra 필드에 <code>Using index</code> 가 표기됩니다.</p>
<p><strong>인덱스 생성</strong></p>
<ul>
<li>해당 쿼리에서 사용된 profile 테이블의 컬럼은  <code>profile_id, user_id, profile_modify_date, profile_intro, profile_image_url, profile_active_flag</code> 입니다. 해당 컬럼들을 사용해 인덱스를 생성할 때 주의해야할 점이 있습니다. WHERE, GROUP BY, ORDER BY 각각의 절이 인덱스를 사용하지 않는 경우를 고려해야 합니다.</li>
</ul>
<p><strong>GROUP BY</strong></p>
<ul>
<li>먼저 GROUP BY 절의 경우, 명시된 컬럼이 인덱스 컬럼의 순서와 같아야 합니다. 예를들어 현재 GROUP BY 절에 <code>profile_id</code> 만 존재하기 때문에, 해당 컬럼으로 시작되는 인덱스만 사용할 수 있습니다.</li>
</ul>
<p><strong>WHERE + GROUP BY</strong></p>
<ul>
<li>여기서 WHERE 조건과 GROUP BY가 함께 사용되면 WHERE 조건이 동등 비교일 경우 GROUP BY 절에 해당 컬럼은 없어도 인덱스가 적용 됩니다. 현재 원본 쿼리가 다음과 같기 때문에 <code>(profile_active_flag, profile_id)</code> 인덱스는 사용 가능합니다.<pre><code class="language-sql">AND profile.profile_active_flag = 1
GROUP BY profile.profile_id</code></pre>
</li>
</ul>
<p><strong>ORDER BY</strong></p>
<ul>
<li>ORDER BY 절의 경우에는, 인덱스의 컬럼이 ORDER BY의 컬럼과 순서와 정렬 방향(ASC/DESC)이 모두 일치해야 합니다. 따라서 ORDER BY 절 최적화를 위해서는 인덱스의 첫 번째부터 <code>(profile_modify_date, user_id)</code> 순서로 컬럼이 있어야 합니다. 게다가 인덱스에 존재하지 않는 컬럼이 뒤에 존재하는 경우에도 해당 인덱스를 사용불가 합니다.</li>
</ul>
<p><strong>WHERE + ORDER BY</strong></p>
<ul>
<li>여기서 GROUP BY와 마찬가지로 ORDER BY 역시 WHERE 조건이 동등 비교인 경우에 ORDER BY 절에 해당 컬럼이 없어도 인덱스가 적용 됩니다. 따라서 쿼리가 다음과 같은 경우에 <code>(profile_active_flag, profile_modify_date DESC, user_id DESC)</code> 인덱스는 사용 가능합니다.<pre><code class="language-sql">AND profile.profile_active_flag = 1
ORDER BY profile.profile_modify_date DESC, profile.user_id DESC</code></pre>
</li>
</ul>
<p><strong>WHERE + GROUP BY + ORDER BY</strong></p>
<ul>
<li>위에서 정리한 인덱스 적용 원칙들을 참고하여 세 가지 절이 모두 있는 경우를 가정해보면, WHERE 절에서 사용한 컬림이 인덱스의 가장 앞에 존재하며, GROUP BY 에서 사용된 컬럼과 그 순서가 일치하면서, ORDER BY 절에 존재하는 컬럼들과 인덱스에 존재하는 컬럼, 순서, 정렬 방향이 모두 일치해야 합니다.</li>
<li>따라서, 원본쿼리를 기준으로 인덱스 적용 원칙을 지키기 위해서는 다음과 같이 쿼리를 수정해야 했습니다.<pre><code class="language-sql">...
AND profile.profile_active_flag = 1
GROUP BY profile.profile_id, profile.profile_modify_date, profile.user_id
ORDER BY profile.profile_id, profile.profile_modify_date DESC, profile.user_id DESC</code></pre>
</li>
<li>그리고 커버링 인덱스 <code>CREATE INDEX idx_t_profile_covering ON t_profile(profile_active_flag, profile_id, profile_modify_date DESC, user_id DESC, profile_intro, profile_image_url);</code> 를 추가하면 아래와 같이 Extra: Using Index 코멘트가 출력되는 것을 확인할 수 있었습니다.</li>
<li><img src="https://velog.velcdn.com/images/dev_hyun/post/fed4bcf6-786f-41f5-84f0-3c438e33db5d/image.png" alt="커버링+인덱스+추가"></li>
</ul>
<h3 id="쿼리-수정">쿼리 수정</h3>
<p>그러나, 이렇게 수정된 쿼리는 <code>최근 수정한</code> 프로필을 먼저 보여줘야 하는 정책을 위반하기 때문에 반영될 수 없었습니다. 대신, 대안으로 쿼리를 수정해 <code>GROUP BY profile.profile_id</code>를 제거했습니다. 수정된 쿼리는 다음과 같습니다.</p>
<pre><code class="language-sql">SELECT
    p.profile_id, p.user_id, p.profile_modify_date, p.profile_intro, p.profile_image_url
    ,(SELECT GROUP_CONCAT(k.keyword_subject)
        FROM t_m_user_keyword muk
        INNER JOIN t_keyword k ON muk.keyword_id = k.keyword_id
        WHERE muk.user_id = p.user_id
        GROUP BY muk.user_id
    ) AS keyword_names
    ,u.user_grade, u.user_nick_name
FROM
    t_profile as p
INNER JOIN t_user_account u ON p.user_id = u.user_id
    AND u.user_grade IN (&#39;FIRST_GRADE&#39;, &#39;SECOND_GRADE&#39;, &#39;THIRD_GRADE&#39;, &#39;FOURTH_GRADE&#39;, &#39;GRADUATE&#39;)
INNER JOIN
    (
        SELECT muk.user_id
        FROM t_m_user_keyword muk
        WHERE muk.keyword_id IN (
            598340246278506499, 598340246278506500, 598340246278506501, 598340246278506502,
            598340246282702823, 598340246282702824, 598340246282702825, 598340246282702826,
            598340246282702827, 598340246286893150, 598340246286893151, 598340246286893152,
            598340246286893153, 598340246291091010, 598340246299478613
        )
        GROUP BY muk.user_id
    ) filtered_users ON p.user_id = filtered_users.user_id
WHERE p.profile_active_flag = 1
ORDER BY p.profile_modify_date DESC, p.user_id DESC
LIMIT 10 OFFSET 0;</code></pre>
<p>GROUP BY 절을 제거했기 때문에, 인덱스에 존재했던 profile_id를 제거하고 다음과 같이 새로운 인덱스를 생성했습니다. 그리고 수정된 쿼리에 다시 실행계획을 출력해보면 다음과 같이 <code>Using Index</code> 가 표시며 커버링 인덱스가 적용되고 있음을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/0ba922f9-a76a-4fb7-8d25-a5a12b01e69c/image.png" alt=""></p>
<pre><code class="language-sql">CREATE INDEX idx_t_profile_covering_2 ON t_profile(profile_active_flag, profile_modify_date DESC, user_id DESC, profile_intro, profile_image_url);</code></pre>
<ul>
<li><code>GROUP BY</code> 절이 사라졌기 때문에 <code>profile_id</code> 컬럼이 인덱스에서의 위치가 더이상 중요하지는 않지만, <code>SELECT</code> 절에서는 여전히 사용되고 있습니다. 그럼에도 인덱스를 생성할 때 <code>profile_id</code> 컬럼을 명시하지 않은 이유는 MySQL 스토리지 엔진의 특징 때문입니다. <code>InnoDB</code>의 모든 테이블은 <code>Clustering Index</code>로 구성되어 있습니다. 그리고 모든 <code>Non Clustered Index(Secondary Index)</code>는 데이터 레코드의 주솟값으로 <code>PK</code>를 가집니다. 즉, <code>Clustered Key</code>가 항상 포함되어 있습니다. 따라서 <code>profile_id</code>가 포함되어 있지 않아도 생성한 인덱스에 <code>profile_id</code> 칼럼이 같이 저장된 효과를 냅니다.</li>
</ul>
<p>쿼리가 수정됨에 따라 실행계획 출력 결과 다시 살펴보면, 이전과는 다른 새로운 행이 2개 추가되었습니다.</p>
<pre><code>3,DERIVED,muk,,index,&quot;t_m_user_keyword_t_keyword_keyword_id_fk,t_m_user_keyword_t_user_account_user_id_fk&quot;,t_m_user_keyword_t_user_account_user_id_fk,8,,1687896,100,Using where</code></pre><ul>
<li><code>select_type: DERIVED</code><ul>
<li>DERIVED는 파생 테이블을 의미하며, FROM 절의 서브쿼리를 나타냅니다.</li>
</ul>
</li>
<li><code>type: index</code><ul>
<li>전체 인덱스 스캔을 수행하고 있습니다. 이는 인덱스를 사용하여 테이블의 모든 행을 읽는 것을 의미합니다. 개선할 필요성이 있습니다.</li>
</ul>
</li>
</ul>
<pre><code>1,PRIMARY,&lt;derived3&gt;,,ref,&lt;auto_key0&gt;,&lt;auto_key0&gt;,8,p.user_id,10,100,Using index</code></pre><ul>
<li><code>select_type: PRIMARY</code> &amp; <code>table: &lt;derived3&gt;</code><ul>
<li>이 행은 메인쿼리가 from절의 파생테이블인 filtered_users와 어떻게 조인되는지를 보여줍니다.</li>
<li>id가 3인 파생테이블을 가리키고 있습니다.</li>
</ul>
</li>
<li><code>key: &lt;auto_key0&gt;</code><ul>
<li>MySQL이 파생 테이블에 대해 자동으로 생성한 임시 인덱스를 의미합니다. </li>
</ul>
</li>
<li><code>ref: p.user_id</code><ul>
<li>조인에 사용된 컬럼은 p.user_id이며, 이는 t_profile 테이블의 user_id입니다. 즉, 조인 조건이 p.user_id = filtered_users.user_id임을 나타냅니다.</li>
</ul>
</li>
</ul>
<h3 id="카디널리티를-고려한-t_m_user_keyword-테이블-복합-인덱스-생성">카디널리티를 고려한 t_m_user_keyword 테이블 복합 인덱스 생성</h3>
<p>profile 테이블에는 적절한 인덱스를 생성하여 성능을 개선했으니, 다음으로 t_m_user_keyword 테이블에 인덱스를 생성할 차례입니다.</p>
<p>사용자 테이블과 키워드 테이블의 N:M 관계를 저장하기 위해 <code>t_m_user_keyword</code> 매핑 테이블이 존재합니다. 해당 테이블은 현재 파생 테이블(<code>filtered_users</code>)에서 사용되고 있는데, <code>keyword_id</code>로 필터링하고 <code>user_id</code>를 가져오고 있습니다.</p>
<pre><code>(SELECT muk.user_id
    FROM t_m_user_keyword muk
    WHERE muk.keyword_id IN (
        598340246278506499, 598340246278506500, 598340246278506501, 598340246278506502,
        598340246282702823, 598340246282702824, 598340246282702825, 598340246282702826,
        598340246282702827, 598340246286893150, 598340246286893151, 598340246286893152,
        598340246286893153, 598340246291091010, 598340246299478613
    )
    GROUP BY muk.user_id ) as &#39;filtered_users&#39;</code></pre><p>SELECT 구문의 처리 순서를 고려하여 인덱스를 생성할 때, 다음과 같이 WHERE 절에서 사용된 keyword_id 를 선두에 두고, 필터링된 레코드를 그룹핑 하기 때문에 GROUP BY 절에서 사용된 user_id 를 두번째로 지정해 생성했습니다.</p>
<blockquote>
<p><code>create index idx_t_m_user_covering2 on t_m_user_keyword (keyword_id, user_id);</code></p>
</blockquote>
<p>그러나, 옵티마이저는 해당 인덱스를 사용하지 않았으며, extra: Using where가 표기되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/26d49209-fd67-439d-a01e-76701bc01be4/image.png" alt=""></p>
<p>따라서 아래와 같이 순서를 뒤바꾼 복합인덱스도 생성하여 테스트 해본 결과, 옵티마이저는 순서를 바꾼 복합 인덱스를 선택했습니다.</p>
<blockquote>
<p><code>create index idx_t_m_user_covering1 on t_m_user_keyword (user_id, keyword_id);</code></p>
</blockquote>
<p><code>USE INDEX</code> 옵티마이저 힌트를 사용하여 강제로 (keyword_id, user_id) 인덱스를 사용하도록 처리하자, 50% 가량 속도가 저하되었습니다.</p>
<ul>
<li><code>USE INDEX</code> : </li>
</ul>
<p>원인은 <code>카디널리티</code>에 있었습니다. 어떠한 컬럼의 중복되지 않는 레코드의 수 즉, Cardinality 숫자가 높아야 인덱스 조회 시 더 효율적으로 인덱스를 활용할 수 있습니다. <code>show index from t_m_user_keyword;</code> 구문을 사용하면 해당 테이블의 인덱스 정보를 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/545c9a14-47fe-4413-8f60-e29852d4b358/image.png" alt="매핑테이블_인덱스_생성"></p>
<p>밥풀 서비스에 존재하는 keyword_id는 현재 20여가지 인 반면, user_id 는 사용자 테이블의 PK 으로, 카디널리티가 압도적으로(약100만) 높습니다. 따라서 <code>(user_id, keyword_id)</code> 순으로 복합인덱스를 생성하는 것이 더욱 효율적임을 증명했습니다.</p>
<h3 id="join-성능을-고려한-인덱스">JOIN 성능을 고려한 인덱스</h3>
<p>마지막으로 t_user_account 테이블에 인덱스를 추가했습니다. JOIN, WHERE, SELECT 절에서 사용된 컬럼들을 고려하여 다음과 같은 인덱스를 생성했습니다.</p>
<pre><code class="language-sql">CREATE INDEX idx_user_account_covering1 ON t_user_account (user_id, user_grade, user_nick_name);</code></pre>
<ul>
<li>InnoDB 스토리지 엔진에서 보조 인덱스(Secondary Index)는 기본 키(PK)를 자동으로 포함함에도 불구하고, 인덱스 생성 시 <code>user_id</code>를 첫 번째 컬럼으로 배치한 이유는, 인덱스 탐색 시에는 인덱스 정의에 명시적으로 포함된 컬럼만 사용되기 때문입니다. 따라서, 인덱스 검색 조건에 사용될 컬럼은 인덱스 정의에 명시적으로 포함되어야 합니다.</li>
<li>그러나, 옵티마이저가 새로 생성한 인덱스를 사용하지 않고 t_user_account 테이블의 PRIMARY KEY를 사용하고 있습니다. 이는 옵티마이저가 PRIMARY KEY를 사용하여 JOIN을 수행하는 것이 더 효율적이라고 판단했기 때문입니다.</li>
<li>따라서, 해당 테이블에는 별도의 인덱스를 추가하지 않기로 결정했습니다.</li>
</ul>
<h3 id="데이터-개수-기반-방식을-결합">데이터 개수 기반 방식을 결합</h3>
<p>인덱스를 적용했을때 평균 시간을 약 900ms까지 줄일 수 있었으나, 조회 시간이 선형적으로 증가하는 문제는 해결이 불가했습니다. 이를 위해 데이터 개수 기반 방식을 수정된 쿼리에 결합하였습니다.</p>
<p><strong>데이터 개수 기반 방식</strong>이란, 지정된 건수 만큼 결과 데이터를 반환하는 형태로, 처음 쿼리를 실행할 때와 그 이후 쿼리를 실행할 때의 쿼리 형태가 달라집니다. 우리의 수정된 페이징 쿼리는 ORDER BY 절에서 범위 조건 컬럼인 <code>profile_modify_date</code> 이 포함되어 있습니다. 해당 컬럼을 선두에 명시하여 (profile_modify_date, profile_id) 인덱스를 사용해서 file sort 작업 없이 원하는 건수만큼 순차적으로 데이터를 읽을 수 있어 처리 효율이 향상됩니다. 예를들어 첫 페이지 이후의 쿼리는 다음과 같이 작성할 수 있습니다.</p>
<pre><code class="language-sql">...
where 
(
  (
      profile_modify_date = {&#39;페이지에 노출된 마지막 프로필의 수정일자&#39;}
      AND 
    id &gt; {&#39;페이지에 노출된 마지막 사용자 식별 값&#39;}
  )
  or 
  (
    profile_modify_date &lt; {&#39;페이지에 노출된 마지막 프로필의 수정일자&#39;}
  )
)
order by profile_modify_date desc, user_id desc
...</code></pre>
<!--
### DB 스케일 업

[Amazon RDS 인스턴스 유형](https://aws.amazon.com/ko/rds/instance-types/)
[Amazon RDS 프리티어 인스턴스 추가](https://aws.amazon.com/ko/about-aws/whats-new/2022/03/amazon-rds-free-tier-dbt3micro-graviton2-based-instances/)
-->

<h2 id="페이징-카운트-쿼리-개선">페이징 카운트 쿼리 개선</h2>
<p>밥풀 프로젝트는 페이징 방법으로 <code>스크롤</code> 또는 <code>더보기</code> 방식이 아닌, 전형적인 페이지 번호를 나타내는 <code>페이지네이션</code>방법으로 기획되었습니다. 따라서, 특정 조건들로 사용자가 프로필 페이징을 요청하면, 하단에 페이지 번호를 명시하기 위해 COUNT 쿼리의 수행이 반드시 필요합니다. </p>
<h3 id="count-vs-select--vs-countdistinct-column-vs-countcolumn-비교"><code>COUNT(*)</code> vs <code>SELECT *</code> vs <code>COUNT(DISTINCT column)</code> vs <code>COUNT(column)</code> 비교</h3>
<p><code>SELECT COUNT(*)</code> 쿼리는 <code>WHERE</code> 조건을 만족하는 모든 행의 조회가 끝나야 개수를 반환할 수 있습니다. 차라리 <code>SELECT *</code> 쿼리를 수행 후, 애플리케이션 레이어에서 리스트 사이즈를 측정하는 것이 더 빠른 경우가 많습니다. 물론 네트워크 사용량은 <code>COUNT(*)</code> 이 더 적긴 합니다.</p>
<p>일반적으로 한 페이지당 보여주는 행의 개수는 10<del>50 정도에  페이지 번호의 범위는 10</del>20 정도이므로, 프로젝트 정책에 따라 적절한 값을 정한 뒤 <code>SELECT * ~ LIMIT</code> 을 사용하면 성능향상을 기대할 수 있습니다.</p>
<p><code>COUNT</code> 쿼리에서도 커버링 인덱스를 적용해볼 수 있습니다만, 모든 구문에서 사용되는 컬럼을 커버하는 인덱스를 추가하기에 무리가 있고, 검색 필터 또는 조건이 기획 변경에 따라 변화 가능성이 열려 있음을 고려한 결과 커버링 인덱스는 적용에 어려움이 있다고 판단했습니다.</p>
<p><code>COUNT(DISTINCT)</code> 는 <code>COUNT(*)</code> 와 동작방식이 상이합니다. 모든 행의 개수만 확인하는 <code>COUNT(*)</code> 과 달리 <code>COUNT(DISTINCT)</code> 는 중복 제거를 위해 테이블의 행을 모두 임시테이블로 복사(중복검사/행삽입) 후 임시테이블에서 최종 행의 개수를 반환합니다. 따라서 <code>COUNT(*)</code> 보다 성능이 좋지 않은 경우가 대부분입니다.</p>
<p>마지막으로 <code>COUNT(column)</code> 과 <code>COUNT(*)</code> 비교하자면, column이 Null 허용인 경우 각 행 마다 Null 여부를 확인해야 하기 때문에 <code>COUNT(*)</code> 보다 매우 낮은 성능을 보이며, Not null 컬럼의 경우, 각 행 마다 Null 여부를 확인하지는 않지만 <code>inoodb_parallel_read_threads</code> 설정 값에 따라 쿼리 성능을 달라질 수 있기 때문에 높은 확률로 <code>COUNT(*)</code> 성능이 더 좋습니다. </p>
<p>이렇게 4가지 정도의 집계 방법을 비교하여 결과적으로 <code>COUNT(*)</code> 함수를 사용하게 되었습니다.</p>
<h3 id="count-튜닝"><code>COUNT(*)</code> 튜닝</h3>
<p>앞서 말씀드렸듯 기획에 의해 <code>페이지네이션</code> 방식을 사용해야하는 상황에서 COUNT 쿼리를 사용하지 않을 수 없었습니다. 이때 고려할 수 있는 튜닝 방법으로 아래 2가지가 있습니다.</p>
<p>1) 표시할 페이지 번호 범위 만큼의 레코드만 건수 확인
2) 임의의 페이지 번호 표기 후, 페이지 이동 발생 시 번호 갱신</p>
<p>최소한의 노력(변경)으로 튜닝할 수 있는 방법을 선택하는 것이 이상적이라 판단되어 추후 페이지 번호에 대한 보정이 필요한 두 번째 대안보다 첫 번째 대안을 활용하기로 결정했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시에 들어온 여러 요청 처리하기]]></title>
            <link>https://velog.io/@dev_hyun/%EB%8F%99%EC%8B%9C%EC%97%90-%EB%93%A4%EC%96%B4%EC%98%A8-%EC%97%AC%EB%9F%AC%EC%9A%94%EC%B2%AD-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyun/%EB%8F%99%EC%8B%9C%EC%97%90-%EB%93%A4%EC%96%B4%EC%98%A8-%EC%97%AC%EB%9F%AC%EC%9A%94%EC%B2%AD-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 22 Apr 2024 08:25:58 GMT</pubDate>
            <description><![CDATA[<h2 id="새로운-이슈-발생--동시-요청-제어">새로운 이슈 발생 : 동시 요청 제어</h2>
<p>Babpool 프로젝트의 고도화를 위해, 기획 또는 사용자 경험상 미비한 부분을 보강하기 위한 미팅을 진행했습니다. 그 중 <code>밥 약속 요청</code> 플로우에서 개선의 여지를 발견하였고 변경된 내용은 다음과 같습니다.</p>
<p>Sender(요청을 보내려는 사용자) A, B와 Receiver(요청을 받으려는 사용자) C가 있음을 가정하겠습니다. 이전에는 A, B가 동일한 날짜/시간 여부와 관계 없이 최대 3개의 일정을 C에게 요청을 보내고 있습니다. 시퀀스 다이어그램으로 나타내면 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/5c493517-c16f-4fa8-b7cf-2ac4d419cc84/image.png" alt="비포+시퀀스다이어그램"></p>
<p>이전 플로우는 기획적 관점에서 &quot;한번에 하나의 요청만 상세 보기를 통해 시간을 열람하기 때문에, C 사용자가 동일 시간에 A, B 로부터 요청이 왔음을 파악하기 어렵다&quot;는 문제가 있었습니다.</p>
<p>따라서 요청을 보내려는 사용자는 1개의 날짜 및 시간을 선택하여 요청을 받으려는 사용자에게 발송되도록 기획을 변경했습니다.</p>
<p>이때, 이전에는 고려하지 않아도 되었던 <code>동시성</code> 문제가 대두되었습니다. &quot;만약, A, B가 동일한 날짜/시간에 C에게 요청을 보냈을 경우, 가장 먼저 요청을 발송한 사용자의 요청만 C에게 도달하도록 한다.&quot; 라는 요구사항을 충족하기 위해 &quot;동일한 날짜 및 시간에 대해서는 한 명의 요청만 허용&quot; 되어야 합니다.</p>
<p>동시 요청 제어를 고려해 위 프로세스를 시퀀스 다이어그램으로 나타내면 아래와 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/a4e23c2e-ebef-46a1-9f50-5962553e01db/image.svg" alt="애프터+시퀀스다이어그램"></p>
<h2 id="database를-활용한-동시성-제어-기법-비교">Database를 활용한 동시성 제어 기법 비교</h2>
<p>동시성 제어를 위해 여러 가지 대안을 고려해 보았고, 최종적으로 데이터의 정합성을 최우선으로 하여 비관적 락(Pessimistic Lock)을 선택하게 되었습니다. 이 선택을 하기까지의 과정을 설명하며, 각 기법의 장단점을 정리해보겠습니다.</p>
<h3 id="synchronized-평생-서버-1대만-둘-건-아니니까"><code>Synchronized</code>, 평생 서버 1대만 둘 건 아니니까</h3>
<p>Synchronized 키워드를 이용하여 동시성을 제어할 수 있는 방법은 간단하지만, 서버가 단일 인스턴스일 때만 유효한 해결책입니다. 만약 서버를 확장하여 여러 대의 서버가 요청을 처리하게 된다면, 각 인스턴스에서 별도의 메모리를 사용하기 때문에 락이 분산되지 않습니다. 따라서 서버를 확장할 계획이 있는 경우에는 적합하지 않은 방법입니다.</p>
<h3 id="비관적-락-pessimistic-lock">비관적 락 (Pessimistic Lock)</h3>
<p>실제로 데이터에 Lock 을 걸어서 다른 트랜잭션이 접근하지 못하도록 하여 정합성을 맞추는 방법입니다. exclusive lock 을 걸게되며 다른 트랜잭션에서는 lock 이 해제되기전에 데이터를 가져갈 수 없게됩니다.
<code>SELECT ... FOR UPDATE</code> 같은 쿼리를 사용하여 데이터의 정합성을 보장합니다.
데드락이 걸릴 수 있기때문에 주의하여 사용하여야 합니다.</p>
<p>장점</p>
<ul>
<li>데이터 정합성을 철저히 보장합니다.
가장 먼저 락을 획득한 요청만 처리되므로 충돌 가능성을 원천 차단합니다.</li>
</ul>
<p>단점</p>
<ul>
<li>데드락(Deadlock) 발생 가능성이 있어 주의가 필요합니다.
락을 걸고 있는 동안 다른 요청이 대기해야 하므로 성능 저하가 발생할 수 있습니다.</li>
</ul>
<h3 id="낙관적-락-optimistic-lock">낙관적 락 (Optimistic Lock)</h3>
<p>실제로 Lock 을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다. 먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 합니다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은후에 작업을 수행해야 합니다.</p>
<h3 id="네임드-락-named-lock">네임드 락 (Named Lock)</h3>
<p>이름을 가진 metadata locking 입니다. 이름을 가진 lock 을 획득한 후 해제할때까지 다른 세션은 이 lock 을 획득할 수 없도록 합니다. 주의할점으로는 transaction 이 종료될 때 lock 이 자동으로 해제되지 않습니다. 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제됩니다.</p>
<h2 id="redis-lock">Redis Lock</h2>
<p>DataBase Lock를 이용하면 추가적인 인프라 구성 없이 동시성 문제를 해결할 수 있습니다. 하지만 Lock 획득을 위해 대기하는 Connection 이 증가할 수 있고, 이건 높은 트래픽 상황에서 부하로 이어집니다.</p>
<p>반면, Redis를 이용한 Distributed Lock은 DataBase Connection 증가를 방지할 수 있지만, 별도의 관리가 필요합니다. Redis 관리에는 메모리 최적화, 장애 복구, 데이터 일관성 유지 등의 과제가 부가적으로 필요하다는 의미입니다. 따라서 트래픽이 많아 트랜잭션이 많이 일어나거나, 다중 서버 환경인 경우 Redis를 이용한 Lock 기법을 고려해볼 수 있습니다.</p>
<h3 id="분산-락">분산 락</h3>
<p>분산 락의 목적은 여러 분산된 서버가 존재할 때, 여러 서버에서 공유 자원에 접근하여 발생하는 Race Condition 자체를 없앱니다. 즉, 공유 자원 자체에 Lock을 설정하는 비관적 락/낙관적 락과 다르게 임계 영역(critical section)에 Lock을 설정합니다.</p>
<p>Redis를 사용한 분산 락에는 다음과 같이 2가지 방식이 존재합니다.</p>
<ol>
<li>Lettuce : Spin Lock으로 지속적으로 Redis 서버에 요청을 보내서 Lock 여부 확인</li>
<li>Redisson : RedLock 알고리즘을 사용해서 Lock 획득-해제를 pub-sub 구조로 수행</li>
</ol>
<p>참고 중인 링크</p>
<ul>
<li><a href="https://6161990src.tistory.com/146">https://6161990src.tistory.com/146</a></li>
<li><a href="https://ksh-coding.tistory.com/150#3.%20Redis%EB%A5%BC%20%EC%82%AC%EC%9A%A9%ED%95%9C%20%EB%B6%84%EC%82%B0%20%EB%9D%BD-1">https://ksh-coding.tistory.com/150#3.%20Redis%EB%A5%BC%20%EC%82%AC%EC%9A%A9%ED%95%9C%20%EB%B6%84%EC%82%B0%20%EB%9D%BD-1</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[ID 채번 전략 비교 및 TSID 도입]]></title>
            <link>https://velog.io/@dev_hyun/id-%EC%B1%84%EB%B2%88-%EC%A0%84%EB%9E%B5-%EB%B9%84%EA%B5%90-%EB%B0%8F-tsid-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@dev_hyun/id-%EC%B1%84%EB%B2%88-%EC%A0%84%EB%9E%B5-%EB%B9%84%EA%B5%90-%EB%B0%8F-tsid-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Tue, 27 Feb 2024 08:20:00 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-auto_increment-대신-랜덤-식별-값을-고려하게-되었는가">왜 Auto_Increment 대신 랜덤 식별 값을 고려하게 되었는가</h2>
<p>그동안 참여했던 웹 프로젝트에서도, 학습 과정에서 활용되었던 프로젝트에서도 자원을 식별하는 값의 전략으로 <code>Auto Increment</code> 를 사용해왔습니다. <code>Auto Increment</code> 전략은 테이블에 새로운 행을 생성할 때, 고유한 식별 값을 생성하는 과정을 데이터베이스에게 일임합니다. 증가하는 시퀀스 번호를 자동으로 할당해주기 때문에 개발자는 사용에 간편합니다. 가독성이 좋아 기억하기에도 쉽고 식별 값을 활용하는 내부 사용자(개발자, 관리자)간의 의사소통에도 불편함이 상대적으로 적습니다. </p>
<p>하지만, <code>Auto Increment</code>를 PK으로 사용하면서 발생될 수 있는 몇몇 문제들 때문에 ID 채번 전략을 다시 고민하게 되었습니다.</p>
<p><strong>1) 분산 DB 환경에서 충돌 발생</strong></p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/a945b741-8a09-49c8-9cc4-6733c059698e/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/ce432a36-9282-4e46-8aa0-2ea4ec442227/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>1개의 Master DB 사용시. 이미지 출처 : <a href="https://nesoy.github.io/articles/2018-02/Database-Replication">링크</a></td>
<td>2개 이상의 Master DB 사용 시. 이미지 출처 : <a href="https://blog.naver.com/seuis398/70039269328">링크</a></td>
</tr>
</tbody></table>
<p>단일 DB 환경이 아닌, 여러 데이터베이스 서버를 사용하는 분산 DB 환경에서는, 데이터베이스간 동기화가 되어 있지 않으면, 동일한 ID가 나타나 충돌이 발생할 수 있습니다. PK의 일관성을 보장하지 못하는 문제가 발생하는 것입니다.</p>
<p>물론 분산DB 환경이라고 다 문제가 되는 것은 아닙니다. 일반적으로 분산DB 환경의 경우 Write 하는 Master 는 하나를 두고 Read만 하는 여러대의 Slave DB에 그 값을 동기화 하도록 하는데, 이 경우에는 AUTO_INCREMENT 를 사용해도 문제가 되지 않습니다. 또한 Write 하는 Master DB 가 2대 이상의 분산처리 하는 경우 <code>auto_increment_offset</code>, <code>auto_increment_increment</code> 설정을 통해 AUTO_INCREMENT 증가를 제어함으로써 충돌을 방지할 수 있습니다. 예를 들어 master db가 2대 인 경우 각각 홀/짝수로 증가하도록 해서 해결할 수 있습니다. 하지만 이러한 Multi Master Replication 환경에서는 여러 문제가 발생할 수 있습니다. ID의 유일성은 보장되지만 더 높은 값의 ID가 더 최근에 생성됨을 보장하지 않습니다. 즉 시간의 흐름에 따라 커짐을 보장할 수 없습니다. 또한, 서버를 추가하거나 삭제할때마다 올바른 ID 생성을 위해 설정을 변경해야 하는데 이를 처리하기가 쉽지 않다는 문제가 있습니다.</p>
<p><strong>2) 추정 하기 쉬운 식별 값</strong></p>
<p>Multi Master Replication과 같은 분산 DB 환경에서 애플리케이션이 동작하지 않더라도, Auto Increment 전략은 추정하기 쉬운 식별 값 이라는 문제에 있습니다. 규칙적으로 증가하는 형식의 PK를 통해 자원에 대한 정보를 추정하기 너무 쉽습니다.</p>
<p>특정 자원에 접근할 때, 클라이언트는 개발자의 의도 순서대로 자원에 접근하지 않고 URL 을 통해 식별 값을 변경시켜가며 직접접근을 시도할 수 있습니다. 이를통해 서비스의 자원을 손 쉽게 수집할 수 있으며, 순차 증가하는 전략의 특징으로 인해 서비스의 규모를 추정할 수 있는 여지가 되기도 합니다. 일반적인 사용자가 아니라 크롤링 봇인 경우 문제가 더욱 두드러집니다</p>
<p>자원을 수정하기 위한 요청을 전송할 때도 의도적으로 자원 식별 값을 위조하기에도 쉽습니다. 당연히 이러한 잘못된 접근을 방지하는 자원 접근 권한에 대한 검증 로직이 존재해야 하는 것은 맞습니다만, 애초에 외부 사용자에게 노출되는 식별 값이 쉽게 해석되지 않는 장벽이 있다면 올바르지 않은 시도를 더 줄일 수 있을 것이라 판단했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/234ceb42-753a-42d9-9358-009a9a5d2d4f/image.png" alt="velog-글-수정하기-url"></p>
<p>따라서 첫 번째 대안으로, 상단 이미지의 예시처럼 유추하기 쉬운 정수 값(88)을 식별 값으로 사용하는 것 보다, 그 아래와 같이 랜덤한 값을 자원 식별 값으로 사용하기 위해 <code>UUID</code> 도입을 먼저 고려하게 되었습니다.</p>
<h2 id="uuid-에-대해-알아보기">UUID 에 대해 알아보기</h2>
<p><strong>개요</strong></p>
<p>UUID(Universally Unique Identifier)는 개체의 고유성을 충족하기 위해 탄생한 식별자로, 아래와 같이 총 36개의 문자로 16진수 문자 32개와 하이픈 <code>-</code> 문자 4개로 구성되어 있습니다. 16진수를 나타내기 위해서는 4bit가 필요하고, 32개의 16진수 이기 때문에 128비트 길이를 가지고 있습니다. 초기 개념은 오픈 소프트웨어 재단(OSF)에 의해 표준이 제정되었습니다. 현재의 표준은 IETF에 의해 관리되고 있습니다. <a href="https://datatracker.ietf.org/doc/rfc9562/">참고링크 : Universally Unique IDentifiers (UUIDs) RFC 9562</a></p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/353a65ec-1462-476a-b108-4509f76ed6de/image.png" alt=""></p>
<p><strong>중복 가능성</strong></p>
<p>중복 가능성이 매우 낮은 특징을 가지고 있습니다. 이론상 중복될 확률이 0은 아니지만, UUID 버전 4를 기준으로 충돌 확률 0.00009% 를 만들기 위해서는 초당 100만개를 100년동안 생성해야 합니다(참고 링크 : <a href="https://www.cochori.com/uuid-%EC%A4%91%EB%B3%B5-%ED%99%95%EB%A5%A0/">블로그</a>). 따라서 사실상 0에 가깝다고 판단하고 UUID를 사용할 수 있습니다. </p>
<p><strong>사용법</strong></p>
<p>UUID는 버전 4 외에도 다양한 버전이 존재하고, 버전에 따라 생성 방식과 특징도 다릅니다. Java에서 <code>java.util</code> 패키지의 <code>UUID</code> 클래스를 사용하면 1,3,4 버전을 생성할 수 있습니다. 가장 간편하게 생성할 수 있는 버전 4는 <code>randomUUID()</code> 메서드를 사용합니다.</p>
<pre><code>UUID uuid = UUID.randomUUID();</code></pre><p>MySQL 8.0 버전부터는 <code>UUID()</code> 함수를 사용하면 버전 1의 UUID를 간편하게 생성할 수 있습니다.</p>
<h2 id="성능-비교를-통한-uuid-개선-과정">성능 비교를 통한 UUID 개선 과정</h2>
<p>Auto Increment 되는 식별 값을 외부 클라이언트에게 노출하였을 때 발생하는 문제를 인지해 UUID 적용을 고려하게 되었고, ID 충돌이 발생할 확률도 매우 낮아 충분히 고유한 값을 갖는 키의 역할로 활용될 수 있습니다. 하지만 UUID를 서비스에 도입할 때 발생하는 생성비용, 삽입 및 조회 성능의 저하 등을 고려해야 합니다. 따라서 대량의 더미 데이터를 삽입하는 성능 비교를 통해 타협 가능한 정도인지를 확인하고 UUID 도입의 당위성을 챙기고자 했습니다.</p>
<h3 id="innodb-인덱스-정렬-특징을-고려하기">InnoDB 인덱스 정렬 특징을 고려하기</h3>
<p>8.0 버전을 기준으로 MySQL은 B-Tree 인덱스 알고리즘을 사용하기 때문에 인덱스 구조체 내에서 항상 정렬된 상태를 유지합니다. 물론 InnoDB 엔진의 특성상 Insert 쿼리가 수행될 때 지능적으로 인덱스 알고리즘을 적용하기 위해 즉시 새로운 인덱스 키 값을 B-Tree 인덱스에 변경하지는 않습니다. 그럼에도 비순차적 값을 인덱스로 사용할 경우, 인덱스 알고리즘이 동작할 때 B-Tree 구조의 재배치 동작이 순차적인 값을 사용하는 경우보다 성능적 측면에서 비효율 적입니다.</p>
<p>따라서 UUID에 인덱스를 걸어도 정렬이 올바르게 되어 있지 않다면 빠르게 원하는 key를 찾을 수 없습니다. 최대한 빠르게 찾아가기 위해서는 특정 순서로 정렬되어 있어야 합니다. 즉, UUID 컬럼의 값을 특정 순서로 정렬할 수 있어야 하기 때문에 구현이 간편한 UUID v4 를 사용하는 것 보다 정렬될 수 있는 UUID 버전을 사용해야 했습니다. 여러 버전 중 대표적으로 1, 6, 7 버전이 타임스탬프를 기준으로 정렬이 가능합니다. </p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/c3f9fdab-b979-479e-88a8-2720b4a4cea1/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/7f9edff5-514d-42d6-8da7-3532be863a7a/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>이미지 출처 : <a href="https://www.techtarget.com/searchapparchitecture/definition/UUID-Universal-Unique-Identifier">링크</a></td>
<td>이미지 출처 : <a href="https://ko.wikipedia.org/wiki/%EB%B2%94%EC%9A%A9_%EA%B3%A0%EC%9C%A0_%EC%8B%9D%EB%B3%84%EC%9E%90">위키피디아</a></td>
</tr>
</tbody></table>
<p>이들 중 48 비트의 타임스탬프 + 74 비트의 무작위 값으로 구성된 버전 7을 선택했습니다. 버전 7을 선택한 이유는 버전 1에서는 중간 48비트를 MAC 주소를 사용하는데 이를통한 개인 정보 보호 및 네트워크 보안 문제가 발생할 수 있다고 알려져 있으며, 버전 6이 시스템 시간에 의존적인 반면 7은 Unix Epcoh 타임스탬프를 사용하여 시스템 독립성을 강화하였기 때문입니다. 또한 그레고리안 형식보다 Unix 타임스탬프의 형식이 계산과 저장연산이 조금 더 간단하고 빠르다고 알려져있습니다. IETF(Internet Engineering Task Force, 국제 인터넷 표준화 기구)에서도 가능한 version 7를 사용하도록 권장하고 있다고 합니다. (<a href="https://monday9pm.com/time-based-uuid%EC%9D%98-%EC%83%88%EB%A1%9C%EC%9A%B4-%ED%91%9C%EC%A4%80-ieft-version-6-7-8-0924b325c2db">참고 링크</a>)
최근에는 UUID 버전 7도 IETF의 표준화 과정을 거쳐 제안 표준(Proposed Standard)으로 승인되었음을 확인할 수 있었습니다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/b3b3cc79-6cdc-4b73-b733-c06aa99fbde7/image.png" alt=""></p>
<!--
**버전 별 특징**

간략하게 1, 6, 7 버전별 특징은 다음과 같습니다.

UUID v1
- 60 비트의 타임스탬프 생성 시간 + 48비트 MAC 주소 + 14비트 고유 clock 시퀀스
- 시간에 따라 정렬 가능
- MAC 주소가 포함되어 개인정보 유출 위험이 있음
- 시스템 시간에 의존적
- 삽입 순서에 따라 정렬 불가

UUID v6
- 생성 시간 + 랜덤 값(v1 개선)
- 시스템 시간에 의존적
- 삽입 순서에 따라 정렬 불가

UUID v7
- 48 비트의 타임스탬프 + 74 비트의 무작위 값
- 생성 시간(v6 개선) + 랜덤 값
- 시스템 시간 대신 모노토닉(단조증가) Unix Epoch 타임스탬프를 사용하여 시스템 독립성을 강화
- 삽입 순서에 따라 정렬 가능
-->

<p><strong>버전 별 삽입 성능 비교</strong></p>
<p>위의 이론을 증명하는 벤치마크를 조사해본 결과 두 곳에서 유의미한 삽입 성능 비교 결과를 찾아볼 수 있었습니다. 공통적으로 v1, v4 보다 v6, v7이 월등한 삽입 성능 (약 28%) 을 보였습니다. v6와 v7은 근소한 차이밖에 나지 않았으나 v7이 가장 최신이며 효율적인 버전으로 간주됩니다.</p>
<table>
<thead>
<tr>
<th>버전 별 삽입 성능 비교</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/dev_hyun/post/4a22c0e5-f141-4007-bdff-e2f95913acb6/image.png" alt=""> 이미지 출처 : <a href="">링크</a></td>
</tr>
<tr>
<td><img src="https://miro.medium.com/v2/resize:fit:2000/format:webp/1*RHXQklN48YI8wGbOo60MAw.png" alt="">이미지 출처 : <a href="https://itnext.io/why-uuid7-is-better-than-uuid4-as-clustered-index-edb02bf70056">링크</a></td>
</tr>
</tbody></table>
<h3 id="innodb-인덱스-크기를-고려하기">InnoDB 인덱스 크기를 고려하기</h3>
<p>인덱스의 키 값의 크기는 인덱스 키 검색과 밀접한 관계가 있습니다. 데이터 버퍼과 데이터 저장의 기본 단위는 페이지에 저장할 수 있는 인덱스 키의 개수가 많을 수록, 더 적은 I/O 횟수로 페이지를 읽어 원하는 인덱스를 찾을 수 있기 때문입니다. </p>
<p>UUID는 하이픈을 포함해 36자리 문자열로 구성되어 있습니다. 하이픈을 제거하더라도 이를 통으로 저장하려면 varchar(32) 타입을 사용해야 하는데, 이는 DB 입장에서 너무 큰 ID 값입니다. 또한 비교 연산을 수행할 때도 유의미한 성능 저하가 발생합니다. 따라서 UUID를 ID으로 사용할 때도 그대로 저장하기 보다 조금이라도 줄이는 것이 좋다고 판단했습니다.</p>
<p>UUID의 구성을 보면 각 자리는 16진수(4비트) 32자리 입니다. 총 128비트는 16바이트으로, UUID는 16바이트 크기로 나타낼 수 있습니다. 따라서 UUID를 바이트 배열로 변환하여 MySQL에서 지원하는 BINARY(16) 데이터 타입을 사용하여 저장 크기를 기존 32바이트에서 절반으로 줄일 수 있었습니다.</p>
<p>실제로 UUID의 데이터타입을 어떤 것을 선택하는지에 따라 데이터 삽입 속도에서도 차이가 발생했습니다. 회당 50만 데이터를 삽입하는 작업을 5회 수행한 결과 VARCHAR(36) 타입을 선택한 경우 평균 10% 의 성능저하가 나타났습니다.</p>
<p>MySQL 공식문서와 JPA 하이버네이트 문서에서도 UUID를 16바이트 크기로 줄이는 방법을 권장하고 있습니다.</p>
<ul>
<li>UUID 사용예제 MYSQL 공식문서 <a href="https://dev.mysql.com/blog-archive/storing-uuid-values-in-mysql-tables/">링크</a></li>
<li>하이버네이트(JPA)에서 UUID 사용시 바이트배열을 권장 <a href="https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#_uuid_as_binary">링크</a></li>
</ul>
<h2 id="불편한-uuid">불편한 UUID</h2>
<p>이렇게 정렬 가능한 UUID v7 버전을 선택하고, 저장 크기를 절반의 크기로 줄일 수 있었으나, UUID를 자원의 <strong>단일 식별 값으로 사용하기에</strong> 너무 긴 식별 값으로 인해 <strong>의사소통에 불편</strong>한 점이 발견되었습니다. </p>
<p>첫째로, 외부에 노출되었을 때 자원에 대해 추정하기 어렵다는 장점이 내부 관계자들이 소통할 때에도 소통에 어려움을 겪는 문제였습니다.</p>
<p>또한 둘째로, 일부 데이터베이스 도구에서 UUID 조회시 아래 이미지와 같이 BLOB 데이터 타입으로 보여지는 문제가 있습니다. (DataGrip 은 별도 변환 없이 문자열로 변환되어 보여집니다.)</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/9a94febd-ecdc-4a84-8a9c-b7b524a302aa/image.png" alt="uuid-blob"></p>
<p>이를 해결하기 위해서는 아래와 같이 변환해야 조회가 가능합니다.</p>
<pre><code class="language-sql">select LOWER(CONCAT(
        SUBSTR(HEX(uuid), 1, 8), &#39;-&#39;,
        SUBSTR(HEX(uuid), 9, 4), &#39;-&#39;,
        SUBSTR(HEX(uuid), 13, 4), &#39;-&#39;,
        SUBSTR(HEX(uuid), 17, 4), &#39;-&#39;,
        SUBSTR(HEX(uuid), 21)
    )), table.*
from table;</code></pre>
<p>저는 쿼리가 길어지는 문제를 다음과 같이 <code>UuidResolver</code> 를 구현해 해결하기도 했습니다.</p>
<pre><code class="language-java">@Slf4j
@Component
public class UuidResolver {

    public UUID generateUuid() {
        UUID uuidV7 = Generators.timeBasedEpochGenerator().generate();
        return uuidV7;
    }

    public byte[] parseUuidToBytes(UUID targetUuid) {
        ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
        bb.putLong(targetUuid.getMostSignificantBits());
        bb.putLong(targetUuid.getLeastSignificantBits());
        return bb.array();
    }

    public UUID parseBytesToUuid(byte[] targetUuidBytes) {
        ByteBuffer bb = ByteBuffer.wrap(targetUuidBytes);
        long mostSignificantBits = bb.getLong();
        long leastSignificantBits = bb.getLong();
        return new UUID(mostSignificantBits, leastSignificantBits);
    }

}</code></pre>
<p>이외에도 <strong>저장 공간 효율성 측면에서도</strong> UUID는 128비트(<code>16바이트</code>)를 차지하여, 정수형 ID(<code>BIGINT 8바이트</code>)에 비해 더 많은 저장 공간을 필요로 합니다. 이로 인한 저장 공간 및 인덱스 크기 증가가 발생할 수 있습니다.</p>
<h3 id="인덱스의-unique-여부-비교하기-unique-index-non-unique-index">인덱스의 Unique 여부 비교하기 (Unique Index, Non Unique Index)</h3>
<p>세컨더리 인덱스는 Unique 옵션의 여부에 따라 Unique Index(유니크 인덱스)와 Non Unique Index(일반 세컨더리 인덱스)으로 구분할 수 있습니다. Insert(쓰기) 관점에서 Unique Index는 유일성을 보장하기 위해 중복된 값이 있는지 확인하는 과정이 필요합니다. 따라서 Unique Index는 Non Unique 인덱스보다 삽입 성능이 낮습니다. (참고 : Real MySQL 8.0 1권 8장 9절)</p>
<p>실제로 책에서 학습한 내용과 동일한지 확인해보기 위해 Unique 옵션만 다르게 두 테이블을 생성하고, 50만 Row 를 삽입하는 테스트를 5회 반복했습니다. 의문스럽게도 삽입하려는 식별 값이 테이블에서 유일한지 검사가 필요한 Unique Index가 적용된 테이블이 그렇지 않은 테이블보다 느린적이 단 한번도 없었습니다. 삽입하는 행의 개수가 너무 적어서 이런 결과가 나왔을 수 있으니 100만 Rows를 삽입하는 테스트를 3회 더 반복해보았지만 결과는 다르지 않았습니다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/090722a3-9cda-4077-a696-caab548bb797/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/dev_hyun/post/a407b960-8d9d-410f-bfcc-fd52139d7613/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td>50만 건 삽입, 5회 반복</td>
<td>100만 건 삽입, 3회 반복</td>
</tr>
</tbody></table>
<p>왜 이런 결과가?
[지피티 답변, 코파일럿 답변 참고해서 정리]</p>
<p>데이터 타입 때문일 수도...
따라서 이번엔 UUID에 BINARY(16) 타입 대신 VARCHAR(36) 타입의 테이블 2개를 생성하고 Unique 옵션을 다르게 적용 후, 다시 테스트를 5회 반복했습니다. 전체 테스트의 40%는 Unique 옵션을 적용하였을 때 삽입 속도가 더 느린 흥미로운 결과를 받아볼 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/c6fa0581-9bb0-4591-9c42-8e6ecde46082/image.png" alt=""></p>
<p>UUID 버전 7은 중복된 식별 값이 생성될 확률이 로또 1등을 10회 연속 당첨될 확률 보다 낮습니다. 따라서 유일성 보장을 위해 Unique 옵션을 적용하는 것이 적절해 보임.</p>
<p>결과적으로 자원 식별 값이 외부 클라이언트에게 노출되어 UUID를 사용해야 한다면 자원에 대한 유일성을 보장</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/2ba10e35-6bb9-46e1-8883-7f6a544fff1c/image.png" alt="2개의 ID를 채번하는 전략은 성능차이가 2.5배 발생한다."></p>
<p>다음 세 가지 테이블을 생성 후, 50만 건의 데이터를 삽입하였을 때 소요되는 시간을 측정한 결과</p>
<ol>
<li>Auto Increment ID 를 PK 으로 사용하는 테이블</li>
<li>정렬 가능한 UUID 버전7을 PK 으로 사용하는 테이블</li>
<li>Auto Increment ID 를 PK, UUID 버전7에 세컨더리 인덱스를 적용한 테이블</li>
</ol>
<h2 id="uuid-대안-찾기">UUID 대안 찾기</h2>
<p>UUID의 한계를 인식한 후, UUID를 대체할 식별자 전략을 조사했습니다. 주요 대안으로 유튜브 UID, Snowflake, TSID를 고려했습니다.</p>
<p><strong>유튜브 UID</strong>
유튜브에서 사용하는 UID 시스템은 다음과 같은 특징을 가집니다:</p>
<ul>
<li>11자리의 Base64 인코딩 문자열 사용</li>
<li>시간 정보를 포함하지 않음</li>
<li>고유성은 보장하지만 정렬이 어려움</li>
<li><a href="https://github.com/gpslab/base64uid">깃허브 공식 래포지토리 링크</a></li>
</ul>
<p><strong>Snowflake</strong>
Twitter에서 개발한 Snowflake ID 방식은 여러 서버 인스턴스에서 각자 ID를 생성하는 방식입니다. 따라서 특정 시간에 특정 인스턴스가 생성한 시퀀스로 ID의 유일함을 보장합니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/45d47cec-329f-4210-8424-ae835374b063/image.png" alt=""></p>
<p>식별 값의 구조는 위와 같이 타임스탬프 41bit와 서버 인스턴스 번호(InstanceId) 10bit, 시퀀스 12bit를 합쳐서 63bit에 1bit를 더해 4byte로 표현합니다.</p>
<ul>
<li>64비트 정수로 구성</li>
<li>타임스탬프, 워커 ID, 시퀀스 번호를 포함</li>
<li>시간 순 정렬 가능, 분산 시스템에 적합</li>
<li>하지만 워커 ID 관리의 복잡성이 존재</li>
</ul>
<p><strong>TSID (Time-Sorted Unique Identifier)</strong>
TSID는 다음과 같은 특징을 가집니다:</p>
<ul>
<li>13자리의 숫자 문자열로 표현되어, 64비트 정수로 구성되어 있습니다. 따라서 UUID 보다 훨씬 짧아 가독성 측면에서도 이점이 있습니다.</li>
<li>식별 값에 타임스탬프를 포함하여 시간 순 정렬이 가능합니다.</li>
<li>시간 순 정렬 가능</li>
<li>충돌 가능성이 극히 낮아 분산 시스템에 적합합니다.</li>
<li>분산 시스템에 적합하며 관리가 간단</li>
<li>Snowflake와 달리 추가적인 인프라 관리가 필요 없습니다.</li>
<li><a href="https://github.com/f4b6a3/tsid-creator">깃허브 공식 래포지토리 링크</a></li>
</ul>
<p>트위터 Snowflake
<a href="https://ramka-devstory.tistory.com/3">https://ramka-devstory.tistory.com/3</a>
<a href="https://dev.to/josethz00/benchmark-snowflake-vs-uuidv4-2h80">https://dev.to/josethz00/benchmark-snowflake-vs-uuidv4-2h80</a></p>
<p><a href="https://techblog.lycorp.co.jp/ko/experience-in-migrating-order-db-on-ecommerce-platform">https://techblog.lycorp.co.jp/ko/experience-in-migrating-order-db-on-ecommerce-platform</a></p>
<h3 id="성능-비교">성능 비교</h3>
<p>준수한 삽입 성능을 가지는 TSID</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/bbf66839-7d22-4032-9bcc-ba4f0104b4d1/image.png" alt=""></p>
<p>TSID가 다른 식별자들에 비해 우수한 삽입 성능을 보여주고 있습니다. 특히 Auto Increment와 비슷한 수준의 성능을 보이면서도</p>
<p>종합적인 분석 결과, 우리는 TSID를 새로운 식별자 시스템으로 도입하기로 결정했습니다. TSID는 UUID의 장점인 고유성과 분산 시스템 적합성을 유지하면서도, 더 짧고 효율적인 형태를 제공합니다. 또한, 시간 순 정렬이 가능하여 데이터베이스 성능 최적화에도 도움이 됩니다.</p>
<h2 id="tsid-도입">TSID 도입</h2>
<h3 id="트러블슈팅-javascript의-최대-정수-표현-범위를-벗어나다">트러블슈팅, Javascript의 최대 정수 표현 범위를 벗어나다</h3>
<p><strong>문제상황</strong></p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/6377b587-c916-4bbe-9666-3f9efe15753b/image.png" alt=""></p>
<p>TSID를 식별 값 생성전략으로 변경한 후, 클라이언트가 정상적으로 데이터를 응답받지 못하는 이슈가 발생했습니다.
로그를 살펴보니 서버에서 클라이언트로 응답한 식별 값의 길이가 너무 길어서 Javascript가 뒤 2자리를 00으로 바꿔버려 요청 시 올바르지 않은 식별 값을 전달한 것을 확인할 수 있었습니다.</p>
<p><strong>원인</strong></p>
<p>TSID를 이용시에는 Long 타입의 16바이트를 모드 이용하는데, 자바스크립트 언어에서의 정수는 53비트로 제한되어 있습니다. 즉, 53비트보다 큰 수일 경우에 정확하게 수를 표현하지 못합니다. 프론트에서 처리해야 한다면, <code>json-bigint</code>와 같은 라이브러리를 활용해 해결할 수 있다는 것을 알게되었습니다. (라이브러리 <a href="https://www.npmjs.com/package/json-bigint">공식 링크</a>) 그러나, 기한 문제로 백엔드에서 처리해달라는 프론트의 요청에 의해 직렬화/역직렬화 시 형변환을 통해 백엔드에서 해결하는 방향을 선택했습니다.</p>
<p><strong>해결) Json 직렬화/역직렬화</strong></p>
<p>Spring Boot는 <code>Jackson</code> 라이브러리를 통해 Java 객체와 JSON 간의 변환을 처리합니다. Jackson은 Java 객체와 JSON 데이터 간의 변환을 수행하는 라이브러리로, Java 객체를 JSON으로 직렬화(Serialize)하거나, JSON 데이터를 Java 객체로 역직렬화(Deserialize)할 수 있습니다. SpringBoot 3.0 이상에서는 기본적으로 <code>spring-boot-starter-json</code>에 포함되어 있습니다.</p>
<p>그리고, ObjectMapper는 Jackson 라이브러리의 핵심 클래스으로, Spring Boot에서는 Jackson 라이브러리의 ObjectMapper 클래스를 사용하여 JSON 데이터를 처리합니다.</p>
<p>Jackson에서는 직렬화와 역직렬화를 수행할 때 사용하는 Serializer와 Deserializer를 지정할 수 있습니다. Serializer는 Java 객체를 JSON 데이터로 변환할 때 사용되고, Deserializer는 JSON 데이터를 Java 객체로 변환할 때 사용됩니다. Spring Boot에서는 이러한 Serializer와 Deserializer를 간편하게 등록하고 사용할 수 있도록 JsonSerialize와 JsonDeserialize 어노테이션을 제공합니다.</p>
<p>다음과 같이 직렬화를 위해 JsonSerializer&lt;&gt;를 상속받아 구현하고, <code>@JsonSerialize</code> 어노테이션을 통해 특정 객체의 필드나 메서드에 지정할 수 있습니다.</p>
<pre><code class="language-java">public class CustomLocalDateTimeSerializer extends JsonSerializer&lt;LocalDateTime&gt; {
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;);

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeString(value.format(formatter));
    }
}

// 활용 방법
@JsonSerialize(using = CustomLocalDateTimeSerializer.class)
    @JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
    private LocalDateTime eventDate;</code></pre>
<p>하지만, 식별 값은 이미 response, request DTO 들이 프로젝트 전반에 너무 많이 산재되어 있어, DTO 클래스마다 직렬화/역직렬화 어노테이션을 부착하기에는 너무 많은 변경과 작업량이 우려되었습니다. 따라서 프로젝트 전역적으로 해결할 수 있는 방법인 <code>@JsonComponent</code> 에 대해 조사하게 되었습니다.</p>
<p>완성된 직렬화/역직렬화 코드 일부는 아래와 같습니다.</p>
<p>서버 → 클라이언트 응답</p>
<pre><code>@JsonComponent
public class JsonResponseLongToStringConfig extends JsonSerializer&lt;Long&gt;{

    @Override
    public void serialize(Long idLong, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeString(String.valueOf(idLong));
    }
}</code></pre><p>클라이언트 요청 → 컨트롤러 파라미터로 바인딩</p>
<pre><code>@JsonComponent
public class JsonRequestNumericStringToLongConfig extends JsonDeserializer&lt;Long&gt; {
    @Override
    public Long deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
        if(org.springframework.util.StringUtils.hasText(jsonParser.getText()) &amp;&amp; StringUtils.isNumeric(jsonParser.getText())){
            return Long.parseLong(jsonParser.getText());
        }
        return null;
    }
}</code></pre><p>참고한 링크 : <a href="https://siyoon210.tistory.com/185">Spring Boot에서 특정 필드 직렬화 방식 변경하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS 프리티어인데 돈이나간다(RDS, Public IPv4)]]></title>
            <link>https://velog.io/@dev_hyun/AWS-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4%EC%9D%B8%EB%8D%B0-%EB%8F%88%EC%9D%B4%EB%82%98%EA%B0%84%EB%8B%A4-RDS-Public-IPv4</link>
            <guid>https://velog.io/@dev_hyun/AWS-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4%EC%9D%B8%EB%8D%B0-%EB%8F%88%EC%9D%B4%EB%82%98%EA%B0%84%EB%8B%A4-RDS-Public-IPv4</guid>
            <pubDate>Tue, 13 Feb 2024 06:27:27 GMT</pubDate>
            <description><![CDATA[<h2 id="😨-041-달러가-주는-불안감">😨 0.41 달러가 주는 불안감</h2>
<p>약 4일 전, 새로운 사이드 프로젝트의 인프라 구축을 담당하게 되어 AWS 신규가입을 했고, 1년간 프리티어 혜택을 활용할(뽕뽑을) 계획으로 서비스를 이용하기 시작했습니다.</p>
<p>우선 반드시 사용할 EC2와 RDS를 생성했고, EC2에 탄력적 IP를 발급받아 할당했습니다. 과거(21년도)의 경험을 바탕으로 해당 작업 까지는 프리티어 계정에서 1년간 비용이 발생했던 점이 전혀 없었기에 안심하고 있었습니다만,,,</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d48fa2f4-2df0-4eeb-af6f-71f2dc3640b8/image.png" alt="0.41달러 과금 정보"></p>
<p>이럴 수가.. 고환율 시대(1,333원)에 0.41 달러의 비용이 누적된 것을 발견했습니다. 💸</p>
<p>다른 개발자분들의 AWS 프리티어 수 백 만원대 비용이 과금되었다는 눈물없이 못듣는 썰 만큼 제 사례가 치명적인 것은 당연히 아니지만, 비용이 발생한 사건에 &#39;펀하고 쿨하게&#39;(끄덕) 돈을 내고 넘어갈 일은 아니라고 판단했습니다.</p>
<p>먼저, 비용이 발생한 원인을 파악하고 제가 목표로하는 클라우드 아키텍처를 구성하기 위한 대처까지 정리해보겠습니다.</p>
<h2 id="🤔-왜-비용이-발생했는가">🤔 왜 비용이 발생했는가</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/9a620048-bb50-4bd6-8507-e602a6a2300e/image.png" alt="AWS서비스별요금"></p>
<p>즉시, <code>결제 및 비용 관리</code> 페이지의 <code>청구 및 결제</code> - <code>청구서</code> 탭을 클릭해 스크롤을 조금 내려 <code>서비스별 요금</code> 영역을 확인해보았습니다.</p>
<p>이곳에서 요금이 발생한 서비스가 <code>VPC</code> 그 중에서도 <code>In-use public IPv4</code> 임을 파악했습니다.</p>
<p>한국어로 번역해보자면, 사용중인 퍼블릭(공용) IPv4 주소 때문에 비용이 발생했습니다.</p>
<h3 id="elastic-ip-탄력적-ip-때문일까">Elastic IP (탄력적 IP) 때문일까?</h3>
<p>이상했습니다. 제가 직접 IP 관련해서 주소를 할당 받은 것은 탄력적 IP 주소 1개 뿐이며, 프리티어 계정에서 요금이 발생하지 않도록 다음 규칙을 따르고 있었는데 말이죠.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/82266d93-42eb-4851-a57f-584f26127b85/image.png" alt=""></p>
<ul>
<li>인스턴스와 연결되지 않은 Elastic IP는 시간당 $0.005 (약 5원)이 과금되기 때문에 Elastic IP(탄력적 IP)를 실행중인 프리티어 인스턴스인 EC2와 연결 한다.</li>
<li>인스턴스를 중지하거나 삭제해 탄력적 IP가 연결되어 있지 않으면 비용이 발생한다.</li>
</ul>
<h2 id="😥-원인은-aws-public-ipv4-요금-변경">😥 원인은 AWS Public IPv4 요금 변경</h2>
<p><code>AWS Public IPv4</code> 키워드로 검색해보니, <a href="https://aws.amazon.com/ko/blogs/korea/new-aws-public-ipv4-address-charge-public-ip-insights/">AWS 한국 블로그(링크)</a>와 <a href="https://aws.amazon.com/ko/about-aws/whats-new/2024/02/aws-free-tier-750-hours-free-public-ipv4-addresses/">AWS 공지사항(링크)</a>에서 친절하게 원인을 알려주고 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/81b99223-54e7-4c15-94c1-92724c4b887c/image.png" alt="아무튼돈이나간다는뜻"></p>
<p>주요내용은</p>
<blockquote>
<p>AWS에서 퍼블릭(Public) IPv4 주소에 대한 새로운 요금이 도입됩니다. <strong>2024년 2월 1일부터 서비스 연결 여부에 관계없이 모든 퍼블릭 IPv4 주소에 대해</strong> 시간당 IP당 0.005 USD의 요금이 부과됩니다.</p>
</blockquote>
<p>왜 이런 요금 정책 변경이 발생했는지도 설명되어 있습니다.</p>
<blockquote>
<p>IPv4 주소는 점점 더 부족해지고 있으며 퍼블릭 IPv4 주소를 하나 취득하는 데 드는 비용은 지난 5년간 300% 이상 증가했습니다. 새로운 요금 변경 사항은 <strong>자사 유지 비용을 반영하며 퍼블릭 IPv4 주소 사용을 줄이고 현대화 및 보존 조치로 IPv6 채택을 가속화</strong>하는 것을 고려하도록 권장하기 위한 것입니다.</p>
</blockquote>
<p>조금 더 제 상황에 맞게 해석하자면, Free-Tier 사양에 해당되는 EC2를 생성하고, 인스턴스에 탄력적 IP를 월 750시간 이내로 사용하는 것은 비용이 청구되지 않는다는 의미입니다.</p>
<p>그럼 저는 어느 서비스의 Public IPv4가 비용을 발생시켰던 것일까요? </p>
<h2 id="💡-이제-public-rds는-비용이-발생해요">💡 이제 Public RDS는 비용이 발생해요</h2>
<p>조금 더 구글링을 하다보니, 커뮤니티 댓글에서 단서를 찾을 수 있었습니다. 프리티어 계정에서, RDS 의 접근 방식을 Public 하게 설정하여 생성했었고, Public IPv4가 자동으로 할당된 것이 문제의 원인인 것 같다는 가능성을 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/39f2770d-876f-47dc-8591-cc84cf308f41/image.png" alt="okky커뮤니티에나같은상황이"> (출처 : <a href="https://okky.kr/questions/1488081">https://okky.kr/questions/1488081</a>)</p>
<h3 id="🔍-ipam-기능으로-public-ipv4-확인하기">🔍 IPAM 기능으로 Public IPv4 확인하기</h3>
<p><a href="https://aws.amazon.com/ko/blogs/korea/new-aws-public-ipv4-address-charge-public-ip-insights/">AWS 공식 블로그(링크)</a>을 다시 살펴보면 AWS의 요금 정책 변경과 더불어, 어느 서비스가 Public IPv4를 가지고 있는지 퍼블릭 IPv4 주소 사용을 보다 쉽게 모니터링, 분석 및 감사할 수 있도록 <code>무료</code>(중요하죠) 기능을 지원한다는 내용을 발견할 수 있습니다.</p>
<p>23년 8월에 출시된 <code>Amazon VPC IP Address Manager</code>의 새로운 기능인 <code>Public IP Insights</code> 입니다. 이 기능을 통해 현재 사용중인 퍼블릭 IP 유형과 EIP 사용량에 대해 파악할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/dadcc7b8-ec9c-42af-9f95-e5d517819ca1/image.png" alt=""></p>
<p><code>Public IP Insights</code> 기능을 사용하기 위해서는 서비스를 생성해야 합니다. 콘솔 검색창에 <code>IPAM</code>을 검색해 <code>Amazon VPC IP Address Manager</code>을 클릭하고, <code>Create IPAM</code> 을 클릭합니다. (해당 기능에 대해 더 자세히 알고 싶을 경우 <a href="https://docs.aws.amazon.com/ko_kr/vpc/latest/ipam/what-it-is-ipam.html">해당 공식 링크를</a> 참고하세요!)</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/a26b074a-1d2e-4144-b4b4-d591dbba876d/image.png" alt="IPAM생성">(이미지출처: <a href="https://docs.aws.amazon.com/ko_kr/vpc/latest/ipam/tutorials-get-started-console.html">https://docs.aws.amazon.com/ko_kr/vpc/latest/ipam/tutorials-get-started-console.html</a>)</p>
<ul>
<li>Name tag : 선택 옵션 입니다. 공란으로 두어도 무방합니다.</li>
<li>Description : 선택 옵션 입니다. 역시 공란으로 두어도 무방합니다.</li>
<li>Operating Regions : 어느 리전에 대해 IP들을 탐색할지 선택할 수 있습니다. 저의 경우 Asia Seoul 리전에서만 EC2와 RDS 등의 서비스를 생성했으니 Seoul 리전만 선택합니다.</li>
</ul>
<p>생성이 완료되고, Public IPv4들이 탐색되기까지 약 5분 정도의 시간이 소요됩니다. 탐색이 완료된 결과 화면은 아래와 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d1ffe743-e8fb-46f4-8d71-54894c264a74/image.png" alt=""></p>
<p>특히 저와 같이 EC2 생성, EIP(탄력IP) 연결, Public 접근 허용 RDS를 생성한 상황이라면 동일한 인사이트 화면을 마주하게 되실 겁니다. 위 화면처럼 총 퍼블릭 IP의 개수는 2개(Amazon 소유의 EIP 1개, 서비스 관리형 IP 1개) 그리고 연결되지 않은 EIP는 0개임을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/da29f082-0ea8-4e23-b94d-abf4e6b489f2/image.png" alt="">
(이미지 출처 : AWS)</p>
<p>조금 더 아래로 스크롤을 하면, AWS에서 제공한 참고이미지(위)와 같은 화면처럼, 각 IP 별로 어떤 유형에 해당되는지도 분석할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/89499972-11f4-4fc1-b5d6-9d3fd261726e/image.png" alt=""></p>
<p>제가 마주한 Public IP addresses 목록은 총 2개로 RDS가 사용중인 서비스 관리형 IP 1개 그리고 EIP(탄력적IP)가 나타났습니다. 이로써 IPAM 기능을 통해 확실하게 RDS의 Public IPv4가 비용을 발생했음을 알 수 있었습니다.</p>
<p>그저 검색엔진에 <code>AWS Free Tier RDS 생성 방법</code>을 검색해서 참고자료들을 따라하며 RDS를 생성했었는데, 퍼블릭 액세스를 허용했던 것이 문제였습니다. 퍼블릭 액세스를 허용할 경우 Public IP주소를 데이터베이스에 할당하기 때문입니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d2913d8f-7c70-4d1d-a52f-f8fa834e1abb/image.png" alt="퍼블릭액세스허용"></p>
<h2 id="🤗-해결-방안---private-rds-사용">🤗 해결 방안 - Private RDS 사용</h2>
<p>프로젝트 개발 단계 이전에 해당 문제를 발견한게 정말 다행이란 생각이 들었습니다. 과감하게 문제가 되는 RDS를 삭제하고, Public 접근 대신 EC2 인스턴스를 통해서 접근하도록 즉, Private 연결만 가능하도록 새로운 RDS를 생성함으로써 문제를 해결했습니다.</p>
<pre><code>다른 해결 방법

- 사용하지 않는 Public IPv4 삭제
- 서브넷의 퍼블릭 IPv4 자동 할당 비활성화
- IPv4 대신 IPv6 도입하기

참고 링크

- https://dev.classmethod.jp/articles/rate-policy-for-aws-public-ipv4-addresses-will-change-kr/
- https://blog.nuricloud.com/aws-alternatives-to-public-ipv4-charges/</code></pre><p>여기부터는 Private RDS를 생성하는 방법과 로컬 PC에서 Private RDS를 연결하는 방법에 대해 정리해보겠습니다.</p>
<h3 id="🚧-private-rds-생성">🚧 Private RDS 생성</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/11e4de42-b99e-4716-b56d-0d2ea4746d9a/image.png" alt=""></p>
<p>생성할 때 유의할 점은 <code>퍼블릭 액세스</code>를 <code>아니요</code>으로 선택해야 Public IP 주소가 할당되지 않습니다.
EC2 컴퓨팅 리소스에 연결을 선택하면 RDS가 필요한 서브넷 그룹과 VPC 보안그룹을 자동으로 추가합니다.
이 외 설정은 프리티어 한도 내에서 과금이 발생되지 않도록 적절히 선택 후 생성을 누르면 완료됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/97e20971-9f94-4862-b390-9da7129535df/image.png" alt=""></p>
<p>생성된 데이터베이스의 <code>연결된 컴퓨팅 리소스</code>에서는 EC2와 연결된 것을, <code>보안그룹 규칙</code>에서는 <code>인바운드</code>, <code>아웃바운드</code> 규칙이 적용되어 있는 것을 확인할 수 있습니다. <a href="https://devbksheen.tistory.com/entry/EC2-%ED%84%B0%EB%84%90%EB%A7%81%EC%9C%BC%EB%A1%9C-private-subnet-RDS-%EC%A0%91%EC%86%8D%ED%95%98%EA%B8%B0">참고한 링크 : EC2 터널링으로 private subnet RDS 접속하기</a></p>
<h3 id="🔗-rds와-db-tool-ssh-터널링-연동">🔗 RDS와 DB tool SSH 터널링 연동</h3>
<p>DB Tool 에서 Private 접근이 허용된 RDS에 접근하기 위해 SSH 터널링 방법을 사용했습니다. 
DBeaver, MySql Workbench, DataGrip, 인텔리제이 내장 Database Tool 등 많은 도구들이 DB 연결 시에 SSH 터널링을 지원하기 때문에 어떤걸 사용해도 무방합니다. 저는 DataGrip을 사용했습니다. 설정 순서는 아래 이미지와 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/cee49e5b-ec66-44a5-b055-eb94e6808965/image.jpg" alt=""></p>
<h4 id="🌉-ssh-tunneling">🌉 SSH Tunneling</h4>
<p>ssh 터널링이란, “출발지에서 목적지로 한 번에 이동할 수 없을 때, 목적지로 이동 가능한 지점을 거쳐서 이동하는 우회 접근 방법” 입니다. 로컬 서버에서 DB 서버에 접근하고 싶지만, RDS가 private subnet에 구축되어 있을때 외부에서는 RDS에 접속할 수 없습니다. 그렇기 때문에 DB 서버에 접근 가능한 EC2 서버를 우회해서 접근합니다. (즉, 로컬 서버 → EC2 서버 → DB 서버의 형태로 접근)</p>
<p>참고 링크 : </p>
<ul>
<li><a href="https://velog.io/@fcfargo/AWS-Private-Database-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B03-SSH-Tunneling-%EC%9C%BC%EB%A1%9C-Private-DB-%EC%A0%91%EC%86%8D%ED%95%98%EA%B8%B0-Mac">AWS - Private Database 구축하기</a></li>
<li><a href="https://velog.io/@fcfargo/AWS-Private-Subnet-Instance-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B01-VPN-Public-Subnet-Private-Subnet">VPC, Public Subnet, Private Subnet 개념 이해하기</a></li>
</ul>
<h3 id="🔗-rds와-local-실행-환경-연동">🔗 RDS와 Local 실행 환경 연동</h3>
<p>그렇다면 Datagrip과 같은 DB Tool에서 RDS에 접근하는 것이 아니라, IDE를 통해 로컬에서 서버를 실행했을 때 RDS와 연결하려면 어떻게 해야할까요? 이전 처럼 SpringBoot 프로젝트에서 Public 접근이 가능한 RDS의 경우 application 설정파일을 아래와 같이 작성했었습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/91954ca5-b1cd-4225-a222-b089fd529b59/image.png" alt="application.yml"></p>
<p>그러나, 이제는 EC2를 통해서만 RDS에 접근 가능한 Private RDS를 생성했기 때문에 아래의 명령어를 통해 터미널에서 SSH 터널링 세션을 생성해야 합니다.</p>
<blockquote>
<p>ssh -i [키 페어 파일 경로].pem -N -L [로컬 포트]:[RDS 엔드포인트]:3306 [EC2 사용자 이름]@[EC2 인스턴스 주소]</p>
</blockquote>
<p>해당 명령어는 로컬에서 SSH 프로토콜을 사용해 시스템간 연결을 생성합니다. localhost(127.0.0.1)의 3307 포트로 요청을 하면, EC2 를 거쳐 RDS로 요청이 전달됩니다</p>
<ul>
<li><code>-i</code> : 사용자가 EC2 서버에 로그인하는 데 사용하는 개인 키를 지정합니다.</li>
<li><code>-N</code> : 실제 셸 세션을 시작하지 않고 포워딩만 수행하도록 ssh에 지시합니다. RDS에 대한 접속 전용 터널을 생성/유지 합니다.</li>
<li><code>F</code> : 백그라운드로 ssh 터널을 등록합니다. 매번 다시 연결을 위한 설정할 필요 없습니다. <code>-N</code> 옵션과 달리 셸 세션을 통해 명령어를 수행할 수 있습니다. 백그라운드에서 열린 상태로 유지되면 예상치 못한 상황이 발생할 수 있을 것 같아 해당 옵션은 사용하지 않았습니다.</li>
<li><code>-L</code> : <code>로컬포트:원격서버:원격서버포트</code> 포맷으로 로컬 포워딩을 설정하기 위한 옵션 입니다.</li>
</ul>
<p>위 명령어는 Linux나 OSX기반의 터미널이 있는 OS를 대상으로 하기 때문에 Window 환경인 저는 Git Bash 터미널을 이용했습니다. PuTTY를 사용해도 무방합니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/4b7d386f-df01-41f0-b373-72a83c20e01a/image.png" alt="">(이미지출처:<a href="https://steady-coding.tistory.com/626">https://steady-coding.tistory.com/626</a>)</p>
<p>여기서 조심해야 할 점은 <strong>로컬에 설치된 DB와 동일한 Port를 사용할 경우 포트 충돌이 일어나 <code>Permission denied</code>가 발생</strong>할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/88dd17f9-c893-42bb-80b3-0439d63553d3/image.png" alt="">(이미지출처: <a href="https://velog.io/@kjyeon1101/Spring-%EC%88%98%EC%88%99%EA%B4%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0Spring-Boot-IntelliJ-MySQL-ssh">https://velog.io/@kjyeon1101/Spring-수숙관-프로젝트-실행하기Spring-Boot-IntelliJ-MySQL-ssh</a>)</p>
<p>따라서, 사용중이지 않는 PID를 찾아서 사용해야 합니다. 저의 경우 로컬에 설치된 MySQL이 3306, 33060 포트를 사용하기 때문에 3307 로컬 포트를 사용했습니다.</p>
<p>만약 SSH Tunneling 명령어를 입력했을 때, 아래와 같이 등록된적 없는 host 경고 메시지가 발생하면 yes 입력합니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/73e19cd7-05ae-4439-8dcf-04218a24886e/image.png" alt=""> (이미지 출처 : <a href="https://info-lab.tistory.com/254">https://info-lab.tistory.com/254</a>)</p>
<p>참고 링크</p>
<ul>
<li><a href="https://blueyikim.tistory.com/1792">ssh로 해당 호스트 최초 접속시 fingerprint 관련 이슈 해결</a></li>
<li><a href="https://velog.io/@kjyeon1101/Spring-%EC%88%98%EC%88%99%EA%B4%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0Spring-Boot-IntelliJ-MySQL-ssh">Spring-수숙관-프로젝트-실행하기</a></li>
</ul>
<h3 id="🔍-ipam-다시-확인해보기">🔍 IPAM 다시 확인해보기</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/b50bdce0-d183-4b88-8a89-1cd56d7dedb1/image.png" alt=""></p>
<p>문제가 된 Public 접근 허용된 RDS를 제거하고, Private 접근만 허용된 RDS를 생성하고 난 뒤, 다시 IPAM에 진입해 Public IPv4 목록을 확인하면 위와 같이 저에게 할당된 Public IPv4는 탄력적 IP 1개만 존재하도록 변경된 것을 확인할 수 있습니다.</p>
<h2 id="번외😧-public-rds를-삭제했는데도-청구서-금액이-올랐어요">(번외)😧 Public RDS를 삭제했는데도 청구서 금액이 올랐어요</h2>
<p>분명 퍼블릭 액세스를 허용한 RDS를 삭제하고, Private 접근만 허용한 RDS를 생성했으며, Public IPv4 1개도 EC2에만 연결되어 있는 것을 검토했음에도 불구하고 청구서 금액이 올라서 놀라셨나요?</p>
<p>네,, 저는 놀랐습니다 하하.. 문제가 되었던 RDS를 삭제했으니 이제 금액이 더이상 오르지 않겠지 생각했으나, 시간당 0.005 USD가 청구되는 Public IPv4의 사용량과 그에 따른 금액이 0.41 USD &rarr; 0.46 USD으로 오르는 것을 목격했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/f35f57f1-a50e-4ac1-96b6-272714f7c2de/image.PNG" alt="청구서금액이올랐다"></p>
<p>금액이 오른 이슈를 포함해 프리티어와 관련된 여러 궁금증을 해소하기 위해 AWS 지원센터에 문의글을 남겼고, 매크로 같은 형식적인 답변을 받았습니다만, 덕분에 그 안에서 이유를 파악할 수 있었습니다.</p>
<blockquote>
<p><em>AWS 프리 티어를 사용하는 동안 실수로 요금이 발생했습니다. 요금이 다시 청구되지 않도록 하려면 어떻게 해야 하나요?</em></p>
<p>과금 및 비용 관리 콘솔에서 활성 리소스의 사용량 및 요금 <strong>정보 업데이트에는 약 24시간이 걸립니다.</strong> (출처 : <a href="https://repost.aws/ko/knowledge-center/stop-future-free-tier-charges">답변에 포함된 AWS 지식센터 링크</a>)</p>
</blockquote>
<p>청구서는 실시간으로 책정된 금액이 표시되지 않는다는 것을 알게되었습니다. 실제로 EC2 생성일자, RDS 생성일자, RDS 삭제일자를 개인적으로 기록해둔 것과 비교대조 해보니 92.231 시간이 최종적으로 Public 접근을 허용한 RDS가 활성화 되어 있던 시간이었으며, 이 이후로는 더이상 금액이 증가하지 않음을 확인했습니다.</p>
<h2 id="❗-느낀-점">❗ 느낀 점</h2>
<ol>
<li><p>적은 비용이더라도 금액이 청구되는 것과 청구되지 않는 것 사이에는 커다란 심리적 차이가 존재했습니다.</p>
</li>
<li><p>인프라 구축 과정을 프로젝트 개발 단계 이전에 시작한 것이, 늦지 않게 이슈를 해결할 수 있었던 것에 도움을 주었다고 생각합니다.</p>
</li>
<li><p>공지사항, 블로그, 매크로 답변 무엇이든 <code>공식 참고 자료</code>는 시간이 걸리더라도 천천히 읽어보는 것이 좋다는 것을 또 한번 깨닫게 되었습니다. 문제 파악 방법, 원인, 해결 방법 모두 공식 참고 자료 내에 포함되어 있었습니다. </p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github Actions Database Test 도입하기]]></title>
            <link>https://velog.io/@dev_hyun/GithubActions-Database-Test-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@dev_hyun/GithubActions-Database-Test-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Sun, 21 Jan 2024 05:56:59 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가기-앞서">들어가기 앞서</h1>
<p> Rest docs가 지원하는 메소드를 사용해 API 테스트 코드를 작성하고, 테스트 수행 성공을 기반으로 Open API 3 규격으로 변환해 정적 문서를 제공 받습니다. 이를 Swagger-ui 도구를 통해 API 문서를 사용자에게 제공합니다.</p>
<p> 로컬환경에서는 API 문서에 접근하는 것에 성공하였으나, 배포 환경에서도 접근 할 수 있도록 개선하는 것이 목표였습니다.</p>
<p> 목표를 달성하기 위해 데이터베이스 연동이 필요한 테스트를 통과가 선행되야 했습니다. Runners 환경에서는 로컬에 존재하는 Test Database에 접근할 수 없으니 Github Actions Market에서 검색되는 비공식 플러그인 <code>getong/mariadb-action@v1.1</code>을 사용해 Runners 위에 MariaDB를 설치했습니다. 그러나 <code>SQLSyntaxErrorException</code> 에러가 발생하며, 테스트를 통과하지 못했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/580fa2c0-2ea7-4926-aa93-15790bdd2803/image.png" alt=""></p>
<p>이번 포스팅에서는 궁극적으로 API 문서를 배포하기 위해 거쳐간 난관 중 데이터베이스 테스트를 수행하며 맞이한 문제 해결 과정을 다루어보겠습니다.</p>
<hr>
<h1 id="데이터베이스-연동-테스트-실패-해결-과정">데이터베이스 연동 테스트 실패 해결 과정</h1>
<h2 id="1-service-container-도입">1) Service Container 도입</h2>
<p>먼저, Github Actions Flow에 도입한 <code>getong/mariadb-action@v1.1</code> 액션을 아래 세 가지 이유를 근거로 깃허브에서 공식적으로 제공하는 Service Container 으로 변경했습니다.</p>
<ol>
<li>Non-Official Market Action은 충분한 레퍼런스를 제공하지 않는다.</li>
<li>데이터베이스 설치에 필요한 설정이 Service Container에 비해 더 간편하지 않다.</li>
<li>해당 Action의 사용자가 충분히 많지 않아, 문제가 발생하였을 때 해결의 실마리를 찾기 어렵다.</li>
</ol>
<p>Github Service Container는 Actions Workflow에서 애플리케이션을 테스트 및 운영할 때 사용하는 도구를 Docker 컨테이너로 제공합니다. </p>
<pre><code>services:
      mariadb:
        image: mariadb:latest
        env:
          MARIADB_ROOT_PASSWORD: ${{ secrets.MARIADB_ROOT_PASSWORD }}
          MARIADB_DATABASE: &#39;데이터베이스 이름&#39;
          MARIADB_ROOT_HOST: &quot;%&quot;
          MARIADB_CHARACTER_SET_SERVER: &#39;utf8mb4&#39;
          MARIADB_COLLATION_SERVER: &#39;utf8mb4_unicode_ci&#39;
        ports:
          - 3306:3306
        options: --health-cmd=&quot;healthcheck.sh --connect --innodb_initialized&quot; --health-interval=10s --health-timeout=5s --health-retries=3</code></pre><p>위 설정은 <a href="https://firefart.at/post/using-mysql-service-with-github-actions/">해당 링크</a>를 참고하여 작성했습니다.</p>
<ul>
<li>image : 도커 허브의 mariadb 공식 이미지에서 지원하는 버전을 확인할 수 있습니다. <a href="https://hub.docker.com/_/mariadb">링크</a></li>
<li>env : 이미지를 시작할 때 docker 실행 명령줄에 하나 이상의 환경 변수를 전달하여 MariaDB 인스턴스의 구성을 조정할 수 있습니다. 조정할 수 있는 환경 변수의 종류는 다음 링크에서 확인할 수 있습니다. <a href="https://mariadb.com/kb/en/mariadb-server-docker-official-image-environment-variables/">링크</a></li>
<li>options : <code>--health-cmd=&quot;healthcheck.sh --connect --innodb_initialized&quot; --health-interval=10s --health-timeout=5s --health-retries=3</code> 이 명령은 테스트 중에 데이터베이스에 연결할 수 있는지 확인하며, 지정된 명령이 실패할 경우 도커가 컨테이너를 자동으로 다시 시작합니다. MariaDB를 통한 테스트를 계속하기 전에 데이터베이스가 완전히 가동되고 실행 중인지 확인하기 위해 간단한 mysqladmin 핑을 실행합니다.</li>
</ul>
<h2 id="2-sqlsyntaxerrorexception-에러-해결-과정">2) <code>SQLSyntaxErrorException</code> 에러 해결 과정</h2>
<h3 id="상세-로그-출력-설정">상세 로그 출력 설정</h3>
<p>에러의 원인 중 하나로 출력된 <code>SQLSyntaxErrorException</code>은 SQL 문법 규칙을 위반했을 때 발생합니다. 그러나 로컬환경에서 문법 규칙으로 인해 에러가 발생한 케이스가 없으므로 다른 측면에서 원인을 찾기 시작했습니다.(추후 돌이켜보니 컴퓨터는 거짓말 하지 않았습니다. 사실상 문법이 잘못되었기 때문입니다.)</p>
<p><code>java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:132</code> 오류 만으로는 정확한 에러의 원인을 찾기 어렵습니다. 특히 IDE의 도움을 받을 수 없는 환경에서는 더욱 그렇습니다. 따라서 조금 더 자세한 예외 메시지를 확인하기 위해 <code>./gradlew build</code>  명령어 뒤에 <code>-i</code> 옵션을 주도록 변경했습니다.</p>
<p>변경 후, 다시 Job을 수행하면 정말 긴 로그를 확인할 수 있습니다. 수행되는 테스트의 개수가 많을 수록 스크롤의 압박이 있지만 덕분에 원인을 찾을 수 있었습니다.</p>
<pre><code>Cannot resolve reference to bean &#39;sqlSessionTemplate&#39; while setting bean property &#39;sqlSessionTemplate&#39;; nested exception is
org.springframework.beans.factory.BeanCreationException: Error creating bean with name &#39;dataSourceScriptDatabaseInitializer&#39; defined in class path resource [org/springframework/boot/autoconfigure/sql/init/DataSourceInitializationConfiguration.class]: Invocation of init method failed; 
nested exception is org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement 
#1 of class path resource [data.sql]: INSERT INTO TUSERACCOUNT{이하 쿼리 생략}; nested exception is java.sql.SQLSyntaxErrorException: (conn=12) Table &#39;{테스트용 데이터베이스}.TUSERACCOUNT&#39; doesn&#39;t exist</code></pre><p>에러 로그를 확인해본 결과 data.sql 으로 테스트에 필요한 초기 데이터를 삽입하는 과정에서 문제가 발생했습니다. 분명 init.sql 스크립트를 사용해 테스트 데이터베이스에 테이블 생성을 위한 DDL을 실행하였음에도 테스트 데이터베이스에 <code>TUSERACCOUNT</code> 테이블이 존재하지 않습니다.</p>
<h3 id="테이블-명-대소문자-구분">테이블 명 대소문자 구분</h3>
<p>테이블을 생성했음에도 데이터를 삽입할 때 테이블이 존재하지 않는다는 모순이 발생한 이유는 MariaDB 그리고 MySQL 데이터베이스의 운영체제에 따른 테이블 명의 대소문자를 구분되는 여부가 다르기 때문이었습니다.</p>
<p>설정 키워드는 <code>lower_case_table_names</code>으로 0인 경우 테이블 생성 및 조회 시 대소문자를 구분합니다. 1인 경우 모두 소문자로 인식합니다. 이때 리눅스 계열은 0, 윈도우 계열은 1이 기본값으로 데이터베이스가 설치됩니다.</p>
<p>개발 및 테스트를 수행한 로컬환경이 윈도우여서 테이블 명의 대소문자가 혼재되어도 전혀 문제가 발생된 적 없지만, Github Actions Runners 환경은 <code>runs-on:</code> 설정 값이 <code>Ubuntu</code> 환경이어서 대소문자 구분이 문제가 되었습니다.</p>
<p>mariadb Service Container env 설정 하위에 <code>MARIADB_LOWER_CASE_TABLE_NAMES: &#39;1&#39;</code>를 추가하여 대소문자 구분을 하지 않도록 의도하였으나 설정이 적용되지 않았습니다. 때문에 SQL 쿼리가 사용되는 모든 영역을 대상으로 테이블명을 대문자로 통일하였고, 성공적으로 모든 테스트가 통과하는 것을 확인했습니다.</p>
<h2 id="3-socket-fail-to-connect-to-host-해결-과정">3) socket fail to connect to host 해결 과정</h2>
<p>Github Actions에서 데이터베이스 테스트를 수행한 이후, 실제 서비스에서 사용될 데이터베이스의 대소문자 구분을 수동 설정 하기 위해 접속을 시도하였습니다. 그러나, 다음과 같이 Cloud VM 위에 설치된 MariaDB에 외부접속 도구 DBeaver, Datagrip 등을 통해 접속에 실패했습니다. 불과 1-2주일 전에도 정상 연결되었는데도 말이죠.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/2111341f-0a0e-4b6d-afe0-92ce233ff269/image.png" alt=""></p>
<p>데이터베이스 환경은 다음과 같습니다.</p>
<blockquote>
<p>vm : 오라클 클라우드
os : Ubuntu 20.04
mariadb : 10.4 버전</p>
</blockquote>
<p>※ 데이터베이스의 설정을 변경 후에는 반드시 flush 및 재시작을 수행해야 정상적으로 반영이 됨을 유의해야 합니다.</p>
<p><strong>1) 공용 서브넷 설정 점검</strong></p>
<p>먼저, 클라우드 플랫폼에 접속해 데이터베이스 연결에 필요한 포트에 외부접속 가능하도록 설정되어있는지 점검했고, 해당 포트에 대해 모든 주소에서 접근이 허용됨을 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/ff2289e6-873b-4a99-9167-578972732774/image.png" alt=""></p>
<p><strong>2) VM 포트 개방 점검</strong></p>
<p>Virtual Machine 에서도 3306 포트가 열려 있음을 확인하기 위해 <code>netstat -nap | grep LISTEN</code> 명령어를 사용했습니다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/ed88da80-fa62-42c1-b225-4e3fee35af2d/image.png" alt=""></p>
<p><strong>3) mariadb 50-server.cnf 설정 파일 점검</strong>
<img src="https://velog.velcdn.com/images/dev_hyun/post/5931a8fd-ac8b-4330-901d-19fdb1fec60b/image.png" alt="">
<code>/etc/mysql/mariadb.conf.d</code> 경로의
<code>mariadb 50-server.cnf</code> 설정 파일을 편집기로 열어 3306 포트와, 모든 주소가 허용되었는지 확인했습니다. 127.0.0.1 또한 주석 처리 되어 있어야 합니다.</p>
<pre><code>port = 3306
bind-address = 0.0.0.0</code></pre><p>이미지 출처 : <a href="https://docs.3rdeyesys.com/database/ncloud_database_mysql_mariadb_config_my_cnf.html">링크</a></p>
<p><strong>4) user 설정 점검</strong></p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/1e5d4ee4-729d-40ee-b5f0-3e2f157d9490/image.png" alt=""></p>
<p>Github Service Container으로 생성한 MariaDB에 root 계정으로 접속할 것이기에 별도의 사용자는 생성하지 않고, root 계정으로 내부(localhost)/외부(%)에서 접속이 가능하도록 Host 설정이 올바르게 되어있는지 확인했습니다.</p>
<p><strong>5) 해결 - iptables 세팅 재설정</strong></p>
<p> 문제가 되었던 것은 iptables 세팅이었습니다. 추가해주었던 인바운드 규칙이 모두 초기화 되어 있었습니다.</p>
<p> 재시작이 필요한 패키지가 존재할 경우나 보안업데이트, 커널 업데이트가 필요할 때 <code>*** System restart required ***</code> 메시지가 출력되는데, 만약 iptables 설정을 별도의 파일로 저장하고 불러오는 과정이 없다면 restart에 의해 설정이 모두 초기화 된것이 원인이었습니다.</p>
<pre><code>sudo iptables -I INPUT 5 -i {네트워크 인터페이스 명} -p tcp --dport 3306 -m state --state NEW,ESTABLISHED -j ACCEPT</code></pre><p>네트워크 인터페이스 명은 자신이 사용하고 있는 환경에 따라 다르기 때문에 <code>ifconfig</code> 명령어를 통해 확인해 명시해야 합니다.</p>
<p>설정 후 데이터베이스 연동을 시도한 결과 정상적으로 연결되었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Restdocs+Swagger API문서 TroubleShooting (1)]]></title>
            <link>https://velog.io/@dev_hyun/RestdocsSwagger-API%EB%AC%B8%EC%84%9C-TroubleShooting1</link>
            <guid>https://velog.io/@dev_hyun/RestdocsSwagger-API%EB%AC%B8%EC%84%9C-TroubleShooting1</guid>
            <pubDate>Mon, 25 Dec 2023 06:32:34 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p><code>북마크 관리 웹</code> 프로젝트는 API 문서 생성 도구로 <code>Spring REST Docs</code>와 <code>Swagger</code> 를 결합한 형태를 선택했습니다.</p>
<p><code>openapi3</code> 으로 생성된 <code>OpenAPISpec</code>의 문서 파일(open-api-3.0.1.json)을 <code>swagger-ui</code>으로 호출하여 동적인 API 문서를 보여주기 위함입니다.</p>
<p>로컬 상에서는 정상적으로 Swagger가 json 정적파일을 호출하는데 반해, 배포 후 아래와 같은 에러가 발생했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/0c2388bf-f4ff-46b4-a5e2-40357249a39e/image.png" alt=""></p>
<blockquote>
<p>Errors
Parser error on line 15
end of the stream or a document separator is expected</p>
</blockquote>
<h2 id="정보-수집-및-원인-추론">정보 수집 및 원인 추론</h2>
<ol>
<li><p>백엔드 서버에 저장된 static 자원을 반환하기 위한 설정이 잘못되어 있을 수 있다.</p>
<ul>
<li><code>addResourceHandlers</code> 메서드의 오버라이드를 점검할 것.</li>
</ul>
</li>
<li><p>open-api-3.0.1.json 파일에 오타 또는 문법적 오류가 있다. (원인X)</p>
<ul>
<li>에러메시지를 직역하면, &quot;스트림의 끝 또는 문서 구분 기호가 예상된다&quot;.</li>
<li>그러나, 자동으로 생성되는 파일에 오타 또는 문법적 오류의 가능성은 낮을 것으로 예상.</li>
<li>API 문서의 문법적 오류를 파악하기 위해 생성된 파일을 아래와 같이 <a href="https://editor.swagger.io/">swagger 에디터 링크</a>에 붙여 넣어 점검할 수 있습니다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/f2fa8eb2-0084-42d5-906b-eb5b5aebc434/image.png" alt=""></li>
<li>참고 링크 : <a href="https://github.com/swagger-api/swagger-ui/issues/4428">Parser error: Parser error on line 57 end of the stream or a document separator is expected</a></li>
</ul>
</li>
</ol>
<ol start="3">
<li><code>openapi3</code> 으로 <code>OpenAPISpec</code> 문서를 생성하기 위한 설정 중, <code>server</code> 프로퍼티가 <code>localhost:{port}</code> 으로 시작되어 있다. (원인X)<ul>
<li><code>localhost</code>를 <code>도메인</code> 으로 변경해본 결과, API 테스트를 위한 <code>server</code>를 설정하기 위한 프로퍼티 였음을 알게됨.
<img src="https://velog.velcdn.com/images/dev_hyun/post/4d6933a1-c7fa-42b5-bbe9-cb8431abed8f/image.png" alt=""></li>
</ul>
</li>
</ol>
<h2 id="plan-b">Plan B</h2>
<p>해당 이슈를 백엔드 서버에서 해결하지 못할 경우를 대비해, 다음 두 가지 대안을 떠올렸습니다.</p>
<ol>
<li>Docker Swagger Container에서 API 문서를 반환<ul>
<li>출처 : <a href="https://velog.io/@dailyzett/API-%EA%B7%9C%EA%B2%A9%EC%84%9C-%EC%84%9C%EB%B2%84%EB%A1%9C-%EB%8F%84%EC%BB%A4-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%99%80-Swagger-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EA%B8%B0">API-규격서-서버로-도커-컨테이너와-Swagger-를-이용하기</a></li>
</ul>
</li>
<li>Github hosting을 통해 API 문서를 반환</li>
</ol>
<p>API 문서를 다른 호스팅 도구로 배포할 수 있기에 반드시 백엔드 서버에서 제공해야할 의무는 없다고 판단했습니다. 그럼에도 별도의 도구를 추가하는 것 보다 현재의 상황 즉, 백엔드 서버에서 문서를 반환하도록 해결하는 것을 최우선 목표로 설정했습니다. 만약 데드라인 내에 시도할 수 있는 모든 방법으로 해결하지 못한다면 위 대안을 고려할 계획이었습니다.</p>
<h2 id="과정-1-github-actions-db-test">과정 1) Github Actions, DB Test</h2>
<p>Github Actions 를 통해 Auto CI/CD 되는 과정을 다시 살펴보니, 아직 해결하지 못한 문제가 남아있었습니다.</p>
<pre><code>// github actions 일부

- name: Execute Gradle build
  run: ./gradlew clean build -x test</code></pre><p>RESTdocs 도구를 사용하고 있기 때문에 API 문서를 생성하기 위해서는 Test가 수행되어야 하는데, 현재는 위와 같이 테스트 과정을 제외하고 build하고 있습니다. 제외된 이유는 Test가 정상 동작하기 위해서는 DB Connection이 되어야 하지만, Github Actions에서 local에 설치된 DB에 접근할 수 없기 때문입니다. 따라서, Github Actions 에서 build 시 test 과정에서 사용될 Database가 필요했습니다.</p>
<h3 id="1-1-db-test를-위한-설정-파일-업로드">1-1) DB Test를 위한 설정 파일 업로드</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/56a95803-7751-4424-a3c6-45c7316a694e/image.png" alt=""></p>
<p>DB Test를 위해 사용되는 파일은 세 가지 입니다. Test 수행시 동작할 설정파일(application.properties), Test DB에 필요한 Table을 생성하는 sql파일(schema-mariadb-init.sql) 그리고 Test 과정에서 사용될 데이터 생성을 위해 삽입 쿼리가 저장된 sql파일(data.sql)이 해당됩니다.</p>
<p>이들은 보안상의 이유로 .gitignore 파일에 의해 저장소에 제외됩니다. 따라서 github secrets 에 base64 방식으로 인코딩하여 저장하고, github actions workflow 에서 디코딩하여 파일을 생성하도록 합니다.</p>
<h4 id="no-such-file-or-directory-오류">No such file or directory 오류</h4>
<p>위에서 언급한 세 파일을 생성하기 위해, Remote 저장소에는 존재하지 않는 <code>./src/test/resources</code> 경로를 생성하고 파일을 생성하고자 했으나 다음과 같이 <code>No such file or directory</code> 오류가 발생했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d19a4e3f-ea11-4e14-943b-ff6b2da9f848/image.png" alt=""></p>
<p>오류가 발생되는 workflow 파일을 살펴보면 <code>mkdir</code> 명령어를 통해 경로를 생성했음에도 다음번째 파일을 생성하는 시점에서는 명시한 경로를 찾지 못하고 있었습니다. <code>mkdir</code> 명령어를 통해 생성한 <code>resource</code>s 폴더가 실제 remote 저장소에는 존재하지 않기 때문에 <code>uses: actions/checkout</code> 을 수행할 경우 해당 폴더를 인식하지 못하기 때문입니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/b6b3e749-4157-43d8-9578-8828d5d5ab54/image.png" alt=""></p>
<p>따라서 세 가지 파일을 생성하는 과정을 아래와 같이 한 번의 <code>run</code>으로 통합하여 수정한 결과 정상적으로 경로 및 파일이 생성되었습니다.</p>
<pre><code>      # Create files for database test
      - run: |
          mkdir -p ./src/test/resources
          touch ./src/test/resources/application.properties
          echo -e &quot;${{secrets.APPLICATION_TEST}}&quot; | base64 --decode &gt; ./src/test/resources/application.properties
          touch ./src/test/resources/data.sql
          echo -e &quot;${{secrets.DATA_SQL}}&quot; | base64 --decode &gt; ./src/test/resources/data.sql
          touch ./src/test/resources/schema-mariadb-init.sql
          echo -e &quot;${{secrets.INIT_SQL}}&quot; | base64 --decode &gt; ./src/test/resources/schema-mariadb-init.sql
      - uses: actions/upload-artifact@v3
        with:
          name: application.properties
          path: ./src/test/resources/application.properties
          retention-days: 1
      - uses: actions/upload-artifact@v3
        with:
          name: data.sql
          path: ./src/test/resources/data.sql
          retention-days: 1
      - uses: actions/upload-artifact@v3
        with:
          name: schema-mariadb-init.sql
          path: ./src/test/resources/schema-mariadb-init.sql
          retention-days: 1</code></pre><h3 id="1-2-github-actions에서-mariadb-사용하기">1-2) Github Actions에서 MariaDB 사용하기</h3>
<p>필요한 파일은 정상적으로 업로드 하였으니 이제는 테스트를 수행할 데이터베이스가 필요합니다. Github Actions Market 에서 검색한 결과 두가지 도구가 존재했고 그 중 <code>getong/mariadb-action@v1.1</code> 을 사용했습니다.</p>
<ul>
<li>참고 링크 : <a href="https://github.com/marketplace/actions/actions-setup-mysql">Github Marketplace Actions : actions-setup-mysql</a></li>
<li>참고 링크 : <a href="https://github.com/marketplace/actions/start-mariadb">Github Marketplace Actions : Start MariaDB</a></li>
</ul>
<p>github action 은 Mysql database를 기본적으로 탑재하고 있습니다. 그러나 해당 프로젝트는 mariaDB를 사용하고 있고, 두 데이터베이스가 모두 존재할 경우 충돌이 발생될 것을 고려하여 Mysql의 동작은 중지하는 과정을 workflow에 포함하였습니다.</p>
<pre><code>      # Shutdown the Default MySQL
      - name: Shutdown Ubuntu MySQL (SUDO)
        run: sudo service mysql stop

      # MariaDB for Springboot Test - getong/mariadb-action@v1.1
      - name: Set up MariaDB
        uses: getong/mariadb-action@v1.1
        with:
          host port: 3306
          container port: 3306
          mariadb version: &#39;10.6&#39;
          mysql database: &#39;{사용할 데이터베이스 이름}&#39;
          mysql root password: ${{ secrets.MARIADB_ROOT_PASSWORD }}
      # start mariadb
      - run: sudo systemctl start mariadb.service</code></pre><p>정상적으로 MariaDB가 설치 되었음에도, db test 과정에서 아래와 같이 오류가 발생했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/2cf60fcc-44ce-4e9d-b3f6-90db3d952d4c/image.png" alt=""></p>
<p>2편에서 계속됩니다.</p>
<!--
## 과정 2) build.gradle Task 점검

참고 링크 : [REST-Docs-어딜-보시는-거죠-그건-제-잔상입니다만](https://velog.io/@ohzzi/REST-Docs-%EC%96%B4%EB%94%9C-%EB%B3%B4%EC%8B%9C%EB%8A%94-%EA%B1%B0%EC%A3%A0-%EA%B7%B8%EA%B1%B4-%EC%A0%9C-%EC%9E%94%EC%83%81%EC%9E%85%EB%8B%88%EB%8B%A4%EB%A7%8C)

 정적 자원과 `addResourceHandlers`

Local 환경에서는 재정의 하지 않아도 문제가 발생하지 않지만, JAR 파일로 배포한 이후에는 static 경로에 별도의 설정 없이 접근할 수 없습니다. 따라서 addResourceHandlers 메서드를 재정의 하여 static 자원을 반환할 수 있도록 설정이 필요합니다.

![](https://velog.velcdn.com/images/dev_hyun/post/534603ae-d6cc-498a-8541-dadc0f924830/image.PNG)

### 정보 수집

현재 Spring Server에 Swagger 설정이 어떻게 되어있는지 파악해보았다.

기본으로 지정된 문서 요청 API 대신, `path`에 설정한 `/api/docs/swagger` 으로 요청하였을 때 `/swagger-ui/index.html` 으로 접속한다.

지정하지 않았을 경우에는 `localhost:{port}/swagger-ui/index.html` 으로 접속된다.

> application.yml
```
springdoc:
  default-consumes-media-type: application/json;charset=UTF-8
  default-produces-media-type: application/json;charset=UTF-8
  swagger-ui:
    url: /docs/open-api-3.0.1.json
    path: /api/docs/swagger
```

> StaticResourceConfig.java
```
@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // Swagger UI의 JSON 파일 경로를 설정합니다.
        registry.addResourceHandler("/docs/**")
                .addResourceLocations("classpath:/static/docs/");
        // Swagger UI의 리소스 핸들러를 설정합니다.
        registry.addResourceHandler("/api/docs/swagger/**")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}
```

> build.gradle 일부
```
tasks.withType(GenerateSwaggerUI) {
    dependsOn 'openapi3'
    delete file('src/main/resources/static/docs/')
    copy {
        from "build/resources/main/static/docs"
        into "src/main/resources/static/docs/"
    }
}
```

Temp Controller
정적 파일이 정상적으로 반환되는 것을 확인했다.

## 과정 2) JAR 파일 내부에 Static 파일 넣기

정적 파일이 정상적으로 반환되기에 CI/CD 후 배포 환경에서도 정적 파일이 반환될 것이라는 예상이 빗나갔다.
로컬의 프로젝트 폴더가 VM에서 동작하는 것이 아닌 빌드 결과물 `.jar` 파일이 -->]]></description>
        </item>
        <item>
            <title><![CDATA[제 1회 싸피니티 세미나를 다녀와서]]></title>
            <link>https://velog.io/@dev_hyun/%EC%A0%9C-1%ED%9A%8C-%EC%8B%B8%ED%94%BC%EB%8B%88%ED%8B%B0-%EC%84%B8%EB%AF%B8%EB%82%98%EB%A5%BC-%EB%8B%A4%EB%85%80%EC%99%80%EC%84%9C</link>
            <guid>https://velog.io/@dev_hyun/%EC%A0%9C-1%ED%9A%8C-%EC%8B%B8%ED%94%BC%EB%8B%88%ED%8B%B0-%EC%84%B8%EB%AF%B8%EB%82%98%EB%A5%BC-%EB%8B%A4%EB%85%80%EC%99%80%EC%84%9C</guid>
            <pubDate>Sun, 26 Nov 2023 01:19:10 GMT</pubDate>
            <description><![CDATA[<h2 id="인생-첫-개발-세미나">인생 첫 개발 세미나</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/58772252-dc25-4c85-99e6-24baad759932/image.jpg" alt="">(이미지출처 : 싸피니티 제공)</p>
<p>개발과 관련된 세미나를 인생 처음으로 참가하게 되었다. 유명 교육기관의 이름으로 주최되는 것에 더하여 모 취준 채팅방의 네임드 연사님(<code>보초</code>님)이 출강하셨다. 때문에 높은 경쟁률로 나에게 기회가 오리라 생각하지 못했는데, 운좋게 막차를 타고 최종 신청이 완료되었다.</p>
<p>다른 취준생 분들의 이야기도 듣고 세미나가 다들 어땠는지 회고도 할 겸 가벼운 마음으로 <code>커피-챗☕</code>을 모집했다. 그런데 이게 웬일.. 강연 연사님이 함께해도 되는지 연락이 오셨다. 평소에도 당근 동네생활 같은 플랫폼을 통해 개발자님들과 커피챗을 나눴던 경험은 많았는데, 인기 많은 분과의 커피챗은 처음이라 더 설레었다.</p>
<p>의미있는 행사가 된 것 같아 짧은 회고와 함께 앞으로의 계획을 기록해둔다.</p>
<h2 id="싸피니티가-뭐에요">싸피니티가 뭐에요..?</h2>
<p>SSAFY와 독립적인 단체이며 수료생을 대상으로 운영되는 동문회로 수료생들의 정보, 문화, 취미 등의 교류를 이어나가는 네트워크이다. 이전까진 주요활동들이 수료생들에게만 한정되어 있었으나, SSAFY인들만의 장이 아닌 개발자들의 장으로 발전하기 위해 올해 처음 외부인에게도 세미나 참석의 기회가 열렸다. 싸피니티의 활동들과 회원제도 등 기타 정보는 아래 탐플렛을 참고하길 바란다.
<img src="https://velog.velcdn.com/images/dev_hyun/post/215b5285-204a-463c-8e62-79b70df6be80/image.jpg" alt=""></p>
<h2 id="1부-퇴근-후-해볼-만한-n가지-활동">(1부) 퇴근 후 해볼 만한 N가지 활동</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/bf1ac9a0-5905-41b7-b632-f19ff79e77f0/image.png" alt=""> (이미지 출처 : 이석재 연사님 발표자료)</p>
<p>강연을 듣기 전, 나에겐 변화가 필요했다. 9 to 6 복무 후 집에 돌아오면 녹초가 되어 침대위에 누워있기 일상이었다. 이를 개선하기 위해 퇴근 후 공부할 수 있는 환경을 조성하는 것에 관심이 많았고 다양한 대안도 고려해봤다. <code>현업에 계신 개발자는 퇴근 후 어떤 활동을 할까?</code> 그리고 <code>시도할 만한 좋은 아이디어가 있을까?</code> 알고 싶어 <code>이석재</code> 연사님의 세션에 참가했다.</p>
<p>총 10가지 분류의 활동들을 소개해주셨고, 퇴근 후 어느 하나의 활동을 하는 것 만으로도 대단한 것이라고 용기를 주셨다. 강연을 바탕으로 <code>세미나 이전에 하고 있던 것</code>, <code>앞으로 해보고 싶은 것</code> 그리고 <code>세미나 1주일 이후</code>를 기록해보았다.</p>
<blockquote>
<p>Before Seminar</p>
</blockquote>
<ul>
<li>운동 (🚨위태롭다..다음주엔 꼭 가야지)</li>
<li>글쓰기 (최근 <code>글쓰는 또라이</code> 9기에 참가하게 되었다)</li>
<li>취준(CS, PS, CV, Skills)</li>
</ul>
<blockquote>
<p>What i wanna do</p>
</blockquote>
<ul>
<li>CS 면접 스터디</li>
<li>IT 연합 동아리</li>
<li>오픈소스 컨트리뷰트</li>
</ul>
<blockquote>
<p>After 1 Week</p>
</blockquote>
<ul>
<li><del>운동(🙄따뜻해지면 다시 하는걸로...ㅎ)</del></li>
<li>글또 커뮤니티 활동 중</li>
<li>취준 : CS 면접 스터디를 위한 모집 지원서 초안, 운영 계획 작성 완료</li>
<li>IT 연합 동아리 : 12월 말 모집되는 모 동아리 지원 예정</li>
</ul>
<p>앞으로 해보고 싶은 활동에 <code>오픈소스에 기여</code>를 추가하게 되었다. &#39;오픈소스&#39; 그리고 &#39;기여&#39;라는 단어가 주는 무게감 때문에 진입장벽이 느껴지고 어디서부터 시작해야할지 감이 잡히지 않아 관심있던 활동이 아니었으나, 연사님이 주신 팁들을 활용해 시도해보고자 한다.</p>
<blockquote>
<p>✨ 오픈소스 기여 팁
    1. 평소 관심있던 라이브러리의 신규 버전 업데이트 주시
    2. 시작은 오탈자, 변수명 제안과 같이 작은 것 부터
    3. Issue, PR 작성하는 방법을 익혀보기</p>
</blockquote>
<p>그 외, 어느 한 기술분야의 Depth를 더하라는 취준 조언과 최근 재밌게 읽으신 개발 서적 <a href="https://product.kyobobook.co.kr/detail/S000202521361"><code>내 코드가 그렇게 이상한가요?</code></a>을 추천해주셨다. 비교적 최근(23.06) 출판된 번역 서적으로, 좋은 코드를 작성하기 위해 어떻게 설계되어야 하는지에 대해 다루었다.</p>
<p>연사님이 강연에 사용하신 자료는 다음 링크드인 링크에서 공개적으로 열람 가능하다. <a href="https://www.linkedin.com/feed/update/urn:li:activity:7134174191116853248/"><code>퇴근 후 해볼만한 N 가지 활동(개발자 Ver.) 발표 자료</code></a></p>
<p>해당 연사님의 또 다른 세미나가 궁금하다면 <a href="https://inflearn.com/infcon-2023/schedule/session-detail?id=765"><code>인프콘 2023 : 코프링 프로젝트 투입 일주일 전</code></a> 인프런의 2023 인프콘에서 강연하신 영상을 해당 링크에서 확인할 수 있다!</p>
<h2 id="2부-cs-지식과-연계된-be-강의">(2부) CS 지식과 연계된 BE 강의</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d4d53b18-31e0-4c76-a217-b7e28e5eb5c7/image.jpg" alt=""> (이미지출처:싸피니티제공)</p>
<p>두 번째 세션(보초님)도 시간가는줄 모르고 들었다. 기존 4시간 분량의 내용이 1시간에 압축되었다 보니 감질맛 나기도 하고, 오리지널 버전을 들은 사람들이 부럽기도 했다. </p>
<p>강연 주제는 <code>CS 지식과 연계한 Backend 강의</code>로, CS 지식이 어떻게 백엔드 개발과 연결되는지 그리고 CS를 왜 배워야 하는지를 알려주기 위해 특정 CS 지식에서 시작해 고려해볼 만한 다양한 사례들을 소개해 주셨다. 기억을 더듬어 일부 요약하자면 다음과 같다. </p>
<p><strong>강연 일부</strong></p>
<p>먼저 운영체제 과목에서 메모리 관리하는 <code>Paging</code> 기법과, 논리적 메모리를 동일한 크기로 나누는 최소 작업 단위 <code>Page</code> 와 관련된 부분을 학습했다. 그중 물리적으로 인접한 값들은 같은 페이지에 담겨 캐시의 공간 지역성 효과를 누릴 수 있다는 개념에서 사례는 시작되었다.</p>
<p> 생각해볼 수 있는 사례는 MySQL의 <code>Primary-Key</code> 이다. MySQL은 PK, Index를 모두 페이지 단위로 관리한다. 따라서 페이지에 저장되는 데이터의 크기가 작을 수록 더 많은 데이터를 하나의 페이지 단위로 처리할 수 있다. 때문에 PK의 타입을 크기가 큰 문자열이 아닌 Long 타입으로 저장하는 것이 효율적이다 라는 결론에 도달할 수 있다. 또한 PK는 레코드를 물리적으로 연속되게 저장하는 기준이 된다. 따라서 쿼리를 통해 데이터를 조회할 때 조건절에서 PK를 사용하거나 연속된 데이터를 조회할 경우 공간 지역성 효과를 받아 쿼리 성능이 높다. 읽기 성능은 뛰어난 반면 PK가 수정되거나 삽입될 경우 물리적인 재배치가 필요해 처리 성능이 낮다. 따라서 도메인의 Id와 Pk를 분리하는 것이 성능상 유리할 수 있다.</p>
<p>연사님이 강연 초반에 해주신 말씀을 복기하며 2부 회고를 마무리 하려고 한다. <strong><code>현재의 개발자는 기능 개발 뿐만 아니라 성능 개선(최적화)을 고려해야 하는 시기이며, 때문에 CS 지식은 지원자의 잠재력을 평가하기 위한 중요한 지표이다.</code></strong></p>
<h2 id="☕-커피챗과-전체-후기">☕ 커피챗과 전체 후기</h2>
<p>세미나의 두 세션도 모두 유익했는데, 커피챗 마저도 좋은 동기부여가 되어 만족스러운 하루로 마무리할 수 있었다. 미처 커피 사진을 찍지 못해 톡방에 연사님을 초대드렸던 캡처라도 기념으로 업로드한다...</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d2cee68b-9998-442c-81ed-3055f3f68457/image.jpg" alt=""></p>
<p>보초님 외에도 1부 연사님, 취준방 회원님 그리고 먼저 커피챗에 지원해주셨던 취준생 분들까지 총 여섯이 커피챗에 참여했다. 다들 말씀도 잘 하시고 개발에 대한 의지가 높으신 분들과 함께할 수 있어 감사했다.</p>
<p>커피챗에서 어떤 이야기를 하는지 궁금한 사람이 있어, 일부를 나열하자면</p>
<blockquote>
<ul>
<li>세미나 후기</li>
</ul>
</blockquote>
<ul>
<li>채용 시장 근황과 예상 🥶</li>
<li>CS 스터디 운영 팁</li>
<li>새로운 기술을 도입하는 것의 어려움</li>
</ul>
<p>위 내용들을 중심으로 대화를 나누었으며, 그 외에도 다양한 이야기가 오고갔다. 나보다 뛰어난 분들의 이야기를 들으면서 동기부여를 얻은 점이 가장 큰 수확이었다. 그리고 조만간 CS 스터디를 모집할 계획이었는데, 연사님께 과거 스터디 운영 경험담을 여쭤볼 수 있어 기뻤다.</p>
<p>알찬 내용을 강연해주신 두 연사님께, 외부인에게도 좋은 기회를 열어준 싸피니티 측에 그리고 커피챗에 함께해주신 취준생분들께 모두 감사의 인사를 전한다 😀</p>
<p>Ps. 이석재 연사님 노트북 스티커 감사합니다! 너무 예뻐요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코드트리 챌린지] 8주차 - 중급 자료구조]]></title>
            <link>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-8%EC%A3%BC%EC%B0%A8-%EC%A4%91%EA%B8%89-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-8%EC%A3%BC%EC%B0%A8-%EC%A4%91%EA%B8%89-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Wed, 25 Oct 2023 02:01:04 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가기-앞서">0) 들어가기 앞서</h2>
<p>어느덧 코드트리 블로그 챌린지의 마지막 8주차 이다. 2개월이란 시간이 순식간에 지나갔고, 코드트리에서 코테를 학습하는 행동이 주중 아침을 시작하는 루틴으로 자리잡혔다. 챌린지가 마감되어도 주차마다 진단테스트를 수행하고 저장소에 업로드하는 것을 유지해보면 의미있을 것 같다 :)</p>
<h2 id="1-실력-진단-결과">1) 실력 진단 결과</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/a6d3b4ac-e338-49ce-8c8c-211863355c5d/image.png" alt=""></p>
<p>비록 다시 700점대로 진입했으나, <code>투 포인터</code> 유형으로 추정되는 문항에서 시간초과로 풀이에 성공하지 못한 것이 아쉬웠다. (투 포인터, HashMap 두 가지 방법으로 모두 풀이 가능한 문항이었다. 아래 리뷰를 참고.)</p>
<p>테스트 결과 IM단계의 중급 자료구조를 학습하라는 진단을 받았고, 이번 마지막 주차는 해당 유형을 학습하겠다!</p>
<h2 id="2-중급-자료구조-hashmap">2) 중급 자료구조 HashMap</h2>
<p><strong>문제1</strong></p>
<ul>
<li><code>두 수의 합</code> (링크 : <a href="https://www.codetree.ai/cote/20/problems/sum-of-two-num?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/cote/20/problems/sum-of-two-num?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
<li>실력진단테스트에서 출제된 문항과 비슷한 문항이다.</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>HashMap 사용 풀이<pre><code>n, k = map(int, input().split())
arr = list(map(int, input().split()))
</code></pre></li>
</ul>
<p>dic = dict()
count = 0</p>
<p>for num in arr:
    if k - num in dic:
        count += dic[k - num]
    dic[num] = dic.get(num, 0) + 1</p>
<p>print(count)</p>
<pre><code>
**문제2**
- `원소의 합이 0` (링크 : https://www.codetree.ai/missions/8/problems/the-sum-of-the-elements-is-0?&amp;utm_source=clipboard&amp;utm_medium=text_

**리뷰**
두 가지 중점이 있었다.

1. 조합을 (A,B) (C,D) 두 쌍만 고려한 것.
2. 합이 일치하는 항목을 찾은 후, 해당 항목을 삭제하지 않는 것.

먼저, (A, C) (B, D) 등을 고려하지 않고, (A,B) (C, D) 쌍만 고려한 이유는 어떤 수열끼리 쌍을 이룰지는 중요하지 않고, 어떤 수열끼리 쌍을 이루던 네 개의 숫자의 모든 조합이 고려된다.

그리고 모든 조합을 고려하기 위해서 두 번째 중점처럼 일치하는 합 항목을 찾은 후, 해당 항목을 삭제하지 않아야 한다. 만약 삭제할 경우 동일한 합을 가진 다른 잠재적인 쌍을 고려하지 않게 되기 때문이다.

&gt; 다음 케이스를 살펴보면 잠재적인 쌍을 고려해야 함을 알 수 있다.</code></pre><p>n = 2
arr = [1, 2]
brr = [-1, -2]
crr = [1, 2]
drr = [-1, -2]</p>
<pre><code>arr, brr 에서 만들 수 있는 조합은
`(1, -1), (1, -2), (2, -1), (2, -2)` 이고, 이를 dictionary에 key(합) value(개수) 형태로 저장하면 `dic = {[0:2], [1:1], [-1:1]}` 이다.
&lt;/br&gt;
crr, brr 에서 만들 수 있는 조합도 `(1, -1), (1, -2), (2, -1), (2, -2)` 이다. 합은 0, -1, 1, 0 순서이다. crr, brr 쌍의 합 0을 먼저 찾으면 2개 이다. 이를 의미하는 쌍은 (1, -1, 1, -1) 과 (2, -2, 1, -1) 이다. 그런데 해당 항목을 삭제하게 될 경우 crr, brr 에서 만들어지는 쌍 (2, -2) 에 대해 고려할 수 없게 된다.

다음 케이스들도 유사하다.</code></pre><p>n = 2
arr = [1, 1]
brr = [-1, -1]
crr = [1, 1]
drr = [-1, -1]</p>
<p>n = 2
arr = [0, 0]
brr = [0, 0]
crr = [0, 0]
drr = [0, 0]</p>
<pre><code>
따라서 위 두 중점을 고려하여 코드를 작성하면 다음과 같다.</code></pre><p>n = int(input())
arr = list(map(int, input().split()))
brr = list(map(int, input().split()))
crr = list(map(int, input().split()))
drr = list(map(int, input().split()))    </p>
<p>dic_ab = dict()
ans = 0</p>
<p>for i in arr:
    for j in brr:
        dic_ab[i + j] = dic_ab.get(i + j, 0) + 1</p>
<p>for i in crr:
    for j in drr:
        diff = -(i + j)
        if diff in dic_ab:
            ans += dic_ab[diff]
            # del dic_ab[diff]</p>
<p>print(ans)</p>
<pre><code>

## 3) 경품 추첨

![](https://velog.velcdn.com/images/dev_hyun/post/a740c42b-87df-4b5b-8572-81dcb1d3a436/image.png)

- 블로그 챌린지 7주차 보상으로 경품 자동 응모가 되었다고 한다. (결과는 To be continue...)</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[코드트리 챌린지] 7주차 - DP Ⅱ]]></title>
            <link>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-7%EC%A3%BC%EC%B0%A8-DP-2</link>
            <guid>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-7%EC%A3%BC%EC%B0%A8-DP-2</guid>
            <pubDate>Wed, 18 Oct 2023 01:14:17 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가기-앞서">0) 들어가기 앞서</h2>
<p>지난 6주차는 <code>투 포인터</code>를 학습하였고, 많은 난관을 맞이했다. 또한 TreeSet과 같은 자료구조에 대해 알지 못해 해설을 참고하는데도 어려움이 있었다.</p>
<h2 id="1-실력-진단-결과">1) 실력 진단 결과</h2>
<p>예상대로 실력진단 결과 점수가 낮아졌다. 이전에 완료하지 못한 유형을 다시 풀이할 수 있게 되어 오히려 좋다. 학습 조언에 따라 이번 주차는 IL단계의 DP 유형 <code>아이템을 적절히 고르는 문제</code> 부터 학습한다!</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/e040438f-e0d2-4180-a4c4-4604f15b5649/image.png" alt=""></p>
<p>실력 진단 테스트에서 출제된 DP유형 문제를 복기하여 다시 풀이 해보았다.</p>
<ul>
<li>개선할 점<ol>
<li>문제를 제대로 읽지 않았다. 도착지점이 (n,1) 즉, <code>[n-1][0]</code> 이라는 것을 놓쳤다.</li>
<li>&#39;-1&#39; 을 지나지 않도록 하기 위한 방법으로 <code>if grid[i][j] == -1 : dp[i][j] = -sys.maxsize</code> 와 같이 시도하였었다.</li>
</ol>
</li>
<li>풀이<ul>
<li>현재 좌표의 값이 -1 인 경우, 현재 좌표로 지나올 수 있는 두 경로가 모두 -1 인 경우, 둘 중 하나의 경로가 -1 인 경우, 앞선 경우들이 아닌 경우(즉, -1을 지나지 않은 경우) 로 분기하여 DP를 진행했다.</li>
<li>시간복잡도는 O(n^2) 으로 풀이했다.</li>
</ul>
</li>
</ul>
<h2 id="2-dp-ⅱ">2) DP Ⅱ</h2>
<p>동전 거슬러주기, 배낭 채우기, 부분수열 구하기 등 주어진 리스트의 원소들을 적절히 선택하여 전체 해를 구하는 유형이다. 코드트리에서는 &#39;아이템을 적절히 고르는 문제&#39; 유형이다.</p>
<p>크게, (1) 주어진 원소를 중복해서 사용해도 되는 경우와 (2) 주어진 원소를 한 번만 사용해야 하는 경우로 구분된다.</p>
<p>DP배열을 0이 아닌 <code>-sys.maxsize</code> 등으로 초기화 해주는데, <code>-sys.maxsize</code> 의 경우 &#39;아직 최초의 솔루션을 찾지 못함&#39;을 뜻하는 반면, <code>0</code>의 경우 &#39;한 번 검증을 했으나 그 결과 0임&#39;을 뜻하기 때문이다.
내가 작성한 토론탭 질문글의 답변에 의하면 문제에 따라 0으로 초기화 되어, 마치 이를 시작값으로 간주해, 조건을 충족할 수 있는 상태로 취급될 수 있다고 한다. 따라서 0이 아닌 <code>-sys.maxsize</code> 등으로 초기화 하는 것이 안전하다고 결론을 내렸다.</p>
<p><strong>문제1</strong></p>
<ul>
<li><code>동전 거슬러주기</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/coin-change?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/coin-change?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>주어진 원소를 중복해서 사용해도 되는 경우에 해당된다. 이 경우, DP 배열을 정방향으로 순회하며 주어진 원소가 조건을 충족하도록 고른다(대입한다).<pre><code># 예시 코드
import sys
n, m = map(int, input().split())
coins = list(map(int, input().split()))
</code></pre></li>
</ul>
<p>dp = [sys.maxsize] * (m+1)
dp[0] = 0</p>
<p>for i in range(1, m+1):
    for coin in coins:
        if i &gt;= coin:
            dp[i] = min(dp[i], dp[i - coin] + 1)</p>
<p>print(dp[m] if dp[m] != sys.maxsize else -1)</p>
<pre><code>
**문제2**
- `부분 수열의 합이 m` (링크 : https://www.codetree.ai/missions/2/problems/the-sum-of-the-subsequences-is-m?&amp;utm_source=clipboard&amp;utm_medium=text)

**리뷰**
- 주어진 원소를 한 번만 사용해야 하는 경우에 해당된다. 이 경우, 바깥 for문을 주어진 원소를 순회하는 동안 내부 for문에서 DP 배열을 역방향으로 순회하며 주어진 원소가 조건을 충족하도록 고른다(대입한다).
- 이것이 가능한 이유는 `거꾸로 for문이 진행되어 동일한 원소를 2번 갱신하지 않기 때문이다.`</code></pre><h1 id="예시-코드">예시 코드</h1>
<p>import sys
n, m = map(int, input().split())
arr = list(map(int, input().split()))
dp = [sys.maxsize] * (m+1)
dp[0] = 0</p>
<p>for a in arr:
    for j in range(m, -1, -1):
        if j &gt;= a:
            if dp[j-a] == sys.maxsize:
                continue</p>
<pre><code>        dp[j] = min(dp[j], dp[j-a] + 1)</code></pre><p>print(dp[m] if dp[m] != sys.maxsize else -1)</p>
<pre><code></code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[코드트리 챌린지] 6주차 - 투포인터]]></title>
            <link>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-6%EC%A3%BC%EC%B0%A8-%ED%88%AC%ED%8F%AC%EC%9D%B8%ED%84%B0</link>
            <guid>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-6%EC%A3%BC%EC%B0%A8-%ED%88%AC%ED%8F%AC%EC%9D%B8%ED%84%B0</guid>
            <pubDate>Wed, 11 Oct 2023 08:30:13 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가기-앞서">0) 들어가기 앞서</h2>
<p>지난 5주차 DP유형을 원할하게 풀이하지 못해 이번주차도 동일 유형을 학습하게 될 것이라 생각했다. 그러나 예상과는 다른 결과가 나타났는데..</p>
<h2 id="1-실력-진단-결과">1) 실력 진단 결과</h2>
<p>IM단계의 TwoPointer유형을 학습하라는 진단을 받았다. 운좋게 실력진단 문항의 난이도가 쉽게 나왔던 것 같다. (지난 실력진단에서 통과하지 못했던 DP/이등변삼각형 문항이 출제되지 않았다.) 실력 진단 과정에서 Math, tuple, dictionary(key, value 해체), Counter, 순열/조합 관련된 내용을 검색하여 풀이했다. 참고했던 내용은 하단에 별도로 정리해보자!</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/d7586cd5-af71-4ba5-a336-03be4168fbee/image.png" alt=""></p>
<p><strong>리뷰</strong></p>
<ul>
<li>Math.comb()
(링크 : <a href="https://www.w3schools.com/python/ref_math_comb.asp">https://www.w3schools.com/python/ref_math_comb.asp</a>)</li>
<li>tup = (1, 2, 3) s = &#39;&#39;.join(map(str, tup)) (5주차 학습 내용 참고)</li>
<li>리스트 내 요소의 종류 및 개수 (링크 : <a href="https://bramhyun.tistory.com/55">https://bramhyun.tistory.com/55</a>)</li>
<li>딕셔너리 for loop (링크 : <a href="https://wikidocs.net/16043">https://wikidocs.net/16043</a>)</li>
</ul>
<h2 id="2-투-포인터">2) 투 포인터</h2>
<p>구간을 가리키는 시작 포인터와 끝 포인터가 한 방향으로만 전진하는 형태를 <code>투 포인터(Two Pointer)</code> 라고 한다.</p>
<p>주어진 리스트를 이중 for문으로 시간복잡도 O(n^2)에 풀이가 가능했다.</p>
<pre><code># 시작 포인터 반복
for i in range(n):
   # 끝 포인터 반복
   for j in range(i, n):</code></pre><p>이를 2개의 포인터를 움직여서 2개의 반복문을 사용해 시간복잡도 O(n)으로 해결하는 알고리즘이 <code>투 포인터</code> 이다.</p>
<pre><code># 시작 포인터 반복
for i in range(n):
   # 구간 조건을 충족하도록 j를 반복 전진
   while j범위조건 and 구간조건:
      j += 1
   if 조건을 충족한다면:
      # 일련의 처리
      break
   # 다음 시작 포인터(i+1)를 탐색하기 전에 array[i] 값은 구간에서 제외</code></pre><p><strong>문제 1</strong></p>
<ul>
<li><code>0에 가장 가까운 합</code> (링크 : <a href="https://www.codetree.ai/missions/8/problems/sum-closest-to-zero?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/8/problems/sum-closest-to-zero?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>가장 먼저 주어진 리스트를 정렬해야 한다. 오름차순으로 정렬했음을 가정해보자. 시작 인덱스가 i일 때, 끝 인덱스 j가 증가할수록(뒤쪽으로 갈수록) 합은 반드시 커진다.</li>
<li>즉, 시작 인덱스 i가 증가하면, 두 값의 합은 커지고, 종료 인덱스 j가 감소하면, 두 값의 합은 작아진다.</li>
<li>목표로 하는 것이 0에 가까워지는 것이기에, 두 값의 합이 0보다 크면 작아지게 만들기 위해 j를 감소시키고, 두 값의 합이 0보다 작으면 커지게 만들기 위해 i를 증가시켜야 한다. (참고 링크 : <a href="https://eda-ai-lab.tistory.com/434">https://eda-ai-lab.tistory.com/434</a>)</li>
<li>위 과정을 반복하며, 두 값의 합의 절댓값의 최소값을 갱신해준다. Python 코드로 구현하면 아래와 같다.<pre><code>import sys
n = int(input())
arr = list(map(int, input().split()))
arr.sort()
</code></pre></li>
</ul>
<p>abs_ans = sys.maxsize
i, j = 0, n-1</p>
<p>while i &lt; j:
    sum_value = arr[i] + arr[j]
    abs_ans = min(abs_ans, abs(sum_value))</p>
<pre><code>if sum_value &lt; 0:
    i += 1
else:
    j -= 1</code></pre><p>print(abs_ans)</p>
<pre><code>
**문제 2**
- `최소가 되는 좌표 범위의 차` (링크 : https://www.codetree.ai/missions/8/problems/the-minimum-difference-in-coordinate-range?&amp;utm_source=clipboard&amp;utm_medium=text)

**리뷰**
- 이중 for문으로 시도하였으나 N의 개수가 20,000 개를 넘어가면서 부터 시간초과가 발생하였고, Two Pointers 기법으로 해결되지 않아 풀이를 열어보았다.</code></pre><h1 id="잘못된-나의-풀이">잘못된 나의 풀이</h1>
<p>import sys
N, D = map(int, input().split())
_list = [tuple(map(int, input().split())) for _ in range(N)]  # (2 4) (4 10) (6 3) (12 15)
_list.sort()</p>
<p>min_differ = sys.maxsize</p>
<p>for start in range(len(_list)-1):
    for end in range(start+1, len(_list)):
        if D &lt;= abs(_list[start][1] - _list[end][1]):
            min_differ = min(min_differ, _list[end][0] - _list[start][0])
            break</p>
<pre><code>    if _list[end][0] - _list[start][0] &gt;= min_differ:
        break</code></pre><p>print(-1 if min_differ == sys.maxsize else min_differ)
``` </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코드트리 챌린지] 5주차 - DP]]></title>
            <link>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-5%EC%A3%BC%EC%B0%A8-DP</link>
            <guid>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-5%EC%A3%BC%EC%B0%A8-DP</guid>
            <pubDate>Wed, 04 Oct 2023 00:49:54 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가기-앞서">0) 들어가기 앞서</h2>
<p>지난 4주차에 학습하던 IL단계의 DFS/BFS유형을 완료하지 못했다. BFS의 <code>돌 잘 치우기</code> 문항부터 고전중이다. </p>
<h2 id="1-실력-진단-결과">1) 실력 진단 결과</h2>
<p>DP 문항을 접하고 테스트케이스 까진 통과하였으나 제출에서 실패하였다. (맞왜틀🤔?) 실력 진단 결과에 따라 이번 주차는 DP유형을 학습한다!</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/e5a465a6-1ef8-4552-bee8-362be1d87e70/image.png" alt=""></p>
<p><strong>리뷰</strong></p>
<ul>
<li>중복 순열<pre><code>from itertools import product
_list = product([1,2,3], repeat=3)
print(*_list)</code></pre></li>
<li>정수형 튜플을 하나의 문자열로 합쳐서 반환<pre><code>tup = (1, 2, 3)
s = &#39;&#39;.join(map(str, tup))
print(s)    # 123</code></pre></li>
</ul>
<h2 id="2-dp">2) DP</h2>
<p>지금까지의 유형중 가장 많은 해설을 열어봐야 했으며 그 때문인지 가장 집중하지 못한 유형이었다. 다음주차도 DP 유형을 학습할 것 같다.</p>
<p><strong>문제1</strong></p>
<ul>
<li><code>사각형 채우기 3</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/rectangle-fill-3?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/rectangle-fill-3?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>1X1 타일이 주어지면서 부터 <code>dp[i-1], dp[i-2]</code> 만 고려해선 안되었다.</li>
</ul>
<p><strong>문제2</strong></p>
<ul>
<li><code>정수 사각형 최솟값의 최댓값</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/maximin-path-in-square?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/maximin-path-in-square?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>0행 및 0열은 제공된 해설과 동일하게 초기화 하였다.</li>
<li>점화식을 세우는데 어려움을 겪었다. 문제를 충분히 이해하지 않고 코드를 바로 작성하는 경향이 문제였던 것 같다.</li>
</ul>
<p><strong>문제3</strong></p>
<ul>
<li><code>정수 사각형 최장 증가 수열</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/lis-on-the-integer-grid?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/lis-on-the-integer-grid?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>재귀함수를 이용해 접근했다. dx/dy 기법을 이용해 next_x, next_y 의 위치가 격자 범위 밖이거나, 이미 탐색한 위치이거나, 증가하는 수열이 아닌 경우 탐색을 중단했다. 그러나 왜 테스트케이스 에서 시간초과가 발생하는지 의문이다.</li>
<li>아래 캡처의 좌측은 나의 풀이, 우측은 타인의 풀이이다. 
<img src="https://velog.velcdn.com/images/dev_hyun/post/2a9e2312-96ff-47c4-934f-e62ef5791205/image.png" alt=""></li>
<li>다음 두 가지 의문이 생겨 토론 탭 질문을 이용해 해소하였다.<ol>
<li>테스트케이스 3번에서 시간초과가 발생하는 이유가 이미 탐색한 결과를 반환하지 않았기 때문인지<ul>
<li>나의 풀이는 visited 배열을 활용해 Memoization을 적용했으나, 모든 (i,j)에 대하여 DFS 탐색이 이루어져 이중for문 O(n^2) * DFS탐색 O(n^2) &rarr; O(n^4) 시간복잡도가 발생한다.</li>
<li>타인의 풀이는 dp 배열의 (i,j) 좌표에 이미 탐색한 값이 저장되어 있으면 탐색을 중단한다. 즉, 모든 (i,j) 좌표에 대하여 1번의 탐색만 수행된다. 따라서 이중for문 O(n^2) * DFS탐색 O(1) &rarr; O(n^2) 시간복잡도가 발생한다.</li>
</ul>
</li>
<li>23번째 라인에서 max_cnt = max(max_cnt, 1 + dfs(nx, ny)) 이미 1에서 카운팅이 시작되는데도 출력 시 1을 더해주어야 하는 이유가 무엇인지.<ul>
<li><code>시작위치에 대한 경우가 처리되어있지 않아서 그렇습니다. main의 (i, j)에서 시작했으니 해당 칸이 포함되어야 합니다 :)</code> 라는 답변을 받았다.</li>
</ul>
</li>
</ol>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코드트리 챌린지] 4주차 - DFS/BFS]]></title>
            <link>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-4%EC%A3%BC%EC%B0%A8-DFSBFS</link>
            <guid>https://velog.io/@dev_hyun/%EC%BD%94%EB%93%9C%ED%8A%B8%EB%A6%AC-%EC%B1%8C%EB%A6%B0%EC%A7%80-4%EC%A3%BC%EC%B0%A8-DFSBFS</guid>
            <pubDate>Thu, 28 Sep 2023 05:30:43 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가기-앞서">0) 들어가기 앞서</h2>
<p>지난 3주차에서는 NH 단계의 DP를 학습했다. 이전 주차들과 달리 해설을 봤던 문항들이 많았으며, 직접 코드를 구현해보지 않아 경험이 부족함을 많이 느꼈다.</p>
<h2 id="1-실력-진단-결과">1) 실력 진단 결과</h2>
<p>슬프게도 진단 점수가 하락했다. 특히 DFS 유형으로 분류되는 실력진단 문제에서 return 문을 잘못된 부분에 작성해 다음 문제로 넘어가지 못했는데 뒤늦게 알아차린 것이 가장 아쉬웠다. (DP 유형 문제를 실력진단에서 꼭 만나보고 싶었는데 다음 주차엔 꼭 풀어보고 말겠다.)</p>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/2ec706c3-dc1c-46dd-9a12-e2601bdf71b2/image.png" alt=""></p>
<p>동일하게 DFS/BFS 유형에 대해 부족하다는 결과를 받았다 😥</p>
<!-- **진단테스트 주요 학습 내용** 중복 순열 -->

<h2 id="2-4주차-학습-주요-내용">2) 4주차 학습 주요 내용</h2>
<p><img src="https://velog.velcdn.com/images/dev_hyun/post/25969a0f-cd38-4b6f-a7ce-a50aa430fd3a/image.jpg" alt="">(이미지출처:<a href="https://howtolivelikehuman.tistory.com/75">https://howtolivelikehuman.tistory.com/75</a>)</p>
<ul>
<li>그래프 탐색의 방법으로 DFS(깊이우선탐색), BFS(너비우선탐색)이 있다.</li>
<li>그래프의 정점 간 연결관계를 표현하기 위해서 반드시 위 이미지와 같이 인접행렬 또는 인접리스트로 나타내어야 한다. 그러나 문제에서 2차원배열 격자형태로 주어지고 dxdy 기법을 활용해 제한된 이동만 가능할 경우 변환과정은 불필요 하다.</li>
<li>단, DFS는 재귀함수를 BFS는 Queue를 활용해야 풀이할 수 있다.</li>
<li>한번 탐색에 성공한 좌표를 중복해서 탐색하지 않도록 visited 배열을 활용하면 시간복잡도를 줄일 수 있다.</li>
</ul>
<h3 id="dfs">DFS</h3>
<p><strong>문제 1, 2</strong></p>
<ul>
<li>두 문제 모두 격자 내에서 영역을 구분하는 문제로 동일한 유형이다.</li>
<li><code>마을 구분하기</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/seperate-village?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/seperate-village?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
<li><code>안전 지대</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/comfort-zone?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/comfort-zone?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰 1</strong></p>
<p>1) 첫 풀이와 개선점</p>
<ul>
<li>격자 내에 남아있는 영역이 존재하는지 그리고 존재한다면 좌표를 알아내기 위해 while 반목문과 2개의 함수를 사용했었다.</li>
<li>2중 for 문을 사용하는 방법으로 개선할 수 있다. 모든 좌표에 접근하되 이동 가능한 좌표인지, 방문하지 않은 좌표인지 확인한다.</li>
</ul>
<p><strong>리뷰 2</strong></p>
<p>1) 2차원 배열의 max/min 값 찾기</p>
<ul>
<li><code>map()</code> 함수를 이용하여 간편하게 찾을 수 있었다.<pre><code>max(map(max, iterable object))</code></pre></li>
</ul>
<p>2) <code>maximum recursion depth exceeded</code> 에러</p>
<pre><code>import sys
sys.setrecursionlimit(2500)</code></pre><p>3) 첫 풀이와 개선점</p>
<ul>
<li>동일한 환경(격자)을 사용하지 않기 때문에 처음 입력받은 격자를 조건이 변경될 때 마다 복사하여 사용했다. 이때 깊은 복사를 사용했다. (참고 링크 : <a href="https://black-hair.tistory.com/49">https://black-hair.tistory.com/49</a>)</li>
</ul>
<hr>
<h3 id="bfs">BFS</h3>
<p><strong>풀이 pseudo code</strong></p>
<pre><code>from collections import deque

grid 입력으로 주어지는 격자
visitied 방문 여부를 저장할 격자
answer 방문 순서를 저장할 격자
order = 1 방문 순번
que = deque()
dxs = [방향전환]
dys = [방향전환]

def possible(x, y):
    if 격자 범위 밖:
        return False
    if 이미 방문했거나, 장애물이 있는 경우:
        return False
    return True

def push(x, y):
    answer[x][y] = order
    order += 1
    visited[x][y] = True
    que.append((x,y))

def bfs():
    while que:    # que에 요소가 존재한다면 반복
        x, y = que.popleft()   # que에서 요소 pop
        for dx, dy in zip(dxs, dys):
            nx, ny = x+dx, y+dy
            if possible(nx, ny):
                push(nx, ny)

push(시작 좌표) # que에 시작지점 삽입
bfs()</code></pre><ul>
<li>answer, order 는 BFS 탐색의 탐색순서를 필요로 할 경우 사용된다.</li>
<li>방향전환/인접좌표로 이동 시 추가적인 규칙 또는 우선순위가 존재한다면 dxs/dys 배열을 다르게 정의할 수 있다.</li>
</ul>
<p><strong>문제1</strong></p>
<ul>
<li><code>K번 최댓값으로 이동하기</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/move-to-max-k-times?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/move-to-max-k-times?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰1</strong></p>
<ul>
<li><p>첫 번째 시도</p>
<ul>
<li>테스트케이스도 통과하지 못해 북마크에 추가한 문항이다. 다음 좌표로 이동하기 위한 조건을 충족하기 위한 과정이 복잡했다.</li>
<li>visited 배열에 <code>True/False</code> 대신 <code>1 ~ k번</code> 값을 저장했다.</li>
</ul>
</li>
<li><p>두 번째 시도</p>
<ul>
<li><p>각 과정을 외부함수로 분리하지 않고, k번 반복하는 for문 내부에서 주석으로 각 과정을 기술하여 구분했다. 과정이 복잡한 경우 오히려 순차적으로 작성한 코드가 가독성이 높았다.</p>
</li>
<li><p>동일한 visited 배열을 사용하는 대신 for문에서 k번에 해당하는 새로운 visited 배열을 새로 생성하였다.</p>
</li>
<li><p>함수의 반환값이 동일한 자료형일 필요가 없음을 알게되었다. 가령 아래와 같은 코드가 가능했다.</p>
<pre><code>def find_next_position(max_val):
global n, cnt

for i in range(n):
   for j in range(n):
       if visited[i][j] and grid[i][j] == max_val:
           return i, j

return False</code></pre></li>
</ul>
</li>
</ul>
<p><strong>문제2</strong></p>
<ul>
<li><code>돌 잘 치우기</code> (링크 : <a href="https://www.codetree.ai/missions/2/problems/clear-stones-well?&amp;utm_source=clipboard&amp;utm_medium=text">https://www.codetree.ai/missions/2/problems/clear-stones-well?&amp;utm_source=clipboard&amp;utm_medium=text</a>)</li>
</ul>
<p><strong>리뷰</strong></p>
<ul>
<li>시도 중<ol>
<li>각 좌표의 우선순위를 파악하기 위해, 각 좌표에서 현재 위치를 포함해 몇 번의 이동이 가능한지 기록한다. 단, 현재 위치의 돌의 유무는 관계없이 카운팅 한다.</li>
<li>돌을 제거할 수 있는 횟수가 남은 경우, 돌 좌표들 중 가장 높은 우선순위의 좌표 순으로 돌을 미리 제거해 둔다. 만약, 제거할 수 있는 횟수보다 동일 우선순위의 개수가 더 많은 경우 돌 좌표들을 새롭게 갱신한다.</li>
<li>입력으로 제공되는 좌표를 기준으로 이동 가능한 횟수를 정답 후보로 선정한다.</li>
</ol>
</li>
</ul>
<h2 id="3-어느-상황에서-사용해야-할까">3) 어느 상황에서 사용해야 할까?</h2>
<p><strong>DFS</strong></p>
<ul>
<li>격자 내에서 영역을 구분해야 하는 경우</li>
</ul>
<p><strong>BFS</strong></p>
<ul>
<li>탐색 시작 지점이 여러개인 경우 :<ul>
<li>초기 queue에 시작 지점을 모두 삽입해도, 각 칸의 방문은 최대 1번이다. 따라서 시간복잡도는 O(n**2)</li>
</ul>
</li>
<li>최단거리를 구해야 할 경우 :<ul>
<li>현재 노드에서 가까운 곳부터 찾기 때문에 경로 탐색 시 첫번째로 찾아지는 해답이 곧 최단거리이다.</li>
</ul>
</li>
</ul>
<p><strong>공통</strong></p>
<ul>
<li>모든 정점(지점)을 방문하는 것이 중요할 경우.</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>