<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>bruni.log</title>
        <link>https://velog.io/</link>
        <description>다음 단계를 고민하려고 노력하는 사람입니다</description>
        <lastBuildDate>Sun, 05 May 2024 09:37:55 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>bruni.log</title>
            <url>https://velog.velcdn.com/images/bruni_23yong/profile/21bbadca-19da-4db8-ab78-913b1e4205b9/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. bruni.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/bruni_23yong" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Coroutine의 중단과 재개]]></title>
            <link>https://velog.io/@bruni_23yong/Coroutine%EC%9D%98-%EC%A4%91%EB%8B%A8%EA%B3%BC-%EC%9E%AC%EA%B0%9C</link>
            <guid>https://velog.io/@bruni_23yong/Coroutine%EC%9D%98-%EC%A4%91%EB%8B%A8%EA%B3%BC-%EC%9E%AC%EA%B0%9C</guid>
            <pubDate>Sun, 05 May 2024 09:37:55 GMT</pubDate>
            <description><![CDATA[<p>회사에서 일을 하면서 Kotlin을 사용하게 되었습니다. 이전까지 Java를 학습해오던 저에게 Kotlin은 꽤 매력적인 언어로 느껴졌습니다. Java의 불필요한 코드(boilerplate)들이 최소화되고, 좀 더 안전하게 코딩을 할 수 있게 된 것 같습니다. 입사 5개월차인 지금, kotlin에 어느정도 익숙해졌습니다.</p>
<p>하지만 여전히 알듯말듯한 코루틴(coroutine) 이라는 존재가 꽤 재밌으면서 어렵습니다. 지금까지 코틀린 코루틴을 사용하면서 알게 된 지식들을 정리하고자 합니다.</p>
<h2 id="코루틴coroutine">코루틴(Coroutine)?</h2>
<p>사실 코루틴(coroutine)이라는 개념은 코틀린에만 존재하는 개념은 아닙니다. 위키피디아에서는 coroutine을 아래와 같이 정의하고 있습니다.</p>
<blockquote>
<p>Coroutines are computer program components that allow execution to be suspended and resumed, generalizing subroutines for cooperative multitasking.</p>
</blockquote>
<p>코루틴을 정의해보면, 중단되었다가 다시 실행할 수 있는 프로그램 컴포넌트라고 할 수 있습니다. 
이 말만으로는 이해하기가 어려워 좀 더 자세히 알아보겠습니다.</p>
<blockquote>
<p>co + routine </p>
</blockquote>
<p>coroutine은 함께 / 협력하는(co) 함수(routine) 입니다. (여기서 루틴(routine)을 함수로 보면 좋을 것 같습니다.) 그런데 코루틴은 일반적인 함수와는 다르게 중간에 일시중단(suspend)하고 함수 블럭을 벗어나 다른 작업을 수행할 수 있습니다.</p>
<p>위에서도 언급했듯이 코틀린 코루틴이 도입한 핵심 기능은 코루틴을 특정 지점에서 중단하고 이후에 다시 재개할 수 있다는 것입니다. 코루틴을 중단시켰을 때 스레드는 블로킹(blocking)되지 않으며 다른 코루틴을 실행시키는 작업이 가능합니다. (아래에서 다루겠습니다.)</p>
<p>이제 예를 들어, 세 개의 API를 호출하고 결과를 이용해 사용자에게 보여줘야하는 경우 코틀린 코루틴을 통해 아래와 같이 간단하게 작성할 수 있습니다.</p>
<pre><code class="language-kotlin">suspend fun showSomeInfo() {
    val config = async { getConfigFromApi() }
    val news = async { getNewsFromApi(config.await()) }
    val user = async { getUserFromApi() }

    view.showNews(user.await(), news.await())
}</code></pre>
<p>문법을 몰라도 쉽게 읽히지 않나요? 이렇게 코틀린 코루틴은 쉽게 적용이 가능하다는게 장점입니다.</p>
<h2 id="코루틴의-중단">코루틴의 중단</h2>
<p>코루틴에서 <code>중단</code>은 함수의 실행을 중간에 멈춘다는 것을 의미합니다. 우리는 게임을 하다가도 부모님이 부르면 중간에 잠깐 게임을 잠시 저장해 두었다가 집안일을 하고 저장한 지점부터 다시 게임을 시작할 수 있습니다ㅎㅎ.. 코루틴도 이와 비슷하게 함수의 실행을 중간에 멈추고 저장해두었다가 다른 함수를 실행하다가 다시 돌아와 원래 작업을 이어하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/d8d54fa0-4122-417c-b042-3c3448782500/image.png" alt=""></p>
<p>여기서 <code>저장</code>한다는게 코루틴이 스레드와는 다르다는 것을 의미할 수 있는데, 스레드는 저장이 불가능하고 멈추는 것만 가능합니다. (마치 체크포인트 없는 게임..) 또한 코루틴은 중단되었을 때 어떠한 자원도 사용하지 않는 것이 특징입니다. 이는 코루틴이 중단되었을 때 <code>Continuation</code> 라는 객체를 반환해서 현재까지의 진행정보를 저장하기 때문입니다. </p>
<p>아직 어떤 의미인지 크게 와닿지는 않고 이게 어떻게 가능한지 잘 이해가 되지 않습니다. 우선 코루틴은 스레드와 다르게 중단이 가능하다 정도만 이해하고 다음으로 넘어가도 좋을 것 같습니다.</p>
<h2 id="코루틴의-재개">코루틴의 재개</h2>
<p>중단이 있다면 작업을 다시 재개할 수도 있어야겠죠. 코루틴을 만들어서 중간에 코루틴을 중단해보도록 하겠습니다. 코루틴을 만드는 방법은 여러가지가 있지만 여기서는 실행하기 쉽게 중단가능한 main 함수를 만들겠습니다.</p>
<pre><code class="language-kotlin">suspend fun main() {
    println(&quot;BEFORE SUSPEND&quot;)

    suspendCoroutine&lt;Unit&gt; {  }

    println(&quot;AFTER SUSPEND&quot;)
}</code></pre>
<p>위 코드를 실행하게 되면 <code>BEFORE SUSPEND</code>를 출력하고 프로그램은 실행된 상태로 유지됩니다. 바로 <code>suspendCoroutine</code>을 통해 BEFORE와 AFTER 사이를 중단했기 때문입니다. 이 프로그램은 중단된채로 재개되지 않는데, 어떻게 다시 실행시킬 수 있을까요?</p>
<p>앞서 언급했던 <code>Continuation</code>을 기억하시나요? 이 Continuation 객체를 통해 함수를 재개할 수 있습니다. suspendCoroutine 함수를 보면 Continuation을 인자로 받는 것을 확인할 수 있습니다.</p>
<pre><code class="language-kotlin">public suspend inline fun &lt;T&gt; suspendCoroutine(crossinline block: (Continuation&lt;T&gt;) -&gt; Unit): T {
    //...
}</code></pre>
<p>suspendCoroutine 함수는 중단되기 전 Continuation 객체를 사용할 수 있습니다. suspendCoroutine이 호출된 후에는 Continuation 객체를 사용할 수 없기 때문에 람다 표현식이 함수의 인자로 들어가 중단되기 전 실행이 됩니다. 그러면 다시 함수를 재개시켜보겠습니다.</p>
<pre><code class="language-kotlin">suspend fun main() {
    println(&quot;BEFORE SUSPEND&quot;)

    suspendCoroutine&lt;Unit&gt; { continuation -&gt;
        continuation.resume(Unit)
    }

    println(&quot;AFTER SUSPEND&quot;)
}</code></pre>
<p>이렇게 코루틴은 언제든 중단될 수 있습니다. 이게 어떻게 가능한지 내부동작이 궁금해집니다.</p>
<h2 id="cpscontinuation-passing-style">CPS(Continuation Passing Style)</h2>
<p>코틀린은 CPS(Continuation Passing Style)를 통해 중단함수를 구현했습니다. 컨티뉴에이션 객체는 함수의 마지막 인자를 통해 들어가게 됩니다. </p>
<pre><code class="language-kotlin">suspend fun getUser(): User?
suspend fun setUser(user: User)
suspend fun sum(val1: Int, val2: Int): Int</code></pre>
<pre><code class="language-kotlin">fun getUser(continuation: Continuation&lt;*&gt;): Any?
fun setUser(user: User, continuation: Continuation&lt;*&gt;): Any
fun sum(val1: Int, val2: Int, continuation: Continuation&lt;*&gt;): Any</code></pre>
<p>중단함수(suspend function)을 자세히 보면 원래 선언했던 형태와 반환타입이 달라진 걸 확인할 수 있습니다. 그리고 반환타입은 Any 혹은 Any? 로 바뀐 것을 확인할 수 있는데 이는 중단함수를 실행하는 도중에 중단되면 선언된 타입의 값을 반환하지 않을 수도 있기 때문입니다. 우선 getUser 함수가 User?나 <code>COROUTINE_SUSPEND</code>를 반환할 수 있기 때문에 반환타입이 Any?로 지정되었다는 것만 확인하면 됩니다.</p>
<h3 id="간단한-예제">간단한 예제</h3>
<pre><code class="language-kotlin">suspend fun sum(num1: Int, num2: Int): Int {
    println(&quot;sum START&quot;)
    delay(1000)
    println(&quot;sum END&quot;)
    return num1 + num2
}</code></pre>
<p>sum 함수의 시그니처를 예측하면 아래와 같습니다.</p>
<pre><code class="language-kotlin">fun sum(num1: Int, num2: Int, continuation: Continuation&lt;*&gt;): Any</code></pre>
<p>이 함수가 시작되는 지점은 함수가 처음 호출되거나 중단 이후의 재개 지점일 것입니다. 코틀린은 모든 중단 가능한 지점을 찾아 when으로 표현하게 됩니다. 코틀린 컴파일러는 내부적으로 중단 가능한 지점을 식별하고 이 지점들로 코드를 구분하게 됩니다. 이때 구분된 코드들을 분리하기 위해 <code>label</code>이라는 필드를 사용하게 되는데 함수가 처음 시작될 때 이 값은 0으로 지정됩니다. 이후에는 중단되기 전 다음 상태로 설정되어 코루틴이 재개될 시점을 알 수 있게 해줍니다. (이런 방식을 state machine이라고도 부르기도 합니다..)</p>
<pre><code class="language-kotlin">fun sum(num1: Int, num2: Int, continuation: Continuation&lt;Unit&gt;): Any {
    when (continuation.label) {
        0 -&gt; { // 함수가 처음 호출될 때
            println(&quot;sum Start&quot;)
            continuation.label = 1
            if (delay(1000, continuation) == COROUTINE_SUSPEND) {
                return COROUTINE_SUSPEND
            }
        }
        1 -&gt; { // 중단 이후의 호출 지점
            println(&quot;sum END&quot;)
            return num1 + num2
        }
    }
}</code></pre>
<p>함수가 <code>delay</code>라는 중단 함수에 의해 중단된 경우 <code>COROUTINE_SUSPEND</code>가 반환되고 sum은 <code>COROUTINE_SUSPEND</code>를 반환합니다. sum을 호출한 함수부터 시작해 콜 스택에 있는 함수도 마찬가지입니다. 따라서 중단이 일어나면 콜 스택에 있는 모든 함수가 종료되고, 중단된 코루틴을 수행하던 스레드를 실행 가능한 코드가 사용할 수 있게 해줍니다.</p>
<p>이제 익명 클래스로 구현된 컴파일된 Continuation객체를 간단하게 나타내보면 아래와 같습니다.</p>
<pre><code class="language-kotlin">cont = new ContinuationImpl(continuation) {
    Object result;
    int label = 0;

    @Nullable
    public final Object invokeSuspend(@NotNull Object $result) {
       this.result = $result;
       return sum(var0, var1, this);
    }
}</code></pre>
<p>result로 코루틴 내의 상태를 관리하고 label 을 통해 어디서부터 재시작할 지를 관리합니다.</p>
<h2 id="정리">정리</h2>
<p>현재 회사에서 코루틴을 활용해서 로직을 작성하는 일이 많습니다. 처음 코투틴을 접했을 때는 이게 대체 무슨 소리인지 잘 몰랐고, 궁금한 것들이 많았습니다. 지금은 이건가..? 하면서 계속 배우는 느낌이네요. 아직 배울게 넘쳐나는 것 같습니다.</p>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://ko.wikipedia.org/wiki/%EC%BD%94%EB%A3%A8%ED%8B%B4">https://ko.wikipedia.org/wiki/%EC%BD%94%EB%A3%A8%ED%8B%B4</a>
<a href="https://m.yes24.com/Goods/Detail/123034354">https://m.yes24.com/Goods/Detail/123034354</a>
<a href="https://myungpyo.medium.com/%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B3%B5%EC%8B%9D-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%9E%90%EC%84%B8%ED%9E%88-%EC%9D%BD%EA%B8%B0-part-1-dive-3-b174c735d4fa">https://myungpyo.medium.com/%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B3%B5%EC%8B%9D-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%9E%90%EC%84%B8%ED%9E%88-%EC%9D%BD%EA%B8%B0-part-1-dive-3-b174c735d4fa</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] OFFSET 방식의 한계]]></title>
            <link>https://velog.io/@bruni_23yong/MySQL-OFFSET-%EB%B0%A9%EC%8B%9D%EC%9D%98-%ED%95%9C%EA%B3%84</link>
            <guid>https://velog.io/@bruni_23yong/MySQL-OFFSET-%EB%B0%A9%EC%8B%9D%EC%9D%98-%ED%95%9C%EA%B3%84</guid>
            <pubDate>Mon, 11 Dec 2023 07:41:43 GMT</pubDate>
            <description><![CDATA[<p>예전에 프로젝트를 진행하던 중 페이징을 구현하던 중 아래와 같은 리뷰가 달렸습니다.</p>
<blockquote>
<p>그리고 limit offset 방식의 페이징을 사용하면 어떤 한계가 있는지도 알아보세요~ (지금 상황에서 문제생길 일이 없기 때문에 수정하시라는 건 아닙니다ㅎㅎ)</p>
</blockquote>
<p>그래서 <code>limit offset</code> 방식의 한계를 알아보고 이를 개선해 나가는 글을 작성해보고자 합니다.</p>
<blockquote>
<p>✓ 참고로 작성하는 글에 관련된 데이터베이스는 MySQL이고 8.0 버전 이상임을 알립니다.</p>
</blockquote>
<h2 id="limit--offset-">LIMIT .. OFFSET ..</h2>
<p>아래와 같은 쿼리가 있을 때 MySQL 에서 동작은 어떻게 될까요?</p>
<pre><code class="language-sql">SELECT id 
FROM comment
WHERE is_deleted = FALSE
LIMIT 15 
OFFSET 10000;</code></pre>
<p>예상에는 <strong>10001</strong> 번째 행부터 데이터를 읽기 시작할 것 같지만 실제로는 <strong>0</strong>번째 행부터 데이터를 읽고 <strong>10000</strong> 번째 까지의 데이터는 버리게 됩니다. </p>
<p><img width=500 src="https://velog.velcdn.com/images/bruni_23yong/post/3f29e873-68ac-4605-9e08-782be8fb31cd/image.png"></img></p>
<p>데이터의 수가 적다면 이는 크게 문제가 되지 않겠지만, 데이터의 수가 많아지면 버리지만 읽어야 하는 행의 개수가 많아져 응답속도가 느려지게 될 것 입니다.</p>
<h2 id="no-offset">No offset</h2>
<p>그러면 이를 해결하기 위해서는 <code>OFFSET</code>을 사용하지 않는 구조로 변경하는 것이 적절해 보입니다.</p>
<p>No offset 방식은 SNS의 더보기 방식과 동일하다고 생각할 수 있습니다. 우리가 핸드폰으로 화면을 스크롤하며 게시물들을 계속해서 볼 수 있는 것을 생각하면 될 것 같습니다.</p>
<p>먼저 <code>no offset</code> 방식의 쿼리를 살펴보겠습니다.</p>
<pre><code class="language-sql">SELECT id
FROM comment
WHERE is_deleted = FALSE AND id &lt; 마지막_조회_ID 
LIMIT 15</code></pre>
<p>마지막 조회한 id 부분을 인덱스로 찾아 매번 첫 페이지만 읽도록 하는 방식입니다. 이전에 조회한 결과를 한 번에 건너뛰고 조회하고 싶은 부분부터 조회하게 됩니다.</p>
<p>이를 다른 말로 커서 페이징(cursor pagination) 이라고도 합니다.</p>
<h2 id="코드로-옮겨보자">코드로 옮겨보자</h2>
<p>Spring Data JPA의 <code>Slice</code> 인터페이스를 떠올려 다음과 같은 클래스를 생성했습니다.</p>
<pre><code class="language-java">public class Slice&lt;T&gt; {

    private static final int HAS_NEXT_DATA_SIZE = 11;

    private List&lt;T&gt; data;
    private Boolean hasMore;
    private Integer nextCursor;

    public Slice(List&lt;T&gt; data, Integer cursor) {
        if (data.size() == HAS_NEXT_DATA_SIZE) {
            this.data = data.subList(0, 10);
            this.hasMore = true;
            this.nextCursor = cursor;
            return;
        }
        this.data = data;
        this.hasMore = false;
        this.nextCursor = cursor;
    }
}</code></pre>
<p>로직의 흐름은 다음과 같습니다.</p>
<ul>
<li>만약 10개 단위로 데이터를 가지고 온다면 11개를 가지고 옵니다.</li>
<li>11번째 데이터가 존재한다면 다음 페이지가 존재함을 의미합니다.<ul>
<li>그렇기 때문에 <code>hasMore</code> 필드를 true로 설정하고 반환하는 데이터를 10개 담습니다.</li>
</ul>
</li>
<li>데이터가 11개 미만이라면 다음 페이지가 존재하지 않음을 의미합니다.<ul>
<li>그렇기 때문에 <code>hasMore</code> 필드를 false로 설정합니다.</li>
</ul>
</li>
</ul>
<p>이후 커서 방식의 페이징을 수행하는 쿼리를 날립니다.</p>
<pre><code class="language-java">String sql = &quot;SELECT comment.id, user_account.login_id, user_account.profile_url, comment.content, comment.created_at &quot;
                + &quot;FROM comment &quot;
                + &quot;JOIN user_account ON comment.user_account_id = user_account.id &quot;
                + &quot;WHERE comment.issue_id = :issueId AND comment.is_deleted = false AND comment.id &gt;= :cursor LIMIT 11&quot;;</code></pre>
<h2 id="결론">결론</h2>
<p>굳이 전체 페이지를 나타낼 필요가 없고, 무한 스크롤 방식의 페이징을 구현하고자 한다면 NO-OFFSET 방식의 페이징을 사용하는게 좋을 것 같습니다.
물론 정렬 기준이 다양해진다면 OFFSET 방식의 페이징을 사용하면서 다른 대안을 고려해 봐야겠네요..!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[슬로우 쿼리 개선기]]></title>
            <link>https://velog.io/@bruni_23yong/%EC%8A%AC%EB%A1%9C%EC%9A%B0-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/%EC%8A%AC%EB%A1%9C%EC%9A%B0-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0</guid>
            <pubDate>Wed, 22 Nov 2023 06:23:36 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@bruni_23yong/%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%A1%9C%EC%A7%81%EC%9D%98-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%A0%81%EC%9A%A9%EA%B8%B0">이전</a> 포스팅에서 쿠폰 발급 로직을 개선 하였고, 이번에는 DB에 요청되는 쿼리를 개선하는 과정을 포스팅해보고자 합니다.</p>
<h2 id="개요">개요</h2>
<p>K6를 활용한 부하테스트 중 많은 데이터가 있다는 가정하에 쿠폰 발급 요청을 날렸을 때 타임아웃이 나는 것을 확인할 수 있었습니다.</p>
<pre><code>org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
Unable to acquire JDBC Connection
HikariPool-1 - Connection is not available, request timed out after 3005ms.</code></pre><p>쿠폰 발급의 트랜잭션을 시작하며 커넥션을 얻고 HikariCP에 커넥션을 반납하지 못해 나타나는 문제로 생각했습니다. 그렇다면 어디가 병목지점인지 알 필요가 있었는데요. DB에 많은 데이터를 넣기 전에는 문제가 발생하지 않았기에, DB가 병목지점이라고 판단했습니다. DB의 어떤 지점이 문제를 일으키고 있는지 확인하기 위해 AWS CloudWatch 를 이용했습니다.</p>
<p>슬로우 쿼리로 인해 문제가 발생한 것을 확인할 수 있었는데요, 이를 개선하기 위해 슬로우 쿼리 분석해보기로 결정했습니다.</p>
<h2 id="슬로우쿼리-모니터링-설정">슬로우쿼리 모니터링 설정</h2>
<p>저희 프로젝트에서는 데이터베이스로 AWS RDS를 사용하고 있는데, AWS 에서 기본적으로 CloudWatch 모니터링을 제공합니다. 기본적인 지표들은 손쉽게 확인할 수 있지만 슬로우 쿼리나 DB 로그들을 확인하기 위해서는 몇 가지 추가 설정이 필요합니다.</p>
<p>먼저 RDS 설정에서 <code>로그 내보내기</code> 설정에서 <code>느린 쿼리 로그</code>를 선택합니다.
<img src="https://velog.velcdn.com/images/bruni_23yong/post/3c331ded-f0cc-4d49-9b28-9fddb70fccb4/image.png" alt=""></p>
<p>그리고 슬로우 쿼리를 파일로 내보내기 위한 몇 가지 설정을 위해 파라미터를 설정해줘야 하는데요. RDS 파라미터 그룹에서 파라미터 그룹을 생성하고 아래 파라미터를 검색해 값을 편집해야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/242ffad3-b24b-4bbe-94e5-416a74b5917e/image.png" alt=""></p>
<ul>
<li>slow_query_log = 1 (기본값 0)</li>
<li>long_query_time = 1<ul>
<li>몇 초 이상 실행된 쿼리에 대해 로그를 남길 것인지 결정합니다.</li>
<li>저희 프로젝트에서는 1초 이상의 쿼리에 대해 로그를 남기기로 결정했습니다.</li>
</ul>
</li>
<li>log_output = FILE</li>
</ul>
<p>이렇게 편집된 파라미터 그룹을 DB 인스턴스에 적용해주도록 합니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/a58ee8b0-f297-4967-a7ef-e6f8cf470eb3/image.png" alt=""></p>
<p>여기까지 완료되면 DB가 수정되며 파라미터 그룹이 적용됩니다. 그러면 이제 RDS 대시보드에서 다음과 같은 화면을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/ef09528c-356e-4e27-9a8c-a26f7c3870af/image.png" alt=""></p>
<p>슬로우 쿼리가 파일 형태로 남아있는 것을 확인할 수 있는데요. 어떤 쿼리들이 문제를 일으키는지 확인해보겠습니다.</p>
<pre><code># Time: 2023-11-21T15:40:54.099470Z
# User@Host: admin[admin] @ [10.1.1.152] Id: 300
# Query_time: 3.309853 Lock_time: 0.000003 Rows_sent: 3 Rows_examined: 2999787
use promotion;
SET timestamp=1700581250;
select promotiono0_.id as id1_8_, promotiono0_.last_order_at as last_ord2_8_, promotiono0_.last_order_before as last_ord3_8_, promotiono0_.member_type as member_t4_8_, promotiono0_.promotion_id as promotio5_8_ from promotion_option promotiono0_ left outer join promotion promotion1_ on promotiono0_.promotion_id=promotion1_.id where promotion1_.id=1;

# Time: 2023-11-21T15:40:55.795175Z
# User@Host: admin[admin] @ [10.1.1.152] Id: 300
# Query_time: 1.685002 Lock_time: 0.000002 Rows_sent: 1 Rows_examined: 2999603
SET timestamp=1700581254;
select promotiono0_.id as id1_9_0_, coupongrou1_.id as id1_2_1_, promotiono0_.coupon_group_id as coupon_g2_9_0_, promotiono0_.promotion_option_id as promotio3_9_0_, coupongrou1_.admin_nickname as admin_ni2_2_1_, coupongrou1_.finished_at as finished3_2_1_, coupongrou1_.is_random as is_rando4_2_1_, coupongrou1_.promotion_id as promotio8_2_1_, coupongrou1_.started_at as started_5_2_1_, coupongrou1_.title as title6_2_1_, coupongrou1_.type as type7_2_1_ from promotion_option_coupon_group promotiono0_ inner join coupon_group coupongrou1_ on promotiono0_.coupon_group_id=coupongrou1_.id where promotiono0_.promotion_option_id=1;

# Time: 2023-11-21T15:41:08.433419Z
# User@Host: admin[admin] @ [10.1.1.152] Id: 300
# Query_time: 12.632252 Lock_time: 0.000003 Rows_sent: 4 Rows_examined: 9999772
SET timestamp=1700581255;
select coupon0_.id as id1_1_, coupon0_.created_at as created_2_1_, coupon0_.coupon_group_id as coupon_g8_1_, coupon0_.discount as discount3_1_, coupon0_.initial_quantity as initial_4_1_, coupon0_.remain_quantity as remain_q5_1_, coupon0_.title as title6_1_, coupon0_.type as type7_1_ from coupon coupon0_ left outer join coupon_group coupongrou1_ on coupon0_.coupon_group_id=coupongrou1_.id where coupongrou1_.id=1;

# Time: 2023-11-21T15:41:18.232504Z
# User@Host: admin[admin] @ [10.1.1.152] Id: 300
# Query_time: 9.791262 Lock_time: 0.000003 Rows_sent: 4 Rows_examined: 9999772
SET timestamp=1700581268;
select coupon0_.id as col_0_0_ from coupon coupon0_ where coupon0_.coupon_group_id=1;</code></pre><p>현재 프로젝트에는 어떠한 인덱스도 걸려있지 않기 때문에 풀 테이블 스캔을 통해 레코드를 읽어와 느린 것으로 파악이 되는데요. 이제 이를 개선해보겠습니다.</p>
<h2 id="mysql-쿼리-최적화">MySQL 쿼리 최적화</h2>
<p>쿼리를 차례대로 분석해 나가보도록 하겠습니다.</p>
<h3 id="select--from-promotion_option-po-left-join-promotion-p-on-popromotion_id--pid-where-pid--1">select * from promotion_option po left join promotion p on po.promotion_id = p.id where p.id = 1;</h3>
<pre><code>mysql&gt; explain select * from promotion_option po left join promotion p on po.promotion_id = p.id where p.id = 1;
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+---------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows    | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+---------+----------+-------------+
|  1 | SIMPLE      | p     | NULL       | const | PRIMARY       | PRIMARY | 8       | const |       1 |   100.00 | NULL        |
|  1 | SIMPLE      | po    | NULL       | ALL   | NULL          | NULL    | NULL    | NULL  | 2959992 |    10.00 | Using where |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+---------+----------+-------------+

mysql&gt; explain analyze select * from promotion_option po left join promotion p on po.promotion_id = p.id where p.id = 1;
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                             |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -&gt; Filter: (po.promotion_id = 1)  (cost=305637 rows=295999) (actual time=2.53..6118 rows=3 loops=1)
    -&gt; Table scan on po  (cost=305637 rows=2.96e+6) (actual time=2.52..3840 rows=3e+6 loops=1)
 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (6.17 sec)</code></pre><p>먼저 EXPLAIN ANALYZE 기능을 이용해 쿼리의 실행 계획과 단계별 소요된 시간 정보를 분석하겠습니다.</p>
<ol>
<li>promotion_option 테이블을 Table scan</li>
<li>조건(promotion_id가 1)에 일치하는 건 하나를 가져옴</li>
</ol>
<p>promotion_option 테이블에는 PK를 제외한 나머지 컬럼에는 어떠한 인덱스도 걸려있지 않기 때문에 LEFT JOIN시에 promtion_id 컬럼을 가지고 풀 테이블 스캔을 수행합니다. 이후 조건에 맞는 로우를 필터링 합니다.</p>
<p>EXPLAIN 기능을 이용해 분석한 쿼리를 확인해 보면</p>
<ul>
<li>type<ul>
<li>const<ul>
<li>테이블의 레코드 건수에 관계없이 쿼리가 PK 혹은 UK 컬럼을 이용하는 WHERE 조건 절을 가지고 있으며 반드시 1건을 반환하는 경우의 처리 방식입니다.</li>
</ul>
</li>
<li>ALL<ul>
<li>풀 테이블 스캔을 의미하는 접근 방법입니다. </li>
</ul>
</li>
</ul>
</li>
<li>key<ul>
<li>PRIMARY<ul>
<li>최종 선택된 실행 계획에서 사용하는 인덱스를 의미합니다.</li>
</ul>
</li>
</ul>
</li>
<li>key_len<ul>
<li>인덱스의 각 레코드에서 몇 바이트까지 사용했는지 알려주는 값입니다.</li>
<li>id의 타입은 BIGINT이기 때문에 8byte를 사용 하고 있습니다.</li>
</ul>
</li>
<li>rows<ul>
<li>옵티마이저는 얼마나 많은 레코드를 읽고 비교해야 하는지 예측해서 비용을 산정하는데, 대상 테이블에 얼마나 많은 레코드가 포함되어 있는지 혹은 각 인덱스의 분포도가 어떤지를 통계 정보를 기준으로 조사해서 예측합니다.</li>
<li>이때 rows 칼럼 값은 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수를 보여줍니다.</li>
</ul>
</li>
</ul>
<p>두 결과의 정보를 종합해보면 </p>
<ol>
<li>풀 테이블 스캔을 통해 <code>promotion_option</code> 테이블을 스캔하고 (약 300만 건의 레코드)</li>
<li>대상 테이블의 <code>promotion_id</code> 컬럼 값을 기준으로 LEFT JOIN을 수행한 뒤 </li>
<li><code>promotion</code> 테이블의 PK를 기준으로 한 건의 결과를 반환</li>
</ol>
<p>이때 개선할 수 있는 사항은 <code>promotion_option</code> 테이블의 <code>promotion_id</code> 컬럼에 인덱스를 거는 것이라고 판단했습니다.</p>
<pre><code class="language-sql">CREATE INDEX promotion_id_idx ON promtion_option (promotion_id);</code></pre>
<p>인덱스를 적용한 후 다시 결과를 살펴보면 다음과 같습니다.</p>
<pre><code>mysql&gt; explain select * from promotion_option po left join promotion p on po.promotion_id = p.id where p.id = 1;
+----+-------------+-------+------------+-------+------------------+------------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type  | possible_keys    | key              | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+-------+------------------+------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | p     | NULL       | const | PRIMARY          | PRIMARY          | 8       | const |    1 |   100.00 | NULL  |
|  1 | SIMPLE      | po    | NULL       | ref   | promotion_id_idx | promotion_id_idx | 8       | const |    3 |   100.00 | NULL  |
+----+-------------+-------+------------+-------+------------------+------------------+---------+-------+------+----------+-------+
2 rows in set, 1 warning (0.00 sec)

mysql&gt; explain analyze select * from promotion_option po left join promotion p on po.promotion_id = p.id where p.id = 1;
+----------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                    |
+----------------------------------------------------------------------------------------------------------------------------+
| -&gt; Index lookup on po using promotion_id_idx (promotion_id=1)  (cost=2.72 rows=3) (actual time=2.96..2.96 rows=3 loops=1)
 |
+----------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.02 sec)</code></pre><p>promotion_id_idx 인덱스를 이용해 index lookup을 수행합니다. 이때 ref 접근 방법을 사용하는데 동등(Equal) 조건으로 검색할 때 사용되는 접근방법입니다.
결과적으로 처음 6초 걸리던 쿼리를 0.02초로 개선할 수 있었습니다.</p>
<h3 id="select--from-promotion_option_coupon_group-pocg-inner-join-coupon_group-cg-on-pocgcoupon_group_id--cgid-where-pocgpromotion_option_id--1">select * from promotion_option_coupon_group pocg inner join coupon_group cg on pocg.coupon_group_id = cg.id where pocg.promotion_option_id = 1;</h3>
<pre><code>mysql&gt; explain select * from promotion_option_coupon_group pocg inner join coupon_group cg on pocg.coupon_group_id = cg.id where pocg.promotion_option_id = 1;
+----+-------------+-------+------------+--------+---------------+---------+---------+--------------------------------+---------+----------+-------------+
| id | select_type | table | partitions | type   | possible_keys | key     | key_len | ref                            | rows    | filtered | Extra       |
+----+-------------+-------+------------+--------+---------------+---------+---------+--------------------------------+---------+----------+-------------+
|  1 | SIMPLE      | pocg  | NULL       | ALL    | NULL          | NULL    | NULL    | NULL                           | 2715699 |    10.00 | Using where |
|  1 | SIMPLE      | cg    | NULL       | eq_ref | PRIMARY       | PRIMARY | 8       | promotion.pocg.coupon_group_id |       1 |   100.00 | NULL        |
+----+-------------+-------+------------+--------+---------------+---------+---------+--------------------------------+---------+----------+-------------+
2 rows in set, 1 warning (0.01 sec)

mysql&gt; explain analyze select * from promotion_option_coupon_group pocg inner join coupon_group cg on pocg.coupon_group_id = cg.id where pocg.promotion_option_id = 1;
+-------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                             |
+-------------------------------------------------------------------------------------------------------------------------------------+
| -&gt; Nested loop inner join  (cost=572211 rows=271570) (actual time=2.68..5959 rows=1 loops=1)
    -&gt; Filter: (pocg.promotion_option_id = 1)  (cost=273484 rows=271570) (actual time=1.22..5958 rows=1 loops=1)
        -&gt; Table scan on pocg  (cost=273484 rows=2.72e+6) (actual time=1.22..3757 rows=3e+6 loops=1)
    -&gt; Single-row index lookup on cg using PRIMARY (id=pocg.coupon_group_id)  (cost=1 rows=1) (actual time=1.45..1.45 rows=1 loops=1)
|
+-------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (6.01 sec)</code></pre><p>쿼리를 분석해보면 다음과 같습니다.</p>
<ol>
<li><code>promotion_option_coupon_group</code> 테이블을 풀 테이블 스캔을 한 후 </li>
<li>promotion_option_id가 1인 컬럼을 필터링 합니다.</li>
<li><code>coupon_group</code> 테이블의 PK를 가지고 index lookup을 수행합니다.</li>
<li>두 테이블 결과를 조인합니다.</li>
</ol>
<p>전에 분석했던 쿼리와 다른 점은 <code>coupon_group</code> 테이블의 type 컬럼 값인데요. eq_ref 접근 방법을 사용하고 있습니다.</p>
<ul>
<li>eq_ref<ul>
<li>조인에서 처음 읽은 테이블의 컬럼 값을, 그 다음에 읽어야 할 테이블의 PK나 UK 컬럼의 검색 조건에 사용할 때 사용하는 접근방법입니다.</li>
<li>이때 두 번째 이후에 읽는 테이블의 type 컬럼에 eq_ref가 표시됩니다.</li>
</ul>
</li>
</ul>
<p><code>promotion_option_coupon_group</code> 테이블의 covering index를 두어 성능의 개선을 기대할 수 있을 것 같습니다. 현재 저희 프로젝트에서는 coupon_group_id를 가지고 쿼리를 수행하는 로직이 없고 WHERE절에 promotion_option_id를 가지고 쿼리를 수행하는 로직이 존재하기 때문에 다음과 같이 covering index를 두도록 합니다.</p>
<pre><code class="language-sql">CREATE INDEX po_cg_idx ON promotion_option_coupon_group (promotion_option_id, coupon_group_id);</code></pre>
<p>이제 달라진 결과를 보도록 하겠습니다.</p>
<pre><code>mysql&gt; explain select * from promotion_option_coupon_group pocg inner join coupon_group cg on pocg.coupon_group_id = cg.id where pocg.promotion_option_id = 1;
+----+-------------+-------+------------+--------+---------------+-----------+---------+--------------------------------+------+----------+-------------+
| id | select_type | table | partitions | type   | possible_keys | key       | key_len | ref                            | rows | filtered | Extra       |
+----+-------------+-------+------------+--------+---------------+-----------+---------+--------------------------------+------+----------+-------------+
|  1 | SIMPLE      | pocg  | NULL       | ref    | po_cg_idx     | po_cg_idx | 8       | const                          |    1 |   100.00 | Using index |
|  1 | SIMPLE      | cg    | NULL       | eq_ref | PRIMARY       | PRIMARY   | 8       | promotion.pocg.coupon_group_id |    1 |   100.00 | NULL        |
+----+-------------+-------+------------+--------+---------------+-----------+---------+--------------------------------+------+----------+-------------+
2 rows in set, 1 warning (0.01 sec)

mysql&gt; explain analyze select * from promotion_option_coupon_group pocg inner join coupon_group cg on pocg.coupon_group_id = cg.id where pocg.promotion_option_id = 1;
+--------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                    |
+--------------------------------------------------------------------------------------------------------------------------------------------+
| -&gt; Nested loop inner join  (cost=2.2 rows=1) (actual time=0.0946..0.109 rows=1 loops=1)
    -&gt; Covering index lookup on pocg using po_cg_idx (promotion_option_id=1)  (cost=1.1 rows=1) (actual time=0.0412..0.0493 rows=1 loops=1)
    -&gt; Single-row index lookup on cg using PRIMARY (id=pocg.coupon_group_id)  (cost=1.1 rows=1) (actual time=0.036..0.0376 rows=1 loops=1)
|
+--------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.02 sec)</code></pre><p>promotion_option_coupon_group 테이블의 covering index lookup을 통해 promotion_option_id 가 1인 값을 탐색하고 coupon_group 테이블의 PK index lookup을 통해 id가 1인 값을 탐색합니다.</p>
<p>결과적으로 처음 6초 정도 걸리는 쿼리를 0.02초로 개선할 수 있었습니다.</p>
<h3 id="select--from-coupon-c-left-join-coupon_group-cg-on-ccoupon_group_id--cgid-where-cgid--1">select * from coupon c left join coupon_group cg on c.coupon_group_id = cg.id where cg.id = 1;</h3>
<pre><code>mysql&gt; explain select * from coupon c left join coupon_group cg on c.coupon_group_id = cg.id where cg.id = 1;
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+---------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows    | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+---------+----------+-------------+
|  1 | SIMPLE      | cg    | NULL       | const | PRIMARY       | PRIMARY | 8       | const |       1 |   100.00 | NULL        |
|  1 | SIMPLE      | c     | NULL       | ALL   | NULL          | NULL    | NULL    | NULL  | 9556578 |    10.00 | Using where |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+---------+----------+-------------+
2 rows in set, 1 warning (0.01 sec)

mysql&gt; explain analyze  select * from coupon c left join coupon_group cg on c.coupon_group_id = cg.id where cg.id = 1;
+--------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                  |
+--------------------------------------------------------------------------------------------------------------------------+
| -&gt; Filter: (c.coupon_group_id = 1)  (cost=995629 rows=955658) (actual time=1.96..21308 rows=4 loops=1)
    -&gt; Table scan on c  (cost=995629 rows=9.56e+6) (actual time=1.96..13914 rows=10e+6 loops=1)
|
+--------------------------------------------------------------------------------------------------------------------------+
1 row in set (21.34 sec)</code></pre><p>쿼리를 분석해보면 <code>coupon</code> 테이블에 대해 풀 테이블 스캔을 수행한 후 coupon_group_id가 1인 값을 필터링하는 것을 확인할 수 있습니다.</p>
<p><code>coupon</code> 테이블의 <code>coupon_group_id</code> 컬럼에 인덱스를 적용하면 성능의 개선을 기대할 수 있을 것 같습니다.</p>
<pre><code class="language-sql">CREATE INDEX coupon_group_idx ON coupon (coupon_group_id);</code></pre>
<p>이제 결과를 확인해 보겠습니다.</p>
<pre><code>mysql&gt; explain select * from coupon c left join coupon_group cg on c.coupon_group_id = cg.id where cg.id = 1;
+----+-------------+-------+------------+-------+------------------+------------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type  | possible_keys    | key              | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+-------+------------------+------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | cg    | NULL       | const | PRIMARY          | PRIMARY          | 8       | const |    1 |   100.00 | NULL  |
|  1 | SIMPLE      | c     | NULL       | ref   | coupon_group_idx | coupon_group_idx | 8       | const |    4 |   100.00 | NULL  |
+----+-------------+-------+------------+-------+------------------+------------------+---------+-------+------+----------+-------+
2 rows in set, 1 warning (0.04 sec)

mysql&gt; explain analyze  select * from coupon c left join coupon_group cg on c.coupon_group_id = cg.id where cg.id = 1;
+------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                      |
+------------------------------------------------------------------------------------------------------------------------------+
| -&gt; Index lookup on c using coupon_group_idx (coupon_group_id=1)  (cost=4.29 rows=4) (actual time=6.19..6.19 rows=4 loops=1)
|
+------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.04 sec)</code></pre><p><code>coupon</code> 테이블에서 index lookup을 통해 조건에 해당하는 결과를 찾아 결과를 반환하는 것을 확인할 수 있습니다. 결과적으로 21초 정도 걸리는 쿼리를 0.04초로 개선할 수 있었습니다.</p>
<h3 id="select--from-coupon-where-coupon_group_id--1">select * from coupon where coupon_group_id = 1;</h3>
<p>해당 쿼리는 바로 전 단계에서 <code>coupon</code> 테이블의 <code>coupon_group_id</code>에 인덱스를 적용함으로써 해결할 수 있었습니다.</p>
<h2 id="결론">결론</h2>
<p>AWS의 CloudWatch를 통해 슬로우 쿼리를 분석 및 개선하는 과정을 거쳤습니다. 
개선 작업을 수행하기 전 백만 건 이상의 데이터가 있을 때 생각보다 시간이 오래 걸리는 것에 놀랐는데, 개선을 통해 이렇게 까지 시간이 줄어드는 것에 또 놀라게 되었네요.</p>
<p>이상입니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿠폰 발급 로직의 리팩토링 적용기]]></title>
            <link>https://velog.io/@bruni_23yong/%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%A1%9C%EC%A7%81%EC%9D%98-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%A1%9C%EC%A7%81%EC%9D%98-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Thu, 09 Nov 2023 11:36:04 GMT</pubDate>
            <description><![CDATA[<p>프로젝트에서 쿠폰 발급 로직을 개발하는 역할을 맡게 되었습니다. 먼저 ERD를 보여드리자면 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/64aa1dba-71b9-4c92-8b9d-6e0bb9c12a0e/image.png" alt=""></p>
<p>쿠폰을 발급받기 위해서는 아래와 같은 과정을 거쳐야 합니다.</p>
<ol>
<li>회원이 프로모션 조건에 부합하는지 확인</li>
<li>쿠폰의 잔여수량이 1이상인지 확인</li>
<li>쿠폰그룹의 발급 기간이 지나지 않았는지 확인</li>
<li>이미 쿠폰을 발급 받았는지 확인</li>
</ol>
<p>즉 회원이 프로모션 탭에 들어가 <code>쿠폰 받기</code> 버튼을 누르게 되면 위의 조건들을 확인하고 쿠폰을 발급받게 되는 것입니다.</p>
<p>생각보다 복잡한 로직 구현에 머리를 오래 쓰게 되었는데요, 오늘은 이 과정을 공유하고자 합니다.</p>
<h2 id="프로모션-조건">프로모션 조건</h2>
<p>처음에는 회원이 프로모션의 프로모션 조건들에 부합하는지 하나하나 확인하는 과정을 구현하기가 어려웠습니다. 여러 개의 for문과 if 문들의 행진에 정신이 아득해져버려서.. 어떻게 개선할 수 있을지 고민했습니다.
<img src="https://velog.velcdn.com/images/bruni_23yong/post/6c015432-b68e-4f10-af38-148e8c676a9c/image.png" alt=""></p>
<p><code>회원이 각 프로모션 조건들에 부합한다</code> 라는 하나의 행동을 가진 인터페이스를 선언하고 각 조건들이 이를 구현하면 되지 않을까? 라는 생각이 들었습니다.</p>
<p>그래서 회원이 프로모션 조건에 부합하는지 확인하기 위해 조건에 부합한지 확인하는 메서드를 가진 인터페이스 하나를 정의했습니다.</p>
<pre><code class="language-java">public interface PromotionOptionCondition {

    boolean isSatisfied(Order lastOrder);
}</code></pre>
<p>현재 조건은 다음과 같습니다.</p>
<ol>
<li>특정 마지막 주문일을 기준으로 전/후에 주문이력이 있는지 확인</li>
<li>기존회원인지 신규회원인지 (주문이 있는지 없는지의 여부로 확인)</li>
</ol>
<p>이제 각 조건들을 정의하고 있는 클래스들을 작성하고 <code>PromotionOptionCondition</code> 인테페이스를 구현하도록 했습니다.</p>
<pre><code class="language-java">public class MemberTypeCondition implements PromotionOptionCondition {

    private final MemberType memberType;

    public MemberTypeCondition(MemberType memberType) {
        this.memberType = memberType;
    }

    @Override
    public boolean isSatisfied(Order lastOrder) {
        if (lastOrder == null) {
            return memberType == MemberType.NEW_MEMBER;
        }
        return memberType == MemberType.OLD_MEMBER;
    }
}</code></pre>
<pre><code class="language-java">public class LastOrderCondition implements PromotionOptionCondition {

    private final Instant lastOrderedAt;
    private final Boolean lastOrderBefore;

    public LastOrderCondition(Instant lastOrderedAt, Boolean lastOrderBefore) {
        this.lastOrderedAt = lastOrderedAt;
        this.lastOrderBefore = lastOrderBefore;
    }

    @Override
    public boolean isSatisfied(Order lastOrder) {
        if (lastOrder == null) {
            return true;
        }

        Instant lastOrderedAt = lastOrder.getCreatedAt();
        if (lastOrderBefore) {
            return lastOrderedAt.isBefore(this.lastOrderedAt);
        }
        return lastOrderedAt.isAfter(this.lastOrderedAt);
    }
}</code></pre>
<h2 id="쿠폰-발급">쿠폰 발급</h2>
<p>여기까지의 과정을 코드로 옮겨보면 아래와 같습니다.</p>
<pre><code class="language-java">    @Transactional
    public void issueCoupon(CouponIssueRequest request, Member member) {
        List&lt;PromotionOption&gt; promotionOptions = promotionOptionRepository.findByPromotionId(request.promotionId());

        CouponGroup allMatchedCouponGroup = promotionOptions.stream()
                .filter(promotionOption -&gt; isMemberSatisfied(member, promotionOption.getConditions()))
                //...
    }

    private boolean isMemberSatisfied(Member member, List&lt;PromotionOptionCondition&gt; conditions) {
        Order lastOrder = orderRepository.findLastOneByMemberId(member.getId());

        return conditions.stream()
                .allMatch(condition -&gt; condition.isSatisfied(lastOrder));
    }</code></pre>
<p>하나의 프로모션에 해당하는 프로모션 조건들을 모두 찾고 발급해주려는 회원이 조건에 부합한지 확인합니다.</p>
<p>이제 나머지 3개의 조건을 확인하면 되는데요.</p>
<ul>
<li>쿠폰의 잔여수량이 1이상인지 확인</li>
<li>쿠폰그룹의 발급 기간이 지나지 않았는지 확인</li>
<li>이미 쿠폰을 발급 받았는지 확인</li>
</ul>
<p>먼저 쿠폰의 잔여수량이 1이상인지 확인하는 메서드를 작성해보겠습니다.</p>
<pre><code class="language-java">    private boolean hasRemainCoupon(CouponGroup couponGroup) {
        return couponRepository.findByCouponGroupId(couponGroup.getId()).stream()
                .allMatch(coupon -&gt; coupon.getRemainQuantity() &gt; 0);
    }</code></pre>
<p>이제 쿠폰그룹의 발급 기간이 지나지 않았는지 확인해야겠죠?</p>
<pre><code class="language-java">    private boolean isExpiredCouponGroup(CouponGroup couponGroup) {
        return Instant.now().isAfter(couponGroup.getFinishedAt());
    }</code></pre>
<p>마지막으로 쿠폰을 이미 발급받았는지 확인해봅시다.</p>
<pre><code class="language-java">    private boolean isAlreadyIssued(Member member, CouponGroup couponGroup) {
        List&lt;Long&gt; couponIds = couponRepository.findIdsByCouponGroupId(couponGroup.getId());
        if (couponGroup.getType() == Type.PERIOD) {
            return memberCouponRepository.existsByMemberIdAndCouponIdIn(member.getId(), couponIds);
        }
        return memberCouponRepository.existsByMemberIdAndCouponIdInAndToday(member.getId(),
                couponIds,
                Instant.now().truncatedTo(ChronoUnit.DAYS),
                Instant.now().truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS));
    }</code></pre>
<p>쿠폰의 종류에는 두 가지가 있는데요. </p>
<ul>
<li>매일발급 가능한 쿠폰</li>
<li>프로모션 기간내 한 번만 발급가능한 쿠폰</li>
</ul>
<p>쿠폰의 종류가 두 가지가 존재하기 때문에 쿠폰의 종류에 맞는 쿼리를 작성하게 되었습니다.</p>
<p>이제 이를 하나의 스트림으로 엮어 작성하기만 하면 됩니다!</p>
<pre><code class="language-java">    @Transactional
    public void issueCoupon(CouponIssueRequest request, Member member) {
        List&lt;PromotionOption&gt; promotionOptions = promotionOptionRepository.findByPromotionId(request.promotionId());

        CouponGroup allMatchedCouponGroup = promotionOptions.stream()
                .filter(promotionOption -&gt; isMemberSatisfied(member, promotionOption.getConditions()))
                .map(this::getCouponGroups)
                .findFirst()
                .flatMap(couponGroups -&gt; couponGroups.stream()
                        .filter(this::hasRemainCoupon)
                        .filter(couponGroup -&gt; !isExpiredCouponGroup(couponGroup))
                        .filter(couponGroup -&gt; !isAlreadyIssued(member, couponGroup))
                        .findFirst())
                .orElseThrow(() -&gt; new ApiException(CouponGroupException.NOT_FOUND));

        issueCouponInCouponGroup(allMatchedCouponGroup, member);
    }</code></pre>
<h2 id="결론">결론</h2>
<p>쿠폰 도메인이 정말 복잡하다는 것을 느꼈는데, 실제 현업에서는 어떤 방식으로 처리하고 있을지, 얼마나 복잡할지 궁금해지는 시간이었습니다.</p>
<p>결과적으로 복잡한 이전 로직을 Stream API를 이용하며 개선해 나가는 재밌는 시간이었습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] DNS 탐구]]></title>
            <link>https://velog.io/@bruni_23yong/Network-DNS-%ED%83%90%EA%B5%AC</link>
            <guid>https://velog.io/@bruni_23yong/Network-DNS-%ED%83%90%EA%B5%AC</guid>
            <pubDate>Mon, 30 Oct 2023 03:27:00 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 진행하며 가비아에서 도메인을 구입하여 DNS 관리 서비스를 통해 호스트를 설정하고 IP주소를 설정한 경험이 있었습니다. 하지만 이것들이 어떤 것을 의미하는지 모르고 사용했기 때문에 오늘은 이를 이해하기 위해 DNS에 대해 알아보고자 합니다.</p>
