<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hooni_.log</title>
        <link>https://velog.io/</link>
        <description>영차영차</description>
        <lastBuildDate>Sun, 07 Jul 2024 13:53:51 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hooni_.log</title>
            <url>https://images.velog.io/images/hooni_/profile/9cd536d8-c9e4-425d-8041-5bf7829de0f4/social.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hooni_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hooni_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[코프링에서의 Kotest (1)]]></title>
            <link>https://velog.io/@hooni_/%EC%BD%94%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C%EC%9D%98-Kotest-1</link>
            <guid>https://velog.io/@hooni_/%EC%BD%94%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C%EC%9D%98-Kotest-1</guid>
            <pubDate>Sun, 07 Jul 2024 13:53:51 GMT</pubDate>
            <description><![CDATA[<p>회사에서 <code>코틀린+스프링</code> 조합으로 백엔드 개발을 하고 있습니다.
코틀린을 사용하긴 하지만 Junit을 이용하여 테스트 코드를 작성하고 있습니다.
Junit 보다 Kotest가 편한 부분은 Kotest로 적용시켜보고자 이에 대해 학습해보았습니다.</p>
<h2 id="kotest란">Kotest란?</h2>
<p>코틀린에서 사용가능한 테스트 프레임워크입니다.
Kotest의 DSL을 이용하여 테스트 코드를 가독성 좋게 작성하도록 돕습니다.</p>
<pre><code class="language-kotlin">class MyTest : StringSpec({
    &quot;2 + 2 = 4 이다&quot; {
        val sum = 2 + 2
        sum shouldBe 4
    }

    &quot;숫자를 0으로 나누면 예외가 발생한다&quot; {
        shouldThrow&lt;ArithmeticException&gt; {
            val result = 1 / 0
        }
    }
})</code></pre>
<p>위의 예시와 같이 <code>shouldBe</code>, <code>shouldThrow</code> 와 <code>StringSpec</code>를 사용하여 간단하고 명료하게 테스트 코드를 작성할 수 있습니다.
그리고 Kotest는 다양한 테스트 스타일을 제공하여 개발자가 선호하는 테스트 작성 방식을 선택할 수 있게 합니다.</p>
</br>

<h2 id="설치">설치</h2>
<pre><code class="language-kotlin">    dependencies {
        testImplementation(&quot;io.kotest:kotest-runner-junit5-jvm:${Versions.KOTEST}&quot;)
        testImplementation(&quot;io.kotest:kotest-assertions-core:${Versions.KOTEST}&quot;)
        testImplementation(&quot;io.kotest.extensions:kotest-extensions-spring:${Versions.KOTEST_SPRING}&quot;)
    }</code></pre>
<p>저는 <code>build.gradle.kts</code> 파일에서 위와 같은 의존성을 추가했습니다. 마지막의 spring 관련 kotest는 스프링을 사용할 때 추가적으로 사용할 수 있습니다. 오늘 다룰 내용에서는 굳이 필요하지 않습니다.</p>
<p>gradle에 추가를 했다면 intellij 플러그인으로 Kotest를 설치해야합니다. 그렇지 않으면 테스트 실행을 할 수 없습니다.</p>
</br>

<h2 id="다양한-테스트-스타일">다양한 테스트 스타일</h2>
<p>kotest에서는 10개의 테스트 스타일을 제공하고 있습니다. </p>
<table>
<thead>
<tr>
<th>Test Style</th>
<th>Inspired By</th>
</tr>
</thead>
<tbody><tr>
<td>Fun Spec</td>
<td>ScalaTest</td>
</tr>
<tr>
<td>Describe Spec</td>
<td>Javascript frameworks and RSpec</td>
</tr>
<tr>
<td>Should Spec</td>
<td>A Kotest original</td>
</tr>
<tr>
<td>String Spec</td>
<td>A Kotest original</td>
</tr>
<tr>
<td>Behavior Spec</td>
<td>BDD frameworks</td>
</tr>
<tr>
<td>Free Spec</td>
<td>ScalaTest</td>
</tr>
<tr>
<td>Word Spec</td>
<td>ScalaTest</td>
</tr>
<tr>
<td>Feature Spec</td>
<td>Cucumber</td>
</tr>
<tr>
<td>Expect Spec</td>
<td>A Kotest original</td>
</tr>
<tr>
<td>Annotation Spec</td>
<td>JUnit</td>
</tr>
</tbody></table>
<p>간단히 몇 종류의 스타일만 알아보도록 하겠습니다.</p>
<h3 id="funspec">FunSpec</h3>
<pre><code class="language-kotlin">class MyFunSpecTest : FunSpec({
    test(&quot;2 + 2 = 4 이다&quot;) {
        val sum = 2 + 2
        sum shouldBe 4
    }

    test(&quot;숫자를 0으로 나누면 예외가 발생한다&quot;) {
        shouldThrow&lt;ArithmeticException&gt; {
            val result = 1 / 0
        }
    }
})</code></pre>
<p>StringSpec와 큰 차이가 없어보입니다. 그저 <code>test</code>라는 함수 이름을 명시하여 테스트라는 것을 한눈에 보이도록 했습니다.</p>
<h3 id="behaviorspec">BehaviorSpec</h3>
<pre><code class="language-kotlin">class MyBehaviorSpecTest : BehaviorSpec({
    given(&quot;2가 2개 주어졌을 때&quot;) {
        `when`(&quot;두 숫자를 더하면&quot;) {
            then(&quot;4가 된다&quot;) {
                val sum = 2 + 2
                sum shouldBe 4
            }
        }
    }

    given(&quot;1과 0이 주어졌을 때&quot;) {
        `when`(&quot;1을 0으로 나누면&quot;) {
            then(&quot;예외가 발생한다&quot;) {
                shouldThrow&lt;ArithmeticException&gt; {
                    val result = 1 / 0
                }
            }
        }
    }
})</code></pre>
<p>BDD 스타일의 테스트입니다. 많은 개발자들에게 익숙한 given-when-then 키워드를 사용하여 테스트 코드를 작성할 수 있습니다.
<code>when</code>의 경우 코틀린의 키워드와 겹치기 때문에 백틱을 사용하고 있습니다. 이를 보완하고자 kotest에서는 아래와 같이 대문자로도 사용할 수 있게끔 제공해줍니다.</p>
<pre><code class="language-kotlin">    Given(&quot;2가 2개 주어졌을 때&quot;) {
        When(&quot;두 숫자를 더하면&quot;) {
            Then(&quot;4가 된다&quot;) {
                val sum = 2 + 2
                sum shouldBe 4
            }
        }
    }</code></pre>
</br>

<h2 id="focus-and-bang">focus and bang</h2>
<h3 id="focus">focus</h3>
<p>kotest에서 focus를 사용하면 focus 적용된 테스트만 실행됩니다.</p>
<pre><code class="language-kotlin">class FocusExample : StringSpec({
    &quot;test 1&quot; {
     // this will be skipped
    }

    &quot;f:test 2&quot; {
     // this will be executed
    }

    &quot;test 3&quot; {
     // this will be skipped
    }
})</code></pre>
<p>예시처럼 함수 설명 앞에 <code>f:</code> 키워드를 붙이면 focus 모드가 되고, 전체 테스트를 실행시키더라도 focus 된 테스트만 실행됩니다. 특정 테스트에 집중하게 하려는 목적으로 만든 것 같은데, 유용할지는 모르겠습니다. 그냥 단축키 이용해서 테스트 하나만 실행시키면 되지 않나... 생각이 드는 기능입니다.</p>
<h3 id="bang">bang</h3>
<p>focus와 반대로 실행하지 않을 테스트를 정하는 기능입니다. focus처럼 함수 설명 앞에 <code>!</code> 만 붙이면 됩니다. 느낌표 하나만으로 해당 테스트를 실행하지 않을 수 있습니다.
Junit의 <code>@Ignore</code> 어노테이션과 비슷한 역할을 합니다. </p>
<pre><code class="language-kotlin">class BangExample : StringSpec({

  &quot;!test 1&quot; {
    // this will be ignored
  }

  &quot;test 2&quot; {
    // this will run
  }

  &quot;test 3&quot; {
    // this will run too
  }
})</code></pre>
<p>Junit에 비해 간단하게 사용할 수 있지만, 한 눈에 들어오지는 않을 것 같습니다. 실행되지 않는 테스트를 확인하고 싶을 때 확인할 수 있는 방법이 로그를 확인하는 것 말고는 없을 것 같습니다.</p>
<p>Junit을 사용할 때는 전체검색 <code>cmd+shift+f</code>로 <code>@Ignore</code>을 검색해 사용하고 있지 않는 테스트를 확인할 수 있지만, 느낌표는 코드에서 너무 많이 사용되는 키워드이기 때문에 쉽게 찾기 어려울 것 같습니다.
따라서 bang도 굳이 사용하지 않을 것 같습니다.</p>
</br>

<h2 id="isolation-modes">Isolation Modes</h2>
<p>Kotest는 여러가지 격리 모드를 지원하여 테스트 간의 상호작용을 제어할 수 있습니다. Kotest의 격리 모드는 IsolationMode enum을 통해 설정할 수 있으며, 각 모드는 테스트 실행 방식에 영향을 미칩니다.</p>
<h3 id="적용법">적용법</h3>
<p>전역 변수의 값을 변경해주는 방식과 함수 override를 사용하는 방식, 총 2가지 방법으로 적용할 수 있습니다.</p>
<h4 id="전역-변수-값-변경-방식">전역 변수 값 변경 방식</h4>
<pre><code class="language-kotlin">class MyTestClass : WordSpec({
 isolationMode = IsolationMode.SingleInstance
 // tests here
})</code></pre>
<h4 id="override-방식">override 방식</h4>
<pre><code class="language-kotlin">class MyTestClass : WordSpec() {
  override fun isolationMode() = IsolationMode.SingleInstance
  init {
    // tests here
  }
}</code></pre>
<p>override를 이용한 방식은 init 블록 내부에 테스트를 작성해야합니다. 초기화 문제 때문입니다.
만약 클래스 초기화 블록인 init 외부에 테스트를 작성하게 되면, 해당 테스트는 코틀린 객체가 초기화되기 전에 실행됩니다. 그렇다면 override가 적용되기 전에 테스트가 실행되겠죠? 따라서 init 블록 밖에서 테스트를 작성하게 되면 격리 모드가 올바르게 적용되지 않습니다.</p>
<h3 id="다양한-격리-모드">다양한 격리 모드</h3>
<ul>
<li><code>IsolationMode.InstancePerTest</code><ul>
<li>각 테스트마다 새로운 테스트 인스턴스를 생성합니다.</li>
<li>테스트 간 상태 공유가 없으며, 완전한 격리를 보장합니다.</br></li>
</ul>
</li>
<li><code>IsolationMode.InstancePerLeaf</code><ul>
<li>각 리프(leaf) 테스트마다 새로운 인스턴스를 생성합니다.</li>
<li>일반적으로 FunSpec과 같은 스타일에서 사용되며, 각 test 블록마다 인스턴스를 생성합니다.</br></li>
</ul>
</li>
<li><code>IsolationMode.SingleInstance</code><ul>
<li>테스트 클래스 전체에서 하나의 인스턴스만 생성합니다.</li>
<li>상태를 공유하는 테스트가 가능하지만, 테스트 간 상호작용이 발생할 수 있습니다.</li>
</ul>
</li>
</ul>
<h3 id="instancepertest">InstancePerTest</h3>
<p><code>InstancePerTest</code> 모드는 모든 테스트 케이스에 대해 spec 인스턴스가 생성됩니다. 즉 모든 테스트 케이스가 독립적으로 실행됩니다. 아래의 테스트 코드가 있다고 가정하겠습니다.</p>
<pre><code class="language-kotlin">class InstancePerTestExample : WordSpec() {

  override fun isolationMode(): IsolationMode = IsolationMode.InstancePerTest

  init {
    &quot;a&quot; should {
      println(&quot;Hello&quot;)
      &quot;b&quot; {
        println(&quot;From&quot;)
      }
      &quot;c&quot; {
        println(&quot;Sam&quot;)
      }
    }
  }
}</code></pre>
<p>테스트를 실행시켰을 때 결과는 아래와 같습니다.</p>
<pre><code>Hello
Hello
From
Hello
Sam</code></pre><p>처음에는 &quot;a&quot;를 위한 spec 인스턴스를 생성하여 Hello를 출력합니다.
두번째로 &quot;b&quot;를 위한 spec 인스턴스를 생성하고 부모에서의 Hello와 b 내부에서의 From을 출력합니다.
세번째로 &quot;c&quot;를 위한 spec 인스턴스를 생성하고 부모에서의 Hello와 c 내부에서의 Sam을 출력합니다.</p>
<p>성능이 아쉬울 것 같아서 완전 격리가 필요한 상황이 아니라면 굳이 사용할 것 같지 않은 모드입니다. </p>
<h3 id="instanceperleaf">InstancePerLeaf</h3>
<p><code>InstancePerLeaf</code> 모드는 모든 리프에 대해 spec 인스턴스가 생성됩니다. 이전의 <code>InstancePerTest</code>에서는 루트까지 격리했다면, <code>InstancePerLeaf</code>는 리프만 격리합니다.</p>
<pre><code class="language-kotlin">class InstancePerLeafExample : WordSpec() {

  override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf

  init {
    &quot;a&quot; should {
      println(&quot;Hello&quot;)
      &quot;b&quot; {
        println(&quot;From&quot;)
      }
      &quot;c&quot; {
        println(&quot;Sam&quot;)
      }
    }
  }
}</code></pre>
<p>같은 예시 테스트입니다.</p>
<pre><code>Hello
From
Hello
Sam</code></pre><p>해당 테스트에서는 부모 루트는 격리하지 않기 때문에,
처음에는 &quot;b&quot;를 위한 spec 인스턴스를 생성하여 Hello와 From을 출력합니다.
두번째로 &quot;c&quot;를 위한 spec 인스턴스를 생성하여 Hello와 Sam을 출력합니다.</p>
<h3 id="singleinstance">SingleInstance</h3>
<p><code>SingleInstance</code> 모드는 테스트 클래스의 인스턴스가 전체 테스트 실행 동안 한 번만 생성됩니다. 이는 테스트 클래스의 모든 테스트가 동일한 인스턴스에서 실행된다는 것을 의미합니다. 따라서 상태가 공유될 수 있고, 초기화 블록도 한 번만 실행됩니다.</p>
<pre><code class="language-kotlin">class SingleInstanceExample : WordSpec() {

  override fun isolationMode(): IsolationMode = IsolationMode.SingleInstance

  init {
    &quot;a&quot; should {
      println(&quot;Hello&quot;)
      &quot;b&quot; {
        println(&quot;From&quot;)
      }
      &quot;c&quot; {
        println(&quot;Sam&quot;)
      }
    }
  }
}</code></pre>
<pre><code>Hello
From
Sam</code></pre><p>같은 테스트를 실행시켰을 때 결과는 위와 같습니다.
&quot;a&quot;가 실행되면서 Hello를 출력하고, 그 뒤에 &quot;b&quot;가 실행되면서 From, &quot;c&quot;가 실행되면서 Sam이 출력됩니다.</p>
<p>상태가 공유되기 때문에 공통 변수를 사용한다면 문제가 생길 여지가 있습니다.</p>
<h2 id="마무리">마무리</h2>
<p>kotest의 기본 구조와 사용법에 대해 알아보았습니다. 다음에는 이를 스프링부트에서 어떻게 활용하는지에 대해 작성할 예정입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 외부 설정 파일의 optional]]></title>
            <link>https://velog.io/@hooni_/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%99%B8%EB%B6%80-%EC%84%A4%EC%A0%95-%ED%8C%8C%EC%9D%BC%EC%9D%98-optional</link>
            <guid>https://velog.io/@hooni_/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%99%B8%EB%B6%80-%EC%84%A4%EC%A0%95-%ED%8C%8C%EC%9D%BC%EC%9D%98-optional</guid>
            <pubDate>Sun, 02 Jun 2024 13:16:45 GMT</pubDate>
            <description><![CDATA[<p>최근에 스프링 설정 파일 구조를 개선하다가 위험한 상황을 마주쳤는데, 같은 실수를 반복하지 않고자 글을 적어보려 합니다.</p>
<h2 id="yml-파일-구조">yml 파일 구조</h2>
<p>프로젝트는 크게 api, application, rdb 모듈로 이루어져있습니다.
api 모듈은 application 모듈에 의존하고, application 모듈은 rdb 모듈에 의존하고 있습니다.
rdb 모듈에 데이터베이스 관련 정보를 적어놨고, 상위인 api 모듈에서 active profile을 설정할 수 있도록 구성했습니다.</p>
<p>rdb 모듈의 설정 파일은 아래와 같습니다.</p>
<pre><code class="language-yaml">server:
  shutdown: graceful
spring:
  jpa:
    open-in-view: false

---
spring:
  config:
    import:
      - optional:application-secret-rdb
    activate:
      on-profile: prod
  jpa:
    ...

---
spring:
  config:
    activate:
      on-profile: local
  datasource:
    url: jdbc:mysql://localhost:3307/nottodo?allowPublicKeyRetrieval=true&amp;characterEncoding=UTF-8&amp;serverTimezone=Asia/Seoul
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    ...

---
spring:
  config:
    activate:
      on-profile: test
  datasource:
    hikari:
      driver-class-name: org.h2.Driver
      jdbc-url: jdbc:h2:mem://localhost/~/nottodo;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;DB_CLOSE_ON_EXIT=false;TIME ZONE=Asia/Seoul
    username: sa
    password:
  jpa:
    ...
</code></pre>
<p>prod 환경인 경우 github에 데이터베이스 정보가 유출되면 안되기 때문에 <a href="https://velog.io/@hooni_/git-submodule">서브 모듈</a>을 두어 prod profile 인 경우에만 데이터베이스 정보를 받을 수 있도록 설정했습니다.</p>
<h2 id="optional">optional</h2>
<p>yml 파일을 작성하면서 optional 이라는 것을 처음 사용해보았는데요.
optional은 말그대로 있으면 사용하고, 없으면 사용하지 않는다는 뜻입니다.
위에 작성한 yml 파일을 예로들면 <code>applicaiton-secret-rdb.yml</code> 파일이 있으면 해당 환경변수 파일을 import 하고, 없는 경우 import 하지 않습니다.</p>
<h3 id="위험점">위험점</h3>
<p>그런데, 이 기능을 설정하면서 prod의 데이터베이스를 초기화시켰습니다..
개발할때는 active profile을 local로 설정했는데 어떻게 날릴 수 있었을까요?</p>
<p>환경 설정 파일의 우선순위 때문입니다.
optional의 경우 있으면 해당 환경 설정 파일을 사용한다 정도가 아니라 해당 환경 설정으로 덮어씌웁니다. active profile에 상관없이 <code>application-secret-rdb.yml</code> 파일이 존재하면 해당 파일을 import하여 기존 datasource를 덮어씌웁니다.
active profile의 우선순위가 optional의 우선순위보다 낮다는 뜻입니다.</p>
<p>첫 실행을 했을 때 &quot;왜 local DB가 안보이지? 분명 잘 설정했는데...&quot;. 원래는 <code>ddl-auto</code> 옵션을 <code>update</code>로 설정하는데, 잘 되지 않아 local profile의 <code>ddl-auto</code> 옵션을 <code>create</code>로 바꾸어보았습니다. 그렇게 prod DB의 데이터가 초기화되었고 복구하는데 고생했습니다.</p>
<h3 id="문제-해결">문제 해결</h3>
<p>처음에 제가 optional을 사용한 의도는 <code>application-secret-rdb.yml</code> 파일이 없어도 FileNotFoundException과 같은 오류를 내지 않기 위해서입니다. 동료 개발자가 prod의 데이터베이스 정보를 알지 않아도 개발에 지장없게끔 하고 싶었습니다.</p>
<p>기존에는 어플리케이션 실행 시에 gradle에서 서브모듈 파일을 복사하고, 복사본을 읽을 수 있도록 했습니다. 이렇게 하는 경우 실행할 때마다 서브모듈의 파일이 계속 복사되기 때문에 yml의 optional 옵션에 걸리게 되고, local profile 이더라도 prod db를 읽어오는 참사가 일어납니다. 
저는 prod profile 인 경우에는 서브모듈의 파일을 복사하고, 빌드하고 나선 복사본을 제거하는 간단한 방법으로 문제를 해결했습니다.</p>
<p>gradle 코드는 아래와 같습니다.</p>
<pre><code class="language-kotlin">val copyYml = tasks.register&lt;Copy&gt;(&quot;copyYml&quot;) {
    from(&quot;../../server-secret/application-secret-rdb.yml&quot;)
    into(&quot;src/main/resources/&quot;)

    doLast {
        val file = file(&quot;src/main/resources/application-secret-rdb.yml&quot;)
        if (file.exists()) {
            file.delete()
        }
    }
}

tasks.processResources {
    if (project.hasProperty(&quot;spring.profiles.active&quot;) &amp;&amp; project.property(&quot;spring.profiles.active&quot;) == &quot;prod&quot;) {
        dependsOn(copyYml)
    }
}</code></pre>
<h2 id="마무리">마무리</h2>
<p>반년만에 글을 써봤습니다.
첫 시작이 어렵다는데, 일단 어려운 고비 다시 넘겼으니 계속 글을 남겨보겠습니다.
짧은 글이지만 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 @Transactional에서의 proxy]]></title>
            <link>https://velog.io/@hooni_/%EC%8A%A4%ED%94%84%EB%A7%81-Transactional</link>
            <guid>https://velog.io/@hooni_/%EC%8A%A4%ED%94%84%EB%A7%81-Transactional</guid>
            <pubDate>Mon, 27 Nov 2023 08:09:21 GMT</pubDate>
            <description><![CDATA[<p>데이터베이스 트랜잭션만 신경쓰고, 어플리케이션에서 트랜잭션은 깊게 생각해보지 못했습니다.
최근 큰 코를 다쳐 잘못 알고있었던 개념에 대해 정리해보고자 합니다.</p>
<h2 id="직접-트랜잭션">직접 트랜잭션</h2>
<pre><code class="language-java">    public void transaction() throws SQLException {
        Connection connection = null;
        try {
            connection = dataSource.getConnection();
            connection.setAutoCommit(false);
            PreparedStatement preparedStatement = connection.prepareStatement(
                    &quot;INSERT INTO item (name, price, stock_quantity) VALUES (?, ?, ?)&quot;);
            preparedStatement.setString(1, &quot;트랜잭션 상품&quot;);
            preparedStatement.setInt(2, 1000);
            preparedStatement.setInt(3, 1);
            preparedStatement.executeUpdate();
            connection.commit();
        } catch (SQLException e) {
            if (connection != null) {
                connection.rollback();
            }
        } finally {
            if (connection != null) {
                connection.close();
            }
        }
    }</code></pre>
<p>위 코드와 같이 <code>connection.setAutoCommit(false);</code>를 설정하면 자동 커밋 옵션이 해제됩니다.
이렇게 <code>item</code>을 생성하는 것에 대해 직접 트랜잭션을 적용하게 되면, rollback과 commit을 직접 설정할 수 있습니다.</p>
<h3 id="문제점">문제점</h3>
<p>하지만 만약 하나의 트랜잭션이 조금 커지면 어떻게 될까요?
코드가 길어질거고, transaction 설정 부분을 service 계층에 두어야하는 상황이 발생합니다.
service 부분까지 <code>dataSource</code>가 침입하게 되죠.
(비즈니스 로직 + 트랜잭션 로직) 두 로직을 함께 처리해야하기 때문에 복잡해집니다.</p>
</br>

<h2 id="선언적-트랜잭션">선언적 트랜잭션</h2>
<p>어노테이션을 이용하여 직접 트랜잭션을 적용하는 방식입니다.</p>
<pre><code class="language-java">    @Transactional
    public Long declarativeTransaction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        final Item item = itemRepository.save(new Item(&quot;트랜잭션 상품&quot;, 1000, 1));
        return item.getId();
    }</code></pre>
<p>어노테이션 만으로 트랜잭션이 동작합니다. 롤백과 커밋 모두 어노테이션 하나로 동작할 수 있습니다.
그렇다면 스프링은 어떻게 어노테이션 만으로 트랜잭션 동작이 가능하게 한 것일까요?</p>
<h3 id="transactional-동작-원리">@Transactional 동작 원리</h3>
<p><code>@Transactional</code>은 Spring AOP를 통해 프록시 객체를 생성하여 사용됩니다.
프록시 객체를 통해 트랜잭션의 로직을 처리하고, 비즈니스 로직은 target 객체에서 처리하는 방법을 선택했습니다.
스프링 <code>@Transactional</code>은 2가지 방법으로 프록시 패턴 사용했는데요. 각각 살펴보도록 하겠습니다.</p>
<h4 id="jdk-dynamic-프록시">JDK dynamic 프록시</h4>
<p>JDK dynamic 프록시는 인터페이스를 기반으로 작동합니다. 이 방식에서는 <code>InvocationHandler</code>를 사용하여 메소드 호출을 가로챕니다. <code>@Transactional</code>이 붙은 메소드 호출이 프록시를 통해 이루어질 때, <code>InvocationHandler</code>의 <code>invoke()</code> 메소드를 통해 트랜잭션 시작, 커밋, 롤백이 실행됩니다.</p>
<pre><code class="language-java">public interface ProductService {
    void perform();
}</code></pre>
<pre><code class="language-java">public class ProductServiceImpl implements ProductService {
    @Transactional
    @Override
    public void perform() {
        System.out.println(&quot;Performing a transactional action&quot;);
        // 여기에 비즈니스 로직 구현
    }
}</code></pre>
<p>인터페이스 기반으로 동작한다고 했는데, 이는 반드시 필요합니다. 비즈니스 로직을 처리하는 Service 객체에 인터페이스가 존재해야 합니다.
인터페이스를 두고, target 객체를 구현체로 만들었습니다. target에는 비즈니스 로직을 작성했다고 가정하겠습니다.</p>
<p>스프링 어플리케이션이 시작되고 스프링 컨텍스트가 로드될 때, 스프링은 <code>@Transactional</code>이 붙은 어노테이션이 적용된 클래스 또는 인터페이스를 찾아 프록시 객체를 생성하는 작업을 수행합니다.
이 때 리플렉션 API의 Proxy 클래스를 사용하여 인터페이스를 구현하는 프록시 객체가 생성됩니다.
생성된 프록시 객체는 원본 객체의 메소드 호출을 가로채고, 필요한 추가적인 처리를 수행한 후 실제 메소드를 호출합니다. 이 호출을 가로챌 때 <code>InvocationHandler</code>가 사용된다고 했는데, 어떤 원리로 동작하는지 자세히 살펴보겠습니다.</p>
<pre><code class="language-java">public class TransactionalInvocationHandler implements InvocationHandler {
    private final Object target;

    public TransactionalInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getName().startsWith(&quot;perform&quot;)) {
            beginTransaction();
            try {
                Object result = method.invoke(target, args);
                commitTransaction();
                return result;
            } catch (Exception e) {
                rollbackTransaction();
                throw e;
            }
        } else {
            return method.invoke(target, args);
        }
    }

    private void beginTransaction() {
        System.out.println(&quot;Transaction started&quot;);
        // 트랜잭션 시작 로직
    }

    private void commitTransaction() {
        System.out.println(&quot;Transaction committed&quot;);
        // 트랜잭션 커밋 로직
    }

    private void rollbackTransaction() {
        System.out.println(&quot;Transaction rolled back&quot;);
        // 트랜잭션 롤백 로직
    }
}</code></pre>
<p><code>TransactionalInvocationHandler</code>를 만들어서 <code>invoke()</code>메소드에 트랜잭션 로직을 모두 작성했습니다. 실제 코드는 훨씬 복잡하지만 간단하게만 보았을 때 위 코드와 같습니다.
동작 순서를 살펴보자면,</p>
<ol>
<li>컨트롤러에서 <code>ProductService</code>의 <code>perform()</code> 메소드를 호출하면, 이 호출은 실제로 <code>ProductService</code>의 프록시 객체를 통해 메소드 호출이 이루어집니다.</li>
<li>프록시 객체에서 <code>invoke()</code> 메소드를 호출하여 트랜잭션 관리 로직을 실행한 후, 프록시는 원본 <code>ProductServiceImpl</code> 클래스의 <code>perform()</code> 메소드를 호출합니다.</li>
<li><code>perform()</code> 메소드의 실행이 완료되면, 그 결과는 <code>invoke()</code> 메소드를 통해 호출자에게 반환됩니다. 메소드 실행 중 예외가 발생하면, 이는 <code>invoke()</code> 메소드에서 상황에 따라 트랜잭션을 롤백할 수 있습니다.</li>
</ol>
<p>JDK dynamic 프록시 방식의 경우 리플랙션을 활용하여 메소드를 호출하기 때문에 성능이 조금 아쉬울 수 있습니다. 하지만, 성능 차이가 미미하기 때문에 신경쓸 정도가 아니라고 하네요!</p>
</br>

