<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>JeongJin.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 25 Nov 2025 13:56:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>JeongJin.log</title>
            <url>https://velog.velcdn.com/images/dl-00-e8/profile/f89a5532-2628-40ca-b099-38ab4decf6ed/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. JeongJin.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dl-00-e8" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[신한은행 ICT 체험형 인턴십] 합격부터 수료까지]]></title>
            <link>https://velog.io/@dl-00-e8/%EC%8B%A0%ED%95%9C%EC%9D%80%ED%96%89-ICT-%EC%B2%B4%ED%97%98%ED%98%95-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%95%A9%EA%B2%A9%EB%B6%80%ED%84%B0-%EC%88%98%EB%A3%8C%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@dl-00-e8/%EC%8B%A0%ED%95%9C%EC%9D%80%ED%96%89-ICT-%EC%B2%B4%ED%97%98%ED%98%95-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%95%A9%EA%B2%A9%EB%B6%80%ED%84%B0-%EC%88%98%EB%A3%8C%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 25 Nov 2025 13:56:43 GMT</pubDate>
            <description><![CDATA[<p>4학년 1학기를 재학하면서 본격적으로 취업 준비를 시작했다. 26년 2월 졸업예정자를 위한 공고가 많지 않기에, 방학 때 인턴을 해야겠다고 계획했다. 당시 은행권들에서 계속 공고가 올라왔고, 가장 먼저 올라온 신한은행 인턴 공고를 먼저 쓰게 되었다.</p>
<h2 id="지원">지원</h2>
<h3 id="서류">서류</h3>
<p>나는 군대를 전역한 2학년 2학기부터 지원 가능한 공고들이 있다면 최대한 써왔었다. 
또한, 지원했던 기록들을 노션으로 정리해서 관리해오고 있었다.</p>
<p>아래는 실제로 지원했던 내역들을 날짜별로 캡쳐해놓은 것이다. 
4학년 1학기까지이지만, 나름대로 적지 않은 도전을 해봤다고 생각한다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/dl-00-e8/post/b9c231ac-fe67-4288-a054-c2c3b41b8af9/image.png" alt="이미지 1" width="200"></th>
<th><img src="https://velog.velcdn.com/images/dl-00-e8/post/c5395cfa-da9e-445e-909c-8c66993ddb79/image.png" alt="이미지 2" width="200"></th>
</tr>
</thead>
</table>
<p>이렇게 공고를 쓰면서, 나름대로 작성했던 기록들 및 찾아봤던 주요 기출 서류 문항들에 대해서는 1500~2000자 사이의 템플릿을 만들어 놓을 수 있었다. 
준비했던 분야들과 관련된 실제 서류 문항들을 정리하자면 다음과 같다.</p>
<hr>
<p><strong>자기소개 + 지원 동기 + 입사 후 포부</strong></p>
<ul>
<li>삼성전자를 지원한 이유와 입사 후 회사에서 이루고 싶은 꿈을 기술하십시오.<ul>
<li>삼성 기출문항</li>
</ul>
</li>
<li>“LG 전자&quot;에 대한 지원동기, 근무희망 분야 및 그 이유에 대하여 구체적으로 기술하여 주십시오.<ul>
<li>LG전자 기출문항</li>
</ul>
</li>
<li>본인의 지원직무를 어떻게 이해하고 있는지 구체적으로 기술하고, 해당 분야에 본인이 적합하다고 판단할 수 있는 근거를 사례 및 경험을 바탕으로 기재해주세요.<ul>
<li>현대모비스</li>
</ul>
</li>
</ul>
<p><strong>학업 및 직무 관련 경험</strong></p>
<ul>
<li>본인이 지원하는 직무를 잘 수행할 수 있는 이유에 대해 작성해 주세요.<ul>
<li>CJ</li>
</ul>
</li>
<li>본인의 전공능력이 지원한 직무에 적합한 사유를 구체적 사례를 들어 기술해 주시기 바랍니다.<ul>
<li>삼성 기출문항</li>
</ul>
</li>
<li>낯선 환경에서 자발적으로 최고 수준의 성과를 만들어냈던 경험을 구체적으로 서술하고, 그 과정에서 어떤 목표와 전략을 세우고 실행했는지 서술해주세요.<ul>
<li>SK AX</li>
</ul>
</li>
<li>본인이 수행한 ICT 분야 개발 경험 중 가장 의미 있었던 사례 2~3가지를 소개해 주세요. 각 사례의 목표, 주요 내용, 기간, 본인의 역할, 사용한 기술·지식·도구, 과정에서 겪은 기술적 문제와 해결 방법, 성과와 배운 점, 그리고 목표 달성을 위해 본인이 기여한 부분을 중심으로 구체적으로 작성해 주세요.<ul>
<li>신한은행</li>
</ul>
</li>
</ul>
<p><strong>도전성</strong></p>
<ul>
<li>목표를 달성하는 과정에서 힘들고 어려운 문제가 발생하였음에도 포기하지 않고 임무를 완수한 사례를 작성해주세요.<ul>
<li>현대모비스</li>
</ul>
</li>
<li>도전적인 목표를 세우고 성취하기 위해 끈질기게 노력한 경험에 대해 서술해주세요.<ul>
<li>SK 하이닉스</li>
</ul>
</li>
<li>스스로의 의지로 새로운 도전이나 변화를 시도했던 경험을 작성해 주세요.<ul>
<li>네이버</li>
</ul>
</li>
</ul>
<p><strong>본인의 성장 과정과 가치관</strong></p>
<ul>
<li>본인의 삶에서 가장 중요하게 생각하는 &#39;가치관&#39;은 무엇이며, 그 생각을 갖게 된 계기나 배경에 대해 작성해 주세요. (예 : 직접 겪은 경험, 영향을 준 인물, 인상 깊게 본 책·영상, 평소 자주 생각해 온 주제 등)<ul>
<li>신한은행</li>
</ul>
</li>
<li>본인의 성장과정을 간략히 기술하되 현재의 자신에게 가장 큰 영향을 끼친 사건, 인물 등을 포함하여 기술하시기 바랍니다. (※작품 속 가상인물도 가능)<ul>
<li>삼성전자</li>
</ul>
</li>
</ul>
<p><strong>대인관계 및 협업 경험</strong></p>
<ul>
<li>팀 혹은 모임 내에서 도전적인 과제를 진행하며 중요한 책임을 맡았던 경험과 그 결과를 작성해 주세요.<ul>
<li>네이버</li>
</ul>
</li>
<li>조직 내 다양한 사람들과 협업하기 위해 중요한 요소가 무엇이라고 생각하는지 경험을 바탕으로 작성해 주십시오.<ul>
<li>우리은행</li>
</ul>
</li>
</ul>
<p><strong>성격 장단점</strong></p>
<ul>
<li>자신의 강점과 그 강점을 어떻게 업무에 활용할 수 있을지 설명해주세요.<ul>
<li>크래프톤</li>
</ul>
</li>
</ul>
<p><strong>갈등 해결 사례</strong></p>
<ul>
<li>나와 대다수의 의견이 다른 경우, 본인이 대처했던 경험과 그로부터 얻은 교훈을 기술해 주십시오.<ul>
<li>국민은행</li>
</ul>
</li>
</ul>
<p><strong>특이한 문항</strong></p>
<ul>
<li>최근 1년 중 가장 재미있게 플레이했던 게임과 그 이유를 설명해주세요.<ul>
<li>크래프톤</li>
</ul>
</li>
<li>AI 도구를 활용하여 실제로 업무나 과제, 프로젝트의 효율을 높였던 경험이 있다면 구체적으로 서술해 주세요. (700자 이내)<ul>
<li>SK AX</li>
</ul>
</li>
<li>최근 사회 이슈 중 중요하다고 생각되는 한 가지를 선택하고 이에 관한 자신의 견해를 기술해 주시기 바랍니다.<ul>
<li>삼성전자</li>
</ul>
</li>
</ul>
<hr>
<p>위와 같은 문항들에 대한 템플릿을 활용하여 신한은행의 자기소개서를 작성했다. </p>
<p>먼저, 서류 전형의 문항은 간략하게 정리해보자면 다음과 같다.</p>
<pre><code>1. 이번 인턴십이 성장의 측면에서 얼마나 의미있을지?
2. 수행한 ICT 개발 경험 중 의미 있었던 사례 소개
3. &#39;가치관&#39;이 무엇인지와 그 배경</code></pre><p>1번은 두 가지 내용을 작성했다. 첫 번째는 대고객 서비스를 안정적으로 개발 및 운영하는 경험을 해보고 싶다는 것을 작성했으며, 두 번째는 경영학과를 복수전공하면서 금융 개발에 관심이 있다고 작성했다. 2번은 위에 분류했던 학업 및 직무 관련해 기존에 작성해놓았던 소프트웨어 마에스트로에서의 경험을 작성했다. 3번은 성장과정과 가치관 분류에서 차용해서 작성했다.</p>
<h3 id="코딩테스트">코딩테스트</h3>
<p>서류를 합격한 이후, 코딩테스트를 응시했다. 총 3문제를 응시하는 시험이었으며, 프로그래머스로 진행되었다.</p>
<p>문제의 난이도는 프로그래머스 Lv2 정도로 생각하면 될 것 같다. 프로그래머스 Lv2가 같은 Lv2여도 난이도의 편차가 큰 편이라고 생각하는 편인데, 굳이 정리해보자면 쉬운 Lv2 2문제, 어려운 Lv2 한 문제 정도이지 않을까 싶다.
문제의 유형은 단순구현 1문제, 그래프 한 문제, 백트래킹 한 문제였다. 다만, 이는 내가 풀이한 방식의 알고리즘을 나열한 것이기에 실제 문제 유형과 다를 수도 있다.</p>
<p>2시간 시험이었는데, 1시간 30분 정도 응시해서 모든 문제를 풀고 나왔다. 3번 문제의 경우, 100% 정답을 확신할 수는 없는 풀이 방식이었지만, 더 나은 방법을 생각하지 못해 시간을 끝까지 쓰기보다는 중간에 마무리하는 것으로 결정했다.</p>
<blockquote>
<p>앞으로 신한은행 ICT 인턴 공고가 올라올지는 모르겠지만, 면접 때 코딩테스트 문제들에 대한 질문이 나오기에 꼭 복기하는 것을 추천한다.</p>
</blockquote>
<h3 id="면접">면접</h3>
<p>코딩테스트를 합격한 이후에는 면접을 진행했다. 면접은 한 번만 진행되었다. 아마 인턴이기에 간소화된 절차로 진행된 것으로 보인다.</p>
<p>면접은 총 2시간 30분 정도의 시간동안 진행되었다. 실제 면접을 본 시간은 기술면접 40분 + 인성면접 40분이였으며, 나머지는 코딩테스트 복기 및 50문항 답변을 작성하는 시간으로 활용되었다.</p>
<p>먼저 기술면접을 응시했다. 내 경우는 제출한 서류 기반의 질문들로 대부분 구성되었다. 40분 중 코딩테스트에 대한 질문은 3개뿐이였고, 질문의 내용 각 코딩테스트 문제를 어떻게 풀었는지에 대한 설명을 요청하는 것이었다.</p>
<p>두 번째로는 인성면접을 응시했다. 인성면접은 사전에 작성한 50문항을 기반으로 진행되었으며, 이외에 나온 질문은 &quot;AI 시대에서 개발자로서 뒤쳐지지 않기 위해 어떠한 노력을 할 것인가?&quot; 등이 있다.</p>
<h3 id="합격">합격</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/9a59dfbb-c853-4108-b768-1af3de02a3dc/image.PNG" alt=""></p>
<h2 id="인턴-경험--수료-후기">인턴 경험 &amp; 수료 후기</h2>
<p>채용 프로세스에서 내 이력으로서 장점이 되는지는 모르겠지만,경영학과를 복수전공한 것이 인턴으로 첫 주차 교육을 받을 때에 많은 도움이 되었다.</p>
<p>여신, 수신, 외환 등 은행의 기본 도메인 지식에 대한 교육을 들었다. 이 과정에서, 경영학과에서 수강했던 회계원리, Finance 등에서 배웠던 개념들이 많이 나와서 해당 내용을 처음 배우는 인턴 동기들보다는 익숙하게 받아들일 수 있었다.</p>
<p>교육 이후에는 실무 프로젝트와 개인 프로젝트 등 총 두 개의 프로젝트를 진행했다.</p>
<p>실무 프로젝트는 글로벌서비스개발부로 배정을 받아, 인도 신한은행 홈페이지 개발을 진행했다. 사용해보지 못했던 툴과 폐쇄망, 엄격한 보안 및 권한 관리 등을 경험해볼 수 있었다. </p>
<p>개인 프로젝트는 사용해보지 못한 기술을 활용해보는 것이 주요 목적이었다. 나는 신한은행의 애플워치 앱이 없다는 것에서 착안하여, watchOS를 활용한 신한은행 내 계좌 잔액 조회 및 이체하기 등을 마이크로 진행할 수 있는 어플리케이션을 개발했다.</p>
<blockquote>
<p>개인 프로젝트: <a href="https://github.com/dl-00-e8/shinhan_project">https://github.com/dl-00-e8/shinhan_project</a></p>
</blockquote>
<p>마지막 주차에는 원하는 부서에 가서 실무자분들이 일하시는 과정이나 회의 과정에 참관했다. 나는 정보서비스개발부에서 교육 및 참관을 진행했으며, 실제 업무 프로세스를 따라가볼 수 있어서 은행 개발자는 어떻게 일하는지를 체험할 수 있었다.</p>
<p>6주라는 짧은 시간의 인턴이었지만, 수료 이후 되돌아보니 꽤나 알차게 경험했다고 생각한다. 은행, 즉 금융권은 실력도 중요하지만 조직에 녹아들 수 있는 사람인지를 많이 본다는 것을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Programmers] 두 큐 합 같게 만들기]]></title>
            <link>https://velog.io/@dl-00-e8/Programmers-%EB%91%90-%ED%81%90-%ED%95%A9-%EA%B0%99%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/Programmers-%EB%91%90-%ED%81%90-%ED%95%A9-%EA%B0%99%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 27 Dec 2024 13:58:35 GMT</pubDate>
            <description><![CDATA[<h1 id="두-큐-합-같게-만들기">두 큐 합 같게 만들기</h1>
<blockquote>
<p>문제 출처: <a href="https://school.programmers.co.kr/learn/courses/30/lessons/118667?language=cpp">https://school.programmers.co.kr/learn/courses/30/lessons/118667?language=cpp</a></p>
</blockquote>
<h2 id="단순-그리디">단순 그리디</h2>
<p>문제를 보자마자 그리디 알고리즘을 떠올렸다. 그리디는 일반적으로 가능한 모든 방식이 다 불가능하다고 판단될 때, 적용하는 것이 효과적이지만 문제를 보자마자 그리디를 떠올리게 되어, 바로 접근해보았다. </p>
<p>먼저, -1을 반환하는 경우로 두 가지를 찾았다.</p>
<ul>
<li>두 큐의 합이 같아야 하므로, 전체 수의 합은 짝수여야 한다.</li>
<li>특정 한 수가 전체 큐의 합의 절반보다 크지 않아야 한다.</li>
</ul>
<p>위 두 가지 조건을 필터링한 이후에는, 반복문을 기반으로 합이 큰 쪽에서 작은 쪽으로 옮겨주는 방식으로 구현했다. 
구현하다 보니, 시간 초과가 날 것이라는 확신이 생겼지만 확인차 제출해본 결과 73.3/100.0을 받았다.</p>
<pre><code class="language-cpp">#include &lt;bits/stdc++.h&gt;

using namespace std;

#define ll long long

int solution(vector&lt;int&gt; queue1, vector&lt;int&gt; queue2) {
    int answer = 0, max_num = 0; // 전체 수의 합: total, 가장 큰 수: max_num
    ll sum_1 = 0, sum_2 = 0, total = 0;

    for(auto value: queue1) {
        total += value;
        max_num = max(max_num, value);
        sum_1 += value;
    }
    for(auto value: queue2) {
        total += value;
        max_num = max(max_num, value);
        sum_2 += value;
    }

    // 총합이 홀 수이거나, 특정 원소가 절반보다 큰 경우
    if(total % 2 != 0 || total / 2 &lt; max_num) {
        return -1;
    }

    while(sum_1 != sum_2) {
        if(sum_1 &gt; sum_2) {
            int now = queue1.front();
            queue1.erase(queue1.begin());
            queue2.push_back(now);
            sum_1 -= now;
            sum_2 += now;
            answer++;
        } else {
            int now = queue2.front();
            queue2.erase(queue2.begin());
            queue1.push_back(now);
            sum_2 -= now;
            sum_1 += now;
            answer++;
        }
    }

    return answer;
}</code></pre>
<h2 id="개선된-그리디---종료-조건-추가">개선된 그리디 - 종료 조건 추가</h2>
<p>시간초과를 해결할 수 있는 방법이 무엇일까를 생각해보았다. 바로, 위에 언급되었던 두 조건이 아니더라도 -1이 되는 경우에 대하여 위의 코드로는 판단할 수 없다는 것이다. -1로 간주해야 하는 기준으로는 처음과 동일한 상태가 되는 최소 횟수를 찾고자 했다.
이를 생각할 때, 문제의 <code>하나의 큐를 골라 원소를 추출(pop)하고, 추출된 원소를 다른 큐에 집어넣는(insert) 작업</code>이라는 문장에서 아이디어를 얻었다.
두 개의 큐를 연결해놓은 뒤 첫 번째 큐를 의미하는 시작 idx와 끝 idx를 지정한다. 이후, 해당 idx들을 이동시키면서 첫 번째 큐가 어떤 값을 가지고 있는지를 확인하면서 순회하게 된다는 가정을 잡으면 처음과 동일한 상태가 되는 최소 횟수를 계산할 수 있다.
두 개의 큐를 연결했기에, 총 길이는 2<em>N이 된다. 첫 번째 큐의 시작 idx가 다시 첫 번째로 돌아오게 된다면, 사실상 동일한 탐색을 재시작하는 상태가 됨을 추측할 수 있다. 그렇기에, 총 4</em>N의 횟수를 초과하는 순간 -1로 간주하면 시간 초과를 해결할 수 있을 것이다.</p>
<p>개선된 방식을 적용한 코드는 아래와 같다.</p>
<pre><code class="language-cpp">#include &lt;bits/stdc++.h&gt;

using namespace std;

#define ll long long

int solution(vector&lt;int&gt; queue1, vector&lt;int&gt; queue2) {
    int answer = 0, max_num = 0; // 전체 수의 합: total, 가장 큰 수: max_num
    ll sum_1 = 0, sum_2 = 0, total = 0;
    int limit_cnt = queue1.size() * 4;

    for(auto value: queue1) {
        total += value;
        max_num = max(max_num, value);
        sum_1 += value;
    }
    for(auto value: queue2) {
        total += value;
        max_num = max(max_num, value);
        sum_2 += value;
    }

    // 총합이 홀 수이거나, 특정 원소가 절반보다 큰 경우
    if(total % 2 != 0 || total / 2 &lt; max_num) {
        return -1;
    }

    while(sum_1 != sum_2) {
        if(sum_1 &gt; sum_2) {
            int now = queue1.front();
            queue1.erase(queue1.begin());
            queue2.push_back(now);
            sum_1 -= now;
            sum_2 += now;
            answer++;
        } else {
            int now = queue2.front();
            queue2.erase(queue2.begin());
            queue1.push_back(now);
            sum_2 -= now;
            sum_1 += now;
            answer++;
        }

        if(answer &gt; limit_cnt) {
            return -1;
        }
    }

    return answer;
}</code></pre>
<p>이번엔, 80.0/100.0을 받았다.</p>
<h2 id="개선된-그리디-2---배열-대신-큐">개선된 그리디 2 - 배열 대신 큐</h2>
<p>개선했음에도 불구하고, 계속 시간 초과가 나는 경우가 존재했다. 이를 해결하고자 vector를 queue로 변경했다. queue는 FIFO구조이기에, pop()으로 첫 번째 데이터를 지우는 과정에서 O(1)의 상수시간이 필요하지만, vector는 erase()로 데이터를 지우는 과정에서 O(N)의 시간이 필요하기 때문이다. </p>
<pre><code class="language-cpp">#include &lt;bits/stdc++.h&gt;

using namespace std;

#define ll long long

int solution(vector&lt;int&gt; queue1, vector&lt;int&gt; queue2) {
    int answer = 0, max_num = 0; // 전체 수의 합: total, 가장 큰 수: max_num
    ll sum_1 = 0, sum_2 = 0, total = 0;
    int limit_cnt = queue1.size() * 4;
    queue&lt;int&gt; q1, q2;

    for(auto value: queue1) {
        total += value;
        max_num = max(max_num, value);
        sum_1 += value;
        q1.push(value);
    }
    for(auto value: queue2) {
        total += value;
        max_num = max(max_num, value);
        sum_2 += value;
        q2.push(value);
    }

    // 총합이 홀 수이거나, 특정 원소가 절반보다 큰 경우
    if(total % 2 != 0 || total / 2 &lt; max_num) {
        return -1;
    }

    while(sum_1 != sum_2) {
        if(sum_1 &gt; sum_2) {
            int now = q1.front();
            q1.pop();
            q2.push(now);
            sum_1 -= now;
            sum_2 += now;
            answer++;
        } else {
            int now = q2.front();
            q2.pop();
            q1.push(now);
            sum_2 -= now;
            sum_1 += now;
            answer++;
        }

        if(answer &gt; limit_cnt) {
            return -1;
        }
    }

    return answer;
}</code></pre>
<p>위 개선 과정을 통해서 정답 판정을 받을 수 있었다. 시간 초과를 고민하기 위해 4*N을 기준점으로 잡는 과정이 도움 없이 해결하기에 굉장히 까다로웠던 것 같다. 세밀한 조건들을 고민하는 과정을 놓치지 않는 연습을 해야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Programmers] 도넛과 막대 그래프]]></title>
            <link>https://velog.io/@dl-00-e8/Programmers-%EB%8F%84%EB%84%9B%EA%B3%BC-%EB%A7%89%EB%8C%80-%EA%B7%B8%EB%9E%98%ED%94%84</link>
            <guid>https://velog.io/@dl-00-e8/Programmers-%EB%8F%84%EB%84%9B%EA%B3%BC-%EB%A7%89%EB%8C%80-%EA%B7%B8%EB%9E%98%ED%94%84</guid>
            <pubDate>Mon, 11 Nov 2024 04:21:40 GMT</pubDate>
            <description><![CDATA[<h1 id="도넛과-막대-그래프">도넛과 막대 그래프</h1>
<blockquote>
<p>문제 출처: <a href="https://school.programmers.co.kr/learn/courses/30/lessons/258711?language=cpp">https://school.programmers.co.kr/learn/courses/30/lessons/258711?language=cpp</a></p>
</blockquote>
<h2 id="문제-풀이-과정">문제 풀이 과정</h2>
<h3 id="예제-풀이-1">예제 풀이 1</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/00ba09b3-7605-4ef2-b2a7-6937dda1a75a/image.png" alt="">
예제는 이미지로 구조화되어 있기에, 직관적으로 파악할 수 있다. 2번이 생성 노드임을 알 수 있다.</p>
<h3 id="예제-풀이-2">예제 풀이 2</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/2b6c09ad-99c9-4db1-8248-4fc8b8318df0/image.png" alt="">
예제는 이미지로 구조화되어 있기에, 직관적으로 파악할 수 있다. 4번이 생성 노드임을 알 수 있다.</p>
<h3 id="접근-과정">접근 과정</h3>
<p>예제 2개에서 공통적으로 찾을 수 있는 특징으로는 생성 노드가 목적지가 되는 경우가 없다는 것이다. 문제의 내용에서도 관련된 내용을 찾을 수 있다. 문제에서 보면, <code>그래프들과 무관한 정점을 하나 생성한 뒤, 각 도넛 모양 그래프, 막대 모양 그래프, 8자 모양 그래프의 임의의 정점 하나로 향하는 간선들을 연결했습니다.</code>라고 되어있다. 즉, 생성한 노드가 간선의 출발 노드가 된다는 것이다.
그렇기에, 각 노드가 목적지가 되는 횟수를 배열로 가지고 있으면서, 이것이 0일 대 생성한 노드가 될 수 있는 첫 번째 조건으로 활용하면 된다.</p>
<p>두 번째 생성 노드 조건은 문제를 잘 읽어보면 찾을 수 있다. 문제 제한사항을 살펴보면, <code>도넛 모양 그래프, 막대 모양 그래프, 8자 모양 그래프의 수의 합은 2이상입니다.</code>라는 문장이 있다. 이 문장이, 두 번째 생성 노드와 관련된 조건이다. 즉, 생성 노드가 출발 노드인 간선이 2개 이상 존재한다는 것이다.
두 번째 조건이 중요한 이유는 막대 모양 그래프를 통해 확인할 수 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/2bbd64a2-f338-45b2-a0fd-2b11f9ca5178/image.png" alt="">
막대모양 그래프의 SIZE=1인 모양 그래프를 보면, 출발지가 되는 경우와 목적지가 되는 경우가 모두 0이다. SIZE&gt;=2인 그래프들 또한 목적지가 되는 경우가 0이면서 출발지가 되는 경우가 1인 노드가 존재한다.
두 번째 조건이 없다면, 막대모양 그래프의 일부인지 생성한 노드인지를 판단할 수 없는 것이다.</p>
<p>이렇게 생성한 노드를 찾은 이후에는, 생성한 노드로부터 연결된 각 노드들이 어떠한 모양 그래프의 포함되는지를 판단하면 된다.</p>
<p>도넛 모양 그래프의 특징을 정리하면 다음과 같다.</p>
<ul>
<li>각 노드가 출발지가 되는 경우, 목적지가 되는 경우가 무조건 1번이다.</li>
</ul>
<p>막대 모양 그래프의 특징을 정리하면 다음과 같다.</p>
<ul>
<li>막대 모양 그래프의 노드 중 하나는 출발지  노드로는 1번, 목적지 노드로는 0번이 된다. (생성한 노드와 연결되는 경우를 제외하면)</li>
</ul>
<p>8자 모양 그래프의 특징을 정리하면 다음과 같다.</p>
<ul>
<li>8자의 중심이 되는 노드는 출발지 노드로 2번, 목적지 노드로 2번이 되어야 한다. (생성한 노드와 연결되는 경우를 제외하면)</li>
</ul>
<p>위 세 가지 특징 중에서, 판단하기 제일 어려운 것이 도넛 모양 그래프라고 생각했다. 순환되는 도넛 모양 전체를 한 바퀴 돌아야만 판단이 가능하기 때문이다.
그렇기에, 생성한 노드와 간선으로 연결된 노드에서 모양 그래프를 판단할 때, 기본적으로 도넛 모양으로 가정한 이후, 막대 모양이나 8자 모양에 해당하는 특징을 가진 노드가 있다면, 해당 모양을 의미하도록 변경하는 방식을 취했다.</p>
<h3 id="최종-로직">최종 로직</h3>
<ol>
<li>주어진 input을 노드별 목적지를 가지도록 변경하여 저장</li>
<li>생성한 노드를 찾기</li>
<li>생성한 노드와 연결된 노드 순회 진행</li>
<li>도넛 모양을 기본 설정으로 잡은 뒤, 막대 모양과 8자 모양의 특징에 해당하는 노드가 등장할 경우, 모양 변경</li>
<li>연결된 모든 간선 순회했다면, 최종 모양으로 answer 배열 업데이트</li>
<li>3~5의 과정을 모든 연결된 노드에서 진행</li>
</ol>
<h2 id="정답-코드">정답 코드</h2>
<pre><code class="language-cpp">#include &lt;bits/stdc++.h&gt;

using namespace std;

vector&lt;int&gt; solution(vector&lt;vector&lt;int&gt;&gt; edges) {
    vector&lt;int&gt; answer(4); // 순서: 생성한 정점의 번호, 도넛 모양 그래프의 수, 막대 모양 그래프의 수, 8자 모양 그래프의 수
    vector&lt;int&gt; graph[1000001]; 
    int dest_cnt[1000001]; // 노드가 목적지가 된 횟수 계산
    set&lt;pair&lt;int, int&gt;&gt; visited;

    // 방문 배열 초기화
    memset(dest_cnt, 0, sizeof(dest_cnt));

    int max_idx = 0;
    for(auto edge: edges) {
        graph[edge[0]].push_back(edge[1]);
        dest_cnt[edge[1]]++;

        // 최대 index 확인 위한 업데이트
        max_idx = max(max_idx, edge[0]);
        max_idx = max(max_idx, edge[1]);
    }

    // 생성 노드 찾기
    for(int i = 1; i &lt; max_idx + 1; i++) {
        if(dest_cnt[i] == false &amp;&amp; graph[i].size() &gt;= 2) {
            answer[0] = i;
            break;
        }
    }

    // 생성 노드 기준으로 그래프 모양 찾기
    for(auto node: graph[answer[0]]) {
        visited.insert({answer[0], node}); // 방문 처리 (앞으로 미사용)
        dest_cnt[node]--; // 목적지 횟수 감소
        int node_type = 1; // 1: 도넛 모양, 2: 막대 모양, 3: 8자 모양

        queue&lt;int&gt; q;
        q.push(node);
        while(!q.empty()) {
            int now = q.front();
            q.pop();

            // 막대 모양
            if(dest_cnt[now] &lt;= 1 &amp;&amp; graph[now].size() == 0) {
                node_type = 2;
                break;
            }

            // 도넛 모양
            if(dest_cnt[now] == 2 &amp;&amp; graph[now].size() == 2) {
                node_type = 3;
                break;
            }

            for(auto next: graph[now]) {
                if(visited.find({now, next}) == visited.end()) {
                    visited.insert({now, next});
                    q.push(next);
                    break;
                }
            }
        }
        answer[node_type]++;
    }

    return answer;
}</code></pre>
<hr>
<p><strong>느낀 점</strong>
Lv2이지만, 개인적으로는 정말 어렵게 느껴졌던 문제였다. 2시간을 넘게 고민하면서, 해설을 일부 참고하기도 했다.
문제에서 특징을 찾아내고, 정리하는 과정에서 부족함을 많이 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[과릿] Redis 과의존 개선기]]></title>
            <link>https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EA%B3%BC%EC%9D%98%EC%A1%B4-%EA%B0%9C%EC%84%A0%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EA%B3%BC%EC%9D%98%EC%A1%B4-%EA%B0%9C%EC%84%A0%EA%B8%B0</guid>
            <pubDate>Mon, 30 Sep 2024 04:57:06 GMT</pubDate>
            <description><![CDATA[<p>과릿을 통해 경험한 내용 및 달성한 성과를 포트폴리오에 정리하면서 지인들에게 피드백을 요청했었다. 지인들과 과릿에서의 Redis 사용에 대해서 많은 얘기를 나누었는데, Redis를 사용하면서 놓치고 있던 부분(Ex: 데이터 싱크, 장애 대응 등)이 많았다는 것을 깨달았다. 제대로 고려하지 않고 도입한 Redis로 인해 발생할 수 있는 (또는 발행한 적이 있었던) 문제 상황 및 해결 과정을 정리해보고자 한다.</p>
<h1 id="문제상황">문제상황</h1>
<p>과릿을 운영하며 실제로 Redis 장애(참조: <a href="https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0">Redis 스냅샷 오류</a>)를 경험한 적이 있었다. 당시, 로그인 장애가 발생하여 <strong>약 15분간</strong> 신규 사용자 유입이 불가능했었다.</p>
<p>이에 더해 발생한 적은 없지만, Redis의 다운으로 인해 데이터 손실이 발생했을 때를 대비하여 MySQL이 그 역할을 이어받을 수 있어야 했다. 
정확히 말하자면, Redis가 유일하게 데이터를 저장하는 공간이 되지 않아야 하는 기본 원칙을 지키지 않았기에, 이를 <strong>FailOver 형식</strong>으로 개선하기로 결정하고 도입했던 과정을 정리한다.</p>
<h1 id="과릿과-redis">과릿과 Redis</h1>
<p>개선 과정을 정리하게 앞서, Redis는 무엇이며 과릿에서 왜 Redis를 도입해서 활용하고 있는지를 언급하고 가겠다.</p>
<h2 id="redis란">Redis란?</h2>
<p>Redis를 정의하자면, key-value 저장소로서, 인메모리 NoSQL 데이터베이스이다. 캐싱, 세션 관리, PubSub, Sorted Set을 활용한 랭킹 시스템 등의 다양한 용도로 활용할 수 있다.</p>
<h2 id="redis-도입-목적-및-관리하는-데이터">Redis 도입 목적 및 관리하는 데이터</h2>
<h3 id="도입-목적">도입 목적</h3>
<ul>
<li>Timeout이 존재하는 데이터를 적재 및 삭제 용이</li>
<li>데이터 캐싱을 통해 DB I/O 최소화 및 사용자 경험 개선</li>
</ul>
<h3 id="관리하는-데이터-및-flow">관리하는 데이터 및 Flow</h3>
<p><strong>인증번호</strong>
과릿은 회원가입 및 비밀번호 초기화 시 전화번호 인증을 통한 사용자 확인을 진행하고 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/d9711ad5-ed3a-4943-b56e-5a6c875f34b2/image.png" alt="비밀번호 초기화 Flow"> 위 이미지의 중간 과정을 보면, 랜덤 인증번호를 발급 받은 이후, 전화번호와 인증번호를 Redis에 저장하고 있는 것을 알 수 있다. 인증번호는 5분이라는 인증 유효기간 이후 사라지며, 재요청 시 덮어쓰기가 되어야 하기에, Redis에 데이터를 적재하는 것을 선택했었다.</p>
<p><strong>JWT 토큰</strong>
과릿은 로그인 시, Access Token과 Refresh Token을 발급하여 반환한다. 그 중 Refresh Token을 Redis에 저장하여, Access Token이 만료되었을 때 재발급 시 일치 여부를 확인하기 위하여 사용한다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/4dc45779-4bdf-44b6-9a41-ab6321ac7395/image.png" alt="로그인 Flow"> 또한 위 이미지 로직과 더불어, 로그아웃 API를 통해 사용하지 않는 Access Token을 블랙리스트로 등록해 Expire전까지 해당 Access Token으로 API 요청이 올 경우, 권한 없음으로 처리하도록 개발했다.</p>
<blockquote>
<p><strong>Access Token과 Refresh Token 구조를 채택한 이유</strong>
Access Token만 있을 경우, Access Token이 만료되면 지속적으로 로그인을 진행해야 한다. 모바일 앱에서는 대체적으로 로그인 상태 유지 옵션을 활성화하는 것이 일반적이고, 이것이 사용자 경험의 불편함을 해소하는 옵션이라고 생각하여 Access Token과 Refresh Token을 함께 사용하는 구조를 채택했다.</p>
</blockquote>
<h2 id="redis-장애-시-대응-방법">Redis 장애 시 대응 방법</h2>
<p>Redis의 장애 시 대응 방법은 단순하게 서버 재시작뿐이었다. 과릿의 Redis는 RDB 스냅샷 방식의 백업을 사용하고 있기에, 해당 스냅샷으로 Redis를 재시작한다면 데이터의 손실은 최소화한 상태로 운영은 가능했다.
다만, 이 방식은 결국 <strong>수동</strong>이라는 점이다. 과릿의 백엔드/인프라를 혼자 담당하고 있기에 확인 및 대응이 불가능한 시점에 장애가 발생한다면 서비스에 치명적인 혼란을 초래할 수 있어 개선이 꼭 필요한 상황이다.</p>
<h2 id="redis-대신-다른-방법은-사용할-수-없었는지">Redis 대신 다른 방법은 사용할 수 없었는지</h2>
<p>Redis 대신 서버 내 자료 구조를 활용하는 등의 방법은 없었는지 궁금할 수 있다. 
이는 과릿의 첫 번째 인프라 아키텍처를 통해 Redis를 도입하게 된 이유를 알 수 있다. 
과릿의 첫 번째 인프라는 다음과 같이 구성되어 있었다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/cf8b45f5-d912-45ac-848a-80b6966ae224/image.png" alt=""> Auto Scaling 설정을 통해, CPU 점유율이 일정 수치 이상이 되면 새로운 EC2 서버를 띄워 트래픽을 분산하도록 ElasticBeanstalk 기반의 인프라를 구성했었다. 이 구성에서는 서버 내 자료 구조를 활용하게 될 경우, 새롭게 띄워진 EC2 서버에서는 데이터를 확인할 수 있는 방법이 없다. Redis를 활용해 Auto Scaling 상황에서도 데이터를 공유하면서 관리하는 것이 효과적이라 판단했다.</p>
<blockquote>
<p>Redis를 AWS ElastiCache를 사용하지 않은 이유는 비용 때문이다.</p>
</blockquote>
<h1 id="과릿의-인프라">과릿의 인프라</h1>
<p>과릿은 현재는 위의 인프라를 유지하고 있지 않다. 간략하게, 변경된 인프라를 정리한다.</p>
<h2 id="3번의-마이그레이션">3번의 마이그레이션</h2>
<p>위의 첫 번째 인프라 아키텍처는 SW마에스트로에서 서버 비용을 지원해주었기에 구성할 수 있었던 꽤나 비싼 인프라였다. SW마에스트로 고도화 과정이 종료된 이후, 팀원과 논의하여 계속 과릿을 운영하기로 결정했기에 인프라 이전이 필요했다.</p>
<p>GCP는 3개월간 최대 30만원의 비용을 사용할 수 있는 지원이 존재한다. 두 번째 인프라는 GCP를 활용해서 구성했다. 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/40b4c507-be6d-4e93-b6d9-440801b997c9/image.png" alt=""></p>
<p>GCP의 3개월 요금 지원이 종료된 이후, AWS의 프리티어 계정을 활용하여 마이그레이션을 진행했다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/2977febd-c31d-4404-a659-5f0af7e4a2d8/image.png" alt=""></p>
<blockquote>
<p>위 아키텍처들은 IGW, NAT Gateway 등은 생략된 인프라 아키텍처이다.</p>
</blockquote>
<h2 id="redis를-계속-사용하는-이유">Redis를 계속 사용하는 이유</h2>
<p>첫 번째 인프라와 달리 두 번째, 세 번째 인프라는 Auto Scaling 옵션을 적용하고 있지 않다. 그럼에도 불구하고, 왜 Redis를 사용하고 있는지 궁금할 수 있다. 이에 대한 답변은 다음과 같다.
마이그레이션 과정에서 Redis 백업본을 그대로 가져와서 활용했다. Redis를 사용하지 않고, 다른 방식으로 전환하게 된다면 모든 사용자의 로그인이 반드시 풀려야 하는 상황이 발생한다. 이는, 사용자 입장에서 로그인을 다시 진행하거나 오래되어 비밀번호를 까먹어 재발급 및 수정을 진행하는 등의 경험을 하게 된다. 사용자 경험상 발생시키지 않아야 하는 일이라고 판단되어, Redis를 유지하기로 결정했다.</p>
<blockquote>
<p>Redis의 백업본을 효과적으로 서버 내부 자료 구조 등으로 마이그레이션할 수 있는 방법에 대해서 알고 있지 않아 선택한 방식이기도 하다.</p>
</blockquote>
<h1 id="과릿의-바뀐-redis-사용법">과릿의 바뀐 Redis 사용법</h1>
<p>과릿은 현재 FailOver 방식으로 Redis를 사용하고 있다. FailOver란 무엇인지, 과릿에서는 어떻게 적용했는지 그 상세한 내용을 다루어본다.</p>
<h2 id="failover-방식">FailOver 방식</h2>
<p>토스의 <a href="https://toss.tech/article/25301">캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁</a>이라는 글에서 캐시 문제에 관한 다양한 내용을 다루고 있다. 해당 글의 <code>3. 캐시 시스템 장애</code> 파트를 보면 과릿이 겪을 수 있는 문제점에 대한 내용이 정리되어 있다.</p>
<p>문제 상황으로, 조회 요청이 폭발하여 Redis 서버에 장애가 발생했다고 가정하고 있다. 이를 과릿의 상황으로 대입해보자면, 악성 사용자가 인증번호를 무수히 많이 요청한 상황이거나 단일 EC2 t2.micro 서버의 한계로 인해 메모리가 부족한 상황이 될 수 있겠다.</p>
<p>이 때, 토스가 제시한 해결책이 FailOver이다.
FailOver는 한국어로는 <strong>대체작동</strong>이라고 정의할 수 있다.</p>
<p>FailOver의 핵심 내용은 다음과 같다.</p>
<ul>
<li>반드시 동작해야 하는 핵심 기능은 유지</li>
<li>편의를 위한 부가 기능은 동작 정지</li>
</ul>
<p>즉, 핵심 기능은 데이터베이스를 활용해 트래픽을 처리하도록 하면서 부가 기능은 사용자에게 별도의 공지를 통해 사용 불가함을 알려 해결하는 것이다. 이 방식을 적용하기 위해서는 <strong>서비스의 핵심 기능과 부가 기능을 분리</strong>할 수 있어야 하며, <strong>핵심 기능을 데이터베이스를 활용해 트래픽을 처리할 수 있도록 구성</strong>해야 한다.</p>
<h2 id="과릿에서의-failover">과릿에서의 FailOver</h2>
<blockquote>
<ul>
<li>Redis 서버 다운 시 대처 방안<ul>
<li>토스 기술 블로그의 캐시 글에서 나온 FailOver 방식 도입</li>
<li>과릿 서비스에 어떻게 적용한 것인지 설명</li>
<li>Redis가 죽었을 때, 서비스가 에러를 뱉지 않고 MySQL을 통해 중요 서비스를 유지할 수 있도록 리팩토링</li>
</ul>
</li>
</ul>
</blockquote>
<p>과릿에서 FailOver를 도입하기 위해서 핵심 기능과 부가 기능을 정리해야 한다.
과릿에서 Redis를 활용하고 있는 정보는 다음과 같다.</p>
<ul>
<li>인증번호</li>
<li>블랙리스트</li>
<li>Refresh Token</li>
</ul>
<p>서비스 전체에 장애를 줄 수 있는 핵심 기능은 인증번호, 블랙리스트 등 총 두 가지의 정보이다. 인증번호는 회원가입, 비밀번호 초기화와 같은 인증 로직에서 핵심적인 역할을 하는 데이터이기 때문이며, 블랙리스트는 토큰을 통한 부적절한 접근을 막기 위한 보안적인 기능이기 때문이다.
Refresh Token은 장애 발생 시, 재로그인을 통해서 사용은 가능하므로 부가 기능으로 판단했다.</p>
<p>위와 같이 핵심 기능과 부가 기능을 분리했지만, FailOver를 처음 도입해보기에 해당 세 가지 정보를 모두 FailOver 동작이 가능하도록 구성해볼 것이다.</p>
<h3 id="redis-장애와-처리-flow">Redis 장애와 처리 Flow</h3>
<p>Redis 장애 여부와 데이터 존재 여부에 따른 처리 Flow를 분리해서 정리한 내용이다. 
<strong>Redis 정상 &amp; 데이터 존재</strong> 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/aab97dc1-87e1-4d99-87dc-9bfd904d59a4/image.png" alt=""> Redis가 정상이면서, 데이터가 존재한다면 데이터베이스로 트래픽을 돌릴 필요 없이 바로 Redis의 데이터를 활용하면 된다.</p>
<p><strong>Redis 정상 &amp; 데이터 미존재</strong> 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/fa15fbb6-ff96-46a3-a546-181c9fdece88/image.png" alt=""> Redis가 정상이지만, 데이터가 미존재하는 경우가 있을 수 있다. 
과거 Redis가 장애가 발생했을 당시, 데이터베이스에만 데이터가 저장된 경우가 그 예시이다. 이럴 때에는 Redis가 정상임에도 불구하고 데이터베이스로 트래픽을 돌려서 데이터를 조회하여 비즈니스 로직을 진행하면 된다.</p>
<p><strong>Redis 장애</strong> 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/f9d5609d-be46-4ad8-9caf-392a726edc6d/image.png" alt=""> Redis 장애가 발생했을 경우이다. 모든 트래픽이 데이터베이스로 몰리게 된다. </p>
<h3 id="데이터-설계">데이터 설계</h3>
<ul>
<li>인증번호</li>
<li>블랙리스트</li>
<li>Refresh Token</li>
</ul>
<p>위 데이터들을 데이터베이스에 어떻게 저장할 것인지 설계해야 한다.</p>
<p>처음에는 Redis의 key-value 형태를 동일하게 차용하여 pk를 key, value라는 text 자료형의 column, Long 타입의 expired_at으로 설계했었다. RDB 특성을 살리지 않는 방식이면서, 적재된 데이터들에 대해서 유지 및 관리의 어려움이 있다고 판단되어 데이터별로 다음과 같이 테이블을 분리했다. 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/3ba61cf4-4027-47a4-b115-0f45eadaa593/image.png" alt="ERD"></p>
<h3 id="redis-장애-여부-확인">Redis 장애 여부 확인</h3>
<p>시퀀스 다이어그램에서도 확인할 수 있듯이 데이터 조회 과정에서는 Redis 장애 여부를 확인하는 것이 MySQL을 대체로 사용할 것인지에 판단하는 핵심 조건이다.</p>
<p><strong>RedisDto</strong></p>
<pre><code class="language-java">@Getter
@Builder
public class RedisDto {

    private String key; // Redis Key
    private String value; // Redis Value
    private boolean isSuccess; // Redis 작업 성공 여부
}</code></pre>
<p><strong>RedisClient</strong></p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class RedisClient {

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

    /**
     * Redis에 key-value 저장
     * @param key 저장할 key
     * @param value 저장할 value
     * @param timeout 데이터 유효시간 (expire time)
     */
    public RedisDto setValue(String key, String value, Long timeout) {
        // Redis 서버가 정상적으로 동작하지 않을 경우
        if(!isRedisAvailable()) {
            return RedisDto.builder()
                    .key(key)
                    .value(value)
                    .isSuccess(false)
                    .build();
        }

        ValueOperations&lt;String, String&gt; values = redisTemplate.opsForValue();
        values.set(key, value, Duration.ofMinutes(timeout));

        return RedisDto.builder()
                .key(key)
                .value(value)
                .isSuccess(true)
                .build();
    }

    /**
     * Redis에서 key로 value 조회
     * @param key 조회할 key
     * @return key에 해당하는 value
     */
    public RedisDto getValue(String key) {
        if(!isRedisAvailable()) {
            return RedisDto.builder()
                    .key(key)
                    .isSuccess(false)
                    .build();
        }

        ValueOperations&lt;String, String&gt; values = redisTemplate.opsForValue();

        return RedisDto.builder()
                .key(key)
                .value(values.get(key))
                .isSuccess(true)
                .build();
    }

    /**
     * Redis에서 key로 value 삭제
     * @param key 삭제할 key
     */
    public void deleteValue(String key) {
        // Redis 정상 동작 시에만 삭제
        if(isRedisAvailable()) {
            redisTemplate.delete(key);
        }
    }

    /**
     * Redis 서버가 정상적으로 동작하는지 확인
     * @return Redis 서버 동작 여부
     */
    private boolean isRedisAvailable() {
        try {
            return Optional.ofNullable(redisTemplate.getConnectionFactory())
                    .map(connectionFactory -&gt; (connectionFactory.getConnection().ping() != null))
                    .orElse(Boolean.TRUE);
        } catch (Exception e) {
            return false;
        }
    }
}</code></pre>
<p>처음에는 위 코드에서 <code>isRedisAvailalbe()</code> 메소드를 외부 접근이 가능하도록 설정하여, 각 비즈니스 로직에서 장애를 확인하도록 작성했었다. 그러나, 해당 방식이 변경 포인트가 너무 많아진다고 판단되어 <code>RedisDto</code>를 만들어 장애 발생 여부를 표현하는 <code>isSuccess</code>를 활용하는 방식을 채택했다.</p>
<h4 id="인증번호-개선">인증번호 개선</h4>
<p><strong>인증번호 발송</strong></p>
<pre><code class="language-java">@Transactional
public void sendAuthorizationCode(PostAuthPhoneReq postAuthPhoneReq) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException, URISyntaxException {
    // Business Logic - 테스트계정은 문자 발송이 되지 않도록 수정
    if(!postAuthPhoneReq.getPhone().equals(&quot;01011111111&quot;)) {
        String code = smsClient.sendAuthorizationCode(postAuthPhoneReq);

        // Redis 저장 (데이터 쓰기 작업은 Redis 성공/실패 여부와 상관없이 동작해야 하므로)
        redisClient.setValue(postAuthPhoneReq.getPhone(), code, 300L);

        // MySQL 저장 (계정당 가장 최신의 요청 하나만 가지고 있어야 하므로 DELETE 후 INSERT)
        authorizationCodeRepository.deleteAllByPhone(postAuthPhoneReq.getPhone());
        AuthorizationCode authorizationCode = AuthorizationCode.builder()
                .phone(postAuthPhoneReq.getPhone())
                .authorizationCode(code)
                .build();
        authorizationCodeRepository.save(authorizationCode);
    }

    // Response
}</code></pre>
<p><strong>인증번호 확인</strong></p>
<pre><code class="language-java">private void checkAuthenticationCode(String phone, String code) {
    // Redis - 인증코드 조회 및 확인
    RedisDto redisDto = redisClient.getValue(phone);
    if(!redisDto.isSuccess() || redisDto.getValue() == null || !redisDto.getValue().equals(code)) {
        // MySQL - 인증코드 조회 및 확인 (Redis 장애 또는 Redis 데이터가 없는 경우)
        AuthorizationCode authorizationCode = authorizationCodeRepository.
                findByPhone(phone)
                .orElse(null);
        // 데이터가 존재하지 않을 경우
        if (authorizationCode == null) {
            throw new MemberException(ErrorCode.WRONG_AUTHENTICATION_CODE);
        }
        // 인증코드 유효기간이 끝난 경우
        if(LocalDateTime.now().isAfter(authorizationCode.getExpiredAt())) {
            throw new MemberException(ErrorCode.WRONG_AUTHENTICATION_CODE);
        }
        // 인증코드가 일치하지 않는 경우
        if(!authorizationCode.getAuthorizationCode().equals(code)) {
            throw new MemberException(ErrorCode.WRONG_AUTHENTICATION_CODE);
        }
    }
}</code></pre>
<h3 id="로그인로그아웃-개선">로그인/로그아웃 개선</h3>
<p><strong>로그인</strong></p>
<pre><code class="language-java">@Transactional
public PostLoginRes login(PostLoginReq postLoginReq) {
    // Validation: 계정 존재 여부 및 회원탈퇴 여부 확인
    Member member = memberRepository.findActiveByPhoneAndType(postLoginReq.getPhone(), MemberType.valueOf(postLoginReq.getType())).orElse(null);
    if(member == null) {
        throw new MemberException(ErrorCode.NOT_FOUND_EXCEPTION);
    }
    if(!member.getPassword().equals(SHA256.encrypt(postLoginReq.getPassword()))) {
        throw new MemberException(ErrorCode.WRONG_PASSWORD);
    }

    // Business Logic: 토큰 발급 및 Redis 저장
    TokenDto tokenDto = tokenProvider.generateToken(member);
    String key = member.getType() + member.getPhone(); // unique 확인은 phone + type이므로 이를 string으로 저장, 앞 7자리는 type으로 고정
    // Redis 토큰 저장 (데이터 쓰기 작업은 Redis 성공/실패 여부와 상관없이 동작해야 하므로)
    redisClient.setValue(key, tokenDto.getRefreshToken(), 30 * 24 * 60 * 60 * 1000L);
    refreshTokenRepository.deleteAllByPhoneAndMemberType(member.getPhone(), member.getType());
    RefreshToken refreshToken = new RefreshToken(
            member.getPhone(),
            member.getType(),
            tokenDto.getRefreshToken(),
            tokenProvider.getTokenExpirationAsLocalDateTime(tokenDto.getRefreshToken())
    );
    refreshTokenRepository.save(refreshToken);

    // Response
    return new PostLoginRes().toDto(tokenDto, member);
}</code></pre>
<p><strong>로그아웃</strong></p>
<pre><code class="language-java">@Transactional
public void logout(String atk, Member member) {
    // Business Logic
    String key = member.getType() + member.getPhone();

    // Redis 블랙리스트 토큰 저장 (데이터 쓰기 작업은 Redis 성공/실패 여부와 상관없이 동작해야 하므로)
    redisClient.deleteValue(key);
    redisClient.setValue(atk, &quot;logout&quot;, tokenProvider.getExpiration(atk));

    // MySQL 블랙리스트 등록
    refreshTokenRepository.deleteAllByPhoneAndMemberType(member.getPhone(), member.getType());
    Blacklist blacklist = new Blacklist(
            atk,
            tokenProvider.getTokenExpirationAsLocalDateTime(atk)
    );
    blacklistRepository.save(blacklist);

    // FCM 토큰 정보 삭제
    member.deleteToken();
    memberRepository.save(member);

    // Response
}</code></pre>
<p><strong>토큰 재발급</strong></p>
<pre><code class="language-java">@Transactional
public GetRefreshRes reissue(HttpServletRequest httpServletRequest) {
    // Validation: RTK 조회
    String rtk = httpServletRequest.getHeader(&quot;Authorization&quot;);
    tokenProvider.validateToken(rtk); // RTK 유효성 검증
    String key = tokenProvider.getType(rtk) + tokenProvider.getPhone(rtk);
    RedisDto redisDto = redisClient.getValue(key);
    // Redis 장애 또는 Cache Miss 시, MySQL Data 대체
    if(!redisDto.isSuccess() || redisDto.getValue() == null || !redisDto.getValue().equals(rtk)) {
        RefreshToken refreshToken = refreshTokenRepository
                .findByPhoneAndMemberType(tokenProvider.getPhone(rtk), MemberType.valueOf(tokenProvider.getType(rtk)))
                .orElse(null);
        if(rtk.isBlank()
                || refreshToken == null
                || (tokenProvider.getTokenExpirationAsLocalDateTime(rtk).isBefore(LocalDateTime.now()))
                || (tokenProvider.getTokenExpirationAsLocalDateTime(refreshToken.getToken()).isAfter(LocalDateTime.now())
                    &amp;&amp; !refreshToken.getToken().equals(rtk))) {
            throw new ApplicationException(ErrorCode.WRONG_TOKEN);
        }
    }

    Member member = memberRepository
            .findActiveByPhoneAndType(tokenProvider.getPhone(rtk), MemberType.valueOf(tokenProvider.getType(rtk)))
            .orElse(null);
    if(member == null) {
        throw new ApplicationException(ErrorCode.WRONG_TOKEN);
    }

    // Business Logic
    TokenDto tokenDto = tokenProvider.regenerateToken(member, rtk);
    String newRefreshToken = tokenDto.getRefreshToken();
    if(!newRefreshToken.equals(rtk)) {
        redisClient.setValue(key, newRefreshToken, tokenProvider.getExpiration(newRefreshToken));

        refreshTokenRepository.deleteAllByPhoneAndMemberType(member.getPhone(), member.getType());
        RefreshToken refreshToken = RefreshToken.builder()
                .token(newRefreshToken)
                .phone(member.getPhone())
                .memberType(member.getType())
                .build();
        refreshTokenRepository.save(refreshToken);
    }

    // Response
    return new GetRefreshRes().toDto(tokenDto.getAccessToken(), tokenDto.getRefreshToken());
}</code></pre>
<p><strong>ArgumentResolver</strong></p>
<pre><code class="language-java">@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    String authorization = webRequest.getHeader(&quot;Authorization&quot;);

    // 토큰 정보 유무 확인
    if (authorization == null) {
        throw new ApplicationException(ErrorCode.WRONG_TOKEN);
    }

    // 토큰 유효 여부 확인
    RedisDto redisDto = redisClient.getValue(authorization);
    // Redis 장애 또는 Cache Miss 시, MySQL Data 대체
    if(!redisDto.isSuccess() || redisDto.getValue() == null || !redisDto.getValue().equals(&quot;logout&quot;)) {
        Blacklist blacklist = blacklistRepository.findBlacklistByToken(authorization).orElse(null);
        // MySQL Data 존재 시, 로그아웃 Value 확인 및 처리 - ExpiredAt이 현재 시간보다 크면 블랙리스트로 처리
        if(blacklist != null &amp;&amp; blacklist.getExpiredAt().isAfter(LocalDateTime.now())) {
            throw new ApplicationException(ErrorCode.LOGOUT_TOKEN);
        }
    }
    // Redis Data 존재 시, 로그아웃 Value 확인 및 처리
    else {
        throw new ApplicationException(ErrorCode.LOGOUT_TOKEN);
    }

    // 토큰 유효성 검사
    tokenProvider.validateToken(authorization);

    // 토큰에서 사용자 정보 추출
    String phone = tokenProvider.getPhone(authorization);
    String type = tokenProvider.getType(authorization);

    // 사용자 정보 획득
    Member member = memberRepository.findActiveByPhoneAndType(phone, MemberType.valueOf(type)).orElse(null);
    if(member == null) {
        throw new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION);
    }

    // 사용자 정보 반환
    return  member;
}</code></pre>
<blockquote>
<p>작성자가 봐도 코드 퀄리티가 좋지 않다. 
리팩토링 중이므로, 위 코드에서는 FailOver 적용에 초점을 맞추어 코드를 보면 된다.</p>
</blockquote>
<h3 id="장애-상황-연출">장애 상황 연출</h3>
<p>Production 환경에서 장애 상황을 연출할 수 없으므로, 개발 환경에서 장애 상황을 연출하고 테스트를 진행했다. 현재 서버 비용 등의 이유로 별도의 개발 서버를 운영하고 있지 않다. 그렇기에, 로컬에서 도커 컨테이너를 이용해 정상 상황 및 장애 상황을 연출해서 확인했다.</p>
<p><strong>Redis 정상</strong></p>
<pre><code class="language-bash">Hibernate: 
    select
        member0_.member_id as member_i1_10_,
        member0_.created_at as created_2_10_,
        member0_.deleted_at as deleted_3_10_,
        member0_.modified_at as modified4_10_,
        member0_.grade as grade5_10_,
        member0_.is_advertisement as is_adver6_10_,
        member0_.is_privacy as is_priva7_10_,
        member0_.name as name8_10_,
        member0_.need_notification as need_not9_10_,
        member0_.password as passwor10_10_,
        member0_.phone as phone11_10_,
        member0_.school as school12_10_,
        member0_.state as state13_10_,
        member0_.token as token14_10_,
        member0_.type as type15_10_ 
    from
        member member0_ 
    where
        member0_.phone=? 
        and member0_.type=? 
        and member0_.state=? 
        and (
            member0_.deleted_at is null
        ) limit ?
2024-09-30 13:43:30.830  INFO 2749 --- [nio-8080-exec-5] c.s.gwalit.global.aop.LogAspect          : [GetRefreshRes com.selfrunner.gwalit.domain.member.service.AuthService.reissue(HttpServletRequest)]  execution time: 50ms</code></pre>
<p>Redis가 살아있을 때에는 RefreshToken을 확인하기 위해 데이터베이스로 트래픽이 발생하지 않음을 알 수 있다.</p>
<p><strong>Redis 장애</strong>
Redis 장애 상황 연출은 API 서버를 실행시킨 뒤, Redis 컨테이너를 종료시키고 요청을 보냈다.</p>
<pre><code class="language-bash">Hibernate: 
    select
        refreshtok0_.refresh_token_id as refresh_1_14_,
        refreshtok0_.created_at as created_2_14_,
        refreshtok0_.expired_at as expired_3_14_,
        refreshtok0_.member_type as member_t4_14_,
        refreshtok0_.phone as phone5_14_,
        refreshtok0_.token as token6_14_ 
    from
        refresh_token refreshtok0_ 
    where
        refreshtok0_.phone=? 
        and refreshtok0_.member_type=?
Hibernate: 
    select
        member0_.member_id as member_i1_10_,
        member0_.created_at as created_2_10_,
        member0_.deleted_at as deleted_3_10_,
        member0_.modified_at as modified4_10_,
        member0_.grade as grade5_10_,
        member0_.is_advertisement as is_adver6_10_,
        member0_.is_privacy as is_priva7_10_,
        member0_.name as name8_10_,
        member0_.need_notification as need_not9_10_,
        member0_.password as passwor10_10_,
        member0_.phone as phone11_10_,
        member0_.school as school12_10_,
        member0_.state as state13_10_,
        member0_.token as token14_10_,
        member0_.type as type15_10_ 
    from
        member member0_ 
    where
        member0_.phone=? 
        and member0_.type=? 
        and member0_.state=? 
        and (
            member0_.deleted_at is null
        ) limit ?
2024-09-30 13:41:36.119  INFO 2749 --- [nio-8080-exec-1] c.s.gwalit.global.aop.LogAspect          : [GetRefreshRes com.selfrunner.gwalit.domain.member.service.AuthService.reissue(HttpServletRequest)]  execution time: 364ms</code></pre>
<p>Redis 장애로 인해, 데이터베이스 쿼리가 발생된 것을 확인할 수 있다.</p>
<h1 id="정리">정리</h1>
<p>Redis에 대하여 다음과 같은 두 개의 관점으로만 섣불리 도입을 결정했었다.</p>
<ol>
<li>DB I/O 최소화</li>
<li>Timeout을 통한 데이터 삭제 가능
위 관점으로만 Redis를 도입하면서 사용자 경험의 향상을 목표로 했지만, 정작 Redis 장애로 인해 서비스 전체의 중단은 고려하지 않았었다. 
서비스 개발 시에는 <strong>서비스의 안정성이 최우선순위</strong>이며, 이후의 사용자 경험이 따라와야 한다는 관점을 놓치지 않아야 한다.</li>
</ol>
<p>Redis는 인메모리 DB이며, 스냅샷이 존재하지만 데이터 손실 가능성을 늘 유의해야 한다. MySQL로 대체 작동할 수 있도록 <strong>FailOver 방식을 적용하는 것이 필수적</strong>이다. 
과릿은 이번 FailOver 방식을 통해서, Redis 장애 시에도 서비스 전체에 문제가 생기는 상황을 방지할 수 있게 되었다.</p>
<p>위와 같이 FailOver를 도입했음에도 불구하고 아직 일부 문제들이 남아있다.
<strong>MySQL에는 데이터가 존재하지 않음에도 불구하고 Redis에 데이터가 남아 있어 해당 데이터를 반환하는 상황</strong>이 그 예이다.
위와 같은 상황은 현재의 인프라에서는 Redis의 데이터가 만료되어 삭제되기를 기다려야 한다. Redis 장애로 인해 복구되는 과정에서 Redis와 MySQL의 데이터 싱크는 어떻게 맞추어갈 수 있을지를 고민하고 적용하는게 다음 도전이 될 것이다.</p>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://toss.tech/article/25301">캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁 Toss</a></li>
<li><a href="https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0">Redis 스냅샷 오류 Velog</a></li>
<li><a href="https://docs.spring.io/spring-data/redis/reference/redis/template.html">Redis Template</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS Certified Solutions Architect – Associate 시험 응시 및 합격 후기]]></title>
            <link>https://velog.io/@dl-00-e8/SAA-C03-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/SAA-C03-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Fri, 20 Sep 2024 09:26:15 GMT</pubDate>
            <description><![CDATA[<p>AWS 대학생 커뮤니티인 <a href="https://ausg.me/">AUSG</a> 8기에 합격해 현재 활동하고 있다.
다양한 사람들의 경험을 듣고 또 공유할 수 있다는 점 등이 너무 매력적인 커뮤니티이다. 시간이 된다면 회고와 함께 AUSG 합격 후기도 들고 올 예정이다.</p>
<p>AUSG은 빅챗 외에 스터디가 진행된다. 나는 AWS SAA 스터디에 참여해서 시험을 준비했다. 이번 SAA 스터디는 3주 안에 시험을 합격하는 것을 목표로 일주일에 한 번 1시간 내외동안 각자가 학습하는 과정에서 몰랐던, 공유하고 싶었던 내용을 공유하는 방식으로 진행했다.</p>
<p>나는 9월 19일 대면 시험을 접수하고 준비했다. 비대면으로 시험을 보기 위한 환경을 만드는 것이 까다로운 편이었다는 점과 함께 학교 30분 거리에 시험을 응시할 수 있는 장소가 있어, 학교에서 가서 응시를 하는 것이 더 좋을 것이라는 판단때문이었다.
실제로, 시험장을 가니 꽤나 절차가 복잡했다. </p>
<ol>
<li>신분증/신용(체크)카드 확인</li>
<li>동의서 서명</li>
<li>액세서리를 포함하여 모든 개인장비를 사물함 보관</li>
<li>사진 촬영</li>
<li>주머니, 양말 등 검사
엄청나게 빡빡하게 이루어진 것은 아니었지만, 위 과정을 진행했었고, 이렇다보니 비대면으로 응시한다면 얼마나 더 까다로울까라는 생각이 든다.</li>
</ol>
<h3 id="학습">학습</h3>
<p>먼저 기초적인 내용을 학습하기 위해 3시간 길이의 <a href="https://www.youtube.com/watch?v=zBwikdaBqGA&amp;t=8878s">유튜브 영상</a>을 시청했다. 시청한 이후에, 별도의 추가 개념 학습 없이 바로 문제 풀이를 진행했다.</p>
<p>Udemy가 아닌 <a href="https://skillcertpro.com/">Skillcertpro</a>의 문제집을풀었다. 특별한 이유는 아니고, 지인이 해당 계정으로 AWS SAA 코스를 구입했다고 하였기에 활용할 수 있어서이다.
해당 코스에서는 17세트의 문제집과 1개의 치팅 시트를 제공해주는데, 나는 10개 정도의 문제집을 풀었고, 그 중 절반은 2회독을 진행했다.</p>
<p>이와 별개로, 덤프 문제 18.45버전을 활용하여 약 200문제 정도로 추가로 풀어보았던 것 같다.</p>
<blockquote>
<p>Skillcertpro의 코스가 가지고 있는 장점을 뽑아본다면, 다음과 같다.
<strong>1. 한 문제를 풀고 <code>check</code>버튼으로 정답 및 해설을 볼 수 있다.</strong>
덤프의 경우, 문제를 풀고 나서 답을 확인하기 위해서 <a href="https://www.examtopics.com/exams/amazon/aws-certified-solutions-architect-associate-saa-c03/">ExamTopics</a>를 활용해야 하는데 이는 정답이 공개된 것이 아니라 답이 무엇인지에 대한 토론이 일어나 오히려 헷갈리는 부분이 생겼다.
<strong>2. 평균 점수 등을 계산해준다. **
응시한 사용자의 평균 점수와 내 점수를 시험 뒤에 보여주는데, 이게 내 학습량을 보여주는 지표가 되어서 개인적으로는 효과적이었다.
**3. 치팅 시트</strong>
초반에는 문제를 풀다가 아예 모르는 서비스 또는 개념이 나왔을 때 AWS Docs와 구글링을 통해서 찾아보고 정리했다. 그러나, 정리하고 나서 다시 보았을 때 문제에 대한 설명 없이 단문장으로 정리하다 보니 오히려 <code>무슨 내용이었지?</code>하고 다시 찾는 비효율이 발생했었다. 치팅 시트는 이러한 부분을 커버할 수 있는 PDF파일이어서 유용하게 활용했다.</p>
</blockquote>
<p>문제를 풀면서 틀리거나, 헷갈려서 고민했던 문제들과 관련된 내용들을 노션에 한 페이지로 정리했다. 마지막 당일날은 문제를 추가적으로 더 풀기보다는 이 노션에 있는 내용들을 암기하고자 노력했다.</p>
<h3 id="결과">결과</h3>
<p>시험을 15:02분에 입실하여 16:58에 퇴실했다. 안내듣는 시간과 이런 것들을 제외하면 130분 중에 40분 정도 남았을 때 시험을 마무리했다.
많이 놀랐던 점은 Credly로 배지 등록하라는 합격 결과가 19시경에 메일로 날라왔다는 것이다. 합격 여부를 포함한 2번째 이미지와 같이 상세 점수 등의 세부적인 시험 결과는 새벽 3시경에 메일로 받아볼 수 있었다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/5801b82f-59f9-4eec-901e-d762cf3e0e18/image.png" alt="">
<img src="https://velog.velcdn.com/images/dl-00-e8/post/f462c423-3393-4e3e-8ea3-0235702958b2/image.png" alt=""></p>
<h3 id="후기">후기</h3>
<p>한국어로 시험을 응시했는데, 결과적으로는 원문 보기 기능을 통해서 영어로 풀었던 문제가 더 많았다. 기본적으로 영어로 문제를 풀어왔기 때문이기도 하지만, 번역된 한국어의 퀄리티가 생각보다 좋지 않았기 때문이다.
만약, 이 글을 보시고 응시하러 가신다면 문제를 풀 때 원문 보기 기능을 통해 영어로 보시는 것을 추천한다.</p>
<p>AWS SAA를 따면서 정말 다양한 AWS 제품들에 대해서 알게 되었다. 
기존에도 사용해보고 싶었던 Amplify, EKS, RedShift들을 조금 더 알아볼 수 있었다. SW마에스트로에서 AWS 실습을 하면서 경험한 GuardDuty등도 학습하게 되어 반가웠다.
신기한 서비스로는 SnowFamily, Macie등이 있었다. 온프레미스 환경의 데이터를 하드웨어 장치를 이용해 옮기는 기능을 제공하는 SnowFamily는 다양한 환경을 커버할 수 있는 AWS 커버력을 보여주는 듯 했다.</p>
<p>짧은 기간동안 준비했지만, 유의미한 결과를 받아볼 수 있어 굉장히 좋은 경험이었다. 50% 바우처를 받았으니, AWS 자격증 중 따보고 싶은 또 다른 자격증을 찾아보아야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RDB에서 Json 타입 다뤄보기]]></title>
            <link>https://velog.io/@dl-00-e8/RDB%EC%97%90%EC%84%9C-Json-%ED%83%80%EC%9E%85-%EB%8B%A4%EB%A4%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/RDB%EC%97%90%EC%84%9C-Json-%ED%83%80%EC%9E%85-%EB%8B%A4%EB%A4%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 27 Aug 2024 09:43:49 GMT</pubDate>
            <description><![CDATA[<p>과거 과릿을 개발하던 당시, Json 타입을 도입했다 다양한 이유들(Ex: 조건문, 정렬, 부분 수정 가능성 등)로 인해, Json 타입의 칼럼을 여러 개의 칼럼들로 분리하게 되었다.
최근, 다시 Json 타입을 사용할만한 상황이 생겼고, 과거와 같은 행동을 반복하지 않기 위해 관련된 내용을 찾고 정리한다.</p>
<h1 id="json-타입-사용하기">Json 타입 사용하기</h1>
<h2 id="spring-boot--mysql">Spring Boot + MySQL</h2>
<p>먼저, Json 타입을 어떻게 Spring Boot, MySQL 환경에서 사용할 수 있는지 정리해보려고 한다.</p>
<p>먼저, Json 타입을 아래와 같이 저장하고 사용하는 것을 목표로 한다고 봐보자.</p>
<pre><code class="language-json">{
  &quot;city&quot;: &quot;서울시&quot;,
  &quot;division_list&quot;: [
    {
      &quot;district&quot;: &quot;광진구&quot;
    },
    {
      &quot;district&quot;: &quot;마포구&quot;
    }
  ]
}</code></pre>
<h3 id="dto">DTO</h3>
<pre><code class="language-java">public record LocationRequest(
    String city,
    List&lt;DivisionRequest&gt; divisionList
) {
    public record DivisionRequest(
            String district
    ) {
    }
}</code></pre>
<h3 id="entity">Entity</h3>
<pre><code class="language-java">@Getter
@Entity
@Table(name = &quot;location&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Location {

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

    @Column(name = &quot;city&quot;, columnDefinition = &quot;json&quot;)
    @JdbcTypeCode(SqlTypes.JSON)
    private City city;

    @Builder
    public Location(City city) {
        this.city = city;
    }
}</code></pre>
<p>Entity에서 City라는 객체를 활용하고 있는 것을 볼 수 있다.
이는 아래와 같이 작성되어 있다.</p>
<pre><code class="language-java">public record City(
        String city,
        List&lt;Division&gt; divisionList
) {

    public record Division(
            String district
    ) {
    }
}</code></pre>
<blockquote>
<p><strong>Json 타입을 명시하는 법</strong><br>hibernate 버전이 6 미만일 경우, 아래와 같이 사용한다.</p>
</blockquote>
<pre><code class="language-java">@TypeDef(name = &quot;json&quot;, typeClass = JsonStringType.class)
public class Entity {
    @Column(name = &quot;column&quot;, columnDefinition = &quot;json&quot;)
    @Type(type = &quot;json&quot;)
    private Json column;</code></pre>
<p>hibernate 버전이 6 이상일 경우는, <code>@JdbcTypeCode(SqlTypes.JSON)</code>만 추가해주면 된다.</p>
<h3 id="api-테스트하기">API 테스트하기</h3>
<p>위와 같이, 설정한 이후에 Postman을 통해 API 요청을 보내면 아래와 같이 결과를 받아볼 수 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/36f17c63-55f4-461d-98a7-664d3f5b59f9/image.png" alt=""><img src="https://velog.velcdn.com/images/dl-00-e8/post/35e1ca1f-47c6-4a90-b199-ca33f3cf513c/image.png" alt=""></p>
<h2 id="자유도-높게-사용하는-법">자유도 높게 사용하는 법</h2>
<p>Json 타입을 사용하는 예시를 보면서, 아래와 같은 궁금증을 가진 분들도 있을 것이다.
<code>DTO의 변수명을 지정해 준다면, 사용자의 입력 값이 value에만 들어가게 되어 Json 타입을 제대로 활용하지 못하는 거 아닌가?</code>
이러한 자유도는 아래와 같은 방식으로 개발하여 해결할 수 있다.</p>
<h3 id="dto-1">DTO</h3>
<pre><code class="language-java">public record FreeLocationRequest(
        Map&lt;String, List&lt;String&gt;&gt; city
) {
}</code></pre>
<h3 id="entity-1">Entity</h3>
<pre><code class="language-java">@Getter
@Entity
@Table(name = &quot;location&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Location {

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

    @Column(name = &quot;free_city&quot;, columnDefinition = &quot;json&quot;)
    @JdbcTypeCode(SqlTypes.JSON)
    private Map&lt;String, List&lt;String&gt;&gt; freeCity;

    @Builder
    public Location(Map&lt;String, List&lt;String&gt;&gt; freeCity) {
        this.freeCity = freeCity;
    }
}</code></pre>
<h3 id="api-테스트하기-1">API 테스트하기</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/49d9eeec-2235-44ce-94f0-d81f1b2ced4c/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/68f9ee4c-ce96-4992-b5f3-6004a414014a/image.png" alt=""></p>
<blockquote>
<p>위와 같은 방식에서, <code>Map&lt;String, List&lt;String&gt;&gt;</code>이 아닌 <code>Map&lt;String, Obejct&gt;</code>로 활용한다면, 더 높은 자유도를 가질 수 있다. 다만, 인덱스를 거는 등의 성능을 개선하는 과정에서 이득을 보기에는 어렵기에, 사용하는 과정에서 많은 고민이 필요할 수 있다.</p>
</blockquote>
<h1 id="json-톺아보기">Json 톺아보기</h1>
<p>MySQL 5.7.8 이후 버전부터 공식적으로 Json 타입이 지원되고 있지만, 기존에는 Json 형태의 데이터를 Text로 변환하여 저장하고, 활용해오기도 했었다.
그렇기에, 둘 중 어떤 타입을 사용하는 것이 더 좋을지를 기준점을 잡으면 앞으로 활용할 때 더욱 빠르게 판단할 수 있을 것이라 생각해, 과거 <a href="https://medium.com/daangn/json-vs-text-c2c1448b8b1f">당근 기술블로그</a>를 읽었던 기억이 나 관련 내용을 기반으로 정리해보려고 합니다.</p>
<h2 id="json-타입과-text-타입의-차이점">Json 타입과 Text 타입의 차이점</h2>
<h3 id="인덱스">인덱스</h3>
<p><strong>Text는 Json 타입의 정보를 문자열로 저장하기에, 데이터 내의 특정 필드를 기준으로 하는 인덱스를 걸 수 없다. 이와 반대로, Json 타입은 특정 필드를 인덱스로 활용할 수 있다.</strong></p>
<p>Json 타입에 대해서 인덱스를 걸어줄 때는 직접 거는 방식과 가상 칼럼을 활용하는 방식 등 총 2가지 방식으로 적용할 수 있다.
샘플은, 위에서 다루었던 시/군/구 Json 데이터를 기준으로 하겠다.</p>
<h4 id="직접-걸기">직접 걸기</h4>
<pre><code class="language-sql">CREATE INDEX idx_city ON location ((CAST(city-&gt;&gt;&#39;$.city&#39; AS CHAR(255))));</code></pre>
<p>위와 같이 인덱스를 건 이후, 아래와 같이 인덱스를 타는 조회 쿼리를 <code>EXPLAIN</code>을 활용해보면, 인덱스를 타고 있는 것을 알 수 있다.</p>
<pre><code class="language-sql">EXPLAIN SELECT * FROM location WHERE city-&gt;&gt;&#39;$.city&#39; = &#39;서울시&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/79741389-dd76-4eba-a106-094c59baeb4e/image.png" alt=""></p>
<h4 id="가상-칼럼-활용하기">가상 칼럼 활용하기</h4>
<p>Json 타입에 인덱스를 걸어줄 때, <code>GENERATED COLUMN</code>을 사용해서 걸 수 있다.</p>
<pre><code class="language-sql">ALTER TABLE location ADD COLUMN city_virtual VARCHAR(255) GENERATED ALWAYS AS (city-&gt;&gt;&#39;$.city&#39;) VIRTUAL;
CREATE INDEX city_idx ON location (city_virtual);</code></pre>
<blockquote>
<p>Q. <code>GEREATED ALWAYS AS</code>, <code>VIRTUAL</code>은 뭐야?
A. <code>GENERATED ALWAYS AS</code> 는 열이 생성된 열이라는 것을 의미한다. (city-&gt;&gt;&#39;$.city&#39;) 는 JSON 데이터에서 &#39;city&#39; 키의 값을 추출하는 표현식이다. <code>VIRTUAL</code>은 이 열이 실제로 저장되지 않고 필요할 때 계산됨을 나타냅니다.</p>
<p>실제로 저장하면서, 활용하고 싶다면 <code>STORED</code>를 사용하면 된다.</p>
<ul>
<li>VIRTUAL: 실제로 저장되지 않고 필요 시에만 계산</li>
<li>STORED: 데이터가 실제로 저장되고 업데이트 시 계산 (조회 성능에서 상대적으로 유리)</li>
</ul>
</blockquote>
<p>MySQL에서는 두 가지 유형의 생성된 열이 있습니다:</p>
<p>VIRTUAL: 데이터는 저장되지 않고 조회 시 계산됩니다.
STORED: 데이터가 실제로 저장되고 업데이트 시 계산됩니다.</p>
<p>위와 같이 가상 칼럼을 생성하고 인덱스를 걸면, 아래와 같이 가상 칼럼이 생성된 것을 확인할 수 있다. 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/c76a6f59-7efe-49e9-b7e2-db6eeb69dc07/image.png" alt="">
아래와 같이 인덱스를 타는 조회 쿼리를 <code>EXPLAIN</code>을 활용해보면, 인덱스를 타고 있는 것을 확인할 수 있다.</p>
<pre><code class="language-sql">EXPLAIN SELECT * FROM location WHERE city_virtual = &#39;서울시&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/f3a61737-4e55-495c-a2f4-36b44c6c4e22/image.png" alt=""></p>
<h4 id="직접-인덱스와-가상-칼럼-장단점-비교">직접 인덱스와 가상 칼럼 장단점 비교</h4>
<p><strong>JSON에 직접 인덱스를 사용할 경우, 가지는 장점</strong></p>
<ul>
<li>추가적인 칼럼 없이 JSON 데이터에 직접 접근할 수 있다.</li>
<li>테이블 구조를 변경하지 않고도 인덱스를 추가할 수 있다.</li>
<li>저장 공간을 약간 더 절약할 수 있다.</li>
</ul>
<p><strong>가상 칼럼 사용이 가져오는 장점</strong></p>
<ul>
<li>쿼리 작성이 더 간단해질 수 있다.</li>
<li>JSON 데이터의 특정 부분을 별도의 칼럼처럼 다룰 수 있어 가독성이 좋다.</li>
<li>다른 테이블과의 조인 등에서 활용하기 쉽다.</li>
</ul>
<blockquote>
<p>위와 같은 장점을 기반으로 했을 때, 나는 가상 칼럼을 활용하여 인덱스를 거는 것이 더 많은 효용성을 가진다고 생각한다.</p>
</blockquote>
<h3 id="in-place-업데이트">In-place 업데이트</h3>
<p>In-place 업데이트란 데이터베이스에서 데이터를 수정할 때, 해당 데이터가 저장된 원래 위치에서 직접 변경을 수행하는 방식으로, 데이터를 새로운 위치로 이동시키거나 전체 레코드를 다시 쓰지 않고, 필요한 부분만 효율적으로 수정하는 기법을 말한다.</p>
<p><strong>Text타입은 전체 레코드를 다시 써야하지만, 이와 반대로 Json타입은 특정 필드의 데이터만 직접 변경할 수 있다. 즉, Json 타입은 In-place 업데이트를 사용할 수 있다.</strong></p>
<p>이제, In-place 업데이트의 장점과 사용법 등을 정리한다.</p>
<h4 id="in-place-업데이트-장점">In-place 업데이트 장점</h4>
<ul>
<li>성능 향상: 전체 데이터를 다시 쓰지 않아 I/O 작업이 줄어든다.</li>
<li>동시성 개선: 다른 트랜잭션의 블로킹 시간이 줄어든다.</li>
<li>리소스 효율성: 메모리와 디스크 사용이 최적화된다.</li>
</ul>
<h4 id="json타입은-어떻게-in-place-업데이트가-가능할까">Json타입은 어떻게 In-place 업데이트가 가능할까?</h4>
<ul>
<li>MySQL에서 Json타입 컬럼에 대해 내부적으로 Binary Json (=BSON) 저장 포맷으로 변환한다. </li>
<li>키-값 쌍을 별도의 내부 구조로 저장하며에, 별도의 내부 구조에서는 데이터 유형, 오프셋, 길이 등의 메타 데이터도 포함된다.</li>
<li>JSON 내의 각 요소에 대해 약간의 추가 공간을 할당해놓아, 이 공간을 활용해 전체 문서를 재작성하지 않고 일부만 수정이 가능하도록 한다.</li>
<li>MySQL에서는<code>JSON_SET</code>, <code>JSON_REPLACE</code>, <code>JSON_REMOVE</code> 등의 함수를 제공하고 있다. (출처: <a href="https://dev.mysql.com/doc/refman/8.4/en/json-functions.html">MySQL Json Funtion Docs</a>)</li>
</ul>
<p>위와 같은 특징을 기반으로, MySQL에서는 Json타입에 대해 In-place 업데이트를 지원한다.</p>
<h4 id="in-place-업데이트-쿼리">In-place 업데이트 쿼리</h4>
<pre><code class="language-sql">UPDATE mytable
SET json_column = JSON_SET(json_column, &#39;$.key&#39;, &#39;new_value&#39;)
WHERE id = 1;</code></pre>
<h3 id="mysql-server-side에서-필드의-값을-조회-및-변경-가능">MySQL server-side에서 필드의 값을 조회 및 변경 가능</h3>
<p>처음에 기술 블로그에서, 이 표현을 듣고 무슨 의도인지를 파악하는데 일부 시간이 걸렸다.
정리하자면, 아래와 같은 의미라고 판단했다.</p>
<p><strong>어플리케이션(=API 서버)에서 데이터를 조회하여, 수정 후 재저장하는 방식이 아닌, MySQL 서버 내에서 <code>JSON_SET</code>, <code>JSON_REPLACE</code>, <code>JSON_REMOVE</code> 등의 함수를 활용해서, 직접 데이터를 원하는 방식으로 조회 및 변경이 가능하다는 의미이다.</strong></p>
<p>다만, JPA(+ QueryDSL)에서는 Json 관련 함수를 지원하지 않으므로, Custom Function을 활용해서 사용하고자 하는 함수들을 정의해놓아야 한다는 점이 불편한 점으로 느껴진다.
jOOQ에서는 JSON 관련 함수를 지원하고 있는 것으로 확인되어, Raw SQL을 JPA 환경에서 사용하기 보단, jOOQ를 도입하는 것이 더 현명한 방식일 것 같다. (참고: <a href="https://www.jooq.org/doc/latest/manual/sql-building/column-expressions/json-functions/">jOOQ JSON functions</a>)</p>
<h2 id="json-타입을-사용해야-하는-요건">Json 타입을 사용해야 하는 요건</h2>
<p>당근에서 제시하는 요건과 내가 직접 사용해보며 겪은 Json 타입을 사용해야 하는 요건을 정리해본다.</p>
<p><strong>당근에서 제시하는 요건</strong></p>
<ul>
<li>JSON 데이터의 특정 필드만 접근이 가능해야 한 경우</li>
<li>JSON 데이터의 특정 필드(고정 길이 필드)만 자주 업데이트되는 경우</li>
<li>JSON 데이터의 특정 필드로 인덱스 생성이 필요한 경우</li>
</ul>
<p><strong>내가 생각하는 요건</strong></p>
<ul>
<li>JSON 데이터의 특정 필드를 기반으로 조건/정렬 등의 필터링이 필요한 경우</li>
<li>JSON 데이터의 업데이트 빈도가 매우 낮을 경우</li>
<li>부득이하게 NoSQL 형태의 데이터를 RDB 상황에서 같이 사용해야 할 경우</li>
</ul>
<h1 id="공작소에-json-타입-적용기">공작소에 Json 타입 적용기</h1>
<p>위와 같이, 내용을 정리한 이후 실제로 Json 타입을 적용을 고민하는 상황이 생겼다. 해당 적용기는 적용 완료 후 재정리할 예정이다.</p>
<blockquote>
<p>관련 코드는 Github(<a href="https://github.com/dl-00-e8/study-development/tree/main/hibernate-type">샘플코드</a>/<a href="">공작소</a>)에서 확인할 수 있습니다.</p>
</blockquote>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://github.com/vladmihalcea/hypersistence-utils">Hibernate-type</a></li>
<li><a href="https://medium.com/daangn/json-vs-text-c2c1448b8b1f">당근 기술블로그</a></li>
<li><a href="https://danawalab.github.io/spring/2022/08/05/Jpa-Json-Type.html">다나와 기술블로그</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.4/en/json-functions.html">MySQL Json Function Docs</a></li>
<li><a href="https://www.jooq.org/doc/latest/manual/sql-building/column-expressions/json-functions/">jOOQ JSON functions</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cloud PubSub 도입기 (Java/Spring Boot, Go/Echo)]]></title>
            <link>https://velog.io/@dl-00-e8/Cloud-PubSub-%EB%8F%84%EC%9E%85%EA%B8%B0-JavaSpring-Boot-GoEcho</link>
            <guid>https://velog.io/@dl-00-e8/Cloud-PubSub-%EB%8F%84%EC%9E%85%EA%B8%B0-JavaSpring-Boot-GoEcho</guid>
            <pubDate>Tue, 20 Aug 2024 11:58:34 GMT</pubDate>
            <description><![CDATA[<p>회사에서 Cloud PubSub을 활용해 대용량 메일/문자 발송 서비스를 개발하게 되어, 관련 내용을 허락을 받아 정리한다.</p>
<h1 id="cloud-pubsub">Cloud PubSub</h1>
<p>Cloud PubSub은 메시지를 생성하는 서비스를 해당 메시지를 처리하는 서비스에서 분리하는 확장 가능한 <strong>비동기 메시징 서비스</strong>이다.</p>
<p>주요 특징은 아래와 같다.</p>
<ul>
<li><strong>게시자(Publisher)</strong>와 <strong>구독자(Subscriber)</strong>라는 이벤트 제작자 및 소비자 시스템으로 만들어진다.<ul>
<li>게시자는 동기식 리모트 프로시져 콜(RPC)가 아닌 이벤트를 브로드 캐스트로 구독자와 <strong>비동기적</strong>으로 통신</li>
</ul>
</li>
<li>비동기적으로 <strong>100밀리초의 지연시간</strong>으로 통신할 수 있다.</li>
<li>구독 서버가 다운된 상황에도 해당 메시지들이 큐에 쌓여 있기에, 서버 재시작 이후 <strong>순차적으로 처리</strong>할 수 있다.</li>
<li>Apache Kafka와 달리 파티션 기반 메시징이 아닌 <strong>메시지당 동시 로드 방식</strong>을 사용한다.<ul>
<li>개별 메시지를 구독자 클라이언트에 ‘임대’한 다음 지정된 메시지가 성공적으로 처리되었는지 주기적으로 확인하는 방식</li>
</ul>
</li>
</ul>
<h2 id="cloud-pubsub-구조">Cloud PubSub 구조</h2>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/76b7666b-7f05-41cc-9f2b-3c5a553eb87a/image.png" alt=""></p>
<ul>
<li>Publisher: 게시자 또는 제작자라고 칭하며, 특정 주제에 대한 메시지를 만들어 메시징 서비스로 전송(게시)하는 역할이다.</li>
<li>Message: 서비스를 통해 이동하는 데이터</li>
<li>Topic: 주제라고 칭하며, 메시지 피드를 나타내는, 이름이 지정된 항목이다.</li>
<li>Schema: Pub/Sub 메시지의 데이터 형식을 제어하는 이름이 지정된 항목이다.</li>
<li>Subscription: 구독이라고 칭하며, 특정 주제의 메시지 수신 의향을 나타내는, 이름이 지정된 항목이다.</li>
<li>Subscriber: 구독자 또는 소비자라고 칭하며, 지정한 구독에 대한 메시지를 수신한다.</li>
</ul>
<h2 id="cloud-pubsub-워크-플로우">Cloud PubSub 워크 플로우</h2>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/1cf0671e-c924-4bf2-9429-3683d4719932/image.png" alt=""></p>
<ol>
<li>Publisher가 PubSub Topic에 Message를 전송한다.</li>
<li>Message가 Storage에 기록된다.</li>
<li>Storage에 Message를 기록하는 것과 함께 PubSub이 해당 Topic의 모든 연결된 Subscription에 Message를 전달한다. (구독은 단일 구독일 수도, 다중 구독일 수도 있다.)</li>
<li>Subscription이 Message를 연결된 Subsriber에게 전송한다.</li>
<li>Subscriber가 Message 처리 확인 여부를 PubSub으로 전송한다.</li>
<li>각 Subscription에 대해 하나 이상의 Subscriber가 Message를 확인하면 PubSub이 Message를 Storage에서 삭제한다.</li>
</ol>
<blockquote>
<p>‘가상 면접 사례로 배우는 대규모 시스텀 설계 기초’ 책에서 PubSub 구조를 읽은 적이 있는데, 해당 책에서 칭하는 소비자 그룹이 여기서의 Subscription이며, 소비자 그룹 내 각각의 소비자가 Subscriber로 이해하면 될 것이다.</p>
</blockquote>
<h2 id="pubsub-게시-및-구독-패턴">PubSub 게시 및 구독 패턴</h2>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/8851cdb7-ec58-4641-8875-47cd814e2150/image.png" alt=""></p>
<ul>
<li>Many-to-one(다대일/팬인)<ul>
<li>여러 게시자 애플리케이션이 단일 주제로 메시지를 게시</li>
</ul>
</li>
<li>Many-to-many(다대다/부하 분산)<ul>
<li>단일 또는 여러 게시자 애플리케이션이 단일 주제로 메시지를 게시하고, 단일 구독에 연결되어 있어, 여러 개의 구독자 애플리케이션에 연결</li>
<li>여러 구독자를 사용해서 규모에 맞게 메시지를 처리하는 방식에 적합</li>
</ul>
</li>
<li>One-to-many(일대다/팬아웃)<ul>
<li>단일 또는 여러 게시자 애플리케이션이 단일 주제로 메시지 게시, 여러 구독에 연결</li>
<li>동일한 메시지 집합에서 여러 다른 데이터 작업을 수행하는 경우 적합</li>
</ul>
</li>
</ul>
<blockquote>
<p>대용량 메일/문자 발송 서비스를 구축하게 되었으므로, 여기서는 부하 분산 패턴을 사용할 것이다.</p>
</blockquote>
<h2 id="구독-개요-pullpush내보내기">구독 개요 (Pull/Push/내보내기)</h2>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/e946970f-1f27-48d9-a242-fbd9c37dd00b/image.png" alt=""></p>
<p>(출처: <a href="https://tachingchen.com/blog/google-cloud-pubsub-pull-subscription/">tachingchen 블로그</a> / 2024-06-24)</p>
<p><strong>구독 유형</strong></p>
<ul>
<li><strong>Pull</strong>: 구독자 클라이언트를 사용하여 PubSub 서버에서 메시지를 요청</li>
<li><strong>Push</strong>: PubSub 서버를 사용하여 구독자 애플리케이션에 메시지 전송을 요청</li>
<li><strong>내보내기 구독</strong>: 메시지를 Google Cloud 리소스로 직접 내보낼 수 있다.</li>
</ul>
<blockquote>
<p>내보내기는 GCP 문서에는 존재하지만, Google Cloud 리소스로 직접 내보내는 경우에 대해서만 지원하는 것으로 보이며, 관련한 자세한 레퍼런스를 아직 찾지 못했다.</p>
</blockquote>
<p><strong>구독 유형별 비교</strong></p>
<table>
<thead>
<tr>
<th>기능</th>
<th>Pull(가져오기)</th>
<th>Push</th>
<th>내보내기</th>
</tr>
</thead>
<tbody><tr>
<td>사용 사례</td>
<td>- 대량 메세지 <br> - 메시지 처리의 효율성과 처리량이 대단히 중요 <br> - 자체 서명되지 않은 SSL 인증서가 있는 공개 HTTPS 엔드포인트를 설정할 수 없는 환경</td>
<td>- 여러 주제를 같은 웹훅으로 처리해야 함 <br> - App Engine 표준 및 Cloud Functions 구독자 <br> - Google Cloud 종속 항목(사용자 인증 정보와 클라이언트 라이브러리)을 설정하기 어려운 환경</td>
<td>- 메시지가 초당 수백만 개까지 확장될 수 있는 대량 메시지 <br> - 메시지가 추가 처리 없이 Google Cloud 리소스에 직접 전송됨</td>
</tr>
<tr>
<td>엔드포인트</td>
<td>사용자 인증 정보를 증명한 인터넷상의 모든 기기는 Pub/Sub API를 호출할 수 있음</td>
<td>- 공개 웹에 액세스 가능한, 자체 서명되지 않은 인증서가 있는 HTTPS 서버 <br> - 수신 엔드포인트는 PubSub 구독과 분리될 수 있으며, 따라서 여러 구독의 메시지가 단일 엔드포인트에 전송됨</td>
<td>- BigQuery 구독의 BigQuery 데이터 세트 및 테이블 <br> - Cloud Storage 구독의 Cloud Storage 버킷</td>
</tr>
<tr>
<td>부하 분산</td>
<td>-여러 구독자가 같은 &#39;공유&#39; 구독에 가져오기 호출을 보낼 수 있음 <br> - 각 구독자는 메시지 하위 집합을 수신함</td>
<td>push 엔드포인트가 부하 분산기가 될 수 있음</td>
<td>PubSub 서비스가 자동으로 부하를 분산</td>
</tr>
<tr>
<td>구성</td>
<td>구성할 필요가 없음</td>
<td>- 구독자와 같은 프로젝트에 있는 App Engine 앱은 구성할 필요가 없음 <br> - 푸시 엔드포인트 확인은 Google Cloud 콘솔에서 필요하지 않음 <br> -엔드포인트는 DNS 이름을 통해 연결할 수 있으며, SSL 인증서가 설치되어 있어야 함</td>
<td>- 적절한 권한으로 구성된 BigQuery 구독에 대해 BigQuery 데이터 세트와 테이블이 있어야 함 <br> - 적절한 권한으로 구성된 Cloud Storage 구독에 대한 Cloud Storage 버킷이 있어야 함</td>
</tr>
<tr>
<td>흐름 제어</td>
<td>구독자 클아이언트가 전달 속도를 조절함. 구독자는 확인 기한을 동적으로 수정하며, 따라서 메시지 처리에 걸리는 시간이 임의로 길어질 수 있음</td>
<td>PubSub 서버가 자동으로 흐름 제어를 구현함. 클라이언트 측에서 메시지 흐름을 처리할 필요는 없지만 HTTP 오류를 되돌려 보내 클아이언트가 현재 메시지 부하를 처리할 수 없음을 표시할 수는 있음</td>
<td>PubSub 서버가 Google Cloud 리소스에 대한 메시지 쓰기를 최적화하기 위해 자동으로 흐름 제어를 구현</td>
</tr>
<tr>
<td>효율성 및 처리량</td>
<td>일괄 전송, 확인, 대량의 동시 소비가 가능해 낮은 CPU와 대역폭에서도 높은 처리량을 구현함. 메시지 전송 시간 최소화를 위해 적극적인 폴링을 사용하면 효율성이 떨어질 수 있음</td>
<td>요청당 메시지 1개를 전달하며 대기 메시지 최대 숫자를 제한함</td>
<td>확장성은 PubSub 서버에 의해 동적으로 처리됨</td>
</tr>
</tbody></table>
<h1 id="cloud-pubsub--spring-bootecho">Cloud PubSub + Spring Boot/Echo</h1>
<p>기존 대용량 메일 발송 서비스는 Monolithic으로 개발한 상황이다. 나는 Cloud PubSub의 도입을 주장하였는데, 근거는 아래와 같다.</p>
<ul>
<li><p>기존 Monolithic으로 구현된 서비스는 확장성에서 불리하다.  </p>
<ul>
<li>대용량 메일 발송 기능만 구현되어 있는데, 문자 발송 기능이 추가 예정이기 때문이다.</li>
</ul>
</li>
<li><p>동기 형식으로 개발되어 있는 API</p>
<ul>
<li>Elixir로 개발을 진행했는데, API 요청으로 Connection이 30초를 넘어가는 순간 Stream Timeout 문제가 발생하기에 비동기로 전환할 필요성이 있다.</li>
</ul>
</li>
<li><p>회사 인프라는 GCP를 클라우드로 사용한다.</p>
<ul>
<li>회사가 GCP를 사용하고 있기에, AWS SES 등을 추가적으로 도입해 활용하는 것보다 Cloud PubSub을 활용해 비동기로 API 서버와 발송 서버를 분리하는 것이 효과적일 것이다.</li>
</ul>
</li>
</ul>
<p>다양한 의견 교류 과정을 거쳐, 최종적으로 Spring Boot를 API 서버로, Echo를 발송 서버로 활용하기로 결정했다.</p>
<blockquote>
<p><strong>Spring Boot를 API 서버로 선택한 이유</strong></p>
<ul>
<li>Spring Security 기반으로 인증/인가 로직 개발 용이</li>
<li>다양한 레퍼런스와 <a href="https://cloud.google.com/pubsub/docs/spring?hl=ko">공식 문서</a> 지원</li>
<li>프로젝트 진행 경험이 있는 언어 및 프레임워크로 적은 러닝 커브, 빠른 개발 속도로 기개발된 API 마이그레이션 가능 </li>
<li>Elixir와 달리 국내 개발자 풀이 넓어, 새로운 인력이 들어왔을 때 코드 이해도 높아질 것이라는 예상</li>
</ul>
<p><strong>Echo를 발송 서버로 선택한 이유</strong></p>
<ul>
<li>높은 동시성과 빠른 실행 속도</li>
<li>상대적으로 간단한 속도로 높은 성능과 확장성을 가지는 Echo 프레임워크</li>
<li>적은 메모리로 대량의 요청 처리 가능</li>
<li>(Optional) 더 높은 동시성 효율을 위해 고루틴 사용 가능</li>
</ul>
</blockquote>
<h2 id="pull-vs-push">Pull vs Push</h2>
<p>먼저 내보내기는 BigQuery과 Cloud Storage 서비스를 지원하기에, 사용할 수 없어서 제외했다. </p>
<p>이 서비스에 대한 Push 모델과 Pull 모델의 장/단점을 아래의 표로 정리할 수 있다.</p>
<table>
<thead>
<tr>
<th align="left"></th>
<th align="left">장점</th>
<th align="left">단점</th>
</tr>
</thead>
<tbody><tr>
<td align="left">Push</td>
<td align="left">- 실시간 처리 가능(메시지 발생 즉시 전달) <br> - 서버는 엔드포인트만 제공하면 바로 처리가 가능하므로 개발 용이</td>
<td align="left">- 구독 서버가 항상 메시지를 받을 준비를 하고 있어야 함 <br> - 트래픽의 증가에 따른 부하 관리가 어려움 <br> - 대용량 메일 요청이 왔을 때 Stream Timeout에 대한 관리 필요</td>
</tr>
<tr>
<td align="left">Pull</td>
<td align="left">- 구독자가 메시지를 가져오는 시기와 양을 조절 가능 <br> - 트래픽이 몰릴 경우, 구독자를 새로 추가 및 제거하는 방식으로 관리 가능 <br> - 구독자가 일시적으로 중단되어도 손실을 최소화할 수 있음</td>
<td align="left">- 실시간성이 Push 모델에 비해 떨어짐 <br> - 구독자 측에서 별도의 풀링 로직을 개발해야 함</td>
</tr>
</tbody></table>
<p>최종적으로는 Push모델을 선택했다. Push모델을 선택한 이유는 아래와 같다.</p>
<ul>
<li>실시간 처리 가능</li>
<li>발송 서버의 개발 용이성</li>
<li>PubSub 내 재시도 로직 존재 (구독 서버가 메시지 받을 준비가 안 되어 있다, 정상화되면 바로 처리 가능)</li>
<li>대용량 요청 처리 시, 고루틴이라는 해결책 존재</li>
</ul>
<h2 id="cloud-pubsub-설정">Cloud PubSub 설정</h2>
<h3 id="로컬에서-cloud-pubsub-설정">로컬에서 Cloud PubSub 설정</h3>
<p>GCP는 정말 친절하게도 Cloud PubSub Emulator를 Docker 환경에서 활용할 수 있도록 <a href="https://cloud.google.com/pubsub/docs/emulator?hl=ko">제공</a>하고 있다. </p>
<h4 id="docker-image-pull">Docker Image Pull</h4>
<p>먼저 Docker Image를 아래의 커맨드를 활용하여 가져온다.</p>
<pre><code class="language-shell">docker pull google/cloud-sdk:emulators</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/b5d4c5ca-547b-4417-8e22-b4491440c8de/image.png" alt=""></p>
<h4 id="docker-run">Docker Run</h4>
<p>가져온 이미지로 컨테이너를 실행한다.</p>
<pre><code class="language-shell">docker run --rm -p 8085:8085 google/cloud-sdk:emulators /bin/bash -c &quot;gcloud beta emulators pubsub start --project=some-project-id --host-port=&#39;0.0.0.0:8085&#39;&quot;</code></pre>
<ul>
<li>일반적으로 8085 포트를 사용하기에, 8085를 포트번호로 지정했다.</li>
<li>google/cloud-sdk:emulators 이미지에서 PubSub Emulator를 명령어로 실행시키는 방식이다.</li>
<li>여기서 사용되는 project id는 로컬에서만 사용하는 project id이다. 나는 <code>study-pubsub</code>으로 진행했다.</li>
<li>백그라운드 실행을 위해서는 <code>-d</code> 옵션을 활용해야 한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/0d08ebca-c063-4b12-a658-6e2605fb5149/image.png" alt=""> 실행시키면, 위 이미지와 같이 PubSub의 테스트용이라고 뜨면서 실행된다.</p>
<p>이를 활용해서, 비용 발생 없이 테스트해볼 수 있을 것이다.</p>
<blockquote>
<p>나는 CLI에서 PubSub 제어하는 것이 너무 고통스러워, 결국 GCP에 직접 띄워서 진행했다.</p>
</blockquote>
<h3 id="gcp에서-cloud-pubsub-설정">GCP에서 Cloud PubSub 설정</h3>
<h4 id="cloud-pubsub-주제-설정">Cloud PubSub 주제 설정</h4>
<ol>
<li><p>주제 만들기 클릭
<img src="https://velog.velcdn.com/images/dl-00-e8/post/0b25c993-649b-42f4-8ef0-a83035d72adc/image.png" alt=""></p>
</li>
<li><p>주제 ID 설정
<img src="https://velog.velcdn.com/images/dl-00-e8/post/3d4f98ed-d8c8-49c2-877d-046e84c91c84/image.png" alt="">
발송 서버의 예제이므로, 주제 ID를 <code>send-test</code>로 진행했다.
페이지 내에 보면, 기본 구독 추가 ~ Cloud Storage에 메시지 데이터 백업 등 다양한 선택지가 존재한다. 정리하면 아래와 같다.</p>
<blockquote>
<ul>
<li>기본 구독 추가: 구독 설정이 기본 구독값으로 구성됩니다. 기본값은 아래와 같습니다.</li>
</ul>
</blockquote>
<ol>
<li>pull 전송 유형</li>
<li>메시지 보관 기간 7일</li>
<li>31일 동안 활동이 없으면 만료됨</li>
<li>확인 기한 10초</li>
<li>즉시 재시도 정책<blockquote>
<ul>
<li>스키마 사용: 기존 스키마를 가져와 할당하거나 새로운 스키마를 만들어 적용할 수 있다. (스키마 = 주제의 메시지가 따라야 하는 형식)</li>
<li>메시지 보관 사용: 발송된 메시지의 히스토리를 최대 31일동안 보관 및 확인할 수 있다는 의미이다. 유료임을 유의해야 한다.</li>
<li>BigQuery로 메시지 데이터 내보내기: Publish된다면 BigQuery테이블로 메세지 데이터가 직접 전송된다.</li>
<li>Cloud Storage에 메시지 데이터 백업: Publish된다면 Cloud Storage의 버킷으로 메세지 데이터가 직접 전송된다.</li>
</ul>
</blockquote>
</li>
</ol>
</li>
</ol>
<p>여기서 Push 모델을 사용할 예정이므로, 기본 구독 추가 옵션을 선택한 이후, 생성된 주제 안에서 Push 모델로 설정을 변경할 것이다.</p>
<ol start="3">
<li><p>Push모델 변경
<img src="https://velog.velcdn.com/images/dl-00-e8/post/af20a8b4-b87f-474f-b491-5f1d5e9c60cb/image.png" alt=""> 구독 수정에 접속한다면, 전송 유형을 선택할 수 있다. 여기서, 푸시를 선택하면 된다.</p>
<blockquote>
<p>유의사항: Push모델은 SSL/TLS 보안 인증이 되어 있는 엔드포인트에 한해서 가능하다. 그렇기에, 로컬에서 경험하고자 개발하고 있다면, 아래의 개발 과정에서 설명하고 있는 ngrok을 활용하여 https 도메인을 받아서 푸시 엔드포인트를 업데이트하면 된다.</p>
</blockquote>
</li>
<li><p>재시도 설정
<img src="https://velog.velcdn.com/images/dl-00-e8/post/d6bb32f1-94af-4610-834f-28f4a7c4e26d/image.png" alt=""> 구독하고 있는 소비자에게 메세지를 전달하였지만, 실패된 경우 Cloud PubSub은 재시도를 지원한다. 이 때, 즉시 재시도와 지수 백오푸 지연 후 재시도 등의 옵션이 있으니 상황에 맞추어 설정하면 된다. 여기서는 기본값인 즉시 재시도를 유지했다.</p>
</li>
</ol>
<h2 id="개발">개발</h2>
<h3 id="message-구조">Message 구조</h3>
<p>Cloud PubSub에서 메시지 구조는 일반적으로 아래와 같다. 
(출처: <a href="https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage">PubSub REST API Message Docs</a>)</p>
<pre><code class="language-json">{
    &quot;message&quot;: {
      &quot;data&quot;: string,
      &quot;attributes&quot;: {
        string: string,
        ...
      },
      &quot;messageId&quot;: string,
      &quot;publishTime&quot;: string,
      &quot;orderingKey&quot;: string
    },
    &quot;subscription&quot;: &quot;topic/subsription name&quot;
}</code></pre>
<p>여기서, publishTime, messageId, subscription를 제외하고는 모두 선택사항이다.</p>
<p>나는 여기서, data/attributes 이 두 필드를 활용해 필요한 데이터를 전달 및 검증을 진행할 예정이다.
<strong>1. data 필드는 실제로 필요한 데이터들을 담고 있다.</strong>
발송을 처리하는 로직에 대한 예제이므로, 단편적으로 이메일 제목/본문/수신자 정보만을 공유하도록 처리했다.</p>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;이메일 제목&quot;,
  &quot;body&quot;: &quot;이메일 본문&quot;,
  &quot;receiver&quot;: &quot;수신자&quot;
}</code></pre>
<p><strong>2. attributes 필드에는 서버 간 별도 지정한 인증키를 저장하여, 유효성 검증을 진행한다.</strong> 
유효성 검증은 subscription을 통해서, 지정된 Topic에 대한 구독인지를 검증하는 것이 1차이며, attributes 필드의 인증키 검증을 통해 2차 인증을 진행한다. 2차 인증을 도입한 이유는 혹시 모를 Topic 노출 사태에 대비하는 차원과 보안 수준은 높으면 높을수록 좋다고 생각하기 때문이다.</p>
<blockquote>
<p>주의사항: data 필드는 key-value 형태가 아니다. <strong>Base64로 인코딩된 문자열이여야 하므로</strong>, 발행과 구독 파트 모두 인코딩/디코딩 로직을 포함해야 한다.</p>
</blockquote>
<h3 id="goecho-개발">Go/Echo 개발</h3>
<p>먼저 구독 파트를 개발한다. 
구독 파트를 먼저 개발하는 이유는 Push모델 특성상 SSL/TLS 인증이 필수적이기 때문이다.
로컬에 띄운 서버의 엔드포인트로 SSL/TLS 엔드포인트를 부여하고자 한다면, ngrok을 사용해서 엔드포인트를 발급받은 후, 해당 엔드포인트를 Cloud PubSub에 등록하는 과정을 진행해야 한다. ngrok에 대해 궁금하신 분들은, 과거에 작성한 <a href="https://velog.io/@dl-00-e8/Ngrok-%EC%99%B8%EB%B6%80%EC%97%90%EC%84%9C-%EB%A1%9C%EC%BB%AC-%EC%84%9C%EB%B2%84-%EC%A0%91%EC%86%8D%ED%95%98%EA%B8%B0-MAC">ngrok 글</a>을 읽어보시면 좋을 것 같다.</p>
<h4 id="api-개발">API 개발</h4>
<p><strong>handler</strong></p>
<pre><code class="language-go">func (eh *EmailHandler) SendEmail(c echo.Context) error {
    // Request Data Binding
    var pubsubMessage dto.PubSubMessage

    // Request Body와 Request DTO 매핑, 오류 발생 시 401 반환
    err := c.Bind(&amp;pubsubMessage)
    if err != nil {
        return err
    }

    fmt.Println(pubsubMessage)

    // Service Method
    ctx := c.Request().Context()
    serviceErr := eh.EmailService.SendEmail(ctx, pubsubMessage)
    if serviceErr != nil {
        return serviceErr
    }

    return c.JSON(http.StatusOK, common.NewSuccessResponseWithoutData())
}</code></pre>
<p><strong>service</strong></p>
<pre><code class="language-go">func (es *EmailService) SendEmail(ctx context.Context, pubsubMessage dto.PubSubMessage) error {
    // Transaction Start
    tx := es.EmailRepository.DB.Begin()
    defer func() {
        if err := recover(); err != nil {
            tx.Rollback()
        }
    }()

    // Validation - PubSub Topic Check
    if pubsubMessage.Subscription != config.GetEnvVar(&quot;GCP_PUBSUB_TOPIC&quot;) {
        tx.Rollback()
        return &amp;common.InvalidSubscriptionError
    }

    // Validation - PubSub Authentication Key Check
    if pubsubMessage.Message.Attributes[&quot;authentication&quot;] != config.GetEnvVar(&quot;AUTHENTICATION_KEY&quot;) {
        tx.Rollback()
        return &amp;common.InvalidAuthenticationKeyError
    }

    // Validation - Cloud PubSub Message Decoding &amp; Binding
    decodedData, decodeErr := pubsubMessage.DecodedData()
    if decodeErr != nil {
        tx.Rollback()
        return decodeErr
    }

    var emailRequest dto.EmailRequest
    if err := json.Unmarshal(decodedData, &amp;emailRequest); err != nil {
        tx.Rollback()
        return err
    }
    email, findErr := es.EmailRepository.FindByID(ctx, tx, emailRequest.ID)
    if findErr != nil {
        tx.Rollback()
        return findErr
    }

    // Business Logic
    // ThirdParty Email Send - Random Response
    emailErr := randomResponse()

    // 발송 상태 업데이트
    if emailErr != nil {
        email.Status = model.FAILED
        es.EmailRepository.Update(ctx, tx, email)
    } else {
        email.Status = model.Sent
        es.EmailRepository.Update(ctx, tx, email)
    }

    // Transaction End
    if err := tx.Commit().Error; err != nil {
        tx.Rollback()
        return err
    }

    return nil
}

func randomResponse() error {
    // 시드 초기화
    rand.Seed(time.Now().UnixNano())

    // 0 또는 1 랜덤 생성
    randomBit := rand.Intn(2) // 0과 1 중 하나를 랜덤으로 반환

    // 0이면 에러 반환
    if randomBit == 0 {
        return &amp;common.EmailError
    } else {
        return nil
    }
}</code></pre>
<p><strong>repository</strong></p>
<pre><code class="language-go">func (er *EmailRepository) FindByID(ctx context.Context, tx *gorm.DB, emailId uint) (*model.Email, error) {
    var email model.Email
    if err := tx.WithContext(ctx).Where(&quot;id = ?&quot;, emailId).First(&amp;email).Error; err != nil {
        return nil, err
    }
    return &amp;email, nil
}

func (er *EmailRepository) Update(ctx context.Context, tx *gorm.DB, email *model.Email) error {
    if err := tx.WithContext(ctx).Save(email).Error; err != nil {
        return err
    }
    return nil
}</code></pre>
<h3 id="javaspring-boot-개발">Java/Spring Boot 개발</h3>
<p>구독 파트는 개발을 완료했으니, 이제는 발행 파트이다.</p>
<p>기본적인 프로젝트 환경은 생략한다. PubSub에 메세지를 발행하는 PubSubClient와 비즈니스 로직 코드를 공유한다.</p>
<p>주요 특징으로는, PubSub Dependency 내에 인코딩 로직이 포함되어 있다. 그렇기에, 메세지 발행하는 PubSubClient에서는 ObjectMapper를 활용해 Json형태로만 만들어주었다.</p>
<blockquote>
<p>Base64 인코딩 파트를 찾기 위해, 라이브러리에서 Publish 메소드까지 따라 들어가보았지만, 찾지 못했다. 내부적으로 gRPC를 기반으로 통신하는데, 해당 위치에서 인코딩 로직이 있는 것으로 보인다.</p>
</blockquote>
<h4 id="buildgradle">build.gradle</h4>
<pre><code>// Pubsub
implementation group: &#39;com.google.cloud&#39;, name: &#39;spring-cloud-gcp-starter-pubsub&#39;, version: &#39;5.4.1&#39;
</code></pre><p><a href="https://mvnrepository.com/artifact/com.google.cloud/google-cloud-pubsub/1.130.1">Maven Repository</a>에서 Cloud PubSub에 활용하는 의존성을 확인할 수 있다.</p>
<h4 id="pubsubclient">PubSubClient</h4>
<pre><code class="language-yaml">spring:
    cloud:
      gcp:
        credentials:
          location: {GCP 서비스 계정 경로}
        project-id: {GCP 프로젝트 ID}
        pubsub:
          topic: {Cloud PubSub Topic}
          authentication-key: {서버 간 공유 인증키}</code></pre>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class PubSubClient {

    private final PubSubTemplate pubSubTemplate;
    private final ObjectMapper objectMapper;

    @Value(&quot;${spring.cloud.gcp.pubsub.topic}&quot;)
    private String topicName;

    @Value(&quot;${spring.cloud.gcp.pubsub.authentication-key}&quot;)
    private String authenticationKey;

    public void publishMessage(MessageContent messageContent) {
        try {
            // Convert to Json
            String message = objectMapper.writeValueAsString(messageContent);

            // Generate PubsubMessage Object with attributes
            PubsubMessage pubsubMessage = PubsubMessage.newBuilder()
                    .setData(com.google.protobuf.ByteString.copyFromUtf8(message))
                    .putAttributes(&quot;authentication&quot;, authenticationKey)
                    .build();

            // Publish message
            pubSubTemplate.publish(topicName, pubsubMessage);
        } catch (JsonProcessingException jsonProcessingException) {
            throw new RuntimeException(jsonProcessingException.getMessage());
        }
    }
}</code></pre>
<h4 id="비즈니스-로직">비즈니스 로직</h4>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class EmailService {

    private final EmailRepository emailRepository;
    private final PubSubClient pubSubClient;

    public void sendEmail(EmailReq emailReq) {
        Email email = emailReq.from();
        Email savedEmail = emailRepository.save(email);
        MessageContent messageContent = new MessageContent(
                savedEmail.getId(),
                savedEmail.getTitle(),
                savedEmail.getBody(),
                savedEmail.getReceiver()
        );
        pubSubClient.publishMessage(messageContent);
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/26e9d443-61e8-4cb9-a0a0-95d52d1854be/image.png" alt=""> 개발한 이후, 테스트해보면 아래와 같이 게시 시도 카운트가 올라가는 것을 GCP 대시보드에서 확인할 수 있다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/e2917b2f-e387-4560-848b-9f8f148da779/image.png" alt=""></p>
<p>발송 서버에서는 0/1을 랜덤으로 뽑아 성공/실패를 결정하도록 하여, 이메일 서드파티에서 문제상황이 발생하는 것을 가정해서 진행해보았는데, 실제로 <code>FAILED</code>와 <code>SENT</code>가 정상적으로 분기되는 것을 확인할 수 있다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/c7a7ea05-7453-4284-b9e1-e4a0b1a79699/image.png" alt=""><img src="https://velog.velcdn.com/images/dl-00-e8/post/86dc9e19-c843-43ab-80f3-58cacd6ba7cc/image.png" alt=""></p>
<blockquote>
<p>Cloud PubSub을 연결하여 사용하는 과정에 대한 예시 코드는 <a href="https://github.com/dl-00-e8/study-development/tree/main/gcp-pubsub">Github Repo</a>에서 확인할 수 있다.</p>
</blockquote>
<hr>
<p><strong>후기</strong>
회사에서 도입했던 Cloud PubSub을 도입 근거부터 도입 예제까지 정리하면서, 다른 다양한 인프라들도 적용해보고 싶다는 생각이 많이 들었다. 오버 엔지니어링을 경계면서, 서비스 개선을 위한 다양한 인프라를 앞으로 학습하고 적용해볼 예정이다.</p>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://cloud.google.com/pubsub/docs/overview?hl=ko">PubSub Docs</a></li>
<li><a href="https://medium.com/@crip.popescu/running-gcp-pubsub-emulator-on-a-local-docker-environment-735c7f1e1f41">Local PubSub 개발환경 구축하기-Medium</a></li>
<li><a href="https://cloud.google.com/pubsub/docs/samples/pubsub-create-topic?hl=ko">PubSub Docs</a></li>
<li><a href="https://velog.io/@caesars000/Spring-Boot%EC%97%90%EC%84%9C-Google-Cloud-Pub-%ED%99%9C%EC%9A%A9-%EC%98%88%EC%A0%9C">Spring Boot + PubSub</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] S3 Presigned URL & GCS Signed URL 적용하기]]></title>
            <link>https://velog.io/@dl-00-e8/Spring-Boot-S3-Presigned-URL-GCS-Signed-URL-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/Spring-Boot-S3-Presigned-URL-GCS-Signed-URL-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 19 Jul 2024 08:33:43 GMT</pubDate>
            <description><![CDATA[<p>최근 GCP의 GCS 파일 업로드를 구현하며, 다른 점을 정리해보고 싶다는 생각이 들어 AWS와 GCP 각각의 파일 업로드 방법 및 차이를 정리해보고자 한다.</p>
<h1 id="presigned와-signed">Presigned와 Signed</h1>
<h2 id="presigned와-signed의-차이">Presigned와 Signed의 차이</h2>
<p>AWS S3 <a href="https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/PresignedUrlUploadObject.html">Presigned Url 문서</a>를 보면 Presigned Url의 정의를 알 수 있다.</p>
<p>문서를 읽어보았을 때에는 두 기능의 차이가 정확히 존재하지 않는 것으로 보여, GPT와 Claude에게 물어봤다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/63e77bd6-bb33-408a-87b8-41d3a93e843b/image.png" alt=""><img src="https://velog.velcdn.com/images/dl-00-e8/post/181a82c6-15b8-493e-9abc-6a729a24cf6e/image.png" alt=""> 첫 번째가 GPT, 두 번째가 Claude의 답변이다. 
결론적으로 보자면, 동일하게 <strong>안전하게 임시 액세스를 제공한다</strong>는 관점의 기능이라는 것을 알 수 있었다.</p>
<h2 id="사용하는-이유">사용하는 이유</h2>
<p>이제 차이가 크게 없다는 것을 알았고, 왜 사용해야 하는가에 대하여 알아보려고 한다. 
(Presigned와 Signed를 중복해서 적는 것이 불편하기에, 여기서는 Presigned로 통칭한다. / 이에 더해 아래에서의 스토리지는 GCS/S3를 말한다.)</p>
<p><strong>Presigned를 사용하지 않을 때  업로드 방식</strong></p>
<ol>
<li>클라이언트에서 사용자의 파일 입력</li>
<li>클라이언트가 서버에게 파일 전송</li>
<li>서버는 객체 스토리지에 파일 업로드</li>
<li>객체 스토리지에서 업로드된 URL 정보를 서버에게 반환</li>
<li>서버는 해당 URL와 필요한 다른 정보들을 함께 처리 후 응답</li>
</ol>
<p><strong>Presigned를 사용할 때 업로드 방식</strong></p>
<ol>
<li>클라이언트에서 사용자의 파일 입력</li>
<li>클라이언트는 서버에게 Presigned URL 발급 요청</li>
<li>서버는 객체 스토리지에게 Presigned URL 발급 요청</li>
<li>객체 스토리지는 Presigned URL을 반환</li>
<li>서버는 Presigned URL을 클라이언트에게 반환</li>
<li>클라이언트는 Presigned URL로 파일 업로드 진행</li>
<li>업로드한 이후, 해당 경로 정보를 서버에게 전달</li>
<li>서버는 관련한 나머지 비즈니스 로직 처리</li>
</ol>
<p>위 두 방식에서 가장 큰 차이점은 <strong>파일이 서버에게 전달되는지에 대한 유무</strong>이다. 
Presigned를 사용하지 않는다면, 서버에게 파일이 전달되는데, 서버는 용량이 큰 파일을 내부적으로 들고 있으면서 처리를 해야 한다. Spring Boot 기준으로, 10MB의 용량 제한을 기준으로 10MB 이상의 데이터는 메모리에 저장한 이후 처리를 하고 있는데, 이는 파일 업로드 요청이 많아진다면 <strong>직접적인 서버 부하</strong>로 이어지게 된다.
Presigned를 사용한다면, 직접적인 서버 부하를 최소화 및 파일 용량의 제한이 사라진다는 점이 장점이다.</p>
<blockquote>
<p>Q. 클라이언트에서 바로 업로드하면 안 되나요?
A. 클라이언트에서 서버를 통해 Presigned URL을 발급받는 이유는 <strong>보안</strong> 때문이다. 즉, 인가된 사용자 또는 권한을 받은 사용자만 업로드할 수 있어야 한다는 점에서 Presigned URL을 통해 업로드해야 한다는 것이다.</p>
</blockquote>
<h2 id="사용하는-방법">사용하는 방법</h2>
<p>Presigned URL을 사용하는 방법으로는 크게 두 가지가 있다.</p>
<p><strong>업로드</strong>
파일을 Presigned URL로 Put 요청을 통해 업로드할 수 있다.</p>
<p><strong>다운로드</strong>
기존에 스토리지에 존재하는 파일에 대해, 지정된 시간동안 해당 URL로 객체 접근 및 다운로드가 가능하다.</p>
<h1 id="gcp-gcs-signed-url">GCP GCS Signed URL</h1>
<h2 id="gcs-생성-및-접근-권한-설정">GCS 생성 및 접근 권한 설정</h2>
<h3 id="버킷-만들기">버킷 만들기</h3>
<p>먼저, Signed URL을 발급받기 위해서는 버킷을 생성해야 한다. (프로젝트는 미리 만들어둔 상황이라고 가정한다.)
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/257f7407-1a99-4c35-98f0-fa2d9500707e/image.png' width = 300>
Cloud Storage 콘솔에 접근 이후, 좌측 상단의 만들기를 통해 생성할 수 있다. 
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/4558cf20-a74c-4ee6-9dc1-47bcdc33c641/image.png' width = 300>
버킷 관련된 설정은 위와 같이 진행했다.
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/20f0ce69-e369-40db-8a9c-0077f42100ca/image.png' width = 500>
생성된 버킷은 위와 같다. </p>
<blockquote>
<p><strong>데이터의 스토리지 클래스 선택</strong>
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/ccf1e0fb-ed6d-4617-86ea-2bb95328a5a4/image.png' width = 300>
과거 사용할 때는 별 생각 없이 생성했던 부분이다. 최근 <code>가상 면접 사례로 배우는 대규모 시스템 설계 기초 2</code>로 스터디를 진행하며, S3도 접근 빈도 등에 따라 6개 내외의 클래스로 나뉘는데, GCS도 동일함을 알 수 있다.</p>
</blockquote>
<h3 id="서비스-계정-생성">서비스 계정 생성</h3>
<p>생성한 버킷에 접근하여 Presigned URL을 발급할 수 있는 권한을 가진 서비스 계정을 생성해야 한다. <img src = 'https://velog.velcdn.com/images/dl-00-e8/post/2824c46d-60fb-4471-bb10-7aabd893c5d7/image.png' width = 300>
좌측 상단의 리스트르 클릭하여, IAM 및 관리자 -&gt; 서비스 계정에 접속한다.
<code>저장소 개체 생성자</code>, <code>저장소 관리자</code>, <code>저장소 개체 관리자</code> 권한을 추가한 뒤 계정 생성을 완료하면 아래와 같이 서비스 계정이 생성되어 있는 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/f17051dd-ddad-4c8c-a727-a9436c125a6f/image.png" alt=""></p>
<h3 id="키-생성">키 생성</h3>
<p>서비스 계정을 생성한 이후에는, 해당 권한을 가지고 있는 키를 생성해야 한다.
여기서 생성한 키는 json파일로, 개발할 때 권한 인증된 객체를 생성할 때 활용하게 된다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/9fb7130f-83e0-42ed-827b-1988793aca9d/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/fc584004-2b5c-4af9-9ac8-2a9576b26d8e/image.png" alt=""></p>
<p>접근 권한별 내용은 <a href="https://cloud.google.com/storage/docs/access-control/iam-roles?hl=ko">GCP IAM Docs</a>에서 확인할 수 있다.</p>
<h2 id="개발">개발</h2>
<p>개발 과정은 <a href="https://cloud.google.com/storage/docs/access-control/signing-urls-with-helpers?hl=ko">GCP Docs</a>에 있는 샘플 코드를 최대한 활용한다.</p>
<h3 id="의존성-주입">의존성 주입</h3>
<pre><code class="language-java">implementation group: &#39;com.google.cloud&#39;, name: &#39;google-cloud-storage&#39;, version: &#39;2.40.1&#39;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/17ae2566-7511-46fe-8034-d22b8a43747e/image.png" alt="">
Spring Boot에서 GCS의 Signed URL을 발급받고자 한다면, <a href="https://mvnrepository.com/artifact/com.google.cloud/google-cloud-storage/2.40.1">Google Cloud Storage</a> 의존성을 주입해야 한다. 다만, 위 이미지와 같이 Spring Boot 버전이 제한되어 있으므로 꼭 확인해야 한다. </p>
<h3 id="키-저장">키 저장</h3>
<p>이전에 발급받은 json 확장자의 키를 resources 하위에 위치시킨다.</p>
<h3 id="gcsconfig">GcsConfig</h3>
<pre><code class="language-java">@Configuration
public class GcsConfig {

    @Value(&quot;${spring.cloud.gcp.credentials.location}&quot;)
    private String location;

    @Value(&quot;${spring.cloud.gcp.storage.project-id}&quot;)
    private String projectId;

    @Bean
    public Storage storage() throws IOException {
        ClassPathResource resource = new ClassPathResource(location);
        InputStream inputStream = resource.getInputStream();
        GoogleCredentials credentials = GoogleCredentials.fromStream(inputStream);

        return StorageOptions.newBuilder()
                .setCredentials(credentials)
                .setProjectId(projectId)
                .build()
                .getService();
    }
}</code></pre>
<h3 id="gcsservice">GcsService</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class GcsService {

    private final Storage storage;

    @Value(&quot;${spring.cloud.gcp.storage.bucket}&quot;)
    private String bucketName;

    public String generateSignedURL(String objectName) {
        BlobInfo blobInfo = BlobInfo.newBuilder(BlobId.of(bucketName, objectName)).build();

        Map&lt;String, String&gt; extensionHeaders = new HashMap&lt;&gt;();
        String contentType = getContentType(objectName);

        extensionHeaders.put(&quot;Content-Type&quot;, contentType);


        URL url = storage.signUrl(blobInfo,
                15,
                TimeUnit.MINUTES,
                Storage.SignUrlOption.httpMethod(HttpMethod.PUT),
                Storage.SignUrlOption.withExtHeaders(extensionHeaders),
                Storage.SignUrlOption.withV4Signature());

        return url.toString();
    }

    private static String getContentType(String objectName) {
        String contentType;
        // 파일 이름에서 확장자 추출
        int lastDotIndex = objectName.lastIndexOf(&#39;.&#39;);
        if (lastDotIndex != -1) {
            String extension = objectName.substring(lastDotIndex + 1).toLowerCase();
            contentType = switch (extension) {
                case &quot;pdf&quot; -&gt; &quot;application/pdf&quot;;
                case &quot;jpg&quot;, &quot;jpeg&quot; -&gt; &quot;image/jpeg&quot;;
                case &quot;png&quot; -&gt; &quot;image/png&quot;;
                default -&gt; &quot;application/octet-stream&quot;; // 기본값
            };
        } else {
            contentType = &quot;application/octet-stream&quot;; // 확장자가 없는 경우 기본값
        }
        return contentType;
    }
}</code></pre>
<blockquote>
<p><strong>Content-Type</strong>
Service에서 GCP Docs와 다르게 Content-Type을 파일명을 받아서 구분하고 있다. 
<code>application/octet-stream</code>으로 Signed URL을 발급하면, <code>SignatureDoesNotMatch</code>라는 오류가 발생했기 때문이다. 
여기서는, 학습의 목적으로 진행했기에 오류가 발생한 것을 알리기 위해 위의 코드를 의도적으로 변경하지 않았다.</p>
</blockquote>
<h3 id="실행-및-결과">실행 및 결과</h3>
<p>서버를 실행 후, API 요청을 보내면 아래와 같이, 발급할 때 사용하는 Signed URL을 받을 수 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/27ab7d98-9a37-4f14-a3cf-dea815b3775b/image.png" alt=""> 발급받은 URL로 Put 요청을 보내면 아래와 같이 성공으로 응답이 온다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/e755b91a-4f81-4deb-b97d-d7ea5224bfaa/image.png" alt=""> GCS를 접근해보면, 정상적으로 잘 업로드되었음을 확인할 수 있다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/6e11a0dc-aaa1-4c24-a928-f03b239280fc/image.png" alt=""></p>
<h1 id="aws-s3-presigned-url">AWS S3 Presigned URL</h1>
<h2 id="s3-bucket-생성-및-접근-권한-설정">S3 Bucket 생성 및 접근 권한 설정</h2>
<h3 id="버킷-만들기-1">버킷 만들기</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/7375acef-38ba-4d16-92c5-3722eea1995f/image.png" alt=""></p>
<h3 id="정책-수정">정책 수정</h3>
<p>S3 자체에 대하여, 어떤 권한을 가지도록 할 것인지를 설정해야 한다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/2de476bf-2a01-41c0-9e76-13f9bd390a5c/image.png" alt=""> 위 이미지의 정책 생성기를 클릭하여 정책을 생성해야 한다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/600fcdd2-ef1e-414c-8e02-486aaf248457/image.png" alt="">
나는 위와 같이 진행했으며, ARN에는 버캣 정책 편집이라는 페이지에 작성되어 있는 ARN을 복사하여 사용하면 된다.</p>
<h3 id="cors-설정">CORS 설정</h3>
<p>Presigned URL은 결국 외부에서 업로드 및 다운로드를 진행하기에 CORS 오류가 발생하게 된다. CORS 정책을 적용하여 오류가 발생하지 않도록 처리해야 한다.</p>
<pre><code class="language-json">[
    {
        &quot;AllowedHeaders&quot;: [&quot;*&quot;],
        &quot;AllowedMethods&quot;: [&quot;GET&quot;, &quot;HEAD&quot;, &quot;PUT&quot;, &quot;POST&quot;, &quot;DELETE&quot;],
        &quot;AllowedOrigins&quot;: [&quot;*&quot;],
        &quot;ExposeHeaders&quot;: []
    }
]</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/47795de3-351e-4e56-a403-cc37cf67417a/image.png" alt="">
학습 목적이기에, 별도의 추가 설정 없이 와일드카드로 모든 접근을 허용했다.</p>
<h3 id="iam-생성">IAM 생성</h3>
<h4 id="사용자-생성">사용자 생성</h4>
<p>버킷을 생성했다면, 업로드/다운로드 등의 권한을 가지고 있는 IAM을 생성해야 한다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/152f6548-9a01-4d2b-943c-a624cb3e061e/image.png" alt=""><img src="https://velog.velcdn.com/images/dl-00-e8/post/987f5c60-cd83-471e-8904-8e8ad5d2e81a/image.png" alt=""> S3와 관련된 역할만 필요하기에 <code>AmazonS3FullAccess</code> 권한만 설정해주었다.
기존에 사용 중이던 사용자가 있다면, 해당 사용자에 위 권한을 추가하여 활용하시면 된다.</p>
<h4 id="액세스-키-발급">액세스 키 발급</h4>
<p>사용자 생성 후, 해당 사용자 페이지로 접근하여 액세스 키를 발급받으면 된다.
액세스 키는 외부에 노출되면 안되므로, 꼭 Github에 올라가지 않도록 암호화 또는 .gitignore 설정을 해야 한다.</p>
<h2 id="개발-1">개발</h2>
<h3 id="의존성-추가">의존성 추가</h3>
<pre><code class="language-java">implementation group: &#39;io.awspring.cloud&#39;, name: &#39;spring-cloud-starter-aws&#39;, version: &#39;2.4.4&#39;</code></pre>
<h3 id="applicationyml">application.yml</h3>
<pre><code class="language-yml">cloud:
  aws:
    s3:
      bucket: {BUCKET_NAME}
    region:
      static: ap-northeast-2
    credentials:
      access-key: {ACCESS_KEY}
      secret-key: {SECRET_KEY}</code></pre>
<h3 id="s3config">S3Config</h3>
<pre><code class="language-java">@Configuration
public class S3Config {

    @Value(&quot;${cloud.aws.credentials.access-key}&quot;)
    private String accessKey;
    @Value(&quot;${cloud.aws.credentials.secret-key}&quot;)
    private String secretKey;
    @Value(&quot;${cloud.aws.region.static}&quot;)
    private String region;

    @Bean
    public AmazonS3 amazonS3() {
        return AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)))
                .build();
    }
}</code></pre>
<h3 id="s3service">S3Service</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class S3Service {

    @Value(&quot;${cloud.aws.s3.bucket}&quot;)
    private String bucket;

    private final AmazonS3 amazonS3;

    public String generatePreSignedUrl(String fileName) {
        // AWS Presigned URL 발급을 위한 요청 객체 생성
        GeneratePresignedUrlRequest generatePresignedUrlRequest =
                new GeneratePresignedUrlRequest(bucket, fileName)
                        .withMethod(HttpMethod.PUT) // 업로드이므로 PUT
                        .withExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000)); // URL 유효기간 설정 (10분)
        generatePresignedUrlRequest.addRequestParameter(
                Headers.S3_CANNED_ACL, // ACL 설정
                CannedAccessControlList.PublicRead.toString()); // 공개 읽기 권한 적용

        // Presigned URL 발급
        URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
        return url.toString();
    }
}</code></pre>
<h3 id="실행-및-결과-1">실행 및 결과</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/24d0342f-b2da-4b80-8a52-cbe1a573ee17/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/62fce2f4-5904-4ba5-8c50-3119bafdc1ec/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/6b212f06-e868-4a2e-9514-076169225c54/image.png" alt=""></p>
<p>성공적으로 업로드된 것을 확인할 수 있다.</p>
<blockquote>
<p><strong>not allow ACLs, Access Denied</strong>
ACL 문제는 버켓-권한-객체 소유권 설정을 ACL 활성화됨으로 변경해야 한다.
Access Denied 문제는 버켓-권한-퍼블릭 액세스 차단(버킷 설정)을 변경해보면서 확인해야 한다.</p>
</blockquote>
<blockquote>
<p>개발한 코드는 <a href="">Github Repository</a>에서 확인할 수 있다.</p>
</blockquote>
<hr>
<p><strong>더 고려해야 할 점</strong>
실제 서비스 개발 과정에서 사용할 경우, 아래와 같은 점을 추가로 고려해야 한다.</p>
<ol>
<li>파일 이름 중복 문제</li>
</ol>
<p>파일이름이 중복되어, 덮어쓰는 상황이 발생할 수 있다. GCS 기준,동일한 파일명일 경우 최신 파일로 덮어쓰게 되기에, 이러한 상황을 예방하기 위해서는 UUID를 생성하여 이를 파일명에 붙여야 한다. 즉, 파일 테이블에 원본 파일명은 별도로 관리해야 할 수 있다는 것이다. 이 문제는, 아래의 디렉토리 경로 설정 문제와 같이 고려애야 한다.</p>
<ol start="2">
<li>디렉토리 경로 설정</li>
</ol>
<p>업로드하고자 하는 파일이 기능별로 구분되어야 할 경우, 업로드되는 경로를 파일이름 앞에 추가해야 한다. 이렇게 구분하지 않을 경우, 어떤 기능에 대해 업로드된 파일인지를 인지하기 어려워 더 많은 비효율을 야기할 것이다. 이 문제는 위의 파일 이름 중복 문제와 함께 고려해서 적용해야 한다.</p>
<hr>
<p><strong>후기</strong>
GCS 오류를 마주쳐서, 이를 해결하는 김에 S3도 같이 정리를 진행했다. 재사용할 수 있는 코드를 만들어놓을 수 있다는 점과 생각하고 있었던 내용을 정리해볼 수 있어서 좋은 기회였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Elastic Search와 Open Search, 그리고 VectorDB]]></title>
            <link>https://velog.io/@dl-00-e8/Elastic-Search%EC%99%80-Open-Search-%EA%B7%B8%EB%A6%AC%EA%B3%A0-VectorDB</link>
            <guid>https://velog.io/@dl-00-e8/Elastic-Search%EC%99%80-Open-Search-%EA%B7%B8%EB%A6%AC%EA%B3%A0-VectorDB</guid>
            <pubDate>Mon, 15 Jul 2024 14:53:49 GMT</pubDate>
            <description><![CDATA[<p>이번에 <a href="https://ausg.me/">AUSG</a>에 지원해서 합격했는데, 서류 작성 시 아래의 문항에 대한 답변을 작성해야 했다.
<code>AUSG에서 어떤 활동을 하고 싶은지 알려주세요!</code>
이 문항에 대하여 스터디와 대규모 트래픽이 존재하는 IT 서비스 인프라 설계해보기 이렇게 두 개의 활동을 해보고 싶다고 답변했었다. 스터디는 K8S 스터디와 검색 엔진과 관련된 스터디를 해보고 싶다고 작성했었는데, 스터디를 참여하기 전에 검색 엔진에 대해서 한 번 공부해보면 좋을 것 같아 이 주제를 LIVID에서 수박 겉핥기 수준으로 정리해서 공유하기로 결정했다.</p>
<h1 id="elastic-search-open-search">Elastic Search, Open Search</h1>
<h2 id="elastic-search와-open-search의-역사">Elastic Search와 Open Search의 역사</h2>
<p>과거 Elastic Search와 Open Search가 사실상 같은 제품이라고 들었던 기억이 있었는데, 현재는 다른 제품으로 구분하기에 관련된 내용을 정리하면서 시작하고자 한다.</p>
<p>Elastic의 Elastic Search가 오픈 소스로 공개된 이후, AWS는 Elastic의 Elastic Search를 기반으로 한 AWS 내에서 제품을 출시하여 사용자들에게 제공해왔었다.</p>
<p>그러던 중, Elastic Search가 SSPL 라이선스로 제품이 전환되었고, AWS는 이에 대응하여 SSPL 라이선스 정식 적용 직전 해당 오픈소스를 fork하여 Document DB와 Open Search를 출시하면서 유사하지만 다른 제품으로 분리되었다.</p>
<p>분리된 이후 Elastic Search와 Open Search는 다른 방향의 업데이트를 지속해왔다.</p>
<p>Elastic Search는 Managed Service를 강화하며 사용성과 편의성에 중점을 두었으며, Open Search는 보안이나 인증 등에 대해 집중하고 있다.</p>
<p>(출처: <a href="http://cloudinsight.net/cloud/elastic%EA%B3%BC-opensearch-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4%EC%99%80-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4/">Elastic과 Opensearch: 오픈 소스를 둘러싼 대립</a>, 2024-07-15)</p>
<h2 id="elastic-search">Elastic Search</h2>
<p>Elastic에서는 <a href="https://www.elastic.co/kr/elasticsearch">Elastic Search</a>에 대해  시간이 갈수록 증가하는 문제를 처리하는 분산형 RESTful 검색 및 분석 엔진이라고 설명하고 있다.</p>
<h3 id="특징">특징</h3>
<ul>
<li>분산 시스템이기에 수평적 확정이 가능</li>
<li>RESTful하기에, Data의 CRUD 작업이 HTTP Restful API를 활용해 수행 가능</li>
<li>새로운 문서를 indexing할 때부터 검색 가능한 대기 시간이 1초 정도 걸림.</li>
<li>Document형 DB</li>
<li>스키마가 아닌, Json형식의 문서로 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html">Query DSL</a>을 이용해 문서 탐색</li>
</ul>
<blockquote>
<p>여기서의 Query DSL은 JPA에서 활용되는 Query DSL이 아닌 Json 형식의 검색 질의 방식</p>
</blockquote>
<h2 id="open-search">Open Search</h2>
<p><a href="https://aws.amazon.com/ko/what-is/opensearch/">AWS OpenSearch 기본 설명서</a>에서는 Open Search를 Apache 2.0 라이선스 하에 제공되는 분산형 커뮤니티 기반 100% 오픈 소스 검색 및 분석 제품군으로, 실시간 애플리케이션 모니터링, 로그 분석 및 웹 사이트 검색과 같이 다양한 사용 사례에 사용할 수 있는 제품이라고 설명하고 있다.</p>
<h3 id="특징-1">특징</h3>
<ul>
<li>Elastic Search와 유사한 기능 제공</li>
<li>보안 플러그인, 알림 기능 등 추가 기능 포함</li>
<li>Elastic Search API와 호환성 유지 </li>
</ul>
<blockquote>
<p>Elastic Search API와는 완벽한 호환성을 제공하지 않기에, 유의해야 한다.</p>
</blockquote>
<h2 id="동작-원리">동작 원리</h2>
<p>Elastic Search의 동작 원리에 대해 잘 정리되어 있는 <a href="https://sihyung92.oopy.io/database/elasticsearch/1">블로그 글</a>을 첨부한다.</p>
<p>쉽게 정리하면 아래와 같이 동작한다.</p>
<ol>
<li>문서 저장</li>
<li>역색인 자료구조 저장</li>
<li>Analyzer를 활용한 텍스트 분석</li>
</ol>
<h2 id="어떤-제품을-선택해야-할까">어떤 제품을 선택해야 할까?</h2>
<p>라이센스/레퍼런스 등의 측면에서 구분해놓은 <a href="https://velog.io/@yekim/AWS-OpenSearchElasticsearch-Elasticsearch-VS-OpenSearch">블로그 글</a>이 있어 첨부한다.
이를 참고해서 선택하면 좋을 것 같다.</p>
<h1 id="vectordb">VectorDB</h1>
<p>사실 VectorDB는 위의 두 검색 엔진과는 다르게 데이터베이스의 한 종류이다. VectorDB를 짧게 정의해보면, <strong>고차원의 벡터 데이터들에 대한 저장 및 조회를 높은 성능으로 할 수 있는 데이터베이스</strong>이다. </p>
<h2 id="검색">검색</h2>
<p>VectorDB를 이용해서도 검색을 구현할 수 있는데, 관련된 내용을 Google Summit 세션으로 들었기에, 세션에서 사용된 이미지를 가지고 정리해본다.</p>
<p>다만 아래의 예시는 이미지/비디오/텍스트를 모두 아우르기에 검색과는 다른 부분이 명확히 존재한다는 점을 유의해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/5a916ebb-8c57-45d1-8775-45e71ea07c81/image.png" alt="">
먼저, 입력이 들어온다면 위와 같이 텍스트 임베딩 모델을 활용하여 임베딩을 진행한다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/0f48de15-aa56-481d-87e2-d81cd1c49703/image.png" alt="">
임베딩 과정에서, AI는 벡터값을 부여한다. 
위의 이미지를 보면 food/plant/animal이라는 3축이 존재하며, 축별로 값을 가지는 것을 알 수 있다.
이를 통해, 유사한 의미를 가지는 데이터일 경우, 벡터값의 차이가 적다는 것을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/77657328-560f-4396-b00f-75e5ec8eb23b/image.png" alt="">
최종적으로 검색할 경우, 임베딩된 공간에서 가장 높은 유사도를 가지고 있는 데이터를 찾아 반환하게 되는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/2c7c46d9-ff43-497c-a273-2dfd734561f7/image.png" alt="">
위의 과정을 하나의 장표로 정리한 내용이다.
임베딩을 통해 벡터값으로 변환시킨 뒤 인덱스를 부여하고, 이렇게 벡터공간에 위치한 데이터를 검색할 수 있다는 것이다.</p>
<blockquote>
<p>이 부분은 지식적으로 많이 부족해서, 틀린 점이 존재할 가능성이 있기에 &quot;이런게 있구나.&quot;와 같이만 이해하면 좋을 것 같다.</p>
</blockquote>
<h1 id="rdb를-검색엔진에-어떻게-적용할까">RDB를 검색엔진에 어떻게 적용할까?</h1>
<p>이 주제를 정하게 된, 가장 큰 이유는 <code>RDB에 저장되어 있는 데이터를 어떻게 Document형으로 변환하여 적재하고 검색 엔진에서 검색이 가능하도록 할것인가?</code>에 대한 궁금증 해결이다.
이 궁금증을 해결하기 위해 관련된 내용을 찾아보고 학습하다 <a href="https://helloworld.kurly.com/blog/2023-review-opensearch/">후기 서비스 AWS Opensearch 도입기</a>라는 마켓 컬리 기술 블로그 글을 읽게 되었다. 해당 글이 관련해서 가장 많은 도움을 받아서 해당 내용을 포함하여 정리해보고자 한다.</p>
<h2 id="내가-생각한-방법">내가 생각한 방법</h2>
<h3 id="데이터-모델-설계">데이터 모델 설계</h3>
<p>먼저, RDB에 있는 데이터를 검색엔진에 넣기 전에 데이터 모델을 설계해야 한다고 판단했다.
검색엔진은 Document가 <strong>Json 형식으로 저장</strong>되기에, RDB의 데이터를 그대로 저장할 수 없으므로 변환이 필요하고, 이를 인덱스 및 필드로 매핑시켜야 할 것이다.</p>
<h3 id="인덱스-설계">인덱스 설계</h3>
<p>검색 엔진은 <code>가상 면접 사례로 배우는 대규모 시스템 설계 기초</code> 책에서 나온 것과 동일하게 역인덱스를 활용하기에, 검색 성능을 최대한으로 내기 위해서는 인덱스 설계가 중요하다고 생각했다. </p>
<p>인덱스를 생성할 때에는 형태소 분석기 등을 활용하여 토큰화하는 방식을 사용한다.
형태소 분석기가 무엇인지에 따라 달라질 수 있기에 주의해서 결정해야 한다.
실제로, 마켓 컬리도 <strong>은전한닢 형태소 분석기</strong>를 사용했을 당시 <strong>오프셋 역전 현상</strong>이 발생했었으며,이후 추가된 <strong>Nori 형태소 분석기</strong>로 변경하여 해당 문제를 해결했다고 한다.</p>
<blockquote>
<p>토큰화와 관련된 내용은 <a href="https://techblog.yogiyo.co.kr/%EA%B2%80%EC%83%89%EC%97%94%EC%A7%84%EC%9D%98-analyzer-%ED%98%95%ED%83%9C%EC%86%8C%EB%B6%84%EC%84%9D%EA%B8%B0-%ED%86%A0%ED%81%AC%EB%82%98%EC%9D%B4%EC%A0%80-5878af195d14">요기요 기술 블로그</a>가 잘 정리되어 있는 것 같아 참고하면 좋을 것 같다.</p>
</blockquote>
<p>Full-text search를 사이드 프로젝트에서 적용한 과정을 정리했었기에, 혹시 <a href="https://velog.io/@dl-00-e8/MySQL-Full-text-search-%EB%8F%84%EC%9E%85">Full-text search 도입기</a>가 궁금하면 한 번 읽어보면 된다.</p>
<h3 id="데이터-변환-및-적재">데이터 변환 및 적재</h3>
<p>나였다면, 카프카를 활용해 실시간으로 데이터를 검색 서버에 반영할 수 있도록 인프라를 구축할 것 같다. 
<strong>API 서버 &lt;-&gt; 카프카 &lt;-&gt; 검색 서버 &lt;-&gt; 검색 엔진</strong>
카프카는 실시간으로 높은 성능으로 데이터를 처리할 수 있으므로, 다량의 데이터가 추가되더라도 데이터 유실 없이 검색 서버에 반영할 수 있을 것이라고 생각했다.</p>
<p>실시간으로 반영되지 않아도 되는 데이터들은 <strong>트래픽이 가장 낮은 시간</strong>대에 <strong>배치를 활용</strong>해 적재할 것 같다.</p>
<blockquote>
<p>위의 과정에서, 데이터 변환은 카프카를 소비하는 검색 서버에서 진행한다.</p>
</blockquote>
<h3 id="검색-쿼리-개발">검색 쿼리 개발</h3>
<p>모든 데이터를 적재하는 과정이 끝났으므로, 검색엔진에서 사용할 검색 쿼리를 개발해야 한다. Json형식의 데이터를 찾기 위한 Query DSL을 제공하므로 이를 활용하면 된다고 생각했다.
<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html">Query DSL 예시</a> 문서를 통해 찾아본 결과, Golang 기준 아래와 같이 활용할 수 있다.</p>
<pre><code class="language-go">res, err := es.Search(
    es.Search.WithBody(strings.NewReader(`{
      &quot;query&quot;: {
        &quot;bool&quot;: {
          &quot;must&quot;: [
            {
              &quot;match&quot;: {
                &quot;title&quot;: &quot;Search&quot;
              }
            },
            {
              &quot;match&quot;: {
                &quot;content&quot;: &quot;Elasticsearch&quot;
              }
            }
          ],
          &quot;filter&quot;: [
            {
              &quot;term&quot;: {
                &quot;status&quot;: &quot;published&quot;
              }
            },
            {
              &quot;range&quot;: {
                &quot;publish_date&quot;: {
                  &quot;gte&quot;: &quot;2015-01-01&quot;
                }
              }
            }
          ]
        }
      }
    }`)),
    es.Search.WithPretty(),
)
fmt.Println(res, err)</code></pre>
<h2 id="마켓-컬리의-방법">마켓 컬리의 방법</h2>
<p>RDB의 데이터베이스에서 Json형식으로 변환하는 것은 당연한 내용이라 <code>후기 원천 데이터를 모두 역정규화하여 운영 AWS Opensearch 인덱싱</code>이라는 문장으로 정리되어 있다.
그렇기에, 데이터 동기화 과정을 중점적으로 정리한다.</p>
<h3 id="데이터-동기화">데이터 동기화</h3>
<p>마켓 컬리에서는 Event Driven Architecture를 선택했다.
<img src = 'https://helloworld.kurly.com/post-img/2023-review-opensearch/review-event-architecture.png' width = 400>
(그림: <a href="https://helloworld.kurly.com/blog/2023-review-opensearch/#aws-opensearch-vs-elasticcloud">컬리</a>, © 2023. Kurly. All rights reserved.)</p>
<p>Event Driven Architecture를 선택한 이유 중 마켓 컬리의 서비스와 상관없이 가장 대표적인 이유는 아래와 같다.</p>
<ul>
<li>후기 원천 데이터는 저장 성공 이후, Opensearch에서 실패하는 경우 사용자에게 실패 응답을 반환할 수 있어야 함.</li>
<li>동기화한다면, 후기 서버가 아닌 OpenSearch에서 문제가 발생했을 경우에도 응답이 지연될 수 있다는 점</li>
<li>SRP(단일 책임 원칙)의 위반 발생</li>
</ul>
<blockquote>
<p><strong>왜 SRP 위반인가?</strong>
일단 원본 데이터를 저장하는 것과 역정규화된 데이터를 저장하는 것이 두 가지 책임으로 분리된다. 만약 이걸 카프카 없이 하나의 플로우에서 관리한다면, 두 가지 책임이 하나의 플로우에서 관리되므로 SRP 위반으로 간주할 수 있다는 것이다.</p>
</blockquote>
<h3 id="인상깊었던-점">인상깊었던 점</h3>
<p>정리되어 있는 모든 내용이 재밌었고, 많은 것을 배울 수 있었지만 느낀 점이 가장 인상 깊었다.
<code>데이터 직군이 아닌 개발자라도 운영하고 있는 서비스에 대한 데이터에 대한 관심을 가져야 하며, 데이터를 가지고 더 좋은 방향성을 만들어 낼 수 있도록 해야 합니다.</code>
데이터 기반으로 짧은 주기의 업데이트를 지향하는 내 관점과 일치해서 그런 것 같기는 한데, 앞으로도 데이터 기반으로 최고의 사용자 경험을 만들어 나가야겠다.</p>
<blockquote>
<p>스터디에서 공유하고자 정리한 내용을 올린 글입니다.</p>
</blockquote>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://www.elastic.co/kr/amazon-opensearch-service">Elastic</a></li>
<li><a href="https://helloworld.kurly.com/blog/2023-review-opensearch/">마켓 컬리 기술 블로그</a></li>
<li><a href="https://velog.io/@yekim/AWS-OpenSearchElasticsearch-Elasticsearch-VS-OpenSearch">Open Search와 Elastic Search 기술 블로그</a></li>
<li><a href="http://cloudinsight.net/cloud/elastic%EA%B3%BC-opensearch-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4%EC%99%80-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4/">Open Search와 Elastic Search의 대입 기술 블로그</a></li>
</ul>
<p><strong>읽어보면 좋을 관련 글</strong></p>
<ul>
<li><a href="https://zuminternet.github.io/SearchPilotProject/">줌의 검색 파일럿 프로젝트</a></li>
<li><a href="https://medium.com/daangn/%EB%8B%B9%EA%B7%BC%EB%A7%88%EC%BC%93-%EA%B2%80%EC%83%89-%EC%97%94%EC%A7%84-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4%EB%A1%9C-%EC%89%BD%EA%B2%8C-%EC%9A%B4%EC%98%81%ED%95%98%EA%B8%B0-bdf2688df267">당근의 검색 엔진 운영 글</a></li>
<li><a href="https://gruuuuu.github.io/ai/vector-store/">호다닥 톺아보는 VectorDB 기초</a></li>
<li><a href="https://www.comworld.co.kr/news/articleView.html?idxno=51034">생성형 AI 시대, 꽃피우는 ‘벡터 DB’ 뉴스 글</a></li>
<li><a href="https://velog.io/@choi-yh/%EA%B2%80%EC%83%89%EC%97%94%EC%A7%84-%EB%AC%B4%EC%8B%A0%EC%82%AC-%EA%B2%80%EC%83%89-%EC%B6%94%EC%B2%9C-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%95%EB%A6%AC">Velog 무신사 검색 추천 시스템 관련 글</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot/MySQL] 공작소 Full-text search 도입기]]></title>
            <link>https://velog.io/@dl-00-e8/MySQL-Full-text-search-%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@dl-00-e8/MySQL-Full-text-search-%EB%8F%84%EC%9E%85</guid>
            <pubDate>Sun, 14 Jul 2024 11:49:53 GMT</pubDate>
            <description><![CDATA[<p>공작소는 페이지에 아래와 같이 검색 기능을 제공하고 있다.
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/964ae695-d918-4891-8f8b-58f87fcb9eae/image.png' width = 400></p>
<p>현재, 해당 검색 기능은 아래와 같이 개발되어 있는 상황이다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/ae6ae22b-d9d2-4001-86f3-d0db22eaccdb/image.png" alt=""></p>
<p>현재 검색은 팀 모집 공고의 제목을 기반으로만 검색이 가능하며, 공고가 많아질 경우 Full Scan으로 동작하는 Like문의 특성상 성능의 문제가 발생할 것으로 보여 Full-text search로 변환을 결정했다.</p>
<h1 id="full-text-search와-like">Full-text search와 Like</h1>
<h2 id="mysql의-검색타입">MySQL의 검색타입</h2>
<p>Full-text search와 Like를 알아보기 전, MySQL의 검색 타입에 대해 정리하면 아래와 같다.</p>
<ul>
<li><p>all, index: 이 두 타입은 테이블 전체를 스캔하는 것으로, 인덱스를 사용하지 않고 모든 행을 검색한다. 특히 all은 <strong>LIKE 절을 사용한 검색에서 많이 발생</strong>할 수 있다. <strong>데이터가 많은 경우 성능 저하를 유발</strong>할 수 있다.</p>
</li>
<li><p>range: 이 타입은 인덱스를 사용하여 범위 검색을 수행한다. 예를 들어, WHERE 절에서 BETWEEN을 사용한 경우에 해당된다. 인덱스를 효율적으로 사용하여 데이터를 검색하므로 일반적으로 빠른 검색 속도를 제공한다.</p>
</li>
<li><p>fulltext: MATCH...AGAINST 구문을 사용하여 Full Text Search를 실행할 때 사용된다. Full Text Search는 <strong>전용 인덱스를 사용하여 텍스트 검색을 빠르게 수행</strong>한다.</p>
</li>
<li><p>ref, eq_ref, const: 이들은 주로 <strong>조인 작업에서 발생</strong>하는 타입이다. ref는 일반적인 인덱스 검색을 나타내며, eq_ref는 유일한 인덱스 또는 기본 키를 통해 한 개의 행만을 검색한다. const는 상수 값을 기반으로 한 인덱스 검색을 의미하며, 가장 빠른 검색 타입 중 하나이다.</p>
</li>
<li><p>system: 데이터가 없거나 한 개만 있는 경우에 해당되며 잘 사용되지 않는다.</p>
</li>
</ul>
<h2 id="like">Like</h2>
<p>Like문을 활용한 예시 쿼리를 살펴보면 아래와 같다.</p>
<pre><code class="language-sql">SELECT * from table where column like &#39;%input%&#39;</code></pre>
<p>즉, column에 해당하는 값 중 <code>input</code>을 포함하고 있는 값을 찾는 쿼리인데, 패턴이 문자열의 양쪽에 존재하기 때문에 인덱스를 활용할 수 없다.
검색 문자열인 <code>%input%</code>은 크게 3가지로 나누어 볼 수 있다.</p>
<ol>
<li><p><code>input%</code></p>
</li>
<li><p><code>%input</code></p>
</li>
<li><p><code>%input%</code></p>
</li>
</ol>
<p>1번은 첫 문자가 패턴이 아닌 문자열이므로 인덱스를 활용할 수 있지만 2번과 3번은 패턴이므로 인덱스를 활용할 수 없다. 즉, Full Scan으로 일치하는 데이터를 찾을 수 밖에 없기에 데이터 양이 늘어날수록 성능 저하의 폭이 커진다.</p>
<h2 id="full-text-search">Full-text search</h2>
<p>Full-text search는 전체 텍스트 내에서 검색을 효과적으로 수행하기 위한 방식이다. Full-text index를 활용해 텍스트 데이터를 효율적으로 인덱싱하여 검색 성능을 향상시킬 수 있다. 
MySQL 기준, Full-text search를 활용하려면  <code>MATCH () AGAINST ()</code> 문법을 사용해야 한다. 예시는 아래와 같다.</p>
<pre><code class="language-sql"># MATCH() in SELECT list...
SELECT MATCH (a) AGAINST (&#39;abc&#39;) FROM t GROUP BY a WITH ROLLUP;
SELECT 1 FROM t GROUP BY a, MATCH (a) AGAINST (&#39;abc&#39;) WITH ROLLUP;

# ...in HAVING clause...
SELECT 1 FROM t GROUP BY a WITH ROLLUP HAVING MATCH (a) AGAINST (&#39;abc&#39;);

# ...and in ORDER BY clause
SELECT 1 FROM t GROUP BY a WITH ROLLUP ORDER BY MATCH (a) AGAINST (&#39;abc&#39;);</code></pre>
<p>Full-text Search는 Natural lanague mode, Boolean mode, Query extension 모드 등으로 구분할 수 있다. 여기서는 Natural language mode와 Boolean mode의 개념 및 차이만 정리한다.</p>
<h3 id="natural-language-mode">Natural language mode</h3>
<p><a href="https://dev.mysql.com/doc/refman/8.4/en/fulltext-natural-language.html">MySQL Docs</a>에서의 Natural language mode 정의는 <code>검색 문자열과 목록에 명명된 열의 해당 행에 있는 텍스트 간의 유사성을 측정하는 방식</code>이다. 
즉, 검색하고자 하는 문자열을 단어 단위로 분리한 이후, 해당 단어 중 하나라도 포함되는 데이터를 찾는 방식으로, 얼마나 많은 키워드가 포함되어 있는지(= 매치율)를 기반으로 높은 관련성 순서대로 결과값을 반환한다.</p>
<pre><code class="language-sql">SELECT COUNT(*) 
FROM articles 
WHERE MATCH (title,body) AGAINST (&#39;database&#39; IN NATURAL LANGUAGE MODE);</code></pre>
<p>아래 쿼리와 같이 직접 매치율이 얼마나 되는지를 확인할 수 있다.</p>
<pre><code class="language-sql">SELECT match() AGAINST() AS match_rate 
FROM film;</code></pre>
<h3 id="boolean-mode">Boolean mode</h3>
<p><a href="https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html">MySQL Docs</a>에서 Boolean mode 정의는 <code>수정자를 사용하여 전체 텍스트 검색을 수행하는 방식</code>이다. 여기서의 수정자는 <code>+</code>나<code>-</code> 등을 의미한다.
즉, 문자열을 단어 단위로 분리한 이후, 아래의 예시 쿼리와 같이 추가적인 검색 규칙을 적용하여 이를 만족시키는 데이터를 찾는 방식이다.</p>
<pre><code class="language-sql">SELECT * 
FROM articles 
WHERE MATCH (title,body) AGAINST (&#39;+MySQL -YourSQL&#39; IN BOOLEAN MODE);</code></pre>
<h2 id="두-방식-간-성능-비교">두 방식 간 성능 비교</h2>
<p>먼저 Full-text search와 Like문 간의 성능을 비교하고자 한다.
Full-text search의 Natural language mode를 기준으로 한다.</p>
<h3 id="성능-측정-환경-설정">성능 측정 환경 설정</h3>
<p>쿼리 수행 시 성능 지표 등을 확인하기 위해서는 쿼리 프로파일링(Query Profiling)을 활용해야 한다.</p>
<blockquote>
<p><strong>쿼리 프로파일링(Query Profiling)</strong>
MySQL 5.1 버전부터 지원하는 각 단계별 작업에 시간이 얼마나 걸렸는지 확인할 수 있는 기능</p>
</blockquote>
<p>MySQL 기준, 프로파일링 옵션이 켜져있는지 확인하려면 아래의 명령어를 사용하면 된다.</p>
<pre><code class="language-sql">show variables like &#39;profiling%&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/05c3a4b5-70a1-4708-8f11-b4a36ab14bde/image.png" alt=""></p>
<p>만약 프로파일링 옵션이 off로 설정되어 있다면 아래 명령어를 활용해서 프로파일링 옵션을 활성화할 수 있다.</p>
<pre><code class="language-sql">set profiling = &#39;ON&#39;</code></pre>
<p>확인하고 싶은 프로파일링 기록의 수를 늘리려면 아래의 명령어를 활용하면 된다.</p>
<pre><code class="language-sql">set profiling_history_size = {기록의 수};</code></pre>
<p>쿼리 프로파일링 목록은 아래 명령어로 확인할 수 있다.</p>
<pre><code class="language-sql">show profiles;</code></pre>
<p>특정 쿼리에 대해 자세히 보고자 한다면, 해당 쿼리 ID를 지정해서 확인할 수 있다.</p>
<pre><code class="language-sql">show profile for query {Query_ID};</code></pre>
<h4 id="예시">예시</h4>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/d3337d69-cfc4-4731-854b-4e3a04c18d04/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/aad0e687-4dfa-4ab1-a5e8-1e861dd960ce/image.png" alt=""> 위 이미지에서, duration이 해당 쿼리가 실행되는데 소요된 시간이다. <img src="https://velog.velcdn.com/images/dl-00-e8/post/a733d2bb-ec2c-4ed8-8ec3-f033f0c88ba5/image.png" alt=""> </p>
<h3 id="ngram-parser">Ngram parser</h3>
<p>Ngram Parser는 MySQL의 Full-text parser에 사용되는 내장 parser이다. 단어의 시작과 끝을 지정해 파싱하는 구분점이 되도록 토큰화하는 것으로, 중국어/일본어/한글을 지원한다.
즉, <code>abcd</code>라는 문자열이 있다면 이를 <code>ab</code>, <code>bc</code>, <code>cd</code>등의 토큰화시킨다.
특이점은 공백을 무시한 후 토큰화하기에, <code>ab cd</code>의 결과값도 위와 동일하다.</p>
<p>토큰으로 만드는 사이즈는 아래 명렁어로 확인한다. (기본값은 2이다.)</p>
<pre><code class="language-sql">SHOW GLOBAL VARIABLES LIKE &quot;ngram_token_size&quot;;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/8196912b-78b9-4599-86aa-2ddf52d7d2db/image.png" alt=""></p>
<p>만약 사이즈를 변경하고 싶다면, 서버 시작 시 아래와 같이 옵션을 지정해야 한다.</p>
<pre><code class="language-shell">mysqld --ngram_token_size=2</code></pre>
<p>토큰 사이즈가 작을수록 전체 텍스트 검색 색인이 작아져 검색 속도가 빨라진다는 것을 인지하고 있어야 한다.</p>
<p><strong>여기서는 기본 사이즈인 2를 기준으로 진행한다.</strong></p>
<h3 id="full-text-index">Full-text index</h3>
<p>MySQL의 검색타입에 정리했듯이, Full-text search를 활용하기 위해서는 전용 인덱스를 사용해야 한다. 전용 인덱스가 바로 Full-text index다.</p>
<p>Full-text index는 테이블 생성 시 지정이 가능하며, 이미 생성했다면 <code>ALTER</code>를 활용하여 추가할 수 있다.</p>
<pre><code class="language-sql">// CREATE
CREATE TABLE articles (
      id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
      title VARCHAR(200),
      body TEXT,
      FULLTEXT (title,body) WITH PARSER ngram
    ) ENGINE=InnoDB CHARACTER SET utf8mb4;

// ALTER
CREATE TABLE articles (
      id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
      title VARCHAR(200),
      body TEXT
     ) ENGINE=InnoDB CHARACTER SET utf8mb4;

ALTER TABLE articles ADD FULLTEXT INDEX ft_index (title,body) WITH PARSER ngram;</code></pre>
<h3 id="예시-데이터-삽입">예시 데이터 삽입</h3>
<p>articles라는 테이블에 제목과 본문이라는 두 개의 칼럼이 존재한다고 가정하고 데이터를 삽입한다. 예시 데이터 삽입 과정은 Claude의 도움을 받아 진행했다.</p>
<pre><code class="language-sql">// 테이블 생성
CREATE TABLE articles (
      id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
      title VARCHAR(200),
      body TEXT,
      FULLTEXT (title,body) WITH PARSER ngram
    ) ENGINE=InnoDB CHARACTER SET utf8mb4;

// 찾고자하는 예시 데이터 삽입
INSERT INTO articles (title, body) VALUES
(&#39;MySQL 성능 최적화&#39;, &#39;MySQL 데이터베이스의 성능을 최적화하는 방법에 대해 알아봅시다.&#39;),
(&#39;Full-text Search 사용법&#39;, &#39;MySQL에서 Full-text Search를 효과적으로 사용하는 방법을 설명합니다.&#39;),
(&#39;Like 연산자의 장단점&#39;, &#39;Like 연산자는 간단하지만 대규모 데이터에서는 성능 이슈가 있을 수 있습니다.&#39;),
(&#39;인덱스 설계의 중요성&#39;, &#39;효율적인 인덱스 설계는 데이터베이스 쿼리 성능 향상에 큰 영향을 미칩니다.&#39;),
(&#39;데이터베이스 백업 전략&#39;, &#39;안전한 데이터 관리를 위한 효과적인 백업 전략을 수립해야 합니다.&#39;);

// 랜덤 텍스트를 활용해 데이터 수 늘리기
INSERT INTO articles (title, body)
SELECT
    CONCAT(&#39;제목 &#39;, FLOOR(RAND() * 1000000)),
    CONCAT(&#39;내용 &#39;, REPEAT(&#39;Lorem ipsum &#39;, FLOOR(RAND() * 100)))
FROM
    information_schema.columns
LIMIT {늘리고자 하는 칼럼 수};</code></pre>
<h3 id="성능-측정-결과">성능 측정 결과</h3>
<h4 id="like-1">Like</h4>
<pre><code class="language-sql">SELECT * FROM articles WHERE title LIKE &#39;%성능%&#39; OR body LIKE &#39;%성능%&#39;;</code></pre>
<h4 id="full-text-search-1">Full-text search</h4>
<pre><code class="language-sql">SELECT * FROM articles WHERE MATCH(title, body) AGAINST(&#39;성능&#39; IN NATURAL LANGUAGE MODE);</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/0038031d-2430-4a61-9fa6-3c9ecb127e23/image.png" alt=""></p>
<p>약 30,000건의 데이터 기준 성능은 위와 같다. 빨간색이 Like 쿼리이며, 파란색이 Full-text search이다.</p>
<p><strong>데이터의 수가 적고 칼럼의 수도 적기에 엄청난 차이를 보이지는 않지만 유의미한 차이를 만들 수 있다는 것을 알 수 있다.</strong></p>
<h1 id="spring-boot--mysql-도입">Spring Boot + MySQL 도입</h1>
<h2 id="개발-환경">개발 환경</h2>
<ul>
<li>Java17</li>
<li>Spring Boot 3.x</li>
<li>MySQL 8.x</li>
</ul>
<h2 id="full-text-search용-칼럼-등록하기">Full-text search용 칼럼 등록하기</h2>
<p>공작소에서 검색이 적용되는 테이블은 <code>Post</code> 테이블이다.
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/00482f8d-6890-4863-b49e-0834af45c53f/image.png' width = 200>
현재 검색에서 활용될 칼럼은 <code>title</code>과 <code>contents</code>칼럼이다.</p>
<p>그렇기에, ft_index라는 이름을 가진 FULLTEXT INDEXT 타입의 인덱스를 추가했다.</p>
<pre><code class="language-sql">ALTER TABLE post ADD FULLTEXT INDEX ft_index (title, contents) WITH PARSER ngram;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/0f4d5490-2983-49ad-842b-99bb98c3e0ab/image.png" alt=""></p>
<h2 id="refactoring">Refactoring</h2>
<p>이번 Full-text search로 전환하는 김에 리팩토링을 진행하기로 결정했고, 리팩토링 이후 쿼리를 변경해야 하므로 이 과정을 먼저 정리한다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/d70e2d77-4a77-4714-b734-8eda195cb639/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/03689fcc-c12a-4606-be3f-c95bf53d9397/image.png" alt=""></p>
<p>검색이 들어간 API는 두 가지다.</p>
<ul>
<li>공모전 공고 목록 조회 및 페이지네이션 API</li>
<li>프로젝토 공고 목록 조회 및 페이지네이션 API</li>
</ul>
<p>같은 테이블에 존재하지만, 공고의 타입에 따라 필터도 달라질 수 있기에 두 엔드포인트로 구분해서 개발했다. </p>
<p>개선하고자 하는 점은 아래와 같다.</p>
<ol>
<li><p><strong>필터 적용 계층의 결정</strong>
정렬 조건을 제외하고, 나머지 조건에 대해서는 Controller 계층에서 조건 분기 후 Service 계층을 호출하고 있다. 이는 필터 조건을 묶어서 관리하지 않으므로, 추후 다른 필터가 추가될 때마다 혼돈을 야기할 수 있다고 생각했다. 필터 적용 여부는 사용자 입력에 따라 비즈니스 로직에서 분기되어야 한다고 판단하여 Controller 계층에 있든 분기를 Service 계층으로 변경하는 것으로 결정했다.</p>
</li>
<li><p><strong>코드 가독성 문제(= 유지보수 문제)</strong>
검색이라는 한 기능에 대해 총 8번의 조건 분기를 가지고 있다 보니, 추후 유지보수 관점에서 새로운 조건이 생긴다는 등의 상황에서 비효율을 가져올 것이라고 생각했다.</p>
</li>
</ol>
<p>위의 두 가지 개선점을 고려한 결과, JPA의 @Query를 활용해서 <strong>Native Query를 직접 작성</strong>해서 하나의 쿼리로 1번을 명확히 해결하며, 2번에서 유지보수 코스트를 그나마 줄일 수 있는 방안이라고 판단했다. 
(Query DSL의 도입은 QueryDSL을 사용해본 팀원이 없고, 첫 프로젝트인 사람들이기에 일단 JPA를 더 다룬 뒤에 도입 여부를 결정하기로 했다.)</p>
<blockquote>
<p>Sort를 Pageable에 포함시켜야 하지만, 프론트와 동시에 배포가 되어야 해당 방식을 다운타임 없이 적용할 수 있으므로, 임의로 Service 계층에서 Pageable 객체를 재생성하는 방식을 택했다.</p>
</blockquote>
<h2 id="full-text-search-적용">Full-text search 적용</h2>
<p>위의 리팩토링하고자 하는 내용을 포함해서 Full-text search를 적용한 쿼리로 변경했다. </p>
<h3 id="1n의-관계-처리하기">1:N의 관계 처리하기</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/b820789e-a669-49ed-8b56-419f108c9acf/image.png" alt="">
데이터베이스의 구조를 보면, 연관 관계로 인해 굉장히 많은 요소를 고려해야했다.</p>
<ul>
<li>Member:Post = 1:1</li>
<li>Post:Category = 1:1</li>
<li>Post:PostScrap = 1:N</li>
<li>Post:StackName = 1:N</li>
</ul>
<p>PostScrap의 경우, ScrapCount를 Post의 칼럼으로도 가지고 있는 반정규화를 통해 페이지네이션 시의 JOIN을 걸지 않을 수 있다.
다만, StackName은 검색 필터로도 사용되다보니 이는 필수적으로 JOIN을 묶어줘야 한다.</p>
<p>위와 같은 상황에서 2가지 방법을 고민했고, 그 중 후자의 방식을 선택했다.</p>
<h4 id="n--1-문제-해결을-포기하기">N + 1 문제 해결을 포기하기</h4>
<p>기본적으로 <code>@ManyToOne</code>을 활용한 이후, <code>get@@@()</code>라는 메소드를 활용해서 데이터를 가져올 경우, 추가적인 쿼리가 발생하게 되며, 우리는 이를 <strong>N+1 문제</strong>라고 칭한다.</p>
<blockquote>
<p><code>FetchType.EAGER</code>와 <code>FetchType.LAZY</code>에 따라서는 쿼리의 발생시점에 차이만 있기에, 별도로 구분짓지 않았다.</p>
</blockquote>
<p>N+1문제를 해결하기 위해서는 FetchJoin을 사용해야 하나, 이는 페이징 처리에서는 OOM과 관련된 경고를 출력하며 사용을 권장하지 않는다. (<a href="https://junhyunny.github.io/spring-boot/jpa/jpa-fetch-join-paging-problem/">관련 자료</a>)</p>
<p>그래서, Post데이터만 가져온 이후, Service 계층에서 이를 DTO로 바꾸는 <code>stream().map()</code>에서 쿼리를 통해 조회하여 바인딩할 수 있도록 변경하는 방법을 생각했었지만, 아래와 같은 이유로 해당 방법을 선택하지 않았다.</p>
<p><strong>Post에 연관된 데이터가 많기에, 10개의 Post를 조회할 경우 사실상 31개의 쿼리가 발생한다.</strong> (Member, Category, StackName 각각 조회해야 하므로)</p>
<p>이는, Full-text Search를 활용해 응답 시간을 조금이라도 줄일려고 한 것들을 다 잃어버리게 될 것이라고 판단했다.</p>
<h4 id="비즈니스-로직에서-합치기">비즈니스 로직에서 합치기</h4>
<p>Left Join을 통해 1:N의 관계에 있는 데이터들을 다 가져오고, 이를 DTO로 변환하는 과정에서 합치고자 했다.
이 방식을 선택하게 된 이유는 아래와 같다.</p>
<ul>
<li>QueryDSL의 <code>transform()</code>과 유사한 방식</li>
<li>쿼리는 최초 발생 이후, 별도의 쿼리 발생 X</li>
<li>Service 계층에서 데이터를 조합하므로 반환하는 데이터 형식이 바뀌었을 때 수정하기 용이</li>
</ul>
<h3 id="offset-활용-불가능한-오류">Offset 활용 불가능한 오류</h3>
<p>위와 같이 비즈니스 로직에서 합치고자 한다면, 데이터 중 Post 1개에 4개의 category가 있는 Post가 존재할 경우, size가 6임에도 불구하고 3개의 Post만 조회되게 된다. (A라는 Post에 관련된 Row만 4개)
Offset쿼리를 올바르게 적용할 수 없다는 것이다.
이 문제를 최소화하기 위해, 검색 조건에 해당하는 Post 공고를 조회한 이후, 해당 Post Id에 해당하는 데이터를 가져오는 방식으로 두 개의 쿼리로 분리하여 개발했다.</p>
<h3 id="결과값의-순서-보장하기">결과값의 순서 보장하기</h3>
<p>비즈니스 로직에서 Id값만 리스트로 조회한 이후, 이를 in조건문을 활용한 쿼리를 통해 전체 데이터를 가져오는 과정에서 순서를 보장할 수 없다. 그렇기에, 정렬 순서를 위한 switch문을 한 번 더 활용해야 한다.</p>
<p>이외에도, DTO로 변환할 때 Map을 활용하게 되는데, HashMap을 활용할 경우에도 순서를 보장할 수 없기 때문에 LinkedHashMap을 활용해야 한다.</p>
<h3 id="별칭-중복으로-인한-문제-해결">별칭 중복으로 인한 문제 해결</h3>
<p><code>Encountered a duplicated sql alias [post_id] during auto-discovery of a native-sql query</code>라는 오류를 마주쳤다.
이는 <code>select p.*, m.*, c.*</code>과 같이 쿼리를 작성했더니, <code>created_at</code>과 같은 BaseEntity 칼럼들이 중복되어 발생하는 문제였다.
이를 해결하기 위해 <strong>Closed Projection</strong>을 활용하여 필요한 데이터만을 뽑을 수 있도록 수정했다. </p>
<h3 id="코드">코드</h3>
<h4 id="공모전-service">공모전 Service</h4>
<pre><code class="language-java">@Transactional(readOnly = true)
public Page&lt;GetContestRes&gt; getContestsByFilter(String sort, String meetingCity, String meetingTown, String category, String searchWord, Pageable page) {
    // Business Logic
    List&lt;String&gt; statusList = Arrays.asList(PostStatus.RECRUITING.toString(), PostStatus.EXTENSION.toString()); // 공고가 모집/연장 상태인 경우만 조회되도록 하기 위한 상태값 설정
    String search = (searchWord != null &amp;&amp; !searchWord.isEmpty()) ? searchWord.toLowerCase() : searchWord;
    Sort sortCondition = switch (sort) {
        case &quot;createdAt&quot; -&gt; Sort.by(&quot;created_at&quot;).descending();
        case &quot;scrapCount&quot; -&gt; Sort.by(&quot;scrap_count&quot;).descending();
        default -&gt; throw new IllegalStateException(&quot;Unexpected value: &quot; + sort);
    };

    page = PageRequest.of(page.getPageNumber(), page.getPageSize(), sortCondition);
    Page&lt;Long&gt; postIdPage = postRepository.findContestPaginationByFilter(
            search,
            LocalDateTime.now(),
            statusList,
            meetingCity,
            meetingTown,
            page
    );

    List&lt;ContestProjection&gt; contestProjectionList = switch (sort) {
        case &quot;createdAt&quot; -&gt; postRepository.findContestProjectionListByPostIdListAndCreatedAtDesc(postIdPage.getContent());
        case &quot;scrapCount&quot; -&gt; postRepository.findContestProjectionListByPostIdListAndScrapCountAtDesc(postIdPage.getContent());
        default -&gt; throw new IllegalStateException(&quot;Unexpected value: &quot; + sort);
    };

    // PostId 기준으로 그룹화
    Map&lt;Long, List&lt;ContestProjection&gt;&gt; groupedByPostId = contestProjectionList.stream()
            .collect(Collectors.groupingBy(ContestProjection::getPostId, LinkedHashMap::new, Collectors.toList()));

    // PostId 기준으로 그룹화된 맵을 사용하여 GetContestRes 객체 리스트 생성
    List&lt;GetContestRes&gt; contestResList = groupedByPostId.entrySet().stream()
            .map(entry -&gt; {
                Long postId = entry.getKey();
                List&lt;ContestProjection&gt; contestProjections = entry.getValue();

                // CategoryRes 리스트 구성
                List&lt;CategoryRes&gt; categoryResList = contestProjections.stream()
                        .filter(contestProjection -&gt; contestProjection.getCategoryId() != null &amp;&amp; contestProjection.getCategoryType() != null &amp;&amp; contestProjection.getCategorySize() != null)
                        .map(contestProjection -&gt; CategoryRes.builder()
                                .categoryId(contestProjection.getCategoryId())
                                .categoryType(contestProjection.getCategoryType())
                                .size(contestProjection.getCategorySize())
                                .build())
                        .collect(Collectors.toList());

                // GetContestRes 객체 생성
                ContestProjection firstProjection = contestProjections.get(0); // 여기서도 첫 번째 객체를 가져올 수 있습니다.
                return GetContestRes.builder()
                        .postId(postId)
                        .title(firstProjection.getTitle())
                        .name(firstProjection.getMemberName())
                        .status(firstProjection.getStatus())
                        .startDate(firstProjection.getStartDate())
                        .endDate(firstProjection.getEndDate())
                        .finishDate(firstProjection.getFinishDate())
                        .daysRemaining(firstProjection.getDaysRemaining())
                        .categories(categoryResList)
                        .scrapCount(firstProjection.getScrapCount())
                        .build();
            })
            .collect(Collectors.toList());

    // Response
    return new PageImpl&lt;&gt;(contestResList, postIdPage.getPageable(), postIdPage.getTotalPages());
}</code></pre>
<h4 id="공모전-repository">공모전 Repository</h4>
<pre><code class="language-java">@Query(value = &quot;&quot;&quot;
select p.post_id
from post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
where p.post_type = false
and p.deleted_at is null
and p.finish_date &gt; :currentTimestamp
and p.status in (:status)
and (
    case
        when :searchWord is not null and :searchWord != &#39;&#39; then match(p.title, p.contents) against(:searchWord in natural language mode)
        else true
    end
    )
and (
    case
        when :meetingCity is not null and :meetingCity != &#39;&#39; then p.meeting_city = :meetingCity
        else true
    end
    )
and (
    case
        when :meetingTown is not null and :meetingTown != &#39;&#39; then p.meeting_town = :meetingTown
        else true
    end
    )
group by p.post_id
&quot;&quot;&quot;,
        countQuery = &quot;&quot;&quot;
SELECT COUNT(*)
FROM post p
LEFT JOIN member m ON p.member_id = m.member_id
LEFT JOIN category c ON p.post_id = c.post_id
WHERE p.post_type = false
AND p.deleted_at IS NULL
AND p.finish_date &gt; :currentTimestamp
AND p.status IN (:status)
AND (
    CASE
        WHEN :searchWord IS NOT NULL AND :searchWord != &#39;&#39; THEN MATCH(p.title, p.contents) AGAINST(:searchWord IN natural language mode)
        ELSE 1=1
    END
)
AND (
    CASE
        WHEN :meetingCity IS NOT NULL AND :meetingCity != &#39;&#39; THEN p.meeting_city = :meetingCity
        ELSE 1=1
    END
)
AND (
    CASE
        WHEN :meetingTown IS NOT NULL AND :meetingTown != &#39;&#39; THEN p.meeting_town = :meetingTown
        ELSE 1=1
    END
)
group by p.post_id
&quot;&quot;&quot;,
        nativeQuery = true)
Page&lt;Long&gt; findContestPaginationByFilter(@Param(&quot;searchWord&quot;) String searchWord,
                                                      @Param(&quot;currentTimestamp&quot;) LocalDateTime currentTimestamp,
                                                      @Param(&quot;status&quot;) List&lt;String&gt; status,
                                                      @Param(&quot;meetingCity&quot;) String meetingCity,
                                                      @Param(&quot;meetingTown&quot;) String meetingTown,
                                                      Pageable pageable);

@Query(value = &quot;&quot;&quot;
select p.post_id as postId, p.title as title, m.member_id as memberId, m.name as memberName, p.status as status, p.start_date as startDate, p.end_date as endDate, p.finish_date as finishDate, p.days_remaining as daysRemaining, c.category_id as categoryId, c.category_type as categoryType, c.size as categorySize, p.scrap_count as scrapCount
from post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
where p.post_id in (:postIdList)
order by p.created_at desc
&quot;&quot;&quot;, nativeQuery = true
)
List&lt;ContestProjection&gt; findContestProjectionListByPostIdListAndCreatedAtDesc(@Param(&quot;postIdList&quot;) List&lt;Long&gt; postIdList);

@Query(value = &quot;&quot;&quot;
select p.post_id as postId, p.title as title, m.member_id as memberId, m.name as memberName, p.status as status, p.start_date as startDate, p.end_date as endDate, p.finish_date as finishDate, p.days_remaining as daysRemaining, c.category_id as categoryId, c.category_type as categoryType, c.size as categorySize, p.scrap_count as scrapCount
from post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
where p.post_id in (:postIdList)
order by p.scrap_count desc
&quot;&quot;&quot;, nativeQuery = true
)
List&lt;ContestProjection&gt; findContestProjectionListByPostIdListAndScrapCountAtDesc(@Param(&quot;postIdList&quot;) List&lt;Long&gt; postIdList);</code></pre>
<h4 id="프로젝트-service">프로젝트 Service</h4>
<pre><code class="language-java">@Transactional(readOnly = true)
public Page&lt;GetProjectRes&gt; getProjectsByFilter(String sort, String meetingCity, String meetingTown, String stackName, String searchWord, Pageable page) {
    // Validation
    if(stackName != null &amp;&amp; !stackName.isBlank() &amp;&amp; !StackNameType.isValid(stackName)) {
        throw new ApplicationException(INVALID_VALUE_EXCEPTION);
    }

    // Business Logic
    List&lt;String&gt; statusList = Arrays.asList(PostStatus.RECRUITING.toString(), PostStatus.EXTENSION.toString()); // 공고가 모집/연장 상태인 경우만 조회되도록 하기 위한 상태값 설정
    String search = (searchWord != null &amp;&amp; !searchWord.isEmpty()) ? searchWord.toLowerCase() : searchWord;
    Sort sortCondition = switch (sort) {
        case &quot;createdAt&quot; -&gt; Sort.by(&quot;created_at&quot;).descending();
        case &quot;scrapCount&quot; -&gt; Sort.by(&quot;scrap_count&quot;).descending();
        default -&gt; throw new IllegalStateException(&quot;Unexpected value: &quot; + sort);
    };

    System.out.println(searchWord);

    page = PageRequest.of(page.getPageNumber(), page.getPageSize(), sortCondition);
    Page&lt;Long&gt; postIdPage = postRepository.findProjectPaginationByFilter(
            search,
            LocalDateTime.now(),
            statusList,
            meetingCity,
            meetingTown,
            stackName,
            page
    );

    List&lt;ProjectProjection&gt; projectProjectionList = switch (sort) {
        case &quot;createdAt&quot; -&gt; postRepository.findProjectProjectionListByPostIdListAndCreatedAtDesc(postIdPage.getContent());
        case &quot;scrapCount&quot; -&gt; postRepository.findProjectProjectionListByPostIdListAndScrapCountDesc(postIdPage.getContent());
        default -&gt; throw new IllegalStateException(&quot;Unexpected value: &quot; + sort);
    };

    // PostId 기준으로 그룹화
    Map&lt;Long, List&lt;ProjectProjection&gt;&gt; groupedByPostId = projectProjectionList.stream()
            .collect(Collectors.groupingBy(ProjectProjection::getPostId, LinkedHashMap::new, Collectors.toList()));

    List&lt;GetProjectRes&gt; projectResList = groupedByPostId.entrySet().stream()
            .map(entry -&gt; {
                Long postId = entry.getKey();
                List&lt;ProjectProjection&gt; projections = entry.getValue();

                // CategoryRes 리스트 구성 (중복 제거)
                Set&lt;CategoryRes&gt; categoryResSet = projections.stream()
                        .filter(contestProjection -&gt; contestProjection.getCategoryId() != null
                                &amp;&amp; contestProjection.getCategoryType() != null
                                &amp;&amp; contestProjection.getCategorySize() != null)
                        .map(category -&gt; CategoryRes.builder()
                                .categoryId(category.getCategoryId())
                                .categoryType(category.getCategoryType())
                                .size(category.getCategorySize())
                                .build())
                        .collect(Collectors.toSet());  // Set으로 수집하여 중복 제거

                // StackName 리스트 구성 (중복 제거)
                Set&lt;StackNameRes&gt; stackNameSet = projections.stream()
                        .filter(stack -&gt; stack != null
                                &amp;&amp; stack.getStackNameId() != null
                                &amp;&amp; stack.getStackNameType() != null)
                        .map(stack -&gt; StackNameRes.builder()
                                .stackNameId(stack.getStackNameId())
                                .stackNameType(stack.getStackNameType())
                                .build())
                        .collect(Collectors.toSet());  // Set으로 수집하여 중복 제거

                // GetProjectRes 객체 생성
                ProjectProjection firstProjection = projections.get(0);
                return GetProjectRes.builder()
                        .postId(postId)
                        .title(firstProjection.getTitle())
                        .name(firstProjection.getMemberName())
                        .status(firstProjection.getStatus())
                        .startDate(firstProjection.getStartDate())
                        .endDate(firstProjection.getEndDate())
                        .finishDate(firstProjection.getFinishDate())
                        .daysRemaining(firstProjection.getDaysRemaining())
                        .categories(new ArrayList&lt;&gt;(categoryResSet))  // Set을 List로 변환
                        .stackNames(new ArrayList&lt;&gt;(stackNameSet))    // Set을 List로 변환
                        .scrapCount(firstProjection.getScrapCount())
                        .build();
            })
            .collect(Collectors.toList());

    // Response
    return new PageImpl&lt;&gt;(projectResList, postIdPage.getPageable(), postIdPage.getTotalPages());
}</code></pre>
<h4 id="프로젝트-repository">프로젝트 Repository</h4>
<pre><code class="language-java">@Query(value = &quot;&quot;&quot;
select p.post_id
from post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
left join stack_name sn on p.post_id = sn.post_id
WHERE p.post_type = true
AND p.deleted_at IS NULL
AND p.finish_date &gt; :currentTimestamp
AND p.status IN (:status)
and (
    case
        when :searchWord is not null and :searchWord != &#39;&#39; then match(p.title, p.contents) against(:searchWord in natural language mode)
        else true
    end
    )
and (
    case
        when :meetingCity is not null and :meetingCity != &#39;&#39; then p.meeting_city = :meetingCity
        else true
    end
    )
and (
    case
        when :meetingTown is not null and :meetingTown != &#39;&#39; then p.meeting_town = :meetingTown
        else true
        end
    )
and (
    case
        when :stackName is not null and :stackName != &#39;&#39; then sn.stack_name_type = :stackName
        else true
    end
    )
group by p.post_id
&quot;&quot;&quot;,
        countQuery = &quot;&quot;&quot;
select count(*)
FROM post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
left join stack_name sn on p.post_id = sn.post_id
WHERE p.post_type = true
AND p.deleted_at IS NULL
AND p.finish_date &gt; :currentTimestamp
AND p.status IN (:status)
and (
    case
        when :searchWord is not null and :searchWord != &#39;&#39; then match(p.title, p.contents) against(:searchWord in natural language mode)
        else 1=1
    end
    )
and (
    case
        when :meetingCity is not null and :meetingCity != &#39;&#39; then p.meeting_city = :meetingCity
        else 1=1
    end
    )
and (
    case
        when :meetingTown is not null and :meetingTown != &#39;&#39; then p.meeting_town = :meetingTown
        else 1=1
        end
    )
and (
    case
        when :stackName is not null and :stackName != &#39;&#39; then sn.stack_name_type = :stackName
        else 1=1
    end
    )
group by p.post_id
&quot;&quot;&quot;,
        nativeQuery = true)
Page&lt;Long&gt; findProjectPaginationByFilter(
        @Param(&quot;searchWord&quot;) String searchWord,
        @Param(&quot;currentTimestamp&quot;) LocalDateTime currentTimestamp,
        @Param(&quot;status&quot;) List&lt;String&gt; status,
        @Param(&quot;meetingCity&quot;) String meetingCity,
        @Param(&quot;meetingTown&quot;) String meetingTown,
        @Param(&quot;stackName&quot;) String stackName,
        Pageable pageable
);

@Query(value = &quot;&quot;&quot;
select p.post_id as postId, p.title as title, m.member_id as memberId, m.name as memberName, p.status as status, p.start_date as startDate, p.end_date as endDate, p.finish_date as finishDate, p.days_remaining as daysRemaining, c.category_id as categoryId, c.category_type as categoryType, c.size as categorySize, sn.stack_name_id as stackNameId, sn.stack_name_type as stackNameType, p.scrap_count as scrapCount
from post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
left join stack_name sn on p.post_id = sn.post_id
where p.post_id in (:postIdList)
order by p.created_at desc
&quot;&quot;&quot;, nativeQuery = true)
List&lt;ProjectProjection&gt; findProjectProjectionListByPostIdListAndCreatedAtDesc(@Param(&quot;postIdList&quot;) List&lt;Long&gt; postIdList);

@Query(value = &quot;&quot;&quot;
select p.post_id as postId, p.title as title, m.member_id as memberId, m.name as memberName, p.status as status, p.start_date as startDate, p.end_date as endDate, p.finish_date as finishDate, p.days_remaining as daysRemaining, c.category_id as categoryId, c.category_type as categoryType, c.size as categorySize, sn.stack_name_id as stackNameId, sn.stack_name_type as stackNameType, p.scrap_count as scrapCount
from post p
left join member m on p.member_id = m.member_id
left join category c on p.post_id = c.post_id
left join stack_name sn on p.post_id = sn.post_id
where p.post_id in (:postIdList)
order by p.scrap_count desc
&quot;&quot;&quot;, nativeQuery = true)
List&lt;ProjectProjection&gt; findProjectProjectionListByPostIdListAndScrapCountDesc(@Param(&quot;postIdList&quot;) List&lt;Long&gt; postIdList);</code></pre>
<blockquote>
<p><strong>OrderBy는 왜 작성하지 않았는가?</strong>
JPA를 이용할 경우, Pageable 객체가 매개변수에 존재한다면 자동적으로 바인딩시킨다. 다만, Native Query에 있으면 무시되므로 이를 생각해 Native Query에서는 별도로 정렬 조건을 작성하지 않았다.</p>
</blockquote>
<h3 id="결과">결과</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/8cecaab5-f6f5-49d8-9b2b-d2cc3d73e97c/image.png" alt=""> 위와 같이 <code>ICT 융합 프로젝트</code>라는 키워드로 검색했음에도 불구하고, 프로젝트라는 키워드의 유사도로 인해 다른 프로젝트까지 잘 조회되는 것을 확인할 수 있다. 단순 문자열 완전 일치를 넘어서 유사도가 높아도 조회될 수 있으므로, 사용자들이 검색 과정에서 관련 정보를 더욱 쉽게 찾아볼 수 있다.</p>
<blockquote>
<p>개발 코드 전체는 <a href="https://github.com/Gongjakso/server">공작소 서버 Repository</a>에서 확인할 수 있다.</p>
</blockquote>
<hr>
<p><strong>후기</strong>
이 모든 과정을 JPA로 해결해보면서 QueryDSL의 필요성을 굉장히 많이 느꼈다. JooQ라는 것이 활용되고 있는 경우도 많기에, 시간이 된다면 JooQ를 이용해서 다루는 과정도 한 번 정리해볼 예정이다. </p>
<p><strong>QueryDSL 최고다..</strong> </p>
<p>또한, 위 내용 중 실제 적용 과정은 Full-text search 적용 과정이지만, 공작소 프로젝트의 특성에 따라 다양한 부분이 변경되어 적용되었기에 보시는 분들은 참고용도로만 활용하시면 좋을 것 같다.</p>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://jaehoney.tistory.com/138">쿼리 성능 측정 블로그</a></li>
<li><a href="https://yurimy.tistory.com/48">Full text search + Spring Boot</a></li>
<li><a href="https://hoing.io/archives/16209">MySQL 쿼리 프로파일링</a></li>
<li><a href="https://catsbi.oopy.io/c6410158-5165-4145-9332-58896020f7cc">쿼리 실행계획 확인하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] 배포할 때,GCP Credential 오류 해결하기]]></title>
            <link>https://velog.io/@dl-00-e8/Spring-Boot-%EB%B0%B0%ED%8F%AC%ED%95%A0-%EB%95%8CGCP-Credential-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/Spring-Boot-%EB%B0%B0%ED%8F%AC%ED%95%A0-%EB%95%8CGCP-Credential-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 10 Jul 2024 14:12:23 GMT</pubDate>
            <description><![CDATA[<p>로컬 환경에서는 정상적으로 작동하지만, 동일한 파일을 Actions Secrets로 등록하여 Workflow 실행 중에 주입해서 서버를 배포했을 때,json 파일을 찾지 못했다는 에러를 마주쳐, 이를 해결한 내용을 정리해보고자 한다.</p>
<h2 id="gcp의-자격-증명-방식">GCP의 자격 증명 방식</h2>
<p>먼저, GCP의 자격 증명 방식이다. GCP는 GCP 제품에 접근할 때 <strong>json 형식의 파일</strong>을 기반으로 자격 증명(Credential)을 생성해서 활용한다.
이 json 파일이 자격 증명을 위해 <strong>accesskey</strong>와 <strong>secretKey</strong>를 발급받아 활용하는 AWS와 큰 차이점이라고 볼 수 있다.</p>
<h2 id="로컬">로컬</h2>
<p>로컬 환경에서 Spring Boot에서 GCP Credential을 생성할 때는 아래와 같이 작업할 수 있다.</p>
<p>서비스 계정의 json 파일은 이미 생성한 상황이라고 가정하고 정리한다.</p>
<h3 id="json-파일의-위치">json 파일의 위치</h3>
<p>먼저 json 파일은 resoruces 경로 하단에 위치시킨다. 
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/bfeb4286-9a09-4249-b444-29a1b3250d19/image.png' width = 300></p>
<h3 id="applicationyml">application.yml</h3>
<pre><code class="language-yaml">spring:
  cloud:
    gcp:
      credentials:
        location: gcs-access-key.json
      storage:
        project-id: {프로젝트 ID}
        bucket: {버켓 이름}</code></pre>
<h3 id="gcpconfig">GCPConfig</h3>
<p>GCS에 이미지를 업로드하는 과정으로 예시를 들겠다.</p>
<pre><code class="language-java">@Configuration
public class GcsConfig {

    @Value(&quot;${spring.cloud.gcp.credentials.location}&quot;)
    private ClassPathResource gcpServiceAccountKey;

    @Value(&quot;${spring.cloud.gcp.project-id}&quot;)
    private String projectId;

    @Bean
    public Storage storage() throws IOException {
        GoogleCredentials credentials = GoogleCredentials.fromStream(gcpServiceAccountKey.getInputStream());
        return StorageOptions.newBuilder()
                .setProjectId(projectId)
                .setCredentials(credentials)
                .build()
                .getService();
    }
}</code></pre>
<ul>
<li>프로젝트를 빌드하면, 리소스 파일들은 <strong>CLASS_PATH</strong>에 위치하게 된다. 이 때, ClassPathResource 클래스를 사용하면 해당 위치에 있는 파일을 쉽게 가져올 수 있다. </li>
</ul>
<h2 id="오류가-발생했었던-배포">오류가 발생했었던 배포</h2>
<h3 id="workflow">Workflow</h3>
<pre><code class="language-yaml"># GCP Service Account Key
- name: Make GCP Service Account Key
  run : |
    cd ./src/main/resources
    touch gcp-access-key.json
    echo &quot;${{ secrets.GCP_ACCESS_KEY }}&quot; &gt; ./gcp-access-key.json
  shell: bash</code></pre>
<p>로컬에서는 별 문제가 발생하지 않았기에, 당연하게 배포 또한 별도의 작업 없이 Github Actions Secrets에 json 파일을 등록해놓고, 이를 workflow에서 주입받아서 jar 파일을 생성하도록 했었다.</p>
<h3 id="오류">오류</h3>
<p>VM에서 서버가 실행될 때,<code>Caused by: java.lang.IllegalArgumentException: no JSON input found</code> 라는 json 타입이 아니라는 오류가 발생했었다.</p>
<h2 id="base64를-활용한-배포">Base64를 활용한 배포</h2>
<p>이는 secrets에 json 파일 내부의 값을 그대로 복사 붙여넣기하여 넣었던 상황인데, 이로 인해 json 포맷이 아니라고 인식된다고 판단했다. 다양한 인코딩 방식이 있지만, 가장 쉬운 방식인 Base64를 활용해서 인코딩을 진행했다.</p>
<h3 id="base64로-인코딩하는-법">Base64로 인코딩하는 법</h3>
<pre><code class="language-bash">base64 &lt; your-service-account-key.json | tr -d &#39;\n&#39;</code></pre>
<p>해당 json 파일의 경로로 이동하여, 위 명령어와 같이 입력하면 Base64로 인코딩된 결과가 출력된다. 이를 Actions Secrets에 저장하면 된다.</p>
<blockquote>
<p>위 명령어는 <strong>mac OS</strong> 기준이다. 타 OS는 해당 명령어가 정상적으로 동작하지 않을 수 있다. OS에 맞추어 아래의 명령어를 사용하면 된다.</p>
<p>Linux: <code>base64 -w 0 &lt; your-service-account-key.json</code>
일반: <code>cat your-service-account-key.json | base64 | tr -d &#39;\n&#39;</code></p>
</blockquote>
<h3 id="workflow-수정">Workflow 수정</h3>
<pre><code class="language-yaml"># GCP Service Account Key
- name: Make GCP Service Account Key
  run : |
    cd ./src/main/resources
    echo &quot;${{ secrets.GCP_ACCESS_KEY_BASE64 }}&quot; | openssl base64 -d -A &gt; gcp-access-key.json
  shell: bash</code></pre>
<p>다음으로 이를 디코딩하도록 Workflow를 수정하면 된다. OpenSSL이 대부분의 Unix 계열 시스템(Linux, macOS 등)에 기본적으로 설치되어 있어 호환성 이슈가 적기에 위와 같은 방식으로 디코딩을 진행했다.</p>
<h3 id="json-파일이-정상적으로-들어갔는지-확인하기">json 파일이 정상적으로 들어갔는지 확인하기</h3>
<p>나는 불안해서, 실제로 해당 json 파일이 정상적으로 들어갔는지 확인이 필요하다고 생각했다. 그래서 아래와 같이 코드를 변경해서 정보를 직접 확인했다.</p>
<pre><code class="language-java">@Configuration
public class GcsConfig {

    @Value(&quot;gcp-service-account-key.json&quot;)
    private ClassPathResource gcpServiceAccountKey;

    @Value(&quot;${spring.cloud.gcp.project-id}&quot;)
    private String projectId;

    @Bean
    public Storage storage() throws IOException {
        System.out.println(readResourceAsString(gcpServiceAccountKey));
        GoogleCredentials credentials = GoogleCredentials.fromStream(gcpServiceAccountKey.getInputStream());
        return StorageOptions.newBuilder()
                .setProjectId(projectId)
                .setCredentials(credentials)
                .build()
                .getService();
    }

    private String readResourceAsString(ClassPathResource resource) throws IOException {
        try (InputStream inputStream = resource.getInputStream()) {
            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
        }
    }
}</code></pre>
<p>위와 같이 설정하면, Bean 어노테이션으로 인해 스프링 컨테이너로 들어가는 과정에서 정보를 출력하게 되어 서버 실행 시점에 바로 로그를 통해 확인해볼 수 있다.
임시적인 조치이므로 확인 이후에는 반드시 삭제해야 한다.</p>
<hr>
<h2 id="후기">후기</h2>
<p>솔직하게 말하자면 배포할 때, 오류가 발생할 것 같다는 예상을 하고 있었다.
<em>(속으로는 제발 발생하지 않기를 기도했다...)</em>
과릿의 인프라를 GCP로 마이그레이션하는 과정에서 Credential과 관련된 부분에서 위와 비슷한 오류를 마주했었기 때문이다. 당시에는 Jasypt을 활용하여 암호화/복호화하여 해결했었다. 큰 틀에서 보면, Base64로 인코딩/디코딩하는 방식과 Jasypt으로 암호화/복호화하는 방식은 유사하다고 생각한다. 그래도 이 기억을 바탕으로 빠르게 해결해서 시간 비용은 많이 줄여서 다행이라고 생각한다.</p>
<p>혹시 Jasypt을 활용해보고 싶으시다면 아래의 글을 참고해서 시도해보시면 좋을 것 같다.</p>
<ul>
<li><a href="http://www.jasypt.org/">Jasypt Docs</a></li>
<li><a href="https://www.devglan.com/online-tools/jasypt-online-encryption-decryption">Jasypt 암호화/복호화 진행하는 사이트</a></li>
<li><a href="https://devel-repository.tistory.com/32">Jasypt 기술 블로그</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[트랜잭션 격리 수준과 JPA의 옵션]]></title>
            <link>https://velog.io/@dl-00-e8/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80%EA%B3%BC-JPA%EC%9D%98-%EC%98%B5%EC%85%98</link>
            <guid>https://velog.io/@dl-00-e8/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80%EA%B3%BC-JPA%EC%9D%98-%EC%98%B5%EC%85%98</guid>
            <pubDate>Tue, 09 Jul 2024 15:40:20 GMT</pubDate>
            <description><![CDATA[<p>면접 질문으로 받으면서 데이터베이스 트랜잭션 격리 수준에 대한 질문을 받은 적이 있었다. LIVID에서 자유 주제로 매주 글을 하나씩 정리해서 공유하는 스터디를 참여하고 있었기에, 관련 내용을 주제로 준비했다. <a href="https://helloworld.kurly.com/blog/commit-mvcc-set-autocommit/">마켓 컬리 기술 블로그</a>에서 24년 6월에 이와 관련된 내용을 JPA 설정과 함께 다루고 있어 같이 정리해본다.</p>
<h1 id="트랜잭션-격리-수준">트랜잭션 격리 수준</h1>
<h2 id="정의">정의</h2>
<p><code>동시에 여러 트랜잭션이 처리 될 때, 트랜잭션끼리 얼마나 고립(격리)되어 있는가에 대한 구분</code></p>
<h2 id="acid">ACID</h2>
<h3 id="atomicity-원자성">Atomicity (원자성)</h3>
<p><code>트랜잭션과 관련된 일이 모두 수행되었거나 되지 않았거나를 보장하는 특징</code></p>
<p><strong>커밋:</strong> 여러 쿼리가 성공적으로 처리되었다고 확정하는 명령어로, 하나의 트랜잭션이 성공적으로 수행되었다는 의미</p>
<p><strong>롤백:</strong> 에러나 여러 이슈 때문에 트랜잭션으로 처리한 하나의 묶음 과정을 일어나기 전으로 돌리는 일을 의미</p>
<pre><code class="language-go">func (s *Service) Register(ctx context.Context, data string) (*Test, error) {
    // Transaction Begin
    tx := s.Repository.DB.Begin()
    defer func() {
        if err := recover(); err != nil {
            tx.Rollback()
        }
    }()

    // Business Logic
    test := Test{
        Data: data,
    }
    saveTest, queryErr := s.Repository.Create(ctx, tx, &amp;test)
    if queryErr != nil {
        return nil, queryErr
    }

    // Transaction Commit
    if err := tx.Commit().Error; err != nil {
        tx.Rollback()
        return nil, err
    }

    return saveTest, nil
}</code></pre>
<p>위 코드는 트랜잭션의 커밋과 롤백을 명확하게 이해할 때 도움이 되는 코드라고 생각해서 작성해보았다. (Golang + Echo + GORM 기반으로 작성한 코드다.)</p>
<p><code>//Transaction Commit</code> 하단의 3줄을 제거한 상황에서 API 요청을 보내면, Response에서는 ID값이 증가한 상태로 답변이 오지만, 데이터베이스에는 저장되지 않은 것을 볼 수 있다. 즉, <strong>커밋되지 않았기에</strong> 삽입 쿼리가 완료되지 않은 것이다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/20d89046-4c89-4601-8870-12e968bf7df6/image.png" alt=""><img src="https://velog.velcdn.com/images/dl-00-e8/post/4fddf5f1-8890-4214-b618-561fa45e9738/image.png" alt=""></p>
<h3 id="consistency-일관성-정합성">Consistency (일관성, 정합성)</h3>
<p><code>&#39;허용된 방식&#39;으로만 데이터를 변경해야하는 것을 의미</code></p>
<p>즉, 데이터베이스에 기록된 모든 데이터는 여러 가지 조건, 규칙에 따라 유효성을 가져야 한다.</p>
<h3 id="isolation-격리성">Isolation (격리성)</h3>
<p><code>트랜잭션 수행 시 서로 끼어들지 못하는 것</code> </p>
<p>격리성의 세부적인 내용은 아래에서 별도로 다룬다.</p>
<h3 id="durability-지속성">Durability (지속성)</h3>
<p><code>성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다는 것을 의미</code> </p>
<p>즉, 데이터베이스에 시스템 장애가 발생해도 원래 상태로 복구하는 회복 기능이 있어야 한다는 것을 의미하며, 데이터베이스는 이를 위해 체크섬, 저널링, 롤백 등의 기능을 제공한다.</p>
<blockquote>
<p><strong>저널링</strong>: 파일 시스템 또는 데이터베이스 시스템에 변경 사항을 반영(commit)하기 전에 로깅하는 것, 트랜잭션 등 변경사항에 대한 로그를 남기는 것</p>
</blockquote>
<h2 id="격리수준">격리수준</h2>
<p>위의 트랜잭션의 ACID 중 격리성의 세부 격리 수준을 정리해보면 아래와 같습니다. </p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/22b72477-cece-4196-8f00-c3e7d00d6e11/image.png" alt=""> (출처: 면접을 위한 CS전공지식 노트 p.209)</p>
<h3 id="serializable">Serializable</h3>
<p>Serializable은 트랜잭션을 <strong>순차적으로 진행</strong>시키는 것을 의미 = 트랜잭션이 <strong>동시에 같은 행에 접근 불가</strong></p>
<h3 id="repeatable-read">Repeatable Read</h3>
<p>Repeatable Read는 하나의 트랜잭션이 수정한 행을 다른 트랜잭션이 수정할 수 없도록 막아주지만 새로운 행을 추가하는 것은 막지 않는다.</p>
<p><strong>팬텀 리드(phantom read)</strong></p>
<p>한 트랜잭션 내에서 동일한 쿼리를 보냈을 때, 해당 조회 결과가 다른 경우를 의미한다. <strong>(값은 변경되지 않는다.)</strong></p>
<blockquote>
<p><strong>트랜잭션 A, B가 존재</strong></p>
</blockquote>
<ol>
<li>트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재</li>
<li>트랜잭션 B에서 1번 테이블에 1개의 Row 삽입</li>
<li>트랜잭션 B Commit</li>
<li>트랜잭션 A에서 1번 테이블을 조회 = 3개의 Row 존재<blockquote>
</blockquote>
</li>
</ol>
<h3 id="read-committed">Read Committed</h3>
<p>Read Committed는 다른 트랜잭션이 커밋하지 않은 정보를 읽을 수 없다. 커밋 완료된 데이터에 대해서만 조회를 허용한다. </p>
<p><strong>반복 가능하지 않은 조회(non-repeatable read)</strong></p>
<p>한 트랜잭션 내의 같은 행에 두 번 이상 조회가 발생했을 때, 그 값이 다른 경우를 말한다. <strong>(동일한 행에서 값의 변경이 일어난다.)</strong></p>
<blockquote>
<p><strong>트랜잭션 A, B가 존재</strong></p>
</blockquote>
<ol>
<li>트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 20)</li>
<li>트랜잭션 B에서 1번 테이블의 2번 row의 값을 30으로 변경</li>
<li>트랜잭션 B Commit</li>
<li>트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 30)<blockquote>
</blockquote>
</li>
</ol>
<h3 id="read-uncommitted">Read Uncommitted</h3>
<p>하나의 트랜잭션이 커밋되기 이전에 다른 트랜잭션에 노출될 수 있다. 속도는 가장 빠르지만, 커밋되기 이전에 다른 트랜잭션에 데이터가 노출되므로 데이터 무결성을 위반할 가능성이 높다.</p>
<p><strong>더티 리드(dirty read)</strong></p>
<p>한 트랜잭션이 실행 중일 때 다른 트랜잭션에 의해 수정되었지만 아직 ‘커밋되지 않은’ 행의 데이터를 읽을 수 있을 떄 발생</p>
<blockquote>
<p><strong>트랜잭션 A, B가 존재</strong></p>
</blockquote>
<ol>
<li>트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 20)</li>
<li>트랜잭션 B에서 1번 테이블의 2번 row의 값을 30으로 변경</li>
<li>트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 30)<blockquote>
</blockquote>
</li>
</ol>
<h2 id="데이터베이스별-격리-수준의-default-condition"><strong>데이터베이스별 격리 수준의 Default Condition</strong></h2>
<ul>
<li><p>MySQL의 기본 격리 수준: <strong>Repeatable Read</strong> (<a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html">출처</a>)</p>
</li>
<li><p>PostgreSQL의 기본 격리 수준: <strong>Committed Read</strong> (<a href="https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED">출처</a>)</p>
</li>
<li><p>Oracle의 기본 격리 수준: <strong>Committed Read</strong> (<a href="https://docs.oracle.com/en/database/other-databases/timesten/22.1/introduction/transaction-isolation.html">출처</a>)</p>
</li>
<li><p>MSSQL의 기본 격리 수준: <strong>Committed Read</strong> (<a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql?view=sql-server-ver16">출처</a>)</p>
</li>
<li><p>MongoDB의 기본 격리 수준: <strong>Uncommitted Read</strong> (<a href="https://www.mongodb.com/docs/v6.0/core/read-isolation-consistency-recency/">출처</a>)</p>
</li>
</ul>
<blockquote>
<p><strong>추가적으로 알아볼만한 사항</strong>
Q. 오라클은 Repeatable Read 수준을 지원하지 않는다. 그렇다면, Non Repeatalbe Read 문제를 어떻게 해결할 수 있을까?
A. 오라클은 Exclusive Lock을 통해 이를 해결할 수 있다. 자세한 건 <a href="https://tlatmsrud.tistory.com/118">트랜잭션 격리수준 기술 블로그</a>를 참고하면 좋을 것이다.</p>
</blockquote>
<h1 id="마켓컬리-기술-블로그-내용-정리">마켓컬리 기술 블로그 내용 정리</h1>
<p>위 트랜잭션 격리수준은 마켓컬리의 기술 블로그 내용 중 격리 수준을 Committed Read로 낮추어 해결하는 방법을 생각했었다는 부분에서 파생되어 정리한 내용이다. 
이제는 마켓컬리 기술 블로그의 내용을 간략하게 정리한다.</p>
<h2 id="이슈">이슈</h2>
<h3 id="환경">환경</h3>
<p>회원 서비스와 멤버십 서비스가 분리되어 있으며, 두 서비스가 동일한 DB에 접근할 수 있는 환경이다.</p>
<h3 id="상황">상황</h3>
<ol>
<li>회원 서비스에서 INSERT 쿼리 실행</li>
<li>회원 서비스에서 SELECT 쿼리 실행 (회원 정보 정상적으로 조회)</li>
<li>멤버십 서비스에서 SELECT 쿼리 실행 (간헐적으로 회원 정보 조회 X)</li>
<li>멤버십 서비스에서 다시 SELECT 쿼리 실행 (회원 정보 정상적으로 조회)</li>
</ol>
<h3 id="관련-예시-코드">관련 예시 코드</h3>
<pre><code class="language-java">public Membership outerMethod(long memberNo) {

        var member = memberRepository.findByMemberNo(memberNo)
                .orElseThrow(() -&gt; new RuntimeException(&quot;요청한 리소스를 찾을 수 없습니다.&quot;);

        var innerResult = service.innerMethod(memberNo);
        return ...;
    }

@Transactional(readOnly = true)
public InnerResult innerMethod(long memberNo) {

        var member = memberRepository.findByMemberNo(memberNo);
...
}</code></pre>
<p>출처: <a href="https://helloworld.kurly.com/blog/commit-mvcc-set-autocommit/">마켓 컬리 기술 블로그</a></p>
<ul>
<li>innerMethod는 <code>@Transactional(readonly=true)</code> 선언이 되어있는 상황이다. </li>
</ul>
<h2 id="원인">원인</h2>
<p>3번 과정에서 <strong>신규 데이터가 적재되기 전 Snapshot 을 가지고 있던 Session</strong>일 경우 데이터가 조회되지 않아 ROLLBACK이 발생한 것이었다.
4번 과정이 정상적으로 실행된 이유는 <strong>그 다음 데이터 요청에서 Snapshot 이 갱신</strong>되어 데이터가 조회 가능했던 것이라고 한다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/ea7dccaa-0f0b-47c1-98fa-58041ce09449/image.png" alt=""></p>
<h3 id="snapshot-갱신">Snapshot 갱신</h3>
<p>MySQL의 InnoDB 엔진은 MVCC를 사용해 특정 시점의 데이터베이스 Snapshot을 쿼리에 제공한다. (MVCC는 다중 버전 동시성 제어라는 의미의 약자다.)</p>
<p>트랜잭션 내에서 최초 쿼리 발생 시 Snapshot을 읽어오고, 이 Snapshot 이후의 변경사항에 대해서는 트랜잭션 내에서는 확인할 수 없다. 이를 <strong>Consistent Nonlocking Reads</strong>라고 한다.</p>
<p>Snapshot을 갱신하기 위해서는, <code>COMMIT</code>, <code>ROLLBACK</code>, <code>BEGIN</code>을 실행하여 갱신할 수 있다.</p>
<h2 id="해결책">해결책</h2>
<p><code>@Transactional(readonly=true)</code> 을 외부 메소드에 추가</p>
<h3 id="트랜잭션-중첩">트랜잭션 중첩?</h3>
<p>innerMethod에 <code>@Transactional(readonly=true)</code>를 선언한 상태에서, outerMethod에 다시 <code>@Transactional(readonly=true)</code>를 선언해주었는데, innerMethod에서 해당 선언을 지웠다는 얘기가 없었으므로 트랜잭션 중첩이 되었다고 생각했다.</p>
<p>처음에는 Master DB와 Slave DB를 구분하기 위한 것인가 했으나, 해당 글에서 아래와 같이 Master DB와 Slave DB 연결에 대한 내용을 정리하고 있어 해당사항이 아님을 확인했다.</p>
<blockquote>
<p><strong>Master DB 연결 vs Slave DB 연결</strong></p>
<p>MariaDB Connector/J 의 aurora 모드는 읽기 전용(readOnly=true)으로 설정하면 Slave DB로 로드 밸런싱을 지원해요.</p>
<ul>
<li><code>@Transactional</code> 설정 또는 설정이 없는 경우는 Master DB 로 요청</li>
<li><code>@Transactional(readOnly=true)</code> 설정인 경우 Slave DB 로 요청</li>
</ul>
</blockquote>
<blockquote>
<p>위 질문에 대한 작성자분의 의견이 궁금해 댓글을 남겼으며, 답글이 달리면 관련 내용을 업데이트할 예정이다. <img src = 'https://velog.velcdn.com/images/dl-00-e8/post/adcdd3ab-dbd2-4ca7-9973-177253631ef2/image.png' width = 450></p>
</blockquote>
<h1 id="transaction-in-javaspring--jpa">Transaction in Java/Spring + JPA</h1>
<p>마켓컬리 글에서 다룬 다양한 키워드들에 대한 추가 정리 내용이다.</p>
<h3 id="transactional의-다양한-옵션">@Transactional의 다양한 옵션</h3>
<ul>
<li><strong>readonly</strong>: 읽기/쓰기 또는 읽기 전용 트랜잭션 선택 여부</li>
<li><strong>isolation</strong>: 선택적 격리 수준</li>
<li><strong>timeout</strong>: 트랜잭션 타임아웃</li>
<li><strong>propagation</strong>: 선택적 전파 설정 (REQUIRED가 기본값) </li>
</ul>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/c14dc166-3e79-46fc-a134-51acb254821d/image.png" alt=""> (출처: <a href="https://mangkyu.tistory.com/269">망나니 개발자</a>, 2024. 07. 08)</p>
<h2 id="개발자가-정의한-쿼리-메소드와-hikaricp">개발자가 정의한 쿼리 메소드와 HikariCP</h2>
<p>Spring Data JPA의 기본 메소드는 <code>@Transactional</code> 이 기본적으로 적용되지만, JPA 인터페이스에 정의한 쿼리 메소드는 적용되지 않는다. 그렇기에, 트랜잭션을 적용하기 위해서는 <strong>명시적으로 추가</strong>해야 한다.</p>
<p>HikariCP는 커넥션 종료 시에 COMMIT 여부를 체크한다. 다만, 위에 언급했듯이 JPA 인터페이스나 QueryDSL의 메소드는 <code>@Transactional</code> 이 적용되지 않아 COMMIT이 실행되지 않는다. 그렇기에, 하단에 서술할 auto-commit일 경우, ROLLBACK이 실행된다는 점을 유의해야 한다.</p>
<h2 id="open-in-view">open-in-view</h2>
<p>open-in-view는 영속성 컨텍스트(Persistence Context)를 뷰 렌더링이 끝날 때까지 개방 상태로 유지하는 패턴이다.</p>
<p>Spring에서는 <code>spring.jpa.open-in-view</code> 설정으로 제어할 수 있다.</p>
<pre><code class="language-yaml">spring:
  jpa:
    open-in-view: true/false</code></pre>
<h3 id="옵션">옵션</h3>
<ul>
<li>true(기본값): 트랜잭션이 끝나도 영속성 컨텍스트를 종료하지 않고, 뷰 렌더링이 끝날 때까지 유지한다.</li>
<li>false: 트랜잭션이 끝나면 영속성 컨텍스트도 닫힌다.</li>
</ul>
<h3 id="장점">장점</h3>
<ul>
<li>지연 로딩(Lazy Loading)을 뷰 렌더링 시점까지 사용할 수 있다.</li>
<li>컨트롤러나 뷰에서 필요한 데이터를 편리하게 조회할 수 있다.</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li>데이터베이스 커넥션을 오랫동안 유지하므로 리소스 사용이 증가한다.</li>
<li>뷰 렌더링 중 데이터베이스 작업이 발생할 수 있어 성능 문제가 생길 수 있다.</li>
<li>애플리케이션 복잡도가 증가하고 예측하기 어려운 문제가 발생할 수 있다.</li>
</ul>
<h3 id="마켓컬리에서는">마켓컬리에서는?</h3>
<p>마켓컬리에서는 open-in-view를 true로 설정했었기에, outerMethod의 사용자 조회 쿼리에서 ROLLBACK이 발생되지 않았다. 그 상태에서 innerMethod의 <code>@Transactional(readonly=true)</code>로 인해 innerMethod가 종료되면서 COMMIT을 뱉어 갱신되지 않았다고 얘기한다.</p>
<blockquote>
<p>이 문장에서도, COMMIT은 Snapshot을 갱신한다는 내용이 동일한 글 윗 부분에 적혀있다 보니 갱신되지 않았다는 문장의 의미가 어떤 것인지 확인을 위해 댓글을 남겼다. 답글이 달리면 업데이트할 예정이다. <img src = 'https://velog.velcdn.com/images/dl-00-e8/post/adcdd3ab-dbd2-4ca7-9973-177253631ef2/image.png' width = 450></p>
</blockquote>
<p>그러나, open-in-view에 대해서는 다양한 관점과 내용이 있길래 <a href="https://medium.com/frientrip/spring-boot%EC%9D%98-open-in-view-%EA%B7%B8-%EC%9C%84%ED%97%98%EC%84%B1%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC-83483a03e5dc">Spring Boot의 open-in-view, 그 위험성에 대하여.</a>라는 글을 첨부한다.</p>
<h2 id="auto-commit">auto-commit</h2>
<p>hikari.auto-commit은 각 SQL문에 대하여 자체적으로 단일 트랜잭션을 생성하여 오류가 발생하지 않은 쿼리에 대해 COMMIT을 수행하는 옵션이다. 
true로 설정하면 안정적이지만 <code>@Transactional</code> 전후로 <strong>추가적인 쿼리가 발생</strong>하게 되어 <strong>성능상 손해가 발생</strong>한다.</p>
<p>마켓 컬리의 경우 auto-commit을 false로 하여 성능을 개선시켰다. <strong>(응답시간 1.5ms 감소, 40% 향상)</strong></p>
<h3 id="auto-commit-설정">auto-commit 설정</h3>
<p>auto-commit은 application.yml파일에서 아래와 같이 설정할 수 있다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    hikari:
      auto-commit: false</code></pre>
<h3 id="auto-commit-적용-여부에-따른-차이-직접-확인하기">auto-commit 적용 여부에 따른 차이 직접 확인하기</h3>
<p>먼저 테스트 환경은 아래와 같다.</p>
<ul>
<li>Java17</li>
<li>Spring Boot 3.x</li>
<li>MySQL 8.x</li>
</ul>
<h4 id="mysql-실행된-쿼리-확인하는-법">MySQL 실행된 쿼리 확인하는 법</h4>
<p>MySQL에서는 실행된 모든 쿼리에 대해서 general_log에서 관리하고 있다.
그렇기에 general_log로 관리할 수 있도록 설정을 진행해야 한다.</p>
<ol>
<li>general_log의 ON/OFF 여부 확인<pre><code class="language-sql">SHOW VARIABLES LIKE &quot;general_log%&quot;; </code></pre>
</li>
<li>table engine 확인 및 변경 (general_log가 OFF여야만 적용이 가능)<pre><code class="language-sql">//table engine 확인
SHOW TABLE STATUS LIKE &#39;general_log&#39;\G
SELECT engine FROM information_schema.TABLES WHERE table_name=&#39;general_log&#39;;
</code></pre>
</li>
</ol>
<p>//table engine 변경
ALTER TABLE mysql.general_log engine=MyIsam;</p>
<pre><code>
3. general_log를 ON으로 설정
```sql
SET GLOBAL general_log = &#39;ON&#39;;</code></pre><ol start="4">
<li><p>general_log의 출력 방식 설정 (DB Tool에서 Table 형식으로 확인할 것이게 TABLE로 설정 진행)</p>
<pre><code class="language-sql">SET GLOBAL log_output = &#39;TABLE&#39;; // 출력 방식을 테이블로 설정 (FILE, TABLE, TABLE,FILE 등의 옵션 존재)</code></pre>
</li>
<li><p>로그 조회 </p>
<pre><code class="language-sql">SELECT * FROM mysql.general_log LIMIT 100;</code></pre>
</li>
</ol>
<h4 id="applicationyml">application.yml</h4>
<p>실제로 테스트 시 사용한 환경변수 파일은 아래와 같다. </p>
<pre><code class="language-yaml">server:
  port: 8080

spring:
  application:
    name: auto-commit
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/{데이터베이스}
    username: root
    password: {비밀번호}
    hikari:
      auto-commit: false
  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQLDialect

logging:
  level:
    org.springframework.orm.jpa: TRACE
    org.springframework.transaction: TRACE
</code></pre>
<h4 id="auto-commit-true">auto-commit: true</h4>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/8098e804-8e87-46f4-86d7-dd01b5fa0a81/image.png" alt=""> 실제로 auto-commit 쿼리가 실제 쿼리 전후로 도합 두 번 실행되고 있는 것을 알 수 있다.</p>
<h4 id="auto-commit-false">auto-commit: false</h4>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/42fcf742-456e-4c25-bb89-7b950e1564c7/image.png" alt=""> false로 설정하면 동일한 요청에 대해서 두 개의 auto-commit 쿼리가 사라지고 실제 쿼리만 실행되는 것을 알 수 있다.</p>
<blockquote>
<p> auto-commit 옵션을 <strong>성능 개선 목적</strong>으로 쏠쏠하게 써먹을 수 있을 것 같다. 
다만, 개발하면서 늘 쿼리 종료시점에 <strong>COMMIT 이 실행되고 있는지에 대한 확인</strong>이 필요하다는 점은 기억해야 할 것이다.
<code>@Transactional</code>은 <strong>메소드 정상 종료 시 COMMIT</strong>을, <strong>예외 발생 시 ROLLBACK</strong>을 실행시키므로 이를 활용해 auto-commit을 사용하지 않으면서 COMMIT 실행을 보장할 수 있을 것이다.</p>
</blockquote>
<hr>
<p><strong>스터디에서 얘기해보고 싶은 점 및 관련 내용</strong></p>
<p>Q. (실제로 받았던 면접 질문) 은행에서는 DB 격리 수준을 무엇으로 설정하고 있을까요? 그 이유는 무엇일까요?</p>
<ul>
<li>당시에는 긴장하고 정신을 못 차린 상태로 Serializable을 통해 성능을 포기하더라도 데이터 정합성을 최우선으로 챙길 것이라고 답변했다. 조금 지나고 생각해보니, 데이터 유실률은 어느 정도까지 수용할 수 있는지 등의 세부적인 옵션을 확인한 이후에 Repeatable Read 기반으로 메세지 큐 등의 인프라로 성능 개선 방법을 제안하는 것이 더 좋아보인다. 
(실력 낮은 개인의 의견이므로 이는 정답이 아니다.)</li>
</ul>
<p>Q. 사이드 프로젝트든 현업이든 Transactional의 옵션을 어느 정도까지 세분화해서 다루어보았는지 궁금합니다. </p>
<ul>
<li>트랜잭션 중첩은 별로 없는 것 같았다.</li>
</ul>
<p>Q. 실제로 개발하는 과정에서 격리 수준으로 인해 발생한 오류를 경험한 적이 있는지 궁금합니다.</p>
<ul>
<li>일단 나는 없었는데, 스터디에 계신 분들은 있다고 한다.</li>
</ul>
<p>Q. 마켓컬리는 open-in-view를 true로 사용한 이유는 무엇일까요?</p>
<ul>
<li>정말 궁금하다. 댓글로 물어본 이후, 결과를 추후 업데이트할 예정이다. </li>
</ul>
<p>Q. 마켓 컬리 기술 블로그에서는 <code>@Transactional</code> 이 선언되어 있는 innermethod가 있는 상황에서 outermethod에 추가적으로 <code>@Transactional</code> 을 선언해서 해결했습니다. 별도의 설명이 없었으므로 트랜잭션 중첩된 것으로 판단했는데, 트랜잭션이 중첩되는 설계가 좋은 설계인지 얘기해보고 싶습니다.  </p>
<ul>
<li>별도의 propagation 옵션 설정을 하지 않았기에, 자식 트랜잭션은 기존 부모 트랜잭션에 참여하게 될텐데, 이는 에러가 발생했을 때 명확한 에러 발생 지점을 파악하기 어려워 좋은 설계는 아니지 않을까?라고 생각하고 있다.</li>
</ul>
<p>Q. auto-commit에 대해서 얼마나 극대화해서 쓰고 있는지 공유해보면 좋을 것 같습니다.</p>
<ul>
<li>스터디에 참여하고 계신 현업 개발자분께서 팀에서 auto-commit 옵션을 false로 사용하는 것 같다고 답변해주셨다.</li>
</ul>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://helloworld.kurly.com/blog/commit-mvcc-set-autocommit/">마켓컬리 기술 블로그</a></li>
<li><a href="https://m.yes24.com/Goods/Detail/108887922">면접을 위한 CS 전공지식 노트</a></li>
<li><a href="https://tlatmsrud.tistory.com/118">트랜잭션 격리수준 기술 블로그</a></li>
<li><a href="https://nesoy.github.io/articles/2019-05/Database-Transaction-isolation">트랜잭션 격리수준 기술 블로그 2</a></li>
<li><a href="https://wildeveloperetrain.tistory.com/229">(mysql, mariadb) general_log를 통한 실행된 쿼리 확인 방법</a></li>
</ul>
<blockquote>
<p>스터디에서 공유하고자 정리한 내용을 올린 글입니다. 
위 트랜잭션 샘플 코드는 <a href="https://github.com/dl-00-e8/study-development/tree/main/transactional">Github Repo</a>에서 확인하실 수 있습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring Security + JWT 로 일반 로그인 개발]]></title>
            <link>https://velog.io/@dl-00-e8/Spring-Spring-Security%EB%A1%9C-%EC%9D%BC%EB%B0%98-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@dl-00-e8/Spring-Spring-Security%EB%A1%9C-%EC%9D%BC%EB%B0%98-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 23 Jun 2024 14:13:18 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-security--jwt">Spring Security + JWT</h1>
<p>Spring Boot의 버전을 3점대로 올린 이후, Security를 오랜만에 사용하게 되어, 찾는 수고로움을 덜고 이해한 내용을 정리해서 재사용하고자 이렇게 글로 작성하기로 결정했다.</p>
<p><strong>개발 환경</strong></p>
<ul>
<li>Java17</li>
<li>Spring Boot 3.2.1</li>
<li>Spring Security 6.3.0</li>
<li>PostgreSQL 15.5</li>
</ul>
<h2 id="spring-security">Spring Security</h2>
<h3 id="spring-security란">Spring Security란?</h3>
<p><a href="https://docs.spring.io/spring-security/reference/index.html">Spring Security Docs</a>에서는 이렇게 정의하고 있다. 
<code>Spring Security is a framework that provides authentication, authorization, and protection against common attacks. With first class support for securing both imperative and reactive applications, it is the de-facto standard for securing Spring-based applications.</code> 간단하게 요약하자면, 일반적인 공격에 대한 인증, 인가 및 보호를 제공하는 프레임워크이며, Spring 기반 애플리케이션 보안을 위한 사실상의 표준이라고 한다.</p>
<blockquote>
<p><strong>인증(Authentication)</strong> 
인증은 사용자가 자신을 식별하고 자신이 주장하는 신원을 입증하는 과정</p>
<p><strong>인가(Authorization)</strong>
인가는 증된 사용자에 대해 해당 사용자가 특정 자원 또는 기능에 접근할 수 있는 권한을 가지고 있는지를 확인하는 과정</p>
<p><strong>인증 -&gt; 인가의 순서이다.</strong></p>
</blockquote>
<p>Spring Security는 Filter 위치에서 로직을 처리한다. (참고 자료: <a href="https://docs.spring.io/spring-security/reference/servlet/index.html">Servlet Application</a>)</p>
<blockquote>
<p>Spring의 요청 처리 순서
HTTP 요청 -&gt; WAS -&gt; Filter -&gt; Servlet -&gt; Interceptor -&gt; Controller
(참고 자료: <a href="https://gowoonsori.com/blog/spring/architecture/">gromit.blog</a>)</p>
</blockquote>
<p>Spring Security는 세션 방식과 토큰 방식을 둘 다 사용할 수 있다. 여기서는 세션이 아닌 토큰 방식을 사용하는 방법을 다룰 예정이다.</p>
<h3 id="spring-security-주요-모듈">Spring Security 주요 모듈</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/130467b9-619f-46d5-bc63-8135f5a665f6/image.png" alt=""> (출처: <a href="https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">Spring Security 구조, 흐름 그리고 역할 알아보기</a>, 2024-06-23)</p>
<p>위 이미지에서 확인할 수 있는 모듈 중 중요한 모듈 정보들은 아래와 같다.</p>
<ul>
<li><p>Security filter chain
Spring Security에서 HTTP 요청을 처리하는 데 사용되는 일련의 보안 필터들의 체인이다. 일반적으로 FilterChainProxy가 이 체인을 관리하며, 이 필터 체인은 Spring Security 설정에서 정의된다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/4e1d8aef-de21-4694-922e-32d96719f05b/image.png" alt=""> (출처: <a href="https://docs.spring.io/spring-security/reference/servlet/architecture.html">Spring Security Docs</a>)</p>
</li>
<li><p>SecurityContextHolder
SecurityContext를 가지고 있으며, Spring Security 인증 모델의 핵심이다. 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/890479e6-8733-4bea-9445-7fce30b1a5bb/image.png" alt="">
(출처: <a href="https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder">Spring Security Docs</a>)</p>
</li>
</ul>
<ul>
<li><p>SecurityContext
Authentication 객체를 보관하는 역할</p>
</li>
<li><p>Authentication
현재 접근한 사용자의 정보 및 권한을 담고 있으며, 이 객체는 Security Context에 저장되어 있다. 
크게 두 가지의 목적으로 활용된다. </p>
<ol>
<li>AuthenticationManager에게 input으로 활용되어, 사용자의 자격을 증명할 때 사용됩니다. </li>
<li>현재 인증된 사용자가 누구인지를 나타낸다. 아래의 3가지 정보를 담고 있다.<blockquote>
<ul>
<li>principal: 사용자 기본 정보를 가지고 있으며, 주로 UserDetails 객체입니다. </li>
<li>credentials: 주로 비밀번호를 저장할 때 활용되며, 유출을 방지하고자 사용자 인증 후 삭제된다. </li>
<li>authorities: GrantAuthority 객체를 통해 사용자에게 부여된 권한을 가지고 있습니다.</li>
</ul>
</blockquote>
</li>
</ol>
</li>
<li><p>GrantedAuthority
GrantedAuthority는 사용자의 역할과 범위에 대한 정보를 가진다. 이 정보들은  <code>Authentication.getAuthorities()</code>를 활용해 조회할 수 있다. GrantedAuthority 객체는 UserDetailsService에 의해 로드된다.</p>
</li>
<li><p>AuthenticationManager
실제 인증을 수행하는 부분으로, 일반적인 구현은 ProviderManager를 사용한다. 즉, AuthenticationManager에서 AuthenticationProvider를 활용하여 인증을 수행한다.</p>
</li>
<li><p>AuthenticationProviders
인증 전 객체를 받아 인증 수행 후의 객체를 반환한다. ProviderManager에 여러 개의 AuthenticationProvider를 삽입하여 활용할 수 있다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/0bd0d727-429d-4153-88dd-81383416c296/image.png" alt="">
(출처: <a href="https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-providermanager">Spring Security Docs</a>)</p>
<ul>
<li><p>UserDetails
UserDetailsService에 의해 반환되는 객체로, Spring Security가 사용하는 사용자의 상세 정보를 나타내는 인터페이스이다. 포함할 수 있는 정보로는 사용자의 이름, 암호화된 비밀번호, 권한 정보 등이 있으며, 이를 묶어 하나의 VO로 활용할 수도 있다. </p>
</li>
<li><p>UserDetailsService
UserDetailsService 인터페이스는 UserDetails 객체를 반환하는 단 하나의 메소드를 가지고 있다. 전달받은 사용자 식별 정보를 기반으로 UserRepository를 주입받아 DB에서 사용자 정보를 조회한 이후, 해당 정보를 UserDetails 객체로 반환한다. </p>
</li>
</ul>
<h3 id="spring-security-동작-과정">Spring Security 동작 과정</h3>
<p>Spring Security 동작 순서는 아래의 이미지로 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/7bb90e35-de2e-42d8-8393-bad9de180fb6/image.png" alt="인증 로직"> (출처: <a href="https://mangkyu.tistory.com/76">https://mangkyu.tistory.com/76</a>, 2024-01-13)</p>
<ol>
<li><p>HTTP 요청
사용자가 ID, Password와 같은 식별 정보를 기반으로 인증 요청을 보낸다.</p>
</li>
<li><p>사용자의 요청을 Authentication 필터가 가로챈 이후, 인증에 활용되는 UserPasswordAuthenticationToken 객체를 생성한다.</p>
</li>
<li><p>해당 UserPasswordAuthenticationToken 객체를 AuthenticationManager에게 전달한다.AuthenticationManager은 일반적으로 ProviderManager를 구현체로 사용한다. </p>
</li>
<li><p>ProviderManager는 등록된 AuthenticationProvider를 활용해 인증을 진행한다.</p>
</li>
<li><p>AuthenticationProvider는 UserDetailsService에게 사용자 인증 정보를 반환하도록 요청한다.</p>
</li>
<li><p>UserDetailsService는 전달받은 정보를 기반으로 UserRepository에서 사용자 정보를 조회한다. 이후, 이를 UserDetails 객체로 만든다.</p>
</li>
<li><p>6번 과정을 통해 생성된 UserDetails 객체를 AuthenticationProvider에게 전달한다. </p>
</li>
<li><p>AuthenticationProvider에서 인증 후 Authentication 객체를 반환받는다. 이 과정에서, 인증이 완료되지 않으면 Exception이 발생한다. </p>
</li>
<li><p>AuthenticationFilter에게 Authentication 객체를 반환한다.</p>
</li>
<li><p>SecurityContext에 Authentication 객체를 저장한다.</p>
</li>
</ol>
<p>위 과정은 Spring Security의 가장 기본적인 방법인데, JWT를 활용해서 진행하다 보니, 아래 실제 개발 과정은 위에서 일부분을 변경해서 사용한다.</p>
<h2 id="jwt">JWT</h2>
<h3 id="jwt란">JWT란?</h3>
<p><a href="https://jwt.io/">jwt.io</a>에서는 JWT을 아래와 같이 정의한다.
Json Web Token의 약자로, 당사자 간에 정보를 JSON 개체로 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준(RFC 7519)이다. 이 정보는 디지털 서명이 되어 있으므로 확인하고 신뢰할 수 있다. JWT는 비밀(HMAC 알고리즘 사용) 또는 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있습니다.
즉, Json 기반이며 Claims에 사용자 정보를 담아서 활용하는 Web Token이다.</p>
<h3 id="jwt-구조">JWT 구조</h3>
<p><a href="https://jwt.io/">jwt.io</a>를 보면, JWT가 크게 3가지의 속성을 가지는 것을 확인할 수 있다. 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/daad7768-76fe-4f64-a223-aec79863f209/image.png" alt=""></p>
<p><strong>Header</strong>
Header는 alg과 typ 정보를 가지고 있다.</p>
<ul>
<li>alg: 알고리즘 방식 (HS256, ..., etc)</li>
<li>typ: 토큰의 타입이 무엇인지 (여기서는 JWT) </li>
</ul>
<p><strong>Payload</strong>
토큰에서 사용될 정보들을 담고 있는 클레임(Claim)이 담겨 있다.
클레임은 등록 클레임, 공개 클레임과 비공개 클레임으로 나누어지며, 등록 클레임은 기본적으로 기입하도록 지정되어 있는 정보이고, 공개 클레임은 충돌 방지를 위한 공개용 정보, 비공개 클레임은 개발자가 임의로 지정한 정보들이다. 
클레임은 key-value형태로 저장된다.</p>
<p>대표적인 클레임 목록은 아래와 같다. (자세한 클레임 정보는 <a href="https://datatracker.ietf.org/doc/html/rfc7519#section-4.1">이 사이트</a>를 통해 확인할 수 있다.)</p>
<blockquote>
<p>등록 클레임</p>
<ul>
<li>iss: issuer의 약자로 토큰 발급자를 의미한다.</li>
</ul>
</blockquote>
<ul>
<li>sub: subject의 약자로 토큰 제목을 의미한다.</li>
<li>aud: audience의 약자로 토큰 대상자를 의미한다.</li>
<li>exp: expiration의 약자로, 토큰 만료시간을 의미하며 Nuemric Date를 활용한다.<blockquote>
<p>비공개 클레임 (아래는 예시)</p>
<ul>
<li>email: <a href="mailto:test@test.com">test@test.com</a></li>
<li>role: admin</li>
</ul>
</blockquote>
</li>
</ul>
<p><strong>Signature</strong>
토큰이 클라이언트와 서버 간에 안전하게 전송되고, 변조되지 않았음을 보장하며, 발급자의 신원을 인증하는 데 역할을 하는 암호화 코드이다. 
application.yml에서 설정하는 Secret Key가 암호화 코드로 사용된다.</p>
<blockquote>
<p>JWT는 각 속성별로 Base64 인코딩되어 표현되며, 각 속성별 구분자는 <code>.</code>이다.</p>
</blockquote>
<blockquote>
<p>발급한 JWT에 어떤 정보가 담겨 있는지는 해당 토큰을 <a href="https://jwt.io/">jwt.io</a>에서 기입하여 직접 확인할 수 있다.</p>
</blockquote>
<h3 id="jwt를-사용하는-이유">JWT를 사용하는 이유?</h3>
<ol>
<li><p>무상태성 (Stateless)
토큰을 클라이언트에 저장하고 활용하기에, 서버는 상태 관리를 별도로 진행할 필요가 없다. </p>
</li>
<li><p>확장성 (Scalability)
토큰을 서버에서 관리하지 않기에, 서버가 여러 대로 확장되었을 때 문제 발생 요소가 없다.</p>
</li>
<li><p>보안성
세션 방식은 쿠키를 활용하는데, 이 쿠키 관련 취약점을 활용할 수 없게 되어 보안성에 좋다. 물론, 토큰 환경의 취약점도 존재한다.</p>
</li>
<li><p>CORS
OAuth2.0등의 소셜 로그인과 결합하여 사용할 때, CORS 문제에서 자유롭다.</p>
</li>
</ol>
<h3 id="jwt를-활용하는-api의-비즈니스-로직">JWT를 활용하는 API의 비즈니스 로직</h3>
<p>토큰을 발급하여 활용하는 가장 대표적인 경우는 로그인과 같은 인증/인가 API다.
로그인은 일반 로그인과 OAuth를 활용한 소셜 로그인으로 나눌 수 있다.</p>
<p>각 방식별 비즈니스 로직은 아래와 같다. 
(서비스 특성에 따라 로직은 달라질 수 있으며, Refresh Token을 활용한 방식은 포함하지 않았다.)</p>
<p><strong>일반 회원가입/로그인</strong></p>
<ol>
<li>사용자의 회원가입 요청</li>
<li>요청 정보 확인하여, 기 가입 여부 확인 및 회원 정보 등록 및 성공 응답 반환</li>
<li>사용자의 로그인 요청</li>
<li>요청 정보 확인하여, 기 가입 여부 확인 및 해당 회원 정보 기반으로 Access Token 생성</li>
</ol>
<p><strong>OAuth 로그인</strong></p>
<ol>
<li>사용자의 회원가입/로그인 요청</li>
<li>요청 정보 확인하여, 기 회원가입 유저일 경우 로그인으로 아닐 경우 회원가입으로 처리</li>
<li>해당 사용자 데이터 기반으로 Access Token 발급</li>
</ol>
<h2 id="적용">적용</h2>
<h3 id="erd-설계">ERD 설계</h3>
<p>사용자에 관한 정보는 크게 두 종류의 테이블로 나누어 관리한다. 
<img src="https://velog.velcdn.com/images/dl-00-e8/post/57321380-96e3-4694-99ca-10dbf73541e1/image.png" alt=""></p>
<ul>
<li>users: 사용자의 기본 정보를 관리하는 테이블</li>
<li>credentials: 사용자의 중요 정보를 관리하는 테이블 (ex: 비밀번호 등)</li>
</ul>
<h3 id="개발">개발</h3>
<ol>
<li><p>build.gradle에 Dependency 추가</p>
<pre><code class="language-java"> // Spring Security
 implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;

 // 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>
</li>
<li><p>SecurityConfig 설정</p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
     http.cors(Customizer.withDefaults())
             .csrf(AbstractHttpConfigurer::disable);

     return http.build();
 }

 /**
  * CORS 허용하도록 커스터마이징 진행
  * @return - 변경된 CORS 정책 정보 반환
  */
 @Bean
 CorsConfigurationSource corsConfigurationSource() {
     CorsConfiguration config = new CorsConfiguration();

     // 인증정보 주고받도록 허용
     config.setAllowCredentials(true);
     //
     config.setAllowedOrigins(List.of(&quot;http://localhost:3000&quot;));
     config.setAllowedMethods(List.of(&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;, &quot;DELETE&quot;, &quot;PATCH&quot;, &quot;OPTIONS&quot;));
     config.setAllowedHeaders(List.of(&quot;*&quot;));
     config.setExposedHeaders(List.of(&quot;*&quot;));

     UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
     source.registerCorsConfiguration(&quot;/**&quot;, config);
     return source;
 }
}</code></pre>
</li>
</ol>
<ul>
<li><p>SecurityConfig 설정 과정에서, Security 6.1.0 버전 이후로는 람다식의 형태로 선언하도록 바뀌었음을 주의해야한다. 기존 방식대로 선언 시, 아래와 같은 안내를 확인할 수 있다. </p>
<pre><code>&#39;cors()&#39; is deprecated since version 6.1 and marked for removal</code></pre></li>
<li><p>cors의 경우, withDefault()로 선언 시, Bean으로 CorsConfiguration이 있다면 자동으로 주입하고 여러 개가 있다면 하단과 같이 주입할 수 있다. 
(관련 내용: <a href="https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html">Spring Security 문서 - CORS</a>)</p>
<pre><code class="language-java">public SecurityFilterChain myOtherFilterChain(HttpSecurity http) throws Exception {
      http
          .cors((cors) -&gt; cors
              .configurationSource(myWebsiteConfigurationSource())
          )
          ...
      return http.build();
  }</code></pre>
</li>
<li><p>세션 구조를 사용하지 않을 것이기에, STATELESS를 사용한다. 
(관련 내용: <a href="https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html#customizing-where-authentication-is-stored">Spring Secuirty 문서 - session</a>)</p>
</li>
<li><p>JWT를 활용한 API 방식이기에 httpBasic과 formlogin 비활성화 
(관련 내용: <a href="https://www.inflearn.com/questions/246473/httpbasic-%EC%84%A4%EC%A0%95">인프런 Q&amp;A</a>)</p>
</li>
</ul>
<ol start="3">
<li><p>application.yml 설정</p>
<pre><code class="language-yaml">jwt:
secret: {JWT_SECRET_KEY}
expiration-time: 108000000</code></pre>
<p>JWT를 사용하기 위한 Secret Key를 yml에 설정해야 한다. 
해당 Secret Key는 외부에 노출되지 않아야 한다. 해당 Key를 노출된다면, 해당 Key를 악용하여 토큰 생성 및 변조 등이 가능하기 때문이다.
Secret Key는 <strong>최소 512bits 이상의 값(= 64글자 이상)</strong>을 설정하는 것을 권장한다.
(관련 내용: <a href="https://auth0.com/blog/brute-forcing-hs256-is-possible-the-importance-of-using-strong-keys-to-sign-jwts/">Auth0</a>)
이에 더해, 만료시간은 서비스의 특성별로 달리한다. </p>
</li>
<li><p>JwtProvider </p>
</li>
</ol>
<p>JwtProvider는 아래와 같은 기능을 제공한다.</p>
<ul>
<li>ValidateToken: 토큰의 유효성 검사</li>
<li>GenerateToken: 주어진 정보 기반으로 토큰 생성(여기서는 email)</li>
<li>GetEmailFromToken: 토큰에서 사용자를 식별할 수 있는 정보 추출(여기서는 email)</li>
<li>etc: 만료 시간을 확인하는 메소드 등</li>
</ul>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class JwtProvider {

    @Value(&quot;${jwt.secret}&quot;)
    private String secretKey;
    private Key key;
    @Value(&quot;${jwt.expiration-time}&quot;)
    private long expirationTime;

    @PostConstruct
    protected void init() {
        byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey);
        key = Keys.hmacShaKeyFor(secretKeyBytes);
    }

    /**
     * JWT 생성
     * @param user 사용자 정보
     * @return 사용자 정보를 기반으로 추출된 토큰 반환
     */
    public String generateToken(User user) {
        Claims claims = getClaims(user);

        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + expirationTime))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jws&lt;Claims&gt; claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return claims.getBody().getExpiration().after(new Date());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public String getEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    }

    /**
     * 토큰의 만료기한 반환
     * @param token 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴
     * @return 해당 토큰의 만료정보를 반환
     */
    public Long getExpirationTime(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().getTime();
    }

    /**
     * Claims 정보 생성
     * @param user 사용자 정보 중 사용자를 구분할 수 있는 정보 두 개를 활용함
     * @return 사용자 구분 정보인 이메일과 역할을 저장한 Claims 객체 반환
     */
    private Claims getClaims(User user) {
        return Jwts.claims().setSubject(user.getEmail());
    }
}</code></pre>
<ol start="4">
<li>CustomUserDetails</li>
</ol>
<p>기본 UserDetails의 구현체를 커스텀해서 사용한다. 
보통 class로 많이 사용하지만, Java17 이후 버전부터는 record로 변환할 수 있어, record로 개발했다. 
@Override를 활용해야 하는 <code>is~~()</code> 메소도들은 대부분 JWT 인증방식에서 활용하지 않기에, 아래와 같이 true로만 반환하도록 구성했다.</p>
<pre><code class="language-java">public record CustomUserDetails(User user) implements UserDetails {

    @Override
    public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
        List&lt;String&gt; roles = new ArrayList&lt;&gt;();
        roles.add(&quot;ROLE_&quot; + user.getRole().toString());

        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return &quot;&quot;; // 비밀번호는 별도로 관리하고 있으므로, 해당 메소드는 빈 문자열을 반환하도록 설정
    }

    @Override
    public String getUsername() {
        return user.getName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true; // JWT 인증방식이므로 접근 가능하도록 설정
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // JWT 인증방식이므로 접근 가능하도록 설정
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true; // JWT 인증방식이므로 접근 가능하도록 설정
    }

    @Override
    public boolean isEnabled() {
        return true; // JWT 인증방식의므로 접근 가능하도록 설정
    }
}</code></pre>
<blockquote>
<p>Q. 왜 CustomUserDetails에 User Entity를 직접 저장했는가?</p>
<p>A. 사용자 기본 정보와 중요 정보 테이블을 분리하여 OneToOne의 관계로 설계했기 때문이다. 일반적으로 사용자를 확인할 수 있는 최소 정보만을 Jwt에서 추출하여 CustomUserDetails로 활용하는 것이 맞겠지만, User 엔티티에서는 사용자의 기본 정보만을 가지고 있기에 CustomerUserDetails를 활용하는 목적에서 크게 벗어나지 않는다고 판단했다. 이에 더해, 사용자 정보를 가지고 있는 상태에서 시작하기에 Service 계층에서 기본 정보를 기반으로 사용자를 조회하는 중복 코드를 줄일 수 있을 것으로 판단했기에, 직접 저장하도록 개발했다.</p>
</blockquote>
<ol start="5">
<li><p>CustomUserDetailsService</p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

 private final UserRepository userRepository;

 @Override
 public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
     User user = userRepository.findByEmail(email).orElseThrow(() -&gt; new ApplicationException(ErrorCode.USER_NOT_FOUND));

     return new CustomUserDetails(user);
 }
}</code></pre>
</li>
<li><p>JwtFilter</p>
</li>
</ol>
<p>이 부분이 맨 위에서 적었던 Spring Security 방식과 가장 큰 차이점이다. 
기본적으로 Spring Security는 UsernamePasswordAuthenticationFilter를 활용하는데, 그 앞에 JwtFilter를 두어 처리함으로써 기본 Spring Security와 다르게 동작한다.
사실상, UsernamePasswordAuthenticationFilter를 JwtFilter가 대체하게 되는 것이다. </p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final CustomUserDetailsService customUserDetailsService;


    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);

        // JWT 유효성 검증
        if (StringUtils.hasText(token) &amp;&amp; jwtProvider.validateToken(token)) {
            String email = jwtProvider.getEmail(token);

            // 유저 정보 생성
            UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);

            if (userDetails != null) {
                // UserDetails, Password, Role 정보를 기반으로 접근 권한을 가지고 있는 Token 생성
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

                // Security Context 해당 접근 권한 정보 설정
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // 다음 필터로 넘기기
        filterChain.doFilter(request, response);
    }

    /**
     * Request Header에서 토큰 조회 및 Bearer 문자열 제거 후 반환하는 메소드
     * @param request HttpServletRequest
     * @return 추출된 토큰 정보 반환 (토큰 정보가 없을 경우 null 반환)
     */
    private String resolveToken(HttpServletRequest request) {
        String token = request.getHeader(&quot;Authorization&quot;);

        // Token 정보 존재 여부 및 Bearer 토큰인지 확인
        if (token != null &amp;&amp; token.startsWith(&quot;Bearer &quot;)) {
            return token.substring(7);
        }

        return null;
    }
}</code></pre>
<ol start="7">
<li>JwtAccessDeniedHandler</li>
</ol>
<p>AccessDeniedHandler는 인가되지 않은 요청일 경우에 대한 예외 처리 핸들러 인터페이스이다. 사용자의 요청이 인가되지 않았을 경우, 예외 처리되도록 아래와 같이 구현했다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    /**
     * 인가 실패 관련 403 핸들링
     * @param request ServletRequest 객체
     * @param response ServletResponse 객체
     * @param accessDeniedException 접근권한 거부 예외 정보
     * @throws IOException IO 예외 가능성 처리
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType(&quot;application/json;charset=UTF-8&quot;);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        setResponse(response);
    }

    /**
     * Error 관련 응답 Response 생성 메소드
     * @param response ServletResponse 객체
     * @throws IOException IO 예외 가능성 처리
     */
    private void setResponse(HttpServletResponse response) throws IOException {
        response.setContentType(&quot;application/json;charset=UTF-8&quot;);
        response.setStatus(ErrorCode.FORBIDDEN.getHttpStatus().value());

        ExceptionResponse errorResponse = ExceptionResponse.of(ErrorCode.FORBIDDEN);
        String errorJson = objectMapper.writeValueAsString(errorResponse);

        response.getWriter().write(errorJson);
    }
}</code></pre>
<ol start="8">
<li>JwtAuthenticationEntryPoint</li>
</ol>
<p>AuthenticationEntryPoint는 사용자가 인증을 요구하는 엔드포인트에 접근할 때, 발생하는 예외를 처리하는 인터페이스다. 여기서는 JWT 인증방식이므로 아래와 같이 예외 처리 로직을 구현했다. </p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        Object exception = request.getAttribute(&quot;exception&quot;);

        // exception에 할당된 속성이 ErrorCode일 경우, 관련된 응답 객체 정보를 삽입하도록 설정
        if (exception instanceof ErrorCode) {
            setResponse(response, (ErrorCode) exception);

            return;
        }

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }

    /**
     * Error 관련 응답 Response -&gt; ServletResponse 저장하는 메소드
     * @param response ServletResponse 객체
     * @param errorCode 발생한 에러 정보를 담고 있는 객체
     * @throws IOException IO 과정에서 예외
     */
    private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setContentType(&quot;application/json;charset=UTF-8&quot;);
        response.setStatus(errorCode.getHttpStatus().value());

        ExceptionResponse exceptionResponse = ExceptionResponse.of(errorCode);
        String errorJson = objectMapper.writeValueAsString(exceptionResponse);

        response.getWriter().write(errorJson);
    }
}</code></pre>
<ol start="9">
<li>최종 SecurityConfig</li>
</ol>
<p>최종 SecurityConfig는 아래와 같이 구현했다. 여기서 특징은 <code>/error/*</code> 엔드포인트에 대하여 permitAll()을 걸어놓았다는 것인데, 이는 실제 개발하는 과정에서 마주친 오류 때문이다. </p>
<blockquote>
<p><strong>Spring Boot와 Spring Security 간의 충돌</strong>
실제 개발 후 테스트를 진행하는 과정에서, 401 UnAuthorized와 함께 어떠한 응답도 오지 않은 경우가 발생했다. 이는 <code>.anyRequest().authenticated());</code> 때문에 발생한 것이었다.</p>
<p>예외가 발생했을 때, Spring Boot는 <code>/error/*</code>엔드 포인트로 라우팅시켜, 에러를 처리한다. BasicErrorController가 <code>/error/*</code> 엔드 포인트와 연결되어 있어, 기본적인 HTML, JSON, XML 에러 응답을 내려준다.</p>
<p>그렇기에, 모든 예외 처리를 세부적으로 구현하지 않은 상태에서 어떤 path에서 예외가 발생했는지 확인하면서 개발하기 위해서는 <code>/error/*</code> 엔드포인트에 대해 <code>.permitAll()</code> 설정을 걸어주어야 한다.</p>
</blockquote>
<p>(관련 블로그: <a href="https://colabear754.tistory.com/182#AuthenticationEntryPoint_%EA%B5%AC%ED%98%84%EC%B2%B4_%EB%93%B1%EB%A1%9D">Spring Security 403 에러 관련</a>)</p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;
    private final ExceptionFilter exceptionFilter;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
                .csrf(AbstractHttpConfigurer::disable);

        http.sessionManagement((session) -&gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));// Session 미사용

        // httpBasic, httpFormLogin 비활성화
        http.httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable);

        // JWT 관련 필터 설정 및 예외 처리
        http.exceptionHandling((exceptionHandling) -&gt;
                exceptionHandling
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
        );
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(exceptionFilter, JwtFilter.class);

        // 요청 URI별 권한 설정
        http.authorizeHttpRequests((authorize) -&gt;
                // Swagger UI 외부 접속 허용
                authorize.requestMatchers( &quot;/api-docs/**&quot;, &quot;/swagger-ui/**&quot;, &quot;/swagger-ui.html&quot;).permitAll()
                        // 로그인 로직 접속 허용
                        .requestMatchers(&quot;/v1/auth/**&quot;).permitAll()
                        // DefaultExceptionHandler 처리를 위한 error PermitAll
                        .requestMatchers(&quot;/error/**&quot;).permitAll()
                        // 이외의 모든 요청은 인증 정보 필요
                        .anyRequest().authenticated());

        return http.build();
    }

    /**
     * CORS 허용하도록 커스터마이징 진행
     * @return - 변경된 CORS 정책 정보 반환
     */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // 인증정보 주고받도록 허용
        config.setAllowCredentials(true);
        // 허용할 주소
        config.setAllowedOrigins(List.of(&quot;*&quot;));
        // 허용하고자 하는 HTTP Method
        config.setAllowedMethods(List.of(&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;, &quot;DELETE&quot;, &quot;PATCH&quot;, &quot;OPTIONS&quot;));
        // 허용할 헤더 정보
        config.setAllowedHeaders(List.of(&quot;*&quot;));
        // 노출시킬 헤더 정보
        config.setExposedHeaders(List.of(&quot;*&quot;));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration(&quot;/**&quot;, config);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 비밀번호 암호화 방식 설정
        return new BCryptPasswordEncoder();
    }
}</code></pre>
<p>최종적인 파일 구조는 아래와 같다.
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/617d4432-e43c-4fa1-becb-701872dea80e/image.png' width = 150></p>
<p>이렇게 Spring Security를 구현한 이후, 회원가입/로그인 API를 Security Config에 등록한 PasswordEncoder를 활용하여 개발하면 된다. OAuth를 사용한다면, 위의 CustomUserDetails와 CustomUserDetailsService에서 OAuth를 활용해 받은 정보를 바인딩하는 구조로 활용하면 된다.</p>
<h3 id="argument-resolver-도입">Argument Resolver 도입</h3>
<p>Spring Security에서는 역할 및 권한 제어를 위해 아래와 같이 개발할 수 있다.</p>
<pre><code class="language-java">@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    // 요청 URI별 권한 설정
    http.authorizeHttpRequests((authorize) -&gt;
            // Swagger UI 외부 접속 허용
            authorize.requestMatchers( &quot;/api-docs/**&quot;, &quot;/swagger-ui/**&quot;, &quot;/swagger-ui.html&quot;).permitAll()
                    // 로그인 로직 접속 허용
                    .requestMatchers(&quot;/v1/auth/**&quot;).permitAll()
                    .requestMatchers(&quot;/v1/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
                    // DefaultExceptionHandler 처리를 위한 error PermitAll
                    .requestMatchers(&quot;/error/**&quot;).permitAll()
                    // 이외의 모든 요청은 인증 정보 필요
                    .anyRequest().authenticated());

    return http.build();
}</code></pre>
<p>SecurityConfig 파일로 중앙 집중형으로 권한을 제어하는 방식과 별개로 @Secured나 @PreAuthorize와 같은 어노테이션으로 메소드에 대한 권한을 관리할 수 있다.</p>
<p>이 과정에 더해, 개발자가 서비스 특성에 맞추어 역할/권한별 인가 여부를 관리할 때는 메소드별로 <strong>중복 코드</strong>가 발생할 수 있다. 즉, Service 계층에서 메소드별로 지속적으로 검증해야 하는 것이다. 이런 상황에서 Argument Resolver를 도입하여 중복 코드를 개선할 수 있다.</p>
<p><strong>적용</strong>
직관적으로 이해하기 쉽도록 인가된 사용자의 정보를 가져오는 Argument Resolver를 개발한 내용을 정리한다. (아래의 코드에서 서비스 특성별로 로직을 추가하면 된다.)</p>
<ol>
<li><p>어노테이션으로 활용할 @interface 설정</p>
<pre><code class="language-java">@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthUser {
}</code></pre>
</li>
<li><p>ArgumentResolver 추가</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {

 // @Auth 존재 여부 확인
 @Override
 public boolean supportsParameter(MethodParameter parameter) {
     return parameter.hasParameterAnnotation(AuthUser.class);
 }

 // @Auth 존재 시, 사용자 정보 확인하여 반환
 @Override
 public Object resolveArgument(@NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, @NonNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
     Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
     if (authentication == null) {
         throw new ApplicationException(ErrorCode.USER_NOT_FOUND);
     }

     Object principal = authentication.getPrincipal();
     if (!(principal instanceof CustomUserDetails userDetails)) {
         throw new ApplicationException(ErrorCode.USER_NOT_FOUND);
     }

     return userDetails.user();
 }
}</code></pre>
</li>
<li><p>설정한 ArgumentResolver를 활용할 수 있도록 설정에 추가</p>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

 private final AuthArgumentResolver authArgumentResolver;

 @Override
 public void addArgumentResolvers(List&lt;HandlerMethodArgumentResolver&gt; argumentResolvers) {
     argumentResolvers.add(authArgumentResolver);
 }
}</code></pre>
<p>어노테이션으로 활용할 명칭을 지정해서 생성한다. 여기서는 <code>@AuthUser</code>를 인증받은 사용자라는 의미로 활용한다.</p>
</li>
</ol>
<h3 id="createdby-modifiedby-도입">@CreatedBy, @ModifiedBy 도입</h3>
<p>해당 데이터의 생성, 수정, 삭제 시각을 관리하기 위해 BaseTimeEntity를 만들어서 관리하곤 한다. 이에 더해, 해당 데이터를 생성한 사용자와 수정한 사용자의 정보를 추가적으로 관리해야 할 경우가 있는데, AuditAware 설정을 통해 쉽게 적용할 수 있다.</p>
<p><strong>적용</strong></p>
<pre><code class="language-java">// AuditConfig
@Configuration
public class AuditConfig {

    @Bean
    public AuditorAware&lt;Long&gt; auditorProvider() {
        return new AuditAwareImpl();
    }
}</code></pre>
<pre><code class="language-java">// AuditAwareImpl
public class AuditAwareImpl implements AuditorAware&lt;Long&gt; {

    @Override
    public Optional&lt;Long&gt; getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(authentication == null || !authentication.isAuthenticated()) {
            return Optional.empty();
        }

        Object principal = authentication.getPrincipal();

        if (principal instanceof CustomUserDetails userDetails) {
            return Optional.of(userDetails.user().getId());
        }

        return Optional.empty();
    }
}</code></pre>
<pre><code class="language-java">// BaseEntity
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

    @CreatedBy
    @Column(name = &quot;created_by&quot;, nullable = false, updatable = false)
    private Long createdBy;

    @LastModifiedBy
    @Column(name = &quot;modified_by&quot;, nullable = false)
    private Long modifiedBy;
}
</code></pre>
<p><strong>생성자 적용 결과</strong>
권한 구분을 Validation 조건으로 Service 계층에서 적용한 결과는 아래와 같다.</p>
<ol>
<li>GENERAL 권한</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/52b325de-f056-417e-9dc5-eb63eee5ae6f/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/97d75fad-24f3-4959-bbdf-f139f343192a/image.png" alt="">
사용자의 권한 문제로 인해 권한 오류가 발생한 것을 알 수 있다.</p>
<ol start="2">
<li>ADMIN 권한</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/bd124d4f-036b-4ef1-aca4-70d7867e4a32/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/362e4e09-4ea3-4714-a423-855574045b13/image.png" alt=""> <img src="https://velog.velcdn.com/images/dl-00-e8/post/3e5b58cb-960b-4832-acd2-f6e0c5bbcd1e/image.png" alt="">
사용자의 pk값이 created_by와 updated_by로 적용되어 있는 것을 확인할 수 있다. <span style="color: gray">(이미지를 보면, modified_by가 아닌 updated_by라고 적혀있는데, 이는 최초 칼럼명을 updated_by로 사용하다, 어노테이션에 맞추어 변경한 것이므로 modifed_by를 의미한다.)</span></p>
<blockquote>
<p>관련 코드는 <a href="https://github.com/dl-00-e8/study-development/tree/main/spring_security_with_jwt">Github Repository</a>에서 확인할 수 있다.</p>
</blockquote>
<hr>
<p>오랜만에 Spring Security를 다루어 보았는데, 까먹은 내용도 많고 헷갈리는 내용도 굉장히 많았다. 방대한 기능을 제공하는 만큼 시간 날 때마다 관련 내용을 학습하고 업데이트할 필요성을 느꼈다.</p>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="">Spring Security Docs</a></li>
<li><a href="https://mangkyu.tistory.com/76">망나니 개발자님 블로그</a></li>
<li><a href="https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0#spring-security-architecture%EA%B5%AC%EC%A1%B0">김희망님 블로그</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Excel 입력과 다운로드 (csv, xlsx)]]></title>
            <link>https://velog.io/@dl-00-e8/Spring-Boot-Excel-%EC%9E%85%EB%A0%A5%EA%B3%BC-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-csv-xlsx</link>
            <guid>https://velog.io/@dl-00-e8/Spring-Boot-Excel-%EC%9E%85%EB%A0%A5%EA%B3%BC-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-csv-xlsx</guid>
            <pubDate>Mon, 17 Jun 2024 15:23:00 GMT</pubDate>
            <description><![CDATA[<p>Spring Boot로 Excel을 다룰 일이 생겨, 작업했던 내용을 정리해보려고 한다.</p>
<h1 id="spring-boot--excel">Spring Boot + Excel</h1>
<p>엑셀을 활용한 기능은 크게 두 가지로 나누어 볼 수 있다. </p>
<ul>
<li><strong>엑셀 파일의 데이터를 서버에서 활용 가능한 데이터로 변환하기</strong></li>
<li><strong>서버 데이터를 엑셀 형식으로 변환하여 반환하기</strong></li>
</ul>
<h3 id="dependency">Dependency</h3>
<p>아래와 같이 의존성을 추가하면 된다.</p>
<pre><code class="language-java">// Excel
implementation &#39;org.apache.poi:poi-ooxml:5.2.5&#39;
implementation &#39;com.opencsv:opencsv:5.9&#39;</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/2c56d75a-5c1a-4cec-9229-70a448d16fac/image.png" alt=""></p>
<p><a href="https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml">Maven Repository</a>에서 Apache POI를 확인해보면, 현재 5.x 버전이 모두 취약점이 존재한다는 것을 알 수 있다. 관련 취약점들은 모두 24년도에 발견된 것이고, 2종류는 인증 로직에 관련한 문제, 1종류는 인증서 관련 CPU 소비 문제, 나머지 2종류는 Apache Commons Compress 관련 문제다. 최신 버전이 23년 11월이므로, 24년도에 발생한 취약점이 해결된 버전이 나올 때까지는 해당 취약점에 대해 인지한 상태로 사용하면 될 것 같다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/3819db5f-2988-451c-bcff-a73e17ee3433/image.png" alt="">
<a href="https://mvnrepository.com/artifact/com.opencsv/opencsv/5.9">Maven Repository</a>에서는 Vulnerabilities를 확인할 수 없지만, IntelliJ에서 OpenCSV 또한 취약점이 있다고 한다. 내용을 보면 제어할 수 없는 재귀가 발생할 수 있다고 하는데, 인지하고 사용하면 될 것 같다.</p>
<h2 id="엑셀-파일로-데이터-등록하기">엑셀 파일로 데이터 등록하기</h2>
<p>엑셀 파일을 multipart/form-data형식의 API로 받아 데이터베이스 설계에 맞추어 변환해서 저장하는 로직이다. </p>
<h3 id="applicationyml">application.yml</h3>
<pre><code class="language-yaml">servlet:
  multipart:
    enabled: true # 멀티파트 업로드 지원여부 (default: true)
    file-size-threshold: 0B # 파일을 디스크에 저장하지 않고 메모리에 저장하는 최소 크기 (default: 0B)
    max-file-size: 100MB # 한개 파일의 최대 사이즈 (default: 1MB)
    max-request-size: 100MB # 한개 요청의 최대 사이즈 (default: 10MB)</code></pre>
<p>첫 번째로는 application.yml을 설정해주어야 한다. </p>
<p>설정해야 하는 이유는 서블릿 컨테이너의 동작 과정을 보면 알 수 있다.</p>
<blockquote>
<ol>
<li><p>Tomcat과 같은 서블릿 컨테이너에서 multipart/form-data 형식의 요청을 받아, 해당 데이터를 임시 또는 지정된 위치에 저장하게 된다. 이 때, 서블릿 컨테이너는 max-file-size 또는 max-request-size 설정을 활용하여 파일 크기와 전체 요청 크기를 제한한다.</p>
</li>
<li><p>서블릿 컨테이너는 추출된 데이터를 포함하는 요청 객체를 생성한다.
이 요청 객체는 Spring MVC의 컨트롤러 메서드에 전달된다.
컨트롤러는 @RequestPart 애노테이션을 사용하여 업로드된 파일과 폼 필드 데이터를 처리할 수 있다.</p>
</li>
<li><p>컨트롤러에서 업로드된 데이터에 대해 서비스 계층 등으로 넘겨 비즈니스 로직을 진행한다.
처리가 완료되면 서블릿 컨테이너는 임시 파일이나 메모리에 저장된 데이터를 정리한다.</p>
</li>
</ol>
</blockquote>
<h3 id="controller">Controller</h3>
<pre><code class="language-java">@PostMapping(&quot;/register/excel&quot;)
@Operation(summary = &quot;고객 정보 엑셀로 등록&quot;, description = &quot;사용자의 정보 입력 후 회원가입에 따른 성공/실패 여부 반환&quot;)
@ApiResponses(
        value = {
                @ApiResponse(responseCode = &quot;1000&quot;, description = &quot;성공 시 반환되는 코드&quot;, content = {@Content(schema = @Schema(implementation = ApplicationResponse.class))}),
                @ApiResponse(responseCode = &quot;2000&quot;, description = &quot;입력 값이 유효하지 않거나, 서버 내부에서 오류가 발생했을 때 반환하는 에러&quot;, content = {@Content(schema = @Schema(implementation = ExceptionResponse.class))}),
        })
public ResponseEntity&lt;?&gt; registerCustomerExcel(@RequestPart(name = &quot;file&quot;) MultipartFile file, @RequestParam(name = &quot;level&quot;) String level) {
    customerFacade.registerCustomerExcel(file, level);
    return ResponseEntity.ok()
            .body(ApplicationResponse.ok());
}</code></pre>
<p>파일은 MultipartFile 자료형으로 받아서 처리하면 된다.</p>
<h3 id="facade">Facade</h3>
<pre><code class="language-java">public void registerCustomerExcel(MultipartFile file, String level) {
    List&lt;ExcelDto&gt; excelDtoList = excelClient.extractExcel(file);
    customerService.registerCustomerExcel(excelDtoList, level);
}</code></pre>
<p>현재 Facade패턴으로 개발을 진행하고 있기에, 비즈니스 로직을 처리하는 Service 계층의 메소드를 Facade에서 묶어서 처리한다.
이는 Facade패턴이 아니라면, Service 계층에서 바로 Excel을 처리하는 Component에 대한 의존성을 주입해서 개발해도 무방하다.</p>
<h3 id="component">Component</h3>
<pre><code class="language-java">/**
 * 파일의 확장자가 엑셀에 맞는 .csv / .xlsx 인지 확인하는 메소드
 * @param file 파일
 * @return 파일이 엑셀 확장자일 경우 true, 그 외는 모두 false
 */
private String checkExtension(MultipartFile file) {
    if(file == null) {
        throw new ApplicationException(ErrorCode.EXCEL_EXTENSION);
    }

    String fileName = file.getOriginalFilename();

    if(fileName == null) {
        throw new ApplicationException(ErrorCode.EXCEL_EXTENSION);
    }

    int dotIndex = fileName.lastIndexOf(&quot;.&quot;);
    if(dotIndex == -1) {
        throw new ApplicationException(ErrorCode.EXCEL_EXTENSION);
    }

    String extension = fileName.substring(dotIndex + 1);
    if(!extension.equals(&quot;csv&quot;) &amp;&amp; !extension.equals(&quot;xls&quot;) &amp;&amp; !extension.equals(&quot;xlsx&quot;)) {
        throw new ApplicationException(ErrorCode.EXCEL_EXTENSION);
    }

    return extension;
}

/**
 * xlsx 확장자 파일에서 셀의 값을 문자열로 변환하는 메소드
 * @param cell 셀 정보
 * @return 셀 값을 변환한 문자열
 */
private String getCellValueAsString(Cell cell) {
    if (cell == null) {
        return &quot;&quot;;
    }

    CellType cellType = cell.getCellType();
    Object cellValue = getCellValue(cell, cellType);
    return cellValue != null ? cellValue.toString() : &quot;&quot;;
}

/**
 * xlsx 확장자 파일에서 특정 셀의 값을 반환하는 메소드
 * @param cell 특정 셀 정보
 * @param cellType 특정 셀 자료형
 * @return 해당 셀의 자료형에 맞는 값 반환
 */
private Object getCellValue(Cell cell, CellType cellType) {
    return switch (cellType) {
        case STRING -&gt; cell.getStringCellValue();
        case NUMERIC -&gt; cell.getNumericCellValue();
        case BOOLEAN -&gt; cell.getBooleanCellValue();
        case FORMULA -&gt; cell.getCellFormula();
        default -&gt; null;
    };
}

/**
 * 파일에서 데이터 추출
 * @param file 파일
 * @return 파일의 데이터를 DTO List 변환 후 반환
 */
public List&lt;ExcelDto&gt; extractExcel(MultipartFile file) {
    try {
        List&lt;ExcelDto&gt; excelDtoList = new ArrayList&lt;&gt;();

        String extension = checkExtension(file);

        switch (extension) {
            case &quot;xlsx&quot;:
                Workbook workbook = new XSSFWorkbook(file.getInputStream());

                Sheet sheet = workbook.getSheetAt(0);

                for(Row row : sheet) {
                    if(row.getRowNum() &gt; 0) {
                        String customerName = getCellValueAsString(row.getCell(0));
                        String customerEmail = getCellValueAsString(row.getCell(1));
                        String customerPhone = getCellValueAsString(row.getCell(2));
                        String customerType = getCellValueAsString(row.getCell(3));
                        String company = getCellValueAsString(row.getCell(4));
                        String team = getCellValueAsString(row.getCell(5));
                        String position = getCellValueAsString(row.getCell(6));
                        String etc = getCellValueAsString(row.getCell(7));

                        excelDtoList.add(ExcelDto.builder()
                                .customerName(customerName)
                                .customerEmail(customerEmail)
                                .customerPhone(customerPhone)
                                .customerType(customerType)
                                .company(company)
                                .team(team)
                                .position(position)
                                .etc(etc)
                                .build()
                        );
                    }
                    // Header 검증
                    else {
                        if(!getCellValueAsString(row.getCell(0)).equals(&quot;이름&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(1)).equals(&quot;이메일&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(2)).equals(&quot;전화번호&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(3)).equals(&quot;고객 유형&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(4)).equals(&quot;회사&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(5)).equals(&quot;부서&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(6)).equals(&quot;직책&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                        if(!getCellValueAsString(row.getCell(7)).equals(&quot;기타&quot;)) {
                            throw new ApplicationException(ErrorCode.EXCEL_HEADER);
                        }
                    }
                }

                break;
            case &quot;csv&quot;:
                Reader reader = new BufferedReader(new InputStreamReader(file.getInputStream()));
                CsvToBean&lt;ExcelDto&gt; csvToBean = new CsvToBeanBuilder&lt;ExcelDto&gt;(reader)
                        .withType(ExcelDto.class)
                        .withIgnoreLeadingWhiteSpace(true)
                        .build();

                excelDtoList = csvToBean.parse();
                break;
        }

        return excelDtoList;
    } catch (IOException e) {
        throw new ApplicationException(ErrorCode.EXCEL_CONVERT_ERROR);
    } catch (Exception e) {
        log.error(e.getMessage());
        throw new RuntimeException(e);
    }
}</code></pre>
<ul>
<li>checkExtension은 확장자를 반환하는 메소드로, 확장자에 따라 다른 엑셀 파일 처리 로직을 진행하기에 개발했다.</li>
<li>getCellValueAsString은 Pandas에서 엑셀을 읽을 때 사용하는 <code>dtype=str</code>의 역할을 한다. </li>
<li>getCellValue는 getCellValueAsString를 통해 문자열로만 셀의 값을 반환하기 전, 셀의 자료형에 따라 다르게 가져와야 하는 경우를 처리하는 역할을 한다.</li>
<li>extractExcel은 엑셀 파일을 열어 row별로 데이터를 가져온다.</li>
</ul>
<h3 id="dto">DTO</h3>
<pre><code class="language-java">@Getter
@NoArgsConstructor
public class ExcelDto {

    @CsvBindByName(column = &quot;이름&quot;)
    private String customerName;

    @CsvBindByName(column = &quot;이메일&quot;)
    private String customerEmail;

    @CsvBindByName(column = &quot;전화번호&quot;)
    private String customerPhone;

    @CsvBindByName(column = &quot;고객 유형&quot;)
    private String customerType;

    @CsvBindByName(column = &quot;회사&quot;)
    private String company;

    @CsvBindByName(column = &quot;부서&quot;)
    private String team;

    @CsvBindByName(column = &quot;직책&quot;)
    private String position;

    @CsvBindByName(column = &quot;기타&quot;)
    private String etc;

    @Builder
    public ExcelDto(String customerName, String customerEmail, String customerPhone, String customerType, String company, String team, String position, String etc) {
        this.customerName = customerName;
        this.customerEmail = customerEmail;
        this.customerPhone = customerPhone;
        this.customerType = customerType;
        this.company = company;
        this.team = team;
        this.position = position;
        this.etc = etc;
    }
}</code></pre>
<blockquote>
<p><code>java.lang.RuntimeException: com.opencsv.exceptions.CsvBeanIntrospectionException: Basic instantiation of the given bean type (and subordinate beans created through recursion, if applicable) was determined to be impossible.</code> </p>
<p>OpenCSV를 활용해 , Dto로 변환하는 과정에서 위와 같은 오류가 발생했다.
해당 오류는 <code>@NoArgsConstructor</code>를 추가하지 않아 발생한 오류다.
Csv 파일의 Row별로 가져온 이후, Bean 클래스를 위한 공용 생성자가 있어야 하는데, 이 부분에서 공용 생성자를 찾지 못해 발생한 것이었다. <a href="https://stackoverflow.com/questions/50463948/getting-csvbeanintrospectionexception-while-using-opencsv">stackoverflow</a>를 찾아보니, <code>@Builder</code>를 사용하는 경우에는 <code>@AllArgsConstructor(access = PRIVATE)</code>를 포함해야 한다고 한다.</p>
</blockquote>
<h2 id="등록된-데이터-엑셀로-내려주기">등록된 데이터 엑셀로 내려주기</h2>
<p>xlsx와 csv 중 어떤 확장자로 다운로드할 수 있도록 제공하냐에 따라서 달라진다.
API를 분리해도 되지만, 나는 RequestParameter를 활용한 조건 분기 방식으로 개발했다.</p>
<h3 id="controller-1">Controller</h3>
<pre><code class="language-java">@GetMapping(&quot;/download/excel&quot;)
@Operation(summary = &quot;고객 정보 엑셀로 반환&quot;, description = &quot;csv, xlsx와 같은 type 구분에 따라 해당 확장자로 모든 고객 정보 반환&quot;)
@ApiResponses(
        value = {
                @ApiResponse(responseCode = &quot;1000&quot;, description = &quot;성공 시 반환되는 코드&quot;, content = {@Content(schema = @Schema(implementation = ApplicationResponse.class))}),
                @ApiResponse(responseCode = &quot;2000&quot;, description = &quot;입력 값이 유효하지 않거나, 서버 내부에서 오류가 발생했을 때 반환하는 에러&quot;, content = {@Content(schema = @Schema(implementation = ExceptionResponse.class))}),
        })
public void downloadCustomerExcel(HttpServletRequest request, HttpServletResponse response) {
    try {
        // Query Parameter 추출
        String type = request.getParameter(&quot;type&quot;);

        // 비즈니스 로직 수행
        List&lt;CustomerResponse&gt; customerResponseList = customerFacade.downloadCustomerExcel();

        // 엑셀 응답 반환
        switch (type) {
            case &quot;csv&quot;:
                // 엑셀 응답 설정
                response.setContentType(&quot;text/csv; charset=UTF-8&quot;); // Set the character encoding

                response.setHeader(&quot;Content-Disposition&quot;,
                        &quot;attachment; filename=\&quot;&quot; + URLEncoder.encode(&quot;고객 정보.csv&quot;, StandardCharsets.UTF_8) + &quot;\&quot;&quot;);

                OutputStreamWriter writer = new OutputStreamWriter(response.getOutputStream(),
                        StandardCharsets.UTF_8);
                writer.write(&quot;\uFEFF&quot;);
                CSVWriter csvWriter = new CSVWriter(writer);

                // 데이터 행 추가
                List&lt;String[]&gt; dataList = new ArrayList&lt;&gt;();
                dataList.add(new String[]{&quot;이름&quot;, &quot;이메일&quot;, &quot;전화번호&quot;, &quot;고객 레벨&quot;, &quot;고객 유형&quot;, &quot;회사&quot;, &quot;부서&quot;, &quot;직책&quot;, &quot;기타&quot;, &quot;수신거부 여부&quot;});

                for (CustomerResponse customer : customerResponseList) {
                    String[] dataRow = {
                            customer.customerName(),
                            customer.customerEmail(),
                            customer.customerPhone(),
                            customer.customerLevel(),
                            customer.customerType(),
                            customer.company(),
                            customer.team(),
                            customer.position(),
                            customer.etc(),
                            customer.isRejected() != null ? customer.isRejected().toString() : &quot;&quot;
                    };
                    dataList.add(dataRow);
                }

                // 엑셀 응답 설정
                csvWriter.writeAll(dataList);

                csvWriter.close();
                writer.close();
                break;

            case &quot;xlsx&quot;:
                // 엑셀 데이터 생성
                Workbook workbook = new XSSFWorkbook();
                Sheet sheet = workbook.createSheet(&quot;고객 명단&quot;);
                int rowNo = 0;

                Row headerRow = sheet.createRow(rowNo++);
                headerRow.createCell(0).setCellValue(&quot;이름&quot;);
                headerRow.createCell(1).setCellValue(&quot;이메일&quot;);
                headerRow.createCell(2).setCellValue(&quot;전화번호&quot;);
                headerRow.createCell(3).setCellValue(&quot;고객 레벨&quot;);
                headerRow.createCell(4).setCellValue(&quot;고객 유형&quot;);
                headerRow.createCell(5).setCellValue(&quot;회사&quot;);
                headerRow.createCell(6).setCellValue(&quot;부서&quot;);
                headerRow.createCell(7).setCellValue(&quot;직책&quot;);
                headerRow.createCell(8).setCellValue(&quot;기타&quot;);
                headerRow.createCell(9).setCellValue(&quot;수신거부 여부&quot;);

                for (CustomerResponse customer : customerResponseList) {
                    Row row = sheet.createRow(rowNo++);
                    row.createCell(0).setCellValue(customer.customerName());
                    row.createCell(1).setCellValue(customer.customerEmail());
                    row.createCell(2).setCellValue(customer.customerPhone());
                    row.createCell(3).setCellValue(customer.customerLevel());
                    row.createCell(4).setCellValue(customer.customerType());
                    row.createCell(5).setCellValue(customer.company());
                    row.createCell(6).setCellValue(customer.team());
                    row.createCell(7).setCellValue(customer.position());
                    row.createCell(8).setCellValue(customer.etc());
                    row.createCell(9).setCellValue(customer.isRejected() != null ? customer.isRejected().toString() : &quot;&quot;);
                }

                // 엑셀 응답 설정
                response.setContentType(&quot;application/vnd.openxmlformats-officedocument.spreadsheetml.sheet&quot;);
                response.setHeader(&quot;Content-Disposition&quot;,
                        &quot;attachment; filename=\&quot;&quot; + URLEncoder.encode(&quot;고객 정보.xlsx&quot;, StandardCharsets.UTF_8) + &quot;\&quot;&quot;);

                workbook.write(response.getOutputStream());
                workbook.close();
                break;

            default:
                throw new ApplicationException(ErrorCode.INVALID_VALUE);
        }
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}</code></pre>
<blockquote>
<p>엑셀 파일 다운로드 기능을 위해 HttpServletResponse를 활용해야 한다. 
이 때, Service 계층에서는 List&lt;String[]&gt; 형태의 Row 정보들만 받고 실제 Excel 파일 생성 및 반환하는 로직을 Controller에서 구현했다. 
Excel 파일을 생성 및 반환하는 부분이 비즈니스 로직이라고 생각되어 Service 계층에서 작성할까 했지만, HttpServletResponse를 Service 계층까지 내리는 것은 부적절하다고 판단되어 Controller에서 구현했다. 
작성하고 보니 엑셀 관련 로직에 문제가 생겼을 때, Controller에서도 오류 발생 여부를 확인해야 하기에, 로직이 여러 파일로 분산되어 있는 것 같아 어떻게 개선해야 할지 고민이 된다.</p>
</blockquote>
<h3 id="facade-1">Facade</h3>
<pre><code class="language-java">public List&lt;CustomerResponse&gt; downloadCustomerExcel() {
    List&lt;Customer&gt; customerList = customerService.getAllCustomers();
    return customerList.stream()
            .map(CustomerResponse::of)
            .toList();
}</code></pre>
<hr>
<p><strong>후기 &amp; 개선 필요한 점</strong>
Python으로 엑셀 등의 파일을 다룰 때, 얼마나 편했었는지를 다시 한 번 체감했다.
엑셀 Column별로 유효성 검증 등의 로직을 적용해 파일이 잘못되었을 경우를 핸들링할 수 있다면, 더 안정성이 높아질 것 같아 추후 진행사항으로 잡을 예정이다.
위에 작성했듯이 Controller에서 엑셀 파일을 만드는 코드를 어느 계층에서 처리하는 것이 더 좋을지에 대한 고민도 해볼 것이다.</p>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="">maven repository</a></li>
<li><a href="https://suyou.tistory.com/311">Spring Boot, Java, CSV 다운</a></li>
<li><a href="https://jindory.tistory.com/entry/Java-POI%EB%A1%9C-%EC%97%91%EC%85%80-%EB%82%B4%EB%B3%B4%EB%82%B4%EA%B8%B0">Java POI로 데이터 엑셀 다운받기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Netlify + Cloudflare + HostingKR로 Https 설정하기]]></title>
            <link>https://velog.io/@dl-00-e8/Netlify-Cloudflare-HostingKR%EB%A1%9C-DNS-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/Netlify-Cloudflare-HostingKR%EB%A1%9C-DNS-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 16 Jun 2024 10:03:05 GMT</pubDate>
            <description><![CDATA[<p>공작소는 Nginx + Let&#39;sEncrypt를 활용해서 Https를 활용할 수 있도록 구축했다.
구축한 <a href="https://velog.io/@dl-00-e8/%ED%95%98%EB%82%98%EC%9D%98-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%91%90-%EA%B0%9C%EC%9D%98-%EC%84%9C%EB%B2%84-%EC%97%B0%EA%B2%B0-with-https">도입기</a>는 링크를 확인하면 된다.</p>
<h2 id="왜-전환을-결정하게-되었는가">왜 전환을 결정하게 되었는가?</h2>
<p>이전 도입기의 큰 문제점은 <strong>gongjakso.xyz</strong>라는 도메인은 SSL/TLS 인증서가 적용되어 있지 않았다는 점이다. 최초 설정 과정에서, 이 부분을 미쳐 고민하지 않은 채 서버 도메인에 대한 certbot 인증서만을 발급했기 때문이다. 이는, 사용자 입장에서 보안 연결이 아니라는 문구가 뜨게 되어 사용자의 이탈률을 높이는 중요한 포인트 중 하나라고 생각했다.</p>
<p>두 번째로는, 현재 EC2에 설치된 Docker의 overlay2 디렉토리에 불필요한 용량을 차지하고 있는게 많아 서버 다운이 자주 발생하고 있었다는 점이다. 해당 overlay2 디렉토리는 불필요한 파일이 어떤 것인지를 판단하기 굉장히 어렵다 보니, EC2를 새로 구축하면서 Nginx + Certbot을 이용한 인증서 구조도 개선하면 되겠다고 생각했다.</p>
<h2 id="전환-과정">전환 과정</h2>
<p>이 결정 이후, 도입까지 총 3가지 방식의 시도를 했으며, 마지막 방식으로 성공했다. 
1번 방식과 2번 방식은 경험한 오류만을 정리하며, 3번 방식만 진행한 과정을 자세하게 정리해보고자 한다. </p>
<h3 id="1-nginx에서-와일드-카드-도메인을-가진-인증서-발급하기">1. Nginx에서 와일드 카드 도메인을 가진 인증서 발급하기</h3>
<p>첫 번째로는 기존의 Certbot을 통해 발급하는 방식으로 Netlify로 배포한 프론트의 도메인까지 같은 인증서로 발급받는 방식이다. 해당 과정에서 아래와 같은 오류가 발생했다.</p>
<pre><code class="language-shell">sudo certbot --nginx -d domain.com -d *.domain.com</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/a4728736-1353-40a1-87fb-a5b4e44d5fd7/image.png" alt=""></p>
<p><code>Client withith the currently selected authenticator does not support any combination of challenges that will satisfy the CA. You may need to user an authenticator plugin that can do challenges over DNS.</code><br>위 오류는 Let&#39;s Encrypt의 도메인 소유권 인증 방식과 관련된 오류다. Let&#39;s Encrypt는 도메인 소유권 검증을 위해 다양한 챌린지 방식을 제공하는데, 소유권 검증을 위한 DNS 인증기 플러그인을 사용해야 가능하다고 알려주는 것이다. HostingKR의 네임 서버를 이용하고 있었는데, 관련된 인증기 플러그인을 설명하는 문서를 제대로 찾지 못해 다른 커맨드를 시도하기로 결정했다.  </p>
<pre><code>sudo certbot certonly --manual --preferred-challenges dns -d domain.com -d *.domain.com</code></pre><p>다른 문서들과 블로그 글을 수십 개 읽어본 끝에, 위 커맨드를 사용해서 시도했다. 위 방식은 네임 서버에 TXT 레코드를 추가하여 인증하는 방식인데, 레코드 반영에 시간이 걸리므로 1시간 이상을 기다려 봤음에도 불구하고, 아래와 같이 오류가 발생하여 다른 방식을 선택했다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/83b67d53-1654-4381-945b-dc8a3a18eb7c/image.png" alt=""></p>
<h3 id="2-netlify로-네임-서버-이전">2. Netlify로 네임 서버 이전</h3>
<p>두 번째로는 네임 서버를 Netlify로 이전하는 것이다. Netlify는 자신들의 네임 서버를 사용하게 될 경우, Let&#39;s Encrypt를 활용해서 자동으로 SSL/TLS 인증서를 발급 및 적용해준다. 해당 발급된 인증서를 활용해서, Nginx에 등록하는 방식으로 진행하고자 했으나, Netlify에서 발급된 인증서의 pem 정보를 전달받을 수 있는 방법을 찾지 못해 마지막 3번 방식을 시도했다.</p>
<p><a href="https://docs.netlify.com/domains-https/https-ssl/">Netlify의 HTTPS(SSL)</a></p>
<h3 id="3-cloudflare를-네임-서버로-활용--인증서-발급">3. Cloudflare를 네임 서버로 활용 + 인증서 발급</h3>
<p>아래와 같은 기능을 가지고 있는 Cloudflare를 활용한다.</p>
<ul>
<li>인증서를 발급 및 pem 정보 확인 가능</li>
<li>네임 서버로서 DNS 레코드 반영 가능</li>
</ul>
<p>Cloudflare는 무료 플랜을 활용했으며, Cloudflare 도메인 생성 및 Nginx 설치 등의 기본적인 작업은 진행했다는 전제이다.</p>
<ol>
<li>HostingKR의 네임 서버를 Cloudflare 네임 서버로 변경</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/6b0f4627-dc7e-47fa-82c2-d0a474956331/image.png" alt=""></p>
<p>HostingKR에 접속하여, 변경하고자 하는 도메인의 네임 서버를 Cloudflare의 네임 서버로 변경한다.</p>
<blockquote>
<p><strong>Cloudflare 네임 서버 확인하는 방법</strong>
홈 -&gt; 도메인 -&gt; DNS로 접근, 페이지 하단의 Cloudflare의 네임 서버를 확인</p>
</blockquote>
<ol start="2">
<li>Cloudflare의 DNS 레코드 업데이트</li>
</ol>
<p>연결하고자 하는 IP 또는 웹페이지의 주소를 레코드로 적용해서 업데이트한다.
프론트 주소와 백엔드 주소를 둘 다 하나의 도메인에서 서브 도메인을 활용해서 관리하므로 나는 총 3개의 레코드를 등록했다.</p>
<table>
<thead>
<tr>
<th>유형</th>
<th>이름</th>
<th>콘텐츠</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>backend(예시)</td>
<td>백엔드 서버 IP</td>
</tr>
<tr>
<td>CNAME</td>
<td>@</td>
<td>Netlify URL</td>
</tr>
<tr>
<td>CNAME</td>
<td>www</td>
<td>Netlify URL</td>
</tr>
</tbody></table>
<p>순서대로, 백엔드 연결 / 프론트엔드 연결 / 프론트엔드 연결이다. 여기서 주의사항은 HTTPS를 위한 SSL/TLS 인증서를 Cloudflare에서 발급했으므로, 프록시를 <strong>활성</strong>으로 변경해야 한다.</p>
<blockquote>
<p>위 과정에서, DNS 레코드 업데이트 사항은 아래의 사이트를 통해서 지속적으로 반영 여부를 확인했다.</p>
</blockquote>
<ul>
<li><a href="https://dnschecker.org/">DNS Checker</a></li>
<li><a href="https://www.whatsmydns.net/">WhatsMyDNS</a></li>
</ul>
<ol start="3">
<li>Cloudflare에서 SSL/TLS 인증서 발급하기</li>
</ol>
<p>이제 Cloudflare의 SSL/TLS에 접속하여 인증서를 발급하면 된다. SSL/TLS의 원본 서버를 접속하면, 아래와 같은 페이지가 나오는데 여기서 인증서 생성 버튼을 클릭하여 인증서를 생성하면 된다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/5c56774c-fc41-4d86-b13e-a8ffc9364365/image.png" alt=""></p>
<p>인증서를 생성하고 나면, 원본 인증서와 개인 키를 확인할 수 있다. 
<strong>해당 키 정보를 꼭 개인 PC에 저장해놓아야 한다.</strong>
이에 더해, Netlify에 인증서를 등록할 때 root 인증서가 필요하므로 <a href="https://developers.cloudflare.com/ssl/static/origin_ca_rsa_root.pem">Cloudflare Developers</a>에 접속하여 root 인증서 또한 다운로드해야 한다.</p>
<p>위의 과정을 거친 후에 꼭 SSL/TLS 암호화 모드를 <strong>전체(엄격)</strong> 으로 변경해야 한다. 기본적으로 가변으로 설정되어 있는데, 이는 Cloudflare를 통과한 이후의 백엔드 서버 사이에서의 인증은 정상적으로 이루어지지 않아, 사이트를 접속하면 certificate가 유효하지 않다는 경고문을 띄우기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/c29a2c58-426a-439c-8391-67964d9392fb/image.png" alt="">  </p>
<ol start="4">
<li>Netlify에 발급한 인증서로 변경하기</li>
</ol>
<p>프론트는 Netlify로 배포했기에, Netlify 내에서 인증서 정보를 등록해야 한다.
Netlify에서 변경하고자 하는 site의 Domain management로 접속하면 페이지 하단에서 HTTPS를 위한 인증서를 업데이트할 수 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/0a247b7d-608a-4d03-aeca-b2771f70de62/image.png" alt="">
이미지와 같은 화면에서 <code>update custom certificate</code>를 클릭하여 정보를 입력 후 업데이트를 진행하면 된다. 입력해야 하는 정보는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/468452f8-39d4-4615-aac3-d351ef8850da/image.png" alt=""></p>
<ul>
<li>Certificate: Cloudflare에서 SSL/TLS 인증서 생성 시 저장한 원본 인증서</li>
<li>Private Key: Cloudflare에서 SSL/TLS 인증서 생성 시 저장한 개인 인증서</li>
<li>Intermediate certs: Cloudflare Developers에서 다운받은 root 인증서</li>
</ul>
<p>정상적으로 완료하면, <code>Certificate: Custom</code>으로 변경된 것을 확인할 수 있다.</p>
<ol start="5">
<li>Nginx에 발급한 인증서 반영하기</li>
</ol>
<p>마지막은 백엔드 서버에 인증서를 반영하는 것이다. 백엔드 서버는 Nginx를 활용하기 때문에, Nginx 설정 파일에서 해당 정보를 등록해야 한다.</p>
<p>먼저 pem키들을 특정 폴더를 만들어 저장했다.</p>
<pre><code class="language-shell">sudo mkdir authentication
cd authentication
sudo vi certificate.pem
sudo vi privatekey.pem</code></pre>
<ul>
<li>certificate.pem: 원본 인증서</li>
<li>privatekey.pem: 개인 인증서</li>
</ul>
<p>이후, ngixn.conf를 변경했다. 변경된 nginx.conf는 아래와 같다. (주석은 삭제했다.)</p>
<pre><code class="language-configuration">user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections 768;
}

http {

    sendfile on;
    tcp_nopush on;
    types_hash_max_size 2048;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
    ssl_prefer_server_ciphers on;

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    gzip on;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;

    server {
            if ($host = [Server Domain]) {
                return 301 https://$host$request_uri;
            }

        listen 80;
        server_name [Server Domain];

        return 404;
    }

    server {
        server_name [Server Domain];

            listen 443 ssl;
            ssl_certificate /etc/nginx/authentication/certificate.pem;
           ssl_certificate_key /etc/nginx/authentication/privatekey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
            ssl_prefer_server_ciphers on;

        location / {
            proxy_pass http://127.0.0.1:8080;
        }
    }
}</code></pre>
<p>변경된 내용</p>
<ul>
<li>백엔드 도메인으로 80 요청이 오면 443으로 리다이렉트</li>
<li>백엔드 도메인이 아닌 IP로 80요청이 오면 404 에러</li>
<li>백엔드 도메인으로 443 요청이 오면, 백엔드 서버 포트인 8080으로 포워딩</li>
</ul>
<p>변경된 설정으로 nginx를 재시작하면 성공적으로 반영된다.</p>
<pre><code class="language-shell">sudo service nginx restart</code></pre>
<p><strong>이렇게 공작소는 보안 연결까지 포함되어 다시 태어났다.</strong></p>
<hr>
<p><strong>후기</strong>
전환하는 결정은 쉬웠지만 다운타임은 거진 10시간을 넘겼었기에 앞으로 이런 경우가 발생하지 않도록 처음부터 잘 설계해서 구축해야 한다는 점을 복기할 수 있었고, 마주친 여러 문제들을 통해 네트워크 관련 내용도 학습할 수 있는 재밌는 경험이었다.</p>
<hr>
<p><strong>참고했던 레퍼런스</strong></p>
<ul>
<li><a href="https://blog.betaman.kr/106">Netlify + Cloudflare</a></li>
<li><a href="https://blog.naver.com/doogle/220979742815">기 발급된 인증서에서 도메인 추가</a></li>
<li><a href="https://doitnow-man.tistory.com/entry/nginx-ubuntu-2004%EC%97%90%EC%84%9C-nginx-%EC%99%84%EB%B2%BD%ED%95%9C-%EC%82%AD%EC%A0%9C">Nginx 삭제 후 재설치</a></li>
<li><a href="https://blog.dizy.dev/dev/2018/02/06/certbot-error.html">Certbot 인증서 발급 시 발생한 문제 해결</a></li>
<li><a href="https://velog.io/@suhongkim98/Lets-Encrypted-certbot%EC%9C%BC%EB%A1%9C-%EC%99%80%EC%9D%BC%EB%93%9C%EC%B9%B4%EB%93%9C-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EB%B0%9C%EA%B8%89%EB%B0%9B%EA%B8%B0">LetsEncrypt 와일드카드 도메인 인증서 발급</a></li>
<li><a href="https://bobcares.com/blog/cloudflare-origin-certificate-not-trusted/">Cloudflare 원본 인증서 신뢰 문제</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docker] Logrotate + Crontab으로 Docker Log 관리하기]]></title>
            <link>https://velog.io/@dl-00-e8/Docker-Logrotate-Crontab%EC%9C%BC%EB%A1%9C-Docker-Log-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/Docker-Logrotate-Crontab%EC%9C%BC%EB%A1%9C-Docker-Log-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 05 Jun 2024 17:06:41 GMT</pubDate>
            <description><![CDATA[<p>과릿 운영 중 발생한 <a href="https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0">Redis 스냅샷 오류</a>의 재발을 방지하기 위한 주기적인 Docker Log 삭제를 도입하는 과정을 정리한다. </p>
<h1 id="목적">목적</h1>
<p>Redis 스냅샷 오류의 원인은 Docker Log가 많이 쌓여 발생한 <code>docker no space left on device</code> 때문이다. 이 문제는 log 파일을 삭제해주지 않는다면 지속적으로 발생할 수 밖에 없는 문제기에 해결이 필요하다고 판단했다.</p>
<blockquote>
<p>Q. Log를 지워서 발생할 수 있는 문제는 없을까?</p>
<p>A. Log를 지속적으로 가지고 있으면서 활용할 수 있는 포인트는 크게 3가지라고 생각한다. </p>
</blockquote>
<ul>
<li>로그 기반 시스템 성능 모니터링</li>
<li>에러 발생 시, 로그 기반 디버깅(= 문제 해결)</li>
<li>사용자 행동 분석<blockquote>
<p>과릿은 모니터링 시스템으로 Sentry를 활용하고 있다. 
성능 모니터링에서는 우선순위로 두고 있는 것은 API Latency인데, 이는 <strong>AOP</strong>를 통해 1초 이상의 API Latency가 발생 시 Sentry에 이벤트로 발송하도록 설정하고, 슬로우 쿼리에 대해서는 <strong>MySQL Slow Query</strong> 설정하여 커버할 수 있다고 판단했다.<br>에러 발생은 <strong>Sentry</strong>에서 이벤트로 확인할 수 있고, 관련 정보들도 다 모아 볼 수 있기에 문제 해결 과정에서 로그의 활용도는 상대적으로 낮다고 판단했다.
마지막은 사용자 행동 분석인데, 이는 <strong>Amplitude</strong>를 활용하여 클라이언트에서 트래킹을 하고 있으므로 일정 기간이 지난 로그는 삭제해도 괜찮다고 판단했다.</p>
</blockquote>
</li>
</ul>
<blockquote>
<p>Q. 지우고자 하는 로그의 기간은 어떻게 설정했는가?</p>
<p>A. 최근 &quot;가상 면접 사례로 배우는 대규모 시스템 설계 기초 2&quot;를 기반으로 스터디를 진행하고 있는데, 해당 책에서는 오래된 로그를 냉동 저장소로 옮겨서 저장한다고 얘기한다. 그러면서, 로그를 다양하게 활용한다는 관점에서 한달 지난 로그는 분 단위로/1년이 지난 로그는 시간 단위 등으로 로그를 묶어 최적화를 한다고 표현한다.<br>냉동저장소에 저장할 필요가 없으며, 한달 이상의 장기 로그의 필요성은 더더욱 없다는 점과 프리티더라는 서버 특성상 로그 파일의 규모를 최대한 줄이는 것이 서버의 안정성을 높일 수 있을 것이라 생각해 최종적으로 <strong>3일이 지난 로그들을 삭제</strong>하는 것으로 결정했다.</p>
</blockquote>
<h1 id="방법">방법</h1>
<p>Log를 주기적으로 지우기 위한 방법은 크게 3가지가 있다.</p>
<ol>
<li>수동으로 서버 재시작 (stop이 아닌 rm)</li>
<li>수동으로 *.log 파일 삭제</li>
<li>Logrotate + crontab</li>
</ol>
<p><strong>서버의 중단을 발생시키지 않으며, 편리함이 가장 높은 3번을 도입하는 것으로 결정했다.</strong></p>
<h1 id="logroate--crontab-도입">Logroate + Crontab 도입</h1>
<p>과릿은 AWS EC2에 배포되어 있으며, ubuntu 22.04 버전이다.</p>
<h2 id="1-logrotate-설치-여부-확인">1. logrotate 설치 여부 확인</h2>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/3f2ac252-64ca-4184-892d-7a803ec72175/image.png" alt=""></p>
<h2 id="2-logrotateconf-경로">2. logrotate.conf 경로</h2>
<p><a href="https://linux.die.net/man/8/logrotate">linux die</a> 페이지에서 logrotate에 대하여 정리되어 있다. Files 목차로 가보면, configuration options는 <code>/etc/logrotate.conf</code>에 위치해있음을 알 수 있다.</p>
<h2 id="3-logrotate-옵션">3. logrotate 옵션</h2>
<ul>
<li>daily : 매일 로테이트 진행</li>
<li>weekly : 매주 로테이트 진행</li>
<li>monthly : 매달 로테이트 진행</li>
<li>yearly : 매년 로테이트 진행</li>
<li>rotate [파일갯수] : 로테이트 진행될 파일갯수 ex) rotate 5</li>
<li>compress : 로테이트 진행된 로그파일 압축(gzip)</li>
<li>create [권한] [유저] [그룹] : 로테이트 되는 로그파일 권한 지정 ex) create 644 root root</li>
<li>nocompress : 로테이트 진행된 로그파일을 압축하지 않는다(기본값) </li>
<li>compresscmd [압축명] : gzip 이외의 압축프로그램 지정 </li>
<li>uncompresscmd : 압축해제 명령 지정(기본값 : gunzip)</li>
<li>compressext [확장자명] : 압축된 백업 로그파일에 지정할 확장자 설정 </li>
<li>compressoptions [옵션] : 압축 프로그램에 대한 옵션 설정(-9 : 압축률 최대) </li>
<li>dateext : 로그파일에 YYYYMMDD형식의 확장자 추가  백업 파일의 이름에 날짜가 들어가도록 함  </li>
<li>errors [메일주소] : 에러 발생시 지정된 메일주소로 메일발송</li>
<li>extention 확장자명 : 로테이트 진행된 로그파일의 확장자 지정 </li>
<li>ifempty : 로그파일이 비어있는 경우 로테이트 진행 (기본값)</li>
<li>noifempty : 로그파일이 비어있는 경우 순환하지 않는다 </li>
<li>mail [메일주소] : 순환후 이전 로그파일을 지정된 메일주소로 발송 </li>
<li>maxage : count로 지정된 날수가 지난 백업 파일 삭제 ex) maxage 30 30dl</li>
<li>missingok : 로그파일이 없을 경우에도 에러 처리하지 않는다</li>
<li>prerotate / endscript : 로테이트 진행작업 전에 실행할 작업 설정</li>
<li>postrotate / endscript : 로테이트 진행작업 후에 실행할 작업 설정</li>
<li>sharedscripts : prerotate, postrotate 스크립트를 한번만 실행 </li>
<li>size [사이즈] :  로테이트 진행 결과 파일사이즈가 지정한 크기를 넘지않도록 설정 / 지정된 용량보다 클 경우 로테이트 실행 ex)　size +100k</li>
<li>copytruncate : 현재 로그파일의 내용을 복사하여 원본 로그파일의 크기를 0으로 생성</li>
</ul>
<p>(정리된 옵션 출처: <a href="https://93it-security-service.tistory.com/123">IT/보안 블로그</a> / 2024-06-06)</p>
<h2 id="4-logrotate-파일-생성">4. logrotate 파일 생성</h2>
<p>logrotate를 통해 처리하고자 하는 작업은 아래와 같다.</p>
<ul>
<li>일 단위로 로그를 관리</li>
<li>로그 파일은 최대 3개까지 관리(= 3일 이내의 로그만 보존)</li>
<li>파일 이름에 날짜 명시</li>
<li>회전된 이후, 불필요한 로그 삭제(= 일주일 이상된 로그 삭제)<pre><code>/var/lib/docker/containers/*/*.log {
  daily
  rotate 3
  compress
  missingok
  copytruncate
  notifempty
  create
  dateext
  postrotate
      find /var/lib/docker/containers/ -name &quot;*.log.*&quot; -type f -mtime +3 -delete
  endscript
}</code></pre></li>
</ul>
<h2 id="5-logrotate-실행-확인">5. logrotate 실행 확인</h2>
<p>지우기 전 컨테이너 로그의 양은 아래와 같다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/fc7f8483-96e4-4f9b-bfa2-f791547d1b80/image.png" alt=""></p>
<p>명령어를 통해서, logrotate를 실행시키고 그 결과를 확인할 수 있다.</p>
<pre><code class="language-shell">sudo /usr/sbin/logrotate -f /etc/logrotate.d/docker</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/fcf34cb3-7309-4801-b930-3f6e0d061b04/image.png" alt="">
위 명령어를 실행했으나, 또 No space left on device가 발생해서, <code>docker image prune -a</code>로 미사용 이미지를 지워, logrotate가 실행될 수 있도록 했다. (이제, 로그가 주기적으로 지워질 것이므로 큰 문제가 없지 않을까 예상한다.)</p>
<p>아래 명령어를 통해, logrotate가 정상적으로 잘 동작했는지 확인할 수 있다.</p>
<pre><code class="language-shell">sudo cat /var/lib/logrotate/status</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/ea1f668a-af33-4cc1-b614-eb6584ee3906/image.png" alt=""></p>
<p>지운 후, 컨테이너 로그 양은 이렇게 변화했다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/1072332f-18a0-4d7b-a892-e6486acc4d94/image.png" alt=""></p>
<ul>
<li>323M -&gt; 31M </li>
<li>400k -&gt; 76k</li>
<li>323M -&gt; 31M</li>
</ul>
<p>꽤나 괜찮은 수치의 감소량이라고 생각한다.</p>
<h2 id="6-crontab-설정">6. Crontab 설정</h2>
<pre><code class="language-shell">sudo crontab -e</code></pre>
<p>명령어를 통해 들어가 <code>0 0 * * * /usr/sbin/logrotate -f /etc/logrotate.d/docker</code>를 등록해주었다. 아래와 같이 정상적으로 등록된 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/3e726992-4189-4bdd-aeb1-73e1fe36bfcb/image.png" alt=""></p>
<hr>
<p>이전 글에서 Redis의 옵션을 no로 설정해놓았던 것 또한 yes로 복구시켰다. 
프리티어이기에 다시 메모리가 터진다면 no로 또 돌릴 수도 있을 것 같다는 생각도 든다.</p>
<p>그렇지만, <strong>여유 공간이 없어 서비스 장애가 발생하는 경우를 최소화시킬 수 있게 되었다는 점</strong>과 <strong>logrotate를 통한 용량 최적화 방식을 적용</strong>해볼 수 있었던 좋은 기회였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] 디스코드를 활용한 에러 알림]]></title>
            <link>https://velog.io/@dl-00-e8/Spring-Boot-%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%97%90%EB%9F%AC-%EC%95%8C%EB%A6%BC</link>
            <guid>https://velog.io/@dl-00-e8/Spring-Boot-%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%97%90%EB%9F%AC-%EC%95%8C%EB%A6%BC</guid>
            <pubDate>Mon, 03 Jun 2024 15:41:10 GMT</pubDate>
            <description><![CDATA[<p>공작소는 Prometheus + Grafana 조합을 구성해서 에러 모니터링 시스템을 구축되어 있다. 
일반적으로 모니터링 시스템은 API 서버와 분리하여 구축해놓아야 하지만, 서버 비용 등의 부담으로 인해 단일 EC2 내에 컨테이너로 띄워져 있었는데, 이로 인해 Docker 내에 여유 공간이 없는 문제가 발생해 현재는 Prometheus 컨테이너는 중단해놓은 상황이다.</p>
<p>위와 같은 상황으로 인해, 실제 사용 중에 발생한 문제를 실시간으로 인지할 수 있도록 기존 팀 내 소통 채널인 디스코드를 활용해 에러 알림을 받기로 결정했다.</p>
<h3 id="webhook">Webhook</h3>
<p>디스코드는 웹훅 방식으로 채널에 메시지를 발송할 수 있다. </p>
<p>웹훅에 대해서 간단하게 정리해보자면, 데이터가 변경되었을 때 실시간으로 알림을 받을 수 있는 기능으로, 웹 서비스의 이벤트 데이터를 전달하는 <strong>HTTP 기반 콜백 함수</strong>이다.</p>
<p>웹훅은 서버에서 특정 이벤트가 발생했을 때, 클라이언트를 호출하는 방식이기에 <span style="color: red">역방향 API</span>로 부를 수 있을 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/4fccda5b-ec10-4f6e-91a7-bd0d556f996a/image.png" alt="">
(이미지 출처: <a href="https://docs.tosspayments.com/resources/glossary/webhook">토스페이먼츠 기술 블로그</a> / 2024-06-02)</p>
<h2 id="spring-boot--discord">Spring Boot + Discord</h2>
<ol>
<li><p>채널 생성</p>
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/f6ae6847-45b1-4473-ba49-b3696290ed23/image.png' width = 200>
</li>
<li><p>채널 설정 -&gt; 연동 -&gt; 웹후크 생성</p>
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/eea3054f-2aa4-4bc6-87e3-e3e689f78f4b/image.png' width = 700>
</li>
<li><p>생성 후, 웹후크 URL 복사</p>
<img src = 'https://velog.velcdn.com/images/dl-00-e8/post/d5e9b078-f851-49b7-b6cc-ae94c504b9c5/image.png' width = 700>

</li>
</ol>
<p>위 과정까지 진행하면, 더 이상 디스코드 내에서 할 작업은 없다. 이제는 Spring Boot 서버에서 작업을 진행하면 된다.</p>
<ol start="4">
<li><p>application.yml 설정</p>
<pre><code class="language-yaml">discord:
environment: dev
webhook-url: https://discord.com/api/webhooks/{webhook_id}/{webhook_token}</code></pre>
<p>위의 webook-url은 3번 과정에서 복사한 url을 기입하면 된다. 
environment 변수는 dev/prod 중 하나의 값이며, 배포된 환경에서만 알림을 발송하기 위하여 해당 값이 prod인지 확인하는 조건 분기 처리 시 사용한다.</p>
</li>
<li><p>디스코드 발송 시 활용하는 구조 TEST
<img src="https://velog.velcdn.com/images/dl-00-e8/post/001ebf52-f591-40fc-a600-17bf14eef68a/image.png" alt="">
<img src="https://velog.velcdn.com/images/dl-00-e8/post/20e3fb86-7f22-4f64-acb2-85cef559a206/image.png" alt=""></p>
</li>
</ol>
<p>다만, 나는 위의 &#39;공작소 API Server&#39;라는 문구는 불필요하다고 판단해서 삭제했다. 해당 문구를 삭제한 코드는 아래 과정을 보면 된다.</p>
<ol start="6">
<li>dependency 추가</li>
</ol>
<p>디스코드는 RESTful API 형식으로 Webhook을 활용해 메시지를 발송할 수 있다.
Spring Boot에서는 RestTemplate, WebClient, OpenFeign 등을 HTTP Client 모듈로 활용할 수 있는데, 나는 비동기와 논블럭의 강점을 가진 WebClient를 선택했다. </p>
<pre><code class="language-java">implementation &#39;org.springframework.boot:spring-boot-starter-webflux&#39;</code></pre>
<p>WebClient는 Webflux 내에 존재하기에 위와 같이 Webflux를 dependency에 추가했다.</p>
<ol start="7">
<li><p>디스코드 발송 컴포넌트</p>
<pre><code class="language-java">@Component
public class DiscordClient {

 @Value(&quot;${discord.environment}&quot;)
 private String environment;

 @Value(&quot;${discord.webhook-url}&quot;)
 private String webhookUrl;

 public void sendErrorMessage(Integer code, String message, String stackTrace) {
     if(!environment.equals(&quot;prod&quot;)) {
         return;
     }

     WebClient webClient = WebClient.create();

     //요청 본문
     Map&lt;String, Object&gt; embedData = new HashMap&lt;&gt;();

     embedData.put(&quot;title&quot;, &quot;공작소 서버 에러 발생&quot;);

     Map&lt;String, String&gt; field1 = new HashMap&lt;&gt;();
     field1.put(&quot;name&quot;, &quot;발생시각&quot;);
     field1.put(&quot;value&quot;, LocalDateTime.now().format(DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd hh:mm:ss&quot;)));

     Map&lt;String, String&gt; field2 = new HashMap&lt;&gt;();
     field2.put(&quot;name&quot;, &quot;에러 코드&quot;);
     field2.put(&quot;value&quot;, code.toString());

     Map&lt;String, String&gt; field3 = new HashMap&lt;&gt;();
     field3.put(&quot;name&quot;, &quot;에러 명&quot;);
     field3.put(&quot;value&quot;, message);

     Map&lt;String, String&gt; field4 = new HashMap&lt;&gt;();
     field4.put(&quot;name&quot;, &quot;스택 트레이스&quot;);
     field4.put(&quot;value&quot;, stackTrace);

     embedData.put(&quot;fields&quot;, List.of(field1, field2, field3, field4));

     Map&lt;String, Object&gt; payload = new HashMap&lt;&gt;();
     payload.put(&quot;embeds&quot;, new Object[]{embedData});

     webClient.post()
             .uri(webhookUrl)
             .contentType(MediaType.APPLICATION_JSON)
             .bodyValue(payload)
             .retrieve()
             .bodyToMono(Void.class)
             .block();
 }
}</code></pre>
<p>코드를 보면, 배포 환경이 아니라면 비즈니스 로직을 진행하지 않고 메소드를 반환하는 것을 확인할 수 있다.</p>
</li>
</ol>
<h3 id="결과">결과</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/589e7f98-09c5-41ca-88ab-345ed3e272c2/image.png" alt=""></p>
<p>이제, 배포된 환경에서 오류가 발생하면 실시간으로 알림을 받아볼 수 있다.</p>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://discord.com/developers/docs/resources/webhook#execute-webhook">디스코드 Docs</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[BOJ] 13305. 주유소]]></title>
            <link>https://velog.io/@dl-00-e8/BOJ-13305.-%EC%A3%BC%EC%9C%A0%EC%86%8C</link>
            <guid>https://velog.io/@dl-00-e8/BOJ-13305.-%EC%A3%BC%EC%9C%A0%EC%86%8C</guid>
            <pubDate>Sat, 25 May 2024 08:45:13 GMT</pubDate>
            <description><![CDATA[<h1 id="주유소">주유소</h1>
<blockquote>
<p>문제 출처: <a href="https://www.acmicpc.net/problem/13305">https://www.acmicpc.net/problem/13305</a></p>
</blockquote>
<h2 id="문제-풀이-과정">문제 풀이 과정</h2>
<h3 id="예제-풀이">예제 풀이</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/8664338f-36bb-482a-bea6-81ca6e16984f/image.png" alt=""></p>
<p>위 예제를 기반으로 총 3가지의 접근 방식을 제작했다. (각 주유소의 위치를 노드라고 표현한다.)</p>
<p><strong>1. Node1에서 기름을 전부 다 넣는 경우</strong></p>
<p>6 * 5 = 30
총 30원</p>
<p><strong>2. 다음 Node까지의 거리만 주유하는 방식</strong></p>
<p>Node1 =&gt; 2 * 5 =10
Node2 =&gt; 3 * 2 = 6
Node3 =&gt; 4 * 1= 4
총 20원</p>
<p><strong>3. 현재 노드와 다음 노드의 주유소의 기름값을 비교하여 판단하는 방식</strong></p>
<p>Node1과 Node2 비교: 5&gt;2이므로 현재 위치에서는 다음 Node까지의 이동거리만큼만 주유
Node2와 Node3 비교: 2&lt;4이므로 Node3까지만의 거리가 아닌 Node4를 비교해야함
Node2와 Node4 비교: 2&gt;1이지만, 마지막 Node이므로 별도의 이동 필요 X</p>
<p>위 과정을 거쳐 (5<em>2) + (2</em>4) = 18로, 총 18원이라는 결과 도출</p>
<h3 id="최종-로직">최종 로직</h3>
<ol>
<li>현재 Node와 다음 Node간의 기름값 비교</li>
</ol>
<p>2-1. 현재 Node의 기름값이 비쌀 경우, 측정된 총 거리 * 현재 주유소의 기름값을 결과값에 더함</p>
<p>2-2. 현재 Node의 기름값이 싸거나 같은 경우, 비교할 다음 Node를 그 다음 Node로 변경하며 측정 거리에 움직이는 거리를 더함</p>
<ol start="3">
<li>위 1~2번 과정을 마지막 Node에 도달할 때까지 반복 수행</li>
</ol>
<blockquote>
<p><strong>주의사항</strong></p>
</blockquote>
<ul>
<li>시작 상태는 기름이 없으므로 무조건 첫 번째 노드에서는 기름을 넣어야 한다는 점</li>
<li>마지막 주유소의 기름값은 무의미하다는 점</li>
</ul>
<h3 id="놓친-사항">놓친 사항</h3>
<p>처음 위 방식으로 작성한 코드의 결과는 아래의 부분 점수(58점)를 받았다. 그 이유를 파악해보았더니 아래의 부분을 놓친 것을 알 수 있었다.</p>
<p><strong>문제 입력 조건</strong></p>
<ul>
<li>도시 수 정수 N(2 ≤ N ≤ 100,000)</li>
<li>인접한 두 도시를 연결하는 도로의 길이 (1이상 1,000,000,000 이하의 자연수)</li>
<li>주유소의 리터당 가격 (1 이상 1,000,000,000)</li>
</ul>
<p>위와 같은 문제 입력 조건에서, 결과값은 int의 범위를 초과할 수 있다고 판단하여, result변수를 long long 타입으로 선언하여 활용하였는데, 거리를 더하는 변수 또한 int 범위를 초과할 수 있다는 점을 간과했던 것이었다.
그렇기에, 움직인 거리를 계산할 때 활용하는 변수인 move_dist 또한 long long 타입으로 변경하여 100점을 받을 수 있었다.</p>
<h2 id="부분-점수">부분 점수</h2>
<pre><code class="language-cpp">#include &lt;bits/stdc++.h&gt;

using namespace std;

#define endl &quot;\n&quot;
#define MAX 100000 + 1
#define ll long long

int N;
int dist[MAX];
int gas[MAX];
ll solve();

int main() {
    ios::sync_with_stdio(false);
    cin.tie(NULL);
    cout.tie(NULL);

    memset(dist, 0, sizeof(dist));
    memset(gas, 0, sizeof(gas));

    cin &gt;&gt; N;
    for(int i = 1; i &lt; N; i++) {
        cin &gt;&gt; dist[i];
    }
    for(int i = 1; i &lt; N  + 1; i++) {
        cin &gt;&gt; gas[i];
    }

    cout &lt;&lt; solve() &lt;&lt; endl;

    return 0;
}

ll solve() {
    ll result = 0; // 결과 저장 값
    int now_idx = 1; // 현재 노드
    int comp_idx = 2; // 비교 노드

    // 비교 과정 진행
    int move_dist = 0;
    while(now_idx &lt; N) {
        // 현재 노드의 주유비가 비쌀 경우
        if(gas[now_idx] &gt; gas[comp_idx]) {
            move_dist += dist[comp_idx - 1];
            result += move_dist * gas[now_idx];
            now_idx = comp_idx;
            comp_idx++;

            // 움직인 거리 초기화 (계산 완료했으므로)
            move_dist = 0;
        }
        // 현재 노드의 주유비가 쌀 경우
        else {
            move_dist += dist[comp_idx - 1];
            comp_idx++;
        }

        // 비교 대상이 마지막 노드인 경우는 분리해서 처리
        if(comp_idx == N) {
            move_dist += dist[comp_idx - 1];
            result += move_dist * gas[now_idx];
            now_idx = comp_idx;
        }
    }

    return result;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/84547c0f-f7b4-4b7b-afcb-68529d9f7c08/image.png" alt=""></p>
<h2 id="정답-코드">정답 코드</h2>
<pre><code class="language-cpp">#include &lt;bits/stdc++.h&gt;

using namespace std;

#define endl &quot;\n&quot;
#define MAX 100000 + 1
#define ll long long

int N;
int dist[MAX];
int gas[MAX];
ll solve();

int main() {
    ios::sync_with_stdio(false);
    cin.tie(NULL);
    cout.tie(NULL);

    memset(dist, 0, sizeof(dist));
    memset(gas, 0, sizeof(gas));

    cin &gt;&gt; N;
    for(int i = 1; i &lt; N; i++) {
        cin &gt;&gt; dist[i];
    }
    for(int i = 1; i &lt; N  + 1; i++) {
        cin &gt;&gt; gas[i];
    }

    cout &lt;&lt; solve() &lt;&lt; endl;

    return 0;
}

ll solve() {
    ll result = 0; // 결과 저장 값
    int now_idx = 1; // 현재 노드
    int comp_idx = 2; // 비교 노드

    // 비교 과정 진행
    ll move_dist = 0;
    while(now_idx &lt; N) {
        // 현재 노드의 주유비가 비쌀 경우
        if(gas[now_idx] &gt; gas[comp_idx]) {
            move_dist += dist[comp_idx - 1];
            result += move_dist * gas[now_idx];
            now_idx = comp_idx;
            comp_idx++;

            // 움직인 거리 초기화 (계산 완료했으므로)
            move_dist = 0;
        }
        // 현재 노드의 주유비가 쌀 경우
        else {
            move_dist += dist[comp_idx - 1];
            comp_idx++;
        }

        // 비교 대상이 마지막 노드인 경우는 분리해서 처리
        if(comp_idx == N) {
            move_dist += dist[comp_idx - 1];
            result += move_dist * gas[now_idx];
            now_idx = comp_idx;
        }
    }

    return result;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/978a2c29-5d4d-41dc-b55f-32ccb82cc71d/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[과릿] Redis 스냅샷 오류 해결기 ]]></title>
            <link>https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Redis-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Sun, 19 May 2024 13:06:24 GMT</pubDate>
            <description><![CDATA[<p>과릿 서버 에러 메시지 중 경험한 적 없던 에러를 발견하여, 발견부터 해결까지의 과정을 정리해보려고 한다.</p>
<h1 id="에러-발생">에러 발생</h1>
<p>현재 과릿은 아래와 같은 구조로 에러를 모니터링하고 있다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/8eec6c02-e0ab-4c42-a3ed-e5a1b8bb3ae1/image.png" alt=""></p>
<p>슬랙을 통해 서버 에러 발생을 확인했으며, 해당 에러명이 RedisSystemException임을 통해 Redis 관련 오류가 발생했다고 판단했다.</p>
<p>Sentry에 접속해서, 자세한 에러 정보를 조회하니 아래와 같은 정보를 확인할 수 있었다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/82fe1dd2-2724-40b8-bdbd-605ab25e652b/image.png" alt=""></p>
<p>처음보는 에러이기에, 중요도가 높은지/바로 해결이 가능한지를 판단하던 중 아래와 같은 카카오톡 채널 연락을 받게 되었다.
<img src = "https://velog.velcdn.com/images/dl-00-e8/post/bf0971f6-5d82-483a-b3a9-ed3a2ee5187b/image.png" width = 300>
사용자분이 상세하게 캡쳐와 함께 문제 상황을 설명해주셨다. </p>
<blockquote>
<p>별도로 문제가 발생한 상황을 파악하기 위한 코스트가 거의 없었다. 정말 감사하다🙏</p>
</blockquote>
<h1 id="해결-과정">해결 과정</h1>
<h2 id="문제-상황">문제 상황</h2>
<p>사용자분이 겪은 상황을 앞뒤를 유추하여 정리하면 아래와 같다.</p>
<ol>
<li>로그인 시도 -&gt; 지속적인 로그인 실패</li>
<li>지속적인 로그인 실패로 비밀번호 초기화를 시도</li>
<li>비밀번호 초기화를 위해, 문자발송 버튼을 눌렀으나 문자는 오지만 입력 칸이 활성화되지 않았음</li>
</ol>
<p>먼저 사용자분의 원활한 사용을 위해 자체적으로 임시 비밀번호를 초기화하여 드리고자 했으나, 초기화를 진행한다고 해도 사용자분이 서비스 사용이 불가능하다는 것을 판단해 오류를 확인해 임시 비밀번호 발급 및 업데이트를 진행하겠다고 전달드린 뒤 최대한 빠른 속도로 수정 배포를 진행하는 것으로 결정했다.</p>
<p>사용자분이 임시 비밀번호를 전달드려도 서비스 사용이 불가능한 이유는 우리의 로그인 로직과, 비밀번호 초기화 로직을 보면 알 수 있다.</p>
<p><strong>로그인 로직</strong>
<img src="https://velog.velcdn.com/images/dl-00-e8/post/727f35a9-c70e-4b63-8ecf-36981bbfdb67/image.png" alt=""></p>
<p><strong>비밀번호 초기화 로직</strong>
<img src="https://velog.velcdn.com/images/dl-00-e8/post/85aefcc5-c3b2-4cd1-8aaf-586044591185/image.png" alt=""></p>
<p>위 두 이미지를 보면 두 로직 모두 Redis를 활용하여 값을 저장하고 있다. 즉, Redis의 오류가 해결되지 않으면 해당 로직은 정상적인 기능을 할 수 없다는 의미이다. </p>
<blockquote>
<p>현재 과릿은 CoolSMS를 메세지 발송 서드파티로 이용하고 있다. CoolSMS 도입과정은 <a href="https://velog.io/@dl-00-e8/%EA%B3%BC%EB%A6%BF-Cool-SMS-%EC%A0%84%ED%99%98%EA%B8%B0">이 글</a>을 통해서 확인할 수 있다.</p>
</blockquote>
<h2 id="에러-정리">에러 정리</h2>
<p>정확한 에러는 아래와 같다.</p>
<pre><code class="language-markdown">RedisSystemException
Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: MISCONF Redis is configured to save RDB snapshots, but it&#39;s currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-on-bgsave-error option). Please check the Redis logs for details about the RDB error.</code></pre>
<p>에러를 살펴보면, <code>MISCONF Redis is configured to save RDB snapshots, but it&#39;s currently unable to persist to disk.</code>라는 문장을 볼 수 있다. 이 문장을 직역하면, <code>MISCONF Redis가 RDB 스냅샷을 저장하도록 구성되어 있지만, 현재 디스크에 지속적으로 저장할 수 없다.</code>는 의미다.</p>
<p>이 문장을 이해하기 위해서는 Redis의 백업본 저장 방식을 먼저 알아야 한다.
Redis는 <strong>메모리에 데이터를 저장함을 통해 빠른 속도의 읽기 및 쓰기 연산</strong>을 지원하는 장점이 있다. 다만 이 방식의 가장 큰 단점은 <strong>데이터의 휘발가능성</strong>이다. Redis는 이러한 단점을 커버하기 위해 메모리에 있는 데이터를 디스크에 백업하는 기능을 제공한다. 백업 방식은 크게 두 가지로 나뉜다.</p>
<blockquote>
<p>Redis가 데이터의 영속성을 위하여 사용하는 방식에 대한 자세한 내용은 <a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">공식 문서</a>를 통해서 더 확인할 수 있다.</p>
</blockquote>
<p><strong>1. RDB(Redis Database)</strong>
이 방식은 메모리에 있는 전체 데이터에 대한 스냅샷을 작성하고, 이를 디스크에 저장하는 방식이다.</p>
<p>장점</p>
<ul>
<li>비교적 작은 사이즈의 파일 백업</li>
<li>빠른 로딩 속도</li>
<li>단일 파일이므로, S3등의 스토리지 저장 용이</li>
</ul>
<p>단점</p>
<ul>
<li>스냅샷으로 저장된 시점 이후의 데이터는 유실</li>
</ul>
<p>스냅샷 저장 기준 (설정을 변경하지 않았을 경우) </p>
<ul>
<li>3600초 안에 1개 이상의 데이터가 변경되면 저장</li>
<li>300초 안에 100개 이상의 데이터가 변경되면 저장</li>
<li>60초안에 10000개 이상의 데이터가 변경되면 저장</li>
</ul>
<p>스냅샷 저장 위치</p>
<ul>
<li>dumb.rdb</li>
</ul>
<p><strong>2. AOF(Append Only File)</strong>
이 방식은 데이터가 변경되는 이벤트가 발생하면, 모두 다 로그에 저장하는 방식이다.</p>
<p>장점</p>
<ul>
<li>상대적으로 적은 유실량</li>
<li>데이터의 생성, 수정, 삭제를 초 단위로 취합 및 로그 파일에 작성</li>
</ul>
<p>단점</p>
<ul>
<li>파일 크기가 큼 (다만, 파일 사이즈가 특정 사이즈 이상 커지면 rewrite를 통해 파일 크기 최적화)</li>
<li>상대적으로 느린 로딩 속도</li>
</ul>
<p>과릿의 Redis는 현재 RDB 방식으로 데이터의 영속성을 관리하고 있다. Redis의 RDB 스냅샷 방식은 RDB 스냅 샷이 실패 할 경우, write 중에 오류를 보고하도록 구성되어 있어서 데이터 수정 작업이 불가능해지는데, 디스크 용량 초과로 인하여 RDB 스냅 샷 저장에 실패하여 데이터 생성/수정이 불가능해진 것이다.</p>
<h2 id="해결-방법-결정">해결 방법 결정</h2>
<p>해결하기 위한 방법을 찾아보니, 아래의 명령어를 redis-cli에서 적용하는 것을 일반적으로 활용하는 것을 알아내었다. </p>
<pre><code class="language-shell">config set stop-writes-on-bgsave-error no</code></pre>
<p>위 명령어의 의미는 Redis 구성 매개변수인 stop-writes-on-bgsave-error를 no로 설정하는 것인데, 해당 매개변수는 Redis가 백그라운드에서 RDB 스냅샷을 만드는 과정 중 오류가 발생했을 때의 동작을 제어하는 매개변수다. 
해당 변수가 yes(기본값)일 때는 오류가 발생하면, Redis는 더 이상 데이터 쓰기 작업을 진행할 수 없다. 그러나 no로 설정한다면, 오류가 발생해도 Redis는 계속해서 데이터 쓰기 작업을 진행할 수 있다.
해당 매개변수를 no로 변경하였을 때 발생할 수 있는 장/단점은 아래와 같다.
<strong>장점</strong></p>
<ul>
<li>백그라운드 RDB 저장 중 오류가 발생해도 Redis는 계속 정상 작동</li>
<li>클라이언트의 데이터 쓰기 요청을 계속 수락하고 처리</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>메모리에 있는 데이터는 디스크에 지속적으로 저장되지 않으므로 서버 재시작 시 데이터 손실 위험 존재</li>
</ul>
<p>위와 같은 데이터 손실 위험이 있는 이유는 Redis 서버가 데이터 영속성을 관리하는 방식으로 RDB를 선택했을 경우의 동작 방식에 관련이 있다.
Redis 서버는 서버 재시작 시, 남아 있는 RDB 스냅샷 정보를 기반으로 데이터를 불러오게 된다.
RDB 스냅샷에 오류가 발생한 이후에 저장된 데이터들은 스냅샷에 기록되어 있지 않기 때문에 데이터 손실이 발생할 수 있는 것이다.</p>
<p>과릿 Redis에서는 (1)인증번호 (2)로그아웃된 블랙리스트 토큰 (3)Refresh Token 등의 정보를 저장하고 있는데, 해당 3종류의 데이터 모두 휘발성 데이터임과 더불어 Redis 서버 재시작 가능성이 현저하게 낮기 때문에, <strong>해당 변수를 no로 설정하기로 결정</strong>했다.</p>
<h2 id="해결-과정-1">해결 과정</h2>
<p>과릿은 서버 비용 경감을 위해 Free Tier의 EC2 한 대 안에 API Server와 Redis Server가 Docker를 활용해 구동하고 있다.
그렇기에, ssh를 통해 EC2 접속하여 docker exec 명령어를 활용해 redis-cli에 접근하고자 했다.
그런데, 아래와 같은 오류가 발생했다.</p>
<pre><code class="language-shell">docker no space left on device</code></pre>
<p>근본적인 원인을 위 오류를 통해서 알 수 있게 된 것이다. Spring Boot Server의 로그가 굉장히 많이 쌓여 저장 공간이 부족해진 것이였고, 이에 Redis Server가 영향을 받은 것이었다. 
그래서, Spring Boot Server 오래된 로그 파일을 지웠으며, Redis의 stop-writes-on-bgsave-error 또한 no로 변경을 진행했다.</p>
<blockquote>
<p>해당 게시글을 작성하면서 추가적인 정보를 파악하던 중, logrotate와 crontab 기반으로 주기적으로 컨테이너 로그를 지우는 방법을 알게 되었다.
그래서, <a href="https://velog.io/@dl-00-e8/Docker-Logrotate-Crontab%EC%9C%BC%EB%A1%9C-Docker-Log-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0">다음 글</a>로 logrotate와 crontab을 이용해 오래된 컨테이너 로그를 지우는 과정을 정리하면서 stop-writes-on-bgsave-error의 값을 다시 yes로 돌려 안정성을 높이는 과정을 정리했다.</p>
</blockquote>
<hr>
<p><strong>레퍼런스</strong></p>
<ul>
<li><a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">Redis Docs</a></li>
<li><a href="https://velog.io/@banggeunho/%EB%A0%88%EB%94%94%EC%8A%A4Redis-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90.-%EC%A0%95%EC%9D%98-%EC%A0%80%EC%9E%A5%EB%B0%A9%EC%8B%9D-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EC%9C%A0%ED%9A%A8-%EA%B8%B0%EA%B0%84">Reids 정리 블로그 글</a></li>
<li><a href="https://velog.io/@pjh612/Redis%EC%9D%98-%EB%B0%B1%EC%97%85RDB-AOF-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">Redis 백업 관련 블로그 글</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[프로젝트 템플릿] 노션으로 프로젝트 관리하기 ]]></title>
            <link>https://velog.io/@dl-00-e8/%EB%85%B8%EC%85%98-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%85%9C%ED%94%8C%EB%A6%BF-%EC%A0%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dl-00-e8/%EB%85%B8%EC%85%98-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%85%9C%ED%94%8C%EB%A6%BF-%EC%A0%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 12 May 2024 08:06:23 GMT</pubDate>
            <description><![CDATA[<p>동아리와 프로젝트를 진행하면서, 다양한 문서들을 작성 및 관리하는 과정에서 경험했던 내용들을 기반으로 지정 템플릿의 만들어 활용하는 것이 앞으로 효율적이라고 판단했다. 기존에 활용했었던 템플릿들에서 만족했던 부분과 주변인들의 템플릿 등을 받아서 종합해보았다.</p>
<p>아래는 템플릿에서 파트별 설명을 이미지와 함께 작성해봤다.
(다크모드에 익숙하여, 모든 캡처가 다크모드인 것은 양해바란다.)</p>
<h2 id="메인-페이지">메인 페이지</h2>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/5e4fa22c-e0f1-406a-b840-eb206cf90688/image.png" alt=""> 먼저 메인 페이지다. 메인 페이지에서는 상단의 공통 외부/팀별/공통 내부/바로가기로 분류된 문서파트와 하단의 프로젝트 전체 일정에서 주요 이벤트와 관련내용을 정리하는 일정 관리 파트로 작성되어 있다</p>
<h3 id="바로가기">바로가기</h3>
<p>바로가기는 서비스 개발 프로젝트를 기준으로 하여, 운영 과정에서 활용하는 다양한 서비스들의 링크를 모아서 관리하는 것을 목표로 한다.
바로가기로 등록할 수 있는 링크, 즉 팀 전원이 활용할 수 있는 링크들의 예시로는 아래와 같다.</p>
<ul>
<li><a href="https://www.figma.com/">피그마</a>: 기획자들이 기획서/페르소나/유저 플로우 등 피그마를 활용할 경우가 많고, 디자이너들이 주로 작업이 진행되는 공간이기에 팀 전체의 접근 빈도가 높다.</li>
<li><a href="https://business.kakao.com/dashboard/?sid%3Dpfr%26redirect%3Dhttps%3A%2F%2Fcenter-pf.kakao.com%2Fprofiles">카카오톡 채널</a>: 카카오톡 채널의 관리자 센터로, CS 접수 창구를 카카오톡 채널을 이용할 경우 활용 빈도가 높다.  </li>
<li><a href="https://channel.io/ko">채널톡</a>: 채널톡은 AI를 활용한 메신저로 고객 상담 및 자동 응대 등 다양한 강점을 가지고 있어 CS 접수 창구로의 활용 빈도가 높다.</li>
<li><a href="">Slack</a>: 슬랙은 팀 채널로 많이 활용되며, 파트별 채널 분리 및 권한 제어, 슬랙 봇을 활용한 알림 등 다양하게 활용할 수 있는 좋은 수단이다. 다만 무료요금제는 데이터가 90일만 유지된다는 게 아쉬운 점이다. </li>
</ul>
<h2 id="공통-외부">공통 외부</h2>
<h3 id="프로젝트-소개">프로젝트 소개</h3>
<p>프로젝트 소개 페이지는 동아리/사이드 프로젝트 등의 특정 목적을 기반으로 구성되었을 경우, 해당 프로젝트의 방향성 또는 프로젝트의 목표를 정리하는 페이지다. 해당 페이지는, 사실 서비스 개발 및 운영이 목적인 경우에는 필요성이 낮아, 필요하지 않다면 사용하지 않고 삭제해도 무방하다.</p>
<h3 id="서비스-소개">서비스 소개</h3>
<p>서비스에 대한 원 페이지 소개서를 작성하는 페이지다. <strong>&quot;우린 어떤 서비스다.&quot;</strong> 를 간결하게 알려주는 것을 목표로 한다. 이는 랜딩 페이지와 동일하게 간주될 수 있다. 웹 서비스의 경우, 랜딩 페이지를 분리하여 운영하는 것은 쉬운 편이나 앱 서비스의 경우에는 웹을 별도로 개발하는 것에 코스트가 존재하므로 노션 + 우피의 조합을 통해서 상대적으로 손쉽게 랜딩 페이지로 해당 페이지를 활용해도 된다.</p>
<p>노션 + 우피 조합의 <a href="https://www.oopy.io/ko/landing-page">랜딩 페이지 예시</a>를 첨부한다.</p>
<h3 id="팀-소개">팀 소개</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/1e729ace-2232-42cc-bfc3-4dea62eda19f/image.png" alt=""></p>
<p>팀 소개 페이지의 목적은 크게 두 가지로 생각하고 만들었다.</p>
<p><strong>1. 최초 팀 구성 시, 서로에 대한 기본 정보를 파악하기 위한 목적</strong>
최초 팀 구성 시, 구면인 경우도 있지만 초면인 경우도 분명히 존재할 것이다. 이 때, 가볍게 서로를 알 수 있는 정보들을 작성하여 기본적인 정보를 파악하는데 활용할 수 있다.</p>
<p><strong>2. 외부와 MOU 등의 협업 진행 시, 구성원 소개를 위한 페이지</strong>
실제로, 서비스를 개발 및 운영하는 과정에서 외부와 협업 또는 미팅 등이 진행될 수 있다. (굉장히 잘 풀어나가고 있다는 의미다.) 이럴 때에는, 팀 구성원에 대한 정보를 전달해야 할 경우 활용할 수 있다.</p>
<p>위의 두 가지 목적 중, 팀의 목적과 부합하는 방향으로 기입 정보를 추가 및 삭제하면서 활용하면 좋을 것이다.</p>
<h3 id="개인정보-처리방침">개인정보 처리방침</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/30aa48cc-2c53-49ff-bb2e-d8ae69dae978/image.png" alt=""></p>
<p><strong>개인정보 처리방침의 필요성</strong></p>
<table>
<thead>
<tr>
<th>개인정보보호법 제2조 제5호</th>
<th>개인정보보호법 제30조 제1항</th>
</tr>
</thead>
<tbody><tr>
<td>5. &quot;개인정보처리자&quot;란 업무를 목적으로 개인정보파일을 운용하기 위하여 스스로 또는 다른 사람을 통하여 개인정보를 처리하는 공공기관, 법인, 단체 및 개인 등을 말한다.</td>
<td>① 개인정보처리자는 다음 각 호의 사항이 포함된 개인정보의 처리 방침(이하 &quot;개인정보 처리방침&quot;이라 한다)을 정하여야 한다. 이 경우 공공기관은 제32조에 따라 등록대상이 되는 개인정보파일에 대하여 개인정보 처리방침을 정한다.</td>
</tr>
<tr>
<td>(출처: <a href="https://blog.naver.com/n_privacy/221981553332">‘개인정보 처리방침’ 이란?</a>)</td>
<td></td>
</tr>
<tr>
<td>위의 법에서 안내하듯이, 개인정보파일을 처리하게 된다면, 개인정보처리방침에 대한 고지는 필수적이다.</td>
<td></td>
</tr>
</tbody></table>
<p><strong>고지 주의사항</strong></p>
<ul>
<li>웹 서비스: 개인정보 처리방침을 홈페이지에 계속 공시하여야 함.</li>
<li>앱 서비스: 앱 내에서 지속적으로 개인정보 처리방침을 게시하여야 함.</li>
<li>&quot;개인정보 처리방침&quot;이라는 명칭을 활용하여 게시하여야 함.</li>
<li>개인정보 처리방침 변경 시, 이전 버전도 확인이 가능하게 변경이력을 포함하여야 함.</li>
</ul>
<p>(출처: <a href="https://www.catchsecu.com/archives/15458">개인정보 처리방침 공개, 이런 것도 법으로 정해져 있다고?</a>)</p>
<p><strong>어떻게 만들 수 있는데?</strong>
이미지에도 첨부되어 있는 <a href="https://www.privacy.go.kr/front/per/inf/perInfStep01.do">개인정보 처리방침 만들기</a>를 통해 작성지침을 확인할 수 있다. 23년도 하반기까지만 해도 해당 페이지 내에서 손쉽게 제작이 가능했는데, 어느새부턴가 해당 기능은 사라진 상황이다. 그렇기에, 구글 검색 등 기존 제작된 포맷을 가져와 변경하거나, 작성 지침을 기반으로 새로 만들어야 한다.</p>
<h3 id="서비스-이용약관">서비스 이용약관</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/ff0e79af-419f-4643-9107-c6798563039c/image.png" alt=""></p>
<p>서비스 이용 약관은 사용자들에게 공시해야만 그 효력이 발생하기 때문에, 사용자와 서비스 제공자 간 문제 발생 시 관련 책임을 명확하게 하기 위한 용도로 공시하는 것이 좋다. 찾아본 결과, 양식을 명확히 정의하기는 어려울 것으로 보여, 기입하면 좋을 내용과 참고할 수 있는 약관 사이트를 첨부한다.  </p>
<p><strong>기입하면 좋을 내용</strong></p>
<ul>
<li>서비스 목적</li>
<li>약관의 효력</li>
<li>관련 용어 정의</li>
<li>회원가입 존재 시, 계정 이용과 관련된 내용</li>
<li>서비스 기능과 관련된 내용</li>
<li>공고 날짜 및 수정 날짜 등</li>
</ul>
<p><strong>이용 약관 참고 서비스</strong></p>
<ul>
<li><a href="https://policy.naver.com/policy/service.html">네이버</a></li>
<li><a href="https://www.kakao.com/main">카카오</a></li>
<li><a href="https://img.woowahan.com/www/biz/rule/agreement.html">배달의 민족</a></li>
<li><a href="https://static.hwahae.co.kr/docs/terms/app/terms-of-use.html">화해</a></li>
<li><a href="https://www.daangn.com/policy/terms">당근</a></li>
</ul>
<h2 id="공통-내부">공통 내부</h2>
<h3 id="전체-회의록">전체 회의록</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/71598b38-dcd7-4eec-8b7f-3e4bf250627a/image.png" alt="">
팀 전체의 회의록을 작성 및 관리하는 페이지이다. 
팀 전체가 참여하는 회의를 기반으로, 2개 이상의 파트가 함께 회의를 진행하게 될 경우, 해당 페이지에서 기록하여 모든 파트의 인원들이 확인할 수 있도록 해야 한다.</p>
<p><strong>활용 방법</strong></p>
<ul>
<li>관련된 일정 존재 시, 메인 페이지의 일정 리스트 및 캘린더와 연동 가능</li>
<li>기획팀 자체 회의와 연동될 경우, 기획팀의 OKR 페이지와 연동 가능</li>
</ul>
<h3 id="서비스-운영-지표">서비스 운영 지표</h3>
<p>실제로 서비스가 런칭되고 나서, KPI를 지속적으로 파악하고 있어야 한다.</p>
<blockquote>
<p>KPI란?
KPI는 <strong>Key Performance Indicator</strong>의 약자로, 핵심 성과 지표라는 의미를 담고 있습니다. </p>
</blockquote>
<p>하단의 이미지와 같은 별도의 페이지를 통해서 관리하는 것이, 서비스의 성장세 또는 핵심 기능이 유효하게 동작하고 있는지를 확인할 수 있을 것이다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/96f15079-41a8-4aa7-90e7-0b323df73b3a/image.png" alt=""></p>
<p>다만, 서비스 운영 지표를 관리하는 위 방식은 사람이 꾸준하게 확인 및 기입을 진행해야 하므로, 제품 분석(Product Analysis) 툴을 사용하는 것을 병행하는 것을 추천한다.</p>
<blockquote>
<p>제품 분석 툴이라고 부르는 것이 맞지 않는 경우도 있지만, 정보 전달을 위해 통칭한다. </p>
</blockquote>
<p><strong>제품 분석 툴</strong></p>
<ul>
<li><p><a href="https://marketingplatform.google.com/about/analytics/">GA(Google Analytics)</a></p>
</li>
<li><p><a href="https://amplitude.com/">Amplitude</a></p>
</li>
<li><p><a href="https://mixpanel.com/m/free-plan-ko?utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=APAC-Korea-Brand-Search-KO-Broad-Desktop&amp;utm_content=Mixpanel-Cost-Exact&amp;utm_ad=684288052266&amp;utm_term=%EB%AF%B9%EC%8A%A4%ED%8C%A8%EB%84%90%20%EB%B9%84%EC%9A%A9&amp;matchtype=b&amp;campaign_id=20856517427&amp;ad_id=684288052266&amp;gclid=EAIaIQobChMIv8Dg2siHhgMVc8sWBR0L-QvvEAAYAiAAEgJfIfD_BwE&amp;gad_source=1">Mix Panel</a></p>
</li>
</ul>
<p>필자는 믹스 패널을 제외한, GA와 Amplitude를 사용한 경험이 있다. GA는 무료 모델의 제일 기본적인 기능만 사용했었기에, 크게 효과적이게 사용하지 못했지만, Amplitude에서는 퍼널 설정을 통해 사용자의 서비스 이용 과정에서 이탈 위치를 파악하고 개선 방향성을 찾아내는 과정을 진행했었기에 Amplitude를 조금 더 좋아한다. </p>
<p>세 종류의 분석 툴을 <a href="https://mixpanel.mfitlab.com/blog/2023-09-01-mixpanel-vs-ga4-vs-amplitude">비교하여 정리한 글</a>을 첨부한다.</p>
<h3 id="유저-피드백-리스트-정리">유저 피드백 리스트 정리</h3>
<p>위의 카카오톡 채널, 채널톡을 바로가기에서 등록해놓았듯이, 사용자의 기능 추가 요청 또는 개선사항 등을 정리해서 활용해야 한다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/b23d0654-5970-468c-8ecc-10e22137aa2c/image.png" alt="">
이 템플릿에서는 유저의 피드백 리스트를 다 기입해놓는 섹션과 해당 피드백을 반영했을 경우, 반영 완료 섹션으로 모아서 관리할 수 있도록 만들어 놓았다.</p>
<p>정리하는 내용은 아래와 같다.</p>
<ul>
<li><strong>접수 플랫폼</strong>: 피드백을 접수하는 창구가 다양할 경우, 창구별로 정리의 필요성이 있다.</li>
<li><strong>내용</strong>: 실질적인 피드백 내용을 기입한다. 해당 내용이 자세할 경우, 페이지를 열어 관련된 부분을 세부적으로 작성한다.</li>
<li><strong>서비스 버전</strong>: 사용자가 사용하고 있는 서비스의 버전에 따라 피드백이 달라질 수 있으므로, 서비스 버전을 기입한다.</li>
<li><strong>날짜</strong>: 피드백이 들어온 날짜를 기입한다.</li>
<li><strong>업데이트 반영 여부</strong>: 해당 피드백을 업데이트에 반영할 것인지에 대한 판단 여부를 기입하고, 만약 반영하기로 결정하였다면 반영 예정/반영 중/반영 완료로 나누어서 상태를 관리한다.</li>
</ul>
<h3 id="운영-관련-장애-및-개선사항">운영 관련 장애 및 개선사항</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/870fa1cd-cc98-48b5-96b5-cf47af26e3e0/image.png" alt=""></p>
<p>바로 위 목차에 적혀 있는 유저 피드백에 더해 운영 중 발생한 장애 및 개선사항을 정리하는 페이지이다.
유저 피드백을 기반으로 개선사항을 추가할 수도 있으며, 별도의 모니터링 툴 또는 사용 중 발생한 장애 연락을 명확하게 정리하여, 반영할 수 있도록 한다. 현재는 중요도 정보를 새로 추가하여, 중요도에 따라 개발 우선순위를 관리할 수 있도록 하고 있다.</p>
<p><strong>중요도 (숫자가 높을수록 중요한 업무)</strong></p>
<ul>
<li>1: 개발하지 않아도 되나 최후순위 개발 업무</li>
<li>2: 개발해야 하나 후순위 개발 업무</li>
<li>3: 개발 필요</li>
<li>4: 개발해야 하며, 선순위 개발 업무</li>
<li>5: 긴급한 사항으로, 당장 개발 진행 필요</li>
</ul>
<h3 id="qa-list">QA List</h3>
<p>버전이 업데이트 될 때마다 기존 버전과의 충돌은 없는지 새롭게 추가되거나 수정된 기능의 문제는 없는지 테스트를 거쳐야 합니다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/034f2076-044d-4169-832e-4bcc40131469/image.png" alt=""></p>
<p>QA를 작성할 때, 분류를 잘해야 하는데, 이는 관점이 페이지 단위이냐 기능 단위로 접근하냐에 따라 분류가 달라질 수 있습니다. 접근하는 방식을 구분하지 않는다면, 동일한 기능을 계속 테스트하게 될 수도 있습니다.</p>
<blockquote>
<p>예를 들자면, 인증이라는 기능적 관점에서는 아래와 같이 분류될 수 있습니다.</p>
</blockquote>
<ul>
<li>인증<ul>
<li>회원가입<ul>
<li>카카오 로그인<ul>
<li>애플 로그인</li>
</ul>
</li>
</ul>
</li>
<li>로그인<ul>
<li>로그인<ul>
<li>로그아웃<blockquote>
</blockquote>
페이지에서는 아래와 같이 분류해볼 수 있습니다.</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li>메인 페이지 (비 로그인 상황)<ul>
<li>로그인 버튼 클릭<blockquote>
</blockquote>
<ul>
<li>애플 로그인</li>
<li>카카오 로그인</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>(필자 또한 QA를 잘 아는 사람이 아님을 감안하고 읽어주세요. 더 좋은 예시 또는 개선사항이 있다면 바로 반영할 예정입니다.)</p>
<h3 id="예산-사용-목록">예산 사용 목록</h3>
<p>서비스를 운영하는 과정에서는 다양하게 예산이 사용될 수 있습니다. 이러한 예산을 투명하게 관리하는 것은 꼭 필요한 일입니다. 
보통 사용되는 예산으로는 서버비용, 마케팅 비용, 이벤트 진행 시 경품 지급 비용 등이 있을 수 있습니다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/0a877a4a-c226-4b20-8ff8-4ccc53e96384/image.png" alt="">
예산을 관리하는 과정 중 제일 중요한 부분은 지출 여부 칼럼입니다.
지출 여부 칼럼은 아래와 같은 상태값을 가지고 있습니다.</p>
<ul>
<li>승인 예정: 예산을 사용하겠다고 올려놓은 상태, 팀 내 예산 사용 여부 미결정</li>
<li>승인 완료: 예산을 사용하는 것으로 팀 내 결정된 상태</li>
<li>결제 예정: 예산 승인이 된 상태에서, 아직 결제가 이루어지지 않은 상태</li>
<li>결제 완료: 예산 승인이 된 상태 및 결제가 완료 된 상태</li>
<li>정산 예정: 예산 승인 및 결제 완료 상태에서 팀 내 정산이 이루어지지 않은 상태</li>
<li>정산 완료: 예산 승인 및 결제 완료 상태에서 팀 내 정산이 완료된 상태</li>
<li>증빙 첨부 예정: 예산 승인 및 결제 완료 상태에서, 증빙 자료가 첨부되어 있지 않은 상태</li>
<li>증빙 첨부 완료: 예산 승인 및 결제 완료 상태에서, 증빙 자료가 첨부된 상태</li>
</ul>
<p>위 8가지 상태값에서 팀 내 상황에 맞추어 상태값을 추가 또는 삭제하여 예산 사용을 명확하게 관리할 수 있습니다.
서버비용과 같이 월 결제 비용이면서 비용이 명확하게 딱 결정되지 않는 경우는 결제 예상치를 기반으로 예산을 집행하는 방식으로 진행하면 좋을 것 같습니다. </p>
<h3 id="접속-재원">접속 재원</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/7cc51b98-2935-40f5-bf65-b9cb95b34179/image.png" alt=""></p>
<p>접속 재원은 팀 공식 계정 등을 정리해놓는 페이지입니다. 게정의 비밀번호 변경 등의 특이사항을 해당 페이지에 관리한다면, 계정을 조회하기 위해 별도의 연락을 최소화할 수 있습니다. </p>
<h3 id="업데이트-노트">업데이트 노트</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/39f289c8-01ac-4914-9651-b5c4ce11201f/image.png" alt=""></p>
<p>업데이트 노트는, 서비스의 업데이트 버전 및 날짜 등을 정리해놓는 페이지입니다. 배포하는 플랫폼 및 담당자를 명시함을 통해, 배포 과정에서의 문제상황(Ex: 앱 심사 거절) 등을 정리하고 해결할 때 도움을 줄 수 있습니다. 또한, 여기서 기입된 서비스 버전은 유저 피드백 또는 운영 관련 장애 및 개선사항과 같은 페이지에서 기입되는 버전명을 의미합니다. 또한 버전별 업데이트 사항을 명확히 기입해놓는다면, 앱 스토어의 버전별 기록을 기입할 때 도움을 줄 수 있으며, 기획자/마케터가 업데이트 관련 사용자 모집 과정에서 활용할 수 있습니다. </p>
<h2 id="팀별">팀별</h2>
<h3 id="기획팀">기획팀</h3>
<p>기획을 전문적으로 하지 않아, 작성할 내용이 많지는 않지만, 정리해놓으면 좋을 것 같은 내용들을 정리해보았다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/adf0cd82-922e-434f-a4d7-135e67e637d5/image.png" alt=""></p>
<p>비즈니스 모델 캔버스를 추가했다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/9647bd0a-492e-4da2-9e8b-7588d195a1d9/image.png" alt="">
비즈니스 모델 캔버스는 비즈니스에 포함되어야 할 9개의 주요 정보들을 모아서 관리하는 그래픽 템플릿이다. 템플릿 활용방법은 <a href="https://brunch.co.kr/@givemore/3">브런치 글</a>을 통해 자세히 알아볼 수 있다.</p>
<h3 id="백엔드팀">백엔드팀</h3>
<p><img src="https://velog.velcdn.com/images/dl-00-e8/post/6c781b21-b1fa-4d74-884e-602f9708201a/image.png" alt=""></p>
<p>백엔드 팀은 기본적으로 필요한 정보들만 명시해놓았다.
ERD 설계 시 사용할 수 있는 사이트는 아래와 같다.</p>
<ul>
<li><a href="https://www.erdcloud.com/">ERDCloud</a></li>
<li><a href="https://app.diagrams.net/">draw.io</a></li>
<li><a href="https://dbdiagram.io/home">DBDiagram.io</a></li>
</ul>
<p>API 명세서 템플릿은 굉장히 다양하므로 별도로 제작하지는 않았다. 검색 후 원하는 템플릿을 가져와서 사용하면 좋을 것 같다. 구글 검색 시 나오는 예시 노션 템플릿을 몇 개 첨부한다.</p>
<ul>
<li><a href="https://www.notion.so/ko-kr/templates/api-reference">노션 템플릿 1</a></li>
<li><a href="https://puleugo.tistory.com/135">노션 템플릿 2</a></li>
</ul>
<p>인프라의 경우, 피그마 또는 PPT로 작업을 하는 편인데, 공식 아이콘등을 모아놓은 사이트를 첨부한다.</p>
<ul>
<li><a href="https://docs.google.com/presentation/d/1fD1AwQo4E9Un6012zyPEb7NvUAGlzF6L-vo5DbUe4NQ/edit?hl=ko&amp;pli=1#slide=id.g1d52f84352d_367_105">GCP</a></li>
<li><a href="https://aws.amazon.com/ko/architecture/icons/">AWS</a></li>
</ul>
<p>Issue/PR 템플릿을 추가했다.
<img src="https://velog.velcdn.com/images/dl-00-e8/post/34f166f1-3fc5-411b-a504-245aa0ef1656/image.png" alt=""> 적용 방법도 작성해놓았으므로, 프로젝트 내에서 작성할 내용을 변경 또는 추가해서 사용하면 된다. </p>
<blockquote>
<p>이외에, 네이밍 컨벤션, 코드 리뷰 방식, Monitoring 또는 BI 툴 등 다양한 내용들을 추가하여 관리하면 좋을 것이다.</p>
</blockquote>
<hr>
<ul>
<li>프론트엔드팀은 필자가 백엔드 개발을 주로 하다보니, 필요한 페이지들이 무엇일지 몰라 제작하지 않았다.</li>
<li>디자인 파트는 대부분 피그마에서 작업이 진행되다 보니, 주요 정보들을 어떤 형식으로 템플릿으로 관리하는 것이 좋을지 몰라 별도의 템플릿을 제작하지 않았다.</li>
</ul>
<p>프론트엔드, 디자인 템플릿으로 추천하고자 하는 내용들을 발견하면 지속적으로 업데이트할 예정이다. 이외에도 개선 또는 추가되었으면 하는 내용들을 발견하면 이 또한 지속적으로 업데이트할 예정이다.</p>
<blockquote>
<p><strong><a href="https://wjdwls.notion.site/f795281e1c00496e81a70737e86fcca2?pvs=4">최신 버전 템플릿 사용하러가기</a></strong></p>
<p>업데이트 내역
24. 04. 18: 프로젝트 템플릿 1차 제작
24. 05. 12: 운영 관련 장애 및 개선사항 템플릿에 중요도 칼럼 추가
24. 06. 23: 기획팀의 비즈니스 캔버스 모델 템플릿 추가, 개발팀 Github Template 추가</p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>