<h2 id="dns">DNS</h2>
<p>우리는 naver.com, google.com 과 같이 호스트 네임을 브라우저에 입력해 IP주소를 얻어 필요한 데이터를 얻습니다. 이 과정에서 호스트 네임을 IP 주소로 바꿔주는 과정을 DNS 서버가 담당하게 됩니다. </p>
<img src="https://velog.velcdn.com/images/bruni_23yong/post/c3558324-a5ca-4c60-aa43-247e8ff710f0/image.png" width=500 />

<p>사실 DNS가 제공해주는 서비스는 이것 말고도 몇 가지가 더 존재합니다.</p>
<ul>
<li>호스트 에일리어싱<ul>
<li>복잡한 호스트네임을 가진 호스트는 하나 이상의 이름을 가질 수 있습니다. 예를 들어 apple.red.tasty.food.com이라는 호스트 이름은 food.com, <a href="http://www.food.com%EA%B3%BC">www.food.com과</a> 같은 별칭을 가질 수 있습니다. 이 경우에 apple.red.tasty.food.com는 <code>정식 호스트 이름(canonical hostname)</code>이라고 합니다. 이처럼 DNS는 제시한 호스트 이름에 대한 정식 호스트 이름을 얻기 위해 사용될 수도 있습니다.</li>
</ul>
</li>
<li>메일 서버 에일리어싱<ul>
<li>전자 메일을 송수신할 때 도메인 이름과 메일서버 사이의 중개 역할을 수행하기도 합니다. 우리는 보통 메일의 수신자가 <a href="mailto:user@naver.com">user@naver.com</a> 과 같이 간단한 것을 확인할 수 있었습니다. 메일 클라이언트는 수신자 도메인(naver.com)에 대해 DNS 서버에게 (MX 레코드를 통해) 질의합니다. DNS 서버는 해당 도메인의 메일서버의 호스트 이름을 반환합니다(MX 레코드를 반환). 그러면 메일 클라이언트는 얻은 메일서버의 호스트이름에 메일을 전송하게 됩니다.</li>
</ul>
</li>
<li>부하 분산<ul>
<li>중복된 서버 사이에서 부하를 분산하기 위해서도 DNS를 사용할 수 있습니다. 예를 들어 naver.com 같은 인기있는 사이트는 여러 서버에 중복되어 있기 때문에 각각이 다른 종단 시스템, 다른 IP주소를 가지고 있습니다. DNS 데이터베이스는 이 IP주소들의 집합을 가지고 있고 클라이언트가 해당 호스트 네임으로 질의를 하게 되면 DNS 서버는 IP주소 집합을 가지고 응답을 하게 됩니다.</li>
</ul>
</li>
</ul>
<p>그러면 DNS는 어떻게 동작할까요?</p>
<h3 id="dns의-동작">DNS의 동작</h3>
<p>사용자의 어떤 애플리케이션이 호스트 네임을 IP 주소로 변환하려 한다고 가정해 봅시다. 그 애플리케이션은 DNS 클라이언트를 호출해 네트워크에 질의 메시지를 보낼 것입니다. 그러면 호스트의 DNS는 응답을 메시지를 받게 될 것입니다. 애플리케이션 관점에서 DNS는 변환서비스를 제공하는 블랙박스로 볼 수 있습니다. </p>
<blockquote>
<p>DNS는 UDP 프로토콜을 통해 데이터를 주고 받습니다.</p>
</blockquote>
<p>본격적으로 DNS의 동작과정에 대해 알아보기 전에 알아야 할 것들이 몇 가지 존재합니다.</p>
<h4 id="분산-계층">분산 계층</h4>
<img src="https://velog.velcdn.com/images/bruni_23yong/post/34846e61-82e4-463d-9cb2-4ecd72d19ac4/image.png" width=700 />

<p>DNS는 위 그림과 같이 계층 구조를 가지고 있습니다. 세 유형의 DNS 서버가 존재하는데 다음과 같습니다.</p>
<ul>
<li>루트 DNS 서버</li>
<li>탑 레벨 도메인 서버 (Top-level domain, TLD)</li>
<li>책임 서버 (authoritative) </li>
</ul>
<p>또한 각각의 서버는 한 대만 존재하는 것이 아니라 분산되어 있습니다.</p>
<blockquote>
<p>왜 분산되어 있을까?
만약 전세계에 하나의 DNS 서버만이 존재한다고 가정해봅시다.</p>
<ul>
<li>그렇다면 모든 요청이 한 대의 DNS 서버에게 갈 것이고 단일 DNS 서버가 모든 DNS 질의를 처리해야 할 것입니다. </li>
<li>DNS 서버가 미국에 한 대 있다고 했을 때 대한민국에 있는 저는 미국으로부터의 <code>물리적 거리</code>가 있기 때문에 지연이 발생할 것입니다. 하지만 미국에 있는 사람들은 지연이 덜 하겠죠.</li>
<li>SPOF: 만약 이 서버가 고장이 나면 어떻게 될까요? 인터넷이 멈춰버리는 대참사가 발생하게 됩니다.</li>
</ul>
</blockquote>
<p>상위 계층의 DNS 서버는 하위 계층의 DNS 서버의 IP 주소들을 알고 있습니다. 만약 <a href="http://www.naver.com">www.naver.com</a> 이라는 호스트네임의 IP 주소를 얻고 싶다면 먼저 루트 DNS 서버에게 질의합니다. 그러면 루트 DNS 서버는 com이라는 TLD 서버의 IP 주소를 제공합니다. TLD에서는 책임 DNS 서버(naver.com)에 대한 IP 주소를 제공합니다.</p>
<p>다시 돌아와서, 우리가 naver.com의 IP주소를 원한다고 해보겠습니다. 그러면 아래와 같이 동작하게 되는데, 그 과정은 다음과 같습니다.</p>
<img src="https://velog.velcdn.com/images/bruni_23yong/post/de6750d8-fe7f-49bc-b2f9-3444ef560374/image.png" width=600 />