<h4 id="cglib-프록시">CGLIB 프록시</h4>
<p>JDK dynamic 프록시 방식의 경우 interface가 필수적으로 필요합니다. interface가 없는 클래스에 <code>@Transactional</code>을 사용하는 경우 CGLIB 프록시 방식이 사용됩니다. CGLIB는 클래스를 상속하여 프록시 객체를 생성하는 방식을 사용합니다. 코드로 살펴보겠습니다.</p>
<pre><code class="language-java">public class ProductService {
    public void perform() {
        System.out.println(&quot;Performing a business logic&quot;);
        // 여기에 비즈니스 로직 구현
    }
}</code></pre>
<pre><code class="language-java">public class ProductServiceProxyFactory {
    public static ProductService createProxy() {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ProductService.class);
        enhancer.setCallback(new MethodInterceptor() {
            @Override
            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                // 트랜잭션 시작 로직
                System.out.println(&quot;Transaction started&quot;);
                Object result = null;
                try {
                    result = proxy.invokeSuper(obj, args);
                    // 트랜잭션 커밋 로직
                    System.out.println(&quot;Transaction committed&quot;);
                } catch (Exception e) {
                    // 트랜잭션 롤백 로직
                    System.out.println(&quot;Transaction rolled back&quot;);
                    throw e;
                }
                return result;
            }
        });
        return (ProductService) enhancer.create();
    }
}</code></pre>
<p>JDK dynamic 프록시 방식과 크게 다르지 않습니다. <code>perform()</code> 메소드가 호출되면 <code>intercept()</code>가 먼저 호출된 후에 내부에서 <code>invokeSuper()</code> 메소드를 통해 <code>perform()</code>이 호출됩니다. JDK dynamic 프록시는 리플랙션 방식을 선택한 반면에, CGLIB는 바이트 코드를 조작하는 방식을 선택했습니다.</p>
<p>CGLIB는 런타임에 클래스의 바이트코드를 조작하여 프록시 클래스를 생성합니다. 이 과정은 일반적인 JDK dynamic 프록시보다 더 복잡하고, 초기 프록시 생성 시에 약간 더 많은 리소스를 사용할 수 있습니다. 하지만 생성이 된 후에 실행 속도는 JDK dynamic 프록시보다 빠르다는 장점이 있습니다.</p>
<p>스프링은 기본 설정인 경우 인터페이스가 존재하지 않는 경우에만 CGLIB를 채택하고 있습니다. 만약 설정파일에 <code>proxy-target-class</code> 속성을 true로 둔다면 클래스 기반으로 프록시를 생성합니다. 즉, 인터페이스가 존재하더라도 CGLIB 방식을 채택합니다.</p>
<p>그렇다면 왜 스프링은 default로 JDK dynamic 프록시 방식을 선택했을까요?
CGLIB에는 아래와 같은 단점이 있기 때문입니다.</p>
<ul>
<li><p>클래스의 제약: CGLIB는 final 클래스나 메소드에 대해서는 프록시를 생성할 수 없습니다. 이는 final 클래스나 메소드가 상속 또는 오버라이딩되지 않기 때문입니다.</p>
</li>
<li><p>복잡성: CGLIB는 JDK dynamic 프록시보다 복잡하게 구현되어 있고, 더 무겁습니다.</p>
</li>
</ul>
</br>