<ol>
<li>호스트는 먼저 자신의 로컬 DNS 서버에게 요청을 보냅니다.</li>
<li>로컬 DNS 서버는 이를 받아 root DNS server에게 질의 메시지를 던집니다. </li>
<li>그러면 root DNS 서버는 <code>com</code>을 인식하고 <code>com</code>에 대한 책임을 가진 TLD DNS 서버의 IP 주소 목록을 로컬 DNS 서버에게 보냅니다.</li>
<li>로컬 DNS 서버는 TLD DNS 서버에게 질의 메시지를 던집니다.</li>
<li>TLD 서버는 <code>naver.com</code>을 인식하고 네이버의 책임 DNS 서버의 IP주소를 응답합니다.</li>
<li>마지막으로 로컬 DNS 서버는 책임 DNS 서버에게 질의 메시지를 다시 보내고</li>
<li>IP주소를 얻어</li>
<li>호스트에게 응답합니다.</li>
</ol>
<p>이 과정에서 메시지가 전달되는 과정이 8번이나 반복됐습니다. 이를 해결하기 위해 DNS 캐싱을 수행합니다. 간단히 DNS 서버가 질의를 받았을 때 아는 정보라면 바로 응답해주는 것이죠.</p>
<h3 id="dns-레코드">DNS 레코드</h3>
<p>DNS 서버들은 호스트네임을 IP주소로 매핑하기 위한 Resouce Record(RR)을 저장합니다. RR은 다음과 같은 필드를 포함하는 4개의 튜플로 구성되어 있습니다.</p>
<ul>
<li>name</li>
<li>value</li>
<li>type</li>
<li>ttl (time to live)<ul>
<li>RR이 DNS 서버에서 존재할 수 있는 생존기간을 의미합니다.(자원이 캐시되는 시간을 결정합니다.)</li>
</ul>
</li>
</ul>
<p>name, value는 type에 의해 결정되는데 type은 아래와 같은 것들이 존재합니다.</p>
<ul>
<li>type = A<ul>
<li>type이 A이면 name은 호스트네임이 되고 value는 호스트네임에 대한 IP주소를 의미합니다.</li>
<li>예를 들어 name이 abc.com이면 value는 12.23.34.45 인것이죠.</li>
</ul>
</li>
<li>type = NS<ul>
<li>type이 NS이면 name은 호스트네임이 되고 value는 도메인 내부의 호스트에 대한 IP주소를 얻을 수 있는 방법을 아는 책임 DNS 서버의 호스트네임입니다.</li>
<li>예를 들어 name이 abc.com이면 value는 dns.abc.com 인것이죠.</li>
</ul>
</li>
<li>type = CNAME<ul>
<li>name이 별칭 호스트네임일 때 value는 name(별칭 호스트네임)의 정식 호스트네임입니다.</li>
<li>예를 들어 name이 abc.com이면 value는 ghi.def.abc.com 인 것이죠.</li>
</ul>
</li>
<li>type = MX<ul>
<li>name이 별칭 호스트네임일 때 value는 이를 갖는 메일 서버의 정식 이름입니다.</li>
<li>예를 들어 name이 abc.com이면 value는 mail.abc.com 인 것이죠.</li>
</ul>
</li>
</ul>
<h3 id="dns-클라이언트-만들어보기">DNS 클라이언트 만들어보기</h3>
<p>이제 DNS 의 질의 방식과 응답형태를 알았으니 간단하게 DNS 클라이언트를 만들어보겠습니다.
<a href="https://datatracker.ietf.org/doc/html/rfc1035#section-3.2">RFC 문서</a>를 보며 클라이언트를 만들어보죠.</p>
<p>먼저 DNS 서버에게 요청을 보낼 레코드를 만들어야겠죠? 저는 Java를 이용해 진행해보겠습니다.</p>
<p>RFC 문서에 따르면 Header Section은 아래의 그림과 같이 구성되어 있다고 나옵니다.
<img src="https://velog.velcdn.com/images/bruni_23yong/post/f6c71604-b804-4003-8d71-b6e0508019c8/image.png" alt=""></p>
<p>그리고 Question Section은 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/275aebd0-dfa8-477e-bf65-1e2b0895812c/image.png" alt=""></p>
<pre><code class="language-java"> return new byte[]{
                // Header Section
                // 트랜잭션 아이디 (transaction id)
                0x00, 0x01,
                // |QR| Opcode |AA|TC|RD|RA|Z|RCODE|
                // 0      0     0  0  0  0  0 0
                0x00, 0x00, // QR, Opcode, AA, TC, RD, RA, Z, RCODE
                // QDCOUNT
                0x00, 0x01,
                // ANCOUNT
                0x00, 0x00,
                // NSCOUNT
                0x00, 0x00,
                // ARCOUNT
                0x00, 0x00,

                // Question Section
                // 질의 이름 QNAME(query name) 5naver3com0
                0x05, 0x6e, 0x61, 0x76, 0x65, 0x72,
                0x03, 0x63, 0x6f, 0x6d,
                0x00,

                // 질의 타입 QTYPE(query type) A
                0x00, 0x01, // A

                // 질의 클래스 QCLASS(query class) IN(1)
                0x00, 0x01
        };</code></pre>
<p>이와 같은 질의 쿼리를 보내고 응답을 받았습니다. 바이트 형태로 출력해보면 아래와 같습니다.</p>
<pre><code>Header Section
 00 01 -&gt; 트랜잭션 아이디 (transaction id)
 80 80 -&gt; |QR| Opcode |AA|TC|RD|RA|Z|RCODE|
 00 01 -&gt; QDCOUNT
 00 04 -&gt; ANCOUNT
 00 00 -&gt; NSCOUNT
 00 00 -&gt; ARCOUNT
     *
 Question Section
 // QNAME (5naver3com0)
 05 6e 61 76 65 72 -&gt; naver
 03 63 6f 6d -&gt; com
 00

 00 01 -&gt; 질의 타입 (query type) A type(1)
 00 01 -&gt; 질의 클래스 (query class) IN(1)
     *
 Answer Section
 c0 0c -&gt; offset 12
 00 01 -&gt; TYPE : A type(1)
 00 01 -&gt; CLASS : IN(1)
 00 00 00 b0 -&gt; TTL : 0xb0 -&gt; 176
 00 04 -&gt; RDLENGTH : 4
 df 82 c8 6b -&gt; RDATA : 223 130 200 107
     *
 c0 0c -&gt; offset 12
 00 01 -&gt; TYPE: A type(1)
 00 01 -&gt; CLASS : IN(1)
 00 00 00 b0 -&gt; TTL : 0xb0 -&gt; 176
 00 04 -&gt; RDLENGTH : 4
 df 82 c3 5f -&gt; RDATA : 223 130 195 95

 c0 0c -&gt; offset 12
 00 01 -&gt; TYPE: A type(1)
 00 01 -&gt; CLASS: IN(1)
 00 00 00 b0 -&gt; TTL : 176
 00 04 -&gt; RDLENGTH : 4
 df 82 c3 c8 -&gt; RDATA : 223 130 195 200
     *
 c0 0c -&gt; offset 12
 00 01 -&gt; TYPE: A type(1)
 00 01 -&gt; CLASS: IN(1)
 00 00 00 b0 -&gt; TTL : 176
 00 04 -&gt; RDLENGTH : 4
 df 82 c8 68 -&gt; RDATA : 223 130 200 104</code></pre><p>Answer Section을 살펴보면 우리가 A type의 레코드를 요청했기 때문에 응답도 A type으로 온 것을 볼 수 있습니다. 또한 TTL과 IP주소도 정상적으로 온 것을 확인할 수 있었습니다.</p>
<h2 id="결론">결론</h2>
<p>처음에는 DNS 가 단순히 호스트네임을 IP주소로 변환해준다 정도로 알고 있었는데 별칭을 부여해주는 에일리어싱 서비스, 부하분산등을 제공해준다는 것을 배울 수 있었습니다.
또한 DNS의 응답 type에 따라 오는 value들이 달라진다는 것을 보며 </p>
<p>정말 간단하게 만들어본 DNS Client는 아래 링크에서 확인할 수 있습니다.</p>
<p><a href="https://gist.github.com/23Yong/c7aa6030b73e519bd1e5b2fc7b3e6aa1">https://gist.github.com/23Yong/c7aa6030b73e519bd1e5b2fc7b3e6aa1</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] N+1 문제 해결하기!]]></title>
            <link>https://velog.io/@bruni_23yong/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 20 Oct 2023 13:16:06 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 진행하며 다음과 같은 문제를 해결해야했습니다.</p>
<blockquote>
<p><strong><code>쿠폰그룹</code> 조회시 <code>쿠폰그룹</code> 내의 <code>쿠폰</code> 들의 이름과 <code>쿠폰그룹</code>들의 [잔여 개수 / 총개수] 를 계산하고 페이징 처리하여 <code>쿠폰그룹</code> 목록을 반환</strong></p>
</blockquote>
<p>이번 포스팅에서는 이 문제를 해결하는 과정을 포스팅해보려 합니다.</p>
<h3 id="테이블간의-관계">테이블간의 관계</h3>
<p><code>쿠폰그룹</code> 과 <code>쿠폰</code> 은 다음과 같은 연관관계를 가지고 있습니다.</p>
<p><img src="https://github.com/woowa-coupons/tech-blog/assets/66981851/c8f64cad-692d-497d-b6ac-ca9c073dfc90" alt="image"></p>
<p>위의 그림에서 볼 수 있듯이 <code>쿠폰그룹</code> 과 <code>쿠폰</code>은 <strong>일대다 연관관계</strong>를 가지고 있습니다. 하나의 <code>쿠폰그룹</code> 조회시 여러개의 <code>쿠폰</code> 을 보여줘야 했는데요. JPA를 사용하고 있는 저희 프로젝트에서 간단하게 떠올릴 수 있는 방법은 다음과 같았습니다.</p>
<ul>
<li>OneToMany 양방향 연관관계를 사용해 하나의 쿠폰그룹 조회시 해당 쿠폰그룹의 쿠폰들을 조회</li>
</ul>
<h3 id="onetomany-양방향-연관관계를-이용">OneToMany 양방향 연관관계를 이용?</h3>
<p>OneToMany 양방향 연관관계를 이용하면 다음과 같이 엔티티를 구성해야 했습니다.</p>
<pre><code class="language-java">@Entity
public class Coupon {
  // ...

  @JoinColumn(name = &quot;coupon_group_id&quot;)
  @ManyToOne(fetch = FetchType.LAZY)
  private CouponGroup couponGroup;

  // ...
}</code></pre>
<pre><code class="language-java">public class CouponGroup {
  // ...

  @OneToMany(mappedBy = &quot;couponGroup&quot;)
  private List&lt;Coupon&gt; coupons;
}</code></pre>
<p>이후 서비스 로직에서 아래와 같이 쿠폰그룹내의 쿠폰들을 조회할 수 있었습니다.</p>
<pre><code class="language-java">Page&lt;CouponGroup&gt; couponGroups = couponGroupRepository.findAll(pageRequest);

Map&lt;CouponGroup, List&lt;Coupon&gt;&gt; couponGroupCouponMap = couponGroups.getContent().stream()
        .collect(Collectors.toMap(couponGroup -&gt; couponGroup, CouponGroup::getCoupons));</code></pre>
<p>하지만 막상 코드를 실행시켜보면 아래와 같은 쿼리가 날라가는 것을 확인할 수 있습니다.</p>
<pre><code class="language-sql">[Hibernate] 
    select
        coupongrou0_.id as id1_2_,
        coupongrou0_.finished_at as finished2_2_,
        coupongrou0_.promotion_id as promotio5_2_,
        coupongrou0_.started_at as started_3_2_,
        coupongrou0_.title as title4_2_ 
    from
        coupon_group coupongrou0_ limit ? // 쿠폰그룹을 전체 조회하는 쿼리
[Hibernate] 
    select
        // 생략
    from
        coupon coupons0_ 
    where
        coupons0_.coupon_group_id=? // 특정 쿠폰그룹의 쿠폰 목록을 조회하는 쿼리 1
[Hibernate] 
    select
        // 생략
    from
        coupon coupons0_ 
    where
        coupons0_.coupon_group_id=? // 특정 쿠폰그룹의 쿠폰 목록을 조회하는 쿼리 2
[Hibernate] 
    select
        // 생략
    from
        coupon coupons0_ 
    where
        coupons0_.coupon_group_id=? // 특정 쿠폰그룹의 쿠폰 목록을 조회하는 쿼리 3</code></pre>
<p>쿼리의 결과를 보면 <code>쿠폰그룹</code>을 전체 조회하는 쿼리 1번, 각각의 쿠폰그룹에 대하여 <code>쿠폰</code> 목록이 조회되는 쿼리가 N번 날라가는 것을 확인할 수 있습니다. 이는 쿠폰그룹의 개수가 많아질수록 성능이 떨어지기 때문에 개선해야합니다.</p>
<h3 id="fetch-join">Fetch Join</h3>
<p>N + 1 문제를 해결하는 다양한 방법 중 먼저 FETCH JOIN을 사용해 문제를 해결해보고자 했습니다.</p>
<ul>
<li>OneToMany 관계에서 조인을 하게 되면 카테시안 곱이 발생해 의도와는 다른 결과를 얻을 수 있기 때문에 <code>DISTINCT</code> 키워드를 붙여 중복을 제거합니다.</li>
<li>페이징을 수행하기 위해서는 <code>@Query</code> 에 <code>countQuery</code> attribute도 넣어주어야 하기 때문에 아래와 같이 코드를 작성했습니다.</li>
</ul>
<pre><code class="language-java">@Query(value = &quot;SELECT DISTINCT couponGroup FROM CouponGroup couponGroup JOIN FETCH couponGroup.coupons&quot;,
            countQuery = &quot;SELECT COUNT(DISTINCT couponGroup) FROM CouponGroup couponGroup INNER JOIN couponGroup.coupons&quot;)
Page&lt;CouponGroup&gt; findAll(Pageable pageable);</code></pre>
<blockquote>
<p><strong>왜 countQuery를 넣어야 하는가?🧐</strong> JPA에서 <code>@Query</code>를 사용하지 않고 페이징을 수행하게 되면 의도하지 않았던 countQuery가 날라가는 것을 확인할 수 있는데요, 이는 <code>Page</code> 인터페이스의 <code>getTotalElements()</code>의 메서드의 수행 결과를 반환해야 하기 때문이 아닌가 예상하고 있습니다. 따라서 <code>@Query</code>를 사용해 직접 쿼리를 날려 페이징을 수행하기 위해서는 별도의 countQuery가 필요합니다.</p>
</blockquote>
<p>결과를 보면 다음과 같은 쿼리가 날라가고 있는 것을 확인할 수 있습니다.</p>
<pre><code class="language-sql">select
        distinct coupongrou0_.id as id1_2_0_,
        coupons1_.id as id1_1_1_,
        coupongrou0_.admin_nickname as admin_ni2_2_0_,
        coupongrou0_.finished_at as finished3_2_0_,
        coupongrou0_.promotion_id as promotio6_2_0_,
        coupongrou0_.started_at as started_4_2_0_,
        coupongrou0_.title as title5_2_0_,
        coupons1_.created_at as created_2_1_1_,
        coupons1_.coupon_group_id as coupon_g8_1_1_,
        coupons1_.discount as discount3_1_1_,
        coupons1_.initial_quantity as initial_4_1_1_,
        coupons1_.remain_quantity as remain_q5_1_1_,
        coupons1_.title as title6_1_1_,
        coupons1_.type as type7_1_1_,
        coupons1_.coupon_group_id as coupon_g8_1_0__,
        coupons1_.id as id1_1_0__ 
    from
        coupon_group coupongrou0_ 
    inner join
        coupon coupons1_ 
            on coupongrou0_.id=coupons1_.coupon_group_id</code></pre>
<h4 id="또다른-문제">또다른 문제</h4>
<p>하나의 쿼리로 조인을 해오는 것을 확인할 수 있었습니다. 그런데 이상한 점은 <code>LIMIT</code>이 안보인다는 점인데요. 로그를 보니 아래와 같은 메시지를 보내고 있었습니다.</p>
<pre><code class="language-sql">WARN firstResult/maxResults specified with collection fetch; applying in memory!</code></pre>
<p>JPA를 사용할 때 OneToMany 관계에서 FETCH JOIN과 Pagination을 함께 사용하면 쿼리의 결과를 모두 메모리에 적재한 뒤 메모리에서 페이징을 수행하기 때문입니다. 이와 같은 방법은 OOM(Out Of Memory)문제를 발생시킬 수 있기 때문에 좋지 않은 방법입니다.</p>
<h3 id="default_batch_fetch_size-를-통한-문제-해결">default_batch_fetch_size 를 통한 문제 해결</h3>
<p>JPA의 XXXToMany 관계에서 FETCH JOIN과 페이징을 함께 사용할 수는 없습니다. 그렇기 때문에 FETCH JOIN을 제거하고 N+1 문제를 해결하기 위해 <code>default_batch_fetch_size</code> 옵션을 통해 문제를 해결했습니다.</p>
<p><code>default_batch_fetch_size</code> 옵션을 활용하면 Lazy Loading으로 인해 발생되는 N개의 쿼리를 설정한 옵션만큼 모아 IN 절로 처리하게 됩니다. 예를들어 100개의 추가 쿼리가 발생한다고 했을 때 <code>default_batch_fetch_size</code> 옵션을 10으로설정하게 되면 발생하는 추가 쿼리의 수를 100 / 10으로 줄일 수 있게 됩니다.</p>
<p>실제로 발생하는 쿼리를 확인해볼까요?</p>
<pre><code class="language-sql">[Hibernate] 
    select
        coupongrou0_.id as id1_2_,
        coupongrou0_.admin_nickname as admin_ni2_2_,
        coupongrou0_.finished_at as finished3_2_,
        coupongrou0_.promotion_id as promotio6_2_,
        coupongrou0_.started_at as started_4_2_,
        coupongrou0_.title as title5_2_ 
    from
        coupon_group coupongrou0_ 
    order by
        coupongrou0_.id desc limit ?
[Hibernate] 
    select
        count(coupongrou0_.id) as col_0_0_ 
    from
        coupon_group coupongrou0_
[Hibernate] 
    select
        coupons0_.coupon_group_id as coupon_g8_1_1_,
        coupons0_.id as id1_1_1_,
        coupons0_.id as id1_1_0_,
        coupons0_.created_at as created_2_1_0_,
        coupons0_.coupon_group_id as coupon_g8_1_0_,
        coupons0_.discount as discount3_1_0_,
        coupons0_.initial_quantity as initial_4_1_0_,
        coupons0_.remain_quantity as remain_q5_1_0_,
        coupons0_.title as title6_1_0_,
        coupons0_.type as type7_1_0_ 
    from
        coupon coupons0_ 
    where
        coupons0_.coupon_group_id in (
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        )</code></pre>
<p>마지막 쿼리를 확인해보면 <code>IN</code> 절을 통해 데이터를 한 번에 가져오는 것을 확인할 수 있었습니다.</p>
<p>물론 이 방법에도 문제가 있습니다. 쿠폰그룹의 수가 굉장히 많다면 N/(옵션으로 설정한 값)번의 쿼리가 추가적으로 발생하는 것은 어쩔 수 없습니다. 하지만 저희 프로젝트에서는 페이징을 통해 한 페이지당 조회하는 쿠폰그룹의 수가 제한되어 있기 때문에 이 방법을 통해 N+1 문제를 해결할 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[우리는 채팅을 왜 Long Polling으로 개발했는가]]></title>
            <link>https://velog.io/@bruni_23yong/%EC%9A%B0%EB%A6%AC%EB%8A%94-%EC%B1%84%ED%8C%85%EC%9D%84-%EC%99%9C-Long-Polling%EC%9C%BC%EB%A1%9C-%EA%B0%9C%EB%B0%9C%ED%96%88%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@bruni_23yong/%EC%9A%B0%EB%A6%AC%EB%8A%94-%EC%B1%84%ED%8C%85%EC%9D%84-%EC%99%9C-Long-Polling%EC%9C%BC%EB%A1%9C-%EA%B0%9C%EB%B0%9C%ED%96%88%EB%8A%94%EA%B0%80</guid>
            <pubDate>Sat, 14 Oct 2023 05:50:12 GMT</pubDate>
            <description><![CDATA[<p>채팅을 구현해야하는 프로젝트의 요구사항이 존재해 채팅 기능을 개발해야 했습니다. 채팅이라고 하면 웹 소켓이 가장 먼저 떠올랐지만 다르게 구현할 수 있는 기술이 무엇이 있을까 하고 고민했습니다. 오늘은 그 과정을 포스팅 하고자 합니다.</p>
<h2 id="문제">문제</h2>
<p>기능 요구사항 중 채팅 기능에 대한 요구사항이 있었습니다. 그런데 단순히 &quot;채팅 기능이 있어야 한다.&quot; 정도로 요구사항이 써져있었기 때문에 문제에 대한 상황은 저희가 정해야 했습니다.</p>
<ul>
<li>1:1 채팅? 그룹 채팅?<ul>
<li>판매자와 구매자만이 채팅할 수 있기 때문에 1:1 채팅으로 정합니다.</li>
</ul>
</li>
<li>웹 애플리케이션? 모바일까지 고려?<ul>
<li>프론트엔드가 웹 개발자이기 때문에 웹으로 한정짓습니다.</li>
</ul>
</li>
<li>중요 기능은 무엇인가?<ul>
<li>채팅방의 회원들이 각각 어디까지 읽었는지</li>
<li>읽지 않은 채팅의 개수를 어떻게 처리할 것 인가?</li>
<li>채팅이 왔을 때 어떻게 notification 할 것인가?</li>
<li>이렇게 문제와 상황의 범위를 결정 짓고 채팅을 어떤 방식으로 구현할 지 결정해야 했습니다.</li>
</ul>
</li>
</ul>
<h2 id="채팅의-구현방식">채팅의 구현방식</h2>
<p>채팅을 구현하는 방식에는 여러가지가 존재합니다. 그중에서 우리 팀이 생각한 방식은 아래 세 가지 입니다.</p>
<ul>
<li>Polling</li>
<li>Long polling</li>
<li>Websocket</li>
</ul>
<h3 id="polling">Polling</h3>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/fa73a937-68cf-411b-a950-119b8458f987/image.png" alt=""></p>
<p>처음으로 폴링에 대해서 알아보겠습니다. 클라이언트가 일정 주기로 서버에게 필요한 데이터를 요청하는 방식입니다. 예를 들면, 이런 거죠!</p>
<pre><code>클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: 없는데?

클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: 없다니까?

클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: ...없어

클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: 오 이번에는 줄게 있어.</code></pre><p>가장 구현하기 쉬운 방법이지만 다음과 같은 단점이 있습니다.</p>
<p>서버에 변경사항이 없어도 클라이언트는 계속 요청을 보내게 됩니다.
이로 인해 서버에 부담을 주게 됩니다. 또한 HTTP Connection을 맺기 위한 비용이 계속 발생합니다.
폴링 방식은 서비스가 매우 작고 구현이 굉장히 급하다면 선택할 수 있겠지만 일반적으로 더 나은 방법을 필요로 합니다.</p>
<h3 id="long-polling">Long polling</h3>
<p>롱 폴링은 이름답게 클라이언트의 요청에 대해서 서버가 일정시간동안 기다렸다가 클라이언트에게 응답을 주는 방식입니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/5d014368-7e40-4297-bbeb-163ef3e2fa3e/image.png" alt=""></p>
<p>이 방식도 한 번 예를 들어 보겠습니다.</p>
<pre><code>클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: (잠시 기다렸다가)...없어

클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: (잠시 기다렸다가)...없는데

클라이언트 : 안녕, 나 채팅 메시지 좀 줄래?
서버: (잠시 기다리는 중에 채팅 메시지가 옴)여기있어!</code></pre><p>즉 롱폴링의 흐름은 다음과 같습니다.</p>
<ol>
<li>클라이언트가 서버에게 요청을 보낸다.</li>
<li>서버는 메시지를 보낼 때까지 커넥션을 끊지 않고 기다린다.</li>
<li>만약 서버에 메시지가 왔다면 1번 요청에 대한 응답을 보낸다.</li>
<li>이를 받은 클라이언트는 다시 서버에게 요청을 보낸다.</li>
</ol>
<p>롱폴링 방식은 클라이언트와 연결을 유지하며 변경된 데이터가 발생하거나 정해진 타임아웃 시간이 지나면 데이터를 전송하게 됩니다. 하지만 다음과 같은 단점이 있습니다.</p>
<ul>
<li>데이터가 빈번히 바뀌게 되면 폴링 방식보다 많은 요청과 응답을 하게 됩니다.</li>
<li>또한 다수의 클라이언트에게 동시에 이벤트가 발생하게 되면 클라이언트가 요청을 동시에 보내게 되기 때문에 서버의 부담이 급증하게 됩니다.</li>
</ul>
<h3 id="websocket">Websocket</h3>
<p>웹소켓은 채널을 이용해 양방향 통신을 가능하게 합니다. 기본적으로 HTTP는 단방향 통신이기 때문에 서버가 먼저 클라이언트에게 응답할 수 없습니다. 하지만 양방향 통신을 가능하게 하는게 바로 웹소켓이라는 기술입니다.</p>
<p>이때문에 웹소켓은 HTTP 프로토콜을 사용하지 않고 WS 프로토콜을 사용합니다. 웹소켓의 동작과정은 TCP에 의존하며 동작하게 되는데 과정을 간단하게 적어보겠습니다.</p>
<ol>
<li>Opening Handshake</li>
<li>Data transfer</li>
<li>Closing Handshake</li>
</ol>
<h4 id="opening-handshake">Opening handshake</h4>
<p>최초 연결 요청시 HTTP를 통해 웹 서버에게 나 너랑 웹소켓 프로토콜로 통신하고 싶어라고 요청합니다. RFC 6455문서를 보면 아래와 같은 요청을 먼저 보낸다고 합니다.</p>
<pre><code>        GET /chat HTTP/1.1
        Host: server.example.com
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
        Origin: http://example.com
        Sec-WebSocket-Protocol: chat, superchat
        Sec-WebSocket-Version: 13</code></pre><p>눈여겨 볼점은 Upgrade 헤더를 함께 전송하는 것이겠네요.</p>
<p>이를 받은 서버는 이에 대한 응답을 아래와 같이 보내게 됩니다.</p>
<pre><code>        HTTP/1.1 101 Switching Protocols
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
        Sec-WebSocket-Protocol: chat</code></pre><p>이렇게 핸드쉐이크가 성공하면 프로토콜이 Websokcet으로 전환됩니다.</p>
<h4 id="data-transfer">Data transfer</h4>
<p>앞서 설명했듯이 웹소켓은 양방향 통신을 가능하게 합니다. 따라서 서로는 메시지를 주고 받으며 통신하는데 이때 전송되는 메시지를 웹소켓 프레임이라고 합니다. (자세한 부분은 <a href="https://alnova2.tistory.com/915">이 블로그</a>에 나와 있습니다.)</p>
<p>또한 연결 수립 이후에 서버와 클라이언트는 언제든 상대방에게 ping 패킷을 보낼 수 있습니다. ping을 받은 수신측은 pong을 빠르게 응답해야 합니다. 이런 과정으로 상대방과의 연결이 지속되고 있는지 확인하는 과정을 Heartbeat라고 합니다.</p>
<h4 id="close-handshake">Close handshake</h4>
<p>클라이언트 혹은 서버측 어느 누구나 연결을 종료할 수 있습니다. 연결 종료를 원하는 쪽이 상대방에게 close frame을 전송합니다.</p>
<h2 id="그래서-우리는">그래서 우리는?</h2>
<p>결론부터 말하자면 저희는 롱폴링 방식을 이용해 채팅을 구현하기로 했습니다. 이유는 다음과 같습니다.</p>
<ul>
<li>1:1 채팅만을 지원<ul>
<li>여러 사용자가 동시에 이벤트가 발생하는 일이 많지 않을 것이라 생각했습니다.</li>
</ul>
</li>
<li>채팅이 메인 서비스가 아니다.<ul>
<li>저희는 중고 거래 플랫폼이기 때문에 이 애플리케이션을 이용하는 사용자가 채팅 서비스를 이용하는 시간을 많지 않을 것이라 생각했습니다.</li>
<li>그렇기 때문에 채팅 데이터 변경이 잦게 일어나지 않을 것이라 생각했습니다.</li>
</ul>
</li>
<li>구현이 쉽다.<ul>
<li>프로젝트 기간이 3주 정도 남은 시점에서 웹 소켓을 적용하는 것은 부담이 될 수 있다고 생각했습니다.</li>
</ul>
</li>
</ul>
<p>이러한 이유들로 저희는 롱폴링 방식을 결정하게 되었습니다!</p>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://ko.javascript.info/long-polling">https://ko.javascript.info/long-polling</a>
<a href="https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API">https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API</a>
<a href="https://datatracker.ietf.org/doc/html/rfc6455#section-1.2">https://datatracker.ietf.org/doc/html/rfc6455#section-1.2</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[레디스와 스케줄러를 통한 조회수 증가  (+ 동시성)]]></title>
            <link>https://velog.io/@bruni_23yong/%EB%A0%88%EB%94%94%EC%8A%A4%EC%99%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%A6%9D%EA%B0%80-%EB%8F%99%EC%8B%9C%EC%84%B1</link>
            <guid>https://velog.io/@bruni_23yong/%EB%A0%88%EB%94%94%EC%8A%A4%EC%99%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%A6%9D%EA%B0%80-%EB%8F%99%EC%8B%9C%EC%84%B1</guid>
            <pubDate>Sat, 30 Sep 2023 05:16:13 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>현재 프로젝트에서 상품의 조회수를 증가 시키기 위해 다음과 같은 로직을 사용하고 있었습니다.</p>
<pre><code class="language-java">@Transactional
public ItemDetailResponse read(Long memberId, Long itemId) {
        Item item = findItem(itemId);

        List&lt;ItemImage&gt; images = itemImageRepository.findByItemId(itemId);

        if (!item.isSeller(memberId)) {
            item.incrementViewCount();
            return ItemDetailResponse.toBuyerResponse(item, images);
        }
        return ItemDetailResponse.toSellerResponse(item, images);
    }</code></pre>
<p>하지만 위 로직은 상품 조회마다 DB에 쓰기 작업을 수행하고 있었습니다. 이는 <code>read</code>라는 상품 조회로직에 어색할 뿐만 아니라 조회마다 추가적인 I/O 연산이 발생해 성능이 좋지 않았습니다.</p>
<p>이번 포스팅에서는 이를 해결하는 과정을 작성하고 추가적으로 동시성 문제를 고려한 과정을 공유하고자 합니다.</p>
<h2 id="레디스를-통한-조회수-증가">레디스를 통한 조회수 증가</h2>
<p>조회수 증가 연산은 redis를 통해 수행하고 읽기 로직은 그대로 수행하기로 했습니다. 
먼저 조회수 증가 연산을 수행하는 로직을 작성합니다.</p>
<pre><code class="language-java">public void increaseViewCount(Long itemId) {
    String viewCountKey = RedisUtil.createItemViewCountCacheKey(itemId);

    if (redisService.hasKey(viewCountKey)) {
        redisService.increase(viewCountKey);
        return;
    }

    redisService.set(viewCountKey, INITIAL_VIEW_COUNT, Duration.ofSeconds(100).toMillis());
}</code></pre>
<p><code>createItemViewCountCacheKey</code>메서드는 주어진 상품 아이디를 통해 <code>itemViewCount::1</code>와 같이 키를 만드는 메서드 입니다. 해당 키가 redis에 존재하면 키의 value값을 redis의 <code>incr</code> 명령어를 통해 증가시키고 그렇지 않다면 1로 초기화 시킵니다.</p>
<p>이후 redis에 반영된 조회수 증가를 DB에 반영해야 합니다. redis의 증가된 값을 바로 DB에 반영하는 것은 레디스를 사용하는 의미가 없기 때문에 레디스에 증가분을 모았다가 스케줄러를 통해 DB에 반영하도록 합니다.</p>
<h2 id="스케줄러를-통해-한-번에-db에-반영하기">스케줄러를 통해 한 번에 DB에 반영하기</h2>
<pre><code class="language-java">@Async(&quot;viewCountExecutor&quot;)
@Scheduled(fixedDelay = 5000L)
@Transactional
public void applyViewCountToRDB() {
    List&lt;String&gt; itemViewCountKeys = redisService.getKeysOrderByExpiration(
            RedisUtil.getProductViewCountCacheKeyPattern());

    if (itemViewCountKeys.isEmpty()) {
        return;
    }
    itemViewCountKeys.forEach(key -&gt; {
        int viewCount = redisService.getAndDelete(key);
        itemRepository.findById(extractItemId(key))
                .ifPresent(item -&gt; item.addViewCount(viewCount));
    });
}</code></pre>
<p><code>redisService</code>의 <code>getKeysOrderByExpiration</code> 메서드는 키의 만료기한를 기준으로 만료기한이 얼마 남지 않은 키들을 가져오는 메서드 입니다.</p>
<p>이후 각 키들에 대해 조회수 값을 가져와 DB에 반영하도록 합니다.</p>
<h2 id="동시성-고려하기">동시성 고려하기</h2>
<p>여기까지 간단하게 스케줄러와 레디스를 통해 조회수를 일정 시간마다 반영해보았습니다. 그런데 여기서 고려하지 않은 점이 있습니다. 바로 여러 명의 사용자가 같은 상품에 동시에 접근했을 경우 입니다. 상품 조회의 경우 여러 사람이 동시에 접근하는 것이 가능하다고 생각했기 때문에 이를 고려하여 로직을 작성하려 합니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/aeb0f17a-0ab9-4c41-a1d2-239528cff613/image.png" alt=""></p>
<p>위 그림과 같이 두 명의 사용자가 동시에 <code>increaseViewCount</code> 메서드에 접근한다고 가정해보겠습니다. <code>t1</code> 스레드가 아직 레디스에 값을 저장하지 않았다고 했을 때 <code>t2</code> 스레드가 <code>redisService.hasKey</code> 메서드에 접근한다면 <code>false</code>를 얻을 것이고 조회수가 누락되게 됩니다.</p>
<p>이를 고려해 키가 존재하는지 확인하기 전 lock을 통해 문제를 해결하고자 합니다.</p>
<p>우리는 레디스를 사용하고 있기 때문에 redis의 동시성 문제를 해결할 수 있는 방법 두 가지를 고려해보고자 합니다.</p>
<ul>
<li>Lettuce</li>
<li>Redisson</li>
</ul>
<h3 id="lettuce-사용하기">Lettuce 사용하기</h3>
<p>lettuce를 통해 문제를 해결할 수 있습니다. lettuce의 <a href="https://lettuce.io/lettuce-4/release/api/com/lambdaworks/redis/api/sync/RedisStringCommands.html#setnx-K-V-">setNx</a> 명령어는 <code>set if not exist</code>의 줄임말로 키와 밸류를 설정할 때 기존의 값이 없을 때만 적용되는 명령어 입니다. </p>
<p>이 방식은 spin lock 방식으로 lock 획득에 실패했을 때의 retry 로직을 개발자가 직접 작성해주어야 합니다.</p>
<blockquote>
<p>spin lock? 🧐
스핀 락은 락을 획득하려는 스레드가 락을 획득할 수 있는지 계속해서 반복적으로 확인하면서 락 획득을 시도하는 방식입니다.</p>
</blockquote>
<p>먼저 RedisLockService를 생성하고 lock 획득과 해제 로직을 작성해줍니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class RedisLockService {

    private final RedisTemplate&lt;String, Object&gt; redisTemplate;

    public Boolean lock(String key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(key, &quot;lock&quot;, Duration.ofMillis(3000L));
    }

    public Boolean unlock(String key) {
        return redisTemplate.delete(key);
    }
}</code></pre>
<p>이후 락을 획득한 스레드에 대해서만 값을 변경 혹은 설정할 수 있도록 해줍니다.</p>
<pre><code class="language-java">public void increaseViewCount(Long itemId) {
    String viewCountKey = RedisUtil.createItemViewCountCacheKey(itemId);

    // locking - spinlock
    while (!redisLockRepository.lock(&quot;lock::&quot; + viewCountKey)) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException ignored) {
        }
    }

    try {
        if (redisService.hasKey(viewCountKey)) {
            redisService.increase(viewCountKey);
            return;
        }

        redisService.set(viewCountKey, INITIAL_VIEW_COUNT, Duration.ofSeconds(100).toMillis());
    } finally {
        redisLockRepository.unlock(&quot;lock::&quot; + viewCountKey);
   }
}</code></pre>
<h3 id="redisson-사용하기">Redisson 사용하기</h3>
<p>Redisson은 pub/sub 기반의 락 구현이 되어 있습니다. pub/sub 기반의 락 방식은 채널을 하나 생성하고 락을 점유 중인 스레드가 락을 획득하려고 대기중인 다른 스레드에게 락 해제를 알려주면 이를 알아챈 스레드가 락 획득 시도를 하는 방식입니다.</p>
<p>이 방식은 lettuce와는 다르게 별도의 retry 로직을 작성하지 않아도 됩니다.</p>
<p>코드로 적용해보겠습니다. 먼저 redisson을 사용하기 위해서는 별도의 dependency를 추가해줘야 합니다.</p>
<pre><code class="language-gradle">implementation &#39;org.redisson:redisson-spring-boot-starter:3.23.5&#39;</code></pre>
<p>이후 <code>RedissonClient</code>를 DI받고 조회수를 증가시키는 로직을 작성합니다.</p>
<pre><code class="language-java">    public void increaseViewCount(Long itemId) {
        String viewCountKey = RedisUtil.createItemViewCountCacheKey(itemId);

        RLock lock = redissonClient.getLock(&quot;lock::&quot; + viewCountKey);

        try {
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!available) {
                return;
            }

            if (redisService.hasKey(viewCountKey)) {
                redisService.increase(viewCountKey);
                return;
            }

            redisService.set(viewCountKey, INITIAL_VIEW_COUNT, Duration.ofSeconds(100).toMillis());
        } catch (InterruptedException ignored) {
        } finally {
            lock.unlock();
        }
    }</code></pre>