<h3 id="마무리">마무리</h3>
<p>면접에서 transactional과 spring AOP 관련해서 깊이 있는 질문을 받았습니다. 제대로 대답하지 못해 아쉬워 정리해보았습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[B-Tree와 B+Tree]]></title>
            <link>https://velog.io/@hooni_/B-Tree%EC%99%80-BTree</link>
            <guid>https://velog.io/@hooni_/B-Tree%EC%99%80-BTree</guid>
            <pubDate>Wed, 01 Nov 2023 08:56:58 GMT</pubDate>
            <description><![CDATA[<p>이전에 <a href="https://velog.io/@hooni_/MySQL-Index">index 관련된 글</a>을 올렸습니다.
B-Tree 방식으로 index가 구성되어있다는 것만 알고 깊게 보진 않았는데요.
오늘은 B-Tree에 대해 자세히 살펴보고자 합니다.</p>
<br>

<h2 id="b-tree-란">B-Tree 란?</h2>
<p>B-Tree는 균형 이진 트리(Balanced Binary Tree)의 일종으로, 데이터베이스 시스템과 파일 시스템에서 널리 사용되는 자료구조입니다. 
대량의 데이터를 효율적으로 관리하고 검색할 수 있게끔 설계되어있습니다.</p>
<h3 id="특징">특징</h3>
<p>B-Tree에서 지켜야하는 몇 가지 특징이 있는데요. 아래와 같습니다. </p>
<ol>
<li><strong>균형 트리</strong>: B-Tree의 모든 Leaf 노드는 동일한 깊이를 가지며, 트리가 변경될 때마다 이 균형이 유지됩니다.</li>
<li><strong>다입도</strong>: B-Tree의 각 노드는 여러 개의 자식을 가질 수 있습니다. 이때 노드당 가질 수 있는 최대 자식의 수를 차수(order)라고 합니다. 차수를 <code>m</code>이라고 가정할 때, 최대 <code>m</code>개의 자식을 가질 수 있기 때문에 노드는 최대 2개의 키를 저장할 수 있습니다. </li>
<li><strong>키 정렬</strong>: 각 노드에 저장된 키는 정렬된 상태로 유지됩니다.</li>
<li><strong>분할 및 병합</strong>: 노드의 키가 너무 많아지면 노드가 분할되고, 반대로 키가 너무 적어지면 노드가 인접한 형제 노드와 병합됩니다.</li>
<li><strong>최소 사용률</strong>: B-Tree는 각 노드가 최소한 차수의 절반만큼 키를 가지도록 유지시킵니다. 만약 절반으로 나누어떨어지지 않다면 올림합니다.</li>
</ol>
<h3 id="목적">목적</h3>
<p>B-Tree의 주요 목적은 디스크 I/O 작업을 최소화하면서 대량의 데이터를 저장하고 검색하는 것입니다.
저는 그래서 메모리에 인덱스 테이블을 올려두고 작업하는줄 알았는데요. 그것이 아니었습니다. 일부의 index만 메모리에 올려둔다고 하네요.</p>
<p>디스크에서 데이터를 읽거나 쓸 때, 보통 한 번의 I/O 작업으로 디스크 내의 한 블록의 데이터를 읽거나 씁니다. B-Tree는 이러한 블록 단위의 I/O를 최적화하기 위해 설계되었다고 하는데요. 이때 노드마다 하나의 블록에 저장되는 방식을 이용하여 디스크 I/O를 최소한으로 줄입니다.</p>
<p>이진 트리를 이용하게 되면 각 노드는 하나의 키 밖에 가지지 못하기 때문에 공간 비효율적이게 됩니다. 
B-Tree는 이런 문제점을 개선하고자 이진 트리를 확장하여 N개의 자식을 가질 수 있도록 고안되었습니다. 이때 각 노드는 넣을 수 있는 양 만큼의 키를 저장하여 공간 효율적으로 디스크를 관리합니다. </p>
<br>

<h2 id="동작-원리">동작 원리</h2>
<p>차수(order)를 3이라고 가정하고 B-Tree를 차곡차곡 쌓아보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/1747e4ec-e527-45c5-a193-f030d301fd60/image.png" alt=""></p>
<ol>
<li>키가 1과 20인 것이 tree에 있었다고 가정합니다.</li>
<li>2를 추가하는 요청이 들어왔습니다. 2는 1보다 크고, 20보다 작기 때문에 1과 20 사이에 저장됩니다.</li>
<li>2가 노드에 추가되었습니다. 그런데 노드에 2개의 키 밖에 저장하지 못하기 때문에 새로운 노드를 만들어야합니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hooni_/post/56076060-405d-4e71-9ddd-3c2bf82f9487/image.png" alt=""></p>
<ol start="4">
<li>먼저 20을 떼어내서 새로운 노드를 만듭니다. 그리고 가운데 키 값이었던 2는 부모 노드로 만들어 승급시킵니다. 이렇게 노드가 2개 추가되었습니다.</li>
<li>이번에는 10이 요청으로 들어왔습니다. 부모 노드에서 10이 2보다 큰 지 비교합니다. 2보다 크기 때문에 오른쪽 자식 노드로 이동합니다.</li>
<li>자식 노드(leaf 노드)에 있던 20과 비교했을 때, 10은 20보다 작기 때문에 10을 20보다 앞에 둡니다. 이렇게 자식 노드가 정렬됩니다. 만약 중간 branch 노드였다면 자식 노드를 더 탐색해야했지만, leaf 노드이기 때문에 key를 추가했습니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hooni_/post/5a831715-ce9f-45cb-9ce1-e590716a2d4f/image.png" alt=""></p>
<ol start="7">
<li>30이 추가되는 요청이 들어왔습니다.</li>
<li>leaf 노드까지는 잘 도착했지만, leaf 노드의 수용 공간이 다 찼습니다. 따라서 새로운 공간을 만들어주었습니다.</li>
<li>새로운 leaf 노드를 만들었고, 20을 루트 노드로 승격시켰습니다. 이렇게 2보다 작으면 왼쪽 자식 노드, 2보다 크고 20보다 작으면 중간 자식 노드, 20보다 크면 오른쪽 자식 노드로 이동할 수 있게 했습니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hooni_/post/c5044e0a-c590-4881-92c5-eab3ddc1f922/image.png" alt=""></p>
<ol start="10">
<li>40이 추가되는 요청이 들어왔습니다.</li>
<li>노드의 추가 없이 40이 오른쪽 자식 노드에 추가되었습니다.</li>
<li>25가 추가되는 요청이 들어왔습니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hooni_/post/47280ef3-bc82-465f-9b88-2a0a69b4da6b/image.png" alt=""></p>
<ol start="13">
<li>20보다 크기때문에 오른쪽 자식 노드로 25를 위치시킵니다. 하지만 자식 노드의 키 개수가 3개가 되었습니다.</li>
<li>오른쪽 자식 노드에서 가장 큰 값인 40을 새로운 노드로 만들어 분리했습니다.</li>
<li>그리고 30을 부모 노드로 승격시켰습니다. 이때도 문제가 발생합니다. 노드의 키 개수가 3이 되기때문에 부모 노드에서도 노드를 분리합니다.</li>
<li>자식 노드와 같은 과정을 거치면 그림처럼 새로운 부모 노드가 생성됩니다.</li>
</ol>
<p>위와 같은 로직으로 B-Tree의 키가 저장됩니다. 별 것 아닌 규칙들 같지만, 자연스레 모든 leaf 노드가 같은 레벨에 있는 것을 알 수 있습니다.
이렇게 B-Tree는 모든 검색에 대해 <code>logN</code>의 시간 복잡도를 보장하고 있습니다.</p>
<br>

<h2 id="b-tree-조회-방식">B-Tree 조회 방식</h2>
<p>B-Tree 의 경우에는 모든 key 값이 pk 값이나 DB 레코드 포인터를 가지고 있습니다.
만약 Primary Key Index 라면 <code>(pk, 레코드 포인터)</code> 쌍을 노드에 저장하고 있고, Secondary Index 의 경우에는 <code>(칼럼 값, pk)</code> 쌍을 노드에 저장하고 있습니다.
중요한 건, 부모 노드, 브랜치 노드, 리프 노드 상관 없이 모든 노드의 key가 pk 값이나 레코드 포인터를 저장하고 있다는 것입니다.
<img src="https://velog.velcdn.com/images/hooni_/post/0b65fe32-5145-4494-bf6e-d0269cecbb1c/image.png" alt=""></p>
<br>

<h2 id="btree">B+Tree</h2>
<p>지금까지 B-Tree에 대해 살펴보았는데요. 실제로 mysql은 B-Tree의 개념을 이용한 B+Tree를 사용하여 index 테이블을 구성한다고 합니다.
B+Tree는 B-Tree와 어떤 부분이 다를까요?</p>
<ol>
<li>B+Tree에서 모든 키 값들은 leaf 노드에만 저장되며, 브랜치 노드나 루트 노드는 leaf 노드들로의 경로를 가리키는 용도로 사용됩니다. 루트와 브랜치 노드들에는 키 값만 있고, 실제 레코드에 대한 포인터는 leaf 노드에만 존재합니다.</li>
<li>leaf 노드들이 양방향 혹은 단방향 연결 리스트로 서로 연결되어 있습니다. 덕분에 범위 검색이 더 쉽고 효율적입니다.</li>
<li>키가 leaf 노드에만 존재하므로, 루트와 브랜치 노드에 더 많은 키를 한 페이지에 저장할 수 있어 디스크 I/O를 줄이는 데 도움이 됩니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hooni_/post/7695c563-9b6c-4775-8361-240153ade2b1/image.png" alt=""></p>
<p>위 그림과 같이 leaf 노드에만 레코드 포인터를 저장하고 있습니다. 그리고 linked list 형태로 leaf 노드 간의 범위 검색이 가능하도록 설계했습니다. (mysql은 더블 링크드 리스트라고 합니다.)</p>
<br>

<h2 id="마무리">마무리</h2>
<p>index가 어떤 방식으로 데이터베이스 레코드 포인터를 검색하는지 알았습니다. 다음에는 mysql 만의 index를 효율적으로 사용하는 방식에 대해 알아보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DBCP와 HikariCP]]></title>
            <link>https://velog.io/@hooni_/DBCP%EC%99%80-HikariCP</link>
            <guid>https://velog.io/@hooni_/DBCP%EC%99%80-HikariCP</guid>
            <pubDate>Mon, 16 Oct 2023 16:26:21 GMT</pubDate>
            <description><![CDATA[<p>어플리케이션 서버의 성능을 높이려는 노력은 어느 코드 레벨에서나 있습니다. 코드레벨, DB레벨 등 다양하죠.
요즘 DB 관련해서 공부를 하고 있는데, 오늘은 데이터베이스 커넥션 관련해서 성능을 높여주는 데이터베이스 커넥션 풀에 관해 이야기해보려 합니다.
그리고 Spring에서 채택하고 있는 HikariCP에 관해서도 살펴보겠습니다.</p>
<h2 id="database-connection-pool">Database Connection Pool?</h2>
<p>DBCP(Database Connection Pool)은 데이터베이스 연결을 관리하고 <strong>효율적으로 재사용</strong>하기 위한 자바 라이브러리 또는 프레임워크입니다. DBCP는 데이터베이스와의 연결 생성 및 관리를 단순화하며, 다수의 클라이언트 또는 스레드에서 동시에 데이터베이스 연결을 사용할 때 발생할 수 있는 리소스 부족 문제를 방지하는 데 도움을 줍니다.</p>
<h3 id="동작-방식">동작 방식</h3>
<p>어플리케이션 서버에서 Spring 어플리케이션이 돌아가는데요. 이때 유저의 요청을 받은 Spring은 connection pool을 통해 데이터베이스와 <strong>미리 연결되어 있는</strong> connection을 받습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f6cf362b-0d1a-43fb-acde-8f54ac229e91/image.png" alt=""></p>
<p>&quot;미리 연결&quot;되어 있다고 했는데, 이 부분이 connection pool의 존재 이유이고 성능을 높일 수 있는 핵심이라고 생각합니다.
그렇다면 왜 database connection을 미리 연결해두는 것일까요?</p>
<h3 id="database-connection의-시작과-종료">Database connection의 시작과 종료</h3>
<p>쿼리의 동작 방식을 생각해보겠습니다. Spring에서 쿼리를 작성하면, 그 쿼리는 데이터베이스에 명령을 보내고 쿼리문을 통해 원하는 데이터를 전달받을 것입니다. 이때 데이터베이스와의 connection을 통해 데이터베이스로 요청을 전달하고 데이터베이스한테 정보를 응답받습니다. 이 connection은 TCP 통신을 통해 이루어집니다. TCP 특징상 연결 시에 3-handshake를 진행하고 연결 해제 시에 4-handshake를 진행합니다. TCP의 이런 특징 때문에 connection의 연결과 해제 시에 많은 시간 비용이 듭니다.</p>
<h3 id="connection-pool의-필요성">Connection Pool의 필요성</h3>
<p>connection pool은 <strong>데이터베이스와의 연결을 효율적으로 관리</strong>하는 데 도움을 준다고 했는데요.
데이터베이스와 TCP 연결을 미리 해놓은 connection들을 connection pool에 저장함으로써 속도에 대한 성능을 챙기게 됩니다.
따라서 connection pool의 존재 덕분에 Java 스레드는 connection을 연결하거나 연결 해제하지 않고, 연결되어있는 connection을 connection pool로부터 받기만 하면 됩니다. 작업이 완료되면 connection을 connection pool에 반납하는 방식이죠.</p>
<h4 id="1-connection-획득">1. Connection 획득</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/764b3e3a-a4c4-482e-ba01-2d4b75389186/image.png" alt=""></p>
<h4 id="2-쿼리-실행">2. 쿼리 실행</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f7de8bf3-9433-4151-84d4-6472202f7491/image.png" alt=""></p>
<h4 id="3-connection-반납">3. Connection 반납</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/0b98cf45-6e57-4311-81a1-f04e55fea3a3/image.png" alt=""></p>
</br>

<h3 id="데이터베이스-설정">데이터베이스 설정</h3>
<p>데이터베이스 Connection은 Spring과 Database를 연결하는 작업이기 때문에 개발자는 <strong>Spring 관련 설정</strong>과 <strong>Database 설정</strong> 모두 알고 있어야 합니다. 우선 Database 설정부터 알아보겠습니다.</p>
<p>Database 입장에서는 부하를 방지하기 위해 최대 연결 가능한 수를 제한할 필요가 있습니다. 이 제한을 <code>max_connections</code>라고 합니다.
Connection Pool의 connection 수와 <code>max_connections</code> 수의 설정을 바꿔가며 상황을 살펴보겠습니다.
(Connection Pool에 저장할 수 있는 최대 connection 수를 HikariCP에서는 <code>maximumPoolSize</code>라고 하는데, 편의상 <code>maximumPoolSize</code>로 계속 지칭하겠습니다.)</p>
<h4 id="1-maximumpoolsize--max_connections">1. maximumPoolSize &lt;= max_connections</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/e4a73fe2-e2f4-472f-aee7-31418533922c/image.png" alt=""></p>
<p><code>max_connections</code>가 더 크거나 같은 경우에는 Database 측에서 낭비되고 있는 리소스가 생깁니다. 만약 낭비되는 리소스가 많다면 서버 비용이 더 부담되겠죠... HikariCP에서는 권장하지 않는 방법이라고 합니다.</p>
<h4 id="2-maximumpoolsize--max_connections">2. maximumPoolSize &gt; max_connections</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f6b65236-ce47-489d-b189-4098b3566a12/image.png" alt="">
그림처럼 connection pool에서 연결되지 못한 connection이 존재하게 됩니다. Database 입장에서는 최대 허용 기준치가 찼기 때문에 요청을 거절한 것입니다. 이런 경우에는 connection pool에 최대 4개의 연결을 허용했다고 하더라도 3개까지만 연결되는 현상이 발생합니다.</p>
<h3 id="wait_timeout">wait_timeout</h3>
<p>만약 <code>max_connections</code> 수치가 <code>maximumPoolSize</code>보다 크면 리소스 낭비가 일어날 수 있다고 윗 부분에서 언급했는데요. 이것 말고도 다른 방법으로도 리소스 낭비가 일어날 수 있습니다.
만약 한 Connection이 중간에 비정상적으로 끊기는 경우가 그런 경우입니다. Database 입장에서는 한 Connection에 요청이 들어오지 않을 뿐 close에 대한 요청이 없었기 때문에 connection이 유지되고 있다고 생각할 것입니다. 실제로 요청을 보내고 있지 않은 경우라면 상관 없지만, 오류로 인해 연결이 끊긴지 모르는 것은 문제가 됩니다.</p>
<p>그래서 MySql에는 <code>wait_timeout</code>이라는 옵션이 존재합니다. 이름에서 유추할 수 있듯이, Database에서 스프링 어플리케이션 connection의 대기 시간을 설정하는 변수입니다. 이 변수는 클라이언트와 Database 서버 간의 connection이 유효한 상태로 유지되는 시간을 초 단위로 지정합니다. 만약 클라이언트가 지정된 시간 동안 서버와 통신하지 않으면, 데이터베이스 서버는 해당 connection을 끊습니다. 그로 인해 connection 오류에 대한 해결을 할 수 있는 것이죠.</p>
<p>또, 만약 <code>wait_timeout</code>을 60초로 설정했다고 가정했을 때 60초 안에 요청이 들어오면 Database는 시간 count를 다시 0초로 초기화시킵니다. 이렇게 요청이 계속 오면 60초가 지났다 하더라도 연결이 끊어지지 않고 계속 같은 연결된 상태로 있을 수 있는 것이죠.</p>
</br>

<h3 id="spring-application-설정">Spring Application 설정</h3>
<p>이번에는 Spring Application 에서 Connection Pool을 설정하는 방법에 대해 알아보겠습니다.
<em>앞으로 서술할 내용들은 Spring 2.0부터 지금까지 채택되고 있는 HikariCP 기준으로 작성할 예정입니다.</em></p>
<h4 id="maximumpoolsize">maximumPoolSize</h4>
<p>위에서 잠깐 살펴보았는데요. Connection Pool에 들어갈 수 있는 최대 connection 수를 의미합니다.</p>
<h4 id="minimumidle">minimumIdle</h4>
<p>Connection Pool에서 유지하는 최소한의 idle connection 수를 의미합니다. (idle connection == 일 없는 connection)
Connection Pool은 일반적으로 최소한의 idle connection을 유지하여, 스프링 어플리케이션이 Database와의 연결을 필요로 할 때 추가 연결을 생성하는 시간과 비용을 절감하도록 도와줍니다. 예를 들어, <code>minimumIdle</code>을 5로 설정하면 Connection Pool은 항상 최소 5개의 idle Connection을 유지하려고 노력합니다.
즉, TCP 연결이 끊기지 않도록 노력하는 connection 수 입니다.</p>
<h4 id="minimumidle-의문">minimumIdle 의문</h4>
<p>그렇다면 의문이 생깁니다. 앞전에 Database에는 <code>wait_timeout</code>이 존재한다고 했는데요. <code>wait_timeout</code>이 60초로 설정되어있다고 가정하고, 60초동안 유저가 아무 요청도 보내지 않으면 결국 모든 connection이 끊어지는 것이 아닌가 의심이 들었습니다. HikariCP 코드를 보니 아래와 같이 health check를 하는 부분이 있었습니다. 주기적으로 health check를 하면서 idle connection을 유지하고 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/a86008ef-2ced-4a6e-bf12-735d9250269b/image.png" alt="">
자세한 스케줄링까지는 검색이 되지 않아 살펴볼 수 없었지만 해당 메소드를 통해 주기적으로 health check를 하는 요청을 Database로 보내고 있다는 것은 알 수 있었습니다.</p>
<h4 id="maximumpoolsize와-minimumidle-주의점">maximumPoolSize와 minimumIdle 주의점</h4>
<p><code>maximumPoolSize</code>와 <code>minimumIdle</code>을 커스텀 설정할 때 주의해야하는 부분이 있습니다. <code>minimumIdle</code>은 절대로 <code>maximumPoolSize</code>보다 크면 안됩니다. <code>maximumPoolSize</code>가 <code>minimumIdle</code>보다 우선 순위가 높기 때문인데요. 예시를 통해 조금 더 자세하게 살펴보겠습니다.
<code>maximumPoolSize</code>를 4, <code>minimumIdle</code>을 2로 설정했다고 가정하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/eda0e621-64db-4a61-a1ee-ea3e4da0c28f/image.png" alt=""></p>
<p><code>minimumIdle</code>은 2이기 때문에 아무 요청이 없다면 위 그림과 같이 2개의 connection이 존재할 것입니다.
만약 이때 <code>getConnection()</code> 요청이 들어오면 어떻게 될까요?</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/1a487bad-d0c0-40a3-99db-38dc11bba70c/image.png" alt=""></p>
<p>Database와 연결되어있는 connection을 java thread에 빌려줍니다. 이 때, <code>minimumIdle</code>이 위반되기 때문에 Connection Pool은 재빠르게 새로운 connection을 연결합니다.
그러면 만약 Connection Pool에 <code>maximumPoolSize</code>인 4까지 다 채워지게 된다면 어떻게 될까요?</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/ea31e51a-a9ce-45af-8fe3-c90a68497a86/image.png" alt=""></p>
<p><code>maximumPoolSize</code>가 4이기 때문에 이를 넘는 connection은 만들지 않습니다. 이 때 <code>minimumIdle</code>는 2가 되어야하는 규칙은 <code>maximumPoolSize</code>에 밀려 무시됩니다!</p>
<p>또 정말 중요한 점이 있습니다. HikariCp에서는 <code>maximumPoolSize</code>와 <code>minimumIdle</code>를 같게 두는 것을 권장합니다.</p>
<blockquote>
<p>This property controls the minimum number of idle connections that HikariCP tries to maintain in the pool. If the idle connections dip below this value and total connections in the pool are less than maximumPoolSize, HikariCP will make a best effort to add additional connections quickly and efficiently. However, for maximum performance and responsiveness to spike demands, we recommend not setting this value and instead allowing HikariCP to act as a fixed size connection pool. Default: same as maximumPoolSize</p>
</blockquote>
<p>트래픽이 확 몰리는 경우에 대비하기 위해 이렇게 default로 두는 것을 권장한다고 하네요. </p>
<h4 id="maxlifetime">maxLifeTime</h4>
<p>Connection Pool에서 connection의 최대 수명을 뜻합니다. <code>maxLifeTime</code>을 60초로 잡았다고 가정을 한다면 60초마다 새로운 connection을 만든다는 이야기입니다. 그렇다면 굳이 왜 살아있는 connection을 재설정하는걸까요?
크게 2가지 이유가 있습니다.</p>
<ol>
<li>자원 누수 방지: 연결이 오랜 시간 동안 열려 있으면 자원이 소비되며, 일정 시간 이상 동안 활성화되지 않는 연결은 자원 누수를 발생시킬 수 있습니다. 따라서 유효기간을 설정하여 자원 누수를 막아주는 것입니다.</li>
<li>보안: 연결이 오랜 시간 동안 열려 있으면 보안 취약점을 만들 수 있습니다. connection을 계속 교체해가며 보안을 높이는 효과를 볼 수 있습니다.</li>
</ol>
<p>Connection Pool 내부에 있는 idle Connection은 60초로 설정된 <code>maxLifeTime</code>이 지나면 새로 connection을 교체하는데, java thread가 사용하고 있다면 java thread로부터 반납 받아야 connection을 새롭게 설정할 수 있습니다.
그러면 여기서 문제점이 보이실텐데요. 만약 java thread의 오류로 connection을 계속 점유하고 있다면 어떻게 될까요?</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/b91a60e6-367f-4c8f-a81b-33a7ee4d15f2/image.png" alt=""></p>
<p>Database의 <code>wait_timeout</code>이 동작하여 connection이 끊어지게 될 것입니다.
그런데 만약 정말 오래걸리는 작업이어서 connection이 늦게 Connection Pool로 반환된 경우도 있겠죠. 그런 경우에는 끊어진 connection 이기 때문에 예외가 발생하게 됩니다.
따라서 Connection Pool로 잘 반환할 수 있도록 하는 것이 정말 중요하겠죠. 또 만약 admin을 위한 통계 기능 어플리케이션의 경우 넉넉하게 Database와 어플리케이션 모두 timeout을 넉넉하게 두면 좋을 것 같습니다.</p>
<h4 id="maxlifetime-주의점">maxLifeTime 주의점</h4>
<p>HikariCP에서는 <code>maxLifeTime</code>은 Database나 인프라의 timeout보다 몇 초 더 짧게 설정하는 것을 권장하고 있습니다.</p>
<blockquote>
<p>This property controls the maximum lifetime of a connection in the pool. An in-use connection will never be retired, only when it is closed will it then be removed. On a connection-by-connection basis, minor negative attenuation is applied to avoid mass-extinction in the pool. We strongly recommend setting this value, and it should be several seconds shorter than any database or infrastructure imposed connection time limit. A value of 0 indicates no maximum lifetime (infinite lifetime), subject of course to the idleTimeout setting. The minimum allowed value is 30000ms (30 seconds). Default: 1800000 (30 minutes)</p>
</blockquote>
<p>왜 그럴까요? 한 번 <code>maxLifeTime</code>과 <code>wait_timeout</code>을 60초로 같게 설정해보겠습니다. 그리고 Database와 어플리케이션 모두 0초에서 타이머를 시작했다고 가정하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/627b6e73-2367-4c27-bc7a-cdb66d3217c4/image.png" alt=""></p>
<p>만약 59.9초에 쿼리 요청을 Database로 전달하면 <code>maxLifeTime</code>에 위배되지 않기 때문에 성공적으로 전송됩니다. 하지만 전송 도중에 60초가 땡하고 울리게되면 <code>wait_timeout</code>에서 connection 자원을 정리할 것입니다. 결국 쿼리가 도착하면 connection 자원이 되었기 때문에 요청이 성공하지 않을 것입니다.
타이밍이 아주 잘 맞아야하는 경우긴 하지만 이를 방지하기 위해 HikariCP는 <code>maxLifeTime</code>을 <code>wait_timeout</code>보다 몇 초 작게 설정하라고 권장하고 있었던 겁니다.</p>
<h4 id="connectiontimeout">ConnectionTimeout</h4>
<p>Java thread가 Connection Pool에서 connection을 구하기 위해 대기하는 시간입니다. 이미 Connection Pool의 모든 connection이 사용중일 때 발생합니다. HikariCP는 default를 30초로 잡아두었는데, 이렇게 되면 30초동안 사용자는 아무 응답도 받지 않고 그저 로딩 화면만 보고 대기하게 됩니다. 저는 티켓팅이나 수강신청을 할 때 대기중인 화면이 바로 떠올랐습니다.
서비스에 따라 다르겠지만 티켓팅이나 수강 신청과 같은 서비스가 아니라면 30초보다 적게 설정하는 것이 좋을 것 같습니다. 유저를 대기시키기보다는 요청이 많다는 에러 메시지를 보여주는 것이 더 바람직해보이기 때문입니다.</p>
</br>

<h2 id="마무리">마무리</h2>
<p>변수를 적절하게 설정해보기 위해 미리 HikariCP에 대해 학습해보았습니다. 적절한 connection 변수를 찾는 방법도 부하테스트를 통해 알아보도록 하겠습니다.</p>
</br>

<h2 id="참고">참고</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=zowzVqx3MQ4&amp;t=261">https://www.youtube.com/watch?v=zowzVqx3MQ4&amp;t=261</a></li>
<li><a href="https://github.com/brettwooldridge/HikariCP">https://github.com/brettwooldridge/HikariCP</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Blue Green 무중단 배포]]></title>
            <link>https://velog.io/@hooni_/Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@hooni_/Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Sat, 07 Oct 2023 19:50:38 GMT</pubDate>
            <description><![CDATA[<p>현재 우테코 프로젝트로 운영 중인 <strong>바톤</strong> 서비스에서 무중단 배포를 하게되었습니다.
지금까지는 서버를 종료시킨 후에 새로 업데이트된 서버를 순차적으로 가동하는 방식으로 운영했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/d045db86-f38a-4576-a81c-97cec08157bd/image.png" alt=""></p>
<p>그림과 같이 <code>V1</code>이 종료된 후부터 <code>V2</code>가 정상 작동 되기까지 서버를 다운시켰습니다.
이 다운되어있는 시간은 <em>다운 타임</em> 이라고 합니다. 저희 서비스는 다운 타임이 약 3분이었는데요. 이 3분동안 유저가 서비스를 이용하지 못하면 불편하겠죠...?</p>
<p>그래서 중단 배포에서 무중단 배포로 방식을 교체하여 제로 다운 타임을 만들어 유저의 불편을 줄였습니다.</p>
<h2 id="무중단-배포">무중단 배포?</h2>
<p>중단 배포와 다르게 서버가 다운되어있는 시간 없이 새로운 버전으로 교체하는 배포 방법을 뜻합니다.
무중단 배포 전략으로 크게 3가지가 있는데요. 한 번 알아보겠습니다.</p>
</br>

<h3 id="rolling-update-배포">Rolling update 배포</h3>
<p>로드밸런서로 연결되어있는 서버들을 순차적으로 하나씩 새로운 버전으로 변환시키는 방식입니다. 배포되어있는 서버를 하나씩 교체하는 방식이라고 생각하시면 편합니다!</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/3303a6c9-6f5c-4fef-b8b5-c840653f8789/image.png" alt="">
<img src="https://velog.velcdn.com/images/hooni_/post/999aeb40-a335-4f64-8177-44228f8abbff/image.png" alt=""></p>
<p>연결되어있는 서버를 하나씩 멈추고 새로운 버전을 교체하고 있습니다. 부가적인 서버 자원을 사용하지 않고도 배포할 수 있다는 장점이 있지만 배포되는 동안에는 평소보다 트래픽 부하가 더 걸린다는 단점이 있습니다.</p>
<p>윗 그림의 롤링 방식은 추가적인 서버 자원을 사용하지 않고 배포 동안에 트래픽 부하를 더 주는 방식이지만 반대의 방식도 존재합니다. 추가 서버를 하나 더 띄우고, 업데이트를 위해 정지해놓은 서버 때문에 추가적인 트래픽 부하가 걸리지 않도록 하는 방법입니다.
추가적인 서버 비용이 걱정되는 경우에는 전자를, 트래픽 부하가 걱정되는 경우에는 후자를 선택하면 좋을 것 같습니다.</p>
<p>또, 롤링 배포 방식에서는 기존 버전(<code>V1</code>)과 새로운 버전(<code>V2</code>)의 인스턴스가 동시에 실행될 수 있기 때문에 <strong>두 버전 간의 호환성이 매우 중요</strong>합니다.</p>
<p>예를 들어 &quot;GET /articles&quot; API가 <code>V1</code>에서 사용되고 있는데, <code>V2</code>에서 해당 API가 제거되었다면, 이는 큰 문제가 될 수 있습니다. 왜냐하면 <code>V2</code>로의 업데이트 과정 중에는 <code>V1</code>과 <code>V2</code> 서버가 동시에 존재하게 되고, 이 때 유저가 &quot;GET /articles&quot; API를 호출하면 일부는 오류 응답을 받게 될 것입니다.</p>
<p>이러한 호환성 문제는 API 엔드포인트뿐만 아니라 데이터베이스 스키마 등 여러 계층에서 발생할 수 있습니다! 따라서, 롤링 배포 전략을 수행할 때는 호환성 문제를 미리 고려하여 배포 전에 꼼꼼히 테스트를 수행해야 합니다.</p>
</br>

<h3 id="blue-green-배포">Blue-Green 배포</h3>
<p>Blue-Green 배포는 두 개의 독립적인 환경 <code>Blue</code> 환경과 <code>Green</code> 환경을 가지고 진행되는 배포 전략입니다. 여기서 <code>Blue</code>는 현재 운영중인 환경을 나타내며, <code>Green</code>은 새로운 버전의 환경을 의미합니다. <code>Green</code>의 배포 준비가 완료되면 <code>Blue</code> 환경에서 <code>Green</code> 환경으로 한 번에 전환합니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/7fbe5ceb-a6e5-40f0-a06a-08d8b60bac7a/image.png" alt=""></p>
<p><code>V2</code>가 배포된 순간부터 로드 밸런서는 <code>V2</code>로 모든 트래픽을 이동시키고, <code>V1</code>은 마지막 트래픽의 작업이 끝나면 종료됩니다. 정말 찰나의 순간 <code>V1</code>과 <code>V2</code> 서버가 동시에 켜져있다고 생각하시면 됩니다!</p>
<p>롤링 배포와 다르게 한 번에 버전을 바꾸기 때문에 버전 호환에 대한 문제가 발생하지 않습니다. 또, 만약 새로운 버전에 문제가 발생하면 트래픽을 다시 <code>Blue</code> 환경으로 전환하여 이전 버전으로 빠르게 롤백할 수 있습니다.</p>
<p>단점 또한 존재하는데요. 짧은 시간이지만 두 버전이 동시에 실행되고 있는 시간도 있기 때문에 추가적인 서버 비용이 발생합니다.</p>
</br>

<h3 id="canary-배포">Canary 배포</h3>
<p>카나리 배포 전략은 서비스의 새로운 버전을 모든 사용자에게 바로 제공하는 것이 아니라, 먼저 일부 사용자에게 제공하여 새로운 버전의 안정성과 성능을 검증하는 배포 전략입니다. 광산에서 독가스를 감지하기 위해 캐나리 새를 사용한 것에서 유래되었다고 합니다!</p>
<h4 id="절차">절차</h4>
<ul>
<li><strong>평가 단계:</strong> 먼저 새로운 버전(<code>V2</code>)를 제한된 사용자 그룹에 배포합니다. 이 사용자 그룹을 &quot;카나리&quot; 그룹이라고 합니다.</li>
<li><strong>모니터링 단계:</strong> 카나리 그룹의 피드백과 시스템의 메트릭을 모니터링하여 <code>V2</code>의 문제점을 파악합니다.</li>
<li><strong>확장 배포 단계:</strong> 앞선 단계에서 문제가 없었다면 <code>V2</code> 사용자 그룹을 점진적으로 확대합니다.</li>
<li><strong>전체 배포 단계:</strong> <code>V2</code>를 100% 배포합니다.</li>
</ul>
<p>카나리 배포의 경우 새로운 버전으로 업데이트하면서 발생하는 문제점들을 초기에 확인할 수 있기 때문에 빠른 롤백이 가능합니다. 또, 초기 단계에서 사용자 피드백을 받아 빠르게 수정 및 개선할 수 있습니다.
하지만, 롤링과 블루-그린 배포 전략에 비해 비용이 많이 발생한다는 단점이 있습니다.</p>
</br>

<h2 id="배포-전략-선택">배포 전략 선택</h2>
<p>저희 팀은 Blue-Green 전략을 선택했습니다.</p>
<p>우선 rolling 방식을 사용했을 때는 빠르게 롤백할 수 있는 역량이 부족하다고 판단했습니다. 롤백하는 시간 때문에 몇몇 사용자들은 장시간 에러 페이지를 만날 수 있다는 생각에 Blue-Green 방식을 선택했습니다.</p>
<p>카나리 방식은 사용자가 많아야 의미 있는 전략이라고 생각합니다. 저희는 사용자가 100명대인 작은 서비스이기 때문에 카나리 전략은 생각하지 않았습니다.</p>
<p>추가적인 서버 리소스가 드는 단점이 있지만, 구동 중인 어플리케이션의 수가 2개 밖에 안되기 때문에 비용 체감이 작을 것이라고 생각하여 큰 단점이라고 생각하진 않았습니다.</p>
<p>사실, 서버가 단일 어플리케이션 환경으로 구성되어있기 때문에 rolling, Blue-Green, Canary 모두 같은 의미가 됩니다. 하지만 Blue-Green에 어울린다고 생각해 Blue-Green 전략을 선택했다고 언급한 것입니다.</p>
</br>

<h2 id="구현-과정">구현 과정</h2>
<p>하나의 nginx에 spring application이 하나 연결된 상태라고 가정하겠습니다.</p>
<h3 id="초기-환경">초기 환경</h3>
<p><img src="https://velog.velcdn.com/images/hooni_/post/5d6b2adf-76af-4a56-91c4-a2ed904d0353/image.png" alt=""></p>
<p>Nginx의 리버스 프록시로 연결된 스프링 서버 컨테이너가 있습니다.
그리고 github actios를 이용하여 CI/CD를 진행합니다. ssh 연결이 사설 ip에서만 가능하기 때문에 배포는 self-hosted runner를 사용했습니다.</p>
</br>

<h3 id="docker-compose-생성">docker-compose 생성</h3>
<p>컨테이너를 빌드하기 위해 docker-compose를 이용했습니다.</p>
<pre><code class="language-yaml">version: &#39;3.9&#39;

services:
  dev1:
    container_name: spring-baton1
    image: &#39;{계정 명}/{이미지명}:{태그 이름}&#39;
    ports:
      - &#39;8080:8080&#39;
    environment:
      - TZ=Asia/Seoul
    networks:
      - baton

  dev2:
    container_name: spring-baton2
    image: &#39;{계정 명}/{이미지명}:{태그 이름}&#39;
    ports:
      - &#39;8081:8080&#39;
    environment:
      - TZ=Asia/Seoul
    networks:
      - baton

networks:
  baton:
    external: true</code></pre>
<p>dev1이 구동중인 경우에는 dev2가 새로운 버전으로 배포되고, dev2가 구동중인 경우에는 dev1이 새로운 버전으로 배포되는 방식을 선택했습니다.
dev1과 dev2의 다른점은 8080 포트냐, 8081 포트냐 입니다.
스프링 컨테이너와 데이터베이스 컨테이너가 baton 네트워크로 묶여 있어 추가적인 네트워크 설정도 해주었습니다. 만약 네트워크 설정을 따로 하시지 않았다면 networks 부분은 제거하셔도 됩니다.</p>
</br>

<h3 id="nginx-파일-수정">nginx 파일 수정</h3>
<p>/etc/nginx/nginx.dev1.conf 파일과 /etc/nginx/nginx.dev2.conf 를 만들어 컨테이너를 가리키는 포트만 다르게 두었습니다. 새로운 nginx.dev1.conf와 nginx.dev2.conf 파일은 nginx.conf에서 복제하였습니다.
보안을 위해 바뀐 부분만 보여드리겠습니다.</p>
<pre><code class="language-conf">...
location /api {
    ...
    proxy_pass http://127.0.0.1:8080;
    ...
}
...</code></pre>
<p>nginx.dev1.conf에서는 proxy_pass를 8080 포트로 두었고, nginx.dev2.conf에서는 8081로 두었습니다!
만약 sites-enabled 나 sites-available 경로의 파일에 서버 포트를 설정하셨다면 해당 파일을 2개로 나누면 됩니다.</p>
</br>

<h3 id="배포-스크립트-작성">배포 스크립트 작성</h3>
<pre><code class="language-shell">#!/bin/bash

IS_DEV1=$(docker ps | grep spring-baton1)
DEFAULT_CONF=&quot; /etc/nginx/nginx.conf&quot;
MAX_RETRIES=20

check_service() {
  local RETRIES=0
  local URL=$1
  while [ $RETRIES -lt $MAX_RETRIES ]; do
    echo &quot;Checking service at $URL... (attempt: $((RETRIES+1)))&quot;
    sleep 3

    REQUEST=$(curl $URL)
    if [ -n &quot;$REQUEST&quot; ]; then
      echo &quot;health check success&quot;
      return 0
    fi

    RETRIES=$((RETRIES+1))
  done;

  echo &quot;Failed to check service after $MAX_RETRIES attempts.&quot;
  return 1
}

if [ -z &quot;$IS_DEV1&quot; ];then
  echo &quot;### DEV2 =&gt; DEV1 ###&quot;

  echo &quot;1. DEV1 이미지 받기&quot;
  docker-compose pull dev1

  echo &quot;2. DEV1 컨테이너 실행&quot;
  docker-compose up -d dev1

  echo &quot;3. health check&quot;
  if ! check_service &quot;http://127.0.0.1:8080&quot;; then
    echo &quot;DEV1 health check 가 실패했습니다.&quot;
    exit 1
  fi

  echo &quot;4. nginx 재실행&quot;
  sudo cp /etc/nginx/nginx.dev1.conf /etc/nginx/nginx.conf
  sudo nginx -s reload

  echo &quot;5. DEV2 컨테이너 내리기&quot;
  docker-compose stop dev2
  docker-compose rm -f dev2

else
  echo &quot;### DEV1 =&gt; DEV2 ###&quot;

  echo &quot;1. DEV2 이미지 받기&quot;
  docker-compose pull dev2

  echo &quot;2. DEV2 컨테이너 실행&quot;
  docker-compose up -d dev2

  echo &quot;3. health check&quot;
  if ! check_service &quot;http://127.0.0.1:8081&quot;; then
      echo &quot;DEV1 health check 가 실패했습니다.&quot;
      exit 1
    fi

  echo &quot;4. nginx 재실행&quot;
  sudo cp /etc/nginx/nginx.dev2.conf /etc/nginx/nginx.conf
  sudo nginx -s reload

  echo &quot;5. DEV1 컨테이너 내리기&quot;
  docker-compose stop dev1
  docker-compose rm -f dev2
fi</code></pre>
<p>코드 순서대로 알아보겠습니다.</p>
<ol>
<li>현재 실행중인 컨테이너가 <code>spring-baton1</code>인지 확인합니다.</li>
<li><code>if [ -z &quot;$IS_DEV1&quot; ]</code> 에서 만약 $IS_DEV1 이 비어있다면 참을 반환하는데, 그 경우는 dev2가 실행중인 경우이기 때문에 dev1을 새로 배포시키는 로직을 수행시킵니다. (if와 else 부분의 로직은 같기 때문에 if 부분의 로직만 작성하겠습니다.)</li>
<li>이미지를 pull 받습니다.</li>
<li>컨테이너를 실행시킵니다.</li>
<li>dev1 서비스가 정상적으로 실행되는지 확인합니다.
 5-1. <code>check_service()</code> 함수에서 3초에 한 번씩 서비스 상태를 확인합니다.
 5-2. 만약 서비스가 구동중이지 않다면 계속해서 서비스 상태를 확인하는데, 20번이 넘어가면 실패를 반환합니다.
 5-3. 서비스가 20번 안에 구동된다면 성공을 반환합니다.
 5-4. 함수가 실패한다면 곧바로 스크립트를 오류 상태로 종료시킵니다.</li>
<li>이전에 작성한 nginx.dev1.conf 파일은 nginx.conf 파일로 복사해서 nginx가 바라보는 포트를 바꿉니다. 그 후에 reload합니다.</li>
<li>운영중이던 구 버전의 dev2를 종료시킵니다.</li>
</ol>
</br>

<h4 id="의문-1">의문 1</h4>
<p>다른 블로그들을 참고하여 쉘 스크립트를 작성했는데요. 코드에 의문점이 있었습니다. 기존 프로세스에 아직 많은 트래픽이 남았는데, nginx가 새로운 프로세스를 시작하게 되면 기존 프로세스의 남아있는 트래픽은 처리되지 않지 않을까? 하는 걱정이 있었습니다.
우려했던 점에 대해 검색하고 나서 nginx reload에 대해 몰랐던 점을 하나 배웠는데요. <code>nginx -s reload</code>는 nginx를 재배포하는 것이 아닌 설정을 다시 읽어들이는 방법이라고 합니다. reload 시에 다음과 같은 과정으로 실행됩니다!</p>
<ol>
<li>nginx는 새로운 worker 프로세스를 시작하여 새로운 설정으로 실행합니다.</li>
<li>모든 새로운 트래픽은 이 새로운 worker 프로세스에 의해 처리됩니다.</li>
<li>기존의 worker 프로세스는 현재 활성화된 트래픽들을 계속 처리하다가, 그 트래픽들이 종료되면 자동으로 종료됩니다.</li>
</ol>
<p>nginx가 이런 부분도 신경써주고 있어서 놀랐습니다.</p>
<h4 id="의문-2">의문 2</h4>
<p>비슷한 다른 의문도 있었는데요. 이번에는 어플리케이션 쪽에서 아직 연산이 끝나지 않은 경우에 컨테이너가 내려가면 어떡하지 걱정되었습니다. 현재 상황에서는 종료 명령이 2가지가 있습니다. <code>docker stop</code>과 스프링 어플리케이션의 종료입니다.</p>
<p>스프링 어플리케이션의 종료부터 알아보겠습니다. 스프링부트 2.3 버전부터 Graceful Shutdown을 지원한다고 합니다. 따라서 application 파일에 <code>server.shutdown=graceful</code>를 두면 Hard Shutdown으로 종료되지 않고 모든 요청을 완료한 후에 어플리케이션이 종료됩니다.
그래서 어플리케이션 파일에 graceful shutdown을 설정해주었습니다!</p>
<p>이제 docker stop 에 대해 알아보겠습니다. <code>docker stop</code> 명령이 들어오면 docker는 컨테이너의 주 프로세스에 SIGTERM 신호를 전송합니다. 만약 10초 안에 프로세스가 종료되지 않는다면 SIGKILL 신호를 전송합니다.
여기서 SIGTERM 은 프로세스에게 종료 명령을 내리는 것이고, SIGKILL은 강제 종료의 명령입니다.</p>
<p>자 그러면 도커와 어플리케이션이 종료되는 순서는 아래와 같겠네요.</p>
<ol>
<li>도커가 스프링부트 어플리케이션에 종료 명령</li>
<li>명령을 받은 스프링부트는 shutdown을 실행 (graceful shutdown으로 설정해놓았기 때문에 graceful shutdown)</li>
<li>만약 10초가 지나도 스프링부트가 종료되지 않으면 어플리케이션 강제 종료</li>
</ol>
<p>무서운건 가장 마지막 부분인데요. 상황에 따라서 10초안에 모든 요청의 로직이 끝나지 않을 수도 있습니다. 그런 경우에는 docker-compose 파일에 <code>stop_grace_period: 60s</code>와 같이 SIGKILL 까지의 시간을 명시하여 종료까지 시간을 더 넉넉하게 줄 수도 있습니다.</p>
</br>

<h3 id="결과">결과</h3>
<p>지금까지 잘 동작하는지 실험해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/62ae34a6-340a-41dc-8db0-5295e79bc072/image.png" alt=""></p>
<p>스크립트는 잘 실행되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/a97a9235-bab3-4c74-8433-80446dc5385e/image.png" alt=""></p>
<p>도커 컨테이너도 잘 올라가있군요.
이번엔 반대 방향으로의 배포도 확인해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/b945a539-18cf-41b5-951c-455c52fde216/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/0feca1e8-c8aa-46c9-80fe-aeb776fd3c32/image.png" alt=""></p>
<p>잘 동작하는 것을 확인했습니다.</p>
</br>

<h2 id="github-actions">github actions</h2>
<p>배포 스크립트가 잘 동작하는 것을 확인했으니 이번에는 CD 파일을 작성해보겠습니다.
<a href="https://velog.io/@hooni_/github-actions%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CD">이전 글</a>에서 CI/CD 배포 방법을 살펴보아서 깊게 다루지 않겠습니다. 해당 게시글의 cd 부분만 조금 수정했습니다.
수정된 부분은 아래와 같습니다.</p>
<pre><code class="language-yml">  deploy:
    runs-on: [ self-hosted, Linux, ARM64, dev ]
    needs: build

    steps:

      - name: Pull Latest Docker Image
        run: |
          sudo docker login --username ${{ secrets.DOCKERHUB_DEV_USERNAME }} --password ${{ secrets.DOCKERHUB_DEV_TOKEN }}
          sudo docker pull 2023baton/2023baton:latest

      - name: Docker Compose
        run: |
          /home/ubuntu/zero-downtime-deploy.sh
          sudo docker image prune -af</code></pre>
<p>self-hosted runner로 서버 내에서 코드를 실행시킵니다. 도커에 로그인 한 후에 이미지를 pull 받습니다. 그 후에 위에서 작성한 무중단 배포 파일을 실행하고 필요없는 이미지를 삭제합니다.</p>
</br>

<h2 id="마무리">마무리</h2>
<p>완벽한 무중단 배포 과정은 아닙니다. 배포된 것을 확인하는 부분에서 단순히 root endpoint인 127.0.0.1을 확인했는데, 이건 &quot;스프링이 켜지긴 했다.&quot; 라는 뜻입니다. 데이터베이스 연결 상태, 외부 서비스와의 통신 상태 등 필요한 부분을 health check하는 과정이 필요합니다. 그래야 정말 안정적이라고 할 수 있으니까요.
actuator 의존성을 추가해 health check 하는 것을 생각해보았지만, actuator를 설정하는 순간 해커에게 노출되는 부분이 많습니다. 최근 저희 서비스에 &quot;/actuator/health&quot; 요청을 보낸 해커가 있더라고요... 보안에 대한 학습이 부족한 탓에 조금은 아쉽지만 <code>curl http://127.0.0.1:8080</code>으로 배포 상태를 확인해보기로 했습니다.</p>
</br>

<h2 id="참고">참고</h2>
<ul>
<li><a href="https://mr-popo.tistory.com/230">https://mr-popo.tistory.com/230</a></li>
<li><a href="https://tecoble.techcourse.co.kr/post/2022-11-01-blue-green-deployment/">https://tecoble.techcourse.co.kr/post/2022-11-01-blue-green-deployment/</a></li>
<li><a href="https://hudi.blog/zero-downtime-deployment/">https://hudi.blog/zero-downtime-deployment/</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[K6 성능 테스트]]></title>
            <link>https://velog.io/@hooni_/K6-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@hooni_/K6-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Sat, 16 Sep 2023 18:01:47 GMT</pubDate>
            <description><![CDATA[<p>오랜만의 글입니다.
요즘 학습 키워드들이 쏟아지고 있는데, 오늘은 그 중에서 성능테스트에 대해 알아보도록 하겠습니다.</p>
<h2 id="성능-테스트란">성능 테스트란?</h2>
<p><strong>성능 테스트(Performance Testing)</strong>는 특정 상황에서 소프트웨어, cpu, 램 등의 성능을 측정하는 테스트입니다. 시스템이 특정 작업을 수행하는 데 걸리는 시간, 처리량, 사용 가능한 리소스 등을 평가하기 위해 수행됩니다.</p>
<p>즉, 실제 트래픽 상황에서 정상적으로 동작하는지, 언제 어떤 상황에서 서버가 터지는지 확인하는 작업입니다.
어느정도 트래픽까지 버틸 수 있는지 미리 확인하면 실제 서비스 운영 상황에서 부하가 발생할 것 같을 때 미리 여유롭게 대응할 수 있기 때문에 서버가 다운되는 일은 줄어들 것입니다!</p>
<p>성능 테스트에 여러 카테고리가 있는데 그 중에 <strong>부하 테스트(Load Testing)</strong>와 <strong>스트레스 테스트(Stress Testing)</strong>를 알아보도록 하겠습니다.</p>
<h3 id="부하-테스트">부하 테스트</h3>
<p>시스템이 예상되는 작업 부하를 얼마나 잘 처리할 수 있는지 평가하는 테스트입니다. 시스템의 처리 능력, 응답 시간, 리소스 사용량 등을 측정합니다.
실제 있을법한 트래픽을 시나리오로 두고 시스템이 잘 처리하는지 확인하는 과정이라고 이해했습니다.</p>
<h3 id="스트레스-테스트">스트레스 테스트</h3>
<p>시스템이 극도로 높은 부하나 다양한 스트레스 조건에서 어떻게 동작하는지를 확인하는 테스트입니다. 시스템의 한계를 찾고, 언제 어떻게 실패하는지, 그리고 얼마나 빨리 정상 상태로 회복하는지를 알아보기 위해 진행합니다.
스트레스 테스트를 진행하면 서버가 터지는 순간을 알기 때문에 실제 서비스에서 최악의 재앙을 미리 대비할 수 있습니다.</p>
</br>

<h2 id="k6-vs-jmeter">k6 vs jmeter</h2>
<p>성능 테스트 도구들 중에 떠오르는 신성 K6와 가장 널리 쓰이는 Jmeter 중에서 고민했습니다.</p>
<h3 id="작성해야하는-스크립트-형식">작성해야하는 스크립트 형식</h3>
<ul>
<li>K6: javascript(typescript)</li>
<li>Jmeter: XML</li>
</ul>
<p>두 스크립트 모두 상관이 없었지만, 자바스크립트가 XML 보다는 5배 이상 짧았습니다.
K6가 Jmeter에 비해 빠르게 코드를 작성할 수 있고, 작성한 코드를 이해하기 편했습니다.</p>
<hr>
<h3 id="레퍼런스-양">레퍼런스 양</h3>
<p>아무래도 오래되었고, 부동의 1위를 지키고 있는 JMeter가 레퍼런스 수가 많았습니다.
하지만 K6 공식 문서가 잘 되어있기 때문에 트러블 슈팅만 조금 힘들 수 있을 뿐이지 크게 곤란할 것 같진 않았습니다.</p>
<hr>
<h3 id="리소스-효율">리소스 효율</h3>
<p>K6는 Go로 작성되어 있고, Jmeter는 Java로 작성되어 있습니다. Go가 Java보다 더 적은 메모리와 CPU를 사용하므로 리소스 사용 면에서 K6가 더 유리합니다.</p>
<hr>
<h3 id="gui-제공">GUI 제공</h3>
<ul>
<li>K6: GUI 미제공</li>
<li>Jmeter: 자체 GUI 제공</li>
</ul>
<p>Jmeter의 경우 GUI를 지원합니다. <a href="https://github.com/apache/jmeter">Jmeter 깃허브</a>에서 GUI 사진을 따왔는데, 사진과 같습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/ceed28af-3143-44b8-a62b-d38b19f0c5fc/image.png" alt="">
썩 예쁜 디자인은 아니었습니다. 하지만 확실히 알아보기에는 편한 것 같습니다.
K6는 GUI를 지원하지 않는데 저희 팀은 딱히 상관 없다고 생각했습니다. 스크립트 가독성이 좋아서 GUI에 밀린다고 생각하지 않았습니다.</p>
<hr>
<h3 id="스레드">스레드</h3>
<ul>
<li>K6: 가상 사용자 1명당 goroutine 1개</li>
<li>Jmeter: 가상 사용자 1명당 스레드 1개</li>
</ul>
<blockquote>
<p>goroutine은 OS 쓰레드보다 훨씬 가볍게 비동기 Concurrent 처리를 구현하기 위하여 만든 것으로, 기본적으로 Go 런타임이 자체 관리한다. </p>
</blockquote>
<p>고루틴의 정의 중 일부인데요. java는 OS의 스레드에 직접 매핑되는 반면 고루틴은 경량화된 스레드로 동작합니다. 성능면에서 K6가 더 우월했습니다.</p>
<hr>
<h4 id="결론">결론</h4>
<p>속도, 리소스 효율성 면에서 K6가 더 유리하기 때문에 K6를 선택하게 되었습니다.</p>
</br>

<h2 id="k6-설치">K6 설치</h2>
<p>맥 기준입니다.</p>
<pre><code class="language-bash">brew install k6</code></pre>
<p>위 명령어로 k6가 설치됩니다.</p>
</br>

<h2 id="스크립트-작성">스크립트 작성</h2>
<p>미리 간단한 게시글 GET api를 만들었습니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@RequestMapping(&quot;/posts&quot;)
@RestController
public class PostController {

    private final PostService postService;

    @GetMapping
    public ResponseEntity&lt;List&lt;PostResponse&gt;&gt; readAll() {
        return ResponseEntity.ok(postService.readAll().stream()
                .map(PostResponse::from)
                .toList());
    }
}</code></pre>
<p>해당 api의 스크립트를 작성해보겠습니다.</p>
<pre><code class="language-js">// GET
import http from &quot;k6/http&quot;;
import { sleep } from &quot;k6&quot;;

export const options = {
  vus: 100, // 가상 사용자 수
  duration: &quot;10s&quot;, // 테스트 시간
};

export default function () {
  http.get(&quot;http://localhost:8080/posts&quot;);
  sleep(1);
}</code></pre>
<p><code>function</code> 부분은 http 요청을 보내는 부분이라 생략하겠습니다.
<code>options</code> 부분은 테스트 시에 환경을 설정하는 부분입니다. 처음에는 간단하게 100명의 유저가 10초동안 접속했을 때를 측정했습니다.
요청이 끝나고 1초 동안 행동을 중지했는데요. 처음에는 왜 <code>sleep(1)</code>을 설정했는지 궁금했습니다. 공식 문서와 GPT께서 실제 서비스처럼 모방하기 위함이라고 하네요. <em>&quot;한 번 요청하고 나면 같은 요청을 1초 동안은 보내지 않을 것이다.&quot;</em> 라고 가정한 것입니다.</p>
</br>

<h2 id="실행-결과">실행 결과</h2>
<pre><code class="language-bash">k6 run {파일명}</code></pre>
<p>명령어를 통해 테스트를 진행하면 아래와 같이 성능을 보여줍니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/169d6a71-e206-4432-8e18-35fcb6c7b1ae/image.png" alt=""></p>
<p>그리고 각각의 요소가 의미하는 것은 아래의 표와 같습니다. <a href="https://k6.io/docs/using-k6/metrics/reference/">참고</a></p>
<h3 id="기본-메트릭-프로토콜-상관-없이-수집">기본 메트릭 (프로토콜 상관 없이 수집)</h3>
<table>
<thead>
<tr>
<th>METRIC NAME</th>
<th>TYPE</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>vus</td>
<td>Gauge</td>
<td>현재 활성화된 가상 사용자 수</td>
</tr>
<tr>
<td>vus_max</td>
<td>Gauge</td>
<td>가능한 최대 가상 사용자 수 (성능에 영향을 주지 않도록 VU 리소스는 미리 할당됨)</td>
</tr>
<tr>
<td>iterations</td>
<td>Counter</td>
<td>가상 사용자가 JS 스크립트 (기본 함수)를 실행한 총 횟수</td>
</tr>
<tr>
<td>iteration_duration</td>
<td>Trend</td>
<td>한 번의 완전한 반복을 완료하는 데 걸리는 시간, 설정 및 해체에 소요되는 시간을 포함</td>
</tr>
<tr>
<td>dropped_iterations</td>
<td>Counter</td>
<td>VU 또는 시간 부족으로 시작되지 않은 반복 횟수</td>
</tr>
<tr>
<td>data_received</td>
<td>Counter</td>
<td>수신된 데이터의 양</td>
</tr>
<tr>
<td>data_sent</td>
<td>Counter</td>
<td>전송된 데이터의 양</td>
</tr>
<tr>
<td>checks</td>
<td>Rate</td>
<td>성공적인 체크의 비율</td>
</tr>
</tbody></table>
<h3 id="http-관련-메트릭-http-요청을-할-때만-생성">HTTP 관련 메트릭 (HTTP 요청을 할 때만 생성)</h3>
<table>
<thead>
<tr>
<th>METRIC NAME</th>
<th>TYPE</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>http_reqs</td>
<td>Counter</td>
<td>k6가 생성한 총 HTTP 요청 수</td>
</tr>
<tr>
<td>http_req_blocked</td>
<td>Trend</td>
<td>요청을 시작하기 전에 차단된(무료 TCP 연결 슬롯을 기다리는) 시간</td>
</tr>
<tr>
<td>http_req_connecting</td>
<td>Trend</td>
<td>원격 호스트에 TCP 연결을 설정하는 데 걸린 시간</td>
</tr>
<tr>
<td>http_req_tls_handshaking</td>
<td>Trend</td>
<td>원격 호스트와 TLS 세션을 핸드셰이킹하는 데 걸린 시간</td>
</tr>
<tr>
<td>http_req_sending</td>
<td>Trend</td>
<td>원격 호스트에 데이터를 전송하는 데 걸린 시간.</td>
</tr>
<tr>
<td>http_req_waiting</td>
<td>Trend</td>
<td>원격 호스트의 응답을 기다리는 데 걸린 시간</td>
</tr>
<tr>
<td>http_req_receiving</td>
<td>Trend</td>
<td>원격 호스트로부터 응답 데이터를 받는 데 걸린 시간</td>
</tr>
<tr>
<td>http_req_duration</td>
<td>Trend</td>
<td>요청에 걸린 총 시간. (http_req_sending + http_req_waiting + http_req_receiving)</td>
</tr>
<tr>
<td>http_req_failed</td>
<td>Rate</td>
<td>setResponseCallback에 따른 실패한 요청의 비율</td>
</tr>
</tbody></table>
<p><code>http_req_duration</code> != <code>http_req_sending</code> + <code>http_req_waiting</code> + <code>http_req_receiving</code> 인데, 그건 <code>sleep(1)</code> 때문입니다. 1초를 쉬었으니 1.05 sec가 된 것이고, sleep을 제외한 시간을 계산해보면 딱 맞는다는 것을 확인할 수 있습니다.</p>
</br>

<h2 id="실제-테스트">실제 테스트</h2>
<p>윗 내용까지는 K6 사용법을 알아보기 위해 진행한 테스트이기 때문에 아주 간단한 테스트로 진행했습니다.
성능 테스트는 <strong>실제 환경처럼</strong> 세팅하고 진행하는 것이 중요하기 때문에 실제 환경으로 셋업하고 <strong>시나리오도 작성</strong>해서 진행해보도록 하겠습니다.</p>
</br>

<h3 id="부하-테스트-1">부하 테스트</h3>
<h4 id="목표">목표</h4>
<p>메인 화면의 게시글 목록을 보여줄 때 <strong>100ms</strong> 내에 응답하는 것을 목표로 잡겠습니다.</p>
<ul>
<li>하루 최대 rps(request per second): 100rps</li>
</ul>
<p>저희 서비스는 1시간에 1번 요청이 오는 때도 있기 때문에 평균 요청 횟수를 계산하지 않고 런칭 이벤트와 같은 피크 타임을 기준으로 잡겠습니다.
그렇다면 1초에 100건의 요청을 100ms 안에 보내면 됩니다!</p>
<h4 id="스크립트-작성-1">스크립트 작성</h4>
<p>최대 가상 사용자 수를 100rps * 0.1s 로 설정하겠습니다. (vus = 10)</p>
<pre><code class="language-js">import http from &quot;k6/http&quot;;
import { check } from &quot;k6&quot;;

export const options = {
  stages: [  
    { duration: &#39;2m&#39;, target: 5 }, 
    { duration: &#39;10m&#39;,target: 5 },
    { duration: &#39;3m&#39;, target: 10 },
    { duration: &#39;30m&#39;,target: 10 },
    { duration: &#39;3m&#39;, target: 0 },
  ],

  thresholds: { 
    http_req_duration: [&#39;p(95)&lt;100&#39;],
  }
};

export default function () {
  const response = http.get(&quot;http://localhost:8080/posts&quot;);
  check(response, {
    &quot;success&quot;: (res) =&gt; res.status === 200
  });
}</code></pre>
<p>코드를 해석하자면, 
2분 동안 5명으로 유저를 증가시키고
10분 동안 5명의 유저를 유지시킵니다.
그리고 3분 동안 10명으로 유저를 늘립니다.
피크 시간은 30분 동안 진행되고 10명의 유저를 유지시킵니다.
그리고 3분간 0명의 유저로 줄입니다.</p>
<p>또, 95%가 100ms 안에 응답을 받는 것을 목표로 했습니다.</p>
</br>

<h3 id="스트레스-테스트-1">스트레스 테스트</h3>
<h4 id="목표-1">목표</h4>
<p>서버가 언제 터지는지 확인해보겠습니다.
그리고 어떤 이유로 터지는지 확인해보도록 하겠습니다.</p>
<h4 id="스크립트">스크립트</h4>
<pre><code class="language-js">import http from &quot;k6/http&quot;;
import { check } from &quot;k6&quot;;

export const options = {
  stages: [
    { duration: &quot;1m&quot;, target: 10 },
    { duration: &quot;3m&quot;, target: 10 },
    { duration: &quot;1m&quot;, target: 50 },
    { duration: &quot;3m&quot;, target: 50 },
    { duration: &quot;1m&quot;, target: 100 },
    { duration: &quot;3m&quot;, target: 100 },
    { duration: &quot;1m&quot;, target: 200 },
    { duration: &quot;3m&quot;, target: 200 },
    { duration: &quot;1m&quot;, target: 300 },
    { duration: &quot;3m&quot;, target: 300 },
    { duration: &quot;1m&quot;, target: 0 },
  ],

  thresholds: {
    http_req_duration: [&quot;p(95)&lt;100&quot;],
  },
};

export default function () {
  const response = http.get(&quot;http://localhost:8080/posts&quot;);
  check(response, {
    &quot;success&quot;: (res) =&gt; res.status === 200,
  });
}</code></pre>
</br>

<h3 id="테스트-서버-환경-설정">테스트 서버 환경 설정</h3>
<p>스크립트를 모두 작성했으니 이제 실제 환경에서 테스트를 진행해보겠습니다.
<code>t4g.small</code> 서버를 사용한다고 가정하고 <code>t4g.small</code>에 실제 서비스 환경을 조성해주도록 하겠습니다.</p>
<p>grafana/k6는 arm64를 지원하지 않아 <a href="https://k6.io/docs/get-started/installation/">공식 설치 문서</a>를 참고하여 우분투 내에 직접 설치했습니다.
그런데 공식 문서 또한 되지 않았습니다. k6가 arm64를 지원하지 않는 것 같습니다.
그래서 github를 찾아보았는데 다행히도 arm64 버전이 업데이트 되어있었습니다.
github에서 다운받아 우분투로 scp 해주었습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/3e4c66ac-b71e-4b3a-bb14-f99fec1e5c17/image.png" alt=""></p>
<p>K6를 제외한 grafana와 influxdb를 docker-compose 로 컨테이너를 띄웠습니다.</p>
<pre><code class="language-yaml">version: &#39;3.4&#39;

services:
  influxdb:
    image: influxdb:1.8
    ports:
      - &quot;8086:8086&quot;
    environment:
      - INFLUXDB_HTTP_AUTH_ENABLED=false
      - INFLUXDB_DB=k6

  grafana:
    image: grafana/grafana:latest
    ports:
      - &quot;3000:3000&quot;
    environment:
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_BASIC_ENABLED=false
    volumes:
      - ./grafana:/etc/grafana/provisioning/</code></pre>
<p>influxdb에서 k6 라는 데이터베이스를 미리 만들었습니다.
또, 그라파나에서 로그인 된 상태로 접속할 수 있도록 설정했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/3a60cbe9-72dd-4549-b0ed-c61a3ca62d0b/image.png" alt=""></p>
<p>컨테이너가 모두 잘 띄워져 있습니다!
원래는 독립적인 공간에서 성능 테스트를 해야합니다. 비용 이슈로 저는 한 서버에서 진행했습니다. 연습용이기도 하구요~
스트레스 테스트에서 서버 터지면 성능 테스트 결과도 볼 수 없기 때문에 부하 테스트만 진행해보도록 하겠습니다.</p>
<h3 id="influxdb-연결">influxdb 연결</h3>
<p>aws public ip 주소를 통해 grafana에 접속합니다. 설정에 맞췄다면 <code>{ip주소}:3000</code> 입니다.
화면에서 datasource를 클릭하여 새로 influxdb를 만들어줍니다.
저는 이미 생성해서 아래와 같이 생성된 화면이 나왔습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/a7d2fe97-2bac-4fab-9d63-b5e7c183d854/image.png" alt="">
아래와 같이 포트와 DB 이름을 설정하고 save &amp; test버튼을 클릭하면 정상적이라는 메시지가 뜹니다.
<img src="https://velog.velcdn.com/images/hooni_/post/190fcf1d-5635-4838-92a3-7dea82f8c2a0/image.png" alt=""></p>
<h3 id="대시보드-생성">대시보드 생성</h3>
<p>이제 GUI로 볼 수 있는 대시보드를 만들어보도록 하겠습니다.
grafana 홈 화면에서 햄버거 버튼을 클릭하면 아래와 같은 화면이 나옵니다
<img src="https://velog.velcdn.com/images/hooni_/post/0b89537d-1019-4aa6-a033-c905e42b0ec9/image.png" alt="">
Dashboards를 클릭합니다.
<img src="https://velog.velcdn.com/images/hooni_/post/2075acdd-ec5c-4134-aaf0-9ee5a82b29a2/image.png" alt="">
그리고 import를 해서 예쁜 디자인의 대시보드를 들고오도록 하겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/9a79a782-e1f9-422e-9e4b-57a0917164a0/image.png" alt="">
<a href="https://grafana.com/grafana/dashboards/2587">https://grafana.com/grafana/dashboards/2587</a> 를 입력하고 load 버튼을 클릭하면 아래와 같은 화면이 나타납니다.
<img src="https://velog.velcdn.com/images/hooni_/post/35ccec04-0864-4134-b198-191714b9bb31/image.png" alt="">
이전에 생성한 k6 데이터 소스를 import 하면 모든 준비는 끝입니다.</p>
<h3 id="테스트-진행">테스트 진행</h3>
<p>드디어 테스트를 gui로 보는 과정입니다.
다시 aws로 돌아와 성능 테스트를 실행합니다.</p>
<pre><code class="language-bash">./k6 run --out influxdb=localhost:8086/k6 {스크립트 파일명} </code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/9d3b9c86-b3f0-4020-98ac-65232601e3ad/image.png" alt=""></p>
<p>그리고 그라파나를 확인해볼까요~
<img src="https://velog.velcdn.com/images/hooni_/post/5a556f7b-e07d-4724-80bf-1e113001f753/image.png" alt=""></p>
<p>성공적으로 떴습니다.</p>
</br>

<h2 id="마무리">마무리</h2>
<p>이제 스트레스 테스트의 지표로 어떤 부분을 개선해야할지 알아봐야 할 것 같습니다...</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://k6.io/blog/k6-vs-jmeter/">https://k6.io/blog/k6-vs-jmeter/</a></li>
<li><a href="https://k6.io/docs/get-started/running-k6/">https://k6.io/docs/get-started/running-k6/</a></li>
<li><a href="https://velog.io/@sontulip/performance-test">https://velog.io/@sontulip/performance-test</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[MySQL Index]]></title>
            <link>https://velog.io/@hooni_/MySQL-Index</link>
            <guid>https://velog.io/@hooni_/MySQL-Index</guid>
            <pubDate>Sat, 26 Aug 2023 07:03:15 GMT</pubDate>
            <description><![CDATA[<p>오늘은 데이터베이스 인덱스에 대해 학습했는데요.
이에 관해 글을 쓰고자 합니다.</p>
<h2 id="index-란">index 란?</h2>
<p>인덱스는 빠른 정렬과 그룹핑을 위한 데이터베이스 기능입니다.
지정한 칼럼들을 기준으로 index를 설정하면 <strong>지정한 칼럼들이 정렬되어</strong> index로 나타납니다.</p>
<h4 id="장점">장점</h4>
<ul>
<li>binary search 방식으로 조회하기 때문에 O(logN)의 시간복잡도를 가져 full scan 하는 O(N)보다 성능이 좋습니다.</li>
<li>index는 메모리 영역에 올라가 있으므로 캐싱을 한다면 디스크로 조회하는 것보다 빠릅니다.</li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li>insert, update, delete 시에는 index를 재설정 해주어야하기 때문에 성능적으로 더 느립니다.</li>
<li>index를 설정하게 되면 데이터베이스 공간을 더 차지합니다.</li>
</ul>
<h4 id="언제-사용할까">언제 사용할까?</h4>
<p>조회 시에는 성능적으로 유리하고 생성, 수정, 삭제 시에는 성능적으로 비효율적이기 때문에 조회 쿼리가 많이 쓰이는 table에 index를 설정하면 됩니다!
하지만 조회 또한 권장하는 방향이 있는데요. 1개의 칼럼만 인덱스를 걸어야 한다면 해당 컬럼은 <strong>카디널리티(Cardinality)</strong>가 가장 높은 것을 잡아야 유리합니다.</p>
<blockquote>
<p>카디널리티는 전체 행에 대한 특정 컬럼의 중복 수치를 나타내는 지표이다.
중복도가 ‘낮으면’ 카디널리티가 ‘높다’고 표현한다.
중복도가 ‘높으면’ 카디널리티가 ‘낮다’고 표현한다.</p>
</blockquote>
</br>

<h2 id="index-만들어보기">Index 만들어보기</h2>
<p>실험을 위해 아래와 같은 테이블로 index를 만들어보도록 하겠습니다.
<a href="https://www.bigdata-culture.kr/bigdata/user/data_market/detail.do?id=63513d7b-9b87-4ec1-a398-0a18ecc45411#!">빅데이터 사이트</a>에서 도서 데이터를 50만 개 다운받아 실험 데이터베이스에 넣어주었습니다.</p>
<pre><code class="language-sql">create table book
(
    SEQ_NO                    bigint         not null
        primary key,
    ISBN_THIRTEEN_NO          varchar(13)    not null,
    VLM_NM                    varchar(20)    null,
    TITLE_NM                  varchar(1000)  not null,
    AUTHR_NM                  varchar(1000)  null,
    PUBLISHER_NM              varchar(1000)   null,
    PBLICTE_DE                varchar(30)    null,
    ADTION_SMBL_NM            varchar(5)     null,
    PRC_VALUE                 decimal(9, 2)  null,
    IMAGE_URL                 varchar(1000)  null,
    BOOK_INTRCN_CN            varchar(1000)  null,
    KDC_NM                    decimal(14, 8) null,
    TITLE_SBST_NM             varchar(1000)  null,
    AUTHR_SBST_NM             varchar(1000)  null,
    TWO_PBLICTE_DE            varchar(10)    null,
    INTNT_BOOKST_BOOK_EXST_AT varchar(1)     null,
    PORTAL_SITE_BOOK_EXST_AT  varchar(1)     null,
    ISBN_NO                   varchar(41)    null
);</code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f50a563b-0416-45f1-b246-75ea8dde451d/image.png" alt=""></p>
<p>보통 isbm 13자리로 도서를 검색하는 경우가 많다고 생각해 ISBM_THIRTEEN_NO 칼럼을 index로 걸어보겠습니다. </p>
<pre><code class="language-sql">create index book_idx on book(ISBN_THIRTEEN_NO);</code></pre>
<p>index가 잘 걸렸는지 확인해보겠습니다.</p>
<pre><code class="language-sql">show index from book;</code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/567febe3-644e-4943-8e4f-cf6bf70431f7/image.png" alt="">
사진을 보면 book_idx가 잘 들어가있는 것을 확인할 수 있습니다.
하지만 걸지 않은 PRIMARY라는 이름의 index도 보이는데요. 이는 테이블을 생성할 때 PK 기준으로 자동으로 index를 생성해주는 mysql의 특징 때문입니다.</p>
<h3 id="성능-검증">성능 검증</h3>
<p>index를 걸기 전과 후의 속도를 비교해보도록 하겠습니다.</p>
<h4 id="index-적용-전">index 적용 전</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/5f3d872e-1e4c-45bf-9ff1-20a8cb27b20d/image.png" alt=""></p>
<h4 id="index-적용-후">index 적용 후</h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f632739e-cde6-4d38-8b1a-b3e7581e8e17/image.png" alt="">
31ms로 10배 이상의 성능 차이를 보였습니다.
실행시간만 본다면 60배 이상의 차이를 보였습니다.</p>
<h4 id="주의-사항">주의 사항</h4>
<p>단일 칼럼으로 index를 구성했기 때문에 다른 칼럼에 대해서는 정렬된 상태로 저장되어있지 않습니다. 따라서 <code>where a = 1 and b = 2</code>와 같은 다중 필터링의 경우에는 index를 적용할 때보다 성능이 떨어질 수도 있습니다.
(보통 다중 필터링을 사용하는 경우는 칼럼 자체의 카디널리티가 떨어질 것이기 때문에 index를 사용하지 않을 것 같습니다.)</p>
</br>
그럼 MySQL index는 어떤 방법으로 이렇게 빠르게 데이터를 검색할 수 있는 것일까요?

<h3 id="b-tree">B-Tree</h3>
<p>index는 B-tree 구조로 되어있는데요. 이 구조에서 이분 탐색까지 더해져 좋은 성능을 내는 것입니다.
완벽하게 B-tree의 구조를 이해하지는 못했습니다.
따라서 해당 내용에 대해 궁금하신 분들은 <a href="https://jojoldu.tistory.com/243">동욱님의 블로그</a>를 참고해주시면 감사하겠습니다.</p>
<p>B-tree 구조에서 root와 branch는 자식 노드의 위치를 저장하고, leaf는 조회하려는 데이터 칼럼의 데이터베이스 포인터를 저장합니다. 그리고 포인터를 통해서 데이터베이스의 해당 주소로 바로 이동하는 방식입니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/3aef37c4-c5ed-4ca1-952d-8c3b1caf7792/image.png" alt="">
ISBN_THIRTEEN_NO가 1313131313131인 책을 찾는다고 가정했을 때 위와 같이 Root1 -&gt; BRANCH2 -&gt; LEAF4 를 거쳐 데이터베이스 주소로 다이렉트로 이동하는 방식입니다. (실제로는 브랜치가 더 깊게 퍼져있습니다.)</p>
</br>

<h2 id="composite-index">Composite Index</h2>
<p>두 가지 이상의 칼럼으로 조합된 index를 composite index라고 합니다.
두 가지 칼럼의 조합으로 카디널리티가 높을 때 사용합니다.
지금 도서 데이터에서는 _<strong>책 제목 + 저자</strong>_를 기준으로 복합 index를 적용할 수 있겠습니다.
책 제목은 겹치는 경우가 종종 있고, 한 저자가 여러권의 서로 다른 책을 쓰는 경우도 많죠. 하지만 보통 작가들은 겹치는 책 제목을 짓지는 않을 것 같아서 _<strong>책 제목 + 저자</strong>_의 조합은 카디널리티가 높다고 생각했습니다.</p>
<p>어떻게 composite index를 만드는지 살펴보겠습니다.</p>
<pre><code class="language-sql">create index title_author_idx on book(TITLE_NM, AUTHOR_NM);</code></pre>
<p>이전과 크게 다르지 않습니다. 하지만 동작 방식에서 차이가 있습니다.</p>
<p>만약 TITLE_NM 칼럼만을 index로 설정했다면 title은 사전순대로 정렬되어 있지만 author는 사전순대로 정렬되어 있지 않습니다.
당연한 이야기인데요. index를 title에만 걸었으니 나머지 칼럼에 대해서는 정렬이 되어있지 않게 index가 생성됩니다.
결국 title만 이분 탐색을 하고, author에 대해서는 full scan을 하게 됩니다.</p>
<p>복합 index는 title로도 정렬하고, author에 대해서도 정렬하게 됩니다. 두 칼럼 모두를 검색할 때 이분 탐색을 합니다.</p>
<h3 id="단점-1">단점</h3>
<p>하지만 단점 또한 존재합니다.
<em><strong>책 제목 + 저자</strong></em> 조합이 아닌 <strong><em>저자</em></strong>로만 검색하고 싶을 땐 full scan을 하게 됩니다.</p>
<p>_<strong>책 제목</strong>_으로 검색하는 경우에는 이 composite index를 사용하면 빠르게 검색할 수 있습니다. 1차 정렬을 책 제목으로 했고, 2차 정렬을 저자로 했기 때문입니다. 하지만 저자로 검색하는 경우 composite index가 처음에는 제목 기준으로 정렬되어 있기 때문에 full scan을 하는 것보다 더 오래 걸릴 수도 있습니다.</p>
<pre><code class="language-sql">select * from book where title_nm = &#39;클린코드&#39;;
select * from book where title_nm = &#39;클린코드&#39; and author_nm = &#39;로버트C.마틴&#39;;</code></pre>
<p>위 두 가지 경우에는 빠르게 검색 가능하지만</p>
<pre><code class="language-sql">select * from book where author_nm = &#39;로버트C.마틴&#39;;</code></pre>
<p>위와 같은 경우는 검색 속도가 떨어집니다.</p>
<p>이를 해결하기 위해서는 저자에 대한 index를 만들어주어야 합니다. index를 더 만들면 좋지만 그만큼 용량 차지도 하기 때문에 정말 자주 사용하는 쿼리인지 판단하고 신중하게 index를 설정하는 것이 좋습니다.</p>
</br>

<h2 id="explain">Explain</h2>
<p>index가 많아질수록 쿼리가 어떤 index를 사용하고 있는지 헷갈릴 수 있는데요.
이때 explain 키워드를 통해 어떤 index를 사용하고 있는지 알 수 있습니다.</p>
<pre><code class="language-sql">create index isbn_no_idx on book(ISBN_NO);
create index isbn_thirteen_no_idx on book(ISBN_THIRTEEN_NO);
create index isbn_no_isbn_thirteen_no_idx on book(ISBN_NO, ISBN_THIRTEEN_NO);</code></pre>
<p>이렇게 3개의 index를 생성했다고 가정하겠습니다.</p>
<pre><code class="language-sql">explain select SQL_NO_CACHE * from book where ISBN_NO = &#39;9780358354352&#39;;</code></pre>
<p>위의 explain 문을 실행시키면
<img src="https://velog.velcdn.com/images/hooni_/post/59e29cbc-149c-4b4e-8c95-196bbcd71412/image.png" alt="">
사진과 같이 <code>isbn_no_idx</code>를 사용하고 있다고 알려주고 있습니다.
그리고 가능한 index 목록도 보여주는데요. 저는 isbn_no 칼럼 만을 index로 만들기도 했고 isbn_no + isbn_thirteen_no 조합으로도 index를 만들었습니다. 두 index를 사용할 수 있고 mysql은 isbn_no_idx를 사용한 모습입니다.</p>
<p>만약 isbn_thirteen_no를 검색할 땐 어떻게 될까요?</p>
<pre><code class="language-sql">explain select SQL_NO_CACHE * from book where ISBN_THIRTEEN_NO = &#39;9780358354352&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/829fb0d2-f926-4843-a98e-1d5640a7d700/image.png" alt="">
사진과 같이 isbn_thirteen_no 만을 이용한 index를 사용하고 가능한 index 목록에도 해당 index밖에 표시되지 않습니다.
composite index도 있는데 해당 index를 possible keys에 포함시키지 않은 이유는 mysql에서는 첫 순서의 칼럼이 포함되어 있지 않은 select 문에 대해서는 possible keys로 두지 않기 때문입니다.</p>
<p>그리고 mysql은 optimizer가 알아서 적절하게 index를 선택할 수 있게 하기 때문에 index를 선택하지 않아도 index 방식으로 조회를 하는 것입니다.</p>
</br>

<h2 id="index-hint">Index hint</h2>
<p>그렇다면 개발자가 원하는 index를 사용하게 하려면 어떻게 해야할까요?</p>
<h3 id="use">use</h3>
<p>select 시에 use 키워드를 사용하면 해당 index로 조회가 가능합니다!</p>
<pre><code class="language-sql">explain select SQL_NO_CACHE * from book use index(isbn_no_isbn_thirteen_no_idx) where ISBN_NO = &#39;9780358354352&#39;;</code></pre>
<p>이전에 같은 쿼리에서 optimizer는 isbn_no_idx를 사용했는데요. 이렇게 use를 이용해서 isbn_no_isbn_thirteen_no_idx로 지정해주게 되면 해당 index를 사용합니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/0333f17b-bd48-4832-9a56-8adf8412bb61/image.png" alt=""></p>
<h3 id="force">force</h3>
<pre><code class="language-sql">explain select SQL_NO_CACHE * from book force index(isbn_no_isbn_thirteen_no_idx) where ISBN_NO = &#39;9780358354352&#39;;</code></pre>
<p>force도 use와 같이 원하는 index를 사용하게끔 합니다.
하지만 이름에서 유추할 수 있듯이 더 강한 권장처럼 보이죠.
어떤 것에 use를 써야하고 어떤 것에 force를 써야하는지 추상적이어서 저는 헷갈렸었는데요.
아래와 같은 특징을 가집니다.</p>
<ul>
<li>use<ul>
<li>optimizer에게 지정한 index를 사용하라고 권장합니다.</li>
<li>만약 full scan이 더 빠르다면 optimizer는 index 대신 full scan으로 수행할 수 있습니다.</li>
</ul>
</li>
<li>force<ul>
<li>full scan이 더 효율적이어도 무조건 index를 사용합니다.</li>
<li>index를 사용할 수 없는 쿼리 (index가 걸려있지 않은 컬럼이 조건에 있는 경우)인 경우에만 다른 방법을 선택할 수 있습니다.</li>
</ul>
</li>
</ul>
<h3 id="ignore">ignore</h3>
<pre><code class="language-sql">explain select SQL_NO_CACHE * from book ignore index(isbn_no_isbn_thirteen_no_idx) where ISBN_NO = &#39;9780358354352&#39;;</code></pre>
<p>ignore index는 이름에서 유추할 수 있듯이 지정한 index를 무시하는 문법입니다.
위의 예시처럼 isbn_no_isbn_thirteen_no_idx를 무시하면 이 index를 제외하고 다른 index로 실행하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/d51dfaf2-64d4-4a29-a2d1-6ad489e11a50/image.png" alt="">
기존의 isbn_no_index를 사용하고 있네요!</p>
</br>

<h2 id="covering-index">Covering index</h2>
<p>굳이 database까지 가지 않고 index만으로 커버할 수 있는 것을 말합니다.
예를 들어 아래와 같은 쿼리가 있다고 가정하겠습니다.</p>
<pre><code>select book.ISBN_NO, book.ISBN_THIRTEEN_NO from book where ISBN_NO =&#39;8988557212&#39;;</code></pre><p>이전에 걸어둔 isbn_no_isbn_thirteen_no_idx 인덱스가 존재한다면 해당 인덱스에서 모든 정보를 추출할 수 있기 때문에 데이터베이스까지 통신하지 않고 메모리 영역에서만 탐색하고 결과를 반환합니다.
데이터베이스와 직접 통신하지 않기 때문에 속도가 개선이 되겠죠!</p>
</br>

<h2 id="마무리">마무리</h2>
<p>간단한 index 관련해서 글을 남겨보았는데요.
이외에 hash index, b+ tree 등 여러 개념을 봤는데, 아직 공부할 필요성을 느끼지 못해 자세히 보지 않았습니다.
나중에 필요할 때 사용해볼 예정입니다.</p>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=IMDH4iAQ6zM">쉬운 코딩님 강의</a></li>
<li><a href="https://jojoldu.tistory.com/243">동욱님 블로그</a></li>
<li><a href="https://code-lab1.tistory.com/217">https://code-lab1.tistory.com/217</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[MySQL Transaction 격리 수준]]></title>
            <link>https://velog.io/@hooni_/yj4y7dzl</link>
            <guid>https://velog.io/@hooni_/yj4y7dzl</guid>
            <pubDate>Thu, 24 Aug 2023 15:22:03 GMT</pubDate>
            <description><![CDATA[<p>이전까지는 mysql로 쿼리 짜고, 테이블 만들고, 데이터베이스 만드는 정도로만 사용할 줄 알았습니다.<br>요즘 조금 더 심화적인 데이터베이스의 중요성에 대해 깨달아 관련 공부를 시작했습니다.</p>
<p>그 첫 주제로 데이터베이스의 격리 수준에 대해 정리해보고자 합니다.</p>
<h3 id="트랜잭션"><strong>트랜잭션</strong></h3>
<p><strong>논리적 작업 단위</strong>를 말합니다.</p>
<p>더 이상 나눌 수 없는 작업 단위인데요. 계좌 이체를 예로 들겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/78fe16e4-d333-4204-b9f4-2aee89cfac7d/image.png" alt=""></p>
<p>계좌 이체를 처리하려면</p>
<p>1. 사용자 A의 잔액을 20만원 줄이는 데 성공한다.</p>
<p>2. 사용자 B의 잔액을 20만원 더하는 데 성공한다.</p>
<p>두 가지 모두 성공해야합니다.</p>
<p>쿼리는 2개를 실행하더라도 논리적 작업 단위는 하나이기 때문에 이것을 트랜잭션이라고 합니다.</p>
<h3 id="트랜잭션의-특징"><strong>트랜잭션의 특징</strong></h3>
<p>데이터베이스에서 트랜잭션의 특징이 있는데요. <strong>ACID</strong> 라고 합니다.</p>
<p>총 4가지로 <strong>A</strong>tomicity(원자성), <strong>C</strong>onsistency(일관성), <strong>I</strong>solation(독립성), <strong>D</strong>urability(지속성) 이 있습니다.</p>
<p>하나하나 살펴보도록 하겠습니다.</p>
<h4 id="atomicity원자성"><strong>Atomicity(원자성)</strong></h4>
<p>트랜잭션이 데이터베이스에 모두 다 반영되거나, 모두 다 반영되지 않아야 한다는 특징입니다.</p>
<p>여러 개의 쿼리 중에 일부만 성공했다고 해서 일부만 정상 처리하게 된다면 데이터 정합성이 깨지기 때문에 무조건 모두 다 성공해야 commit을 하고, 그렇지 않으면 rollback을 실행한다는 원칙입니다.</p>
<h4 id="consistency일관성"><strong>Consistency(일관성)</strong></h4>
<p>트랜잭션의 작업 처리 결과가 항상 일관성이 있어야 한다는 특징입니다.</p>
<p>예를 들어 트랜잭션이 완료되면 계좌 잔액의 자료형이 Long에서 String으로 변하지 않는다는 것을 보장한다는 뜻입니다.<br>저는 너무 당연한 말이라고 생각해 100% 완벽하게 이해한 것이 맞는지 잘 모르겠습니다...</p>
<h4 id="isolation독립성"><strong>Isolation(독립성)</strong></h4>
<p>트랜잭션은 다른 트랜잭션이 존재하지 않는 것처럼 서로 간섭 없이 수행되어야 한다는 것을 의미합니다.<br>즉, 하나의 트랜잭션이 커밋되기 전까지 다른 트랜잭션은 특정 트랜잭션의 결과를 참조할 수 없습니다.</p>
<h4 id="durability지속성"><strong>Durability(지속성)</strong></h4>
<p>트랜잭션 커밋 후에는 시스템이 중단되거나 장애가 발생해도 데이터가 그대로 유지되어야 한다는 특징입니다.<br>작업 도중에 에러가 발생하더도 커밋된 데이터는 유지된 상태로 데이터가 변하지 않아야 한다는 뜻입니다.</p>
<h3 id="commit-rollback"><strong>Commit, Rollback</strong></h3>
<h4 id="commit"><strong>Commit</strong></h4>
<p>트랜잭션이 성공적으로 마무리되어, 하나의 트랜잭션이 끝났다는 것을 알려주기 위해 사용하는 연산입니다.<br>저는 자바의 try-catch 구문에서 try 부분이 성공했다는 표현으로 생각했습니다!</p>
<h4 id="rollback"><strong>Rollback</strong></h4>
<p>트랜잭션이 실패했고, 이전 상태로 롤백한다는 것을 알려주기 위해 사용하는 연산입니다.<br>저는 자바의 try-catch 구문에서 catch 부분에 도달했을 때 이전 연산을 원래 상태로 돌리는 표현으로 생각했습니다.</p>
<h3 id="innodb-스토리지-엔진"><strong>InnoDB 스토리지 엔진</strong></h3>
<p>Mysql 스토리지에 memory, MyISAM 등이 있는데 이 중에서 가장 많이 사용되는 스토리지 엔진입니다.</p>
<p>다른 엔진들과 달리 InnoDB는 트랜잭션을 지원한다고 합니다.</p>
<p>원자성으로 예를 들어보겠습니다.</p>
<pre><code class="language-sql">CREATE TABLE inno_db_test (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(20)
) ENGINE=InnoDB;

INSERT INTO inno_db_test VALUES (2, &#39;ditoo&#39;);
INSERT INTO inno_db_test VALUES (1, &#39;hongsil&#39;), (2, &#39;ditoo&#39;), (3, &#39;ethan&#39;);
SELECT * FROM inno_db_test; -- inno_db 결과?

CREATE TABLE myisam_test (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(20)
) ENGINE=MyISAM;

INSERT INTO myisam_test VALUES (2, &#39;ditoo&#39;);
INSERT INTO myisam_test VALUES (1, &#39;hongsil&#39;), (2, &#39;ditoo&#39;), (3, &#39;ethan&#39;);
SELECT * FROM myisam_test; -- myisam 결과?</code></pre>
<p>위의 코드에서 inno_db의 결과는 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td>ditoo</td>
</tr>
</tbody></table>
<p>그리고 myisam의 결과는 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td>ditoo</td>
</tr>
<tr>
<td>1</td>
<td>hongsil</td>
</tr>
</tbody></table>
<p>차이는 transaction의 지원 여부에 있습니다.</p>
<p>inno_db의 경우 트랜잭션을 지원하기 때문에 insert 문을 실행할 때 자동으로 트랜잭션 처리가 되어 여러 value 중 하나라도 잘못되면 전체 롤백에 됩니다. 그래서 조회 결과에 id = 2 인 데이터밖에 조회되지 않은 것입니다.</p>
<p>반면에 myisam의 경우 트랜잭션을 지원하지 않기 때문에 insert 문에 여러 value를 넣었을 때 성공하는 insert까지 데이터베이스에 반영됩니다. 즉, 2번째 insert에서 id = 1 까지 성공하고 id = 2 인 것부터 pk가 겹치기 때문에 실패했기 때문에 id = 1 인 것까지 데이터베이스에 반영이 된 것입니다.</p>
<p>mysql 5.5.5부터 inno db를 디폴트 스토리지 엔진으로 사용하고 있다고 합니다. (왜 그전까지는 myisam을 썼는지 이해되지 않음...)</p>
<h3 id="트랜잭션의-격리-수준"><strong>트랜잭션의 격리 수준</strong></h3>
<p>트랜잭션에 대해 짧게 살펴보았으니, 조금 더 심화적인 부분인 트랜잭션 격리 수준에 대해 알아보고자 합니다.</p>
<p>Real MySQL 8.0에 따르면</p>
<blockquote>
<p>트랜잭션의 격리 수준이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다.  </p>
</blockquote>
<p>라고 합니다.</p>
<p>즉, 서로 다른 두 개 이상의 트랜잭션이 동시에 같은 데이터를 처리할 때 어떤 방식으로 처리해야 하는지에 대해 다룬 것입니다.</p>
<table>
<thead>
<tr>
<th>격리 수준</th>
<th>dirty read</th>
<th>non-repeatable read</th>
<th>phantom read</th>
</tr>
</thead>
<tbody><tr>
<td>read uncommitted</td>
<td>o</td>
<td>o</td>
<td>o</td>
</tr>
<tr>
<td>read committed</td>
<td>x</td>
<td>o</td>
<td>o</td>
</tr>
<tr>
<td>repeatable read</td>
<td>x</td>
<td>x</td>
<td>o</td>
</tr>
<tr>
<td>serializable</td>
<td>x</td>
<td>x</td>
<td>x</td>
</tr>
</tbody></table>
<p>위 표와 같이 격리 수준을 나타내곤 하는데요. 하나하나 살펴보도록 하겠습니다.</p>
<h3 id="읽기-부정합"><strong>읽기 부정합</strong></h3>
<p>트랜잭션의 격리 수준에 따라 발생할 수 있는 문제점들입니다.<br>이 문제점들을 기준으로 격리 수준을 나눌 수 있습니다.</p>
<h4 id="dirty-read"><strong>Dirty Read</strong></h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/48c82ac6-faef-4ef5-b3a8-86dea320d815/image.png" alt=""></p>
<p>위 그림에서 Transaction 2가 Y를 80으로 바꾸었습니다. 하지만 커밋되지 않고 롤백이 되었죠.<br>Transaction 1은 Transaction 2가 롤백하기 전의 Y를 읽었습니다. 따라서 X는 100으로 변했고 커밋되었습니다.<br>여기서 모순이 발생합니다.</p>
<p>X에 Y를 더하면 20 + 30이 계산되어 50이 되어야 하는데, Transaction 1이 롤백하기 전의 Y를 읽어 X가 100으로 변한 것을 확인할 수 있습니다.<br>이런 현상을 <strong>Dirty Read</strong> 라고 합니다.</p>
<h4 id="non-repeatable-read"><strong>Non-Repeatable Read</strong></h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/0528ad6a-33fb-4a9c-84c0-3af79ac6e293/image.png" alt=""></p>
<p>X를 두 번 읽는 Transaction 1의 작업에서 두 번 읽은 X의 값이 서로 달랐습니다.<br>Transaction 2가 중간에 X의 값을 변경했기 때문인데요.<br>이렇게 같은 Transaction에서 같은 데이터를 읽는데 값이 서로 다르게 읽히는 현상을 <strong>Non-Repeatable Read</strong> 라고 합니다.</p>
<h4 id="phantom-read"><strong>Phantom Read</strong></h4>
<p><img src="https://velog.velcdn.com/images/hooni_/post/c4b5debf-5b8b-406d-9e8b-ddc3e2625535/image.png" alt=""></p>
<p>Non-Repeatable Read의 일종입니다. 한 필터로 조회한 데이터들의 묶음이 달라지는 현상을 말합니다.</p>
<p>위의 그림에서 Transaction 2가 t2의 v를 10으로 변경하여 Transaction 1의 두 번의 조회값이 서로 달라졌습니다.<br>앞서 살펴본 Non-Repeatable Read와 매우 유사합니다.</p>
<h3 id="격리-수준-isolation-level"><strong>격리 수준 (Isolation Level)</strong></h3>
<p>격리 수준은 총 4단계로 read uncommitted -&gt; read committed -&gt; repeatable read -&gt; serializable 순으로 격리 수준이 높습니다.</p>
<p>MySQL innoDB의 디폴트 격리 수준은 3단계인 repeatable read 라고 합니다.</p>
<p>그럼 하나하나 살펴보도록 하겠습니다. </p>
<h4 id="read-uncommitted"><strong>Read Uncommitted</strong></h4>
<p>가장 낮은 단계의 격리 수준입니다. read uncommitted에서는 dirty read, non-repeatable read, phantom read 모두 허용합니다.</p>
<pre><code class="language-sql">-- 이하 1번 커넥션

START TRANSACTION;  # 2
INSERT INTO inno_db_test VALUES (1, &#39;test&#39;);  # 5
ROLLBACK ;  # 7</code></pre>
<pre><code class="language-sql">-- 이하 2번 커넥션

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;  # 1

START TRANSACTION;  # 3
SELECT * FROM inno_db_test;  # 4
SELECT * FROM inno_db_test;  # 6
ROLLBACK;  # 8</code></pre>
<p>위의 주석 순서로 실행하게 되면 2번 커넥션의 첫 select에서는 아래와 같이 나옵니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
</table>
<p>그리고 두 번째 select에서는 아래와 같이 데이터가 생긴 것을 확인할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
</tbody></table>
<p>이로 격리 수준이 read uncommitted 인 경우 커밋되지 않은 데이터까지 읽는다는 것을 확인할 수 있습니다.</p>
<h4 id="read-committed"><strong>Read Committed</strong></h4>
<p>두 번째 단계의 격리 수준입니다. read uncommitted와 달리 커밋된 데이터만 읽을 수 있습니다.</p>
<p>하지만 non-repeatable read를 허용하기 때문에 한 트랜잭션에서 같은 데이터를 두 번 읽을 때 서로 다른 값이 나올 수 있습니다.</p>
<p>우선 커밋된 데이터를 읽는지 확인해 보겠습니다.</p>
<pre><code class="language-sql">-- 이하 1번 커넥션

START TRANSACTION;  # 3
INSERT INTO inno_db_test VALUES (1, &#39;test&#39;);  # 4
COMMIT;  # 6</code></pre>
<pre><code class="language-sql">-- 이하 2번 커넥션

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;  # 1

START TRANSACTION;  # 2
SELECT * FROM inno_db_test;  # 5
SELECT * FROM inno_db_test;  # 7
ROLLBACK;  # 8</code></pre>
<p>해당 주석 순서로 실행했을 때, 2번 커넥션의 첫 select에서는 아무 데이터도 읽지 않는다는 것을 확인할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
</table>
<p>1번 커넥션을 커밋하고 나면 7번 주석의 select를 실행할 때 데이터가 삽입되어 있는 것을 확인할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
</tbody></table>
<p>그렇다면 non-repeatable read는 올바르게 동작할까요?</p>
<pre><code class="language-sql">-- 이하 1번 커넥션

START TRANSACTION;  # 3
UPDATE inno_db_test SET name = &#39;updated&#39; WHERE id = 1;  # 4
COMMIT;  # 6</code></pre>
<pre><code class="language-sql">-- 이하 2번 커넥션

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;  # 1

START TRANSACTION; # 2
SELECT * FROM inno_db_test; # 5
SELECT * FROM inno_db_test; # 7
ROLLBACK; # 8</code></pre>
<p>해당 주석을 실행하기 전 데이터베이스 상태는 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
</tbody></table>
<p>위 주석 순서로 실행했을 때, 5번 주석을 실행하면 아래와 같이 나옵니다. 데이터가 바뀌지 않았죠.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
</tbody></table>
<p>1번 커넥션을 커밋하고 7번 주석을 실행하면 아래와 같이 데이터가 업데이트 되었다는 것을 확인할 수 있었습니다. </p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>updated</td>
</tr>
</tbody></table>
<p>이 결과로 non-repeatable read 현상이 발생한 것을 확인할 수 있었습니다.</p>
<h4 id="repeatable-read"><strong>Repeatable Read</strong></h4>
<p>위의 read committed 수준에서 한 단계 더 강화된 격리 수준입니다.</p>
<p>방금 살펴본 non-repeatable read가 발생하지 않도록 격리시킵니다.</p>
<p>그럼 non-repeatable read가 정말 발생하지 않는지 앞선 실험과 같은 데이터로 실험해 보도록 하겠습니다.</p>
<pre><code class="language-sql">-- 이하 1번 커넥션

START TRANSACTION;  # 3
UPDATE inno_db_test SET name = &#39;updated&#39; WHERE id = 1;  # 4
COMMIT;  # 6</code></pre>
<pre><code class="language-sql">-- 이하 2번 커넥션

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;  # 1

START TRANSACTION; # 2
SELECT * FROM inno_db_test; # 5
SELECT * FROM inno_db_test; # 7
ROLLBACK; # 8</code></pre>
<p>이전과 같이 실행 전 데이터베이스 상태는 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
</tbody></table>
<p>그리고 주석 순서대로 진행하여 두 번의 select를 실행했을 때 결과는 모두 아래와 같았습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
</tbody></table>
<p>데이터는 변하지 않았고, 이로 인해 non-repeatable read 가 발생하지 않는다는 것을 검증했습니다.</p>
<p>repeatable read 격리 수준에서 phantom read가 발생하는지 확인해 보도록 하겠습니다.</p>
<pre><code class="language-sql">-- 이하 1번 커넥션

START TRANSACTION;  # 2
UPDATE inno_db_test SET name = &#39;updated&#39; WHERE id = 1;  # 3
COMMIT;  # 6</code></pre>
<pre><code class="language-sql">-- 이하 2번 커넥션

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;  # 1

START TRANSACTION;  # 4
SELECT * FROM inno_db_test where name = &#39;test&#39;;  # 5
SELECT * FROM inno_db_test where name = &#39;test&#39;;  # 7
ROLLBACK;  # 8</code></pre>
<p>해당 주석을 실행하기 전 데이터베이스 상태는 아래와 같습니다.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
<tr>
<td>2</td>
<td>test</td>
</tr>
</tbody></table>
<p>그리고 주석 순서대로 실행시켰을 때 결과는 놀랍게도 두 select의 결과가 같았습니다.</p>
<p>아래와 같이 말이죠.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>test</td>
</tr>
<tr>
<td>2</td>
<td>test</td>
</tr>
</tbody></table>
<p>phantom read가 작동하지 않은 것인데요. 이론 상이라면 repeatable read 수준에서 phantom read가 발생해야 하는데, 왜 그럴까요?</p>
<p>저는 실험에 MySQL 8.0.34 버전을 사용했는데요. <strong>MySQL 8.0 부터</strong> <strong>Repeatable Read 수준에서도 Phantom Read가 발생하지 않는다</strong>고 합니다.</p>
<p>그 이유는 InnoDB 스토리지 엔진의 <strong>MVCC</strong> (Multi-Version Concurrency Control) 메커니즘 때문입니다.</p>
<p>MVCC에서 각 트랜잭션은 자신의 볼 수 있는 데이터 버전을 가지는데, 트랜잭션이 데이터를 수정하면 해당 데이터의 새 버전이 생성됩니다. 기존 버전은 유지되며 다른 트랜잭션은 이전 버전의 데이터를 볼 수 있습니다.</p>
<p>즉, 트랜잭션이 데이터를 읽을 때 해당 트랜잭션이 시작된 시점에서의 데이터 버전을 사용하여 읽습니다. 위의 실험에서 2번 커넥션이 시작했을 때 1번 커넥션이 커밋되기 전 버전의 데이터만 읽기 때문에 phantom read 가 발생하지 않았던 것입니다. 다른 트랜잭션이나 작업에 의한 데이터 변경으로 인한 영향을 받지 않기 때문에 Serializable 수준을 사용하지 않고도 phantom read 문제를 해결할 수 있었습니다.</p>
<h4 id="serializable"><strong>Serializable</strong></h4>
<p>격리 수준 최종 단계인 serializable 입니다. 이는 설명하길 데이터가 중간에 바뀔 일이 절대 없다고 합니다.</p>
<p>그렇다면 어떻게 phamtom read까지 보장할 수 있는지 살펴보도록 하겠습니다.</p>
<pre><code class="language-sql">-- 이하 1번 커넥션

START TRANSACTION;  # 3
UPDATE inno_db_test SET name = &#39;updated&#39; WHERE id = 1;  # 5
COMMIT;  # 6</code></pre>
<pre><code class="language-sql">-- 이하 2번 커넥션

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;  # 1

START TRANSACTION;  # 2
SELECT * FROM inno_db_test where name = &#39;test&#39;;  # 4
SELECT * FROM inno_db_test where name = &#39;test&#39;;  # 7
ROLLBACK;  # 8</code></pre>
<p>위의 실험에서 주석 순서대로 SQL 문을 실행시키면 5번 주석 부분부터 lock이 걸립니다.<br>2번 커넥션 트랜잭션을 다 끝내기 전까지 대기 상태에 걸리고, 2번 커넥션이 완료되어야 lock이 풀리고 SQL 문이 실행됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/b91be030-8c3f-43ff-a827-52e46e19201e/image.png" alt=""></p>
<p>repeatable read 와 달리 접근을 막아 다른 트랜잭션을 대기 상태로 만듭니다.</p>
<p>이는 자원이 겹치는 경우 성능적으로 문제가 많을 것입니다. MySQL 8.0 부터는 MVCC를 활성화하여 repeatable read 수준에서도 phantom read 문제를 해결할 수 있기 때문에 굳이 성능도 좋지 않은 serializable 수준의 격리를 사용하지 않을 것 같습니다.  </p>
<h3 id="참고"><strong>참고</strong></h3>
<ul>
<li><a href="https://www.youtube.com/watch?v=bLLarZTrebU&amp;t=45s" title="쉬운 코드님 강의 영상">https://www.youtube.com/watch?v=bLLarZTrebU&amp;t=45s</a></li>
<li>우아한테크코스 강의</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[github actions를 이용한 CD]]></title>
            <link>https://velog.io/@hooni_/github-actions%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CD</link>
            <guid>https://velog.io/@hooni_/github-actions%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CD</guid>
            <pubDate>Sun, 06 Aug 2023 19:16:09 GMT</pubDate>
            <description><![CDATA[<p>이전 CI에 이어 CD를 만들어보겠습니다.
github actions의 yaml 파일 관련 문법은 이전 글에서 언급했으므로 생략하겠습니다.</p>
<h2 id="self-hosted-runners">self-hosted runners</h2>
<p>CD를 구축하기 위해서 github actions에서 제공하는 self-hosted runners를 사용했습니다.
현재 진행하고 있는 프로젝트의 EC2는 우아한테크코스 ip로만 접속 가능하기 때문에 github actions에서 제공하는 가상 머신이 scp를 통해 빌드 파일을 전송하는 것이 불가능했습니다.
팀 동료가 self-hosted runners는 EC2 내부에서 외부로 접근하는 방식이어서 CD가 가능하다고 말해주어서 해당 방법으로 CD를 구축하게 되었습니다.</p>
<p><a href="https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners">공식 문서</a>에 따르면 작업을 실행하기 위해 배포하고 관리하는 시스템이라고 합니다.</p>
<p>github-hosted runners는 깃허브의 가상 머신을 사용하여 빌드했다면 self-hosted runners는 사용자의 서버에서 직접 빌드하는 방식입니다.</p>
</br>

<h2 id="docker-hub-이용">docker hub 이용</h2>
<p>self-hosted runners는 프로젝트의 서버 자원을 사용하기 때문에 잡일은 최대한 github-hosted runners에게 시키고 싶었습니다.
self-hosted runners에서는 오직 빌드된 jar 파일만 실행시키는 것을 원했는데요. 외부에서 ec2에 scp가 불가능하기에 도커를 이용했습니다.</p>
<p>github-hosted runners에서 jar 파일을 빌드하고 docker hub에 private image를 push하도록 구성했습니다.
ec2에서는 업데이트된 image를 docker hub로부터 pull 받고 해당 image를 이용하는 container를 실행시킵니다.</p>
<p>상세한 순서는 아래와 같습니다.</p>
<h4 id="github-hosted-runners">github-hosted runners</h4>
<ol>
<li>레포지토리에서 체크아웃을 한다.</li>
<li>jdk 17로 세팅한다.</li>
<li><code>application-dev.yml</code> 파일로 환경 설정한다.</li>
<li>gradle로 빌드한다.</li>
<li>docker 빌드 환경을 세팅한다.</li>
<li>docker hub에 로그인한다.</li>
<li>docker image를 빌드한다.</li>
<li>docker hub에 push한다.</li>
</ol>
<h4 id="self-hosted-runners-1">self-hosted runners</h4>
<ol>
<li>docker image를 pull 받는다.</li>
<li>docker container를 실행시킨다.</li>
</ol>
</br>

<h2 id="조금-더-세세하게">조금 더 세세하게...</h2>
<p>단계 하나하나 알아보도록 하겠습니다. ( <a href="https://velog.io/@hooni_/github-actions%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CI">이전 포스트</a>에 올린 github-hosted runners의 1~4 단계는 생략하겠습니다.)</p>
<h3 id="도커-빌드-환경-세팅">도커 빌드 환경 세팅</h3>
<pre><code class="language-yaml">      - name: Setup Docker buildx
        uses: docker/setup-buildx-action@v2.9.1</code></pre>
<p>도커 action을 실행하기 위한 세팅입니다.</p>
<h3 id="도커-로그인">도커 로그인</h3>
<pre><code class="language-yaml">      - name: Login to Docker Hub
        uses: docker/login-action@v2.2.0
        with:
          username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }}
          password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }}</code></pre>
<p>docker 계정의 이메일과 토큰을 github에 secrets로 등록합니다.
그래야 github-hosted runners가 등록한 이메일과 토큰으로 로그인할 수 있습니다. (토큰 대신 password를 입력해도 괜찮습니다. 하지만 토큰을 권유한다는 메시지가 뜨더라구요!)</p>
<p>docker에서 토큰을 발급받는 절차입니다.
<img src="https://velog.velcdn.com/images/hooni_/post/fb36feb8-1a18-41c5-b10b-8a64921f9dee/image.png" alt="">
우선 docker hub에 접속하여 settings -&gt; security로 이동합니다.
New Access Token 버튼을 클릭하여 새로운 토큰을 발급받습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/3fecd1dd-202f-4956-8225-8d93126f3f14/image.png" alt="">
토큰을 복사하여 secrets에 저장합니다.
<img src="https://velog.velcdn.com/images/hooni_/post/290eee5d-fcfe-4298-8252-c0e5d080a9e1/image.png" alt=""></p>
<h3 id="도커-image-빌드">도커 image 빌드</h3>
<pre><code class="language-yaml">      - name: Docker Image Build
        run: |
          docker build --platform linux/x64/v8 -t shb03323/cicd -f Dockerfile .</code></pre>
<p>docker 레포지토리에 image를 빌드하는 명령어입니다.
이미지를 만들기 위해서는 docker hub의 레포지토리가 필요한데요. 한번 만들어보겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/b330dd7e-8f97-4279-9885-2247d5974992/image.png" alt="">
먼저 docker hub의 repositorys 메뉴에 들어갑니다.
<img src="https://velog.velcdn.com/images/hooni_/post/a0638c57-0e20-4c1b-a928-4d89bf87e8fc/image.png" alt="">
그 후에 private repository를 생성합니다. 무료 계정이면 하나의 private repository를 만들 수 있습니다.</p>
<p>위의 스크립트에 <code>-f Dockerfile .</code> 명령어가 있는데요. 이는 프로젝트 내부의 Dockerfile이라는 것을 이용하여 빌드한다는 뜻입니다.
이 Dockerfile을 이용해서 jar 파일을 만들어보겠습니다.
프로젝트 레포지토리 최상단에 Dockerfile을 만들어 아래와 같은 내용으로 구성했습니다.</p>
<pre><code class="language-shell">FROM arm64v8/amazoncorretto:17

WORKDIR /app

COPY ./build/libs/cicd-0.0.1-SNAPSHOT.jar /app/cicd.jar

CMD [&quot;java&quot;, &quot;-jar&quot;, &quot;-Dspring.profiles.active=dev&quot;, &quot;cicd.jar&quot;]</code></pre>
<p>jdk 17로 설정하고, docker 레포지토리에 cicd.jar를 복사합니다.
그 후에 jar 파일로 빌드합니다.</p>
<h3 id="도커-image-push">도커 image push</h3>
<pre><code class="language-yaml">      - name: Docker Hub Push
        run: docker push shb03323/cicd</code></pre>
<p>도커 image push 명령어를 실행합니다.</p>
<h3 id="self-hosted-runners-설정하기">self-hosted runners 설정하기</h3>
<p>EC2에 self-hosted runners를 등록해야 image를 pull 받을 수 있습니다.
한번 등록해보도록 하겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/7ce85bd0-bee8-4b12-97bf-01f5b66a5acd/image.png" alt="">
레포지토리의 settings -&gt; Actions -&gt; Runners 탭을 클릭하면 위와 같은 화면이 나옵니다.
New self-hosted runner 버튼을 클릭하면 명령어들이 나오는데, 그대로 EC2 서버에 입력하시면 됩니다!
<img src="https://velog.velcdn.com/images/hooni_/post/071a2697-81d7-44d9-85ef-2f297fa4c6cb/image.png" alt="">
완료하면 예쁜 CLI창이 나옵니다.
<img src="https://velog.velcdn.com/images/hooni_/post/081e8382-183b-414a-a296-603ab86f6b78/image.png" alt="">
Runners를 다시 확인했을때 self-hosted runner가 생성된 것을 볼 수 있습니다.</p>
<h4 id="self-hosted-runners-job">self-hosted runners job</h4>
<p>github-hosted runners와 다른 환경에서 실행되기 때문에 작업 또한 달라야합니다.
따라서 cd라는 이름을 가진 job를 생성했습니다.</p>
<pre><code class="language-yaml">  cd:
    needs: ci
    runs-on: [ self-hosted, Linux, x64 ]</code></pre>
<p>ci job를 실행한 후에 cd 작업이 수행될 수 있도록 <code>needs</code> 속성을 넣어주었습니다.
또한, 환경도 다르게 설정해주었는데요. 이는 EC2 t2.micro의 환경입니다.</p>
<h3 id="self-hosted-runners에서-image-pull-받기">self-hosted runners에서 image pull 받기</h3>
<pre><code class="language-yaml">      - name: Pull Latest Docker Image
        run: |
          sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }}
          if sudo docker inspect cicd-container &amp;&gt;/dev/null; then
            sudo docker stop cicd-container
            sudo docker rm -f cicd-container
            sudo docker image prune -af
          fi
          sudo docker pull shb03323/cicd:latest</code></pre>
<p>이전에 docker hub에 push한 image를 받기 위해서는 로그인이 필요합니다.
docker hub에 로그인 유지 시간이 있을 것이 분명하기 때문에 일단 계속 로그인 시도하도록 설정했습니다.
그리고 실행중인 container를 삭제하고 새로운 이미지로 pull 받았습니다.</p>
<h3 id="container-실행">container 실행</h3>
<pre><code class="language-yaml">      - name: Run Container
        run: |
          sudo docker run --name cicd-container -p 8080:8080 shb03323/cicd:latest 1&gt;&gt; build.log 2&gt;&gt; error.log &amp;</code></pre>
<p>최신 상태로 pull 받은 image를 이용하여 container를 실행하는 명령어입니다.
이로써 모든 단계가 완료되었습니다.</p>
<h3 id="최종-스크립트">최종 스크립트</h3>
<pre><code class="language-yaml">name: continuous-deploy

on:
  push:
    branches:
      - main

jobs:
  ci:
    runs-on: ubuntu-22.04

    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: &#39;17&#39;
          distribution: &#39;temurin&#39;

      - name: Make application-dev.yml
        if: ${{ github.event.push.head.ref == &#39;main&#39; }}
        env:
          PROPERTIES_DEV: ${{ secrets.PROPERTIES_DEV }}
        run: |
          cd ./src/main/resources
          touch ./application-dev.yml
          echo &quot;${PROPERTIES_DEV}&quot; &gt; ./application-dev.yml
        shell: bash

      - name: Build with Gradle
        uses: gradle/gradle-build-action@v2.6.0
        with:
          arguments: build

      - name: Setup Docker buildx
        uses: docker/setup-buildx-action@v2.9.1

      - name: Login to Docker Hub
        uses: docker/login-action@v2.2.0
        with:
          username: ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }}
          password: ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }}

      - name: Docker Image Build
        run: |
          docker build --platform linux/x64/v8 -t shb03323/cicd -f Dockerfile .

      - name: Docker Hub Push
        run: docker push shb03323/cicd

  cd:
    needs: ci
    runs-on: [ self-hosted, Linux, X64 ]

    steps:
      - name: Pull Latest Docker Image
        run: |
          sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }}
          if sudo docker inspect cicd-container &amp;&gt;/dev/null; then
            sudo docker stop cicd-container
            sudo docker rm -f cicd-container
            sudo docker image prune -af
          fi
          sudo docker pull shb03323/cicd:latest

      - name: Run Container
        run: |
          sudo docker run --name cicd-container -p 8080:8080 shb03323/cicd:latest 1&gt;&gt; build.log 2&gt;&gt; error.log &amp;</code></pre>
<h3 id="확인해보기">확인해보기</h3>
<p>한 번 확인해볼까요?
<img src="https://velog.velcdn.com/images/hooni_/post/0b2f00eb-4922-43b2-8b61-e1feb26f51b4/image.png" alt="">
main에 push를 하니 github actions가 동작했습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/f6619c20-7551-4209-a5d9-ec2e64f2ccb1/image.png" alt="">
CI/CD 둘 다 성공한 것을 볼 수 있습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/056eacbd-b559-4253-9527-c63bceb4a36d/image.png" alt="">
EC2에서 docker가 실행된 것을 볼 수 있습니다. 비록 에러나서 꺼졌지만, cicd는 성공적으로 완수했습니다.</p>
</br>

<h2 id="마무리">마무리</h2>
<p>아직 actions로 밖에 CI/CD를 구축해보지 않았지만 과금이 되면 젠킨스로 넘어가지 않을까 합니다.
github actions는 한 달 2000분까지만 무료라고 하더군요...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[github actions를 이용한 CI]]></title>
            <link>https://velog.io/@hooni_/github-actions%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CI</link>
            <guid>https://velog.io/@hooni_/github-actions%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CI</guid>
            <pubDate>Sat, 05 Aug 2023 18:28:15 GMT</pubDate>
            <description><![CDATA[<h2 id="ci를-쓰는-이유">CI를 쓰는 이유</h2>
<p>여럿이 같이하는 프로젝트에서 코드를 통합할때 특정 부분에서 오류가 발생할 수 있습니다.
실수로 동료의 코드를 지웠다던지, 충돌 해결을 잘못했다던지, 나의 코드 변경으로 동료의 코드가 동작하지 않게 된다던지 등등... 많은 상황을 겪어보았는데요.
이때 CI(지속적 통합)를 통해 공통 브랜치로 코드가 push 될 때 마다 코드가 정상적으로 빌드되는지 확인할 수 있습니다.</p>
</br>

<h2 id="github-actions-선택-이유">github actions 선택 이유</h2>
<p>선택지로 github actions 와 jenkins 두가지가 있었는데요.
자체 서버 자원을 사용해야하고 상대적으로 무거운 jenkins보다는, build용 가상 머신을 제공해주고 가벼운 github actions를 선택했습니다.
지원하는 기능은 jenkins가 더 많지만, 그 기능들에 대해 잘 모르기도 하고 지금 프로젝트 규모에서는 github actions만으로 충분하다고 생각했습니다.</p>
</br>

<h2 id="github-hosted-runners">github-hosted runners</h2>
<p>앞에서 github actions는 빌드를 위한 가상 머신을 제공해준다고 했는데요. 이때 그 가상 머신 실행기가 github-hosted runners 입니다.
github-hosted runners는 아래와 같은 순서로 작업을 처리합니다.</p>
<ol>
<li>레포지토리를 로컬로 복제한다.</li>
<li>테스트 소프트웨어를 설치한다.</li>
<li>코드를 검사하는 명령을 실행한다.</li>
</ol>
<p>github-hosted runners를 사용하기 위해서는 작업(job)을 만들고 <code>runs-on</code> 기능을 사용하여 작업을 처리할 가상 머신의 유형(ubuntu, windows, macOS)을 지정해야 합니다.</p>
<p>작업이 시작되면 깃허브는 해당 작업을 위한 가상 머신을 자동으로 프로비저닝하고 작업을 실행합니다. 작업이 완료되면 가상 머신이 자동으로 꺼집니다.
즉, push나 pull-request와 같은 이벤트에 따라 깃허브가 알아서 자원을 할당하고 해제합니다.</p>
<h3 id="workflow">workflow</h3>
<p>깃허브에서 발생하는 이벤트를 감지하고 이벤트에 따라 작업을 실행해주는 프로세스를 workflow라고 합니다.
레포지토리의 <code>.github/workflows</code> 디렉토리 안에 yaml 파일로 생성할 수 있습니다.</p>
<p>workflow 안에서 여러 github-hosted runners가 실행됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/64745bbe-a9fb-4c4a-be6a-c6f82219b5c1/image.png" alt=""></p>
<p>현재 진행중인 프로젝트에서의 빌드 과정을 그려보았는데요.
하나의 workflow에서 백엔드 관련 runner와 프론트엔드 관련 runner를 각각 작업하도록 둔 것을 볼 수 있습니다.
하나의 작업 안에 여러 step이 있는 것을 볼 수 있는데요. step은 순서대로 작업됩니다. 
빌드를 위한 환경 설정을 초기 step에서 진행하고 마지막 step에서 빌드하는 흐름입니다.</p>
<h3 id="조금-더-세세하게">조금 더 세세하게...</h3>
<pre><code class="language-yaml">name: continuous-integration

on:
  pull_request:
    branches:
      - main

jobs:
  build-spring:
    runs-on: ubuntu-22.04

    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: &#39;17&#39;
        distribution: &#39;temurin&#39;

    - name: Make application-dev.yml
      if: ${{ github.event.pull_request.head.ref == &#39;dev&#39; }}
      run: |
        cd ./src/main/resources
        touch ./application-dev.yml
        echo &quot;${{ secrets.PROPERTIES_DEV }} &gt; ./application-dev.yml
      shell: bash

    - name: Build with Gradle
      uses: gradle/gradle-build-action@v2.6.0
      with:
        arguments: build</code></pre>
<p>임의로 스크립트를 만들어 보았는데요. 위 스크립트를 예시로 하나씩 세세하게 살펴보도록 하겠습니다.</p>
<h4 id="이벤트">이벤트</h4>
<p>workflow는 이벤트를 감지하여 실행된다고 했는데요. 그 이벤트를 정의해주는 곳이 <code>on</code> 입니다.</p>
<pre><code class="language-yaml">on:
  pull_request:
    branches:
      - main</code></pre>
<p>스크립트를 해석하자면, main 브랜치로 pull-request 시에 이벤트를 발생시키겠다는 의미입니다.</p>
<h4 id="jobs">jobs</h4>
<p>workflow에서 job이란 독립된 환경에서 실행하는 하나의 처리 단위를 의미합니다.
즉, workflow 부분의 그림에서 백엔드 빌드 작업과 프론트엔드 빌드 작업은 독립적으로 실행됩니다!</p>
<p>여기서 필수적으로 정의해야 할 속성은 <code>runs-on</code>과 <code>steps</code>입니다.
<code>runs-on</code>은 빌드할 환경을 뜻하고, <code>steps</code>는 작업 목록 순서를 뜻합니다.
<code>build-spring</code>은 그저 작업의 이름을 정의한 것입니다.</p>
<p>위의 스크립트를 보면 ubuntu-22.04 환경에서 빌드하도록 되어있고,
레포지토리 checkout -&gt; JDK 17 세팅 -&gt; application 환경 변수 설정 -&gt; Gradle로 빌드 순으로 작업을 처리하도록 되어있습니다.</p>
<h4 id="steps">steps</h4>
<p>각각의 step을 의미하고 job과 마찬가지로 이름을 설정할 수 있습니다. <code>name</code> 속성으로 step의 이름을 명시하면 됩니다!
<code>run</code> 속성을 통해 스크립트 혹은 커멘드를 실행할 수 있습니다.
<code>uses</code> 속성을 통해서는 action을 사용할 수 있습니다.
<code>if</code> 속성으로 step에 대한 조건도 걸 수 있습니다!</p>
</br>

<h3 id="실행해보기">실행해보기</h3>
<p>PR을 만들면 자동으로 CI를 진행하는 것을 볼 수 있습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/565dfde3-7d47-4e62-8f57-503bc4d45170/image.png" alt=""></p>
<p>검사가 끝나면 정상 코드라는 표시가 뜨게 됩니다.
<img src="https://velog.velcdn.com/images/hooni_/post/bc823d44-a80f-4436-9c45-b250e9e7c5d9/image.png" alt="">
actions 탭에 들어가면 아래와 같이 실행 순서가 step 순서와 같다는 것을 확인할 수 있습니다.
자원 정리는 역순으로 해주네요!
<img src="https://velog.velcdn.com/images/hooni_/post/4d55d674-f1f6-4198-9e71-824683c2dfea/image.png" alt=""></p>
<p>하지만 특이점이 보이죠? <code>application-dev.yml</code>에는 체크 표시가 뜨지 않습니다.
if 조건문의 조건이 만족하지 않으면 실행하지 않습니다.
스크립트대로라면 조건을 만족하기 때문에 읽혀야 정상입니다! 저는 일단 성공시키기 위해 위의 스크립트에서 조건만 슬쩍 바꿔 실행 안되도록 해보았습니다.
위 application-dev.yml 파일을 읽는 것에 성공하지 못하는 이유는 아직 secrets를 설정하지 않아서 그렇습니다.
이제 github secrets로 <code>application-dev.yml</code>을 읽게끔 해보겠습니다.</p>
</br>

<h3 id="깃허브-환경-변수-설정">깃허브 환경 변수 설정</h3>
<p>github actions는 base64로 인코딩된 값을 읽는다고 합니다. 따라서 환경 변수에 대한 인코딩 작업을 진행한 후에 환경변수를 만들도록 하겠습니다.
<a href="https://www.convertstring.com/ko/EncodeDecode/Base64Encode">인코딩 사이트</a>를 통해 인코딩을 진행했습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/9779bfd2-6be1-4552-ba39-b805349eb27d/image.png" alt=""></p>
<p>그 후에 환경 변수를 설정하기 위해 레포지토리로 다시 이동합니다!
레포지토리 페이지의 settings -&gt; Secrets and variables -&gt; Actions 로 들어가면 환경 변수를 설정할 수 있는 페이지가 나옵니다.
<img src="https://velog.velcdn.com/images/hooni_/post/163d9cc4-7f0b-4553-8a15-4a1ec2f06487/image.png" alt="">
New repository secret 버튼을 클릭하여 application.yml 환경 변수를 넣어보도록 하겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/30f0f9cf-f8a6-43ed-a550-d087d21db44f/image.png" alt="">
다시 actions를 실행시켰습니다. 
<img src="https://velog.velcdn.com/images/hooni_/post/d67de7e9-037a-4e7d-b761-c5994ba98d46/image.png" alt="">
만세~ 환경 변수 파일까지 실행시켰습니다:)</p>
</br>

<h3 id="마무리">마무리</h3>
<p>CI 만든지 꽤 됐는데 이제야 작성하고 있습니다. 빠른 시일 내에 CD도 작성해볼까 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[git submodule]]></title>
            <link>https://velog.io/@hooni_/git-submodule</link>
            <guid>https://velog.io/@hooni_/git-submodule</guid>
            <pubDate>Sat, 29 Jul 2023 07:38:12 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 이번에 우테코 프로젝트를 진행하면서 환경변수를 git submodule로 관리하게 되어 이에 관해 글을 남기고자 합니다.</p>
<h2 id="이전까지는">이전까지는...</h2>
<h3 id="메신저로-공유">메신저로 공유</h3>
<p>이전 프로젝트들을 진행하면서 모든 환경 변수 파일을 메신저를 통해 공유했습니다.
환경 변수가 바뀔 때마다 톡방이나 슬랙 채널에 남기게 되었는데요.
거참 되게 불편하죠...? 하지만 이때 당시에는 비밀 파일이기 때문에 당연한줄 알았습니다.</p>
</br>

<h2 id="깃헙-서브모듈이란">깃헙 서브모듈이란?</h2>
<p>한 저장소 안에 있는 또 다른 별개의 저장소입니다.
이때 상위 저장소를 슈퍼 프로젝트(super project), 하위 저장소를 서브 모듈(sub module)이라고 합니다.
레포지토리 안에 다른 레포지토리가 있다고 이해하시면 편할 것 같습니다.</p>
<p>보통 대형 프로젝트에서 기능을 분리하기 위한 목적으로 사용되는데요. 저희 팀은 서브 모듈을 private 레포지토리로 설정하고 해당 레포지토리에 <code>application*.yml</code> 파일을 저장하기 위한 목적으로 사용했습니다.</p>
</br>

<h2 id="레포지토리-연결">레포지토리 연결</h2>
<p>먼저 메인과 서브 두 레포지토리를 만듭니다.
저는 연습용 레포인 CICD 레포지토리를 슈퍼 프로젝트, sub-module 레포지토리를 서브 모듈로 두었습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/731c1a78-d43b-4e06-bfc2-75fb7e28a274/image.png" alt="">
왼쪽이 서브, 오른쪽이 메인입니다!</p>
<p>메인에서 서브모듈을 등록해보겠습니다!</p>
<pre><code class="language-bash"># git submodule add [repository-url] [path]
git submodule add https://github.com/shb03323/sub-module.git</code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/1e5d6f06-140e-4973-a03b-39cc5a3b114b/image.png" alt=""></p>
<p>만약 path를 생략한다면 git repository 명으로 디랙토리가 생성됩니다.
<img src="https://velog.velcdn.com/images/hooni_/post/5287b3f5-1ffe-4005-91f7-a1129f875201/image.png" alt=""></p>
<p>서브모듈을 등록하게 되면 위 사진과 같이 <code>.gitmodules</code> 와 <code>sub-module</code> 디랙토리가 생성된 것을 볼 수 있습니다.</p>
<h3 id="gitmodules">.gitmodules</h3>
<p><code>.gitmodules</code>를 살펴보면 아래와 같이 서브 모듈의 경로와 repository 주소를 등록한 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/2f4aae60-0c3e-49e9-8989-5c9566ffb7ba/image.png" alt=""></p>
<h3 id="서브-모듈-디랙토리">서브 모듈 디랙토리</h3>
<p>서브 모듈에서는 깃 초기화만 진행하고 아무 진행하지 않아 readme 파일만 있는 상태입니다.
깃헙 홈페이지에서는 아래와 같이 보입니다. <code>@ac18ce4</code> 와 같은 해시값이 디랙토리 이름 옆에 붙어있는데요. 이는 서브 모듈 레포지토리의 커밋 주소입니다!
<img src="https://velog.velcdn.com/images/hooni_/post/03645fd4-b652-4f9a-b562-9282a5ca2bec/image.png" alt=""></p>
</br>

<h2 id="서브-모듈-변동-사항-반영">서브 모듈 변동 사항 반영</h2>
<p>서브 모듈 연결까지 성공했으니 이제 서브 모듈 변경사항을 슈퍼 프로젝트에 반영해보겠습니다.</p>
<p>우선 서브 모듈에 커밋, 푸시합니다.
저는 아래와 같이 리드미를 수정하여 커밋했습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/a1f82c58-bdae-40e8-a4cb-c7000f69c1e3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/a95c879e-a56e-4ba1-922b-017cfabf80b8/image.png" alt="">
푸시를 진행하니 커밋 주소가 <code>ac18ce4</code> -&gt; <code>ef4a463</code> 으로 바뀐 것을 확인할 수 있습니다.</p>
<p>그렇다면 슈퍼 프로젝트에서는 어떻게 보일까요? 확인해보겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/9af5b116-49c7-449c-9088-661d4755e3e2/image.png" alt=""><img src="https://velog.velcdn.com/images/hooni_/post/2af03484-0f24-426a-b8e7-81a32a72d11a/image.png" alt="">
아직 슈퍼 프로젝트에는 반영되지 않은 것을 확인할 수 있습니다. pull도 진행해보았는데요. 아래와 같이 최신 버전이라는 말만 나오고 반영되지 않고 있었습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/871424b2-4d01-415e-b207-92f4484485d1/image.png" alt=""></p>
<p>왜그럴까요? 서브 모듈 저장소는 슈퍼 프로젝트 저장소와 독립적인 저장소이기 때문입니다.
서브 모듈의 변동 사항을 반영하기 위해서는 아래의 명령어를 입력해주어야 합니다.</p>
<pre><code class="language-bash">git submodule update --remote</code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/888705cf-e1c1-426d-b974-98be2677e9fa/image.png" alt="">
무엇인가 pull 받아오는 것을 확인할 수 있습니다. 리드미에도 정상적으로 업데이트 되었네요.
<img src="https://velog.velcdn.com/images/hooni_/post/38bdbdc4-73ad-41cd-ae96-07e71a61ea9f/image.png" alt=""></p>
<p>푸시를 하게 되면 아래와 같이 커밋 주소가 바뀐 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/06078efe-b156-40d0-9936-a9512e95cc00/image.png" alt=""></p>
</br>

<h2 id="마무리">마무리</h2>
<p>동료들과 서브모듈을 이용하여 환경 변수를 주고 받는 방법에 대해 알아보았는데요.
원래 이런 용도로 사용하라고 만든 것은 아닌 것 같은데... private 레포를 파서 아주 잘 사용하고 있습니다. 하하하</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker로 DB 관리하기]]></title>
            <link>https://velog.io/@hooni_/Docker%EB%A1%9C-DB-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hooni_/Docker%EB%A1%9C-DB-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 23 Jul 2023 07:05:58 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요 이번주에 우아한테크코스 프로젝트 기초 세팅하는 시간을 가졌는데요. 그 중 docker로 DB를 관리하는 부분이 기억에 남아 글로 기록해보려합니다.</p>
<h2 id="docker로-db를-관리하게-된-이유">docker로 DB를 관리하게 된 이유</h2>
<h3 id="1-일관된-환경">1. 일관된 환경</h3>
<p>도커 이미지를 사용하면 개발, 로컬, 운영 환경에서 모두 동일한 이미지를 배포할 수 있습니다.
즉, 모든 데이터베이스의 환경을 동일하게 유지할 수 있고 환경 변화가 있다면 동일하게 환경을 변경시킬 수 있습니다.</p>
<p>또한 도커 컴포즈를 이용하여 로컬에서도 동료들간 같은 환경을 구성하여 DB를 사용할 수 있습니다.</p>
<h3 id="2-환경-충돌-방지">2. 환경 충돌 방지</h3>
<p>우아한테크코스에서 DB를 위한 ec2 인스턴스는 1개만 제공되었습니다.
나중에 mysql 뿐만 아니라 다른 db를 추가하게 되는 경우에 인스턴스 내부에서 환경이 얽힐수도 있겠다는 생각을 했습니다.
만약 도커를 이용하여 컨테이너를 분리하게 된다면 환경이 얽힐 일이 없고, 더 안정적인 운영이 가능하다고 생각했습니다.</p>
<h3 id="3-손쉬운-스케일링">3. 손쉬운 스케일링</h3>
<p>서비스가 커지게되면 트래픽이 늘어나고 부하가 발생할 수 있는데요.
이때 도커를 이용하면 컨테이너를 필요한 시점에 빠르게 확장하거나 축소할 수 있습니다.</p>
</br>

<h2 id="ec2에-도커-설치">EC2에 도커 설치</h2>
<pre><code class="language-bash">sudo snap install docker</code></pre>
<p>명령어를 이용하여 도커를 설치합니다.</p>
<p>처음에는 <code>apt update</code>와 <code>apt install</code> 명령어를 통해 도커를 설치하려 했는데, 잘 되지 않았습니다.
찾아보니 요즘은 <code>snap</code>을 사용하면 더 간단히 도커를 설치할 수 있기에 <code>snap</code> 사용을 권장한다고 하네요.
<img src="https://velog.velcdn.com/images/hooni_/post/25198967-049d-4723-9a59-c0b4ca7e5857/image.png" alt=""></p>
<p>위와 같이 설치를 완료했습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/6dcd0c64-4f45-491f-b172-b850c0a0febb/image.png" alt=""></p>
<p>저는 매번 <code>sudo</code>를 이용하여 권한 인증하는 것이 귀찮아서 추가적인 설정을 통해 일반 사용자도 도커에 접근할 수 있도록 설정했습니다.
과정은 <a href="https://snapcraft.io/docker">해당 링크</a>를 참고했습니다.</p>
<pre><code class="language-bash"> sudo addgroup --system docker
 sudo adduser $USER docker
 newgrp docker
 sudo snap disable docker
 sudo snap enable docker</code></pre>
<h3 id="before">before</h3>
<p><img src="https://velog.velcdn.com/images/hooni_/post/103f8395-0ee3-4eb8-935d-5fc165ae413c/image.png" alt=""></p>
<h3 id="after">after</h3>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f1634b4b-120e-4808-943c-34a901dedf99/image.png" alt=""></p>
</br>

<h2 id="도커에-mysql-컨테이너-설치">도커에 mysql 컨테이너 설치</h2>
<h3 id="이미지-다운">이미지 다운</h3>
<p>도커에서 mysql 이미지를 다운받습니다.</p>
<pre><code class="language-bash">docker pull mysql</code></pre>
<p>도커에서 <strong>이미지</strong>란 하나의 환경파일(?)을 말합니다. nginx, mysql, node 등등 많은 이미지들이 있는데, 사용 목적에 맞게 이미지를 선택해서 컨테이너로 생성하면 해당 이미지에 맞는 환경이 설치된 상태로 컨테이너를 사용할 수 있습니다.
OOP에서 원하는 클래스의 인스턴스를 생성하는 것과 비슷한 느낌이라고 할 수 있겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/9994784b-ed69-470b-8d09-e0323d42bc15/image.png" alt=""></p>
<p>mysql 이미지가 정상적으로 깔린 것을 확인할 수 있습니다.
<code>tag</code>는 버전을 뜻하는데, latest는 가장 최신버전이라는 뜻입니다. 만약 제가 <code>docker pull mysql:8.0.33</code> 명령어로 이미지를 풀 받게 되면 해당 버전의 mysql을 풀 받습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/1dae895b-e8e4-48f4-88b1-19527ffb205e/image.png" alt=""></p>
<p>현재 가장 최근 버전의 mysql이 8.0.33 이어서 image id가 같은 것 같습니다.</p>
<h3 id="컨테이너-생성--실행">컨테이너 생성 &amp; 실행</h3>
<pre><code class="language-bash">docker run --name mysql-dev-container -e MYSQL_ROOT_PASSWORD={비밀번호} -d -p 3306:3306 mysql:latest</code></pre>
<p>해석하자면, </p>
<ul>
<li><code>--name</code>: <code>mysql-dev-container</code>의 이름으로 컨테이너를 실행한다.</li>
<li><code>-e</code>: 컨테이너의 환경 변수중 <code>MYSQL_ROOT_PASSWORD</code>를 입력한 비밀번호로 설정한다.</li>
<li><code>-d</code>: 데몬 모드로 실행한다. (백그라운드 모드)</li>
<li><code>-p</code>: 호스트 포트:컨테이너 포트로 설정한다.</li>
<li>이미지는 <code>mysql:latest</code> 로 설정한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hooni_/post/f3d174f9-732d-49c7-befb-1bc570d1e138/image.png" alt=""></p>
<p>정상적으로 컨테이너가 생긴 것을 확인할 수 있습니다.</p>
<p>이제 컨테이너에 접속해보겠습니다.</p>
<pre><code class="language-bash">docker exec -it {컨테이너 Id} bash</code></pre>
<p>위 명령어를 통해 도커 컨테이너 bash에 접속했습니다.</p>
<h3 id="mysql에-db-생성">mysql에 db 생성</h3>
<p>이제 컨테이너에 mysql database를 설치해보도록 하겠습니다.
여기부터는 일반 리눅스나 맥에서 database 생성하는 것과 똑같습니다.</p>
<p>먼저 mysql에 접속합니다.</p>
<pre><code class="language-bash">mysql -u root -p</code></pre>
<p>그리고, database를 생성합니다.</p>
<pre><code class="language-bash">create database ditoo_dev;</code></pre>
<p>intellij에서 생성한 db에 정상적으로 접근되는지 확인해보겠습니다!
<img src="https://velog.velcdn.com/images/hooni_/post/92c5d4df-6651-44da-aa40-dea62909261e/image.png" alt="">
정상적으로 접근되었습니다.</p>
<p>간단한 post, tag, post_tag 테이블을 만들어 table이 정상적으로 생성되는지 확인해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/cb75119b-fa86-476b-9b26-bcf9001e992d/image.png" alt=""></p>
<p>스프링 어플리케이션에서는 정상적으로 테이블이 생성된 것을 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/19117d25-f71e-4d01-b1fb-65e754bf582a/image.png" alt=""></p>
<p>db에도 정상적으로 table이 생긴 것을 확인할 수 있었습니다.</p>
</br>

<h2 id="마치며">마치며</h2>
<p>docker로 간단하게 db를 관리하는 것을 학습했습니다.
다음 시간에는 docker-compose를 이용하여 db, spring, react 모두 같이 관리하는 방법에 대해 학습해보려 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA 온보딩]]></title>
            <link>https://velog.io/@hooni_/JPA-%EC%98%A8%EB%B3%B4%EB%94%A9</link>
            <guid>https://velog.io/@hooni_/JPA-%EC%98%A8%EB%B3%B4%EB%94%A9</guid>
            <pubDate>Sun, 09 Jul 2023 14:37:24 GMT</pubDate>
            <description><![CDATA[<h3 id="실험-환경">실험 환경</h3>
<ul>
<li>java 17</li>
<li>springboot 3.1.1</li>
<li>h2 database</li>
</ul>
</br>

<h3 id="실험할-엔티티">실험할 엔티티</h3>
<p>지하철 역과 노선 사이의 관계를 실험할 예정입니다.
각각의 엔티티 정보는 아래와 같습니다.</p>
<h4 id="station">Station</h4>
<pre><code class="language-java">@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Entity
public class Station {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String name;
}</code></pre>
<h4 id="line">Line</h4>
<pre><code class="language-java">@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Entity
public class Line {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;
}</code></pre>
<p><code>Station</code>과 <code>Line</code>은 N:1 관계로 설정할 예정입니다. 하나의 노선 안에 여러 역이 있다고 가정하고 연관 관계를 매핑해보겠습니다.</p>
</br>

<h3 id="연관-관계-매핑">연관 관계 매핑</h3>
<p>먼저 <code>Station</code> 엔티티에 <code>Line</code> 필드를 추가해보았습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/4065df8a-0977-4b9f-ae8e-e6332c3acc77/image.png" width="50%">
영속성 엔티티는 속성 타입으로 될 수 없다는 메시지가 나오네요.
그렇다면 <code>String</code> 타입으로 하면 정상적으로 동작될까요?
<img src="https://velog.velcdn.com/images/hooni_/post/f3c89c7b-3026-4790-b96f-4ddda072dbe8/image.png" width="50%">
빨간 줄이 생기지 않았고, table 또한 정상적으로 생성되었습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/4e52991d-c705-4050-9520-8eb07a2e906f/image.png" width="50%"></p>
<p>자 그럼 이제 <code>Station</code>에 <code>Line</code>을 연관 관계 매핑해보도록 하겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/22aefc26-af0b-4d50-83ba-a5f40794eb9e/image.png" width="50%">
<img src="https://velog.velcdn.com/images/hooni_/post/bc94adc6-9ca5-4804-98d7-97508b0e1e80/image.png" width="50%">
정상적으로 table이 생긴 것을 볼 수 있습니다.
foreign key로 line_id가 지정된 것을 확인할 수 있네요.</p>
<p>만약 <code>@JoinColumn</code> 어노테이션을 이용해서 name을 바꾸어주면 어떻게 될까요?
<img src="https://velog.velcdn.com/images/hooni_/post/7827fc7a-2196-4ba7-a841-437247fdb033/image.png" width="50%">
<img src="https://velog.velcdn.com/images/hooni_/post/277567e2-a778-4044-b214-d70d83529f15/image.png" width="50%">
join column의 이름이 line_number로 설정된 것을 확인할 수 있습니다.</p>
<p>여기서 모르고 있던 한가지를 배웠습니다.
<code>@JoinColumn</code>이 없어도 line_id 라는 이름으로 연관 관계를 맺어준다는 사실을 배웠는데요. 생각보다 스프링은 더 편리해서 놀랐습니다. 하지만 명시적으로 보이는 것도 중요하기 때문에 저는 <code>@JoinColumn(name = &quot;line_id&quot;)</code>로 설정할 예정입니다.</p>
<h4 id="단방향-관계">단방향 관계</h4>
<p>지금까지 작성한 코드를 보면 <code>Station</code>이 <code>Line</code>에 접근 가능하지만, <code>Line</code>은 <code>Station</code>에 접근하지 못합니다. <code>Line</code>에 <code>Station</code> 관련 필드가 없기 때문이죠.
이걸 스프링 JPA에서는 <strong>단방향 관계</strong>라고 합니다.</p>
<p>만약 새로운 <code>Station</code>을 DB에 저장하는데, <code>Line</code>이 영속상태가 아니라면 어떻게 될까요?</p>
<pre><code class="language-java">    @Test
    void saveWithLine() {
        final Station expected = new Station(&quot;선릉역&quot;);
        expected.setLine(new Line(&quot;2호선&quot;));
        final Station actual = stationRepository.save(expected);
        stationRepository.flush();
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/hooni_/post/59be492e-5185-4c61-af15-d29b1e8ab042/image.png" alt="">
예외가 발생합니다. 메시지를 읽어보면, flush를 하기 전에 일시적인 인스턴스를 save하라고 나와있습니다.
자바 객체를 영속성 컨텍스트에 저장해놓지 않으면 DB에 반영할 수 없다는 것을 알 수 있습니다.</p>
<p><code>Line</code>을 먼저 저장하고 <code>Station</code>을 저장하면 정상적으로 동작하는 것을 확인할 수 있습니다.</p>
<pre><code class="language-java">    // no error
    @Test
    void saveWithLine() {
        final Station expected = new Station(&quot;선릉역&quot;);
        expected.setLine(lineRepository.save(new Line(&quot;2호선&quot;)));
        final Station actual = stationRepository.save(expected);
        stationRepository.flush();
    }</code></pre>
<p>만약 update인 경우는 어떨까요?</p>
<pre><code class="language-java">    @Test
    void update() {
        final Station station = new Station(&quot;교대역&quot;);
        station.setLine(lineRepository.save(new Line(&quot;3호선&quot;)));
        stationRepository.save(station);
        stationRepository.flush();

        Station changedStation = stationRepository.findByName(&quot;교대역&quot;);
        changedStation.setLine(new Line(&quot;2호선&quot;));
        stationRepository.save(changedStation);
        stationRepository.flush();
    }</code></pre>
<p>같은 예외가 발생했습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/39e7f674-7489-4146-9cf8-543c81121d4c/image.png" alt=""></p>
<p>만약 <code>Station</code>과 연관 관계를 맺고 있는 <code>Line</code>을 DB에서 delete 하면 어떨까요?
이 경우는 DBMS 관점에서 문제가 생깁니다. 해당 <code>Line</code>을 참조한 모든 데이터의 연관 관계를 지워야만 삭제할 수 있습니다.
만약 참조한 데이터가 많다면 이 모든 데이터의 연관 관계를 끊어줘야 하는데요. 굉장히 노가다스러워 보입니다.</p>
<p>JPA는 이를 위한 것도 제공해주고 있습니다.
바로 양방향 연관 관계를 이용하는 건데요.</p>
</br>

<h4 id="양방향-연관-관계">양방향 연관 관계</h4>
<p>위에서 <code>Station</code>만 <code>Line</code>을 알 수 있었는데요. <code>Line</code>도 <code>Station</code>의 목록을 알게 하면 그것이 양방향 연관 관계가 됩니다.</p>
<pre><code class="language-java">@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Getter
@Entity
public class Line {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = &quot;line&quot;)
    private List&lt;Station&gt; stations = new ArrayList&lt;&gt;();

    public Line(final String name) {
        this.name = name;
    }
}</code></pre>
<p>여기서 <code>@OneToMany</code> 어노테이션이 사용되었는데요. 이는 1대다를 의미하고 있습니다. 속성에 <code>mappedBy</code>는 연관 관계 주인의 field 이름을 뜻합니다. <code>Station</code> 객체에서 <code>Line</code>의 필드 이름을 <code>line</code>으로 두었기 때문에 <code>mappedBy = &quot;line&quot;</code> 으로 두었습니다.
<em>여기서 &#39;연관 관계 주인&#39;은 보통 1대다 관계에서 다 쪽이 가지게 됩니다.</em></p>
<p>만약 이 외래키를 관리하는 속성인 <code>mappedBy</code>를 없애면 어떻게 될까요?
<img src="https://velog.velcdn.com/images/hooni_/post/39647c52-b74f-474f-8c3c-5922151fb0c9/image.png" width="50%"></p>
<p>새로운 table인 <code>line_stations</code>이 생기는 것을 확인할 수 있습니다. 이렇게 관계를 직접 명시해주지 않으면 불필요한 table이 생성되는 것을 확인할 수 있습니다.
</br>
이제 <code>Line</code>을 추가하는 테스트를 해보겠습니다.
먼저 <code>Line</code> 객체에 역 추가 메서드를 만들었습니다.</p>
<pre><code class="language-java">    public void addStation(final Station station) {
        station.setLine(this);
        stations.add(station);
    }</code></pre>
<p>그 후에 테스트를 진행합니다.</p>
<pre><code class="language-java">    @Test
    void save() {
        final Line line = new Line(&quot;2호선&quot;);
        line.addStation(stationRepository.save(new Station(&quot;선릉역&quot;)));
        lineRepository.save(line);
        lineRepository.flush();
    }</code></pre>
<p>테스트는 통과하지만 아래와 같이 연관 관계가 설정되지 않은 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/68fe4381-ca2a-4d8a-bfd5-7ea1936c0372/image.png" alt=""></p>
<p>연관 관계 주인에게 <code>Line</code>에 대한 정보를 제공하지 않았기 때문에 발생한 문제입니다.</p>
<pre><code class="language-java">    public void addStation(final Station station) {
        station.setLine(this);
        stations.add(station);
    }</code></pre>
<p>연관 관계 주인인 <code>Station</code>에도 <code>Line</code>을 세팅했습니다.
그 결과 정상적인 쿼리가 생성되었습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/e52ce54b-1408-4025-b1e9-46b5086364ac/image.png" alt=""></p>
<p>추가적으로 <code>Station</code> 객체의 setter에도 <code>Line</code> 객체를 호출하여 자신을 추가하겠습니다.</p>
<pre><code class="language-java">    public void setLine(Line line) {
        this.line = line;
        line.getStations().add(this);
    }</code></pre>
<p>하지만 이때 문제가 발생합니다.
자칫 잘못하면 순환 참조가 발생하게 됩니다.
따라서 아래와 같이 순환 참조를 막아주었습니다.</p>
<pre><code class="language-java">    public void setLine(Line line) {
        this.line = line;
        final List&lt;Station&gt; stations = line.getStations();
        if (!stations.contains(this)) {
            line.getStations().add(this);
        }
    }</code></pre>
<p>마지막으로 아까 살펴보았던 참조된 연관 관계 모두 끊어 삭제하는 JPA의 기능을 알아보겠습니다.
연관 관계의 주인에서 cascade 옵션을 remove로 걸면 됩니다.
그러면 참조된 것이 모두 제거되는 것을 확인할 수 있습니다.</p>
<pre><code class="language-java">@OneToMany(mappedBy = &quot;line&quot;, cascade = CascadeType.REMOVE)
private List&lt;Station&gt; stations = new ArrayList&lt;&gt;();</code></pre>
<p>하지만 이 방법은 참조된 모든 데이터를 제거합니다. 관계만 끊는게 아니라 싹다 삭제해버리기 때문에 위험해보입니다. 의도하지 않은 데이터도 삭제될 수 있으니까요.</p>
<p>관계만 끊는 방법은 조금 더 공부하고 오도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Converter]]></title>
            <link>https://velog.io/@hooni_/Spring-Converter</link>
            <guid>https://velog.io/@hooni_/Spring-Converter</guid>
            <pubDate>Sat, 20 May 2023 14:59:58 GMT</pubDate>
            <description><![CDATA[<h3 id="정의">정의</h3>
<p>스프링에서 사용되는 Converter는 데이터 변환을 수행하는 함수형 인터페이스입니다.
주로 Spring의 데이터 바인딩, 폼 처리, 데이터 유효성 검증 등에서 사용됩니다.
소스 객체를 대상 객체로 변환하는 기능을 제공합니다.</p>
<pre><code class="language-java">public interface Converter&lt;S, T&gt; {
    T convert(S source);
}</code></pre>
<p><code>S</code> 타입의 객체가 input으로 들어오면 <code>T</code> 타입의 객체로 변환해주는 역할을 합니다.</p>
</br>

<h3 id="학습-계기">학습 계기</h3>
<p>우테코 지하철 미션을 진행하면서 노선에 역을 등록하는 API를 설계하는 것이 미션에 포함되었습니다.</p>
<pre><code class="language-json">{
    &quot;direction&quot;: &quot;UP&quot;,
    &quot;standardStationName&quot;: &quot;구일&quot;,
    &quot;newStationName&quot;: &quot;가산디지털단지&quot;,
    &quot;distance&quot;: 1
}</code></pre>
<p>위는 해당 API의 request body인데요. 노선에 역을 등록할 때 &quot;윗쪽에 추가&quot; 혹은 &quot;밑쪽에 추가&quot;라는 것을 명시해야 했습니다. 그래야 기준역에서 올바른 방향으로 새로운 역이 추가되니까요:)</p>
<p>처음에는 String 형태의 json이 들어오기 때문에 아래와 같이 String 자료형으로 설정해주었습니다.</p>
<pre><code class="language-java">public class StationRegisterInLineRequest {

    @NotBlank(message = &quot;direction 이 비어있습니다.&quot;)
    private final String direction;
    @NotBlank(message = &quot;standardStationName 이 비어있습니다.&quot;)
    private final String standardStationName;
    @NotBlank(message = &quot;newStationName 이 비어있습니다.&quot;)
    private final String newStationName;
    @NotNull(message = &quot;distance 가 null 입니다.&quot;)
    @Positive(message = &quot;거리는 양의 정수만 가능합니다.&quot;)
    private final Integer distance;

    ...

}</code></pre>
<p>이때, String으로 들어오는 <code>&quot;direction&quot;: &quot;UP&quot;</code> 값을 request dto에서 바로 enum으로 설정할 수 있지 않을까 생각했습니다. 아래처럼 말이죠.</p>
<pre><code class="language-java">public class StationRegisterInLineRequest {

    @NotNull(message = &quot;direction 이 null 입니다.&quot;)
    private final SubwayDirection direction;
    @NotBlank(message = &quot;standardStationName 이 비어있습니다.&quot;)
    private final String standardStationName;
    @NotBlank(message = &quot;newStationName 이 비어있습니다.&quot;)
    private final String newStationName;
    @NotNull(message = &quot;distance 가 null 입니다.&quot;)
    @Positive(message = &quot;거리는 양의 정수만 가능합니다.&quot;)
    private final Integer distance;

    ...

}</code></pre>
</br>

<h3 id="converter-선택">Converter 선택</h3>
<p>request body의 필드로 enum을 사용하는 방법을 알아보았을 때, <code>Formatter</code>와 <code>Converter</code>가 주로 쓰인다는 것을 배웠습니다.</p>
<ul>
<li><p><code>Formatter</code></p>
<ul>
<li>Object &lt;-&gt; String 간의 변환을 담당한다.</li>
<li><code>Locale</code>에 따라 다국화하는 기능을 제공한다.</li>
</ul>
</li>
<li><p><code>Converter</code></p>
<ul>
<li>Source가 들어오면 Target을 반환한다.</li>
<li>Thread-safe 해서 스프링 빈으로 등록해서 사용할 수 있다.</li>
</ul>
</li>
</ul>
<p><code>Locale</code>을 지원하는 <code>Formatter</code>는 원하는대로 타입 전환을 할 수 있는 <code>Converter</code>가 현재 서비스에 더 어울린다고 생각했습니다. <code>Converter</code>의 특별한 버전을 <code>Formatter</code>라고 이해했고, <code>Formatter</code>는 문자에 특화된 기능이라고 생각했습니다.</p>
<p>지금 서비스는 객체간의 타입 변환을 목적으로 했기 때문에 <code>Converter</code>를 선택했습니다.</p>
</br>

<h3 id="config-등록">Config 등록</h3>
<p><code>Converter</code>의 구현체를 만들어 String 값을 enum으로 바꾸는 <code>convert</code> 메소드를 구현했습니다.</p>
<pre><code class="language-java">@Component
public class StringToSubwayDirectionConverter implements Converter&lt;String, SubwayDirection&gt; {

    @Override
    public SubwayDirection convert(final String value) {
        return SubwayDirection.from(value);
    }
}</code></pre>
<p>구현한 <code>Converter</code>를 적용하기 위해 config에 등록해주었습니다.</p>
<pre><code class="language-java">@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(final FormatterRegistry registry) {
        registry.addConverter(new StringToSubwayDirectionConverter());
    }
}</code></pre>
<p>enum에도 따로 설정을 해주어야하는데요. json 형태로 값을 받기 때문에 enum의 생성자에 <code>@JsonCreater</code>로 json 값을 java 코드로 직렬화해야합니다.</p>
<pre><code class="language-java">public enum SubwayDirection {

    UP(&quot;up&quot;), DOWN(&quot;down&quot;);

    private final String directionName;

    SubwayDirection(final String directionName) {
        this.directionName = directionName;
    }

    @JsonCreator
    public static SubwayDirection from(final String input) {
        return Arrays.stream(SubwayDirection.values())
                .filter(value -&gt; input.equalsIgnoreCase(value.directionName))
                .findFirst()
                .orElseThrow(InvalidDirectionException::new);
    }
}</code></pre>
</br>

<h3 id="결과">결과</h3>
<p>json 값을 java 코드의 request body에서 enum 타입으로 설정에 성공한 것을 볼 수 있습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/ebb40bd5-a504-4cde-91e5-22fa312e51b0/image.png" alt="">
잘못된 문자를 입력하면 아래와 같이 설정한 오류를 올바르게 오류도 보여줍니다.<br><img src="https://velog.velcdn.com/images/hooni_/post/df51b607-d6de-4722-9a23-606b1321a388/image.png" alt=""></p>
</br>

<h3 id="마무리">마무리</h3>
<p>누군가 알려줘서 찾은 불편이 아닌, 제가 찾은 불편을 스스로 해결해서 뿌듯합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring ArgumentResolver]]></title>
            <link>https://velog.io/@hooni_/Spring-ArgumentResolver</link>
            <guid>https://velog.io/@hooni_/Spring-ArgumentResolver</guid>
            <pubDate>Sat, 06 May 2023 16:35:41 GMT</pubDate>
            <description><![CDATA[<p>controller의 매핑된 uri 요청으로 들어오는 데이터를 원하는 객체로 만들어 줄 수 있는 역할을 합니다.</p>
<h3 id="학습-계기">학습 계기</h3>
<p>지금까지 request body나 request parameter, path variable를 사용하면서 어노테이션을 이용하여 요청 데이터들을 처리해줬습니다. 이를 위한 어노테이션이 있기 때문에 어노테이션 사용만으로 쉽게 원하는 데이터를 바인딩할 수 있었습니다. 하지만 이번 우테코 미션에 추가된 인증과 인가와 같은 특수 상황인 경우에는 이를 위한 어노테이션이 존재하지 않았습니다.</p>
<p>우테코 학습 테스트로 주어진 Interceptor와 ArgumentResolver를 보고 인증과 인가를 처리하는 것을 익혔는데요. controller의 코드에 아무런 표시없이 작업을 처리하는 Interceptor보다 <code>@RequestBody</code>처럼 어노테이션으로 표시를 해주고 작업 처리를 하는 <code>ArgumentResolver</code>를 이용해 사용자 확인 코드를 작성했습니다.</p>
<p>Interceptor로는 요청의 header 안에 Authorization이 있는지 확인하는 역할을 주려했는데요, header에 값을 넣어주어도 Interceptor에서는 보이지 않고 ArgumentResolver에서는 보였습니다. 그래서 Interceptor는 미션에 적용하지 못했습니다. 이 문제는 추후 해결해보겠습니다.</p>
</br>

<h3 id="사용하지-않았을-때">사용하지 않았을 때</h3>
<p>사용하지 않았을 경우, 유저 인증이 필요한 모든 API에서 유저를 찾는 로직을 controller에 중복적으로 넣어야합니다. 아래 코드는 제 예전 프로젝트 코드입니다.</p>
<pre><code class="language-java">@PostMapping
public ResponseMessage createProject(
        @Valid @RequestBody ProjectRequestDto projectRequestDto, HttpServletRequest request) {

    Long userId = Long.valueOf(request.getUserPrincipal().getName());

    return ResponseMessage.toResponseEntity(
            ResponseCode.CREATE_PROJECT_SUCCESS,
            projectService.createProject(projectRequestDto, userId)
    );
}

@PatchMapping(&quot;/{projectId}&quot;)
public ResponseMessage modifyProject(@PathVariable Long projectId,
                                     @Valid @RequestBody ProjectRequestDto projectRequestDto, HttpServletRequest request) {

    Long userId = Long.valueOf(request.getUserPrincipal().getName());

    return ResponseMessage.toResponseEntity(
            ResponseCode.MODIFY_PROJECT_SUCCESS,
            projectService.modifyProject(userId, projectId, projectRequestDto)
    );
}</code></pre>
<p>코드를 보면 <code>userId</code>를 찾는 부분을 중복으로 쓰고 있는 것을 볼 수 있습니다. 중복을 없애기 위해 <code>HandlerMethodArgumentResolver</code>를 사용해보도록 하겠습니다.</p>
</br>

<h3 id="handlermethodargumentresolver"><code>HandlerMethodArgumentResolver</code></h3>
<p><code>HandlerMethodArgumentResolver</code>를 implements하면 두 개의 메소드를 오버라이드 해야합니다. 
<code>supportsParameter()</code>와 <code>resolveArgument()</code>입니다. 각각 알아보겠습니다.</p>
<ul>
<li><code>boolean supportsParameter()</code> : 요청 파라미터에 대한 조건을 걸고 조건을 충족할 때 true를 반환합니다.</li>
<li><code>Object resolveArgument()</code> : 앞의 <code>supportsParameter()</code>에서 true가 반환되면 이 메서드를 실행시켜 parameter를 원하는 형태로 바꾸어주는 메소드입니다.</li>
</ul>
</br>

<p>두 메소드를 통해 특정 조건에 유저 인증을 할 수 있도록 설정할 수 있습니다. 저는 <code>@Auth</code>라는 어노테이션을 만들어 특정 조건을 만들어주었습니다.</p>
<pre><code class="language-java">@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}</code></pre>
<p>간단하게 해당 어노테이션을 런타임시에 파라미터에만 사용할 수 있도록 설정했습니다.</p>
</br>

<h3 id="구현-코드">구현 코드</h3>
<p>먼저 <code>supprotsParameter()</code>에서 파라미터가 <code>Integer</code> 타입이고 <code>@Auth</code> 어노테이션을 사용했을 때 true를 반환하도록 했습니다. controller 메소드의 인자에 <code>@Auth</code> 어노테이션과 <code>Integer</code> 타입이 엮여있으면 유저 인증을 진행합니다.</p>
<p><code>resolveArgument()</code>의 반환 타입이 <code>Object</code>인데, 다형성을 살리기 위해서 자바 개발자들이 가장 큰 <code>Object</code>를 반환하도록 설계했다고 생각했습니다. 저는 인증된 유저의 id를 반환하도록 했기 때문에 <code>Integer</code> 자료형으로 반환이 될 것입니다. 
<code>Integer</code>형으로 반환되기 때문에 <code>supportsParameter()</code>의 조건에 파라미터 타입이 <code>Integer</code>여야 한다는 것을 걸어 동료 개발자가 같은 파일에서 <code>resolveArgument()</code>의 <code>Object</code> 타입을 보고 어떤 타입으로 반환될 지 굳이 다른 파일을 보지 않고 알 수 있게끔 했습니다.</p>
<p><code>resolveArgument()</code>에서는 요청 헤더에 담긴 Basic Authorization을 읽었습니다. 값을 디코딩한 후에 이메일과 패스워드로 사용자의 id를 알아내는 방식입니다.</p>
<p>코드는 아래와 같습니다.</p>
<pre><code class="language-java">@Component
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {

    private static final String AUTHORIZATION = &quot;Authorization&quot;;

    private final AuthDao authDao;

    public AuthArgumentResolver(final AuthDao authDao) {
        this.authDao = authDao;
    }

    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        return parameter.getParameterType().equals(Integer.class) &amp;&amp;
                parameter.hasParameterAnnotation(Auth.class);
    }

    @Override
    public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
                                  final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
        final HttpServletRequest httpServletRequest = (HttpServletRequest) webRequest.getNativeRequest();
        final String authorizationHeader = httpServletRequest.getHeader(AUTHORIZATION);
        final AuthenticationDto authenticationDto = BasicAuthorizationExtractor.extract(authorizationHeader);
        return authDao.findIdByEmailAndPassword(authenticationDto.getEmail(), authenticationDto.getPassword());
    }
}</code></pre>
<p>이제 추가적으로 이 ArgumentResolver를 WebMvcConfigurer에 등록만 해주면 됩니다!</p>
<pre><code class="language-java">@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    private final AuthArgumentResolver authArgumentResolver;

    public WebMvcConfiguration(final AuthArgumentResolver authArgumentResolver) {
        this.authArgumentResolver = authArgumentResolver;
    }

    @Override
    public void addArgumentResolvers(final List&lt;HandlerMethodArgumentResolver&gt; resolvers) {
        resolvers.add(authArgumentResolver);
    }
}</code></pre>
<p>이렇게 하여 컨트롤러에서 아래와 같이 간단한 어노테이션 하나로 유저를 인증할 수 있었습니다.</p>
<pre><code class="language-java">@PostMapping
public ResponseEntity&lt;Void&gt; create(@Auth final Integer userId,
                                   @RequestBody @Valid final CartRequestDto cartRequestDto) {
    cartService.create(cartRequestDto, userId);
    return ResponseEntity.created(URI.create(REDIRECT_URL)).build();
}

@GetMapping
public ResponseEntity&lt;List&lt;CartItemResponseDto&gt;&gt; read(@Auth final Integer userId) {
    final List&lt;CartItemResponseDto&gt; response = cartService.getProductsInCart(userId);
    return ResponseEntity.ok().body(response);
}</code></pre>
</br>

<h3 id="마무리">마무리</h3>
<p>점점 중복을 줄이는 방법을 알게되는 것 같아 행복합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 @JdbcTest]]></title>
            <link>https://velog.io/@hooni_/%EC%8A%A4%ED%94%84%EB%A7%81-JdbcTest</link>
            <guid>https://velog.io/@hooni_/%EC%8A%A4%ED%94%84%EB%A7%81-JdbcTest</guid>
            <pubDate>Sun, 30 Apr 2023 10:36:54 GMT</pubDate>
            <description><![CDATA[<h2 id="글-작성-계기">글 작성 계기</h2>
<p>테스트 코드를 작성할 때 <code>@SpringBootTest</code>, <code>@JdbcTest</code>, <code>@WebMvcTest</code> 차이를 확실하게 알지 않고 코드를 작성한 것 같았습니다. 차이를 제대로 알아보고자 이번 글을 올리게 되었습니다.</p>
</br>

<h2 id="jdbctest">@JdbcTest</h2>
<p>JDBC 관련 테스트입니다. Respository(Dao) 클래스를 위한 테스트입니다. 데이터가 정상적으로 저장되고 정상적으로 호출되는지 확인하는 것을 목적으로 한 테스트 어노테이션입니다.</p>
<h3 id="특징">특징</h3>
<ul>
<li>jdbc와 관련 있는 빈들만 auto configuration하는 테스트</li>
<li>default로 in-memory 데이터베이스를 사용</li>
<li>각각의 테스트는 하나의 트랜젝션이고, 테스트가 끝나면 원래 상태의 데이터베이스로 롤백됨</li>
</ul>
</br>

<p>특징을 보면 굳이 스프링 빈을 모두 주입하지 않고, 상대적으로 가볍게 테스트 할 수 있다는 장점이 있습니다. 또, 데이터베이스도 직접 롤백하지 않아도 되죠.</p>
<p>예시 코드입니다.</p>
<pre><code class="language-java">@JdbcTest
class ProductDaoTest {

    @Autowired
    private DataSource dataSource;

    private JdbcTemplate jdbcTemplate;

    private ProductDao productDao;

    @BeforeEach
    void setUp() {
        productDao = new ProductDao(dataSource);
        jdbcTemplate = new JdbcTemplate(dataSource);
    }

    ...
}</code></pre>
<p>DataSource를 주입시키고 JdbcTemplate와 Dao 인스턴스를 만들어주었습니다.</p>
</br>

<h3 id="sql">@Sql</h3>
<p>테스트를 실행하기 전 원하는 sql문을 실행시켜주는 어노테이션입니다. 단위 테스트를 위해 값이 미리 세팅되어야하는 상황이 발생하는데요. 저는 patch, get, delete 메소드에서 이런 상황이 발생했습니다. 처음에는 테스트 메서드 안에서 JdbcTemplate로 직접 값을 넣어주었습니다.
아래는 그 코드입니다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;상품 전체 조회 성공&quot;)
void findALl_success() {
    // given
    final String sql = &quot;insert into product (name, image, price) values (?, ?, ?)&quot;;
    jdbcTemplate.query(sql, &quot;디투&quot;, &quot;ditoo.jpg&quot;, 1000);

    // when
    final List&lt;ProductEntity&gt; allProducts = productDao.findAll();

    // then
    assertAll(
            () -&gt; assertThat(allProducts).hasSize(1),
            () -&gt; assertThat(allProducts.get(0).getName()).isEqualTo(&quot;디투&quot;),
            () -&gt; assertThat(allProducts.get(0).getImage()).isEqualTo(&quot;ditoo.jpg&quot;),
            () -&gt; assertThat(allProducts.get(0).getPrice()).isEqualTo(1000)
    );
}</code></pre>
<p>@Sql 어노테이션을 이용하여 sql 파일을 불러와 미리 세팅한 상태로 테스트 코드를 작성한다면 given 부분을 조금 더 쉽게 할 수 도 있을 것 같습니다!
아래는 적용한 코드입니다.</p>
<pre><code class="language-java">
@Test
@DisplayName(&quot;상품 전체 조회 성공&quot;)
@Sql(scripts = &quot;/dummy_data.sql&quot;)
void findALl_success() {
    // given, when
    final List&lt;ProductEntity&gt; allProducts = productDao.findAll();

    // then
    assertAll(
            () -&gt; assertThat(allProducts).hasSize(3),
            () -&gt; assertThat(allProducts.get(0).getName()).isEqualTo(&quot;pooh&quot;),
            () -&gt; assertThat(allProducts.get(0).getImage()).isEqualTo(&quot;pooh.jpg&quot;),
            () -&gt; assertThat(allProducts.get(0).getPrice()).isEqualTo(1_000_000),
            () -&gt; assertThat(allProducts.get(2).getPrice()).isEqualTo(10)
    );
}</code></pre>
<p>sql 파일의 내용은 아래와 같습니다.</p>
<pre><code class="language-sql">truncate table product;
alter table product auto_increment = 1;
insert into product (name, image, price)
values (&#39;pooh&#39;, &#39;pooh.jpg&#39;, 1000000),
       (&#39;ditoo&#39;, &#39;ditoo.jpg&#39;, 1000000),
       (&#39;pobi&#39;, &#39;pobi.jpg&#39;, 10);</code></pre>
<p>테스트 실행 전에 sql로 값을 세팅하고 시작하면 더 깔끔한 테스트 코드를 작성할 수 있습니다.</p>
</br>

<h3 id="문제-상황-발생">문제 상황 발생</h3>
<p>sql문을 보시면 <code>alter table product auto_increment = 1;</code>이 있습니다. 이는 pk가 auto increment인 경우에 pk를 다시 1부터 시작한다는 쿼리인데요. 1부터 시작하게 해주지 않으면 테스트가 끝나고 DB가 롤백이 되더라도 pk가 1부터 시작이 되는 것이 아니라 마지막 pk의 다음 숫자부터 시작하게 됩니다.</p>
<p>예를 들어보겠습니다. @Sql을 이용하여 윗 sql 스크립트 파일을 세팅하고 테스트를 2개 돌린다고 가정했을 때, 첫 테스트에서는 pk가 1, 2, 3로 1부터 시작하게 되지만 두 번째 테스트에서는 pk가 4, 5, 6으로 설정됩니다. 데이터는 롤백이 되지만 pk 시작 번호는 롤백이 되지 않은 것이죠.</p>
<p>그리고 이 문법은 mysql 문법이기 때문에 h2 데이터베이스를 사용한다면 application.properties에서 <code>spring.datasource.url=jdbc:h2:mem:productDb;MODE=MySQL</code>를 통해 mysql 모드로 실행되도록 해야합니다.</p>
</br>

<h3 id="두-번째-문제-상황-발생">두 번째 문제 상황 발생</h3>
<p>mysql모드로 설정하고 쿼리를 실행시켰는데도 문제가 발생했는데요.
<code>org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement #2 of class path resource [dummy_data.sql]: alter table product auto_increment = 1; nested exception is org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement &quot;alter table product [*]auto_increment = 1&quot;; expected &quot;., ADD, SET, RENAME, DROP, ALTER&quot;; SQL statement:</code>
<code>alter table product auto_increment = 1 [42001-214]</code>
위와 같은 에러가 발생했습니다. 분명 mysql모드로 바꾸었는데 왜 h2 데이터베이스 sql 오류로 나타났을까요...?</p>
<p>@JdbcTest를 쓰면서 mysql 설정으로 변경해주지 않아서 그렇습니다.
@JdbcTest 특징 부분에서 언급했던 </p>
<blockquote>
<p>default로 in-memory 데이터베이스를 사용</p>
</blockquote>
<p>때문에 테스트가 자동으로 in-memory h2 데이터베이스를 사용하게 된 것이죠. 이 설정을 바꿔주기 위해 <code>@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)</code>를 사용했습니다.</p>
<p>여기서 Replace를 살펴보자면 아래 사진과 같이 3가지 옵션이 있습니다. default는 ANY입니다.</p>
<p><img src="https://velog.velcdn.com/images/hooni_/post/acd999be-84f6-48d4-a2ec-faac9d76261b/image.png" alt=""></p>
<ul>
<li>ANY: 자동 설정이나 수동 정의에 상관 없이 DataSource 빈을 교체</li>
<li>AUTO_CONFIGURED: 자동 설정된 경우에 DataSource 빈을 교체</li>
<li>NONE: default DataSource를 교체하지 않음</li>
</ul>
<p>application.properties에 설정을 해두어도 Replace.ANY 설정으로 in-memory로 바뀌어버린 설정을 None으로 바꾸어 mysql를 사용하도록 설정했습니다.</p>
<p>아래는 바뀐 코드입니다.</p>
<pre><code class="language-java">@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductDaoTest {

    @Autowired
    private DataSource dataSource;

    private JdbcTemplate jdbcTemplate;

    private ProductDao productDao;

    @BeforeEach
    void setUp() {
        productDao = new ProductDao(dataSource);
        jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Test
    @DisplayName(&quot;상품 전체 조회 성공&quot;)
    @Sql(scripts = &quot;/dummy_data.sql&quot;)
    void findALl_success() {
        // given, when
        final List&lt;ProductEntity&gt; allProducts = productDao.findAll();

        // then
        assertAll(
                () -&gt; assertThat(allProducts).hasSize(3),
                () -&gt; assertThat(allProducts.get(0).getName()).isEqualTo(&quot;pooh&quot;),
                () -&gt; assertThat(allProducts.get(0).getImage()).isEqualTo(&quot;pooh.jpg&quot;),
                () -&gt; assertThat(allProducts.get(0).getPrice()).isEqualTo(1_000_000),
                () -&gt; assertThat(allProducts.get(2).getPrice()).isEqualTo(10)
        );
    }

    ...
}</code></pre>
<h2 id="마무리">마무리</h2>
<p>에러를 해결하면서 왜 안되지 하며 저와 비슷한 상황을 겪은 사람들을 검색했습니다. 사실 에러 메시지에서 h2 설정으로 되어있다는 것을 알 수 있었는데, 왜 변하지 않았을까 고민하기 보다는 일단 구글링을 했던 것 같습니다. 조금 더 근본적인 해결책을 찾는 습관을 길러야 할 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring @Controller vs @RestController]]></title>
            <link>https://velog.io/@hooni_/Spring-Controller-vs-RestController</link>
            <guid>https://velog.io/@hooni_/Spring-Controller-vs-RestController</guid>
            <pubDate>Mon, 17 Apr 2023 11:33:17 GMT</pubDate>
            <description><![CDATA[<p>스프링 프로젝트에서 컨트롤러를 지정할 때 사용되는 두 가지 어노테이션이 있습니다.
<code>@Controller</code>와 <code>@RestController</code>인데요, 이 두 어노테이션의 차이는 무엇인지 알아보고자 합니다.</p>
</br>

<h2 id="결론부터">결론부터...</h2>
<p>결론부터 말하자면 <code>@RestController</code>는 <code>@Controller</code>와 <code>@ResponseBody</code>를 합친 어노테이션입니다.</p>
<pre><code class="language-java">@Controller
@ResponseBody
public class Controller {}

@RestController
public class RestfulController {}</code></pre>
<p>그렇다면 <code>@RestController</code>는 컨트롤러에서 <code>ResponseBody</code>로 반환을 해줄 때 사용하면 되는 것일까요?
<code>@Controller</code>는 <code>ResPonseBody</code>가 아닌 형식(json, xml...이 아닌 형식)을 반환할 때 쓰이는 것일까요?</p>
<p>공부해보니 두 어노테이션의 차이는 단순히 반환 값 차이뿐 아니라 철학적인 차이도 있다는 것을 알았습니다. 이번에는 그 철학적인 차이를 알아보기 위해 글을 작성하게 되었습니다.</p>
</br>

<h2 id="controller">@Controller</h2>
<p>스프링에서 <code>@Controller</code>는 주로 뷰를 반환하기 위해 사용됩니다.
컨트롤러의 메소드를 호출하게 되면 컨트롤러에서 반환되는 <code>String</code>에 해당하는 뷰 이름을 <code>ViewResolver</code>에 전달합니다. </p>
<p>자세한 동작 방식은 유저 페이지를 조회하는 것으로 예로 들겠습니다.
<img src="https://velog.velcdn.com/images/hooni_/post/0347eed6-72da-42dc-b9f5-a877fcc3c60b/image.png" alt=""></p>
<p>1️⃣ 클라이언트가 서버에 &quot;/user&quot; uri에 대한 요청을 합니다.
2️⃣ <code>HandlerMapping</code>에서 &quot;/user&quot;와 매칭되는 handler를 찾습니다.
3️⃣ 매칭되는 컨트롤러가 <code>UserController</code>라는 것을 알려줍니다.
4️⃣ <code>HandlerAdapter</code>에게 <code>UserController</code>의 처리를 요청합니다.
5️⃣ 어댑터는 <code>UserController</code>를 실행시킵니다.
6️⃣,7️⃣ 실행 결과를 반환합니다.
8️⃣ 실행 결과와 일치하는 뷰 검색을 <code>ViewResolver</code>에 요청합니다.
9️⃣,🔟 찾은 뷰를 반환한다.</p>
</br>

<h2 id="restcontroller">@RestController</h2>
<p>백엔드 어플리케이션에서 주로 json 형식으로 데이터를 응답합니다. json 형식의 데이터를 반환 받기 위해 스프링에서 <code>@ResponseBody</code> 어노테이션을 이용합니다.</p>
<p>다음은 view가 아닌 json을 반환할 때 실행되는 과정입니다.
<img src="https://velog.velcdn.com/images/hooni_/post/bd98135d-67e1-4560-a738-7a52ee8f92f4/image.png" alt=""></p>
<p>1️⃣ 클라이언트가 서버에 &quot;/user&quot; uri에 대한 요청을 합니다.
2️⃣ <code>HandlerMapping</code>에서 &quot;/user&quot;와 매칭되는 handler를 찾습니다.
3️⃣ 매칭되는 컨트롤러가 <code>UserRestController</code>라는 것을 알려줍니다.
4️⃣ <code>HandlerAdapter</code>에게 <code>UserRestController</code>의 처리를 요청합니다.
5️⃣ 어댑터는 <code>UserController</code>를 실행시킵니다.
6️⃣ 실행 결과로 <code>ResponseEntity</code>를 반환합니다. 이 때 <code>@ResponseBody</code> 어노테이션을 사용하였기 때문에 응답 결과를 웹 응답의 body로 감쌉니다.
7️⃣,8️⃣ 반환 결과를 전달합니다.</p>
</br>

<h2 id="마무리">마무리</h2>
<p>스프링 프로젝트 자체에서 view를 반환하고 싶은 경우에는 <code>@Controller</code>를, json이나 xml 형식으로 클라이언트에 전달하고 싶은 경우에는 <code>@RestController</code>를 사용합시다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring JDBC]]></title>
            <link>https://velog.io/@hooni_/Spring-JDBC</link>
            <guid>https://velog.io/@hooni_/Spring-JDBC</guid>
            <pubDate>Fri, 14 Apr 2023 12:48:37 GMT</pubDate>
            <description><![CDATA[<h2 id="jdbctemplate">JdbcTemplate</h2>
<ul>
<li>스프링에서 지원하는 jdbc를 편하게 사용하기 위한 클래스입니다.</li>
</ul>
</br>

<h3 id="jdbc">JDBC</h3>
<p>우테코 체스 미션에서 <code>JdbcTemplate</code>를 사용하지 않고 직접 jdbc를 사용했습니다.
아래의 코드와 같이 DB 연결, 자원 정리 작업을 직접했습니다.</p>
<pre><code class="language-java">public Connection getConnection() {
    try {
        return DriverManager.getConnection(&quot;jdbc:mysql://&quot; + SERVER + &quot;/&quot; + DATABASE + OPTION, USERNAME, PASSWORD);
    } catch (final SQLException e) {
        System.err.println(&quot;DB 연결 오류:&quot; + e.getMessage());
        e.printStackTrace();
        return null;
    }
}</code></pre>
<pre><code class="language-java">@Override
public int save(final ChessGameDto chessGameDto) {
    try (final Connection connection = databaseConnector.getConnection()) {
        int chessGameId = saveChessGame(chessGameDto, connection);
        savePiece(chessGameDto.getBoard(), connection, chessGameId);
        return chessGameId;
    } catch (SQLException | IllegalStateException e) {
        e.printStackTrace();
        throw new RuntimeException(&quot;정상 저장되지 않았습니다.&quot;);
    }
}

private int saveChessGame(final ChessGameDto chessGameDto, final Connection connection) {
    final String query = &quot;insert into chess_game(turn) values (?)&quot;;
    try (final PreparedStatement ps = connection.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);) {
        ps.setString(1, chessGameDto.getTurn());
        ps.executeUpdate();
        return findChessGameId(ps);
    } catch (SQLException e) {
        e.printStackTrace();
        throw new RuntimeException(&quot;정상 저장되지 않았습니다.&quot;);
    }
}</code></pre>
<p>일부의 코드를 가져왔습니다. 코드에서와 같이 connection을 할 때 <code>application.properties</code> (<code>application.yml</code>)의 DB 연결 정보를 직접 넣어주어야 합니다.
또, 자원 정리 또한 직접 해줘야 합니다.</p>
</br>

<h3 id="spring-jdbc">Spring JDBC</h3>
<p><code>JdbcTemplate</code>을 사용하면 위의 코드처럼 직접 connection을 걸어주지 않아도 됩니다. <code>DataSource</code>가 <code>Connection</code> 객체 생성을 하도록 할 수 있습니다.</p>
<pre><code class="language-java">private final JdbcTemplate jdbcTemplate;

public JdbcTemplateDao(final DataSource dataSource) {
    this.jdbcTemplate = new JdbcTemplate(dataSource);
}

@Override
public int save(final ChessGameDto chessGameDto) {
    final String sql = &quot;insert into chess_game(turn) values (?)&quot;;
    jdbcTemplate.update(sql, chessGameDto.getTurn());
}</code></pre>
<p><code>DataSource</code>를 생성자의 인자로 받아왔지만 <code>getConnection()</code> 메소드가 보이지 않습니다. DB 연결을 하지 않고 쿼리를 날리는 것처럼 보입니다. 자원 정리를 하는 부분도 보이지 않습니다.
<code>JdbcTemplate</code>의 <code>update()</code>메소드에서 DB 연결과 자원을 정리 모두 해주기 때문입니다. 아래 코드는 <code>JdbcTemplate</code>의 <code>update()</code> 메소드의 구현체 입니다. 마지막의 <code>execute()</code> 메소드를 주목해보겠습니다.</p>
<pre><code class="language-java">    @Override
    public int update(final String sql) throws DataAccessException {
        Assert.notNull(sql, &quot;SQL must not be null&quot;);
        if (logger.isDebugEnabled()) {
            logger.debug(&quot;Executing SQL update [&quot; + sql + &quot;]&quot;);
        }

        /**
         * Callback to execute the update statement.
         */
        class UpdateStatementCallback implements StatementCallback&lt;Integer&gt;, SqlProvider {
            @Override
            public Integer doInStatement(Statement stmt) throws SQLException {
                int rows = stmt.executeUpdate(sql);
                if (logger.isTraceEnabled()) {
                    logger.trace(&quot;SQL update affected &quot; + rows + &quot; rows&quot;);
                }
                return rows;
            }
            @Override
            public String getSql() {
                return sql;
            }
        }

        return updateCount(execute(new UpdateStatementCallback(), true));
    }

    @Nullable
    private &lt;T&gt; T execute(StatementCallback&lt;T&gt; action, boolean closeResources) throws DataAccessException {
        Assert.notNull(action, &quot;Callback object must not be null&quot;);

        Connection con = DataSourceUtils.getConnection(obtainDataSource());
        Statement stmt = null;
        try {
            stmt = con.createStatement();
            applyStatementSettings(stmt);
            T result = action.doInStatement(stmt);
            handleWarnings(stmt);
            return result;
        }
        catch (SQLException ex) {
            // Release Connection early, to avoid potential connection pool deadlock
            // in the case when the exception translator hasn&#39;t been initialized yet.
            String sql = getSql(action);
            JdbcUtils.closeStatement(stmt);
            stmt = null;
            DataSourceUtils.releaseConnection(con, getDataSource());
            con = null;
            throw translateException(&quot;StatementCallback&quot;, sql, ex);
        }
        finally {
            if (closeResources) {
                JdbcUtils.closeStatement(stmt);
                DataSourceUtils.releaseConnection(con, getDataSource());
            }
        }
    }</code></pre>
<p>처음에 <code>getConnection()</code>으로 DB와 연결을 하고, 마지막 <code>finally</code>에서 DB 연결을 해제하고 <code>Statement</code>의 자원을 정리합니다. <code>JdbcTemplate</code>의 모든 메소드는 <code>execute()</code>를 거치게 되는데, 이 때 자원 할당과 정리를 해주고 있습니다!</p>
</br>

<h2 id="rowmapper">RowMapper</h2>
<p><code>JdbcTemplate</code>의 메소드를 이용하다 보면 원하는 도메인 클래스 타입으로 반환되게 하고 싶은 경우가 있습니다. 아래의 코드와 같이 반환 값을 도메인 클래스로 지정하게 되면 class mismatch 에러가 발생합니다.</p>
<pre><code class="language-java">public User getUser(Long id) {
    final String sql = &quot;select * from users where id = ?&quot;;
    return jdbcTemplate.queryForObject(sql, User.class, id);
}</code></pre>
<p>당연하게 DB에는 User 객체가 존재하지 않습니다. users table이 존재할 뿐이죠. 그런데 DB에서 User 타입으로 반환하게끔 코드를 작성했기 때문에 에러가 발생한 것입니다. 이런 상황을 위해 Spring JDBC는 <code>RowMapper</code>를 제공합니다.</p>
<p><code>RowMapper</code>를 사용하면 원하는 형태의 결과값을 반환할 수 있습니다.</p>
<pre><code class="language-java">public User getUser(Long id) {
    final String sql = &quot;select * from users where id = ?&quot;;
    return jdbcTemplate.queryForObject(sql, userRowMapper, id);
}

private final RowMapper&lt;User&gt; userRowMapper = (resultSet, rowNum) -&gt; {
    User user = new User(
            resultSet.getLong(&quot;id&quot;),
            resultSet.getString(&quot;name&quot;),
            resultSet.getString(&quot;nickname&quot;),
            resultSet.getInt(&quot;age&quot;),
            resultSet.getString(&quot;address&quot;)
    );
    return user;
};</code></pre>
<p><code>ResultSet</code>으로 DB 결과값을 먼저 받고 User 객체에 담아서 반환하는 방식입니다. 조금 더 개선해보자면 아래와 같이 바꿀 수 있습니다.</p>
<pre><code class="language-java">public User getUser(Long id) {
    final String sql = &quot;select * from users where id = ?&quot;;
    return jdbcTemplate.queryForObject(
            sql, 
            (resultSet, rowNum) -&gt; new User(
                    resultSet.getLong(&quot;id&quot;),
                    resultSet.getString(&quot;name&quot;),
                    resultSet.getString(&quot;nickname&quot;),
                    resultSet.getInt(&quot;age&quot;),
                    resultSet.getString(&quot;address&quot;)
                    ), 
            id);
}</code></pre>
</br>

<h2 id="namedparameterjdbctemplate">NamedParameterJdbcTemplate</h2>
<p>기존 <code>JdbcTemplate</code>에는 불편한 점이 있었습니다. 쿼리에 들어가는 인자를 세팅할 때 순서대로 세팅을 해주어야 했습니다. 아래와 같이 말이죠.</p>
<pre><code class="language-java">final String sql = &quot;insert into users (name, nickname, age, address) values (?,?,?,?)&quot;
template.update(sql, user.getName(), user.getNickname(), user.getAge(), user.getAddress());</code></pre>
<p>인자가 많아질수록 순서는 헷갈리게 되고 굉장히 불편해지는데요, 이럴 때 사용할 수 있는 것이 <code>NamedParameterJdbcTemplate</code> 입니다.
<code>NamedParameterJdbcTemplate</code>를 사용하면 메소드 매개변수의 순서를 고려하지 않아도 됩니다.</p>
<pre><code class="language-java">public int saveUser(final User user) {
    final String sql = &quot;insert into users(name, nickname, age, address) values (:name,:nickname,:age,:address)&quot;;
    final SqlParameterSource param = new MapSqlParameterSource()
            .addValue(&quot;name&quot;, user.getName())
            .addValue(&quot;age&quot;, user.getAge())
            .addValue(&quot;nickname&quot;, user.getNickname())
            .addValue(&quot;address&quot;, user.getAddress());
    return namedParameterJdbcTemplate.update(sql, param);
}</code></pre>
<p>key-value 형태인 Map 방식으로 parameter source를 저장하기 때문에 <code>JdbcTemplate</code>에 비해 더 간편하게 코드를 작성할 수 있었습니다. 위 코드에서는 더 간단하게 코드를 작성할 수도 있습니다. <code>BeanPropertySqlParameterSource</code>를 이용하는 방법인데요. 코드를 우선 보겠습니다.</p>
<pre><code class="language-java">public int saveUser(final User user) {
    final String sql = &quot;insert into users(name, nickname, age, address) values (:name,:nickname,:age,:address)&quot;;
    final SqlParameterSource param = new BeanPropertySqlParameterSource(user);
    return namedParameterJdbcTemplate.update(sql, param);
}</code></pre>
<p><code>BeanPropertySqlParameterSource</code>는 Java Bean 객체의 속성 이름을 key로 하고, 해당 속성의 값을 value로 하여 SqlParameterSource 구현체를 만듭니다. <code>MapSqlParameterSource</code>처럼 key-value를 이용하는데, <code>BeanPropertySqlParameterSource</code>는 자동으로 key-value 쌍을 연결해주는 방식입니다. 당연하게도 필드 명과 parameter source들의 이름이 같아야합니다.</p>
</br>

<h2 id="simplejdbcinsert">SimpleJdbcInsert</h2>
<p>윗 부분의 코드들은 삽입 과정에서 불편한 점이 있습니다. 삽입하고 나서 바로 id를 조회하고 싶을 때, 코드가 굉장히 복잡해집니다. 아래의 코드는 <code>KeyHolder</code>를 이용하여 primary key를 조회하는 방식인데, 가독성이 떨어집니다.</p>
<pre><code class="language-java">public Long insertWithKeyHolder(User user) {
    final String sql = &quot;insert into users (name, nickname, age, address) values (?, ?, ?, ?)&quot;;
        final KeyHolder generatedKeyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -&gt; {
            PreparedStatement ps = connection.prepareStatement(sql, new String[]{&quot;id&quot;});
            ps.setString(1, user.getName());
            ps.setString(2, user.getNickname());
            ps.setInt(3, user.getAge());
            ps.setString(4, user.getAddress());
            return ps;
        }, generatedKeyHolder);
        return generatedKeyHolder.getKey().longValue();
    }</code></pre>
<p>이를 위해 스프링에서는 <code>SimpleJdbcInsert</code>라는 삽입을 위한 클래스를 제공합니다.
처음에 아래와 같이 세팅을 합니다.</p>
<pre><code class="language-java">private SimpleJdbcInsert insertActor;

public SimpleInsertDao(DataSource dataSource) {
    this.insertActor = new SimpleJdbcInsert(dataSource)
            .withTableName(&quot;users&quot;)
            .usingGeneratedKeyColumns(&quot;id&quot;);
}</code></pre>
<p>table명과 generatedKey의 칼럼명을 설정해주면 <code>SimpleJdbcInsert</code>를 사용할 때 해당 table의 해당 칼럼의 값을 조회할 수 있게 됩니다.
아래의 코드처럼 간단하게 insert 할 수 있습니다.</p>
<pre><code class="language-java">public User insertWithSimpleJdbcInsert(User user) {
    SqlParameterSource params = new BeanPropertySqlParameterSource(user);
    final long id = insertActor.executeAndReturnKey(params).longValue();
    return new User(id, user.getName(), user.getNickname(), user.getAge(), user.getAddress());
}</code></pre>
</br>

<h2 id="마무리">마무리</h2>
<p>Spring JDBC 라이브러리에 있는 주요 객체들에 대해 살펴보았습니다. 처음 공부해보는 라이브러리라서 부족한 부분이 있을 것 같습니다. 잘못된 정보는 차차 수정해 나가겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[사다리 타기 2단계 회고]]></title>
            <link>https://velog.io/@hooni_/%EC%82%AC%EB%8B%A4%EB%A6%AC-%ED%83%80%EA%B8%B0-2%EB%8B%A8%EA%B3%84-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@hooni_/%EC%82%AC%EB%8B%A4%EB%A6%AC-%ED%83%80%EA%B8%B0-2%EB%8B%A8%EA%B3%84-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 02 Apr 2023 10:12:18 GMT</pubDate>
            <description><![CDATA[<h3 id="추가된-요구-사항">추가된 요구 사항</h3>
<ul>
<li>사다리 실행 결과를 출력해야 한다.</li>
<li>개인별 이름을 입력하면 개인별 결과를 출력하고, &quot;all&quot;을 입력하면 전체 참여자의 실행 결과를 출력한다.</li>
</ul>
</br>

<h3 id="사다리-타기-방식">사다리 타기 방식</h3>
<p>첫 리뷰 요청에서는 사다리를 내려갈 때 모든 플레이어가 한 층씩 내려갔다. 층을 내려갈 때 다리가 있으면 다리의 왼쪽과 오른쪽에 있는 유저의 위치를 바꾸어주었다. 이렇게 끝까지 내려가면 플레이어들의 최종 위치가 결과 리스트로 생성된다.</p>
<p>처음에는 위와 같은 방식이 효율적이어서 선택했다. 모든 유저를 한 번에 계산하기 때문에 성능적으로 좋아보였다.</p>
<p>하지만 만약 밑에서 위로 연결되는 다리가 있다면 내가 선택한 방식을 쓸 수 없다. 그리고 현실에서 내가 선택한 방식으로 사다리타기를 하지 않는다. 보통 한 명씩 사다리를 탄다. 성능적으로는 이점이 있을지라도 확장성에 있어서 단점이 많이 보이는 방법이었다. 그래서 두번째 리팩터링을 하면서 한 명씩 사다리를 타는 방식으로 변경했다.</p>
</br>

<h3 id="비대한-controller">비대한 controller</h3>
<p>처음 코드를 작성할 때 controller에 도메인 생성이나 정보 교환 로직을 넣었다. controller는 view와 domain의 이어주는 역할만 담당해야하는데 도메인이 담당해야할 부분까지 역할 수행을 했다. 리팩터링하면서 최대한 controller의 크기를 줄여보려 했으나 실패한 것 같다.</p>
<p>회고를 하는 지금 코드를 보았을 때 controller를 더 작게 줄여볼만한 부분이 여럿 보였다. 특히 모든 domain 생성을 controller가 담당하게 하여 아쉬웠다.</p>
</br>

<h3 id="github">github</h3>
<p>코드 : <a href="https://github.com/shb03323/java-ladder/tree/step2">https://github.com/shb03323/java-ladder/tree/step2</a>
PR : <a href="https://github.com/woowacourse/java-ladder/pull/219">https://github.com/woowacourse/java-ladder/pull/219</a></p>
</br>

<h3 id="마무리">마무리</h3>
<p>오래전 미션의 회고여서 그런가,,, 코드가 너무 별로라는 생각이 강하게 든다. 나 정말 발전하긴 했나보다.</p>
]]></description>
        </item>
    </channel>
</rss>