<ul>
<li>redissonClient.getLock 메서드를 통해 락을 생성합니다.</li>
<li>이후 tryLock 메서드를 통해 락 획득을 몇 초 동안 시도할 것인지, 몇 초 동안 점유할 것인지 설정한 후 락을 획득합니다.</li>
<li>락 획득에 성공했다면 조회수를 증가시키고 그렇지 않으면 return 합니다.</li>
<li>모든 로직이 수행되었다면 unlock 메서드를 통해 락을 해제해줍니다.</li>
</ul>
<p>redisson은 pub/sub 구조를 이용하기 때문에 lettuce 를 이용한 방식보다는 레디스에 부하를 덜 준다는 장점이 있습니다. 그렇지만 라이브러리를 별도로 추가해주어야 한다는 부담감과 구현이 복잡하다는 단점이 존재합니다.</p>
<blockquote>
<p>더 나아가 현재 단일 인스턴스 환경에서 스케줄러를 구성했습니다. 만약 WAS의 개수가 늘어나게 되면 동일한 스케줄러가 같은 레디스에 접근하게 될 텐데 이를 어떻게 해결할 수 있을지 고민해야겠습니다.</p>
</blockquote>
<h2 id="성능테스트">성능테스트</h2>
<p> 매 상품 조회마다 DB에 I/O 연산을 수행하는 것보다 얼마나 많은 차이가 있는지 확인하기 위해 JMeter를 통해 TPS를 측정해보았습니다.</p>
<p> <img src="https://velog.velcdn.com/images/bruni_23yong/post/927b8ac8-f7b9-4bc5-9a4a-fe3a4b8e7d30/image.png" alt=""></p>
<p> <img src="https://velog.velcdn.com/images/bruni_23yong/post/79f2ac78-7a3f-458a-8c6e-0079ed80f964/image.png" alt="">
최대 60-70 정도의 TPS가 나오고 있는 것을 확인할 수 있었습니다. 이제 레디스와 스케줄러를 통해 개선된 방법의 경우 TPS를 측정해보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/112a674d-4b17-4aa7-b23c-afd350e7742e/image.png" alt=""></p>
<p>최대 120-130 정도의 TPS가 나오고 있는 것을 확인할 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 문제 해결하기]]></title>
            <link>https://velog.io/@bruni_23yong/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 26 Sep 2023 03:29:35 GMT</pubDate>
            <description><![CDATA[<h2 id="문제점">문제점</h2>
<p>이번 프로젝트를 진행하던 중 상품의 상세 페이지를 조회하면 조회수가 1 증가하는 로직이 있었습니다.
매번 각각의 요청이 들어오게 되면 조회수가 정상적으로 증가하겠지만 동시에 100개의 요청이 들어오면 어떻게 될까요?</p>
<p>테스트코드를 통해 한 번 알아보겠습니다.</p>
<h2 id="테스트">테스트</h2>
<pre><code class="language-java">    @DisplayName(&quot;구매자가 상품의 상세화면을 조회하면 해당 상품의 조회수가 증가한다.&quot;)
    @Test
    void given_whenBuyerReadItemDetails_thenIncreaseViewCount() throws InterruptedException {
        // given
        int threadCount = 8;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch countDownLatch = new CountDownLatch(100);

        // when
        for (int i = 0; i &lt; 100; i++) {
            executorService.submit(() -&gt; {
                try {
                    itemService.read(buyer.getId(), item.getId());
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();

        // then
        Item foundItem = supportRepository.findById(Item.class, item.getId()).get();

        assertThat(foundItem.getViewCount()).isEqualTo(100);
    }</code></pre>
<p>다음 테스트를 수행했을 때 상품의 조회수가 100임을 기대했지만 결과는 그렇지 않았습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/eb5d6123-f6f8-4346-9c08-7e90f66465c7/image.png" alt=""></p>
<p>이를 해결하기 위해서는 어떻게 해야할까요? 생각한 방법으로는 크게 3가지가 있습니다.</p>
<ol>
<li>synchronized 사용하기</li>
<li>database lock 사용하기</li>
<li>redis 사용하기</li>
</ol>
<h2 id="synchronized-이용하기">synchronized 이용하기</h2>
<p>처음 생각한 것은 <code>synchronized</code> 키워드를 이용하여 동시성을 제어하는 것이었습니다.</p>
<p>간략하게 조회수를 증가시키는 로직을 <code>synchronized</code>를 사용해 작성해보았습니다.</p>
<pre><code class="language-java">    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    public synchronized void increaseViewCount(ItemViewEvent event) {
        Item item = itemRepository.findById(event.getItemId()).orElseThrow();
        item.incrementViewCount();
    }</code></pre>
<p>이후 동일한 테스트를 돌렸을 때 각각의 스레드가 이전 스레드의 작업이 완료될 때까지 대기하기 때문에 성공할 것이라 예상했지만 실패했습니다.</p>
<p>이유는 무엇일까요?
바로 스프링이 <code>@Transactional</code>을 처리하는 방식 때문인데요. 스프링은 <code>@Transactional</code> 애노테이션을 AOP 방식으로 처리하게 됩니다. 즉 <code>@Transactional</code> 애노테이션이 선언되어있는 클래스를 상속받는 proxy 클래스를 만들어 트랜잭션을 처리하게 됩니다.</p>
<img src="https://velog.velcdn.com/images/bruni_23yong/post/a4ece945-6de8-4dfc-8c8a-9c669da3d8b9/image.png" width=600 />

<p>그림으로 간략하게 살펴보면 <code>t1</code> 스레드가 조회수를 증가시키는 로직을 완료한 후 트랜잭션을 종료시키려는 시점에 <code>t2</code> 스레드가 접근해버려 문제가 발생했던 것이죠.</p>
<p>그렇다면 트랜잭션 시작 이전에 <code>synchronized</code> 키워드를 사용해 문제를 해결하면 될 것 같습니다.</p>
<pre><code class="language-java">    @TransactionalEventListener
    public synchronized void increaseViewCount(ItemViewEvent event) {
        synchronizedService.increaseViewCount(event.getItemId());
    }</code></pre>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class SynchronizedService {

    private final ItemRepository itemRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void increaseViewCount(Long itemId) {
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.incrementViewCount();
    }
}</code></pre>
<img src = "https://velog.velcdn.com/images/bruni_23yong/post/ade53aad-5b05-46a1-b3a9-8ef8cc3a5920/image.png" width=600 />

<h3 id="여전한-문제점">여전한 문제점</h3>
<p>하지만 <code>synchronized</code>는 서버가 여러 대인 경우 문제가 생길 수 있습니다. <code>synchronized</code>는 하나의 프로세서 안에서만 보장을 받을 수 있기 때문에 서버가 두 대 이상일 경우 데이터의 접근을 여러 곳에서 하게 되면 race condition 문제를 야기할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/69620f6f-2462-44b8-8660-711d0103f6ab/image.png" alt=""></p>
<h2 id="locking">Locking</h2>
<p>서버가 여러 대일 경우 <code>synchronized</code>의 사용은 어려우니 DB에서 락을 활용해 동시성 문제를 해결할 수 있습니다.</p>
<blockquote>
<p>현재 프로젝트에서는 MySQL 8.0 을 사용하고 있음을 미리 알립니다.</p>
</blockquote>
<p>Locking 기법에는 여러 가지가 존재하는데요. 그 중 다음 두 가지를 소개하겠습니다.</p>
<ol>
<li>Pessimistic lock (비관적 락)</li>
<li>Optimistic lock (낙관적 락)</li>
</ol>
<h3 id="pessmisitc-lock">Pessmisitc lock</h3>
<p>비관적 락은 DB의 실제 데이터에 락을 걸어 데이터의 정합성을 맞추는 방법입니다. 비관적 락은 트랜잭션이 시작할 때 X-lock 혹은 S-lock을 걸게 됩니다.</p>
<img src="https://velog.velcdn.com/images/bruni_23yong/post/57835b07-d181-45f1-bccd-04d4b1af9cd6/image.png" width=600/>

<p>그럼 코드를 통해 비관적 락을 적용하는 과정을 살펴보겠습니다.</p>
<pre><code class="language-java">public interface ItemRepository extends JpaRepository&lt;Item, Long&gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;SELECT item FROM Item item WHERE item.id = :itemId&quot;)
    Optional&lt;Item&gt; findByIdWithPessmisticLock(@Param(&quot;itemId&quot;) Long itemId);
}</code></pre>
<pre><code class="language-java">    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    public void increaseViewCount(ItemViewEvent event) {
        Item item = itemRepository.findByIdWithPessmisticLock(event.getItemId())
                .orElseThrow();
        item.incrementViewCount();
    }</code></pre>
<p>JPA에서는 쉽게 락을 설정할 수 있는 <code>@Lock</code> 애노테이션을 제공해줍니다. 해당 애노테이션의 <code>PESSIMISTIC_WRITE</code>는 X-lock을 건다는 의미와 동일합니다. 조회수를 증가시켜야하기 때문에 이를 사용했습니다.</p>
<h4 id="역시나-여전한-문제점">역시나 여전한 문제점</h4>
<p>하지만 비관적 락을 사용하는 것이 항상 좋지만은 않습니다. 왜냐하면 하나의 트랜잭션의 작업이 완료될 때까지 lock을 걸고 있기 때문에 다른 트랜잭션은 대기해야되기 때문입니다.
또한 단일 DB가 아닌 환경에서는 문제가 발생할 수 있습니다.</p>
<h3 id="optimisitic-lock">Optimisitic lock</h3>
<p>낙관적 락은 실제로 락을 이용하지는 않고 버전 정보를 이용해 데이터의 정합성을 맞추는 방법입니다. </p>
<img src="https://velog.velcdn.com/images/bruni_23yong/post/5c429ba2-4fb7-4365-ab7e-2631d4508f96/image.png" width=600 />

<p>위 그림처럼 t1이 먼저 DB의 데이터를 변경하고 버전 정보를 수정한 후 t2가 <code>version=1</code>을 가지고 데이터를 수정한 후 DB에 반영하려 했을 때 내가 가지고 있는 버전 정보와 맞지 않아 업데이트에 실패하게 됩니다. 이것처럼 내가 읽은 버전 정보에서 수정사항이 생겼을 경우 애플리케이션에서 다시 데이터를 읽은 후 업데이트를 시도해야 합니다.</p>
<p>코드로 살펴보겠습니다. 먼저 상품의 조회수를 이벤트를 받는 리스너를 등록합니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class ItemEventListener {

    private final OptimisticLockFacade optimisticLockFacade;

    @TransactionalEventListener
    public void increaseViewCount(ItemViewEvent event) throws InterruptedException {
        optimisticLockFacade.increaseViewCount(event.getItemId());
    }
}</code></pre>
<p>낙관적 락의 경우 버전 정보가 일치하지 않을 때 재시도해야하기 때문에 다음과 같이 <code>while</code>문을 통해 데이터를 수정하는 로직을 작성해 보았습니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class OptimisticLockFacade {

    private final OptimisticLockViewCountService optimisticLockViewCountService;

    public void increaseViewCount(Long itemId) throws InterruptedException {
        while (true) {
            try {
                optimisticLockViewCountService.increaseViewCount(new ItemViewEvent(itemId));
                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}</code></pre>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class OptimisticLockViewCountService {

    private final ItemRepository itemRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void increaseViewCount(ItemViewEvent event) {
        Item item = itemRepository.findByIdWithOptimisiticLock(event.getItemId())
                .orElseThrow();
        item.incrementViewCount();
    }
}</code></pre>
<p>이처럼 낙관적 락의 경우 비관적 락과 달리 데이터에 락을 걸지 않고 버전 정보를 이용하기 때문에 상황에 따라 비관적 락보다 성능이 더 좋을 수 있습니다. 하지만 위처럼 업데이트가 실패했을 경우 개발자가 직접 재시도 로직을 작성해줘야 한다는 단점이 있습니다.</p>
<h2 id="결론">결론</h2>
<p>여기까지 synchronized, 비관적 락, 낙관적 락에 대해 알아보았습니다. 
현재 우리의 프로젝트는 다중 서버, 단일 DB 환경을 고려하고 있기 때문에 <code>synchronized</code> 보다는 락을 활용한 동시성 제어를 사용하게 되었습니다.
또한 조회수 증가의 경우 데이터의 변경이 빈번하게 일어나고 race condition이 종종 발생할 것이라 판단해 현재 가장 간단하게 적용할 수 있는 비관적 락을 선택하게 되었습니다.</p>
<p>만약 분산 DB 환경이 되거나 다른 문제가 생긴다면 그때 다른 방법을 고려해보도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[테스트] Jacoco를 통한 코드 커버리지 측정]]></title>
            <link>https://velog.io/@bruni_23yong/%ED%85%8C%EC%8A%A4%ED%8A%B8-Jacoco%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%BD%94%EB%93%9C-%EC%BB%A4%EB%B2%84%EB%A6%AC%EC%A7%80-%EC%B8%A1%EC%A0%95</link>
            <guid>https://velog.io/@bruni_23yong/%ED%85%8C%EC%8A%A4%ED%8A%B8-Jacoco%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%BD%94%EB%93%9C-%EC%BB%A4%EB%B2%84%EB%A6%AC%EC%A7%80-%EC%B8%A1%EC%A0%95</guid>
            <pubDate>Thu, 21 Sep 2023 13:19:59 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>현재 코드스쿼드 피드백 중에 종종 들은 말이 있습니다.</p>
<blockquote>
<p>&quot;코드 커버리지는 어느정도 되세요?&quot;</p>
</blockquote>
<p>이 질문이 들어올 때마다 저는 &quot;아직 측정해보지 않았습니다.&quot;라는 답변을 할 수 밖에 없었는데요, 이번 글에서는 왜 코드 커버리지 측정이 필요하고 어떻게 측정을 수행했는지 작성해보려 합니다.</p>
<h2 id="코드-커버리지가-뭔데">코드 커버리지가 뭔데!</h2>
<p>코드 커버리지는 테스트코드가 실제 코드를 얼마나 실행했는지를 백분율로 나타내는 지표입니다. 코드 커버리지를 통해 작성된 테스트코드의 수가 충분한지 확인할 수 있습니다.</p>
<h3 id="측정-기준">측정 기준</h3>
<p>그렇다면 코드 커버리지는 어떤 기준으로 측정되는 걸까요? 측정 기준에는 다음과 같은 것들이 있습니다.</p>
<ul>
<li>함수 커버리지</li>
<li>구문 커버리지</li>
<li>브랜치 커버리지</li>
<li>조건 커버리지</li>
</ul>
<h4 id="함수-커버리지">함수 커버리지</h4>
<p>함수 커버리지는 프로그램 내의 각 함수가 한 번이라도 호출되었는지를 검사합니다.</p>
<pre><code class="language-java">public int foo(int x, int y) {
    int z = 0;
    if ((x &gt; 0) &amp;&amp; (y &gt; 0)) {
        z = x;
    }
    return z;
}

public int bar(int x, int y) {
    // ...
}</code></pre>
<p>위와 같은 <code>foo</code>, <code>bar</code> 함수가 있을 때 <code>foo</code>함수가 호출됐으면 함수 커버리지는 50%를 달성하게 됩니다. </p>
<ul>
<li>함수커버리지 = (실행된 함수의 개수) / (전체 함수의 개수) * 100</li>
</ul>
<h4 id="구문-커버리지">구문 커버리지</h4>
<p>구문 커버리지는 프로그램 내의 각 구문(statement)이 한 번이라도 실행됐는지를 검사합니다.
함수 커버리지의 예제를 다시 가져와 보겠습니다.</p>
<pre><code class="language-java">public int foo(int x, int y) {
    int z = 0;  // statement 1
    if ((x &gt; 0) &amp;&amp; (y &gt; 0)) {  // statement 2
        z = x;  // statement 3
    }
    return z;   // statement 4
}</code></pre>
<p>위와 같이 <code>foo</code> 함수가 있을 때 <code>foo(1, 1)</code>을 호출하면 구문 커버리지는 100%를 달성하게 됩니다. 왜냐하면 <code>z=x</code>까지 모든 구문이 실행됐기 때문입니다. 그런데 만약 <code>foo(1, -1)</code>로 호출하게 되면 <code>z=x</code>구문을 실행하지 않게 되고 커버리지는 75%를 달성하게 됩니다.</p>
<ul>
<li>구문 커버리지 = (실행된 구문의 개수) / (전체 구문의 개수) * 100</li>
</ul>
<h4 id="브랜치-커버리지">브랜치 커버리지</h4>
<p>브랜치 커버리지는 결정 커버리지라고 부르기도 합니다. 브랜치 커버리지는 모든 조건식이 <code>true/false</code>를 가지게 되면 충족합니다.</p>
<pre><code class="language-java">public int foo(int x, int y) {
    int z = 0;
    if ((x &gt; 0) &amp;&amp; (y &gt; 0)) {
        z = x;
    }
    return z;
}</code></pre>
<p>위와 같은 코드가 있을 때 나올 수 있는 테스트 케이스를 생각해봅니다. if 문의 조건에 대해 true/false를 모두 가질 수 있는 테스트 케이스는 다음과 같습니다.</p>
<ul>
<li>foo(1, 1)<ul>
<li><code>x &gt; 0 &amp;&amp; y &gt; 0</code>을 모두 만족하기 때문에 true가 나오게 됩니다.</li>
</ul>
</li>
<li>foo(-1, 1)<ul>
<li><code>x &gt; 0 &amp;&amp; y &gt; 0</code>에서 <code>x &gt; 0</code>을 만족하지 못하기 때문에 false가 나오게 됩니다.</li>
</ul>
</li>
</ul>
<p>모든 조건식에 대해 브랜치 커버리지가 100%가 됩니다.</p>
<h4 id="조건-커버리지">조건 커버리지</h4>
<p>조건 커버리지는 결정 커버리지와는 다르게 전체 조건식이 아닌 개별 조건식이 모두 true/false를 한 번씩 갖도록 하면 만족하는 커버리지 입니다.</p>
<pre><code class="language-java">public int foo(int x, int y) {
    int z = 0;
    if ((x &gt; 0) &amp;&amp; (y &gt; 0)) {
        z = x;
    }
    return z;
}</code></pre>
<p>그러면 조건 커버리지의 경우 생각할 수 있는 테스트 케이스는 다음과 같습니다.</p>
<ul>
<li>foo(1, 1)<ul>
<li><code>x &gt; 0</code>, <code>y &gt; 0</code> 모두 true가 나오게 됩니다.</li>
</ul>
</li>
<li>foo(-1, 1)<ul>
<li><code>x &gt; 0</code>은 false가 나오게 됩니다.</li>
<li>이때 boolean 연산자의 lazy-evaluation 때문에 <code>y &gt; 0</code>은 검사하지 않습니다.</li>
</ul>
</li>
<li>foo(1, -1)<ul>
<li><code>x &gt; 0</code>은 true, <code>y &gt; 0</code>은 false가 나오게 됩니다.</li>
</ul>
</li>
</ul>
<p>보통 구문 커버리지가 많이 사용되고 있습니다. 왜냐하면 조건 커버리지나 분기 커버리지는 코드 실행보다는 로직의 시나리오 테스트에 가깝기 때문입니다.</p>
<p>그렇다고 코드 커버리지가 높다고 반드시 좋은 코드는 아닙니다.</p>
<pre><code class="language-java">public int add(int x, int y) {
    return a + b;
}</code></pre>
<p>위와 같은 더하는 함수가 있다고 했을 때 <code>a + b</code>가 <code>a * b</code>로 바뀌어도 100%를 달성할 수도 있기 때문입니다. 또한 다음과 같은 경우도 발생할 수 있습니다.</p>
<pre><code class="language-java">public int getX() {
    return x;
}</code></pre>
<p>위의 <code>getX</code> 함수를 호출해도 100%를 달성하겠지만 이를 위한 테스트코드를 짜야 할까요? 저는 테스트하고자하는 것이 무엇인지를 생각하고 테스트코드를 짜야한다고 생각하는 편인데, getX는 아무런 로직이 없이 단순히 x를 반환하기 때문에 테스트할 필요가 없다고 생각합니다.</p>
<p>이처럼 코드 커버리지에 얽매이지 않고 좋은 테스트코드를 작성하는 것이 좋다고 생각합니다.</p>
<hr>
<p>여기까지 코드 커버리지가 무엇인지 알아봤는데요. 저희 프로젝트에서는 이 코드 커버리지 측정이 왜 필요했을까요?</p>
<h2 id="왜-필요한가">왜 필요한가?</h2>
<p>프로젝트를 진행하며 테스트 커버리지를 측정하고 싶은 순간들이 있었습니다.</p>
<ul>
<li>어떤 것들이 테스트가 안된거지?<ul>
<li>어떤 기능을 구현할 때 바로 테스트코드까지 작성하면 좋겠지만, 마감기한이 얼마 남지 않았을 때는 기능만 빠르게 구현해보고 PR을 날리는 경우가 있었습니다. 이후 다시 기능 개발을 시작하기 전 어떤 테스트를 작성하지 않았는지 기억이 나지 않았습니다.</li>
</ul>
</li>
<li>현재 작성된 테스트코드가 프로덕션 코드의 어느정도를 커버하고 있지?<ul>
<li>테스트코드를 작성했음에도 실제 프로덕션 코드의 어느 부분까지 테스트하는지에 대한 확신은 없었습니다.</li>
</ul>
</li>
</ul>
<h2 id="jacoco를-통한-코드-커버리지-측정">Jacoco를 통한 코드 커버리지 측정</h2>
<p>이를 해결하기 위해 정적 분석 도구인 jacoco를 사용해보려고 합니다.</p>
<h3 id="jacoco">Jacoco?</h3>
<p>Jacoco는 자바진영에서 테스트코드 커버리지를 분석해주는 무료 라이브러리입니다. 
이번에 우리 팀은 Jacoco를 도입해 코드 커버리지를 분석해보려합니다.</p>
<h3 id="jacoco-적용하기">Jacoco 적용하기</h3>
<p>우선 Jacoco를 적용하기 위해서는 <code>build.gradle</code>에 다음과 같은 작업이 필요합니다.
<a href="https://docs.gradle.org/current/userguide/jacoco_plugin.html">gradle 문서</a>에도 친절하게 어떻게 설정하는지에 대해 나와있습니다.</p>
<p>먼저 완성된 gradle 파일을 보여드리고 나머지에 대해 설명하겠습니다.</p>
<pre><code class="language-gradle">
plugins {
    // ...
    id &#39;jacoco&#39;
}

jacoco {
    toolVersion = &quot;0.8.9&quot;
}

/** Jacoco start **/
test {
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test

    reports {
        xml.required.set(true)
        html.required.set(true)

        // QueryDSL Q클래스 제외
        def Qdomains = []
        for (qPattern in &quot;**/QA&quot;..&quot;**/QZ&quot;) {
            Qdomains.add(qPattern + &quot;*&quot;)
        }

        afterEvaluate {
            classDirectories.setFrom(files(classDirectories.files.collect {
                fileTree(dir: it,
                        exclude: [] + Qdomains)
            }))
        }

        xml.destination file(&quot;${buildDir}/jacoco/index.xml&quot;)
        html.destination file(&quot;${buildDir}/jacoco/index.html&quot;)
    }
}
/** Jacoco end **/</code></pre>
<p>먼저 plugin에 jacoco를 추가해주고 버전을 명시해줍니다.</p>
<pre><code class="language-gradle">plugins {
    // ...
    id &#39;jacoco&#39;
}

jacoco {
    toolVersion = &quot;0.8.9&quot;
}</code></pre>
<p>여기까지 수행했다면 아래와 같이 <code>test.exec</code> 파일이 생성된 것을 확인할 수 있습니다. 이 파일은 jacoco가 테스트코드를 실행하고 코드 커버리지를 분석해 만들어준 보고서 파일입니다.</p>
<p><img src="https://github.com/masters2023-project-03-second-hand/second-hand-max-be-b/assets/66981851/5d2181fa-9e63-44c5-91f7-7f50b4b548c8" alt="image"></p>
<p>하지만 이 파일은 바이너리 파일이기 때문에 우리가 읽을 수 없습니다.</p>
<h3 id="xml-html-파일로-커버리지-보고서-생성">XML, HTML 파일로 커버리지 보고서 생성</h3>
<pre><code class="language-gradle">test {
    finalizedBy jacocoTestReport
}</code></pre>
<p>jacoco report가 항상 테스트 수행이후에 만들어지도록 설정합니다.</p>
<pre><code class="language-gradle">jacocoTestReport {
    dependsOn test // 테스트 이후에 수행하도록

    reports {
        xml.required.set(true)  // github actions 에서 사용하기 위해 리포트를 xml 파일 생성
        html.required.set(true) // 우리가 읽을 수 있는 html 파일 리포트 생성

        // QueryDSL Q클래스 제외 (커버리지를 측정할 필요가 없는 클래스 제외)
        def Qdomains = []
        for (qPattern in &quot;**/QA&quot;..&quot;**/QZ&quot;) {
            Qdomains.add(qPattern + &quot;*&quot;)
        }

        afterEvaluate {
            classDirectories.setFrom(files(classDirectories.files.collect {
                fileTree(dir: it,
                        exclude: [] + Qdomains)
            }))
        }

        xml.destination file(&quot;${buildDir}/jacoco/index.xml&quot;)   // `build` 디렉토리에 리포트 파일 생성
        html.destination file(&quot;${buildDir}/jacoco/index.html&quot;)
    }
}
/** Jacoco end **/</code></pre>
<p>Jacoco를 통해 우리가 읽을 수 있는 XML, CSV, HTML 파일로 리포트를 작성할 수 있습니다.
Jacoco Gradle 플러그인은 <code>jacocoTestReport</code>라는 태스크가 존재합니다. 이 태스크는 리포트를 읽을 수 있는 형태로 출력해주는 역할을 합니다.</p>
<p>이제 테스트가 실행되고 나면 아래와 같이 .html, .xml 파일을 얻을 수 있습니다.</p>
<p><img src="https://github.com/masters2023-project-03-second-hand/second-hand-max-be-b/assets/66981851/529ace70-6bb7-4d3f-98cb-57e709cfebee" alt="image"></p>
<blockquote>
<p>.csv 파일도 얻을 수 있습니다.</p>
</blockquote>
<p>이제 직접 .html 파일을 열어 확인해보면 아래와 같이 커버리지가 측정된 모습을 확인할 수 있습니다.</p>
<p><img src="https://github.com/masters2023-project-03-second-hand/second-hand-max-be-b/assets/66981851/d473bc2f-cc6f-4551-8ebd-94d56acbc8a9" alt="image"></p>
<h2 id="github-actions를-통한-커비리지-리포트-생성">Github actions를 통한 커비리지 리포트 생성</h2>
<p>이제 jacoco를 통한 코드 커버리지를 알 수 있습니다. 그런데 PR마다 커버리지가 어느정도 되는지 알고 싶어 아래와 같이 github actions를 통해 커버리지 측정 결과를 PR에 코멘트 형식으로 달도록 설정했습니다.</p>
<pre><code class="language-yaml">      # 테스트 커버리지를 PR에 코멘트로 등록합니다
      - name: Comment test coverage on PR
        id: jacoco
        uses: madrapps/jacoco-report@v1.2
        with:
          title: 📝 테스트 커버리지 리포트
          paths: ${{ github.workspace }}/build/jacoco/index.xml
          token: ${{ secrets.PRIVATE_REPO_ACCESS_TOKEN }}
          min-coverage-overall: 50
          min-coverage-changed-files: 50</code></pre>
<ul>
<li>min-coverage-overall<ul>
<li>프로젝트 전체 테스트커버리지에 대한 기준입니다.</li>
</ul>
</li>
<li>min-coverage-chaged-files<ul>
<li>변경이 일어난 파일의 테스트 커버리지에 대한 기준입니다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/4af81470-01e5-412f-b34a-fb13e6893ee7/image.png" alt=""></p>
<h2 id="결론">결론</h2>
<p>이번에 코드 커버리지를 측정하는 작업을 수행했습니다.
제가 생각하는 장점은 다음과 같습니다.</p>
<ul>
<li>배포시 현재 우리의 테스트코드가 프로덕션 코드의 몇 퍼센트를 커버하고 있는지 알 수 있어 자신감(?)이 생기는 것 같습니다.</li>
<li>리팩토링할 때 테스트코드가 이 로직은 커버를 하고 있으니까 일단 해보자라는 마인드가 생겼습니다. 로직은 테스트코드가 검증하니까!</li>
</ul>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://en.wikipedia.org/wiki/Code_coverage">https://en.wikipedia.org/wiki/Code_coverage</a>
<a href="https://tecoble.techcourse.co.kr/post/2020-10-24-code-coverage/">https://tecoble.techcourse.co.kr/post/2020-10-24-code-coverage/</a>
<a href="https://docs.gradle.org/current/userguide/jacoco_plugin.html">https://docs.gradle.org/current/userguide/jacoco_plugin.html</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] MySQL에서의 saveAll]]></title>
            <link>https://velog.io/@bruni_23yong/JPA-MySQL%EC%97%90%EC%84%9C%EC%9D%98-saveAll</link>
            <guid>https://velog.io/@bruni_23yong/JPA-MySQL%EC%97%90%EC%84%9C%EC%9D%98-saveAll</guid>
            <pubDate>Thu, 14 Sep 2023 09:30:41 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>JPA에서 MySQL 데이터베이스를 사용하고 <code>saveAll</code> 메서드를 호출하려 할 때 다음과 같은 쿼리가 날라갔습니다.</p>
<pre><code class="language-sql">Hibernate:
    insert
    into
        item_image
        (image_url, item_id)
    values
        (?, ?)
Hibernate:
    insert
    into
        item_image
        (image_url, item_id)
    values
        (?, ?)</code></pre>
<p>bulk insert 쿼리가 날라갈 것으로 예상했지만 <code>INSERT</code> 쿼리가 여러 번 날라가고 있었습니다.</p>
<p>이는 MySQL과 같은 데이터베이스에 기본키 생성을 위임하고 있었기 때문입니다.</p>
<h2 id="내부를-들여다보자">내부를 들여다보자</h2>
<p><code>saveAll</code> 메서드를 들여다 보겠습니다.</p>
<pre><code class="language-java">    @Transactional
    @Override
    public &lt;S extends T&gt; List&lt;S&gt; saveAll(Iterable&lt;S&gt; entities) {

        Assert.notNull(entities, &quot;Entities must not be null&quot;);

        List&lt;S&gt; result = new ArrayList&lt;&gt;();

        for (S entity : entities) {
            result.add(save(entity));  // 문제 지점
        }

        return result;
    }</code></pre>
<pre><code class="language-java">    @Transactional
    @Override
    public &lt;S extends T&gt; S save(S entity) {

        Assert.notNull(entity, &quot;Entity must not be null&quot;);

        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }</code></pre>
<pre><code class="language-java">    public boolean isNew(T entity) {
        ID id = getId(entity);
        Class&lt;ID&gt; idType = getIdType();

        if (!idType.isPrimitive()) {
            return id == null;
        }

        if (id instanceof Number) {
            return ((Number) id).longValue() == 0L;
        }

        throw new IllegalArgumentException(String.format(&quot;Unsupported primitive id type %s!&quot;, idType));
    }</code></pre>
<p>JPA의 영속성 컨텍스트에 엔티티가 존재하기 위해서는 식별자 값이 필요합니다.
그런데 <code>IDENTITY</code> 방식에서는 PK값 생성을 DB에 위임하기 때문에 <code>persist</code> 호출 시 엔티티를 영속성 컨텍스트에 등록하기 위해 <code>INSERT</code> 쿼리가 실행됩니다.</p>
<p>그렇다면 기본 키 생성전략이 <code>IDENTITY</code>인 경우에는 bulk insert를 어떻게 수행할 수 있을까요?
제가 생각한 방법은 jdbcTemplate을 사용하는 방법이였습니다.</p>
<h2 id="사용자-정의-레포지토리">사용자 정의 레포지토리</h2>
<p>이를 위해서는 커스텀 레포지토리를 생성할 필요가 있었습니다. 이를 위해 JPA에서 제공하는 커스텀 레포지토리 기능을 이용했습니다.</p>
<pre><code class="language-java">public interface ItemImageRepositoryCustom {

    void saveAllItemImages(List&lt;ItemImage&gt; itemImages);
}</code></pre>
<p>위와 같이 bulk insert 연산을 수행할 메서드를 정의하고 있는 커스텀 레포지토리 인터페이스를 생성합니다.
이후 실제 구현을 담고 있는 클래스를 하나 생성합니다. 이때 주의해야할 점은 이름을 짓는 규칙이 존재한다는 것입니다.
<code>레포지토리 인터페이스 이름 + Impl</code>로 네이밍을 해야 합니다.
이렇게 하면 Spring Data JPA가 사용자 정의 레포지토리로 인식하게 됩니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
public class ItemImageRepositoryImpl implements ItemImageRepositoryCustom {

    private final NamedParameterJdbcTemplate jdbcTemplate;

    @Override
    public void saveAllItemImages(List&lt;ItemImage&gt; itemImages) {
        // IDENTITY 방식의 한계로 bulk insert query 직접 구현
        String sql = &quot;INSERT INTO item_image &quot;
                + &quot;(image_url, item_id) VALUES (:imageUrl, :itemId)&quot;;
        MapSqlParameterSource[] params = itemImages.stream()
                .map(itemImage -&gt; new MapSqlParameterSource()
                        .addValue(&quot;imageUrl&quot;, itemImage.getImageUrl())
                        .addValue(&quot;itemId&quot;, itemImage.getItem().getId()))
                .collect(Collectors.toList())
                .toArray(MapSqlParameterSource[]::new);
        jdbcTemplate.batchUpdate(sql, params);
    }
}</code></pre>
<p>마지막으로 레포지토리 인터페이스에서 사용자 정의 인터페이스를 상속받으면 됩니다.</p>
<pre><code class="language-java">public interface ItemImageRepository extends JpaRepository&lt;ItemImage, Long&gt;, ItemImageRepositoryCustom {
}</code></pre>
<h2 id="결론">결론</h2>
<p>JPA를 사용하면서 saveAll 메서드를 사용하면서 bulk insert 쿼리가 날라갈 것으로 기대했지만 INSERT 쿼리가 여러번 날라갔습니다. 
이는 JPA에서 IDENTITY 방식을 사용하면서 생기는 한계였습니다.
이를 해결하기 위해 저는 JdbcTemplate을 직접 사용하게 되었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Git submodules]]></title>
            <link>https://velog.io/@bruni_23yong/Git-submodules</link>
            <guid>https://velog.io/@bruni_23yong/Git-submodules</guid>
            <pubDate>Wed, 06 Sep 2023 09:39:51 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>프로젝트를 진행하다보면 민감한 정보들을 git에 올리지 못하는 경우가 많습니다.
이번 프로젝트에서는 db의 정보, OAuth 인증키, JWT secret key, AWS access key등을 관리해야 했습니다. 
이런 정보들을 github actions를 자동배포를 할 때, 이러한 데이터들은 github actions secret에 직접 등록하는 방법을 사용하여 세팅해줄 수 있습니다.
그런데 이 방법은 변경이 생길 때마다 직접 변경을 해줘야하는 번거로움이 생기는 경우가 많습니다.
이런 문제점을 해결하기 위해 git submodule을 사용하게 되었습니다.</p>
<h2 id="git-submodule">git submodule</h2>
<p>Git Submodule은 git 저장소 안에 다른 git 저장소를 디렉토리로 분리해 넣어줍니다. 즉, 부모 레포지토리 하위에 공통으로 사용하는 프로젝트 레포지토리를 하위에 두는 방법입니다.
이러한 특성 때문에 프로젝트에서 공통으로 관리해야하는 변수들을 공유할 수 있습니다.</p>
<h2 id="주의-사항">주의 사항</h2>
<p>서브모듈을 사용할 때 주의해야할 사항이 있습니다. 부모-자식 관계라고 생각해서 부모가 자식의 것들을 모두 관리한다고 생각할 수 있는데 전혀 그렇지 않습니다.
둘은 별개의 프로젝트로 관리가 되기 때문입니다. 그래서 부모에서 자식을 직접 변경할 수 없습니다. 또한 자식의 변경사항이 생겨도 부모에서 알아차릴 수 없습니다.</p>
<p>그렇다면 부모 프로젝트는 어떻게 자식 프로젝트를 연결하는 걸까요? 결론부터 말하자면 커밋로그를 기반으로 연결되어 있습니다. </p>
<p>예를 들어 아래와 같이 커밋로그가 있다고 해보겠습니다.</p>
<p><img src="https://github.com/masters2023-project-03-second-hand/second-hand-max-be-b/assets/66981851/a9ef5221-8c0f-4980-8b1c-4d70bc27de9f" alt="image"></p>
<p>부모 레포지토리의 A 브랜치는 <code>docs: db 관련 설정 yml파일 추가</code>를 가리키고 있고 B브랜치는 <code>docs: jwt, aws관련 yml 파일 추가</code>을 가리킬 수 있습니다. 이렇듯 부모-자식간의 레포지토리의 연결은 커밋 로그를 기반으로 연결이 됩니다.</p>
<h2 id="서브모듈의-적용">서브모듈의 적용</h2>
<p>먼저 민감한 파일들을 관리하기 위해 private 레포지토리를 생성합니다.
이번 프로젝트에서는 Organization을 사용하기 때문에 Organization안에 secret이라는 이름으로 레포지토리를 생성했습니다.</p>
<p>이후 git 명령어를 통해 서브 모듈을 추가합니다.
저희 팀은 <code>resources</code> 폴더 아래에 추가하도록 해당 경로로 이동해 명령어를 입력했습니다.</p>
<pre><code>git submodule add {private repository의 url}</code></pre><p>위의 명령어를 입력하면 아래와 같이 디렉토리가 생성됩니다.</p>
<p><img src="https://github.com/masters2023-project-03-second-hand/second-hand-max-be-b/assets/66981851/7279ec96-4cb3-40b4-ba1f-50d524718bfb" alt="image">
git은 위의 secret디렉토리를 서브모듈로 취급해서 디렉토리 내부의 파일 수정사항을 <code>추적하지는 않습니다</code>. </p>
<p>또한 <code>.gitmodules</code> 파일이 생성되는데, 이 파일은 서브디렉토리와 하위 프로젝트 URL의 매핑 정보를 담은 설정파일입니다.</p>
<pre><code>[submodule &quot;src/main/resources/secret&quot;]
    path = src/main/resources/secret
    url = https://github.com/masters2023-project-03-second-hand/secret.git</code></pre><p>서브모듈 개수만큼 이 항목이 생기게 되는데, 이 파일도 <code>.gitignore</code> 파일처럼 버전을 관리하기 때문에 ignore처리하지 않습니다. 그래서 서브모듈이 포함된 프로젝트를 clone 하는 사람은 <code>.gitmodules</code>파일을 보고 어떤 서브모듈 프로젝트가 있는지 알 수 있습니다.</p>
<h3 id="서브모듈을-클론">서브모듈을 클론</h3>
<p>처음 위 명령어를 입력하면 <code>secret</code> 폴더는 비어있습니다. 그렇기 때문에 아래 명령어를 입력해야 합니다.</p>
<pre><code>git submodule init
git submodule update</code></pre><ul>
<li>git submodule init<ul>
<li>서브모듈 정보를 기반으로 로컬 환경설정 파일이 준비됩니다.</li>
</ul>
</li>
<li>git submodule update<ul>
<li>서브모듈의 리모트 저장소에서 데이터를 가져오고 서브모듈을 포함한 프로젝트의 현재 스냅샷에서 checkout 해야 할 커밋 정보를 가져와 서브모듈 프로젝트에 대한 checkout을 수행합니다.</li>
</ul>
</li>
</ul>
<h3 id="서브모듈이-변경되어-반영해야-하는-경우">서브모듈이 변경되어 반영해야 하는 경우</h3>
<p>서브모듈 프로젝트를 최신으로 변경해야 하는 경우 서브모듈 디렉토리로 이동해 직접 git 명령을 수행할 수 있습니다.</p>
<pre><code>git fetch
git merge</code></pre><p>혹은 <code>git pull</code>명령어를 입력해 가져올 수 있습니다. 아니면 <code>git submodule update --remote</code>명령어를 통해 git이 알아서 서브모듈을 fetch하고 업데이트할 수 있습니다.</p>
<h2 id="빌드될-때-submodule에-있는-파일-복사">빌드될 때 submodule에 있는 파일 복사</h2>
<p>이제 서브모듈에 있는 파일들을 gradle로 빌드할 때 프로젝트의 resources 폴더로 복사하려고 합니다.</p>
<p>이를 위해 <code>build.gradle</code>에 아래와 같은 task 를 추가합니다.</p>
<pre><code class="language-gradle">processResources.dependsOn(&#39;copySecret&#39;)

tasks.register(&#39;copySecret&#39;, Copy) {
    description = &#39;Copy submodules to project&#39;

    from(&#39;./src/main/resources/secret&#39;) {
        include(&#39;*.yml&#39;)
    }
    into(&#39;src/main/resources&#39;)
}</code></pre>
<p>이제 gradle을 빌드하면 아래와 같은 task가 수행되며 파일이 복사가 되게 됩니다.</p>
<h3 id="github-actions에-적용">github actions에 적용</h3>
<p>우리 팀은 github actions를 통해 CI/CD를 수행하고 있는데 github actions의 job이 수행될 때 서브모듈에 있는 파일들을 가져오고 싶었습니다.
간단하게 아래와 같이 작성하면 job이 수행될 때 서브모듈을 참조하게 됩니다.</p>
<pre><code class="language-yaml">      - name: Checkout-source code
        uses: actions/checkout@v3
        with:
          token: ${{ secrets.PRIVATE_REPO_ACCESS_TOKEN }}
          submodules: true</code></pre>
<p><code>PRIVATE_REPO_ACCESS_TOKEN</code>은 private 레포지토리에 접근할 수 있는 유저의 액세스 토큰을 사용하면 됩니다.</p>
<h2 id="결론">결론</h2>
<p>이번에 우리 팀은 private한 정보 관리를 위해 서브모듈을 도입하게 되었습니다. 환경 변수를 사용하거나 직접 secret 파일을 등록해야할 때보다 편리하게 파일을 공유할 수 있었습니다.</p>
<p>하지만 서브모듈과 private 레포지토리를 관리해야한다는 단점도 있습니다. 그리고 서브모듈의 변경사항을 인지하지 못하면 안되는 이유를 찾기 힘들 것이며 잘못되면 commit기록 또한 꼬일 수 있게 됩니다. 결국 관리해야할 지점들이 늘어나기 때문에 팀원과 협의해 잘 적용하는 것이 좋다고 생각합니다.</p>
<p><a href="https://git-scm.com/book/ko/v2/Git-%EB%8F%84%EA%B5%AC-%EC%84%9C%EB%B8%8C%EB%AA%A8%EB%93%88">https://git-scm.com/book/ko/v2/Git-%EB%8F%84%EA%B5%AC-%EC%84%9C%EB%B8%8C%EB%AA%A8%EB%93%88</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[테스트에서 Spring Application Context를 한 번만 띄워보자]]></title>
            <link>https://velog.io/@bruni_23yong/%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-Spring-Application-Context%EB%A5%BC-%ED%95%9C-%EB%B2%88%EB%A7%8C-%EB%9D%84%EC%9B%8C%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@bruni_23yong/%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-Spring-Application-Context%EB%A5%BC-%ED%95%9C-%EB%B2%88%EB%A7%8C-%EB%9D%84%EC%9B%8C%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Wed, 30 Aug 2023 01:03:47 GMT</pubDate>
            <description><![CDATA[<p>현재 프로젝트에서 애플리케이션 레이어 - 레포지토리 레이어 테스트코드를 작성할 때 <code>@SpringBootTest</code>를 사용하고 있습니다. 그런데 <code>@MockBean</code>이 있는 테스트 클래스마다 SpringApplicationContext 를 매번 띄우고 있는 상황이 발생하고 있었습니다. </p>
<p>왜 이런 상황이 발생하는지 기록하기 위해 이번 포스팅을 작성합니다.</p>
<h2 id="spring의-컨텍스트-관리-및-캐싱">Spring의 컨텍스트 관리 및 캐싱</h2>
<p><a href="https://docs.spring.io/spring-framework/reference/testing/integration.html#testing-ctx-management">공식문서</a>에 따르면 Spring TestContext Framework는 Spring ApplicationContext 인스턴스 및 WebApplicationContext 인스턴스의 일관된 로딩과 해당 컨텍스트의 캐싱을 제공합니다. </p>
<p>이런 컨텍스트의 캐싱을 제공하는 이유는 최초로 스프링 컨텍스트를 띄우는 시간이 문제가 될 수 있기 때문입니다. 우리가 만들어 놓은 모든 빈들이 빈 컨테이너에 등록되고 스프링 부트가 제공해주는 모든 빈들이 등록되기 때문입니다. 이렇게 되면 모든 테스트를 실행하기 전에 컨텍스트를 띄우는 비용이 발생하게 되고 테스트 실행속도가 느려지게 됩니다.</p>
<p><strong>그런데 똑똑한 스프링은 컨텍스트를 캐싱해두고 재사용하게 됩니다.</strong></p>
<p>기본적으로 스프링 컨텍스트가 일단 로드되면 구성된 내용들은 각 테스트에 재사용되게 됩니다. 따라서 최초 설정 비용은 한 번만 발생하게 되는 것입니다.</p>
<p>그런데 왜 <code>@MockBean</code> 애노테이션이 있을 때 스프링 컨텍스트는 다시 띄워지는 것일까요?</p>
<h2 id="왜">왜?</h2>
<p>공식문서에 따르면 테스트가 애플리케이션 컨텍스트를 손상시키고 다시 로드해야 하는 경우, (예: Bean 정의 또는 애플리케이션 객체의 상태를 수정) Spring TestContext Framework는 다음 테스트를 위해 구성을 다시 로드하고 애플리케이션 컨텍스트를 다시 빌드하도록 구성합니다.</p>
<p>즉 <code>@MockBean</code>을 사용해 빈을 새롭게 정의했기 때문에 컨텍스트가 손상되었고 이를 감지한 스프링이 컨텍스트를 다시 띄우는 것이었습니다.</p>
<h2 id="한-번만-띄우도록-수정">한 번만 띄우도록 수정</h2>
<p>현재 각 테스트 클래스들은 필요한 빈들을 <code>@Autowired</code> 받고 있습니다.</p>
<pre><code class="language-java">@ApplicationTest
class AuthServiceTest {

    @Autowired
    private AuthService authService;

    @Autowired
    private SupportRepository supportRepository;

    @MockBean
    private NaverRequester naverRequester;
...</code></pre>
<pre><code class="language-java">@ApplicationTest
public class CategoryServiceTest {

    @Autowired
    private CategoryService categoryService;

    @Autowired
    private SupportRepository supportRepository;
...</code></pre>
<pre><code class="language-java">@ApplicationTest
class ItemServiceTest {

    @MockBean
    private S3Uploader s3Uploader;

    @Autowired
    private SupportRepository supportRepository;

    @Autowired
    private ItemService itemService;
...</code></pre>
<p>이외에도 각 테스트 클래스들은 제어할 수 없는 것에 대해서는 <code>@MockBean</code> 처리를 해두고 있고 필요한 빈들은 <code>@Autowired</code>를 통해 주입받고 있습니다.</p>
<p>어차피 제어할 수 없는 것들은 <code>@MockBean</code> 처리할 것이기 때문에 이를 공통 필드로 가지고 있는 클래스를 하나 생성한 후 각 테스트 클래스들은 공통 필드를 가지고 있는 클래스를 상속받도록 합니다.</p>
<pre><code class="language-java">@ApplicationTest
public class ApplicationTestSupport {

    @MockBean
    protected S3Uploader s3Uploader;

    @MockBean
    protected NaverRequester naverRequester;

    @Autowired
    protected SupportRepository supportRepository;

...</code></pre>
<pre><code class="language-java">@ApplicationTest
class ItemServiceTest extends ApplicationTestSupport {

    @Autowired
    private ItemService itemService;
...</code></pre>
<h2 id="결론">결론</h2>
<p>이렇게 스프링 컨텍스트가 띄워질 때 목처리할 빈들을 미리 만들어두게 되면서 스프링 애플리케이션 컨텍스트가 테스트 시에 한 번만 띄워지게 되었습니다!</p>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://stackoverflow.com/questions/22939226/how-can-i-initialize-a-spring-applicationcontext-just-once-for-all-tests">https://stackoverflow.com/questions/22939226/how-can-i-initialize-a-spring-applicationcontext-just-once-for-all-tests</a></p>
<p><a href="https://docs.spring.io/spring-framework/reference/testing/integration.html#testing-ctx-management">https://docs.spring.io/spring-framework/reference/testing/integration.html#testing-ctx-management</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github actions & docker를 사용한 자동 배포]]></title>
            <link>https://velog.io/@bruni_23yong/Github-actions-docker%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@bruni_23yong/Github-actions-docker%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Sat, 26 Aug 2023 13:02:11 GMT</pubDate>
            <description><![CDATA[<p>이번에 github actions와 docker를 활용해 자동 배포 환경을 구축해보았는데 그 과정을 기록으로 남기려 합니다.</p>
<h2 id="도입">도입</h2>
<p>이전 프로젝트에서는 github actions를 통해 jar 파일을 빌드해 S3에 업로드하고 AWS Code Deploy를 통해 EC2에 배포하는 작업을 수행했습니다.</p>
<p>그런데 일반적인 웹 서비스는 WAS를 다중화하고 Load Balancing을 통해 HA를 확보할 것입니다. 이렇게 다중화한 매 EC2 서버의 환경을 맞추고, Code Deploy를 설정해주고 배포하는 것은 여간 귀찮은 작업이 아닐 수 없습니다.</p>
<p>또한 앞으로의 확장된 서버의 OS 환경이 항상 동일할 것이라는 보장이 없습니다.</p>
<p>이를 위해 컨테이너 기술인 Docker를 사용해보려 합니다. Docker를 사용하면 <code>애플리케이션이 구동될 수 있는 환경</code>을 미리 구축해두고 호스트 머신의 환경과 무관하게 애플리케이션을 항상 동일한 환경에서 구동할 수 있게 됩니다. 또한 <code>애플리케이션이 구동될 수 있는 환경</code>을 이미지로 빌드해 도커 registry 에 등록한 후 서버를 늘릴 때 해당 이미지를 pull 받아 손쉽게 scale-out을 할 수 있는 장점이 있습니다.</p>
<h2 id="dockerfile을-통한-이미지-생성">Dockerfile을 통한 이미지 생성</h2>
<p>Dockerfile을 통해 <code>docker image</code>를 만들 수 있습니다. 어떤 <code>docker base image</code>에 파일을 작성하고 컨테이너를 실행시키는 과정으로 이미지를 빌드할 수 있습니다. Dockerfile은 결국 이 과정을 담는 스크립트의 파일명이 됩니다.</p>
<h3 id="문법">문법</h3>
<ul>
<li>FROM : 베이스 이미지 지정</li>
<li>RUN : 커맨드를 실행하기 위해 사용</li>
<li>ENV : 환경 변수 설정</li>
<li>ADD : 파일/디렉토리 추가</li>
<li>COPY : 파일 복사</li>
<li>WORKDIR : 작업 디렉토리</li>
<li>CMD : 컨테이너 실행 명령</li>
<li>ENTRYPOINT : 컨테이너 실행 명령</li>
</ul>
<p>먼저 작성한 Dockerfile은 다음과 같습니다.</p>
<pre><code># server base image - java 11
FROM adoptopenjdk/openjdk11

# copy .jar file to docker
COPY ./build/libs/novelpark-0.0.1-SNAPSHOT.jar app.jar

# always do command
ENTRYPOINT [&quot;java&quot;, &quot;-Dspring.profiles.active=prod&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]
</code></pre><p>주요한 문법 몇 가지를 알아보겠습니다.</p>
<h3 id="from">FROM</h3>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/2220885c-740d-4d5a-9e3c-a2ded2957c60/image.png" alt=""></p>
<p>FROM 절을 수행할 때 마치 여러 조각을 쌓는 것처럼 보이는데 이를 레이어(Layer)라고 합니다.</p>
<p>FROM을 이해하기 위해서는 먼저 컨테이너의 레이어구조에 대해서 아는 것이 좋습니다. 우리가 여러 컨테이너를 실행시키고 싶을 때는 어떻게 해야할까요?</p>
<p>docker 컨테이너가 실행되면 모든 읽기 전용 레이어들을 순서대로 쌓은 후 쓰기 가능한 신규 레이어를 쌓게 됩니다. 그 다음 컨테이너 안에서 발생한 결과물들이 쓰기 가능 레이어를 기록되게 합니다.</p>
<p>즉, 아무리 많은 도커 컨테이너를 실행해도 기존 읽기 전용 레이어는 변하지 않고 컨테이너마다 생성된 쓰기 가능 레이어에 데이터가 쌓여 서로 겹치지 않게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/2c240384-d150-41f8-8b25-4eae4df21927/image.png" alt=""></p>
<p>FROM 명령어는 이 Base image를 지정하기 위한 명령어 입니다.</p>
<h3 id="run">RUN</h3>
<p>컨테이너에서 커맨드를 실행하기 위해 사용됩니다.</p>
<pre><code>RUN echo &quot;HELLO WORLD!&quot;</code></pre><h3 id="env">ENV</h3>
<p>Dockerfile에서 고정된 값을 사용하고 싶은 경우가 있을 수 있는데, 이때 사용할 수 있습니다.</p>
<pre><code>ENV KEY VALUE
ENV KEY=VALUE</code></pre><h3 id="add">ADD</h3>
<p>파일 혹은 디렉토리를 추가할 수 있는 명령어입니다. </p>
<h3 id="copy">COPY</h3>
<p>호스트 컴퓨터의 파일을 도커 컨테이너 내부의 파일 시스템으로 복사하기 위해 사용되는 명령어 입니다.</p>
<h3 id="workdir">WORKDIR</h3>
<p>작업 디렉토리의 전환을 위해 사용되는 명령어 입니다. 이 명령어를 사용하면 이후 사용되는 모든 명령어는 지정한 디렉토리에서 수행되게 됩니다.</p>
<h3 id="entrypoint">ENTRYPOINT</h3>
<p>이미지가 실행되었을 때 항상 실행되어야 하는 커맨드를 정의할 때 사용됩니다. 컨테이너를 실행파일처럼 사용할 수 있을 때 유용합니다.</p>
<pre><code># shell
ENTRYPOINT java -jar app.jar

# exec
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre><h3 id="cmd">CMD</h3>
<p>ENTRYPOINT와 유사하게 생성된 이미지를 바탕으로 컨테이너 내부에서 수행될 작업이나 명령을 실행합니다. 주의할 점은 Dockerfile에 하나의 CMD 명령만 기술 가능하기 때문에 여러 개를 기록하게 된다면 마지막 명령만 유효합니다.</p>
<p>아래와 같이 shell 형식과 exec형식을 지원합니다.</p>
<pre><code># shell
CMD java -jar app.jar

# exec
CMD [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre><h3 id="dockerfile">Dockerfile</h3>
<pre><code># server base image - java 11
FROM adoptopenjdk/openjdk11

# copy .jar file to docker
COPY ./build/libs/novelpark-0.0.1-SNAPSHOT.jar app.jar

# always do command
ENTRYPOINT [&quot;java&quot;, &quot;-Dspring.profiles.active=prod&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]
</code></pre><p>다시 Dockerfile로 돌아와서 각 명령어를 보겠습니다.</p>
<ul>
<li>Base image로 <code>adoptopenjdk/openjdk11</code>를 사용했습니다.</li>
<li>빌드된 .jar파일을 컨테이너의 파일시스템에 app.jar라는 이름으로 복사합니다.</li>
<li>컨테이너가 올라가면 스프링부트 애플리케이션을 구동합니다.</li>
</ul>
<h2 id="github-actions">github actions</h2>
<pre><code class="language-yaml">name: CI/CD

on:
  push:
  pull_request:
    branches:
      - main

jobs:
  backend-deploy:
    runs-on: ubuntu-latest
    steps:
      # SOURCE 단계 - 저장소 Checkout
      - name: Checkout-source code
        uses: actions/checkout@v3

      # Gradle 실행권한 부여
      - name: Grant execute permission to gradlew
        run: chmod +x ./gradlew

      # Spring boot application 빌드
      - name: Build with gradle
        run: ./gradlew clean build

      # docker image 빌드
      - name: Build docker image
        run: docker build -t &lt;docker_hub_username&gt;/&lt;docker_image_name&gt; .

      # docker hub 로그인
      - name: Login docker hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # docker hub 퍼블리시
      - name: Publish to docker hub
        run: docker push &lt;docker_hub_username&gt;/&lt;docker_image_name&gt;

      # WAS 인스턴스 접속 &amp; 애플리케이션 실행
      - name: Connect to WAS &amp; Execute Application
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.WAS_HOST }}
          username: ${{ secrets.WAS_USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          port: ${{ secrets.WAS_SSH_PORT }}
          script: |
            docker stop $(docker ps -a -q) 
            docker rm $(docker ps -a -q) 
            docker pull &lt;docker_hub_username&gt;/&lt;docker_image_name&gt;
            docker run -d -p 8080:8080 --name &lt;container_name&gt; &lt;docker_hub_username&gt;/&lt;docker_image_name&gt;</code></pre>
<p>이후 github actions의 workflow 파일을 작성합니다. 각 내용에 대해 설명하면 다음과 같습니다.</p>
<ul>
<li>checkout<ul>
<li>코드 저장소로부터 CI 서버로 코드를 내려받도록 합니다.</li>
</ul>
</li>
<li>gradle 명령어를 수행할 수 있도록 gradlew에 실행권한을 부여합니다.</li>
<li>스프링 애플리케이션을 빌드해 build/libs 디렉토리에 .jar파일에 생성되게 합니다.</li>
<li>이후 Dockerfile을 통해 docker image를 빌드합니다.<ul>
<li>이미지 이름은 <docker_hub_username>/<docker_image_name> 과 같이 구성합니다.</li>
</ul>
</li>
<li>docker hub 로그인<ul>
<li>빌드된 docker image를 hub에 publish 하기 위해 로그인을 수행합니다.</li>
<li>이때 password는 docker hub에서 발급한 token입니다. (Account Settings &gt; Security &gt; Access Tokens)</li>
</ul>
</li>
<li>docker hub publish<ul>
<li>push 명령어를 통해 docker hub에 docker image를 push 합니다.</li>
</ul>
</li>
<li>appleboy/ssh-action을 사용해 WAS 접속 및 애플리케이션 실행<ul>
<li>ssh를 통해 EC2에 접속하고 EC2 도커 명령어를 수행합니다.<ul>
<li>host : EC2 IP</li>
<li>usename : EC2 username</li>
<li>key : pem 파일 (이때 START, END 포함시키기)</li>
<li>port : ssh 포트</li>
<li>script : EC2에서 수행할 명령</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>그런데 여전히 문제가 하나 남아있습니다. 프로젝트에 노출되면 안되는 JWT 토큰 값이나 OAuth Secret Key등이 존재합니다. 이런 민감한 파일들이 docker hub에 public하게 올라가게 되면 누군가 악의적으로 Jar파일을 압축해제하여 정보를 추출할 수 있게 됩니다.</p>
<p>이를 해결하는 방법은 다음 포스팅에서 다뤄보도록 하겠습니다.</p>
<h2 id="결론">결론</h2>
<p>최종적으로 수행되는 흐름은 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/62a35199-72e7-45c6-86da-149cb2d2a1d0/image.png" alt=""></p>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://woochan-autobiography.tistory.com/468">https://woochan-autobiography.tistory.com/468</a></p>
<p><a href="https://www.44bits.io/ko/post/how-docker-image-work">https://www.44bits.io/ko/post/how-docker-image-work</a></p>
<p><a href="https://tech.cloudmt.co.kr/2022/06/29/%EB%8F%84%EC%BB%A4%EC%99%80-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%9D%98-%EC%9D%B4%ED%95%B4-3-3-docker-image-dockerfile-docker-compose/">https://tech.cloudmt.co.kr/2022/06/29/%EB%8F%84%EC%BB%A4%EC%99%80-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%9D%98-%EC%9D%B4%ED%95%B4-3-3-docker-image-dockerfile-docker-compose/</a></p>
<p><a href="https://hudi.blog/deploy-with-docker-and-github-actions/">https://hudi.blog/deploy-with-docker-and-github-actions/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Certbot으로 HTTPS설정하기]]></title>
            <link>https://velog.io/@bruni_23yong/Certbot%EC%9C%BC%EB%A1%9C-HTTPS%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/Certbot%EC%9C%BC%EB%A1%9C-HTTPS%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 18 Aug 2023 06:03:43 GMT</pubDate>
            <description><![CDATA[<p>이슈 트래커 프로젝트를 진행하면서 클라이언트-서버 통신 간을 안전하게 진행하기 위해 SSL 인증서를 적용해 HTTPS 통신을 수행하기 위해서 Certbot을 이용해보았습니다. HTTPS 통신과정의 이해와 어떻게 적용했는지 남기려고 합니다.</p>
<h2 id="https">HTTPS</h2>
<p>HTTPS는 말그래로 SSL(Secure Socket Layer)에 HTTP를 얹은 프로토콜입니다. 즉 보안이 보장된 HTTP 이라고 볼 수 있습니다.</p>
<p>HTTPS는 SSL 핸드쉐이크를 통해 클라이언트와 서버 간 연결을 진행하게 됩니다. 이 과정은 꽤 복잡하기 때문에 먼저 <code>대칭키</code>, <code>비대칭키</code> 암호화 방식을 알 필요가 있습니다.</p>
<h2 id="대칭키">대칭키</h2>
<p>대칭키는 말 그대로 암호화와 복호화시 사용하는 키가 동일한 암호화 기법입니다. 그러기 위해서는 양쪽이 모두 같은 키를 가져야 한다는 특징이 있습니다.</p>
<p><img src=https://velog.velcdn.com/images/bruni_23yong/post/3f284f3a-7d82-4700-8c38-9c287c889df9/image.png width=500></img></p>
<p>위 그림에서 A에서 B로 데이터를 전송할 때 대칭키를 통해 암호화를 하고 이를 받은 B는 같은 키를 통해 복호화를 진행할 수 있습니다.</p>
<p>그런데 이 방식에는 단점이 하나 있습니다. 바로 서로 같은 키를 가지기 위해 키를 주고 받아야 한다는 점입니다. 만약 A에서 B로, 혹은 B에서 A로 대칭키를 전달할 때 대칭키가 탈취되면 다른 사람도 데이터를 복호화 할 수 있게 됩니다.</p>
<p>이런 배경에서 나온 것이 <code>비대칭키(공개키)</code> 방식입니다.</p>
<h2 id="비대칭키공개키">비대칭키(공개키)</h2>
<p>공개키 방식은 대칭키 방식과 다르게 키가 두 개 존재합니다. a라는 키로 암호화를 했다면 b라는 키로 복호화할 수 있고 b키로 암호화하면 a키로 복호화를 할 수 있게 됩니다. 둘 중 하나를 공개키, 나머지 하나를 비공개키로 정하게 됩니다.</p>
<p>HTTPS는 <code>대칭키 암호화</code> 방식과 <code>비대칭키 암호화</code> 방식을 모두 사용하는 프로토콜입니다. </p>
<p><img src=https://velog.velcdn.com/images/bruni_23yong/post/e7b22331-208e-46c8-b4d6-29ba1a5db0bd/image.png width=500></img></p>
<p>각 키를 이용해서 데이터를 주고받는 순서를 알아보겠습니다.</p>
<ol>
<li>A의 공개키를 B에게 전달합니다.</li>
<li>B는 A에게 데이터를 전달하기 위해 1에서 받은 공개키를 통해 데이터를 암호화합니다.</li>
<li>A는 받은 데이터를 비공개키를 통해 복호화합니다.</li>
</ol>
<p>A의 공개키가 탈취되어도 이를 복호화할 수 있는 것은 A의 비공개키를 통해서만 가능하기 때문에 대칭키 방식의 단점을 해결했다고 볼 수 있습니다.</p>
<p>그런데 데이터를 주고받을 때 A의 비공개키로 데이터를 암호화하는 경우도 있습니다. 이는 공개키가 탈취될 경우 데이터가 그대로 보이는 문제가 발생하는데 왜 사용되는 것일까요?</p>
<p>이 방식의 목적은 데이터의 보호가 목적이 아니기 때문입니다.
A에서 자신의 비공개키로 암호화된 데이터를 전달하고 이를 복호활 수 있다면 이는 A에서 보낸 데이터임을 보장할 수 있기 때문입니다.</p>
<p>즉, 공개키로 복호화 가능한 경우 정보를 전달한 사람의 신원을 보장해줍니다. 이것을 전자 서명이라고 하고 SSL 통신에서 사용됩니다.</p>
<h2 id="ssl-인증서">SSL 인증서</h2>
<p>SSL 통신에서 SSL 인증서를 사용하게 됩니다. SSL인증서에는 다음과 같은 정보가 포함되어 있습니다.</p>
<ul>
<li>서비스의 정보 (인증서를 발급한 CA, 서비스의 도메인 등)</li>
<li>서버 측 공개키 (공개키의 내용, 공개키의 암호화 방법)<blockquote>
<p>🧐 CA?
인증서의 역할은 클라이언트가 접속한 서버가 신뢰할 수 있는 서버임을 보장하는 역할을 합니다. 이 역할을 하는 기업들이 있는데 이런 기업들을 <code>CA(Certificate Authority)</code>라고 합니다. </p>
</blockquote>
</li>
</ul>
<p>위와 같은 내용들은 CA에 의해 암호화됩니다. 이때 사용하는 방식이 공개키 방식입니다. CA는 자신의 CA 비공개키를 이용해 서버가 CA에게 제출한 인증서를 암호화합니다.</p>
<p>그러면 이를 받은 클라이언트는 어떻게 CA의 비공개키로 암호화된 데이터를 복호화할 수 있을까요? 
브라우저는 CA 리스트를 알고 있기 때문입니다. 이말은 브라우저는 CA 리스트와 함께 CA의 공개키를 이미 알고 있다는 얘기가 됩니다.</p>
<h2 id="https-통신-과정">HTTPS 통신 과정</h2>
<p>HTTPS는 위 대칭키 암호화 방식과 비대칭키 암호화 방식을 모두 이용합니다. 먼저 비대칭키 방식을 이용해 대칭키를 주고받고, 실제 데이터는 대칭키로 암호화/복호화를 진행해 주고받습니다. </p>
<p>간단하게 말했지만 실제는 좀 더 복잡합니다. 먼저 간략하게 설명하자면 
<code>handshake -&gt; 통신 -&gt; 통신종료</code> 과정으로 진행됩니다. 좀 더 자세히 HTTPS 통신 과정을 알아보겠습니다. </p>
<h3 id="핸드쉐이크-hand-shake">핸드쉐이크 (Hand shake)</h3>
<p>SSL 통신은 데이터를 주고받기 전에 <code>서버가 신원이 검증된 서버인지</code>, <code>어떻게 데이터를 암호화 할지</code> 등에 대해 핸드쉐이크 과정을 통해 확인합니다. </p>
<ol>
<li>Client Hello<ul>
<li>먼저 클라이언트가 서버에 접속하는 단계로 시작합니다.</li>
<li>클라이언트 측에서 랜덤 데이터(대칭키 제조에 필요. 아래에서 설명)를 생성후 전달합니다.</li>
<li>클라이언트가 지원하는 암호화 방식들을 전달합니다.<ul>
<li>클라이언트가 가능한 암호화 방식을 서버에 알려주기 위해서입니다.</li>
</ul>
</li>
</ul>
</li>
<li>Server Hello<ul>
<li>서버 측에서 Client Hello에 대한 응답으로 서버측 랜덤 데이터를 생성후 전달합니다.</li>
<li>서버가 선택한 클라이언트의 암호화 방식을 전달합니다.</li>
<li><strong><code>SSL 인증서</code></strong>를 전달합니다.</li>
</ul>
</li>
<li>인증서 복호화<ul>
<li>클라이언트는 Server Hello 단계에서 넘어온 SSL 인증서를 복호화 합니다.<ul>
<li>이때 SSL 인증서는 CA의 비공개키로 암호화되어 있습니다.</li>
<li>브라우저는 CA리스트와 CA의 공개키를 알고 있기 때문에 이를 이용해 인증서를 복호화 합니다.</li>
</ul>
</li>
<li>인증서 복호화가 성공한다면 이는 신원이 검증된 서버임을 보장하는 것입니다.</li>
<li>클라이언트는 Client Hello, Server Hello 각 단계에서 생성한 랜덤 데이터를 가지고 <code>pre master secret</code>이라는 키를 생성합니다.<ul>
<li><code>pre master secret</code>은 임시적인 보안키입니다.</li>
</ul>
</li>
<li>이 키를 서버 측에 전달하기 위해 <code>공개키</code> 방식을 이용합니다. SSL 인증서에는 서버의 공개키가 포함되어 있기 때문에 이를 통해 <code>pre master secret</code> 키를 암호화해 전달합니다.</li>
</ul>
</li>
<li><code>pre master secret</code> 복호화<ul>
<li>서버는 자신의 비공개키를 이용해 <code>pre master secret</code>을 복호화합니다.</li>
<li>클라이언트와 서버는 각각 자신의 pre master secret과 서버의 pre master secret을 가지고 master secret을 생성합니다. </li>
<li>이 <code>master secret</code>을 통해 세션키를 생성하고 세션키를 이용해 서로가 데이터를 주고받을 때 <code>암호화/복호화</code> 를 수행합니다.</li>
</ul>
</li>
<li>클라이언트와 서버는 핸드쉐이크의 종료를 서로에게 알립니다.</li>
</ol>
<h3 id="통신">통신</h3>
<p>통신 단계는 클라이언트와 서버가 데이터를 주고받는 단계입니다.</p>
<h3 id="통신-종료">통신 종료</h3>
<p>데이터의 전송이 모두 끝나면 SSL 통신이 끝났음을 서로에게 알려줍니다. 이때 <code>master secret</code>은 폐기 합니다.</p>
<h2 id="certbot으로-https-적용">Certbot으로 HTTPS 적용</h2>
<h3 id="1-가비아에서-도메인-구매하기">1. 가비아에서 도메인 구매하기</h3>
<p><a href="https://www.gabia.com/">가비아</a>에서 도메인을 하나 구매합니다.</p>
<h4 id="도메인-등록">도메인 등록</h4>
<p>DNS에 도메인을 등록합니다. 가비아의 DNS 설정으로 가서 아래와 같이 설정합니다. 값/위치에는 NGINX를 설치할 EC2의 IP주소를 기입합니다.
<img src="https://github.com/issue-tracker-08/issue-tracker-max/assets/66981851/90d16b2b-f08e-4d27-9627-de48864ba42d" alt="image"></p>
<h3 id="2-nginx-설치">2. NGINX 설치</h3>
<p>아래의 명령어를 통해 EC2에 NGINX를 설치해줍니다.</p>
<pre><code>sudo apt update
sudo apt install nginx</code></pre><h3 id="3-certbot-설치">3. Certbot 설치</h3>
<p>HTTPS는 통신과정에서 인증서를 필요로 하기 때문에 인증서를 발급해야 합니다.
이를 간단하게 진행해주는 Certbot을 설치해줍니다. Certbot은 무료 오픈소스 소프트웨어로 Let&#39;s Encrypt 인증서를 통해 HTTPS 설정을 가능하게 합니다.</p>
<p>아래의 명령어를 통해 Certbot을 설치합니다.</p>
<pre><code>sudo snap install certbot --classic</code></pre><h3 id="4-인증서-발급">4. 인증서 발급</h3>
<p>Certbot은 도메인에 인증서를 발급해주면서 NGINX에 관련 설정을 자동으로 세팅해줍니다.</p>
<p>아래 명령어를 입력해 인증서를 발급합니다.</p>
<pre><code>sudo certbot --nginx -d {도메인}</code></pre><p>위 명령어 사용시 이메일을 입력하라는 명령이 나올텐데 이메일을 입력해주고 이메일에서 인증을 눌러주면 설정이 완료됩니다.</p>
<h3 id="5-nginx-설정">5. NGINX 설정</h3>
<p>아래 경로로 이동해 <code>default</code> 파일을 수정해줍니다.</p>
<pre><code>/etc/nginx/sites-available</code></pre><p><code>default</code>파일을 열어보면 cerbot이 자동으로 https 관련 설정을 해줄 것입니다.
하지만 자동설정만은 https 설정만 해줄 뿐이지 리버스 프록시의 역할등의 추가적인 설정이 필요합니다.</p>
<pre><code>server {
        server_name codesquad-project.site; # managed by Certbot

        location / {
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 클라이언트가 프록시 처리한 모든 서버의 IP 주소 목록
                proxy_set_header X-Forwarded-Proto $scheme; #프록시 서버의 HTTP 응답이 HTTPS로 변환
                proxy_set_header X-Real-IP $remote_addr; # 실제 방문자의 원격 ip 주소
                proxy_set_header Host $http_host; # 클라이언트가 요청한 원래 호스트 주소

                proxy_pass http://{private-ip}:8080; # 프록시 해줄 서버의 IP
        }

        listen [::]:443 ssl ipv6only=on; # managed by Certbot
        listen 443 ssl; # managed by Certbot

        # SSL 인증 설정
        ssl_certificate /etc/letsencrypt/live/codesquad-project.site/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/codesquad-project.site/privkey.pem; # managed by Certbot
        include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {
        # http로 요청이 오면 https로 리다이렉트
        if ($host = codesquad-project.site) {
                return 301 https://$host$request_uri;
        } # managed by Certbot


        listen 80 ;
        listen [::]:80 ;

        server_name codesquad-project.site;

        return 404; # managed by Certbot
}</code></pre><h3 id="6-재시작">6. 재시작</h3>
<p>모든 설정이 완료되었으니 NGINX를 재시작 해줍니다.</p>
<pre><code>sudo systemctl restart nginx</code></pre><h2 id="결론">결론</h2>
<p>certbot을 통해 간단하게 HTTPS 통신을 적용할 수 있었습니다.
또한 NGINX를 리버스 프록시 처럼 사용하면서 클라이언트가 Spring WAS에 직접적으로 연결되지 않게 되고 WAS가 늘어나게 되어도 SSL 인증서를 추가로 발급받지 않게 되니 확장성 측면에서도 좋을 것 같습니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://opentutorials.org/course/228/4894">https://opentutorials.org/course/228/4894</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[소셜 로그인 도입기]]></title>
            <link>https://velog.io/@bruni_23yong/%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%8F%84%EC%9E%85%EA%B8%B0</guid>
            <pubDate>Fri, 11 Aug 2023 08:30:23 GMT</pubDate>
            <description><![CDATA[<p>이번 프로젝트 요구사항으로 Github 소셜 로그인이 주어졌습니다. 처음 구현해보는 내용이기도 해서 이번 기회에 정리해보려고 합니다!</p>
<h2 id="oauth">OAuth?</h2>
<p>OAuth는 Open Authorization의 약자로 인증과 권한 부여를 위한 개방형 <code>표준 프로토콜</code>입니다.
구글, 깃헙, 카카오, 네이버 같이 다양한 플랫폼의 사용자 데이터에 접근하기 위해 클라이언트(우리의 서비스)가 플랫폼 내의 자신의 데이터에 대한 접근 권한을 부여받을 수 있도록 해줍니다. 사용자의 접근 권한을 위임받을 수 있다는 것은 개인정보 관리의 책임을 Third-Party Application에게 위임하여에 위임하는 것을 의미합니다.
또한 부여받은 접근권한을 통해 Third-Party Application의 사용자 리소스에 접근할 수 있습니다.</p>
<h3 id="왜-필요할까">왜 필요할까?</h3>
<p>OAuth 이전에는 다른 애플리케이션에 로그인하거나 개인 정보를 제공할 때, 사용자 이름과 비밀번호 등을 직접 입력하여 제공해야 했습니다. 이런 방식은 문제점을 가지고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/b388d156-0ee0-46fb-b93e-660dff8aa492/image.png" alt=""></p>
<p>위 그림에서도 알 수 있듯이 애플리케이션에 직접 로그인 정보를 제공해야하기 때문에, 다른 플랫폼의 개인 정보를 노출될 수 있습니다. 이런 문제를 해결하고자 하기 위해 OAuth가 등장했습니다.</p>
<h3 id="용어-정리">용어 정리</h3>
<p>OAuth 도입을 설명하기 전 용어정리부터 하려고 합니다.</p>
<ul>
<li>Client (클라이언트) <ul>
<li>리소스에 접근하는 애플리케이션 혹은 서비스에 해당합니다. 여기서는 Spring Boot 서버에 해당합니다.</li>
</ul>
</li>
<li>Authorization Server (인증 서버)<ul>
<li>인증 / 인가를 수행하는 서버로 access token을 발급합니다.</li>
</ul>
</li>
<li>Resource Server (리소스 서버)<ul>
<li>구글, 깃헙, 카카오, 네이버 등 사용자의 리소스를 가지고 있는 서버를 의미합니다.</li>
</ul>
</li>
<li>Authorization Code (인증 코드)<ul>
<li>사용자가 로그인 성공 후 발급받는 코드로 access token 발급시 필요합니다.</li>
</ul>
</li>
</ul>
<h2 id="oauth-도입기">OAuth 도입기</h2>
<p>설명에 앞서 개발환경은 스프링 부트 2.7.14, Java 11 버전에서 진행했음을 알립니다.</p>
<h3 id="oauth의-흐름">OAuth의 흐름</h3>
<p>제가 생각한 OAuth의 로그인 흐름은 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/3e6634f4-8866-4b3a-b8c4-4ded76a48953/image.png" alt=""></p>
<ol>
<li>소셜 로그인 버튼 클릭<ul>
<li>소셜 로그인 버튼을 클릭하면 Github 로그인 페이지로 리다이렉트 시켜줍니다.</li>
</ul>
</li>
<li>소셜 로그인 시도<ul>
<li>사용자가 Github의 아이디와 비밀번호를 입력하면 이 정보를 Authorization server로 보내줍니다.</li>
</ul>
</li>
<li>Authorization code 반환<ul>
<li>로그인에 성공하면 Authorization code를 발급해줍니다.</li>
<li>이를 이용해서 access token을 요청할 수 있습니다.</li>
</ul>
</li>
<li>Access token 요청 &amp; 발급<ul>
<li>Github Autorization Server에 access token을 요청합니다.</li>
<li>정상적인 code라면 인증 서버로부터 access token이 발급됩니다.</li>
</ul>
</li>
<li>사용자 정보 요청 &amp; 반환<ul>
<li>이전 단계에서 발급 받은 access token을 가지고 사용자 정보를 요청합니다.</li>
<li>정상적인 access token 이라면 사용자 정보가 반환됩니다.</li>
</ul>
</li>
<li>JWT 발급<ul>
<li>이전 단계까지 성공했다면 인증에 성공한 것이기 때문에 JWT를 발급해줍니다.</li>
</ul>
</li>
</ol>
<h3 id="앱-등록">앱 등록</h3>
<p>이제 Spring Boot 환경에 OAuth를 적용해보겠습니다. 먼저 Github에 앱 등록을 수행해야 합니다.</p>
<p>Github Settings -&gt; Developer Settings -&gt; OAuth Apps으로 이동해 프로젝트의 앱을 등록해줍니다.
<img src="https://velog.velcdn.com/images/bruni_23yong/post/675290f3-6aab-4fd3-bd19-81393bf9b98d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/dd638d26-4c01-4935-9763-d241daf0ce1c/image.png" alt=""></p>
<p>그러면 <code>Client ID</code>와 <code>Client Secrets</code>를 얻을 수 있습니다. 그리고 Hompage URL과 Authorization callback URL을 설정해줍니다.</p>
<p><code>Autorization callback URL</code>은 Github 인증 성공 시 callback 해줄 url을 의미합니다.</p>
<h3 id="외부-properties-설정">외부 Properties 설정</h3>
<p>client_id, client_secret을 노출시키지 않기 위해 따로 .yml파일에서 관리하고 이를 클래스에서 읽도록 합니다.</p>
<pre><code class="language-java">@Getter
@ConfigurationProperties(&quot;oauth&quot;)
public class OauthProperties {

    private final String clientId;
    private final String secretId;
    private final String githubOauthUrl;
    private final String githubOpenApiUrl;

    @ConstructorBinding
    public OauthProperties(String clientId, String secretId, String githubOauthUrl, String githubOpenApiUrl) {
        this.clientId = clientId;
        this.secretId = secretId;
        this.githubOauthUrl = githubOauthUrl;
        this.githubOpenApiUrl = githubOpenApiUrl;
    }
}</code></pre>
<ul>
<li>githubOauthUrl<ul>
<li>github의 Authorization server url을 의미합니다.</li>
</ul>
</li>
<li>githubOpenApiUrl<ul>
<li>github의 Resource server url을 의미합니다.</li>
</ul>
</li>
</ul>
<h3 id="소셜-로그인-버튼-클릭">소셜 로그인 버튼 클릭</h3>
<p>먼저 소셜 로그인 버튼을 클릭하면 리다이렉트 시켜주도록 엔드포인트를 설정해줍니다.</p>
<pre><code class="language-java">    @GetMapping(&quot;/login/oauth&quot;)
    public ResponseEntity&lt;Void&gt; oauthLogin() {
        return ResponseEntity.status(HttpStatus.FOUND)
            .header(HttpHeaders.LOCATION, authService.getOAuthLoginPageUrl())
            .build();
    }</code></pre>
<p>리다이렉트를 시켜줄 것이기 때문에 응답코드를 302 FOUND로 설정하고 <code>Location</code> 헤더에 github 로그인 페이지 url을 설정해줍니다.</p>
<pre><code class="language-java">return new StringBuilder().append(githubClient.getGithubLoginBaseUrl())
            .append(&quot;/authorize&quot;)
            .append(&quot;?client_id=&quot;).append(githubClient.getClientId())
            .append(&quot;&amp;scope=user:email&quot;).toString();</code></pre>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/ffbd2a2c-65c2-4dba-b82d-74e1b19ceb5e/image.png" alt=""></p>
<p><a href="https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#1-request-a-users-github-identity">공식문서</a>를 보면 <code>client_id</code>를 필수로 넣어줍니다.
또한 <code>scope</code>를 통해 권한 범위를 설정해줄 수 있기 때문에 이를 설정해줍니다.</p>
<pre><code class="language-http">https://authorization-server.com/oauth/authorize
?client_id=a17c21ed
&amp;scope=user:email</code></pre>
<h4 id="oauth-20-scope">OAuth 2.0 scope</h4>
<p>OAuth 2.0에서의 Scope는 사용자 계정에 대한 애플리케이션의 액세스를 제한하도록 하는 OAuth2.0의 메커니즘입니다. scope는 여러 개가 될 수 있으며 대소문자를 구분하는 문자열을 공백으로 구분하여 표현합니다. 이때 문자열은 OAuth 2.0 인증 서버에 의해 정의됩니다. </p>
<p>우리 서버에서 요청을 보냈을 때 scope를 user:email로 설정했기 때문에 아래와 같이 email addresses를 요청하는 화면을 볼 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/aef2fb10-6a1e-4c33-8ce5-c94f8447fcf2/image.png" alt=""></p>
<h3 id="authorization-code-반환">Authorization code 반환</h3>
<p>인증에 성공하면 code가 반환됩니다. 이를 받아 access token 요청을 시도합니다.</p>
<blockquote>
<p>If the user accepts your request, GitHub Enterprise Server redirects back to your site with a temporary code in a code parameter as well as the state you provided in the previous step in a state parameter.</p>
</blockquote>
<pre><code class="language-java">    @GetMapping(&quot;/login/oauth/github&quot;)
    public ResponseEntity&lt;LoginSuccessResponse&gt; oauthLogin(@RequestParam(&quot;code&quot;) String code) {
        return ResponseEntity.ok(authService.oauthLogin(code));
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/65031cbc-a001-49cd-9898-120a392b1b36/image.png" alt=""></p>
<h4 id="authorization-code는-왜-필요할까">Authorization code는 왜 필요할까?</h4>
<p>전체 흐름에서 바로 access token을 요청하지 않는 이유는 뭘까요? 
OAuth 2.0 문서에서는 Authorization code가 다른 권한 부여 유형에 비해 몇 가지 이점을 제공한다고 합니다. </p>
<p>사용자가 애플리케이션에 권한을 부여하면 URL에 임시 코드가 있는 애플리케이션으로 다시 redirection됩니다. 애플리케이션은 해당 코드를 access token으로 교환합니다. </p>
<p>또한 이는 access token이 사용자나 브라우저에 절대 노출되지 않음을 의미합니다. 왜냐하면 redirection url을 통해 authorization code 발급 과정이 생략되면, access token을 사용자에게 전달하기 위해 redirect url을 통해야 하기 때문입니다. 하지만 access token은 민감한 정보이기 때문에 브라우저에 바로 노출되지 않기 때문에 authorization code를 사용합니다.</p>
<p>따라서 토큰을 애플리케이션에 다시 전달하는 것이 다른 사람에게 토큰이 유출될 위험을 줄이는 가장 안전한 방법입니다.</p>
<p><a href="https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/">https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/</a></p>
<h3 id="access-token-요청--발급">Access token 요청 &amp; 발급</h3>
<p>이제 이전단계에서 받은 code를 통해 인증서버에 access token을 요청합니다.</p>
<pre><code class="language-java">    private String getAccessToken(final String code) {
        Map&lt;String, Object&gt; response = githubLoginClient
                .post()
                .uri(uriBuilder -&gt; uriBuilder
                        .path(&quot;/access_token&quot;)
                        .queryParam(&quot;code&quot;, code)
                        .queryParam(&quot;client_id&quot;, clientId)
                        .queryParam(&quot;client_secret&quot;, secretId)
                        .build())
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference&lt;Map&lt;String, Object&gt;&gt;() {
                })
                .blockOptional()
                .orElseThrow(() -&gt; new ApplicationException(ErrorCode.GITHUB_FAILED_LOGIN));

        validateExistsAccessToken(response);
        return response.get(&quot;access_token&quot;).toString();
    }</code></pre>
<blockquote>
<p>서버에서 인증 서버로 요청을 보내야 하기 때문에 <code>WebClient</code>를 이용합니다. 이를 위해 먼저 의존성을 추가해줍니다.</p>
</blockquote>
<pre><code class="language-gradle">implementation &#39;org.springframework.boot:spring-boot-starter-webflux&#39;</code></pre>
<blockquote>
<p>그렇다고 OAuth만을 위해 <code>webflux</code>를 추가하는 것은 부담이 될 수 있습니다. RestTemplate을 통해 소셜 로그인을 개발하는 것이 더 좋다고 생각합니다.</p>
</blockquote>
<p><a href="https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github">공식문서</a>를 보면 인증서버에서 access token을 발급받기 위해서는<code>code</code>, <code>client_id</code>, <code>client_secret</code>이 필요하다고 나와 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/5a5925e9-91a5-4b92-ae57-56aecb8226eb/image.png" alt=""></p>
<p>이를 쿼리 파라미터와 함께 요청을 보내면 다음과 같은 정보가 날라옵니다.</p>
<p>이때 잘못된 요청으로 access token이 존재하지 않을 수 있기 때문에 response를 검증해줍니다.</p>
<p><a href="https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-oauth-app-access-token-request-errors#incorrect-client-credentials">공식문서</a>를 보면 잘못된 요청에 대해서는 아래와 같은 형태의 응답을 내려주기 때문에 이를 예외메시지로 전달해줍니다.</p>
<pre><code class="language-java">    private void validateExistsAccessToken(Map&lt;String, Object&gt; response) {
        if (!response.containsKey(&quot;access_token&quot;)) {
            throw new OAuthAccessTokenException(response.get(&quot;error_description&quot;).toString(),
                    ErrorCode.GITHUB_FAILED_LOGIN);
        }
    }</code></pre>
<h3 id="사용자-정보-요청--반환">사용자 정보 요청 &amp; 반환</h3>
<p>이제 위에서 발급받은 access token을 통해 리소스서버에게 사용자 정보를 요청을 합니다.</p>
<pre><code class="language-java">    private GithubUser getGithubUser(final String token) {
        Map&lt;String, Object&gt; response = githubResourceClient
                .get()
                .uri(&quot;/user&quot;)
                .header(HttpHeaders.AUTHORIZATION, &quot;Bearer &quot; + token)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference&lt;Map&lt;String, Object&gt;&gt;() {
                })
                .blockOptional()
                .orElseThrow(() -&gt; new ApplicationException(ErrorCode.GITHUB_FAILED_LOGIN));
        return new GithubUser(response);
    }</code></pre>
<p><code>Authorization</code> 헤더에 Bearer 타입으로 access token을 넘겨주면 아래와 같은 응답을 받을 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/c0bf2823-954f-4a6e-958a-ee0db07e3da1/image.png" alt=""></p>
<h3 id="jwt-발급">JWT 발급</h3>
<p>인증까지 성공했으니 이제 JWT를 발급해주도록 합니다.</p>
<pre><code class="language-java">    LoginSuccessResponse.TokenResponse token = jwtProvider.createToken(Map.of(
            &quot;userId&quot;, String.valueOf(userId),
            &quot;loginid&quot;, username
        ));</code></pre>
<h2 id="결론">결론</h2>
<p>위 과정을 거쳐 Github 로그인을 제공할 수 있었습니다. </p>
<ul>
<li>OAuth로그인을 개발하면서 공식문서와 친해지는 계기가 되었습니다. </li>
<li>OAuth의 전체적인 흐름을 이해할 수 있었습니다.</li>
</ul>
<p>전체 코드는 아래에 있습니다.
<a href="https://github.com/issue-tracker-08/issue-tracker-max">https://github.com/issue-tracker-08/issue-tracker-max</a></p>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://oauth.net/2/scope/">https://oauth.net/2/scope/</a></p>
<p><a href="https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/">https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/</a></p>
<p><a href="https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps">https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps</a></p>
<p><a href="https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-authorization-request-errors">https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-authorization-request-errors</a></p>
<p><a href="https://hudi.blog/oauth-2.0/">https://hudi.blog/oauth-2.0/</a></p>
<p><a href="https://tech.kakao.com/2023/01/19/social-login/">https://tech.kakao.com/2023/01/19/social-login/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Test] 테스트별 DB환경 초기화]]></title>
            <link>https://velog.io/@bruni_23yong/Test-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%B3%84-DB%ED%99%98%EA%B2%BD-%EC%B4%88%EA%B8%B0%ED%99%94</link>
            <guid>https://velog.io/@bruni_23yong/Test-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%B3%84-DB%ED%99%98%EA%B2%BD-%EC%B4%88%EA%B8%B0%ED%99%94</guid>
            <pubDate>Mon, 31 Jul 2023 14:13:08 GMT</pubDate>
            <description><![CDATA[<p>이번 issue-tracker 프로젝트를 진행하면서 테스트별로 독립된 환경을 제공할 필요가 있었습니다. 이를 위해 테스트별로 DB환경을 초기화해주면 좋겠다 싶어서 자동으로 DB환경을 초기화해주는 코드를 작성하게 되었습니다.</p>
<blockquote>
<p>글을 작성하기에 앞서 Java11, Spring Boot 2.7.14, spring-boot-starter-jdbc를 사용하였음을 알립니다.</p>
</blockquote>
<h2 id="aftereach">@AfterEach</h2>
<p>우선은 <code>@BeforeEach</code> 애노테이션을 통해 데이터베이스를 초기화할 생각을 하게 되었습니다.
이를 위해 매번 테이블의 모든 내용을 비워주는 기능을 구현했습니다.</p>
<pre><code class="language-java">@Component
public class DatabaseInitializer {

    private static final String TRUNCATE_QUERY = &quot;TRUNCATE TABLE %s&quot;;
    private static final String AUTO_INCREMENT_INIT_QUERY = &quot;ALTER TABLE %s AUTO_INCREMENT = 1&quot;;

    @Autowired
    private DataSource dataSource;
    @Autowired
    private NamedParameterJdbcTemplate jdbcTemplate;

    private final List&lt;String&gt; tableNames = new ArrayList&lt;&gt;();

    @PostConstruct
    public void afterConstruct() {
        try {
            DatabaseMetaData metaData = dataSource.getConnection().getMetaData();
            ResultSet tables = metaData.getTables(null, null, null, new String[] {&quot;TABLE&quot;});

            while (tables.next()) {
                String tableName = tables.getString(&quot;TABLE_NAME&quot;);
                tableNames.add(tableName);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Transactional
    public void truncateTables() {
        for (String tableName : tableNames) {
            truncateTable(tableName);
        }
    }

    private void truncateTable(final String tableName) {
        jdbcTemplate.update(String.format(TRUNCATE_QUERY, tableName), Map.of());
        jdbcTemplate.update(String.format(AUTO_INCREMENT_INIT_QUERY, tableName), Map.of());
    }
}</code></pre>
<p>그리고 <code>@AfterEach</code> 애노테이션을 통해 매 테스트마다 DB의 테이블을 비워주는 <code>truncateTables</code> 메서드를 호출했습니다.</p>
<pre><code class="language-java">@Autowired
private DatabaseInitializer databaseInitializer;

@BeforeEach
void setUp() {
    databaseInitializer.truncateTables();
}</code></pre>
<p>간단하게 databaseInitializer 빈을 주입받고 truncateTables 메서드를 호출하는 것으로 독립된 DB환경을 제공하도록 구현했습니다. 
그런데 JUnit5의 Extension을 이용하면 <code>@AfterEach</code> 메서드를 정의하지 않고 간단한 애노테이션을 붙이는 것만으로 같은 동작을 하게 할 수 있습니다.</p>
<h2 id="junit5-extension">JUnit5 Extension</h2>
<p>JUnit5 Extension은 테스트 실행 중 특정 이벤트와 관련되어 있는데 이를 <code>확장 지점(extension point)</code>라고 합니다. 특정 라이프사이클 단계에 도달하게 되면 JUnit 엔진은 이미 등록된 extension들을 호출합니다.
즉, JUnit5에서 제공하는 라이프사이클을 확장할 수 있도록 도와주는 기능입니다. JUnit5에는 다음과 같은 확장 타입이 존재합니다.</p>
<ul>
<li>test instance post-processing</li>
<li>conditional test execution</li>
<li>life-cycle callbacks</li>
<li>parameter resolution</li>
<li>exception handling</li>
</ul>
<p>이 중에서 이번에는 <code>life-cycle callbacks</code>를 이용하려 합니다.</p>
<h3 id="lifecycle-callbacks">Lifecycle Callbacks</h3>
<p>이 확장은 테스트의 라이프 사이클과 연관되어 있고 다음과 같은 인터페이스를 구현해서 정의할 수 있습니다.</p>
<ul>
<li>BeforeAllCallBack &amp; AfterAllCallBack<ul>
<li>모든 테스트 메서드들이 실행되기 전에 실행됩니다. (@BeforeAll 전, @AfterAll 후)</li>
</ul>
</li>
<li>BeforeEachCallBack &amp; AfterEachCallback <ul>
<li>각 테스트 메서드들이 실행되기 전, 후로 실행됩니다. (@BeforeEach 전, @AfterEach 후)</li>
</ul>
</li>
<li>BeforeTestExecutionCallback and AfterTestExecutionCallback<ul>
<li>테스트 메서드를 실행하기 전, 후로 즉시 실행됩니다. (테스트 메서드 실행 전, 후)</li>
</ul>
</li>
</ul>
<h3 id="aftereachcallback-적용">AfterEachCallBack 적용</h3>
<p>현재 프로젝트에서는 테스트메서드를 실행한 후로 테이블을 비워주어야 하기 때문에 <code>AfterEachCallBack</code> 인터페이스를 구현하도록 하겠습니다.</p>
<pre><code class="language-java">public class DatabaseInitializerExtension implements AfterEachCallback {

    @Override
    public void afterEach(ExtensionContext context) {
        DatabaseInitializer databaseInitializer = (DatabaseInitializer)SpringExtension
            .getApplicationContext(context).getBean(&quot;databaseInitializer&quot;);
        databaseInitializer.truncateTables();
    }
}</code></pre>
<p>테스트 환경의 context에서 databaseIntializer 빈을 가져와 truncateTables 메서드를 호출하도록 했습니다.</p>
<p>이제 이 Extension을 <code>@ExtendWith</code>과 함께 사용해 간결해진 테스트코드를 확인할 수 있습니다.</p>
<pre><code class="language-java">@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@ExtendWith(DatabaseInitializerExtension.class)
public @interface ApplicationTest {
}</code></pre>
<h3 id="다른-방법">다른 방법?</h3>
<p>이 방법말고도 간단하게 <code>@DirtiesContext</code>를 사용하는 방법도 있습니다.</p>
<p>하지만 이 방법은 매번 컨텍스트를 다시 로드하기 때문에 시간이 많이 소요됩니다. 또한 <code>@Nested</code> 내부에 정의되어 있는 테스트에 대해 적용이 안되는 <a href="https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/support-classes.html#testcontext-junit-jupiter-nested-test-configuration">문제</a>가 있습니다. </p>
<p>이를 해결하기 위해서는 아래와 같이 클래스 레벨에 애노테이션을 추가해주는 방법이 있습니다.</p>
<pre><code class="language-java">@NestedTestConfiguration(value = NestedTestConfiguration.EnclosingConfiguration.OVERRIDE)</code></pre>
<h2 id="결론">결론</h2>
<ul>
<li>JUnit5에서는 테스트 라이프사이클의 확장을 위한 기능을 제공해줍니다.</li>
<li>제공하는 인터페이스를 구현하는 방법으로 적용해볼 수 있습니다.</li>
</ul>
<p>JUnit5에서 제공해주는 Extension 덕분에 테스트코드가 간결해졌습니다. 다음 프로젝트에서도 적용해볼 방법이니 기억해둬야겠습니다!</p>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://giron.tistory.com/133">https://giron.tistory.com/133</a>
<a href="https://www.baeldung.com/junit-5-extensions">https://www.baeldung.com/junit-5-extensions</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT 적용기]]></title>
            <link>https://velog.io/@bruni_23yong/JWT-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/JWT-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Thu, 27 Jul 2023 13:52:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/500a063b-f4ad-4589-8e1b-3710db241220/image.png" alt="">
이번 이슈 트래커 프로젝트를 진행하며 JWT를 통한 로그인을 구현하게 되었습니다. 이전에는 세션을 통해 로그인을 구현했었는데 둘은 어떤 차이가 있고 Spring과 JWT를 통해 로그인을 구현한 과정을 소개하겠습니다.</p>
<h2 id="stateless한-http">Stateless한 HTTP</h2>
<p>먼저 HTTP의 특성을 소개하려고 합니다. HTTP는 무상태 프로토콜로 서버가 클라이언트의 상태를 보관하지 않는다는 특성이 있습니다. HTTP는 서버가 클라이언트의 요청을 처리하면 연결을 끊어 클라이언트에 대한 이전 정보를 가지고 있지 않게 됩니다. </p>
<p>이러한 방식은 서버의 확장성(Scale-out)을 높일 수 있고 불필요한 자원의 낭비를 줄일 수 있다는 장점이 있습니다. </p>
<blockquote>
<p>상태를 계속 유지한다면 서버마다 클라이언트의 정보를 가지고 있어야겠죠?</p>
</blockquote>
<p>그런데 우리는 로그인을 한 번 하고 나면 로그인 상태를 유지할 수 있습니다. 어떻게 이를 가능케할까요? 바로 쿠키와 세션 기술을 이용해 이를 가능케합니다.</p>
<h2 id="cookie">Cookie</h2>
<p>세션에 대해 먼저 설명하기 전에 쿠키에 대해 알아보겠습니다.</p>
<p>MDN에는 쿠키가 다음과 같이 정의되어 있습니다.</p>
<blockquote>
<p>HTTP 쿠키(웹 쿠키, 브라우저 쿠키)는 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각입니다. 브라우저는 그 데이터 조각들을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데이터를 함께 전송합니다. 쿠키는 두 요청이 동일한 브라우저에서 들어왔는지 아닌지를 판단할 때 주로 사용합니다. 이를 이용하면 사용자의 로그인 상태를 유지할 수 있습니다. 상태가 없는(stateless) HTTP 프로토콜에서 상태 정보를 기억시켜주기 때문입니다.</p>
</blockquote>
<p>클라이언트는 서버에 요청을 보내게 되면 서버는 <a href="https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Set-Cookie"><code>Set-Cookie</code></a> 헤더에 쿠키를 담아 전달해 줍니다. 그러면 클라이언트는 서버에서 받은 쿠키를 저장하고 이후 HTTP 요청시 해당 쿠키를 담아 전달하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/f1c22105-1837-4e8c-992b-281d2032bb97/image.png" alt=""></p>
<p>주의할 점은 요청시 데이터를 쿠키에 그대로 담아 보내기 때문에 쿠키가 탈취당한다면 그대로 정보가 노출되기 떄문에 민감한 정보는 담지 않는 것이 좋습니다.</p>
<h2 id="session">Session</h2>
<p>세션은 쿠키와 다르게 민감한 정보들은 서버에 저장해두고 서버는 SESSION_ID를 <code>Set-Cookie</code> 헤더에 담아 보내게 됩니다.</p>
<pre><code>HTTP/1.1 200 OK
Set-Cookie: weohfeq2390ghehg42q</code></pre><p>이렇게 되면 클라이언트는 SESSION_ID를 가지고 있게 되고 이후 요청마다 SESSION_ID를 <code>Cookie</code>헤더에 담아 서버에 보내게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/9c23163e-77db-4c96-93d9-b22065e77a8f/image.png" alt=""></p>
<p>이러한 세션방식의 장단점은 다음과 같습니다.</p>
<ul>
<li>장점<ul>
<li>SESSIONID 방식을 사용하고 있기 때문에 해당 ID에 매칭된 회원정보를 바로 확인할 수 있어 편리하다.</li>
<li>쿠키가 탈취당하더라도 사용자의 정보가 아닌 무의미한 정보가 들어가 있기 때문에 쿠키보다 안전하다.</li>
</ul>
</li>
<li>단점<ul>
<li>SESSIONID를 중간에 탈취해 클라이언트인척 위장할 수 있다는 한계점이 존재한다.</li>
<li>서버를 증설하게 되면 각 서버마다 존재하는 세션정보가 일치하지 않아 Scale-out에 불리하다.<ul>
<li>이 경우 공통의 세션 DB를 만들어 관리하는 방법이 있다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<blockquote>
<p><strong>session 을 이용할 때 scale-out시 대처</strong></p>
<ol>
<li>sticky session</li>
<li>session clustering</li>
<li>외부 session 저장소 이용 (ex. redis)</li>
</ol>
</blockquote>
<h2 id="jwt">JWT</h2>
<p>JWT(Json Web Token)는 서명된 토큰입니다. 먼저 JWT가 어떻게 생겼는지 알아보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/a84085a4-8fd6-4b81-bc23-07c048568556/image.png" alt=""></p>
<p>JWT의 구성요소는 아래 3가지와 같은데, 점(.)으로 구분되어 있습니다.</p>
<ul>
<li>Header</li>
<li>Payload</li>
<li>Signature</li>
</ul>
<h3 id="header">Header</h3>
<p>Header는 토큰 타입과 토큰 생성에 어떤 알고리즘이 사용되었는지 알려줍니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/d0293948-8727-463b-9252-616bcf0fe353/image.png" alt=""></p>
<p>그림에서 보면 <code>HS256</code> 알고리즘을 사용했고 <code>JWT</code> 타입인 것을 알 수 있습니다.</p>
<blockquote>
<p><code>HS256</code> 대칭키 방식의 알고리즘 말고 <code>RS256</code> 비대칭키 방식의 알고리즘을 사용하는 방식도 있습니다.</p>
</blockquote>
<h3 id="payload">Payload</h3>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/218295e6-6c4a-46ec-8adc-995ec721553b/image.png" alt=""></p>
<p>Payload는 토큰에 담을 정보를 저장하고 있습니다. Key-Value 한 쌍의 정보를 Claim 이라고 합니다.</p>
<p><a href="https://jwt.io/introduction">jwt.io 문서</a>에 따르면 Claim은 registered, public, private 이렇게 3종류가 있습니다.</p>
<ul>
<li>registered - 필수는 아니지만 미리 정의된 클레임의 집합이다.<ul>
<li>iss(issuer) 토큰 발급자</li>
<li>exp(Expiration Time) 토큰 만료 시간</li>
<li>iat(Issued At) 토큰 발급 시간</li>
<li>aud(Audience) 토큰 대상자</li>
<li>sub(subject) 토큰 인증 주체</li>
</ul>
</li>
</ul>
<p>위와같이  표준 스펙으로 정의된 Claim 스펙이 존재합니다. 위에서 말했듯이 필수는 아니기 때문에 상황에 따라 적절히 사용하면됩니다.</p>
<ul>
<li>public <ul>
<li>JWT를 사용하는 사용자들이 마음대로 정의할 수 있다. 하지만 충돌 방지를 위해서 <a href="https://www.iana.org/assignments/jwt/jwt.xhtml">이곳(IANA Json Web Token Registry)</a>에 등록하거나 충돌방지 네임스페이스를 포함하는 URI로 정의해야 한다.</li>
</ul>
</li>
<li>private <ul>
<li>사용에 동의한 regitered 클레임이나 public 클레임이 아닌 당사자들간에 정보를 공유하기 위해 사용되는 클레임이다.</li>
</ul>
</li>
</ul>
<h3 id="signature">Signature</h3>
<p>Signature는 헤더와 페이로드가 비밀키로 서명되어 저장됩니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/e4e32fc6-f7d1-4bb2-96cd-c7bd51df9d14/image.png" alt=""></p>
<h3 id="언제-jwt를-사용한게-좋을까">언제 JWT를 사용한게 좋을까?</h3>
<p>웹에서 쿠키(cookie)와 세션(session)을 이용한 사용자 인증을 구현하는 방식과 비교해 봤을 때 확장성에 있어 가장 큰 차이를 보입니다. JWT는 이미 사용자의 정보가 저장되어 있고 서버는 이를 검증만 해주면 되기 때문에 세션과 다르게 따로 저장소를 둘 필요가 없습니다.</p>
<p>그렇기 때문에 JWT를 사용할 때는 사용자가 늘어나도 인증을 위한 저장소를 둘 필요가 없으니 인프라 비용을 절감할 수 있습니다.</p>
<h3 id="jwt-항상-좋을까">JWT, 항상 좋을까?</h3>
<p>위를 보면 항상 JWT가 좋아보일 수 있습니다. 하지만 JWT는 몇 가지 한계점이 존재합니다.</p>
<ol>
<li>한 번 발급한 토큰에 대한 제어권이 없다.<ul>
<li>JWT는 발급된 후에는 취소할 수 없으며 유효 기간이 지나기 전까지 계속 유효합니다. 따라서 토큰을 강제로 만료시키거나 취소해야 할 때, 서버 측에서 추가적인 관리 및 로직이 필요합니다 </li>
</ul>
</li>
<li>페이로드 크기 제한<ul>
<li>JWT는 인코딩된 문자열이기 때문에 많은 정보를 담는 경우 페이로드의 크기가 커지며 이는 네트워크의 부하를 증가시킬 수 있습니다.</li>
</ul>
</li>
<li>데이터 변경 감지<ul>
<li>토큰에는 발급된 후에 데이터가 변경되었는지를 확인하는 기능이 없습니다. 따라서 토큰을 갱신하지 않고는 변경된 데이터를 알 수 없습니다.</li>
</ul>
</li>
</ol>
<p>토큰에 대한 제어권이 없기 때문에 규모가 큰 서비스에서는 JWT를 사용하기에는 부족한 느낌이 있습니다. 예를 들어 여러 장치에서 로그인을 하는 것을 막고 싶은 경우 JWT를 사용하기보다는 세션을 이용해야하기 때문입니다.</p>
<h2 id="jwt-적용기">JWT 적용기</h2>
<p>저희 프로젝트는 JWT를 사용하기로 결정했는데요, 규모가 큰 서비스도 아니고 여러 장비를 고려하지 않고 인프라 비용도 절감하기 위해 JWT를 선택했습니다. 또한 빠른 로그인 구현에 초점을 맞추었기 때문에 <code>refreshToken</code>의 발급은 고려하지 않았습니다.</p>
<p>이제 Spring 환경에서 JWT를 적용해보겠습니다.</p>
<blockquote>
<p>프로젝트의 기술 정보는 다음과 같습니다.</p>
<ul>
<li>Java11</li>
<li>Spring Boot 2.7.14</li>
</ul>
</blockquote>
<h3 id="의존성-추가">의존성 추가</h3>
<p>먼저 Gradle에 아래와 같은 의존성을 추가합니다.</p>
<pre><code>    // jwt
    implementation &#39;io.jsonwebtoken:jjwt-api:0.11.5&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.11.5&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.11.5&#39;</code></pre><h3 id="jwt-프로퍼티-설정">JWT 프로퍼티 설정</h3>
<pre><code class="language-java">@Getter
@ConfigurationProperties(&quot;jwt&quot;)
public class JwtProperties {

    private final String secretKey;
    private final long expirationMilliseconds;

    @ConstructorBinding
    public JwtProperties(String secretKey, long expirationMilliseconds) {
        this.secretKey = secretKey;
        this.expirationMilliseconds = expirationMilliseconds;
    }
}</code></pre>
<p>현재 프로젝트에서 비밀키는 코드상에 드러나면 안되기 때문에 <code>application-jwt.yml</code> 파일로 비밀키와 만료시간을 관리하고 있습니다.
yml 파일에서 정보를 읽어오기 위해 <code>@ConfigurationProperties(&quot;jwt&quot;)</code>를 통해 설정정보를 읽어옵니다. 이후 <code>JwtProperties</code> 클래스를 읽어 <code>Jwt</code> 빈을 등록해 줍니다.</p>
<pre><code class="language-java">@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {

    private final JwtProperties properties;

    public JwtConfig(JwtProperties properties) {
        this.properties = properties;
    }

    @Bean
    public Jwt jwtProperties() {
        return new Jwt(properties.getSecretKey(), properties.getExpirationMilliseconds());
    }
}</code></pre>
<h3 id="jwt-발급로직">Jwt 발급로직</h3>
<p>저희는 로그인에 성공한 사용자에 대해 토큰을 발급해주는 로직입니다. 그렇기 때문에 JWT를 발급해주는 로직을 작성하겠습니다.</p>
<pre><code class="language-java">@Component
public class JwtProvider {

    private final SecretKey secretKey;
    private final long expirationMilliseconds;

    public JwtProvider(Jwt jwt) {
        this.secretKey = Keys.hmacShaKeyFor(jwt.getSecretKey().getBytes(StandardCharsets.UTF_8));
        this.expirationMilliseconds = jwt.getExpirationMilliseconds();
    }

    public String createToken(String payload) {
        Date now = new Date();
        return Jwts.builder()
            .signWith(secretKey, SignatureAlgorithm.HS256)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + expirationMilliseconds))
            .setClaims(Map.of(&quot;userId&quot;, payload))
            .compact();
    }
}</code></pre>
<p><code>createToken(String payload)</code> 메서드가 토큰을 발급하는 로직입니다.</p>
<ul>
<li>.signWith(secretKey, SignatureAlgorithm.HS256)<ul>
<li>먼저 JWT를 <code>HS256</code>알고리즘을 통해 <code>secretKey</code>로 서명합니다.</li>
</ul>
</li>
<li>setIssuedAt(now)<ul>
<li>JWT가 언제 발급되었는지 설정합니다.</li>
</ul>
</li>
<li>setExpiration(new Date(now.getTime() + expirationMilliseconds))<ul>
<li>JWT의 만료시간을 설정합니다.</li>
</ul>
</li>
<li>setClaims(Map.of(&quot;userId&quot;, payload))<ul>
<li>JWT의 페이로드의 클레임을 설정합니다.</li>
<li>User의 id를 페이로드에 담았습니다.</li>
<li>이 클레임을 <code>sub</code>로 설정할 수도 있을 것 같습니다.</li>
</ul>
</li>
</ul>
<p>이제 로그인에 성공하면 다음과 같이 토큰을 발급해주는 것을 확인할 수 있습니다.</p>
<pre><code class="language-json">{
    &quot;tokenType&quot;: &quot;Bearer&quot;,
    &quot;accessToken&quot;: &quot;eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIyIn0.bvKP6d_hTx2stQj0k4ROa7LjDaD-ddncZjZ1jmd1VfY&quot;
}</code></pre>
<h3 id="토큰-검증">토큰 검증</h3>
<p>인증되지 않은 사용자는 Spring Context까지 요청이 올 필요가 없다고 생각했기 때문에 토큰의 검증은 Filter단에서 진행하기로 결정했습니다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION = &quot;Authorization&quot;;
    private static final String BEARER = &quot;bearer&quot;;
    private static final int TOKEN_INDEX = 1;

    private static final AntPathMatcher pathMatcher = new AntPathMatcher();
    private static final List&lt;String&gt; excludeUrlPatterns = List.of(&quot;/api/auth/**&quot;);

    private final JwtProvider jwtProvider;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return excludeUrlPatterns.stream()
            .anyMatch(pattern -&gt; pathMatcher.match(pattern, request.getServletPath()));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        extractJwt(request).ifPresentOrElse(jwtProvider::validateToken, () -&gt; {
            throw new ApplicationException(ErrorCode.EMPTY_JWT);
        });

        filterChain.doFilter(request, response);
    }

    private Optional&lt;String&gt; extractJwt(HttpServletRequest request) {
        String header = request.getHeader(AUTHORIZATION);

        if (!StringUtils.hasText(header)) {
            return Optional.empty();
        }

        if (header.toLowerCase().startsWith(BEARER)) {
            return Optional.of(header.split(&quot; &quot;)[TOKEN_INDEX]);
        }
        return Optional.empty();
    }
}</code></pre>
<ul>
<li><code>OncePerRequestFilter</code>를 상속받는 <code>JwtFilter</code>를 생성합니다. <ul>
<li>인증/인가에 대해서는 한 번의 검증만 필요하기 때문에 <code>OncePerRequestFilter</code>를 상속받았습니다.</li>
</ul>
</li>
<li><code>shouldNotFilter</code>를 오버라이딩해서 회원가입/로그인에 대해서는 인증/인가 로직을 수행하지 않게합니다.</li>
<li><code>doFilterInternal</code>메서드로 JWT를 검증합니다.<ul>
<li><code>extractJwt</code>메서드를 통해 <code>Authorization</code> 헤더로 넘어온 토큰을 추출합니다.</li>
<li>이후 토큰이 유효한지 검증합니다.</li>
</ul>
</li>
</ul>
<p>토큰의 검증로직은 다음과 같습니다.</p>
<pre><code class="language-java">// JwtProvider
    public void validateToken(final String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token);
        } catch (ExpiredJwtException e) {
            throw new ApplicationException(ErrorCode.EXPIRED_JWT);
        } catch (JwtException e) {
            throw new ApplicationException(ErrorCode.INVALID_JWT);
        }
    }</code></pre>
<h3 id="빈-등록">빈 등록</h3>
<p>이제 <code>JwtFilter</code>를 빈으로 등록해줍니다.</p>
<pre><code class="language-java">    @Bean
    public FilterRegistrationBean&lt;JwtFilter&gt; jwtFilter() {
        FilterRegistrationBean&lt;JwtFilter&gt; jwtFilter = new FilterRegistrationBean&lt;&gt;();
        jwtFilter.setFilter(new JwtFilter(jwtProvider));
        jwtFilter.addUrlPatterns(&quot;/api/*&quot;);
        return jwtFilter;
    }</code></pre>
<h3 id="추가-preflight-request">(+추가) Preflight Request</h3>
<h4 id="문제-상황">문제 상황</h4>
<ul>
<li>클라이언트는 로그인, 회원가입을 제외한 모든 요청에 대해 <code>Authorizatoin</code> 헤더에 JWT를 넣어 요청</li>
<li>API 서버는 인증 처리를 위해 Client 요청에 대해 Header의 Authorization 헤더의 정보를 검증<ul>
<li>유효하지 않다면 응답 코드를 401로 설정하여 응답</li>
</ul>
</li>
</ul>
<p>이 상황에서 요청을 날렸을 때 만나는 문제는 다음과 같았습니다.</p>
<blockquote>
</blockquote>
<p>Response to preflight request doesn&#39;t pass access control check: No &#39;Access-Control-Allow-Origin&#39; header is present on the requested resource. The response had HTTP status code 401. If an opaque response serves your needs, set the request&#39;s mode to &#39;no-cors&#39; to fetch the resource with CORS disabled.</p>
<p>CORS 설정을 해주었지만 CORS 키워드가 나와 당황했습니다. 그런데 CORS 문제라면 405 응답이 나타나야 하는데 401응답을 준다는 것이 이상했습니다. 이는 Preflight 처리 중 발생한 문제였습니다.</p>
<h4 id="해결">해결</h4>
<p>Preflight Request일 경우 JWT 검증 로직을 수행하지 않도록 했습니다.</p>
<pre><code class="language-java">        if (CorsUtils.isPreFlightRequest(request)) {
            filterChain.doFilter(request, response);
            return;
        }
        String token = extractJwt(request).orElseThrow(() -&gt; new ApplicationException(ErrorCode.EMPTY_JWT));
        jwtProvider.validateToken(token);
        authenticationContext.setPrincipal(jwtProvider.extractUserId(token));

        filterChain.doFilter(request, response);</code></pre>
<h2 id="결론">결론</h2>
<ul>
<li>로그인을 구현할 때 Session 혹은 JWT 방식을 선택할 수 있습니다.</li>
<li>Session은 확장을 고려해야 하고 JWT는 규모가 큰 서비스에서 한계점이 존재합니다.</li>
<li>Spring에서 JWT를 적용해보았습니다.<ul>
<li>로그인에 성공하면 토큰을 발급합니다.</li>
<li>Filter단에서 JWT를 검증합니다.</li>
</ul>
</li>
</ul>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies">https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies</a></p>
<p><a href="https://jwt.io/introduction">https://jwt.io/introduction</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Filter Exception Handling]]></title>
            <link>https://velog.io/@bruni_23yong/Spring-Filter-Exception-Handling</link>
            <guid>https://velog.io/@bruni_23yong/Spring-Filter-Exception-Handling</guid>
            <pubDate>Thu, 20 Jul 2023 13:50:28 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/fae937b0-904f-45c7-9dd4-6b0e6532b61e/image.png" alt="">
JWT 로그인 개발을 수행하면서 토큰 검증을 필터단에서 수행하고 있었습니다. 토큰이 없다면 401 HttpStatus 응답코드를 반환하도록 설정했습니다. 그런데 POSTMAN으로 요청을 날려보니 500 응답코드를 반환하고 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/e152992c-cce2-4c44-8d8a-ff4b01df47f9/image.png" alt=""></p>
<p>왜 그런지 이유를 생각했더니 필터는 Spring Context 밖에서 동작하고 예외가 발생해도 프로젝트에서 설정해 놓은 <code>@RestControllerAdvice</code> 까지 가지 못하기 때문이라고 생각했습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/a78f30b9-5cac-40fe-a4be-1770c5283e0a/image.png" alt=""></p>
<p>그래서 Web Context 단에서 발생하는 예외를 처리하는 과정을 기록해두려 합니다.</p>
<h2 id="filter-exception-handling-적용기">Filter Exception Handling 적용기</h2>
<p>먼저 예외를 처리하는 필터를 생성합니다.</p>
<pre><code class="language-java">public class AuthFailHandlerFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    public AuthFailHandlerFilter(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (UnAuthorizedException e) {
            response.setStatus(e.getErrorCode().getStatusCode());
            response.setContentType(&quot;application/json;charset=UTF-8&quot;);
            response.getWriter()
                .write(objectMapper.writeValueAsString(new ErrorResponse(e.getErrorCode(), e.getMessage())));
        }
    }
}</code></pre>
<ul>
<li><code>OncePerRequestFilter</code> <ul>
<li>동일한 요청 안에서 한 번만 필터 로직을 수행하도록 하는 역할을 수행</li>
</ul>
</li>
<li><code>response.setStatus</code><ul>
<li>기존에 상태코드가 500으로 나갔었는데 이를 수정하기 위해 작성</li>
</ul>
</li>
<li><code>response.setContentType</code><ul>
<li>한글 인코딩 설정을 위한 <code>application/json;charset=UTF-8</code>로 설정</li>
</ul>
</li>
<li><code>response.getWriter().write()</code><ul>
<li>응답 바디를 작성</li>
</ul>
</li>
</ul>
<p>이제 이 필터를 빈으로 등록해주겠습니다. <code>@Component</code>를 통해 빈으로 등록해도 되지만 순서 설정을 위해 다음과 같이 코드를 작성했습니다.</p>
<pre><code class="language-java">    @Bean
    public FilterRegistrationBean&lt;JwtFilter&gt; jwtFilter() {
        FilterRegistrationBean&lt;JwtFilter&gt; jwtFilterBean = new FilterRegistrationBean&lt;&gt;();
        jwtFilterBean.setFilter(new JwtFilter(jwtProvider));
        jwtFilterBean.addUrlPatterns(&quot;/api/cards/*&quot;, &quot;/api/actions/*&quot;, &quot;/api/category/*&quot;);
        jwtFilterBean.setOrder(2);
        return jwtFilterBean;
    }

    @Bean
    public FilterRegistrationBean&lt;AuthFailHandlerFilter&gt; AuthFailHandlerFilter() {
        FilterRegistrationBean&lt;AuthFailHandlerFilter&gt; authFailHandlerFilterBean = new FilterRegistrationBean&lt;&gt;();
        authFailHandlerFilterBean.setFilter(new AuthFailHandlerFilter(objectMapper));
        authFailHandlerFilterBean.setOrder(1);
        return authFailHandlerFilterBean;
    }</code></pre>
<p>이렇게 되면 아래 그림과 같이 필터의 순서가 설정됩니다. 즉 <code>JwtFilter</code>에서 예외가 발생하더라도 <code>AuthFailHandlerFilter</code>에서 예외를 잡을 수 있게 된 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/0ee6c82a-b242-439c-961a-9dd37a2b20a1/image.png" alt=""></p>
<h2 id="결론">결론</h2>
<p>필터는 관리되는 영역이 다릅니다. 필터는 Spring Context 이전의 Servlet Context에서 관리되는 영역이기 때문에 필터는 스프링이 처리해주는 내용들을 적용 받을 수 없습니다. 이로 인해 스프링에 의한 예외처리가 적용되지 않습니다.</p>
<p>따라서 이를 처리하기 위해 예외를 처리해주는 필터를 생성해 빈으로 등록해주었습니다.</p>
<blockquote>
<p>참고로 <code>Filter</code>가 스프링 빈으로 등록되지 못한다고 오해할 수 있습니다. 하지만 필터는 스프링 빈으로 등록이 가능합니다. <code>DelegatingFilterProxy</code> 를 통해 이를 가능하게 해줍니다. 
Spring boot에서는 내장 웹 서버를 지원하기 때문에 Spring Boot 가 웹 서버까지 제어가 가능해 서블릿 필터의 빈을 찾으면 서블릿 필터 체인에 필터를 등록해주게 됩니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fixture Monkey 적용기]]></title>
            <link>https://velog.io/@bruni_23yong/Fixture-Monkey-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@bruni_23yong/Fixture-Monkey-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Mon, 10 Jul 2023 13:36:37 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 마친 후 테스트코드를 보던 중 지저분한 <code>FixtureFactory</code> 클래스를 정리하고 싶은 마음이 생겼습니다. 길어진 given 단계가 생겨 코드의 가독성이 떨어져 이를 위해 생성한 클래스였지만 클래스의 크기가 점점 커지고 읽기 어려워지는 문제가 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/d40322ba-e79a-4860-b83e-ace55d4de8c0/image.png" alt=""></p>
<p>만약 클래스의 개수가 늘어나게 되면 fixture 생성 메서드는 계속 늘어날 것입니다.</p>
<h2 id="테스트-데이터를-자동으로-생성해주는-도구">테스트 데이터를 자동으로 생성해주는 도구</h2>
<p>이런 테스트 데이터를 만드는 번거로운 작업을 줄이기 위해 사용할 수 있는 것이 Fixture Monkey 입니다.</p>
<p><img src="https://velog.velcdn.com/images/bruni_23yong/post/fb1e9a2b-33da-4916-ae22-b5a4e6bbbaa6/image.png" alt=""></p>
<h2 id="fixture-monkey">Fixture Monkey</h2>
<p>Fixture Monkey는 네이버페이 팀에서 만든 오픈소스 프로젝트입니다.</p>
<p>Fixture Monkey는 아래와 같은 두 가지 목표를 가지고 있습니다.</p>
<ol>
<li>테스트를 작성하게 편하게 만들기</li>
<li>테스트를 읽기 편하게 만들기</li>
</ol>
<p>기본적으로 Java/JUnit5를 지원합니다. 필요에 따라 서드파티 모듈을 추가하여 사용해야 하는 점을 유의합시다.</p>
<ul>
<li>fixture-monkey-jackson <ul>
<li>테스트에서 jackson을 통한 직렬화/역직렬화를 지원</li>
</ul>
</li>
<li>fixture-monkey-javax-validation <ul>
<li>JSR380: Bean Validation 2.0 annotation을 활용해 객체의 값을 제어할 수 있도록 플러그인을 지원합니다.</li>
</ul>
</li>
<li>fixture-monkey-kotlin    : 코틀린 지원</li>
<li>fixture-monkey-mockito <ul>
<li>인터페이스와 추상 클래스를 <a href="https://site.mockito.org/">Mockito</a> 객체로 생성할 수 있도록 플러그인을 지원합니다.</li>
</ul>
</li>
<li>fixture-monkey-jakarta-validation<ul>
<li>Jakarta Bean Validation 3.0 annotation을 활용해 객체의 값을 제어할 수 있도록 플러그인을 지원합니다.</li>
</ul>
</li>
</ul>
<p>이것 말고도 아래와 같은 것들이 더 있습니다.</p>
<ul>
<li>fixture-monkey-autoparams</li>
<li>fixture-monkey-engine</li>
</ul>
<h3 id="fixture-monkey-시작">Fixture Monkey 시작</h3>
<p>프로젝트에서는 <code>Gradle</code> 을 사용했기 때문에 아래와 같이 의존성을 추가합니다.</p>
<pre><code class="language-gradle">testImplementation &#39;com.navercorp.fixturemonkey:fixture-monkey-starter:0.5.0&#39;</code></pre>
<p>Java를 사용해서 개발을 하고 있기 때문에 아래와 같이 FixtureMonkey 인스턴스를 생성합니다.</p>
<pre><code class="language-java">FixtureMonkey sut = FixtureMonkey.builder()
            .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
            .build();</code></pre>
<p>이를 사용하기 위해서는 아래 조건 중 하나만 만족하면 됩니다.</p>
<ul>
<li>Java17 LTS 버전을 사용하고 있다면 <code>record</code> 타입입니다.</li>
<li>생성자에 <a href="https://docs.oracle.com/javase%2F7%2Fdocs%2Fapi%2F%2F/java/beans/ConstructorProperties.html">@ContructorProperties</a> 가 있습니다.</li>
<li>lombok @Value을 사용하고 <code>lombok.anyConstructor.addConstructorProperties=true</code> 옵션을 추가합니다.</li>
</ul>
<p>물론 간단하게 다음과 같이 생성할 수도 있습니다.</p>
<pre><code class="language-java">FixtureMonkey sut = FixtureMonkey.create();</code></pre>
<p>이때 기본 생성 전략은 <code>BeanArbitraryIntrospector</code> 이기 때문에 아래와 같은 필요조건이 존재합니다.</p>
<ol>
<li>클래스가 파라미터가 없는 빈 생성자를 가지고 있습니다.</li>
<li>클래스에 세터가 존재합니다.</li>
</ol>
<p>2번 조건에서 테스트하려는 모든 클래스에 세터를 적용하는 것은 좋지 않다고 생각했습니다. 왜냐하면 테스트코드를 위한 도메인 변경은 적절치 않다고 생각하고 도메인의 모든 필드에 세터를 열어주는 것은 변경가능성이 많이 생겨버리기 때문입니다.</p>
<p>따라서 위의 <code>ConstructorPropertiesArbitraryIntrospector.INSTANCE</code>를 택하기로 했습니다.</p>
<h3 id="fixture-monkey-사용해보기">Fixture Monkey 사용해보기</h3>
<p>먼저 <code>FixtureMonkey</code> 인스턴스를 생성합니다.</p>
<pre><code class="language-java">private static final FixtureMonkey sut = FixtureMonkey.builder()
            .defaultNotNull(Boolean.TRUE)
            .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
            .build();</code></pre>
<h4 id="객체-하나-생성해보기">객체 하나 생성해보기</h4>
<pre><code class="language-java">ItemDetailsResponse response = sut.giveMeOne(ItemDetailsResponse.class);</code></pre>
<h4 id="list-생성해보기">List 생성해보기</h4>
<pre><code class="language-java">List&lt;CategoryResponse&gt; response = sut.giveMe(CategoryResponse.class, 3);</code></pre>
<h4 id="특별한-값을-갖는-객체-생성해보기">특별한 값을 갖는 객체 생성해보기</h4>
<ul>
<li><p>임의의 필드 값 설정</p>
<pre><code class="language-java">OrderReceiptRequest orderReceiptRequest = sut.giveMeBuilder(OrderReceiptRequest.class)
              .set(&quot;orders&quot;, new OrdersRequest(1, 1))
              .sample();</code></pre>
</li>
<li><p>임의의 필드 모든 요소 값 설정</p>
<pre><code class="language-java">sut.giveMeBuilder(ItemDetails.class)
              .set(&quot;options[*]&quot;, Arbitraries.strings())
              .sample();</code></pre>
</li>
<li><p>임의의 필드 n번째 요소 값 설정</p>
<pre><code class="language-java">sut.giveMeBuilder(ItemDetails.class)
              .set(&quot;options[n]&quot;, Arbitraries.strings())
              .sample();</code></pre>
</li>
</ul>
<h2 id="결론">결론</h2>
<p>FixtureMonkey를 적용해 따로 <code>FixtureFactory</code> 클래스를 두어 100라인에 달하던 fixture를 생성하는 코드를 모두 제거하게 되었습니다.
또한 클래스의 필드, 생성자 등이 변경되어도 메서드 하나하나를 수정할 필요가 없게 되어 테스트 로직에 집중할 수 있게 되었습니다.</p>
<p>위에서 소개해 드린 내용말고도 <a href="https://naver.github.io/fixture-monkey/kr/docs/v0.5/">공식문서</a>를 보면 유용한 내용이 많습니다.
<del>그런데 약간 불친절한 것 같습니다..</del></p>
]]></description>
        </item>
    </channel>
</rss>