<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ssol_916.log</title>
        <link>https://velog.io/</link>
        <description>Junior Back-end Developer 🫠</description>
        <lastBuildDate>Thu, 20 Mar 2025 07:40:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ssol_916.log</title>
            <url>https://velog.velcdn.com/images/ssol_916/profile/fc778127-6598-4c34-be91-3296f962cd20/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ssol_916.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ssol_916" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Apache Tika로 파일 MIME type 상세 확인하기]]></title>
            <link>https://velog.io/@ssol_916/Tika%EB%A1%9C-%ED%8C%8C%EC%9D%BC-MIME-type-%EC%83%81%EC%84%B8-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ssol_916/Tika%EB%A1%9C-%ED%8C%8C%EC%9D%BC-MIME-type-%EC%83%81%EC%84%B8-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 20 Mar 2025 07:40:02 GMT</pubDate>
            <description><![CDATA[<p>우선 MIME 타입이란?</p>
<blockquote>
<p><strong>Multipurpose Internet Mail Extensions</strong></p>
</blockquote>
<p>SMTP 프로토콜에서 이메일을 보낼때 파일을 확인하기 위해 생성된 표준으로 많은 프로토콜에서 사용됨</p>
<p>이 MIME 타입을 추출하기 위한 대표적인 라이브러리는 Apache의 Tika가 있다.</p>
<h1 id="apache-tika">Apache Tika</h1>
<p>Apache Tika는 다양한 파일 포맷에서 메타데이터와 텍스트를 추출할 수 있는 강력한 라이브러리이다.</p>
<p>Tika는 두 가지 주요한 모듈로 구성되어 있다.</p>
<ul>
<li>tika-core</li>
<li>tika-parsers</li>
</ul>
<p>tika-core는 파일의 기본적인 MIME 타입 체크와 단순 메타데이터 추출 기능을 제공한다. 오직 파일의 내부 구조를 빠르게 확인하기 위한 핵심 기능만 포함하고 있다.</p>
<p>tika-parsers는 tika-core의 기능에 추가하여, 다양한 파일 형식에 맞는 파서를 포함하고 있다.  이를 통해 파일의 내부 콘텐츠를 분석하고 더 세밀한 MIME 타입 판별 및 메타데이터 추출이 가능해진다.</p>
<p>물론 tika-parsers가 내부적으로 더 많은 메타데이터 추출과 분석 작업을 수행해 더 세밀하게 MIME 타입을 확인할 수 있는 대신 처리 속도나 성능 면에서 약간의 오버헤드가 발생할 수 있는데
이 영향은 파일 크기, 처리량, 시스템 환경 등에 따라 달라지므로 정확성이 중요한 경우라면 이 정도 트레이드오프는 감수할 만할 것!</p>
<h2 id="기본-사용-예제와-한계">기본 사용 예제와 한계</h2>
<p>보통 tika에서 밈 타입을 추출하기위해 많이 사용되는 메서드는 다음과 같다.</p>
<pre><code class="language-java">public String detect(InputStream stream) throws IOException {
    return detect(stream, new Metadata());
}</code></pre>
<p>위 메서드는 기본적으로 새 Metadata 객체를 생성하여 내부적으로 detect(InputStream stream, Metadata metadata) 메서드를 호출한다.</p>
<pre><code class="language-java">public void tikaTest(MultipartFile file) {
    Tika tika = new Tika();

    try (InputStream is = file.getInputStream()) {
        return tika.detect(is);
    } catch (IOException e) {
        throw new IllegalArgumentException(&quot;파일의 MIME 타입 감지 중 오류가 발생했습니다.&quot;, e);
    }
}</code></pre>
<p>이런 식으로 사용하면 되는데 </p>
<p>이 방식은 대부분의 파일에 대해 정상적으로 동작하지만, MS Office 파일들(워드, 엑셀, 파워포인트 등)은 모두 application/x-tika-ooxml으로 인식되는 문제가 있다.</p>
<p>이는 tika-core가 파일 내부의 기본 구조만 확인하기 때문이다.
확장자 위조를 방지하거나 세부 파일 타입(예: docx, xlsx, pptx)을 정확히 구분하기 위해서는 tika-parsers 모듈을 활용해야 한다.</p>
<p>이걸로 충분하다면 상관 없지만 다소 광범위한 MIME 타입으로 나오므로 확장자 위조를 구분하거나, 세부 파일 타입을 확인하기 위해선 tika-parsers의 기능을 사용하는게 좋다.</p>
<h2 id="만약-사용자가-확장자를-조작한다면">만약 사용자가 확장자를 조작한다면?</h2>
<p>만약 사용자가 파일 확장자를 조작하더라도, Tika는 파일 시작 부분의 byte magic pattern을 읽어서 MIME 타입을 확인한다.
대부분의 파일은 이 방식으로 실제 파일 형식을 올바르게 판별할 수 있으나, <code>Compound Document</code>나 <code>Simple Container</code>와 같이 파일 내부에 여러 content type을 포함할 수 있는 경우에는 단순 magic detection으로는 부정확할 수 있다.</p>
<p>tika에서는 <a href="https://cwiki.apache.org/confluence/display/tika/MetadataDiscussion#MetadataDiscussion-DocumentTypes">파일 종류</a>를 5가지로 구분한다.</p>
<ul>
<li>Simple Document</li>
<li>Structured Document</li>
<li><strong>Compound Document</strong></li>
<li><strong>Simple Container</strong></li>
<li><strong>Container with Text</strong></li>
</ul>
<p>이 중 <code>Compound Document</code>(xlsx 등)와 <code>Simple Container</code>(zip 등)는 <strong>파일 내부적으로 여러 content type</strong>을 가질 수 있으므로, 보다 세밀한 판별을 위해 tika-parsers의 추가적인 파싱 기능이 필요하다.</p>
<h2 id="tika-parsers를-활용한-개선된-mime-타입-검출">tika-parsers를 활용한 개선된 MIME 타입 검출</h2>
<p>tika-parsers를 함께 사용하면, 파일 이름 정보를 Metadata 객체에 제공하여 보다 정확한 MIME 타입 판별이 가능하다.
아래와 같이 파일 이름을 메타데이터에 추가하고 detect 메서드에 함께 전달하면, MS Office 파일들이 다음과 같이 세부 MIME 타입으로 인식된다.</p>
<ul>
<li>MS Office Word: <code>application/vnd.openxmlformats-officedocument.wordprocessingml.document</code></li>
<li>MS Office Excel: <code>application/vnd.openxmlformats-officedocument.spreadsheetml.sheet</code></li>
<li>MS Office PowerPoint: <code>application/vnd.openxmlformats-officedocument.presentationml.presentation</code></li>
</ul>
<p>사용할 메서드는 오버라이딩 된 detect로</p>
<pre><code class="language-java">public String detect(InputStream stream, Metadata metadata) throws IOException {
    if (stream == null || stream.markSupported()) {
        return detector.detect(stream, metadata).toString();
    } else {
        return detector.detect(new BufferedInputStream(stream), metadata).toString();
    }
}</code></pre>
<p>tika-core, tika-parsers 3.1.0 버전 기준으로 사용 예제 코드는 다음과 같다.</p>
<pre><code class="language-java">public void tikaTest(MultipartFile file) {
    Tika tika = new Tika();
    Metadata metadata = new Metadata();
    // 파일 이름을 설정하여 tika가 파일 확장자 등의 정보를 활용하도록 함
    metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, file.getOriginalFilename());

    try (InputStream is = file.getInputStream()) {
        // Metadata를 함께 전달하여 정확한 MIME 타입을 감지함
        return tika.detect(is);
    } catch (IOException e) {
        throw new IllegalArgumentException(&quot;파일의 MIME 타입 감지 중 오류가 발생했습니다.&quot;, e);
    }
}</code></pre>
<h1 id="정리">정리</h1>
<ul>
<li><strong>MIME 타입</strong>은 파일의 실제 형식을 나타내며, 네트워크 및 파일 전송에서 중요한 역할을 다.</li>
<li><strong>Apache Tika</strong>는 tika-core와 tika-parsers 모듈로 구성되어 있으며, 후자를 함께 사용하면 파일 내부의 내용을 분석해 보다 정확한 MIME 타입 판별이 가능하다.</li>
<li>단순히 tika-core만 사용할 경우 MS Office 파일 등이 application/x-tika-ooxml으로 광범위하게 인식되지만, tika-parsers와 Metadata 정보를 함께 사용하면 확장자 위조 등으로부터 더 정확한 파일 형식을 판별할 수 있다.</li>
<li>파일 업로드 시점에서 MIME 타입 체크를 수행하면, 이후 다운로드 시에는 추가 검증 없이 검증된 파일만 사용하므로 불필요한 오버헤드를 줄일 수 있다.</li>
</ul>
<p>이와 같이 Apache Tika를 활용하면, 파일의 진짜 형식을 보다 정확하게 판별하여 보안과 무결성을 강화할 수 있다. 그러니 프로젝트 요구 사항에 따라 tika-core만 사용할지, tika-parsers 모듈까지 포함할지 결정하면 되겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프콘 24 후기]]></title>
            <link>https://velog.io/@ssol_916/%EC%9D%B8%ED%94%84%EC%BD%98-24-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@ssol_916/%EC%9D%B8%ED%94%84%EC%BD%98-24-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Thu, 08 Aug 2024 00:52:02 GMT</pubDate>
            <description><![CDATA[<p>이번 인프콘도 역시 인산인해였다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/8747d576-5bd7-4937-addd-3a139a28cf31/image.jpeg" alt=""></p>
<p>일찍 코엑스에 도착했는데도 먼저와서 기업 부스를 사냥하는 수많은 인파들
나도 나름 일찍 도착해서 오프닝 전에 몇몇 부스 탐색이나 해볼까 했는데 줄이 장난 아니었다...</p>
<p>그리고 대망의 오프닝</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/caac2b4d-4c7c-4ed7-89ff-7a4cb58f805c/image.jpeg" alt="">
<img src="https://velog.velcdn.com/images/ssol_916/post/e40e2210-cad3-4194-a978-854456a9f772/image.jpeg" alt=""></p>
<p>이형주 인프랩 대표님의 3번째 인사를 시작으로 지금까지 국내 시장을 대상으로 하던 인프런 서비스를 해외에 진출해 유데미 같은 서비스와 경쟁하도록 하겠다는 포부까지!
그리고 이어서 향로님의 인프랩의 한해 발전 내용과 앞으로 해외진출 목표를 위해 작업 중인 내용와 목표까지 들을 수 있었다. 해외 진출을 위한 발표자의 AI 목소리 번역 샘플을 보여주셨는데 정말 자연스러웠다.</p>
<p>이후 이어지는 세션들도 정말 내용들이 알차고 좋았는데, 원래는 3~4개 정도만 듣고 오후에는 네트워킹 존에서 여러 개발자분들을 만나 커피챗을 하려는 마음이 있었다. 그런데 오전 세션이 너무 만족스러워 7개의 세션 타임 중 6개 세션을 듣고 왔지.</p>
<p>만족스러웠던 세션 중 하나의 내용을 살짝 공유해보도록 하겠다. 전체 내용은 9월 중에 인프런에 올라온다고 하니 인프콘24 강의를 참고바란다.</p>
<br>
재민님의 '지속 성장 가능한 설계를 만들어가는 방법'. 오프닝 이후 첫 세션이기도 했고, 개발자들이 설계에 관심이 많아서 그런지 많은 인원이 착석해있었다.

<p>재민님이 무대 위에 올라서 처음 하신 말이 충격적이었는데 &#39;여러분 설계를 잘하는 법은 설계를 하지 않는 것입니다.&#39;라니...</p>
<p>하지만 뒤에 예시 도메인의 코드와 클래스 구조를 직접 보여주시면서 설계 없이 구현부터 시작하면서 구조를 탄탄하게 만들어 나가는 법을 보여주셨는데 바로 설득되었다. 
요컨데 핵심은 개념과 격벽. 개념들을 정리하기 위해 격벽을 세워서 격리를 하고 이 격벽은 허용한 것만 통과를 시키도록 하는 것이다.(접근에 대한 통제) 그러면 이것은 설계가 아닌가 하는 생각이 들 수도 있는데 이건 설계가 아니라 코드의 개념일 뿐이라는 것!
격벽이 잘 세워진 코드라면 클래스 명이 수정 되었을 때 격벽을 통과할 수 있는 부분만 영향을 받아 같이 수정이 되어야 한다는 것이다. 만약 예상보다 더 많은 클래스가 수정에 영향을 받는다면 격벽이 잘못 세워진 것이겠지?</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/88a134d9-240f-4e32-bf0d-509868c1d0f3/image.jpeg" alt=""></p>
<p>내가 재민님의 세션을 들으면서 메모한 내용을 공유하겠다.</p>
<ul>
<li>하나의 개념이 많이 쓰이면 분리를 검토하자.</li>
<li>상태에 의해 개념이 생기면 격벽을 세워보자.</li>
<li>상태나 행위를 개념으로 착각할 수 있다.</li>
</ul>
<h3 id="인정하자">인정하자</h3>
<p>우리는 ‘소프트’웨어를 만드는 사람
요구사항은 계속 변한다
완벽한 설계란 없다.
소프트웨어는 소프트해야 한다.</p>
<h3 id="하지말자">하지말자</h3>
<p>??: 요구사항이 완벽해야 설계를 할 수 있다.
??: 우리 설계에서 그건 개발 못해요.
??: 설계해봐야 개발 일정이 나옵니다.</p>
<h3 id="상기하자">상기하자</h3>
<p>성급한 설계는 모든 것을 망가트린다.
과도한 설계는 모든 것을 망가트린다.
설계는 필요한 만큼만 하자.</p>
<p>개발 과정</p>
<p>기존: 분석 → 설계 → 구현</p>
<p>이제: 분석하지 말고, 설계하지 말고 최소한으로 바로 구현. 개념과 격벽을 활용해서 피드백을 받으면서 테스트 코드로 증명을 하고.</p>
<p>이러면 자연스럽게 설계가 나오게 된다.
그래서 결론은 설계를 잘하는 방법은
<strong>설계를 하지 않는 것</strong>이다.</p>
<p>개념을 잡고
격벽을 세워
구현을 채워나가
설계를 완성하자.</p>
<blockquote>
<p>누구나 그럴싸한 계획을 가지고 있다. 쳐맞기 전까지는.</p>
</blockquote>
<ul>
<li>마이클 타이슨<blockquote>
</blockquote>
</li>
</ul>
<p>이외에도 
김지호님의 백엔드 개발자 관점과 데이터 팀 관점에서의 데이터를 보는 시각을 다룬 ,
서주은 님의 하루 1억 건 이상을 처리하는 견고한 포인트 시스템 만들기,
토비님의 클린 스프링(사실 스프링에 대한 내용보단 왜 클린코드를 추구해야하며, 클린코드는 생산성과 유지보수성을 등가교환하는게 아닌 둘 모두를 추구할 수 있는 개념이란 것을 설명하셨다.)
모두 좋은 인사이트를 얻을 수 있어서 나중에 정리해서 올려볼 생각이다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/f8f9ae27-a08e-467b-b646-30144efd6f41/image.jpeg" alt=""></p>
<p>개발자들의 축제 아니 it인들의 축제란 말이 어울릴만큼 인프랩 측에서 준비한 것도 많았고 
세션이 끝난 후 발표자분들과 질의응답도 아주 좋았고
이번에도 서로 다른 여러 도메인 지식과 많은 인사이트를 얻고 돌아올 수 있었다.</p>
<p>이런 컨퍼런스 한번 참석하고 오면 피가되고 살이 되는 지식도 지식이지만 더 열심히 해야겠다는 동기부여가 된다는게 가장 큰 장점인 것 같다.
내년에도 또 인프콘에 참석할 수 있기를...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프콘24 패자부활전 도전!]]></title>
            <link>https://velog.io/@ssol_916/%EC%9D%B8%ED%94%84%EC%BD%9824-%ED%8C%A8%EC%9E%90%EB%B6%80%ED%99%9C%EC%A0%84-%EB%8F%84%EC%A0%84</link>
            <guid>https://velog.io/@ssol_916/%EC%9D%B8%ED%94%84%EC%BD%9824-%ED%8C%A8%EC%9E%90%EB%B6%80%ED%99%9C%EC%A0%84-%EB%8F%84%EC%A0%84</guid>
            <pubDate>Fri, 12 Jul 2024 08:15:12 GMT</pubDate>
            <description><![CDATA[<p>인프런에서 인프콘24 탈락자들을 위해 마지막 <a href="https://www.inflearn.com/pages/infcon-2024-rallit-hub">패자부활전</a>을 준비한 듯 하다.</p>
<p>그래서 마지막 기회를 잡기 위해 패자부활전에 참가해본다.</p>
<p>1회, 2회 인프콘 모두 다녀온 뒤 엄청난 동기부여가 되어서 그 한해 열심히 공부하고 회사에서 퍼포먼스를 낼 수 있었다.
그러니까 이번에도 나 인프콘 보내줘!!!</p>
<p><a href="https://www.rallit.com/resumes/46957@deunsol916/%EB%B0%95%EB%93%A0%EC%86%94?theme=STANDARD">https://www.rallit.com/resumes/46957@deunsol916/%EB%B0%95%EB%93%A0%EC%86%94?theme=STANDARD</a></p>
<p>나도 인프콘 갈꺼야</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/2f8479a6-d30e-4b58-badd-e151b583253a/image.png" alt=""></p>
<hr>
<p>와 패자부활 성공!
인프콘24 가즈아!!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인프콘24 가즈아!!!]]></title>
            <link>https://velog.io/@ssol_916/%EC%9D%B8%ED%94%84%EC%BD%98-3%EC%97%B0%EC%86%8D-%EC%B0%B8%EA%B0%80-%EA%B0%80%EC%A6%88%EC%95%84</link>
            <guid>https://velog.io/@ssol_916/%EC%9D%B8%ED%94%84%EC%BD%98-3%EC%97%B0%EC%86%8D-%EC%B0%B8%EA%B0%80-%EA%B0%80%EC%A6%88%EC%95%84</guid>
            <pubDate>Thu, 04 Jul 2024 00:13:10 GMT</pubDate>
            <description><![CDATA[<p>운이 좋아 22년 1회 인프콘, 23년 2회 인프콘 모두 참석해 많은 세션과 네트워킹으로 개발 및 커리어에 대한 인사이트를 많이 얻어갈 수 있었다.</p>
<p>올해에도 열리는 인프콘24. 세션 스케줄을 보니 이번에도 쟁쟁한 분들의 퀄리티 높은 경험담, 꿀팁을 들을 수 있는 세션들이 많아보여 기대 중이다.</p>
<p>개발자가 되고나서 여러 컨퍼런스를 다녀봤지만 가장 주니어가 이해하기 쉬운, 주니어가 갈망할만한 주제가 많이 나오는게 인프콘이더라. </p>
<p>그래서 이번 인프콘에도 참석할 기회를 잡고자 인프콘 SNS 공유 이벤트에 참가하고자 글을 올린다.🤪
이번 인프콘은 개발 뿐만 아니라 PM/PO, 디자인, DevOps 세션도 추가되어 it 전반을 아우르는 축제가 될 것으로 보인다.</p>
<p>이번 인프콘도 듣고 싶은 세션 스케줄을 보면서 시간표를 짜보니 어느새 헤르미온느 시간표가...</p>
<p>it 업계인이라면 그리고 특히 열정있는 주니어라면 당신도 한번 인프콘에 참석해보는 것이 어떨까?</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/2b493da7-3992-464b-9384-191ac022981a/image.png" alt=""></p>
<p><a href="https://www.inflearn.com/conf/infcon-2024/share?year=2024&amp;id=736529&amp;hash=deunsol916%40b6a6c441&amp;name=Sol+Park">https://www.inflearn.com/conf/infcon-2024/share?year=2024&amp;id=736529&amp;hash=deunsol916%40b6a6c441&amp;name=Sol+Park</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis와 SSE를 이용한 실시간 알림 구현]]></title>
            <link>https://velog.io/@ssol_916/Redis%EC%99%80-SSE%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC</link>
            <guid>https://velog.io/@ssol_916/Redis%EC%99%80-SSE%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC</guid>
            <pubDate>Tue, 25 Jun 2024 08:29:08 GMT</pubDate>
            <description><![CDATA[<p>최근 개발한 요구사항 중 다음과 같은 기능을 구현해야 했다.</p>
<blockquote>
</blockquote>
<p>일반 유저만 글 작성이 가능한 특정 비밀 게시판에서 글 작성자가 본인 글에 댓글을 달 경우, 해당 유저와 관련된 모든 관리자 롤에게 댓글 등록 알림이 가야 한다. 반대로, 관리자가 댓글을 달 경우 글 작성자인 일반 유저에게 댓글 등록 알림이 가야 한다. 
또한, 로그인하지 않은 동안 달린 댓글 알림을 확인할 수 있도록 현재 날짜로부터 3일 전까지의 댓글 목록을 조회하고, 읽음/안읽음 표시도 할 수 있어야 한다.</p>
<p>이 요구사항을 받자마자 어떻게 구현하면 좋을까 고민을 해보았다.</p>
<h1 id="요구사항-분석">요구사항 분석</h1>
<ol>
<li>일반 유저가 댓글을 달 때: 글 작성자 본인이 댓글을 달 경우 관련된 모든 관리자 롤에게 알림을 보내야 한다.</li>
<li>관리자가 댓글을 달 때: 글 작성자인 일반 유저에게 알림을 보내야 한다.</li>
<li>로그인하지 않은 동안의 알림: 현재 날짜로부터 3일 전까지의 댓글 알림 목록을 조회하고, 읽음/안읽음 상태를 관리해야 한다.</li>
</ol>
<h1 id="기술-스택-선택">기술 스택 선택</h1>
<p>실시간 알림을 구현하기 위해 SSE(Server-Sent Events)를 사용하기로 했다. 이유는 다음과 같다:</p>
<pre><code>•    실시간으로 서버에서 클라이언트로 데이터를 푸시할 수 있다.
•    WebSocket보다 구현이 간단하고 HTTP 프로토콜을 사용하기 때문에 방화벽 및 프록시와의 호환성이 좋다.</code></pre><p>그리고 일반적으로 실시간 알림을 구현할 때는 카프카(Kafka)와 같은 메시지 큐를 사용하는 경우가 많다. 하지만 이번 프로젝트에서는 Redis Pub/Sub을 선택했다. 그 이유는 다음과 같다:</p>
<pre><code>1.    간편한 설정과 사용:
•    Redis는 설정이 간편하고 사용하기 쉬워서 빠르게 개발을 시작할 수 있다.
•    Pub/Sub 기능을 활용하면 별도의 메시지 브로커를 설정하지 않아도 된다.
2.    성능:
•    Redis는 메모리 기반의 저장소로, 매우 빠른 성능을 제공한다.
•    Pub/Sub 메시징 패턴은 높은 처리량과 낮은 지연 시간을 필요로 하는 실시간 알림에 적합하다.
3.    단순성:
•    복잡한 메시지 처리 로직이 필요 없는 경우, Redis Pub/Sub은 간단한 구조로 메시지를 주고받을 수 있다.
•    메시지 큐를 별도로 관리하지 않고도 쉽게 실시간 알림 시스템을 구축할 수 있다.</code></pre><p>하지만, Redis Pub/Sub을 선택함으로써 몇 가지 단점도 존재한다</p>
<pre><code>1.    내구성 부족:
•    Redis Pub/Sub은 메시지를 메모리에 저장하기 때문에 서버가 재시작되거나 장애가 발생하면 메시지가 손실될 수 있다.
•    반면 카프카는 메시지를 디스크에 저장하여 내구성을 제공한다.
2.    스케일링 이슈:
•    Redis는 단일 스레드 모델이기 때문에 높은 부하 상황에서 성능이 저하될 수 있다.
•    카프카는 분산 시스템으로 설계되어 있어, 고가용성과 수평적 확장이 용이하다.
3.    복잡한 메시지 처리:
•    Redis Pub/Sub은 단순한 메시지 브로커로, 메시지 재처리, 순서 보장 등의 기능이 부족하다.
•    카프카는 복잡한 메시지 처리, 순서 보장, 메시지 재처리 등을 지원한다.</code></pre><p>현재 프로젝트에서는 이러한 단점들이 큰 문제가 되지 않기 때문에 Redis Pub/Sub 선택에도 한몫했다. 메시지 손실이 발생하더라도 서비스에 치명적인 영향을 미치지 않으며, 시스템의 부하가 상대적으로 낮기 때문에 Redis의 단일 스레드 모델로도 충분히 감당할 수 있다. 또한, 복잡한 메시지 처리 로직이 필요하지 않아서 Redis의 간단한 구조가 오히려 개발과 유지보수에 유리했다.</p>
<h1 id="redis와-sse를-이용한-알림-구현">Redis와 SSE를 이용한 알림 구현</h1>
<h2 id="redis-config">Redis Config</h2>
<p>Redis Pub/Sub을 이용한 실시간 알림을 구현하기 위해 다음과 같이 Redis 설정을 진행했다.</p>
<pre><code class="language-java">@ConditionalOnProperty(name = &quot;spring.redis.enabled&quot;, havingValue = &quot;true&quot;, matchIfMissing = true)
@Configuration
public class RedisConfig {

    @Bean
    public RedisMessageListenerContainer redisContainer(
            RedisConnectionFactory connectionFactory,
            RedisSubscriber redisSubscriber
    ) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener((message, pattern) -&gt;
                        redisSubscriber.onMessage(new String(message.getChannel()), new String(message.getBody())),
                new PatternTopic(&quot;commentNotification&quot;)
        );
        return container;
    }

    @Bean
    public RedisTemplate&lt;String, Object&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate&lt;String, Object&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}</code></pre>
<ul>
<li>RedisMessageListenerContainer: Redis 메시지 리스너 컨테이너를 설정하여 특정 채널의 메시지를 구독하고, 메시지가 수신되면 RedisSubscriber의 onMessage 메서드를 호출하도록 구성했다.</li>
<li>RedisTemplate: Redis와의 상호작용을 위한 템플릿을 설정했다. 키는 StringRedisSerializer를 사용하고, 값은 GenericJackson2JsonRedisSerializer를 사용하여 직렬화 및 역직렬화한다.</li>
</ul>
<h2 id="redis-publisher-서비스">Redis publisher 서비스</h2>
<p>실시간 알림 기능을 위해 메시지를 퍼블리시하고, 알림 데이터를 Redis에 저장하는 RedisPublisher 클래스를 구현했다.</p>
<pre><code class="language-java">@Slf4j
@Service
public class RedisPublisher {

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

    public RedisPublisher(RedisTemplate&lt;String, Object&gt; redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    public void publish(String channel, Object message) {
        log.info(&quot;Publishing message to channel: [{}] at time: {} with message: {}&quot;, channel, Instant.now(), message);
        redisTemplate.convertAndSend(channel, message);
        log.info(&quot;Published message to channel: [{}] at time: {} with message: {}&quot;, channel, Instant.now(), message);
    }

    public void saveNotificationWithTTL(String key, Notification notification, long ttl, TimeUnit timeUnit) {
        try {
            String notificationJson = objectMapper.writeValueAsString(notification);
            redisTemplate.opsForValue().set(key, notificationJson, ttl, timeUnit);
            log.debug(&quot;Saved notification with key: {} and TTL: {} {}&quot;, key, ttl, timeUnit);
        } catch (Exception e) {
            log.error(&quot;Error saving notification with key: {} and TTL: {} {}&quot;, key, ttl, timeUnit, e);
        }
    }
}
</code></pre>
<ul>
<li>publish: 주어진 메시지를 특정 채널에 퍼블리시한다. Redis의 convertAndSend 메서드를 사용하여 메시지를 전송한다.</li>
<li>saveNotificationWithTTL: 알림 데이터를 JSON 형식으로 직렬화하여 Redis에 저장한다. 이 메서드는 TTL(Time-To-Live)을 설정하여 데이터가 일정 시간 후 자동으로 삭제되도록 한다.</li>
</ul>
<h2 id="redis-subscriber-서비스">Redis subscriber 서비스</h2>
<p>이 클래스는 Redis로부터 메시지를 수신하고, 이를 연결된 클라이언트들에게 실시간으로 전달하는 역할을 한다.</p>
<pre><code class="language-java">@Slf4j
@Service
public class RedisSubscriber {

    private final RedisTemplate&lt;String, Object&gt; redisTemplate;
    private final ObjectMapper objectMapper;
    private final Map&lt;String, List&lt;SseEmitter&gt;&gt; emitters = new ConcurrentHashMap&lt;&gt;();
    private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10);

    public RedisSubscriber(
            RedisTemplate&lt;String, Object&gt; redisTemplate,
            ObjectMapper objectMapper
    ) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    public void onMessage(String channel, String message) {
        log.info(&quot;Received message from channel: [{}] at time: {} with message: {}&quot;, channel, Instant.now(), message);

        String cleanedMessage = message.replace(&quot;\&quot;&quot;, &quot;&quot;);
        log.debug(&quot;Cleaned message: {}&quot;, cleanedMessage);

        processMessage(cleanedMessage, 5); // 최대 5번 재시도
    }

    private void processMessage(String key, int retriesLeft) {
        scheduledExecutorService.submit(() -&gt; {
            try {
                String notificationJson = null;
                for (int attempt = 0; attempt &lt; retriesLeft; attempt++) {
                    notificationJson = (String) redisTemplate.opsForValue().get(key);
                    if (notificationJson != null) {
                        break;
                    }
                    log.debug(&quot;Retrying to get key: {}. Attempt: {}&quot;, key, attempt + 1);
                    try {
                        Thread.sleep(200); // 200ms 대기
                    } catch (InterruptedException e) {
                        log.error(&quot;InterruptedException during sleep&quot;, e);
                        Thread.currentThread().interrupt();
                        return;
                    }
                }

                if (notificationJson != null) {
                    Notification notification = objectMapper.readValue(notificationJson, Notification.class);
                    String userId = notification.getReceiverId();
                    log.debug(&quot;Parsed notification: {} for user: {}&quot;, notification, userId);

                    sendNotificationToEmitters(userId, notification);
                } else {
                    log.warn(&quot;No notification found in Redis for key: {} after maximum retries&quot;, key);
                }
            } catch (Exception e) {
                log.error(&quot;Exception during message processing for key: {}&quot;, key, e);
            }
        });
    }

    private void sendNotificationToEmitters(String userId, Notification notification) {
        List&lt;SseEmitter&gt; userEmitters = emitters.get(userId);
        if (userEmitters != null &amp;&amp; !userEmitters.isEmpty()) {
            List&lt;SseEmitter&gt; deadEmitters = new ArrayList&lt;&gt;();
            for (SseEmitter emitter : userEmitters) {
                try {
                    emitter.send(SseEmitter.event()
                            .name(&quot;newComment&quot;)
                            .data(notification)
                    );
                    log.info(&quot;Sent SSE to user: {} with notification: {} at time: {}&quot;, userId, notification, Instant.now());
                } catch (IOException e) {
                    log.error(&quot;Error sending SSE to user: {} with message: {}&quot;, userId, e.getMessage());
                    deadEmitters.add(emitter);
                }
            }
            userEmitters.removeAll(deadEmitters); // dead emitters 제거
        } else {
            log.warn(&quot;No emitters found for user: {}&quot;, userId);
        }
    }

    public void addEmitter(String userId, SseEmitter emitter) {
        emitters.computeIfAbsent(userId, k -&gt; new ArrayList&lt;&gt;()).add(emitter);
        log.info(&quot;Emitter added for user: {}&quot;, userId);

        emitter.onCompletion(() -&gt; {
            removeEmitter(userId, emitter);
            log.info(&quot;Emitter completed for user: {}&quot;, userId);
        });

        emitter.onTimeout(() -&gt; {
            removeEmitter(userId, emitter);
            log.info(&quot;Emitter timed out for user: {}&quot;, userId);
        });

        emitter.onError((Throwable t) -&gt; {
            removeEmitter(userId, emitter);
            log.error(&quot;Emitter error for user: {} with message: {}&quot;, userId, t.getMessage());
        });
    }

    public void removeEmitter(String userId, SseEmitter emitter) {
        List&lt;SseEmitter&gt; userEmitters = emitters.get(userId);
        if (userEmitters != null) {
            userEmitters.remove(emitter);
            if (userEmitters.isEmpty()) {
                emitters.remove(userId);
            }
        }
        log.info(&quot;Emitter removed for user: {}&quot;, userId);
    }
}</code></pre>
<ol>
<li>Redis 메시지 수신 (onMessage 메서드):
•    Redis 채널로부터 메시지를 수신하면 onMessage 메서드가 호출된다. 수신된 메시지는 로그에 기록되며, processMessage 메서드를 통해 처리된다.</li>
<li>메시지 처리 (processMessage 메서드):
•    메시지 키를 사용하여 Redis에서 메시지 내용을 최대 5번 재시도하면서 가져온다.
•    메시지가 정상적으로 가져와지면 이를 JSON으로 파싱하여 Notification 객체로 변환한다.
•    수신자 ID를 기반으로 해당 유저의 모든 SseEmitter에 메시지를 전송한다.
•    전송 중 오류가 발생한 SseEmitter는 제거된다.</li>
<li>SSE Emitter 추가 (addEmitter 메서드):
•    유저가 알림을 구독할 때 새로운 SseEmitter를 생성하고, 해당 유저의 Emitter 리스트에 추가한다.
•    Emitter가 완료되거나 타임아웃, 오류가 발생할 경우 자동으로 Emitter를 제거하는 콜백을 등록한다.</li>
<li>SSE Emitter 제거 (removeEmitter 메서드):
•    특정 유저의 Emitter 리스트에서 지정된 Emitter를 제거한다.
•    Emitter 리스트가 비어있으면 유저의 Emitter 리스트 자체를 제거한다.</li>
</ol>
<p>여기서 각 유저가 여러 클라이언트에서 동시에 SSE 연결을 유지할 수 있도록 하기 위해, ConcurrentHashMap을 사용해 유저별로 List<SseEmitter>를 관리하도록 했다.</p>
<h2 id="transactionsynchronization-인터페이스를-구현한-추상-클래스">TransactionSynchronization 인터페이스를 구현한 추상 클래스</h2>
<p>알림 발송을 댓글 저장 완료 후에 하더라도, API 응답이 가기 전에 알림이 발송될 수 있다. 이를 방지하고 API 응답이 클라이언트에 전송된 후에 알림이 발송되도록 하려면, 알림 발송을 비동기로 처리하되, API 응답이 완료된 후에 실행되도록 해야 한다. 이를 위해 Spring의 TransactionSynchronizationManager를 사용하여 트랜잭션이 완료된 후에 알림을 발송하도록 설정할 수 있다.</p>
<blockquote>
<p>TransactionSynchronization 인터페이스를 구현하여 트랜잭션 완료 후 작업을 수행할 수 있다.</p>
</blockquote>
<p>이 때 TransactionSynchronizationManager의 registerSynchronization 메서드를 사용하는 방법을 적용할 수 있는데 TransactionSynchronization 인터페이스의 모든 필수 오버라이드 메서드를 강제로 구현해야 한다.</p>
<p>하지만 불필요한 메서드를 구현하는 것을 피하는 방법이 있는데 바로 추상 클래스를 사용하는 방법이다. </p>
<p>  커스텀 추상 클래스를 만들어 TransactionSynchronization를 상속받아 필수 메서드를 구현하면, 커스텀 추상 클래스를 사용하는 곳에서는 불필요한 메서드를 매번 오버라이드하지 않고도 필요한 메서드만 구현할 수 있다. 이 방법을 사용하면 코드의 가독성이 높아지고 유지 보수가 쉬워진다.</p>
<pre><code class="language-java">public abstract class CustomTransactionSynchronization implements TransactionSynchronization {

    @Override
    public void suspend() {
        // No implementation needed
    }

    @Override
    public void resume() {
        // No implementation needed
    }

    @Override
    public void flush() {
        // No implementation needed
    }

    @Override
    public void beforeCommit(boolean readOnly) {
        // No implementation needed
    }

    @Override
    public void beforeCompletion() {
        // No implementation needed
    }

    @Override
    public void afterCompletion(int status) {
        // No implementation needed
    }
}</code></pre>
<ul>
<li>이 클래스는 트랜잭션의 특정 시점에 실행될 로직을 정의할 수 있도록 한다. afterCommit 메서드를 오버라이드하여 트랜잭션이 커밋된 후 실행할 작업을 정의할 수 있다.</li>
</ul>
<h2 id="댓글-서비스">댓글 서비스</h2>
<p>댓글을 저장하고, 트랜잭션 완료 후 알림을 발송하는 PostCommentServiceImpl 클래스를 구현했다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostCommentServiceImpl implements PostCommentService {

    private final PostRepository postRepository;
    private final PostCommentRepository postCommentRepository;
    private final NotificationService notificationService;

    @Transactional
    @Override
    public void saveComment(User user, CommentDto commentDto) {
        boolean isAdmin = Utils.isAdminRole(user.getType());
        postRepository.findPostBy(isAdmin, user.getUserId(), commentDto.getPostId())
                .orElseThrow(() -&gt; new UserDeniedException(&quot;not have permission to view posts.&quot;));
        Long savedCommentId = postCommentRepository.savePostComment(CommentDto.toEntity(commentDto));

        // 트랜잭션 완료 후 알림 발송
        TransactionSynchronizationManager.registerSynchronization(new CustomTransactionSynchronization() {
            @Override
            public void afterCommit() {
                notificationService.publishNotification(commentDto.getPostId(), savedCommentId, user);
            }
        });
    }

    // 그 외 댓글 서비스 로직...
}</code></pre>
<ul>
<li>saveComment: 댓글을 저장하고, 트랜잭션이 완료된 후 알림을 발송한다. 트랜잭션이 완료되면 afterCommit 메서드를 호출하여 publishNotification 메서드를 통해 알림을 발송한다.</li>
<li>트랜잭션이 실패하거나 롤백된 경우, 알림이 발송되지 않도록 보장할 수 있다.</li>
<li>트랜잭션이 성공적으로 완료된 후에 알림을 비동기로 처리함으로써, API 응답 시간이 길어지는 것을 방지한다.</li>
</ul>
<h2 id="실시간-알림-서비스">실시간 알림 서비스</h2>
<p>읽지 않은 댓글을 표현하기 위해, &#39;{수신 유저id}:{댓글id}&#39;를 키로 하는 데이터를 Redis에 저장하였다. 유저가 댓글을 읽으면 해당 키를 삭제하여 읽음 상태로 표시한다. 3일이 지나면 Redis에서 자동으로 값이 삭제되도록 TTL을 설정했다.</p>
<pre><code class="language-java">@Service
public class NotificationService {

      private final RedisPublisher redisPublisher;
    private final RedisSubscriber redisSubscriber;
    private final RedisTemplate&lt;String, Object&gt; redisTemplate;
    private final PostRepository postRepository;
    private final PostCommentRepository postCommentRepository;
    private final UserRepository userRepository;

    public InquiryNotificationService(
            RedisPublisher redisPublisher,
            RedisSubscriber redisSubscriber,
            RedisTemplate&lt;String, Object&gt; redisTemplate,
              PostRepository postRepository,
              PostCommentRepository postCommentRepository,
              UserRepository userRepository
    ) {
        this.redisPublisher = redisPublisher;
        this.redisSubscriber = redisSubscriber;
        this.redisTemplate = redisTemplate;
        this.postRepository = postRepository;
        this.postCommentRepository = postCommentRepository;
          this.userRepository = userRepository
    }

    public void publishNotification(Long postId, Long savedCommentId, Users user) {
        String senderId = user.getUserId();
        if (Utils.isAdminRole(user.getType())) {
            Users recipient = postJooqRepository.findAuthorByPostId(postId);
            publishEventToRedis(postId, savedCommentId, senderId, recipient.getUserId());
        } else {
            List&lt;User&gt; recipients = userRepository.findManagerByGroupId(user.getGroupId());
            for (Users recipient : recipients) {
                publishEventToRedis(postId, savedCommentId, senderId, recipient.getUserId());
            }
        }
    }

    public void publishEventToRedis(Long postId, Long savedCommentId, String senderId, String recipientId) {
        String notificationId = UUID.randomUUID().toString();
        Instant timestamp = Instant.now();

        Notification notification = new Notification(
                notificationId,
                postId,
                savedCommentId,
                senderId,
                recipientId,
                &quot;New comment added&quot;,
                timestamp
        );
        String notificationKey = recipientId + &quot;:&quot; + savedCommentId;

        redisPublisher.saveNotificationWithTTL(notificationKey, notification, 3, TimeUnit.DAYS);
        redisPublisher.publish(&quot;commentNotification&quot;, notificationKey);
    }

    public SseEmitter createEmitter(String userId) {
        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
        redisSubscriber.addEmitter(userId, emitter);

        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        executor.scheduleAtFixedRate(() -&gt; sendHeartbeat(userId, emitter, executor), 0, 15, TimeUnit.SECONDS);

        setEmitterCallbacks(userId, emitter, executor);
        return emitter;
    }

    private void sendHeartbeat(String userId, SseEmitter emitter, ScheduledExecutorService executor) {
        try {
            emitter.send(SseEmitter.event()
                    .name(&quot;heartbeat&quot;)
                    .data(&quot;heartbeat&quot;));
        } catch (IOException e) {
            log.warn(&quot;Error sending heartbeat, connection might be closed. Removing emitter and shutting down executor.&quot;, e);
            redisSubscriber.removeEmitter(userId, emitter);
            emitter.completeWithError(e);
            executor.shutdown();
        }
    }

    private void setEmitterCallbacks(String userId, SseEmitter emitter, ScheduledExecutorService executor) {
        // 클라이언트 연결 종료 시 emitter 제거
        emitter.onCompletion(() -&gt; {
            redisSubscriber.removeEmitter(userId, emitter);
            log.info(&quot;Emitter completed for user: {}&quot;, userId);
            executor.shutdown();
        });
        emitter.onTimeout(() -&gt; {
            redisSubscriber.removeEmitter(userId, emitter);
            log.info(&quot;Emitter timed out for user: {}&quot;, userId);
            executor.shutdown();
        });
        emitter.onError((Throwable t) -&gt; {
            redisSubscriber.removeEmitter(userId, emitter);
            log.error(&quot;Emitter error for user: {}&quot;, userId, t);
            executor.shutdown();
        });
    }

    public boolean isNotificationRead(String userId, Long postId) {
        List&lt;PostComment&gt; postComments = postCommentRepository.findPostCommentByPostId(postId);
        for (PostReply postComment : postComments) {
            // 게시글에 달린 댓글 id로 redis에 저장된 key가 있는지 확인
            String key = userId + &quot;:&quot; + postComment.getCommentId();
            Boolean existKey = redisTemplate.hasKey(key);
            if (Boolean.TRUE.equals(existKey)) {
                return false;  // 반복 중 하나라도 true이면 false 반환
            }
        }
        return true;
    }

    public void markAsCommentReadByPostId(String userId, Long postId) {
        List&lt;PostComment&gt; postComments = postCommentRepository.findPostCommentByPostId(postId);
        for (PostComment postComment : postComments) {
            String key = userId + &quot;:&quot; + postComment.getCommentId();
            redisTemplate.delete(key);
        }
    }
}</code></pre>
<ul>
<li>publishNotification: 댓글 작성자가 관리자 롤인지 일반 유저인지에 따라 알림을 발송한다. 관리자 롤인 경우 글 작성자에게만, 일반 유저인 경우 관련된 모든 관리자에게 알림을 발송한다.</li>
<li>publishEventToRedis: 알림 데이터를 생성하여 Redis에 저장하고, 특정 채널에 퍼블리시한다.</li>
<li>createEmitter: SSE Emitter를 생성하고, 주기적으로 heartbeat 메시지를 전송하여 연결을 유지한다.</li>
<li>sendHeartbeat: heartbeat 메시지를 전송하여 SSE 연결을 유지한다.</li>
<li>setEmitterCallbacks: Emitter의 콜백을 설정하여 완료, 타임아웃, 오류 시 Emitter를 제거한다.</li>
<li>isNotificationRead: 특정 유저가 특정 게시글에 달린 댓글을 읽었는지 여부를 확인한다.</li>
<li>markAsCommentReadByPostId: 특정 유저가 특정 게시글에 달린 모든 댓글을 읽음 상태로 표시한다.</li>
</ul>
<h2 id="엔트포인트">엔트포인트</h2>
<h3 id="sse-연결을-위한-엔드포인트">SSE 연결을 위한 엔드포인트</h3>
<p>SSE(Server-Sent Events)를 사용하여 클라이언트와 서버 간의 실시간 연결을 유지하기 위한 엔드포인트</p>
<pre><code class="language-java">@GetMapping(&quot;/notifications&quot;)
public SseEmitter notificationSubscribe(
        @AuthenticationPrincipal LoginUser loginUser
) {
    return notificationService.createEmitter(loginUser.getUser().getUserId());
}</code></pre>
<ul>
<li>클라이언트가 /notifications 엔드포인트에 접속하면 서버는 새로운 SseEmitter를 생성하고, 이를 redisSubscriber에 추가하여 관리한다.</li>
<li>생성된 SseEmitter는 15초마다 heartbeat 메시지를 전송하여 연결을 유지한다.</li>
<li>클라이언트와의 연결이 완료되거나 타임아웃, 오류가 발생하면 해당 SseEmitter는 redisSubscriber에서 제거되며, 이를 통해 불필요한 자원이 사용되지 않도록 관리한다.</li>
</ul>
<h3 id="댓글-등록-엔드포인트">댓글 등록 엔드포인트</h3>
<p>댓글 작성 시 실시간 알림을 전송하기 위한 엔드포인트</p>
<pre><code class="language-java">@PostMapping(&quot;/{postId}/comments&quot;)
public ResponseEntity&lt;Void&gt; saveInquiryComment(
        @PathVariable Long postId,
        @AuthenticationPrincipal LoginUser loginUser,
        @RequestBody @Valid ReplyRequest replyRequest,
        BindingResult bindingResult
) {
    Long savedCommentId = postCommentService.saveComment(
            loginUser.getUser(),
            ReplyDto.toDto(loginUser.getUser(), postId, replyRequest)
    );

    URI location = URI.create(&quot;/users/posts/&quot; + postId);
    return ResponseEntity.created(location).build();
}</code></pre>
<p>이제 성능 개선과 리팩토링을 진행해봐야지.🏃</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링에서 property 사용할 때 @Value 사용을 추천하지 않는 이유]]></title>
            <link>https://velog.io/@ssol_916/%EC%8A%A4%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C-property-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-Value-%EC%82%AC%EC%9A%A9%EC%9D%84-%EC%B6%94%EC%B2%9C%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@ssol_916/%EC%8A%A4%ED%94%84%EB%A7%81%EC%97%90%EC%84%9C-property-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-Value-%EC%82%AC%EC%9A%A9%EC%9D%84-%EC%B6%94%EC%B2%9C%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 25 Mar 2024 04:25:44 GMT</pubDate>
            <description><![CDATA[<h1 id="value-사용을-추천하지-않는-이유">@Value 사용을 추천하지 않는 이유</h1>
<p><code>@Value</code> 어노테이션을 사용하면 쉽게 프로퍼티 값을 가져올 수 있다.</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class EmailService {

    @Value(&quot;${email-address.business-manager}&quot;)
    private String businessManagerEmail;

    // ......
}</code></pre>
<p>하지만 이렇게 사용하는 것을 추천하지 않는데 이 접근법의 단점으로는</p>
<ol>
<li>외부파일 분리가 어렵다. @Value는 애플리케이션 코드 내에서 직접 설정 값을 정의하고 사용하기에 외부 파일을 통해 설정 값을 변경하려면 코드를 수정해야 한다.</li>
<li>테스트에서 주입받기 힘들어진다.</li>
<li>유연성이 떨어지고 오류가 발생하기 쉬워진다.</li>
</ol>
<p>복잡한 표현식이나 다중 프로퍼티를 다루기 어렵습니다. 이로 인해 특정한 프로퍼티 값을 계산하는 등의 작업을 처리하기에는 한계가 있다.</p>
<p>그리고 테스트 가능성을 망치게되는데, 프로퍼티들은 스프링에 의해 자동 주입될 것이므로 먼저 실행되는 테스트에 스프링 컨텍스트가 필요해진다. (이것은 단위테스트를 느리게 만든다.)
그리고 다른 프로퍼티를 사용해 테스트를 사용하려면? 일반적으로 테스트를 위해 별도의 application 프로퍼티를 가질 수 있지만 지저분해질 수 있다.
이를 해결하기 위해 @Autowired 어노테이션을 사용해서 하나 씩 생성자 주입하는 방법이 있지만 생성자가 길어지고 지저분해지게 된다.</p>
<p>마지막으로 @Value는 접근 방식의 유연성이 떨어지고 데이터 검증을 위한 지원 기능이 내장되어 있지 않다는 점이다.</p>
<h1 id="대신-configurationproperties를-사용하자">대신 <code>@ConfigurationProperties</code>를 사용하자.</h1>
<p>그럼 뭘 사용하면 될까?
<code>@ConfigurationProperties</code>는 프로퍼티를 분리하고 외부화 하는 기능을 제공한다. </p>
<pre><code class="language-yaml">email-address:
  education-manager: apple@gmail.com
  business-manager: banana@gmail.com
  error-recipient: watermelon@gmail.com</code></pre>
<pre><code class="language-java">@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = &quot;email-address&quot;)
@Configuration
public class EmailPropertiesConfig {

    @NotBlank
    private String educationManager;

    @NotBlank
    private String businessManager;

    @NotBlank
    private String errorRecipient;
}</code></pre>
<pre><code class="language-java">@Slf4j
@Service
public class EmailService {

    private final GroupRepository groupRepository;
    private final JavaMailSender mailSender;
    private final EmailPropertiesConfig emailProperties;

    public EmailService(GroupRepository groupRepository, JavaMailSender mailSender, EmailPropertiesConfig emailProperties) {
        this.groupRepository = groupRepository;
        this.mailSender = mailSender;
        this.emailProperties = emailProperties;
    }

    // ...

}</code></pre>
<p>이렇게 configuration으로 분리해서 프로퍼티를 한 곳에서 POJO 클래스로 그룹화하여 관리할 수 있으며, 타입 안정성과 IDE 지원 등의 이점을 누릴 수 있다.</p>
<h3 id="유효성-검사-가능">유효성 검사 가능</h3>
<p><code>@Validated</code> 어노테이션을 사용해서 유효성 검사를 적용할 수 있다. 프로퍼티가 로드되기 전에 필수 형식을 준수하는지 확인할 수 있다.유효성 검사를 진행할 수 있다. 이 유효성 검사를 충족하지 못할 경우 해당 빈이 생성되고 주입될 때 유효성 검사를 실패하게 되어 애플리케이션이 실행 중지 된다.</p>
<blockquote>
<p>만약 <code>@Value</code>로 프로퍼티를 주입받고 있는데 해당 프로퍼티의 값이 null이거나 empty이면 애플리케이션에서 해당 프로퍼티를 받아서 돌아가는 메서드가 실행될 때 NPE가 발생하게 된다. 이를 방어하기 위해 <strong><code>@Value</code></strong>로 주입받은 필드를 사용하기 전에 null 체크를 수행해야 하는데 @Value가 많아질 수록 코드도 길어지게 된다…</p>
</blockquote>
<h3 id="프로퍼티를-가져와서-설정하는-시점은">프로퍼티를 가져와서 설정하는 시점은?</h3>
<p><code>@ConfigurationProperties</code> 인스턴스는 스프링 컨텍스트에 로드될 때 생성되며 이는 일반적으로 애플리케이션이 시작 시점에 해당된다. 프로퍼티 값은 이 인스턴스에 주입될 때 읽힌다. 스프링은 해당 클래스의 필드와 프로퍼티 파일에서 매핑된 프로퍼티 값을 가져와 주입한다. 따라서 프로퍼티를 가져오는 시점은 스프링 애플리케이션이 시작되고 해당 빈이 생성되고 주입될 때 이다.</p>
<p>따라서 유효성 검사도 이 시점에 진행되는데 만약 필드가 유효성 검사를 통과하지 못하면, 스프링은 애플리케이션 시작 시점에서 예외를 발생시킨다. (애플리케이션을 시작하는 과정에서 발생하므로 런타임 예외)</p>
<p><code>@ConfigurationProperties</code>에서 발생한 예외는 애플리케이션 실행을 중지시키는 런타임 예외로, 이 예외는 주로 애플리케이션의 초기화 과정에서 필수적인 설정값을 로드하고 검증하는데 사용된다.</p>
<br>
제목에 @Value 사용을 추천하지 않는다고는 했지만 무조건 적으로 @Value를 사용하지 말라는 것은 아니다. 단순하게 하나의 값을 가져와 사용할 때에는 간단하고 직관적이게 사용할 수 있으므로 동적으로 설정을 변경할 필요가 없는 경우에는 @Value 값에 대한 null 체크 같은 검증만 하고 사용하는 것이 적합할 수 있다. 하지만 대부분의 경우에는 @ConfigurationProperties의 유효성 검증이 아주 유용하므로 해당 방법으로 사용하는 것을 추천한다.]]></description>
        </item>
        <item>
            <title><![CDATA[애플실리콘에서 amd64도커 컨테이너 실행]]></title>
            <link>https://velog.io/@ssol_916/%EC%95%A0%ED%94%8C-%EC%8B%A4%EB%A6%AC%EC%BD%98%EC%97%90%EC%84%9C-amd64%EB%8F%84%EC%BB%A4-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%8B%A4%ED%96%89</link>
            <guid>https://velog.io/@ssol_916/%EC%95%A0%ED%94%8C-%EC%8B%A4%EB%A6%AC%EC%BD%98%EC%97%90%EC%84%9C-amd64%EB%8F%84%EC%BB%A4-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%8B%A4%ED%96%89</guid>
            <pubDate>Tue, 16 Jan 2024 00:50:30 GMT</pubDate>
            <description><![CDATA[<p>MySQL HA와 MaxScale을 공부하던 중 어이없는 실수를 해서 그것을 반성하기 위해 기록을 남긴다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/fb77a400-3af3-437a-80db-331304f95ac1/image.jpeg" alt="">
SRE 공부 재밌숴요...
<br></p>
<hr>

<p>요즘은 애플실리콘이 공개된지도 꽤 되었고 개발자들 사이에선 많이 대중화가 되었기 때문에 arm64 아키텍처 이미지가 많이 나오고 있다. 최근 도커 이미지들을 보면 인텔 사용자와 애플실리콘 사용자를 위해 arm64와 amd64 둘 다 이미지가 나오고 있고 이미지 pull 시에는 해당 운영체제 아키텍처에 맞는 이미지로 알아서 받아지게 된다.</p>
<p>하지만 내가 테스트하는데 사용한 MySQL 5.7은 현재 MySQL이 8버전 까지 나온 것을 생각하면 꽤 오래된 버전이다. 그렇기 때문에 도커 허브에서 5.7 관련 오피셜 이미지를 뒤져보면 죄다 amd64 아키텍처 밖에 없다.</p>
<p>나는 이것을 망각하고 당연히 되겠거니 하면서 애플실리콘에다 이걸 그대로 받아서 그냥 실행해버린 것이지...</p>
<p>뭐 결과는 당연히 불안전 실행으로 제대로 된 MySQL HA 환경을 구성할 수 없었다.
컨테이너는 실행이 되지만 접속하려하면 플랫폼 어쩌구 메시지가 나오면서 안되는 걸 보니 그때 딱 떠오르더라. &#39;이거 운영체제 아키텍처 문제인가??&#39; 그래서 도커 허브에서 내가 받은 이미지를 체크해보니 amd64 였고 이것을 로제타2로 실행해 테스트를 마무리 할 수 있었다.
<img src="https://velog.velcdn.com/images/ssol_916/post/c077e6cd-08c7-4396-ac78-355ddb9941a6/image.png" alt=""></p>
<p>그래서 이번 포스트에선 애플실리콘에서 amd64 이미지를 로제타로 실행하는 방법을 다시 남기려한다.</p>
<pre><code class="language-shell"># Create containers
docker run --platform=linux/x86_64 -d --rm --name=master --net=replicanet --hostname=master \
   -e MYSQL_ROOT_PASSWORD=mypass \
  mysql:5.7 \
  --server-id=1 \
  --log-bin=&#39;mysql-bin-1.log&#39;

docker run --platform=linux/x86_64 -d --rm --name=slave --net=replicanet --hostname=slave \
   -e MYSQL_ROOT_PASSWORD=mypass \
  mysql:5.7 \
  --server-id=2

# Configure Master
docker exec -it master mysql -uroot -pmypass \
  -e &quot;CREATE USER &#39;repl&#39;@&#39;%&#39; IDENTIFIED BY &#39;slavepass&#39;;&quot; \
  -e &quot;GRANT REPLICATION SLAVE ON *.* TO &#39;repl&#39;@&#39;%&#39;;&quot; \
  -e &quot;SHOW MASTER STATUS;&quot;

# Configure Slave
docker exec -it slave mysql -uroot -pmypass \
  -e &quot;CHANGE MASTER TO MASTER_HOST=&#39;master&#39;, MASTER_USER=&#39;repl&#39;, \
    MASTER_PASSWORD=&#39;slavepass&#39;, MASTER_LOG_FILE=&#39;mysql-bin-1.000003&#39;;&quot;</code></pre>
<p>내가 도커로 MaxScale을 연습할 때 사용한 명령어 중 일부이다.
여기서 amd64를 로제타2로 변환실행하기 위해서는 run 옵션 앞에 <strong>--platform=linux/x86_64</strong>를 붙여주면 된다. 간단하지? 하지만 이것도 만능은 아니기에 성능저하나 호환성 이슈가 발생할 수 있으므로 가급적이만 운영체제에 맞는 아키텍처 이미지를 사용하는게 좋다.</p>
<p>어이없는 실수 반성 끝</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT 인증 방식의 보안 - 내가 쿠키를 사용하도록 변경한 이유]]></title>
            <link>https://velog.io/@ssol_916/JWT-%ED%86%A0%ED%81%B0-%EC%9D%B8%EC%A6%9D-%EB%B0%A9%EC%8B%9D%EC%9D%98-%EB%B3%B4%EC%95%88</link>
            <guid>https://velog.io/@ssol_916/JWT-%ED%86%A0%ED%81%B0-%EC%9D%B8%EC%A6%9D-%EB%B0%A9%EC%8B%9D%EC%9D%98-%EB%B3%B4%EC%95%88</guid>
            <pubDate>Tue, 07 Nov 2023 08:34:30 GMT</pubDate>
            <description><![CDATA[<p>내가 현 회사에서 JWT 인증 보안을 위해 고민, 개선한 내용을 기록해 보겠다.
기존 회사의 서비스는 JWT로 인증 처리하고 있었는데 로그인 시 헤더로 토큰을 발급해주며, 클라이언트는 로컬 스토리지에 해당 토큰을 저장해서 보관, 사용하고 있었다.
대충 뭐가 문제인지 감이 올 것이다.
<br></p>
<p>JWT를 헤더로 전송하고 로컬 스토리지에 저장하는 방식은 XSS에 취약하다. </p>
<p>헤더 때문에 취약하다기 보다는 프론트가 헤더로 전달 받으면 저장할 장소가 로컬 스토리지 말곤 마땅한 곳이 없기 때문이다. 로컬/세션 스토리지는 js 코드로 접근이 가능해서 js 코드 삽입으로 JWT 탈취를 할 수 있다. 세션 스토리지나 리액트 상태관리 라이브러리는 브라우저를 닫으면 날아가기 때문에 사용자 경험에 악영향을 준다.</p>
<p>XSS 위협은 과거 세션을 사용한 인증 방식이 주를 이룰 땐 세션 ID를 토큰으로 전달하기 때문에 HttpOnly 속성으로 간단하게 막을 수 있어 거의 사라진 위협인데, MSA로 인해 stateless 한 JWT로 대세가 변경되다 보니 다시 과거의 취약점 위협이 증가하는 것이 아이러니.</p>
<h2 id="xss를-방어하기-위한-쿠키">XSS를 방어하기 위한 쿠키?</h2>
<p>이 XSS 공격을 막을 수 있는 방법이 js의 접근을 차단하는 것인데 이 방법 중 하나가 바로 HttpOnly 속성을 설정한 쿠키에 토큰을 보관하는 것이다.</p>
<p>그런데 문제가 하나 있다. JWT 인증 방식의 로그인 유지를 위한 특성 상 엑세스 토큰과 리프레시 토큰 이렇게 2가지 토큰으로 이루어져 있다는 것. 리프레시 토큰은 애초에 js로 접근하게 할 필요가 없기 때문에 HttpOnly 속성을 사용하면 된다.</p>
<p>하지만 보통 엑세스 토큰은 프론트에서 화면 표시에 필요한 유저의 간단한 정보를 담고 있어서 js로 꺼내와서 파싱을 할 수 있어야 하기 때문에 엑세스 토큰은 어쩔 수 없이 HttpOnly 속성을 사용하지 못한다. 그래서 XSS에 여전히 노출이 되게 되는데 공격을 통한 탈취가 되어도 피해를 최소화 할 수 있도록 엑세스 토큰의 유효 시간을 짧게 주는 방법으로 방어를 하겠지만 충분치 않다.</p>
<h2 id="엑세스-토큰을-위한-추가-보안-조치">엑세스 토큰을 위한 추가 보안 조치</h2>
<p>이 보안 위협을 어느정도 해결하기 위해서는 JWT의 특징인 stateless를 어느 정도 버리는 수밖에 없다. 약간 stateful 하게 만들어줘야 한다. 그 방법 중 하나로 Redis 같은 인메모리 DB를 사용해서 다중 접속이 가능한 서비스라면 블랙리스트를, 다중 접속이 불가능한 서비스라면 화이트리스트를 운영해 서버에서 토큰에 대한 제어를 할 수 있게 구현하는 것이다. 이렇게 되면 토큰에 대한 유효성 검증 로직이 서버에 추가되어야 하는데, 이로 인해 서버의 부하가 약간은 증가하지만 전통적인 풀 stateful 보다는 가볍기 때문에 장점이 있다. </p>
<h2 id="쿠키의-약점과-보완-방법">쿠키의 약점과 보완 방법</h2>
<p>하지만 쿠키를 사용하면 장점만 있느냐? 쿠키를 사용하면 XSS 공격으로 리프레시 토큰 탈취 방어는 되지만 쿠키는 브라우저가 자동으로 요청과 함께 보내버리기 때문에 CSRF 공격에 취약해지게 된다. </p>
<p>이 CSRF 공격을 방어하기 위해선 요청의 출처를 명확히 해주어야 한다. 그 방법 중 하나가 <strong>쿠키의 SameSite 속성</strong>이다. 브라우저가 다른 사이트의 요청과 함께 전송하지 않도록 해 CSRF 공격을 방지하는데 도움이 된다.</p>
<p>하지만 최신 브라우저가 아닐 경우에는 SameSite 속성을 지원하지 않는 경우도 있다. 모든 유저가 최신 브라우저를 사용하는 것은 아니기 때문에 <strong>CSRF 토큰을 함께 사용하는 것이 안전</strong>하다.</p>
<p>당연히 Https 프로토콜에서만 전송될 수 있도록 <strong>secure 속성</strong>도 넣어주면 더 좋겠지?</p>
<h2 id="stateless-하게-csrf-토큰-사용하기">Stateless 하게 CSRF 토큰 사용하기</h2>
<p>세션 인증 방식에선 CSRF 토큰을 세션 ID와 동일하게 세션 메모리에 저장했다. 하지만 JWT를 사용한다는 것은 stateless 하길 원한다는 것. CSRF 토큰도 최근 유행을 따라 기존 stateless하게 운영할 수 있는 방법이 있다.</p>
<p>서버에선 생성한 CSRF 토큰을 쿠키에 넣어서 보내주고 클라이언트는 서버에 요청을 할때마다 쿠키에서 꺼내서 요청 헤더를 통해 서버로 전송하게 하는 것이다. 그리고 서버에서 쿠키로 들어온 CSRF 토큰과 요청 헤더로 온 CSRF 토큰을 비교해서 유효성 검사를 하면 된다.</p>
<blockquote>
</blockquote>
<p>☝🏻 참고로 CSRF 토큰은 모든 요청에서 무조건 보내 검증해도 되긴 하지만, 주로 POST, PUT, DELETE 등 데이터를 변경하는 요청에만 보내서 검증하도록 해도 충분하다.</p>
<h2 id="csrf-토큰-쿠키를-편하고-안전하게-사용하기">CSRF 토큰 쿠키를 편하고 안전하게 사용하기</h2>
<p>바로 csrf 쿠키를 별도로 관리하는 것보다는 엑세스 토큰과 라이프 사이클을 일치시켜 사용하는 것이다.</p>
<ol>
<li><strong>관리의 편의성</strong>: CSRF 토큰과 엑세스 토큰의 라이프 사이클을 일치시키면 별도의 CSRF 토큰 갱신 로직을 구현할 필요가 없어져 관리가 간편해진다.</li>
<li><strong>서버 요청 감소</strong>: 별도의 CSRF 토큰 갱신 요청 없이 엑세스 토큰 재발급 요청 시 CSRF 토큰도 함께 갱신되므로 서버에 대한 요청 수가 줄어들어 효율적이다.</li>
<li><strong>보안 강화</strong>: CSRF 토큰을 자주 갱신함으로써, 공격자가 이 토큰을 탈취해도 사용할 수 있는 시간이 짧아져 보안이 강화된다.</li>
</ol>
<p>이외에도 리프레시 토큰이 조금 더 탈취에서 안전할 수 있도록 토큰 리프레시로 엑세스 토큰을 갱신할 때마다 리프레시 토큰도 새것으로 교체해주는 방법도 있다.(남은 만료 시간은 동일하게) 이건 다음에 다뤄보기로 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Controller 테스트에서 생성자 주입을 잘 사용하지 않는 이유는?]]></title>
            <link>https://velog.io/@ssol_916/%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EC%A3%BC%EC%9E%85%EC%9D%84-%EC%9E%98-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0%EB%8A%94</link>
            <guid>https://velog.io/@ssol_916/%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EC%A3%BC%EC%9E%85%EC%9D%84-%EC%9E%98-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0%EB%8A%94</guid>
            <pubDate>Thu, 26 Oct 2023 02:17:30 GMT</pubDate>
            <description><![CDATA[<p>JUnit5에서는 <code>@WebMvcTest</code> 내에 <code>@ExtendWith</code>를 통해 <code>@Autowired</code>를 인지해서 생성자 주입을 할 수 있게 되었다.</p>
<pre><code class="language-java">@Import(SecurityConfig.class)
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {

    private final MockMvc mvc;

    // 일반적인 스펙상 생성자 주입을 할 때 생성자 위에 붙여주는 @Autowired는 생략이 가능하다.
    ArticleControllerTest(@Autowired MockMvc mvc) {  // 하지만 테스트 패키지에 있는 생성자 주입은 파라미터에 꼭 @Autowired를 붙여야 한다.
        this.mvc = mvc;
    }

        ...

}</code></pre>
<p>그런데 많은 컨트롤러 테스트 코드에서 왜 이 생성자 주입 방식을 잘 사용하지 않을까?</p>
<p>생성자 주입에 사용되는 <code>@Autowired</code>의 문서를 확인해보면 </p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/7953676c-30fb-4997-abf3-e1cecf9b8b61/image.png" alt="Autowired"></p>
<p>메서드와 파라미터에도 사용이 가능해서 생성자 주입에 사용해도 전혀 문제가 없지만 </p>
<p>보통 컨트롤러라 함은 서비스를 의존성으로 가지고 있다. 그래서 테스트 시 이 의존성을 끊어주고 mocking을 해주는 <code>@MockBean</code>을 사용하게 되는데 이 어노테이션의 문서를 확인해보면 </p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/27b55d66-3cc2-4b3b-bf8d-1be213320705/image.png" alt="MockBean"></p>
<p>사용할 수 있는 곳이 타입과 필드 뿐이다. 메서드, 파라미터에 넣을 수가 없는 것이다. 그래서 생성자 주입에 <code>@MockBean</code>를 사용할 수가 없다.</p>
<p>그렇게 되면 결국</p>
<pre><code class="language-java">@Import(SecurityConfig.class)
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {

    private final MockMvc mvc;

    @MockBean  // @WebMvcTest의 컨트롤러 단에 의존하는 의존성 테스트를 위해 사용. 컨트롤러에 있는 의존성을 끊고 mocking 해준다.
    private ArticleService articleService;

    // 일반적인 스펙상 생성자 주입을 할 때 생성자 위에 붙여주는 @Autowired는 생략이 가능하다.
    ArticleControllerTest(@Autowired MockMvc mvc) {  // 하지만 테스트 패키지에 있는 생성자 주입은 파라미터에 꼭 @Autowired를 붙여야 한다.
        this.mvc = mvc;
    }

        ...

}</code></pre>
<p>이렇게 생성자 주입과 필드 주입이 동시에 사용될 수 밖에 없다는 말인데 이러면 통일성이 없는 혼종 코드가 되버리기 때문에 </p>
<pre><code class="language-java">@Import(SecurityConfig.class)
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean  // @WebMvcTest의 컨트롤러 단에 의존하는 의존성 테스트를 위해 사용. 컨트롤러에 있는 의존성을 끊고 mocking 해준다.
    private ArticleService articleService;

        ...

}</code></pre>
<p>그냥 필드 주입으로 통일시켜 MockMvc도 필드 주입으로 많이 사용하는 것이다.</p>
<p>예전 IntelliJ에서는 필드 주입 방식을 사용하면 주황색으로 필드 주입은 권장하지 않는다는 경고 표시를 했지만 지금 버전의 IntelliJ는 테스트 코드에 한해선 필드 주입을 해도 경고 표시를 띄우지 않는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드 품질 향상을 위한 Code Review... 어떻게 하는게 좋을까?]]></title>
            <link>https://velog.io/@ssol_916/%EC%BD%94%EB%93%9C-%ED%92%88%EC%A7%88-%ED%96%A5%EC%83%81%EC%9D%84-%EC%9C%84%ED%95%9C-Code-Review...-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%98%EB%8A%94%EA%B2%8C-%EC%A2%8B%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@ssol_916/%EC%BD%94%EB%93%9C-%ED%92%88%EC%A7%88-%ED%96%A5%EC%83%81%EC%9D%84-%EC%9C%84%ED%95%9C-Code-Review...-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%98%EB%8A%94%EA%B2%8C-%EC%A2%8B%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Thu, 14 Sep 2023 00:40:35 GMT</pubDate>
            <description><![CDATA[<p>그동안 또 블로그 포스팅이 뜸했다... </p>
<p>메모하고 정리한 내용은 많은데, 보통 나만 알아보기 편하게 적어서 최근 프로젝트로 바빴는데 이걸 기존 포스팅 처럼 블로그에 올릴 수 있는 문체로 바꾸고 다듬으려니 엄두가 나지 않더라ㅋㅋㅋ 
그래서 이 문제를 다시 차차 극복하기 위해 &#39;우선 가공되지 않은 글일지라도 먼저 올리고 차차 수정해나가는 식으로 라도 하는게 낫지 않을까&#39;라는 생각이 들어 앞으로는 원본 날 메모에서 약간 소스만 친 1차 가공 내용도 올려보고자 한다.</p>
<p>그 첫번째는 9월 1일 참석한 &#39;코드 품질 향상을 위한 Code Review&#39; 세미나의 정리 내용이다.
<br></p>
<h1 id="코드-리뷰-도입이-힘든-이유">코드 리뷰 도입이 힘든 이유</h1>
<p>꼭 필요한 과정. 하지만 처음 도입하는 과정이라면 굉장히 어렵다…</p>
<p>코드 리뷰를 하는 리뷰어 입장과 받는 입장을 모두 알아보겠다</p>
<h2 id="그거-하고-있을-시간이-어딨어">그거 하고 있을 시간이 어딨어?</h2>
<p>코드 리뷰가 개발 과정의 병목이라는 주장</p>
<blockquote>
<p>“리뷰 하나씩 다 보고 있으면 티켓 처리가 느려진다.”
“지금은 과하고, 나중에 문제 있을 때 수정하자.”</p>
</blockquote>
<h2 id="나-하나-쯤이야">나 하나 쯤이야…</h2>
<p>코드 외적인 요인으으로 유야무야 되버리는 리뷰들</p>
<blockquote>
<p>“혹시 이걸 지적하면 상대가 마음 상하지 않을까?”
“나보다 경력자인데 내가 리뷰를 하는게 맞을까? 이상한 걸 짚었다고 생각하지 않을까?”
“나 말고도 다른 사람들이 봐주겠지…”</p>
</blockquote>
<h2 id="ㅁㅁ님은-항상-이렇게-하시더라구요">ㅁㅁ님은 항상 이렇게 하시더라구요</h2>
<p>리뷰 안에 상대방에 대한 비난이나 공격이 포함되는 경우
제발 꼽 주는 방식으로 말하지 말자. 이렇게 되면 상대는 당연히 방어적이되고 반발이 나올 수 밖에 없다.</p>
<blockquote>
<p>A : “습관적으로 이렇게 짜시던데, 옳지 않은 것 같아요.”
B : ”그건 상황에 따라 다른거 아닌가요? 취향 차이의 문제이지 옳고 그름의 문제는 아닌 것 같아요.”</p>
</blockquote>
<h1 id="코드-리뷰를-해야하는-3가지-이유">코드 리뷰를 해야하는 3가지 이유</h1>
<h2 id="1-제품의-품질의-전반적인-상승과-보존">1. 제품의 품질의 전반적인 상승과 보존</h2>
<ul>
<li>설계/구현/배포 잠재 오류를 사전에 발견하고 제거할 수 있음</li>
<li>여러 사람이 보았기 때문에 문제 발생 시 해결 속도가 빨라짐(내 코드가 아니더라도)</li>
</ul>
<h2 id="2-bus-factor의-최소화">2. Bus Factor의 최소화</h2>
<ul>
<li>Bus Factor = 프로젝트가 잘 진행되기 위해 꼭 필요한 사람 수. 같이 일하던 동료가 버스 사고가 나더라도 팀원들이 커버해서 프로젝트를 무사히 진행시킬 수 있는 인원을 말한다.</li>
<li>Context Sharing을 통한 엔지니어 가담 가능성 상승</li>
</ul>
<h2 id="3-엔지니어링-업무의-효율화">3. 엔지니어링 업무의 효율화</h2>
<ul>
<li>각자 다른 개인의 방식을 팀의 방식으로 합의 시키는 과정</li>
<li>서로 다른 코딩 컨벤션부터 전반적인 설계 원칙까지 점진적으로 싱크</li>
</ul>
<h1 id="우리-팀에-올바른-코드-리뷰-문화-정착시키기">우리 팀에 올바른 코드 리뷰 문화 정착시키기</h1>
<aside>
☝🏻 두 가지 방향에서의 노력이 필요

</aside>

<ul>
<li>코드 리뷰를 받는 사람의 노력 ⭐⭐⭐⭐⭐</li>
<li>코드 리뷰를 하는 사람의 노력 ⭐⭐⭐</li>
</ul>
<h2 id="코드-리뷰를-받는-사람의-자세">코드 리뷰를 받는 사람의 자세</h2>
<p><strong>리뷰받을 준비를 잘 하는 것이 리뷰 자체보다 더 중요</strong></p>
<p>⇒ 코드 리뷰의 부담은 줄이고, 효율성은 높이고</p>
<ol>
<li>Git을 똑바로 사용하자.</li>
<li>리뷰 받을 준비가 된 PR을 만들자.</li>
</ol>
<h3 id="git부터-똑바로-사용하자">Git부터 똑바로 사용하자</h3>
<p>깃을 관리하는 방법을 조직적으로 약속하고 관리</p>
<ul>
<li><p>어떤 코드가 어디에 있을 것인지 직관적으로 알 수 있게 한다.</p>
</li>
<li><p>리뷰어가 리뷰를 시작하기 전에 Context를 빠르게 이해하고 준비하도록 돕는다.</p>
</li>
<li><p>팀 내 Branch 관리 전략 준수</p>
<ul>
<li>Git-flow, Github-flow, GitLab-flow, …</li>
<li>팀에 어울리는 하나의 방법을 정해 합의하자</li>
</ul>
</li>
<li><p>Commit Convention</p>
<p>  TYPE: SUBJECT</p>
<p>  [BODY]</p>
<p>  [FOOTER]</p>
<ul>
<li>TYPE의 종류<ul>
<li>build</li>
<li>ci</li>
<li>docs</li>
<li>feat</li>
<li>fix</li>
<li>pref : 성능 개선 변경</li>
<li>refactor</li>
<li>style</li>
<li>test</li>
<li>chore : 레포 관리를 위한 단순 작업</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3 id="리뷰받을-준비가-된-pr을-만들자">리뷰받을 준비가 된 PR을 만들자</h3>
<p>리뷰어의 시간은 소중하다. 당연하게 여기지 말자.</p>
<ol>
<li><p><strong>CI 파이프라인 통과</strong></p>
<p> 개인의 코드가 인정받기 위한 최소 조건이자, 리뷰어가 굳이 보지 않아도 될 당연한 문제들을 없애주는 역할</p>
<ul>
<li>Formatter, Type Checker(컴파일 오류) 등 팀이 합의한 기준 통과 확인하기</li>
<li>Unit Test 코드 모두 통과 확인하기</li>
</ul>
</li>
<li><p>PR의 크기를 조절하자</p>
<p> File Changed가 너무 많으면 리뷰를 보는 사람이 너무 피곤함</p>
<p> 프로젝트 다 끝나고 코드를 한번에 올리는 문제</p>
<p> 충분히 고민할 수 있는 문제도 대충 넘기게 됨</p>
<p> 적당한 크기의 PR을 세팅하고 꼼꼼하게 리뷰 받자!</p>
<ul>
<li>리뷰어 설정시 팁! 개인 별 지정도 가능하지만 팀 리뷰어 지정도 된다. 팀 리뷰어에서 랜덤이나 특정 규칙에 따라 리뷰어가 선택되도록 할 수 있다.</li>
<li>특정 수의 approve를 받아야만 머지가 되도록 해서 함부로 머지 되는 것을 방지할 수 있다.</li>
<li>특정 코드가 수정되었을 때 꼭 리뷰해야하는 사람 지정 가능. <strong><em>Code Owner</em></strong>
파일 혹은 디렉토리 단위로, 사람 또는 팀 지정 가능</li>
</ul>
</li>
<li><p>Self-review</p>
<p> 리뷰 보내기 전에 내가 먼저 리뷰하자</p>
<p> 코드를 작성할 때 고민했던 다른 대안, 내가 생각하는 문제점 등을 미리 코멘트로 달아 전달하자.</p>
<p> 고민을 먼저 제시하여 더욱 효율적이고 풍부한 리뷰 가능</p>
</li>
</ol>
<h2 id="코드-리뷰를-하는-사람의-자세">코드 리뷰를 하는 사람의 자세</h2>
<p>“코드만” 리뷰하는 것이 아니다.</p>
<p>코드 리뷰의 진정한 가치를 뜰어올리는 3가지 제안</p>
<h3 id="1-모든-것이-리뷰의-대상이다">1. 모든 것이 리뷰의 대상이다</h3>
<p><strong>풀고자 하는 이슈를 잘 해결했는지?</strong></p>
<ul>
<li>전반적인 로직 구성과 대안 검토는 당연(비즈니스 문제)</li>
<li>풀고자 하는 문제와 미래에 올 상황에 대한 고민 역시 해야함(금방 사라질 수 있는 임시 코드인데 너무 추상화를 한다던지)</li>
</ul>
<p><strong>내 지식은 공유, 책임은 서로 나눈다.</strong></p>
<ul>
<li>그 코드를 가장 잘 짤 수 있는 방법을 다각도에서 공유</li>
<li>그 코드에 대한 Ownership은 공유해서 문제에 함께 대응</li>
</ul>
<h3 id="2-리뷰를-두려워하지-말자">2. 리뷰를 두려워하지 말자</h3>
<p><code>Request Change</code>를 두려워하면 안된다.</p>
<ul>
<li>코드를 더 좋게 만들기 위한 의지가 있음을 서로 믿어야 함. 그렇기 때문에 <strong>솔직하게 이야기하고 건강하게 충돌하자.</strong></li>
</ul>
<p>사람이 아닌 코드와 현상을 비판한다는 합의를 전재한다.</p>
<ul>
<li>이 리뷰가 나에게 하는 인신공격이나 비난이 아님을 알고 상처받지 말자.</li>
<li>조직 전체가 효율적으로 소통하고 더 생산적으로 일하자는 약속</li>
</ul>
<h3 id="3-상대방을-존중하고-좋은-부분은-칭찬하자">3. 상대방을 존중하고 좋은 부분은 칭찬하자</h3>
<p><strong>지적 사항이 없을 때도 찾아서 쓰려고 하지는 말자</strong></p>
<ul>
<li>그냥 Approve만 한다고 대충 리뷰하는 것이라고 생각하지 말자. 괜히 잘 쓴 코드를 다시 보고 망가뜨리는 일이 될 수 있다.</li>
</ul>
<p><strong>서로 존중하고 칭찬하는 문화를 유지하자</strong></p>
<ul>
<li>끈끈한 팀워크는 엔지니어링 팀에 큰 효용을 가져다주는 특성</li>
<li>양성 피드백은 그 개발자의 의지를 북돋아 더 나은 퍼포먼스를 내게 한다.</li>
</ul>
<h1 id="정답이라는-건-없다">정답이라는 건 없다!</h1>
<h2 id="코드-리뷰는-문화다">코드 리뷰는 “문화”다</h2>
<p>나 혼자 해서는 안되고, 팀 전체가 그에 맞게 이해하고 움직여야만 잘 할 수 있다.</p>
<p>그렇기에 서로 다른 팀은 서로 다른 문화를 지닌다.</p>
<p>우리 팀에 맞는 방법을 먼저 찾아서 제안해보자</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/b3e2c17b-3fd7-4aea-b643-aaecf8a8ce7a/image.jpeg" alt="코드 리뷰를 위한 문서 정리 예시"></p>
<p>코드 리뷰를 위한 문서 정리 예시
<br></p>
<p>만약 코드 리뷰를 할 수 있는 팀원이 없는 상황이라면
셀프 리뷰를 생활화 하고
내 로컬에 CI 파이프라인을 구축해서 돌려보자.</p>
<p>혼자라도 문화를 확립해보자. 이런 문화를 만들어 나가다 팀원이 합류하면 같이 이어나가면 되고, 만약 팀원 합류가 안되더라도 내 이력서에 한 줄 추가할 내용이 되지 않겠는가?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[메신저 시스템 디자인 해보기]]></title>
            <link>https://velog.io/@ssol_916/%EB%A9%94%EC%8B%A0%EC%A0%80-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%94%94%EC%9E%90%EC%9D%B8-%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@ssol_916/%EB%A9%94%EC%8B%A0%EC%A0%80-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%94%94%EC%9E%90%EC%9D%B8-%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 21 Apr 2023 02:23:27 GMT</pubDate>
            <description><![CDATA[<p>유튜브에서 영상을 보던 도중 신묘한 알고리즘이 추천해주는 가상의 시스템을 설계해보는 영상을 본 적이 있다.
이번 글에는 그 내용을 한번 요약 정리해보고자 한다.</p>
<h1 id="카카오톡-시스템-디자인-해보기">카카오톡 시스템 디자인 해보기</h1>
<h2 id="스펙">스펙</h2>
<ul>
<li>하루에 대략 600억개의 메시지가 오고감</li>
<li>사용자들은 오래된 채팅은 잘 안봄</li>
<li>READ/WRITE 비율이 1:1</li>
</ul>
<h2 id="클라이언트와-서버의-http-커넥션-문제">클라이언트와 서버의 HTTP 커넥션 문제</h2>
<p>보통 서버와 통신을 할 땐 HTTP 통신을 많이 사용한다.
하지만 HTTP 요청은 무조건 클라이언트가 먼저 시작해야 되는 것이 문제.
A가 B에게 메시지를 보내면 그 메시지는 서버에 전달이 되지만 서버에 아무 요청을 하지 않은 B에겐 서버가 먼저 응답을 할 수 없다.</p>
<p>이 문제를 해결하기 위한 몇가지 옵션을 찾아보자.</p>
<h3 id="1-polling">1. Polling</h3>
<p>새로운 내용의 메시지가 있는지 주기적으로 서버에 계속 물어보는 것을 말한다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/184c6b05-f715-4edf-b67f-785b92b5e2dc/image.webp" alt=""></p>
<blockquote>
<p>메시지 왔니?
아니요
메시지 왔니?
아니요
이번엔 메시지 왔니?
네</p>
</blockquote>
<p>이 방식은 request 수가 너무 많아지게 되어 리소스 낭비가 되고, 메시지에 요청 주기만큼 레이턴시가 걸리게 된다.</p>
<h3 id="2-long-polling">2. Long Polling</h3>
<p>Polling 방식과 유사한데 단지 서버가 요청을 받고나서 메시지가 없다면 바로 메시지가 없다고 응답을 주는게 아니라 일정 시간만큼의 타임아웃까지 기다린 후 응답을 보내고, 만약 기다리는 도중 메시지가 있다면 바로 응답하는 방식이다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/b6719540-b2b8-42c8-a60e-12f69b1f7697/image.webp" alt=""></p>
<blockquote>
<p>메시지 왔니?
... 아니요
메시지 왔니?
.. 어? 방금 왔어요!</p>
</blockquote>
<p>Polling에 비해 request 수는 줄지만 여전히 많은 request가 가긴 한다. 그리고 레이턴시도 마찬가지로 존재하고.</p>
<h3 id="3-web-socket">3. Web Socket</h3>
<p>클라이언트와 서버 사이에 오픈 커넥션을 유지하는 방법이다. 그렇기 때문에 양방향 소통이 가능해진다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/fbb52b6f-c868-4141-a27e-2748f8eaa71f/image.webp" alt=""></p>
<p>그래서 카카오톡 같은 1:1 채팅에선 웹소켓을 사용한다.</p>
<ul>
<li>웹 소켓은 오픈 커넥션을 계속 유지해야하기 때문에 이 오픈 커넥션을 유지할 chat server를 따로 만들어서 관리한다.</li>
<li>일반적인 request(로그인, 프로필 변경 등)는 API 서버에서 HTTP로 관리한다.</li>
</ul>
<h2 id="메시지-큐message-queue란">메시지 큐(Message Queue)란?</h2>
<p>서비스들 간에 데이터를 주고 받는 방법 중에 하나이다.
MSA에서 두 개의 서비스 간에 메시지 큐를 사용하지 않고 이야기하는 방식인 REST API나 RPC를 사용하면 Synchronous 하게 데이터를 주고 받음.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/80e89971-cfe6-492b-b401-3ff0c84ff773/image.png" alt="메시지큐 사용 안한 MSA"></p>
<p>메시지 큐를 사용하면 Asynchronous하게 데이터를 처리할 수 있다.
메시지 큐는 두가지 역할을 통해 작동하는데 하나는 이벤트 발생시 메시지 큐에 전달해주는 Publisher와 이 이벤트가 메시지 큐에 들어왔을 때 알림을 받는 Subscriber이다.
대표적인 서비스로는 Kafka, RabbitMQ가 있다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/57ecb25f-982b-42bf-a265-133039b7f627/image.png" alt=""></p>
<p>Service B가 Topic 1이라는 이벤트에 대해 Subscribe를 하고 있어서 Service A가 Topic 1 이벤트를 메시지 큐로 전달 했을 때, 메시지 큐가 Service B에 구독하고 있는 이벤트가 발생했다고 알려주는 것이다.</p>
<h3 id="rest-apirpc-대신-왜-메시지-큐를-쓸까">REST API/RPC 대신 왜 메시지 큐를 쓸까?</h3>
<p>여러 장점이 있는데 그 중 대표적인 것이 Decoupling이다.</p>
<h3 id="decoupling">Decoupling</h3>
<p>마이크로 서비스들이 많아지다 보면 서비스 A에 의존적인 서비스가 많을 수 있겠지? 이 서비스 ㅁ에 대한 요청을 REST API/RPC로 Synchronous하게 하려면 모든 디펜던시에 대한 코드를 서비스 A에 넣어야 한다.
<img src="https://velog.velcdn.com/images/ssol_916/post/fd339400-88cc-40d5-8ef4-cbf1d3dd79a9/image.png" alt=""></p>
<p>이렇게 되면 서비스 A는 점점 복잡해지고 테스트도 어려워질 것이다. =시스템 디자인적으로 굉장히 안좋음</p>
<p>이런 상황에서 메시지 큐를 사용하도록 하면</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/2af4a11d-cc54-43f0-a0f9-dd3deeb6a819/image.png" alt=""></p>
<p>디펜던시가 확 줄게 된다.
왜냐하면 서비스 A에 대한 의존성을 가진 다른 서비스들이 존재한다느 것을 알 필요가 없어지기 때문이다. 서비스 A의 역할은 메시지 큐에 이미지를 보내주는 것으로 끝인 것이다.(메시지 큐를 도입한 후 서비스 A의 디펜던시는 메시지 큐 1개가 되었다.)
각각의 서비스는 메시지 큐만 알면 됨.</p>
<p>이제 메시지가 전달되는 흐름을 한번 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/78a04a54-8d60-4b28-9f80-fa1ad7dc5cba/image.png" alt=""></p>
<p>유저A가 메시지를 보내면 웹소캣으로 chat server 1로 들어간다. 이 메시지는 유저B에게 도착해야 한다. 그러니 유저B의 메시지 큐에 넣어준다. 메시지 큐의 유저B 이벤트를 구독하고 있던 서비스들에게 알림이 간다. DB에도 유저A가 보낸 메시지가 저장이 되고, 유저B가 접속해 있는 chat server 3으로 전달되서 웹소켓으로 유저B에게 전달 되는 것이다.</p>
<h2 id="데이터베이스는-어떤-것을-써야-할까">데이터베이스는 어떤 것을 써야 할까?</h2>
<p>트래픽 특성</p>
<ul>
<li>엄청난 양의 트래픽 그룹 채팅 - 하루 600억개</li>
<li>오래된 채팅 잘 안봄</li>
<li>READ/WRITE 비율 1:1</li>
</ul>
<p>다른 데이터들과 join 할 필요가 거의 없음
RDBMS 같은 경우는 데이터가 많아지고 index가 많아지면 느려짐</p>
<h3 id="그래서-key-value-store-사용">그래서 Key Value Store 사용</h3>
<p>Key Value Store의 장점으로는</p>
<ul>
<li>스케일 하기 편함</li>
<li>Read 레이턴시가 낮음</li>
<li>페이스북은 HBase, 디스코드는 Cassandra를 사용하는 등 실제 운영 사례도 많음</li>
</ul>
<p>Key를 만들 땐 Range Scan 하기 쉽게 디자인 해야 한다.</p>
<ul>
<li>최근 보낸 메시지일수록 Key 값이 높게</li>
</ul>
<h1 id="만약-여기에-그룹-챗-기능을-추가한다면">만약 여기에 그룹 챗 기능을 추가한다면?</h1>
<p>그룹 챗을 몇명까지 지원할 것인가에 따라 디자인이 달라질 수 있음
최대 200명까지라고 가정을 한다면 어느정도의 Fan out은 괜찮다.</p>
<ol>
<li>유저A가 그룹 챗에 메시지를 보냄</li>
<li>chat server가 이 그룹 소속 멤버를 조회하기 위해 Group Chat DB를 찌름(이 DB는 RDBMS여도 상관 없다.)</li>
<li>받아야 할 멤버들의 큐에 메시지를 넣어줌</li>
<li>이후 나머지는 1:1 채팅과 똑같다</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[애플리케이션 모니터링 시스템 구축하기]]></title>
            <link>https://velog.io/@ssol_916/%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ssol_916/%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 10 Mar 2023 11:10:19 GMT</pubDate>
            <description><![CDATA[<h1 id="모니터링의-중요성">모니터링의 중요성</h1>
<blockquote>
<p>어? 이거 왜 이래?</p>
</blockquote>
<p>서비스를 개발하며 운영하다보면 잘되던 서버가 갑자기 오늘 내일 하는 일이 생기곤한다. 물론 아무 이유도 없이 문제가 생긴 건 아닐 것이다. 하지만 아무런 대비를 해두지 않은 상태라면 원인을 찾고 조치를 취하기 위해 많은 리소스를 들어야 할 것이다. 그렇기 때문에 모니터링이 중요한 것이다.</p>
<ul>
<li><p>서버가 쓰러지기 전, 비틀비틀 할 때 알림을 받을 수 있다면?</p>
</li>
<li><p>최소한 무슨 이유로 장애가 발생한건지 알람을 받을 수 있다면?</p>
<br >

</li>
</ul>
<p>모니터링/경보 시스템이 갖춰져 있다면 대비책이 없는 것보다 훨씬 빠르게 원인을 찾고 문제 수정을 할 수 있다.</p>
<p>현재 회사의 서비스는 에러가 발생시 해당 에러의 내용을 웹훅을 이용해 Slack으로 알람을 보내도록 되어있다. 
이 기능도 아주 좋은 기능이지만 이것만으로는 이미 장애 생긴 뒤에야 대응을 할 수 있다는 단점이 있다. 만약 장애가 생기기 직전에 미리 알 수 있는 방법이 있다면 서버가 죽기 전에 조치를 취해서 문제가 아예 생기지 않도록 할 수도 있겠지.</p>
<h1 id="무엇을-이용해-모니터링-할까">무엇을 이용해 모니터링 할까?</h1>
<h2 id="spring-actuator">Spring Actuator</h2>
<p>Spring Boot에는 Actuator라는 기가 막힌 기능이 있다.</p>
<p>애플리케이션의 운영에 필요한 다양한 정보를 제공하는 엔드포인트(endpoint)와 메트릭(metric)을 모아둔 모듈로 Actuator를 사용하면 다음과 같은 정보를 얻을 수 있다.</p>
<ul>
<li>애플리케이션의 상태 정보(health)</li>
<li>환경 변수(environment)</li>
<li>빈(bean) 정보</li>
<li>HTTP 요청 로그</li>
<li>메모리 사용량</li>
<li>CPU 사용량 등의 시스템 정보</li>
</ul>
<p>이러한 정보를 이용하여, 운영 중인 애플리케이션의 상태를 파악하고 성능을 분석하여 개선할 수 있다. 또한, Actuator의 엔드포인트를 이용하여 모니터링 도구와 연동하여 모니터링 정보를 수집할 수도 있다.
주문량이나 취소량 같은 비지니스 로직의 커스텀 메트릭을 추가할 수도 있는데 이 글에서는 다루지 않을 것이다. </p>
<p>Actuator는 간단하게 사용할 수 있는데, Gradle 기준으로 다음과 같은 의존성을 추가해주면 된다.</p>
<pre><code class="language-java">implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;</code></pre>
<p>하지만 Actuator의 엔드포인트들은 기본적으로 숨겨져있는 것들이 많다.
노출되면 않되는 애플리케이션의 민감한 정보를 보여주는 엔드포인트도 많고, 호출하는 것만으로 서버를 내려버리는 shut down 엔드포인트도 존재하기 때문이다.
그래서 Actuator의 엔드포인트는 안전하게 사용할 수 있도록 관리해야 한다.
우형 테크 블로그에 Actuator의 보안 이슈사항에 대한 좋은 글이 있으니 참고해서 설정하자!</p>
<p><a href="https://techblog.woowahan.com/9232/">[우아한 형제들 기술 블로그] Actuator 안전하게 사용하기</a></p>
<p>위 블로그 글에서 강조하는 부분을 살펴보면 이렇다.</p>
<ol>
<li>Actuator의 엔드포인트는 모두 비활성화 하고 필요한 것만 골라서 include하여 화이트리스트 형태로 운영해야 한다.</li>
<li>shutdown 엔드포인트는 활성화 하지 않는다.</li>
<li>JMX 형태로 Actuator를 사용하지 않을 경우 반드시 비활성화 하자.</li>
<li>Actuator는 서비스 운영에 사용되는 포트와 다른 포트를 사용하자</li>
<li>Actuator의 default 경로를 사용하지 않고 변경하여 운영한다.</li>
<li>인증 권한이 있는 사용자만이 접근 가능하도록 제어한다.</li>
</ol>
<p>이점을 유의하면서 application.yml에 엔드포인트 옵션을 설정해주면 된다.</p>
<pre><code class="language-yaml">server:
  port: 8080  # 애플리케이션의 포트
  tomcat:
    mbeanregistry:
      enabled: true  # tomcat의 엔트포인트를 활성화 시키는 옵션

...

# Actuator
management:
  info:  # info 옵션은 꼭 management 바로 아래에 붙여줘야 한다!
    java:
      enabled: true  # JAVA 엔드포인트 활성화
    os:
      enabled: true  # OS 엔드포인트 활성화
    env:
      enabled: true  # env 엔드 포인트 활성화
  endpoint:
    health:
      show-components: always  # health check 관련 컨포넌트 정보 엔드포인트 활성화
  endpoints:
    web:
      exposure:
        include: &quot;*&quot;  # 원래는 필요 옵션만 화이트리스트 방식으로 운영해야 하는데 본 post에선 편의를 위해 와일드카드로 표시하겠다. 실제 사용할 때에는 이렇게 사용하는 것은 권장하지 않는다!
      base-path: /monitor  # Actuator의 기본 path는 &#39;/actuator/**&#39;이다. 이것은 너무 잘 알려져 있는 정보이므로 보안을 위해서 path는 나만의 path로 커스텀 해서 사용하자.
    jmx:
      exposure:
        exclude: &quot;*&quot;  # 나는 JMX 방식으로 메트릭 정보를 확인하지 않을 것이므로 모두 비활성
        include: info, health  # 하지만 혹시 모르니 info와 health는 열어두었다:)
  server:
    port: 9097  # Actuator의 포트. 운영 포트와 다르게 설정하자.

# info 엔드포인트에서 보여질 정보들(info 엔드포인트를 사용하지 않을 거라면 필요없다. 필요시 설정)
info:
  app:
    name: my-back
    company: my-company</code></pre>
<p>이렇게 활성화한 엔드포인트들는 &#39;[내 서비스 도메인]:9097/monitor&#39;로 가면 확인할 수 있다. 물론 back-end 컨테이너에 Actuator 포트도 열고 맵핑 해줘야 확인할 수 있다.
회사 서비스는 docker-compose를 사용해서 컨테이너를 관리하고 있으므로 compose 설정에 Actuator 포트를 추가해주었다.
<img src="https://velog.velcdn.com/images/ssol_916/post/9e1ab0bd-d9f9-4120-9db5-0e6aa6f949b3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/3e1c3e81-bb06-4338-ac7a-d77708f6a2d0/image.jpg" alt=""></p>
<p>엔드포인트들은 json 형식으로 되어있다.
이런 정보는 한눈에 보기도 힘들고, 애플리케이션을 재실행하면 사라져버리는 일시적인 정보일 뿐이다.</p>
<p>그래서 이러한 엔드포인트의 메트릭 정보를 주기적으로 수집해서 저장해두는 일종의 크롤링+DB저장 기능이 필요할 것이다.
이러한 역할을 해주는 툴이 바로 Prometheus이다.</p>
<h2 id="prometheus">Prometheus</h2>
<p>Prometheus는 Actuator가 제공하는 메트릭 정보를 수집하고 저장하고 보여줄 수 있는 기능을 제공하지만 아무 형태나 모두 읽을 수 있는 것은 아니다.
Prometheus가 읽을 수 있는 메트릭 형태가 따로 존재하기 때문에 Actuator에서 이 형태로 메트릭 데이터를 반환해줘야겠지?</p>
<h3 id="마이크로미터">마이크로미터</h3>
<p>이러한 역할을 하는 것이 마이크로미터 라이브러리이다. 
마이크로미터는 여러 메트릭 표현 표준으로 표현할 수 있도록 제공을 해주는데 구현체를 갈아끼워 해당 표준에 맞게 반환할 수 있다. 로그를 추상화하는 SLF4J와 비슷하다 생각하면 될 것이다.
우리는 prometheus를 사용할 것이므로 prometheus의 구현체를 사용할 것이니 다음 의존성을 추가해주자.</p>
<pre><code class="language-java">implementation &#39;io.micrometer:micrometer-registry-prometheus&#39;</code></pre>
<p>이제 Actuator의 엔드포인트에 &#39;[내 Actuator url]/prometheus&#39;라는 것이 생겼을 것이다.
이 엔드포인트를 확인해보면
<img src="https://velog.velcdn.com/images/ssol_916/post/3ce12822-f2c6-468d-9468-f0fd64552103/image.png" alt=""></p>
<p>다음과 같은 포맷의 메트릭 데이터가 반환되는 것을 확인할 수 있다. 이것을 prometheus가 수집하게 하면 된다.</p>
<p>그냥 서버에 prometheus를 설치해서 작동시켜도 되지만 우리 회사는 컨테이너 방식을 사용하니 docker 컨테이너로 작동시킬 것이다.</p>
<h3 id="prometheus-컨테이너-설정">prometheus 컨테이너 설정</h3>
<pre><code class="language-shell">sudo docker pull prom/prometheus</code></pre>
<p>서버에 위 명령어를 사용해 prometheus 이미지를 받아온다.
그리고 나서 prometheus의 설정을 위해 yaml 파일을 작성해줘야 한다.</p>
<pre><code class="language-yaml"># my global config
global:
  scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:
          # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global &#39;evaluation_interval&#39;.
rule_files:
  # - &quot;first_rules.yml&quot;
  # - &quot;second_rules.yml&quot;

# A scrape configuration containing exactly one endpoint to scrape:
# Here it&#39;s Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=&lt;job_name&gt;` to any timeseries scraped from this config.
  - job_name: &quot;prometheus&quot;

    # metrics_path defaults to &#39;/metrics&#39;
    # scheme defaults to &#39;http&#39;.

    static_configs:
      - targets: [&quot;localhost:9090&quot;]

  # 위는 기본 prometheus.yml에 있는 코드. 
  # 아래 부분을 추가해서 우리 애플리케이션의 Actuator 메트릭을 수집할 수 있도록 해야 한다.
  - job_name: &quot;script-actuator&quot;  # job 이름은 알아서 설정~
    metrics_path: &#39;/monitor/prometheus&#39;  # prometheus가 읽을 수 있는 메트릭 정보 엔드포인트
    scrape_interval: 10s  # 얼마의 텀으로 메트릭 정보를 가져올 것인지. 운영 서버라면 보통은 10초~1분을 추천한다. 너무 자주하면 성능에 영향을 주겠지?
    scheme: https  # https 사용
    static_configs:
      - targets: [&#39;[내 서비스 도메인]:9097&#39;]  # 메트릭을 가져올 타겟 ip와 포트
    tls_config:  # tls 설정 구성
      insecure_skip_verify: true  # SSL 인증서 검증 skip</code></pre>
<p>이제 해당 prometheus.yml을 가져가서 실행하도록 하면 된다.</p>
<pre><code class="language-shell">sudo docker run \
--name prometheus -d \
--restart=unless-stopped \
-p 9098:9090 \
-v [서버에 prometheus.yml이 있는 경로]/prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus</code></pre>
<p>prometheus는 디폴트 포트가 9090이다. 하지만 현재 회사의 서비스에서 9090포트가 사용중이라 9098포트를 컨테이너의 9090포트에 맵핑해서 접근할 수 있게 했다.</p>
<p>물론 이 과정에서 prometheus가 Actuator를 잘 물고 있는지 확인하려면 AWS의 인바운드 규칙을 풀어서 확인해봐야 한다. 확인이 끝나면 외부에서 접근할 필요 없이 서버 내부 통신만 하면 되므로 인바운드 규칙을 수정해주자.
<img src="https://velog.velcdn.com/images/ssol_916/post/9ff655f0-32d5-4108-b9d7-7738db070f40/image.png" alt=""></p>
<p>이제 prometheus로 애플리케이션의 메트릭 정보를 수집하고 조회할 수 있게 됬지만 여전히 한눈에 알아보기 힘든 형식인 것은 마찬가지이다. 이 데이터를 표와 그래프로 보기 쉽게 표시하도록 해보자.</p>
<h2 id="grafana">Grafana</h2>
<p>Grafana는 오픈소스 데이터 시각화 및 대시보드 툴이다. 데이터 소스로부터 데이터를 쉽게 가져와 대시보드를 만들 수 있으며, 그래프, 테이블, 알림 등 다양한 시각화 도구를 제공한다.</p>
<p>시간 범위 선택, 그래프 타입 선택, 템플릿 변수 설정 등이 가능합니다. 또한, 대시보드에서 발생하는 이벤트에 대한 알림을 설정할 수 있다. 예를 들어 CPU 사용률이 일정 이상 올라갈 경우 메일이나 슬랙으로 알림을 받을 수 있다는 것이다!
즉, 서버 모니터링에 적합한 툴인 것이지.</p>
<p>Grafana도 prometheus와 마찬가지로 docker 컨테이너로 올릴 것이므로 이미지를 받아오자.</p>
<pre><code class="language-shell">sudo docker pull grafana/grafana</code></pre>
<p>그리고 컨테이너 실행.</p>
<pre><code class="language-shell">sudo docker run \
--name grafana -d \
--restart=unless-stopped \
-p 9099:3000 \
grafana/grafana</code></pre>
<p>Grafana의 디폴트 포트는 3000번이다. 컨테이너의 3000포트를 서버의 9099포트와 맵핑시켜 9099로 접근할 수 있도록 하겠다.</p>
<p>AWS의 인바운드 규칙에서 Grafana 대시보드를 볼 수 있도록 9099포트를 열어주면 되는데, 위에 Actuator 보안에서 언급했듯이 Actuator의 엔드포인트는 함부로 접근할 수 있게 하면 안되기 때문에 회사 내부 ip로만 접속할 수 있도록 설정하는 것이 좋다.</p>
<p>인바운드 규칙을 정리하면</p>
<ul>
<li>9097 : 애플리케이션 Actuator 포트. 서버 내부에서 Prometheus 컨테이너만 접근할 수 있도록 설정한다.</li>
<li>9098 : Prometheus 포트. 서버 내부에서 Grafana 컨테이너만 접근할 수 있도록 설정한다.</li>
<li>9099 : Grafana 포트. 회사 내부 ip로만 접근할 수 있도록 설정한다.</li>
</ul>
<p>&#39;[인스턴스 ip]:9099&#39;로 Grafana에 접근해보면 로그인 화면이 나오게 된다.
<img src="https://velog.velcdn.com/images/ssol_916/post/46179625-ae5b-49cf-9482-3547a02bb839/image.png" alt=""></p>
<p>디폴트 계정은 admin/admin이다. 디폴트 계정을 사용하는 것도 보안에 좋지 않으므로 계정 정보는 알아서 바꿔주자.</p>
<h3 id="grafana-대시보드-설정하기">Grafana 대시보드 설정하기</h3>
<ol>
<li><p>로그인을 하게되면 좌측 하단에 설정 버튼을 누르고 Configuration으로 이동한다.
<img src="https://velog.velcdn.com/images/ssol_916/post/2ada779b-bb8c-41d3-bf3a-5298d3a4f419/image.png" alt=""></p>
</li>
<li><p>prometheus datasource를 설정하는 탭이 보일텐데 선택을 해서 들어가면 URL을 입력하는 칸이 있다. 이곳에 prometheus의 URL을 입력해주고 저장하면 된다.
<img src="https://velog.velcdn.com/images/ssol_916/post/f255145f-1ced-4991-98f7-10a54ee57008/image.png" alt=""></p>
</li>
<li><p>좌측 상단에 그리드 모양 버튼을 누르고 Dashboard로 이동한다.
<img src="https://velog.velcdn.com/images/ssol_916/post/7921f7cb-5e1f-4d20-bd48-728caf857fc4/image.png" alt=""></p>
</li>
<li><p>Grafana는 대시보드를 직접 만들어서 사용해도 되지만, 이미 만들어져 있는 템플릿을 가져와서 사용할 수도 있다. Spring용 대시보드는 잘 만들어진게 많으므로 가져와 사용해봐도 좋다. 오른쪽에 new 버튼을 누르고 import를 선택하자.
<img src="https://velog.velcdn.com/images/ssol_916/post/d9eabb70-7302-4abe-8fce-d8f7a4dc9777/image.png" alt=""></p>
</li>
</ol>
<ol start="5">
<li><a href="https://grafana.com/grafana/dashboards/?search=spring">Grafana Labs - Dashboards</a> 이곳에 있는 여러 대시보드를 가져올 수 있는데 마음에 드는 대시보드의 id를 카피해서 넣어주고 datasource로 우리 prometheus를 선택해준다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/678502e8-af88-4f75-81c3-e13d8b320af3/image.png" alt=""></p>
<p>스프링 모니터링 대시보드 완성!</p>
<p>prometheus 쿼리와 grafana 대시보드 사용법을 공부하면 나만의 메트릭을 확인할 수 있는 보드도 만들 수 있다.
이제 이 애플리케이션 대시보드를 확인하면 서버 장애 징후를 확인할 수도 있고, 장애가 난 후 확인을 하더라도 어느 부분에서 장애가 났는지 더 짐작하기 쉬울 것이다.
<br ></p>
<p>하지만 하루종일 대시보드만 모니터링 할 수는 없는 일...
게다가 업무시간 이외에도 내가 퇴근했을 때, 휴일에도 장애 징후는 나타날 수 있다.</p>
<p>그래서 다음 Grafana 게시글에는 중요 메트릭 정보가 특정 지점을 초과했을 때 알람을 보내는 기능을 다뤄볼 것이다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Gradle, Layered Jar 그리고 Dockerbuild 최적화]]></title>
            <link>https://velog.io/@ssol_916/Gradle-Layered-Jar-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Dockerbuild-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@ssol_916/Gradle-Layered-Jar-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Dockerbuild-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Tue, 28 Feb 2023 08:50:34 GMT</pubDate>
            <description><![CDATA[<p>현 회사에서 개발 중인 프로젝트는 지금 기준으로는 Maven 프로젝트로 되어있고 스프링부트 버전도 2.3.x이며 각종 라이브러리의 버전도 낮은 상태로 레거시라고 할만한 프로젝트였다.</p>
<p>그래서 마이그레이션도 할 겸, 멀티 모듈로 넘어갈 준비도 할 겸 개발 서버와 릴리즈 서버에 배포 소요 시간을 줄이기 위해 Gradle로 빌드툴 변경을 하였다. 이번에 마이그레이션을 하면서 공부한 내용 몇 가지를 기록해보겠다.</p>
<h1 id="gradle">Gradle</h1>
<p>gradle은 처음 공부를 시작할때, 그리고 여러 토이 프로젝트를 할 때 그냥 막연히 가독성 좋고, 빠르고, 익숙하다고 자주 사용했었는데 무슨 이유에서 그런 것인지에 대해서는 자세히 알아본 적이 없었다. 덕분에 이번 기회에 gradle이 빠른 이유를 조금 알 수 있었다.</p>
<p>우선 Gradle의 장점에 대해 알고가보자.
<a href="https://www.youtube.com/watch?v=ntOH2bWLWQs">[우아한테크 - 10분 테코톡] 루나의 Gradle</a></p>
<ul>
<li>프로젝트를 상속 구조가 아닌 설정 주입 방식으로 정의할 수 있다.</li>
<li>그래서 프로젝트 별로 주입되는 설정을 다르게 해줄 수 있다.</li>
<li>xml이 아닌 groovy나 kotlin을 사용해서 의존성을 정의하므로 가독성이 엄청나게 좋아진다.</li>
<li>멀티 모듈 프로젝트 구성에 용이하다.</li>
<li>빌드 속도가 엄청 빠르다.<br >

</li>
</ul>
<p>여기서 가장 체감이 많이되고 눈 여겨 볼 점은 빌드 속도가 빠르다는 것이다.
Gradle이 빌드 속도가 빠른 이유는 무엇때문일까?</p>
<ol>
<li>점진적 빌드(Incremental Builds)를 하기 때문이다.<ul>
<li>gradle은 빌드 실행 중 마지막 빌드 호출 이후에 task의 입력, 출력 혹은 구현이 변경됬는지 확인한다.</li>
<li>최신 상태로 간주하지 않는다면 빌드는 실행되지 않는다.</li>
</ul>
</li>
<li>빌드 캐시(Build Cache)<ul>
<li>두 개 이상의 빌드가 돌아가고, 하나의 빌드에서 사용되는 파일들이 다른 빌드들에 사용된다면 Gradle은 빌드 캐시를 이용햇 이전 빌드의 결과물을 다른 빌드에서 사용할 수 있다.</li>
<li>다시 빌드하지 않아도 되므로 빌드 시간이 줄어들게 된다.</li>
</ul>
</li>
<li>데몬 프로세스<ul>
<li>데몬 프로세스: 서비스의 요청에 응답하기 위해 오래동안 살아있는 프로세스</li>
<li>Gradle의 데몬 프로세스는 메모리 상에 빌드 결과물을 보관</li>
<li>이로 인해 한 번 빌드된 프로젝트는 다음 빌드에서 매우 적은 시간만 소요된다.</li>
</ul>
</li>
</ol>
<p>데몬 프로세스와 점진적 빌드의 차이점은 데몬 프로세스는 gradle을 빌드 후 다음 빌드까지 메모리에 기억해두는 것이라고 이해하면 된다. 빌드 캐싱을 효과적으로 사용하기 위해 도움이 되는 프로세스이다.</p>
<p>Maven에서 Gradle 변환시 속도 체감을 해보자면</p>
<p>↓ maven 빌드 시간</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/da4d1be0-e9a8-49a5-82c2-98332718344c/image.png" alt=""></p>
<p>↓ gradle 빌드 시간</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/8bc63e1a-b3a5-497b-8c22-1f66e553f592/image.png" alt=""></p>
<h2 id="gradle-build-명령어-옵션">gradle build 명령어 옵션</h2>
<ul>
<li><code>gradle bootJar</code> : 단순히 프로젝트의 jar 파일만 생성</li>
<li><code>gradle build</code> : bootJar, test를 포함한 build 관련 테스크를 모두 시행하면서 빌드</li>
<li><code>gradle assemble</code> : 프로젝트의 결과물을 내는 모든 작업을 단일 작업으로 만드는 것</li>
<li><code>gradle clean build</code> : 이전 빌드 파일을 지우고 빌드</li>
<li><code>gradle clean build -x test</code> : 테스트 없이 이전 빌드 파일을 지우고 빌드</li>
</ul>
<p>이런 식으로 원하는 대로 옵션을 주면서 빌드할 수 있다.</p>
<h3 id="gradle-build와-gradlew-build의-차이">gradle build와 gradlew build의 차이</h3>
<p>그냥 <code>gradle build</code>를 사용하면 로컬환경에서 빌드할 경우 로컬 환경에 설치된 java와 gradle 버전에 영향을 받게 된다.
반면 <code>gradlew</code>는 gradle wrapper(내장 gradle)로 새로운 환경에서 프로젝트를 설정할 때 java나 gradle을 설치하지 않고 바로 빌드할 수 있게 해주는 역할을 한다.</p>
<p><code>gradle init</code> 후 <code>gradle wrapper</code>로 생성을 할 수 있는데 보통 이 방법은 거의 사용하지 않고 <a href="https://start.spring.io/">start.spring.io</a>에서 Spring Boot 프로젝트를 생성해 사용하기 때문에 이미 gradlew가 생성되어 있어서 명령어로 직접 생성할 일이 거의 없다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/06d5292d-56e5-43a0-8812-ce91850b2cc4/image.png" alt=""></p>
<p>이 gradlew와 gradlew.bat이 내장 gradle을 실행시키기 위한 쉘스크립트이며 gradlew는 유닉스 기반, gradlew.bat은 윈도우 용 스크립트이다.</p>
<pre><code class="language-shell"># gradlew를 사용해서 빌드하는 명령어
./gradlew build</code></pre>
<p><br><br></p>
<h1 id="layered-jar란">Layered Jar란?</h1>
<p>Layered JAR(계층형 JAR)는 JAR 파일을 논리적으로 분리하여 런타임에 모듈화 및 커스터마이징할 수 있도록 하는 기술이다.</p>
<p><a href="https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/htmlsingle/#packaging-executable.configuring.layered-archives">[공식문서]Spring Boot Gradle Plugin Reference Guide - Packaging Layered Jar or War</a></p>
<p>전통적인 JAR 파일(Fat Jar)은 모든 클래스 및 리소스가 묶여 있으며, 클래스 패스에 추가될 때 한 번에 로드된다. 반면, Layered JAR는 JAR 파일을 여러 레이어로 나누어 각 레이어에는 모듈화된 클래스, 리소스 및 구성 파일이 포함된다. 이를 통해, 런타임에 필요한 모듈만 로드하고, 필요한 경우 레이어를 추가하거나 제거하여 동적으로 애플리케이션을 구성할 수 있다.</p>
<blockquote>
<p>Fat Jar란?
Fat Jar는 애플리케이션의 모든 의존성을 단일 JAR 파일에 패키징하는 방법이다. 이 방법은 애플리케이션을 배포할 때 의존성 파일을 별도로 다운로드하거나 설치하지 않고도 애플리케이션을 실행할 수 있게 해준다. 이를 위해, 빌드 도구(예: Maven, Gradle)가 애플리케이션의 코드와 모든 의존성을 함께 패키징하여 하나의 JAR 파일로 만든다.
하지만 이 방법은 애플리케이션의 크기가 매우 크기 때문에 다운로드와 배포에 많은 시간이 걸릴 수 있다. 또한, 의존성이 충돌하는 경우에는 해결하기가 어려울 수 있다. 그래서 최근에는 더 작은 크기의 애플리케이션과 더 높은 호환성을 위해 Layered JAR와 같은 다른 패키징 방법이 등장하고 있다.</p>
</blockquote>
<p>Layered JAR를 사용하면 애플리케이션을 더 빠르게 시작할 수 있다. 또한, 필요한 모듈만 로드하므로 메모리 사용량이 줄어들고, 배포 및 업데이트가 더 쉬워진다. 이러한 이점으로 인해, 최근에는 Java 애플리케이션을 개발할 때 Layered JAR를 사용하는 것이 권장되고 있다.</p>
<p>Layered JAR는 Java 9부터 도입된 모듈 시스템(Java Module System)의 일부 기능이다. 따라서 Layered JAR를 사용하려면 Java 9 이상의 버전이 필요하다.</p>
<p>그리고 spring boot 2.3.0 이상부터 layer 기능을 지원한다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/a1177719-f76e-40a4-aafb-942e9b53cfba/image.png" alt=""></p>
<p>layered jar로 jar 파일을 만들게 되면 4가지 폴더로 구성되어 있다.</p>
<ul>
<li>application: 애플리케이션 소스 코드</li>
<li>snapshot-dependencies: 프로젝트 클래스 경로에 존재하는 스냅샷 종속성 jar 파일</li>
<li>spring-boot-loader: jar 로더와 런처</li>
<li>dependencies: 프로젝트 클래스 경로에 존재하는 라이브러리 jar 파일</li>
</ul>
<p>여기서 자주 변경되는 layer가 있고, 자주 변경되지 않는 layer도 있다.
뒤에 언급된 layer일 수록 변경이 적다.
<br /></p>
<h2 id="maven에서-layered-jar-사용방법">Maven에서 Layered Jar 사용방법</h2>
<p>기존 프로젝트가 Maven으로 Layered Jar 방식을 사용해 빌드하고 있었는데 이 사용법이 꽤 귀찮다.
pom.xml에 메이븐 플러그인을 설정해주고 </p>
<pre><code class="language-xml">&lt;project&gt;
    &lt;build&gt;
        &lt;plugins&gt;
            &lt;plugin&gt;
                &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
                &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;
                &lt;configuration&gt;
                    &lt;layers&gt;
                        &lt;enabled&gt;true&lt;/enabled&gt;
                        &lt;configuration&gt;${project.basedir}/src/layers.xml&lt;/configuration&gt;
                    &lt;/layers&gt;
                &lt;/configuration&gt;
            &lt;/plugin&gt;
        &lt;/plugins&gt;
    &lt;/build&gt;
&lt;/project&gt;</code></pre>
<p>여기에 설정되어 있는 layers.xml 파일을 작성해야 한다.</p>
<pre><code class="language-xml">&lt;layers xmlns=&quot;http://www.springframework.org/schema/boot/layers&quot;
        xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
        xsi:schemaLocation=&quot;http://www.springframework.org/schema/boot/layers
                          https://www.springframework.org/schema/boot/layers/layers-2.6.xsd&quot;&gt;
    &lt;application&gt;
        &lt;into layer=&quot;spring-boot-loader&quot;&gt;
            &lt;include&gt;org/springframework/boot/loader/**&lt;/include&gt;
        &lt;/into&gt;
        &lt;into layer=&quot;application&quot; /&gt;
    &lt;/application&gt;
    &lt;dependencies&gt;
        &lt;into layer=&quot;application&quot;&gt;
            &lt;includeModuleDependencies /&gt;
        &lt;/into&gt;
        &lt;into layer=&quot;snapshot-dependencies&quot;&gt;
            &lt;include&gt;*:*:*SNAPSHOT&lt;/include&gt;
        &lt;/into&gt;
        &lt;into layer=&quot;dependencies&quot; /&gt;
    &lt;/dependencies&gt;
    &lt;layerOrder&gt;
        &lt;layer&gt;dependencies&lt;/layer&gt;
        &lt;layer&gt;spring-boot-loader&lt;/layer&gt;
        &lt;layer&gt;snapshot-dependencies&lt;/layer&gt;
        &lt;layer&gt;application&lt;/layer&gt;
    &lt;/layerOrder&gt;
&lt;/layers&gt;</code></pre>
<h2 id="gradle에서-layered-jar-사용방법">Gradle에서 Layered Jar 사용방법</h2>
<p>인터넷을 뒤지다보니 gradle에서 layered jar로 빌드하기 위해선 build.gradle에 다음 스크립트를 추가해줘야 한다고 되어 있었다. </p>
<pre><code class="language-yaml"># 결론부터 말하자면 Spring Boot 2.3 이상이면 이 설정도 해줄 필요 없다.
bootJar {
    enabled = true
    layered()
}</code></pre>
<p>하지만 이것은 Spring Boot 2.3 버전 이하에서 사용하기 위한 것이고, Spring Boot 2.3 버전부터는 bootJar 테스크에서 layered() 설정이 기본적으로 활성화되어 있다. 따라서 별도의 설정 없이 bootJar를 실행하면 자동으로 Layered JAR가 생성된다.
이전 버전과 달리 enabled 속성을 따로 설정할 필요도 없다. 다만, 레이어의 구성 방식이나 설정 내용을 변경하려면 bootJar { layered() } 설정을 사용하여 필요한 설정을 추가하거나 수정해 주어야 한다.</p>
<p>편리해졌어!👍
덕분에 gradle로 변경하면서 필요없는 xml을 싹 날릴 수 있게 되었다.
<br><br></p>
<h1 id="docker-build-최적화하기">Docker build 최적화하기</h1>
<p>이 jar의 layer를 이용해서 docker image build를 최적화 할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/b7a86d6b-70bc-4bd1-a6d5-7ad4eb452311/image.png" alt=""></p>
<p>도커는 layer 별로 캐시를 관리한다. 즉, 변경이 없다면 캐시를 사용해서 빌드에 사용하기 때문에 리소스도 적게 들고 빌드 속도 빨라지게 되는 것이다.</p>
<p>그렇다면 jar를 layer로 나눠서 Docker build 하려면 어떻게 해야하나?</p>
<h2 id="layer를-이용한-dockerfile">Layer를 이용한 Dockerfile</h2>
<p>이렇게 Dockerfile을 만들어주면 된다.</p>
<pre><code class="language-yaml">FROM adoptopenjdk:11-jre-hotspot as builder
WORKDIR application
COPY ./my-back/build/libs/app-0.0.1-SNAPSHOT.jar application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM adoptopenjdk:11-jre-hotspot
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT [&quot;java&quot;, &quot;-Dspring.profiles.active=dev&quot;, &quot;-Duser.timezone=Asia/Seoul&quot;, &quot;org.springframework.boot.loader.JarLauncher&quot;]</code></pre>
<p>중요한 부분을 한 줄씩 해석해보자.
4번 라인에 있는 </p>
<pre><code class="language-yaml">java -Djarmode=layertools -jar application.jar extract</code></pre>
<p>이 명령어는 빌드된 jar를 layertools로 추출한다는 뜻이다. 이렇게 하면 위에서 언급한 4개의 layer로 jar가 분리되게 된다.</p>
<p>그리고 두 번째 FROM 지시어 부분에서는 이전 단계에서 추출한 layer들을 모두 COPY 해온다. 여기서 중요한 것은 copy 순서를 자주 바뀌지 않는 것부터 자주 바뀌는 순으로 해주는 것인데, 이렇게 하는 이유는 자주 바뀌는 부분을 나중에 가져와야 캐시가 깨질 가능성이 적어지므로 캐시 효율을 높일 수 있기 때문이다.</p>
<pre><code class="language-yaml">COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./</code></pre>
<p>가져온 layer들로 docker build를 수행하고 dev profile을 주입해 jar를 실행시킨다.</p>
<pre><code class="language-yaml">ENTRYPOINT [&quot;java&quot;, &quot;-Dspring.profiles.active=dev&quot;, &quot;-Duser.timezone=Asia/Seoul&quot;, &quot;org.springframework.boot.loader.JarLauncher&quot;]</code></pre>
<p>이 layer 방식을 통해서 어떤 부분이 수정되더라도 최소한의 범위에서 캐시가 깨지기 때문에 효율적으로 docker image를 build 할 수 있는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@RequestBody로 받았는데 null인 경우]]></title>
            <link>https://velog.io/@ssol_916/RequestBody%EB%A1%9C-%EB%B0%9B%EC%95%98%EB%8A%94%EB%8D%B0-null%EC%9D%B8-%EA%B2%BD%EC%9A%B0</link>
            <guid>https://velog.io/@ssol_916/RequestBody%EB%A1%9C-%EB%B0%9B%EC%95%98%EB%8A%94%EB%8D%B0-null%EC%9D%B8-%EA%B2%BD%EC%9A%B0</guid>
            <pubDate>Tue, 07 Feb 2023 02:26:16 GMT</pubDate>
            <description><![CDATA[<h1 id="이게-왜-null">이게 왜 null?</h1>
<p>데이터 삽입/수정을 테스트하기 위해 Postman에 @RequestBody에 필요한 값들을 넣어서 서버에 요청을 보냈는데 자꾸 null값을 가져오는 문제를 만난적 있는가?</p>
<p>예를 들어 스터디그룹 같은 유저 그룹을 생성하는 기능이라고 생각해보자.</p>
<pre><code class="language-json">{
    &quot;sGroupId&quot; : 102,
    &quot;sGroupName&quot; : &quot;네트워크 스터디&quot;,
      ...
}</code></pre>
<p>위와 같이 body를 전달하고 컨트롤러에서 받아서 로그를 찍어보면</p>
<pre><code class="language-shell">{&quot;sGroupId&quot;:null, &quot;sGroupName&quot;:null, ...}
</code></pre>
<p>이렇게 나와버린다...
무엇이 문제였을까?</p>
<h1 id="원인은-jackson">원인은 Jackson?</h1>
<p>이유를 찾아보니 스프링부트에서 json으로 데이터를 변환하고 맵핑하기 위해 사용하는 <U style="color:indianred"><span style="color:indianred"><em><strong>Jackson</strong></em></span></U>이라는 라이브러리를 비롯한 복합적 문제였다.
이 Jackson 라이브러리의 무엇이 문제였는지 알기 위해 우선 Jackson이 json으로 데이터를 변환하는 과정이 어떻게 되는지 알아야 한다.</p>
<p>Object를 json으로 변환할 때 key 값을 필드명으로 잡는것 같지?
사실은 필드명이 아니라 Getter의 이름을 기준으로 변경해준다. 즉, name이라고 필드명을 사용해도 Getter 메서드로 getUserName이라고 써버리면 json의 키 값이 userName이 되는 것이다.</p>
<pre><code class="language-java">public class UserDto {
    private String name;

    public String getUserName() {
        return name;
    }
}</code></pre>
<pre><code class="language-java">UserDto userDto = new UserDto(&quot;Sol&quot;);
String content = objectMapper.writeValueAsString(jacksonDto);

System.out.println(content);  // {&quot;userName&quot;:&quot;Sol&quot;}</code></pre>
<p>바로 이렇게...</p>
<p>Jackson이 json key로 변환하는데에는 일정한 규칙이 있다.
Jackson은 기본적으로는 JavaBeans 규약을 따르지만 다른 부분이 있다.</p>
<p>여기서 JavaBeans 규약이란?</p>
<h2 id="javabeans-규약">JavaBeans 규약</h2>
<p>자바빈을 사용하기 위해서 몇가지 규칙을 정해둔 것이다.
자바빈을 사용하는 이유는 디자인(프론트엔드)와 로직(백엔드)를 분리하기 위해서이다. 공통의 약속을 지키며 사용함으로써 프론트엔드에 백엔드의 로직을 구현하는 등의 일이 없이 일관된 방식으로 자바 클래스를 사용할 수 있도록 도와준다.</p>
<p>이 규약의 내용을 몇가지 소개하자면</p>
<ol>
<li>기본 생성자를 반드시 가지고 있어야 한다.</li>
<li>빈이 패키지화 되어 있어야한다.</li>
<li>멤버 변수의 접근자는 private으로 선언한다.</li>
<li>멤버 변수에 접근하기 위한 public 접근자인 getter/setter 메서드가 존재해야 한다.</li>
<li>get 메서드는 파라미터가 존재하지 않아야 한다</li>
<li>set 메서드는 반드시 하나 이상의 파라미터가 존재해야 한다</li>
</ol>
<p>등이 있다.</p>
<p>이 중에 이번 Jackson 문제 관련으로 확인해야 할 것은 </p>
<blockquote>
<p>클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 때문에 첫 번째 글자를 소문자로 변환한다. 다만, 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이런 경우는 예외로 둔다.
그리고 예외 케이스를 판별하기 위해 첫 두 문자가 모두 대문자인지를 확인한다.</p>
</blockquote>
<p>라는 규약이다.</p>
<p>java.beans 패키지에 있는 <span style="color:indianred"><em><strong><code>Introspector</code></strong></em></span> 클래스를 확인해보면 실제로 어떤 로직이 들어가있는 지 알 수 있다.</p>
<pre><code class="language-java">public class Introspector {
    /**
     * Utility method to take a string and convert it to normal Java variable
     * name capitalization.  This normally means converting the first
     * character from upper case to lower case, but in the (unusual) special
     * case when there is more than one character and both the first and
     * second characters are upper case, we leave it alone.
     * &lt;p&gt;
     * Thus &quot;FooBah&quot; becomes &quot;fooBah&quot; and &quot;X&quot; becomes &quot;x&quot;, but &quot;URL&quot; stays
     * as &quot;URL&quot;.
     *
     * @param  name The string to be decapitalized.
     * @return  The decapitalized version of the string.
     */
    public static String decapitalize(String name) {
        if (name == null || name.length() == 0) {
            return name;
        }
        if (name.length() &gt; 1 &amp;&amp; Character.isUpperCase(name.charAt(1)) &amp;&amp;
                        Character.isUpperCase(name.charAt(0))){
            return name;
        }
        char chars[] = name.toCharArray();
        chars[0] = Character.toLowerCase(chars[0]);
        return new String(chars);
    }

    //...
}</code></pre>
<ul>
<li>맨 앞 두개가 전부 대문자라면 그대로 리턴하고 아니라면 맨 앞 문자 하나만 소문자로 바꿔서 리턴</li>
</ul>
<h2 id="자바빈-규약과는-다른-jackson의-규칙">자바빈 규약과는 다른 Jackson의 규칙</h2>
<p>Jackson도 JavaBeans 규약을 따르지만 다른 점이 하나 있다.</p>
<ol>
<li>맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.</li>
<li>나머지 모든 케이스에서는 맨 앞 글자만 소문자로 바꿔준다.</li>
</ol>
<p>저 첫번째 규칙이 바로 자바빈 규약과 다른 점이다. 
자바빈 규약에선 첫 두글자가 대문자이면 그대로 사용한다고 되어 있지만 Jackson은 첫 두글자가 대문자이면 모두 소문자로 바꿔버린다.</p>
<h3 id="테스트">테스트</h3>
<pre><code class="language-java">@NoArgsConstructor
public class SampleDto {
    private String AAaa;
    private String BBBb;
    private String CCcC;
    private String DDDD;

    public String getAAaa() {
        return AAaa;
    }

    public String getBBBb() {
        return BBBb;
    }

    public String getCCcC() {
        return CCcC;
    }

    public String getDDDD() {
        return DDDD;
    }
}</code></pre>
<p>이 Dto로 실제 요청이 들어오면 어떻게 작동하는지 확인해보자.</p>
<p>[ Request ]</p>
<pre><code class="language-json">{
    &quot;AAaa&quot;: &quot;a&quot;,
    &quot;BBBb&quot;: &quot;b&quot;,
    &quot;CCcC&quot;: &quot;c&quot;,
    &quot;DDDD&quot;: &quot;d&quot;
}</code></pre>
<p>[ 컨트롤러 ]</p>
<pre><code class="language-java">@RestController
public class SampleController {

    @PostMapping(&quot;/api&quot;)
    public ResponseEntity&lt;SampleDto&gt; postSample(@RequestBody SampleDto dto) {
        System.out.println(dto);

        return ResponseEntity.ok(dto);
    }
}</code></pre>
<p>응답 결과를 확인해보면 </p>
<p>[ Response ]</p>
<pre><code class="language-json">{
    &quot;aaaa&quot;: null,
    &quot;bbbb&quot;: null,
    &quot;cccC&quot;: null,
    &quot;dddd&quot;: null
}</code></pre>
<p>값이 전부 null이다. </p>
<p>왜?🤔</p>
<p>자세히 보면 json key 값들이 원래 설정했던 값과 다르게 응답된 것을 확인할 수 있다.
당연히 request로 보낸 값과 대소문자가 안맞으니 null인 것이겠지.
위에서 봤던 Jackson 라이브러리의 규칙을 생각해보자.</p>
<ul>
<li><code>AAaa</code> → <code>aaaa</code> : 앞 두 글자가 대문자라서 소문자로 변경</li>
<li><code>BBBb</code> → <code>bbbb</code> : 앞 두 글자가 대문자라서 이어진 세번째 문자까지 소문자로 변경</li>
<li><code>CCcC</code> → <code>cccC</code> : 앞 두 글자를 소문자로 변경하지만 맨 뒤의 대문자는 이어져 있지 않아서 그대로 사용</li>
<li><code>DDDD</code> → <code>dddd</code> : 앞 두 글자부터 이어진 대문자를 모두 소문자로 변경</li>
</ul>
<p>Jackson이 Getter의 이름을 기준으로 변경한다고 한 것 기억나지? 그렇다면 필드와 Getter를 다르게 설정해서 다시 테스트 해보자.</p>
<pre><code class="language-java">@NoArgsConstructor
public class Sample2Dto {
    private String aaaa;
    private String bbbB;

    private String Cccc;
    private String DddD;

    private String eEee;
    private String fFfF;

    public String getAaaa() {
        return aaaa;
    }

    public String getBbbB() {
        return bbbB;
    }

    public String getCccc() {
        return Cccc;
    }

    public String getDddD() {
        return DddD;
    }

    public String geteEee() {
        return eEee;
    }

    public String getfFfF() {
        return fFfF;
    }
}</code></pre>
<p>컨트롤러는 동일하게 사용해서 다음과 같은 응답을 보내보았다.</p>
<p>[ Request ]</p>
<pre><code class="language-json">{
    &quot;aaaa&quot;: &quot;a&quot;,
    &quot;bbbB&quot;: &quot;b&quot;,
    &quot;Cccc&quot;: &quot;c&quot;,
    &quot;DddD&quot;: &quot;d&quot;,
    &quot;eEee&quot;: &quot;e&quot;,
    &quot;fFfF&quot;: &quot;f&quot;
}</code></pre>
<p>이제 Jackson의 Convert 규칙을 알게되었으니 이것의 json 변환 결과도 예상할 수 있겠지?</p>
<ul>
<li>getAaaa()는 첫글자가 대문자이므로 소문자로 바뀌어서 → <code>aaaa</code></li>
<li>getBbbB()는 첫 대문자와 연결된 대문자가 아니므로 → <code>bbbB</code></li>
<li>getCccc()는 Aaaa와 마찬가지이므로 → <code>cccc</code></li>
<li>getDddD()는 bbbB와 마찬가지이므로 → <code>dddD</code></li>
<li>geteEee()는 첫글자가 대문자가 아니므로 기존 그대로 사용해서 → <code>eEee</code></li>
<li>getfFfF()도 첫글자가 대문자가 아니므로 기존 그대로 사용해서 → <code>fFfF</code></li>
</ul>
<p>이렇게 변환된 json key과 Dto의 필드 값과 매칭 상태를 보면되는데</p>
<blockquote>
<p>getter json 변환 결과: <code>aaaa</code> = 필드 명: <code>aaaa</code>
getter json 변환 결과: <code>bbbB</code> = 필드 명: <code>bbbB</code>
getter json 변환 결과: <code>cccc</code> != 필드 명: <code>Cccc</code>
getter json 변환 결과: <code>dddD</code> != 필드 명: <code>DddD</code>
getter json 변환 결과: <code>eEee</code> = 필드 명: <code>eEee</code>
getter json 변환 결과: <code>fFfF</code> = 필드 명: <code>fFfF</code></p>
</blockquote>
<p>[ Response ]</p>
<pre><code class="language-json">{
    &quot;aaaa&quot;: &quot;a&quot;,
    &quot;bbbB&quot;: &quot;b&quot;,
    &quot;cccc&quot;: null,
    &quot;dddD&quot;: null,
    &quot;eEee&quot;: &quot;e&quot;,
    &quot;fFfF&quot;: &quot;f&quot;
}</code></pre>
<p>필드 명과 매칭이 안되는 <code>cccc</code>와 <code>dddD</code>는 당연히 null인 예상대로의 결과가 나온 것을 확인할 수 있다.</p>
<h2 id="jackson-규칙-결론">Jackson 규칙 결론</h2>
<p>여기서 확인할 수 있는 결론은 <U style="color:indianred"><span style="color:indianred"><strong>필드 명의 첫 글자가 대문자이면 값이 제대로 들어오지 않는다는 점</strong></span></U>이다.</p>
<p>필드명이 대문자로 시작해도 Getter는 보통 대문자로 시작한다. 더군다나 Lombok으로 Getter를 생성하는 것이라면 당연히 필드 명에서 첫글자를 대문자로 사용하게 되겠지.
하지만 Jackson의 규칙에 따라서 get 이후가 대문자로 시작하면 최소한 첫 글자는 항상 소문자로 바뀌게 되고 대문자로 시작하는 필드와 매치가 안되게 되는 것이다.</p>
<h1 id="또-다른-원인-lombok">또 다른 원인 Lombok?</h1>
<p>위 Jackson 변환 규칙 결론에서 보듯이 필드명의 첫 글자가 대문자이면 값이 제대로 들어오지 않는데 이것은 Lombok으로 생성한 Getter의 문제도 있다. 
(사실 Lombok Getter의 문제라기 보다는 애초에 필드명을 대충 지은 책임이 있겠지만 우선은 Lombok을 중점으로 보도록 하자.)</p>
<p><U style="color:indianred"><span style="color:indianred"><strong>Lombok</strong></span></U>은 반복적인 코드를 간단한 어노테이션을 통해 개발의 편의성을 높여주는 라이브러리로 Jackson과 마찬가지로 스프링부트에 기본으로 들어있는 라이브러리이다. 
이 중 <strong><code>@Getter</code></strong>는 특히 거의 모든 Object에서 사용하는 어노테이션이다.
이 @Getter는 어떤 방식으로 get 메서드를 자동 생성해줄까?</p>
<h2 id="getter의-작동-방식">@Getter의 작동 방식</h2>
<p>사실 <U style="color:indianred"><span style="color:indianred"><strong><code>@Getter</code></strong></span></U>는 딱히 복잡한 규칙없이 필드의 첫 글자를 대문자로 변경해서 get 메서드를 생성해준다.
즉, 제일 처음에 예시를 들었던 sGroupId라는 필드의 @Getter 생성 메서드는 getSGroupId()인 것이다.</p>
<p>하지만 우리는 Jackson의 json 변환 방식을 알게 되었다. 
getSGroupId()를 사용해 sgroupId로 변환 되겠지? 그런데 이렇게 변환된 json key 값과 필드의 값이 일치하지 않아서 값이 들어가지 않는 문제가 발생하게 된다.</p>
<p>이 문제를 피하기 위해서는 Lombok이 아닌 수동으로 Getter를 만들어주면 된다.
IntelliJ에서 제공하는 Getter 생성 기능을 사용하면 </p>
<pre><code class="language-java">public int getsGroupId() {
    return aCount;
}</code></pre>
<p>이렇게 만들어주기 때문에 Jackson의 변환 과정을 거쳐도 문제 없도록 만들어준다.</p>
<p>하지만 대부분의 개발자는 클래스에 직접 get 메서드를 선언하는 것보다는 @Getter를 사용하는 편이잖아? 그 편이 코드도 더 적어지고, 편하니까...
그렇다면 @Getter를 사용해도 문제가 없도록 필드 명을 잘 설정해서 문제가 없도록 하는게 최선의 방법이 되겠다.</p>
<p>즉, 니가 필드 명을 제대로 지었으면 이런 문제가 발생하지 않았을 것이란 것이다!!😇</p>
<h2 id="이름-좀-잘-지어">이름 좀 잘 지어!!</h2>
<p>클린코드 두 번째 챕터에 나오는 것이 바로 <U style="color:indianred"><span style="color:indianred"><strong>&#39;의미있는 이름&#39;</strong></span></U>이다.
그 중 불필요한 맥락을 없애라라는 파트에 나오는 설명을 인용하자면 &#39;고급 휘발유 충전소(Gas Station Deluxe)&#39;라는 애플리케이션을 짤 때 모든 클래스 이름을 GSD라고 시작하는 것은 바람직하지 않다고 한다. 
G를 입력하고 자동완성 단축키를 누르면 IDE는 모든 클래스를 추천되게 될 것이다. IDE는 개발자를 지원하는 도구인데 굳이 이렇게 IDE를 방해할 필요가 없기 때문이다.</p>
<p>일반적으로는 짧은 이름이 긴 이름보다 좋지만, 이것은 의미가 분명한 경우에 한해서이다. 
처음 예시로 들었던 &#39;sGroupName&#39;처럼 스터디그룹을 줄여서 쓰지말고 명확하게 풀어서 썼다면 이런 문제를 겪지 않았을 것이다.</p>
<p>꼭 짧은 이름을 지어야 한다는 강박을 가질 필요는 없다. 이상한 이름으로 인해서 혼돈을 줘 해석에 시간이 오래 걸리거나 버그가 생기지 않도록 명확한게 더 좋은 것이다.
당장 새 팀원이 합류했을 때 프로젝트의 클래스, 변수, 메서드 이름만 보고도 이게 무엇인지 알 수 있도록 만들자.
그래서 개발자들이 좋은 변수 명을 지으려고 고민을 하는 것이 아니겠어?</p>
<h1 id="해결-방법">해결 방법</h1>
<p>그래서 RequestBody에 null이 들어가는 문제를 해결하는 방법이 무엇이냐? 세 가지가 있는데</p>
<ol>
<li>필드 명을 수정하지 않고 그대로 사용하고 싶다면 <U style="color:indianred"><span style="color:indianred">직접 Getter 메서드를 생성해서 사용</span></U>한다.</li>
<li>해당 필드에 <U style="color:indianred"><span style="color:indianred"><strong><code>@JsonProperty</code></strong></span></U>를 붙여주면 된다. 필드에 선언한 그대로 json 키 값을 만들겠다는 어노테이션이다.</li>
<li><strong>애초에 <span style="color:indianred">필드 명을 작성할 때 첫 번째는 소문자, 두 번째는 대문자인 케이스로 만들지 않는다.</span></strong></li>
</ol>
<p>정도가 되겠다.</p>
<p>현 프로젝트 경우는 기존에 필드명을 사용하는 부분이 좀 많은 편이었고 프론트에서도 이미 이렇게 받아서 사용 중이었기 때문에 일단 2번 방법으로 해결을 하였다.
3번 해결법이 가장 바람직하다고 생각은 들지만 실무에선 어쩔 수 없이 어느정도 타협을 해야 할 때도 있으니까... 
나중에 프론트 분이랑 같이 한번에 바꾸는 시간을 잡던가 해야지.</p>
<br />
<br />

<p>참고</p>
<ul>
<li><a href="https://www.baeldung.com/spring-httpmessageconverter-rest">https://www.baeldung.com/spring-httpmessageconverter-rest</a></li>
<li><a href="https://bcp0109.tistory.com/309">https://bcp0109.tistory.com/309</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[@PathVariable에 email이 안들어가??]]></title>
            <link>https://velog.io/@ssol_916/PathVariable%EC%97%90-email%EC%9D%B4-%EC%95%88%EB%93%A4%EC%96%B4%EA%B0%80</link>
            <guid>https://velog.io/@ssol_916/PathVariable%EC%97%90-email%EC%9D%B4-%EC%95%88%EB%93%A4%EC%96%B4%EA%B0%80</guid>
            <pubDate>Tue, 17 Jan 2023 02:03:52 GMT</pubDate>
            <description><![CDATA[<ul>
<li>406에러를 만나서 콘텐츠 협상까지 의식이 흐름대로 야크 쉐이빙</li>
</ul>
<p>최근 API를 개발하면서 @PathVariable에 email을 넣어야 할 일이 생겼는데 만든 API가 406 에러를 띄우는 문제가 있었다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/8e690a6a-d49a-4e3c-9ba1-d601cbbde0a4/image.png" alt=""></p>
<h2 id="406-에러">406 에러?</h2>
<blockquote>
<p>406 에러란 서버가 요청의 사전 콘텐츠 협상 헤더에 정의 된 허용 가능한 값 목록과 일치하는 응답을 생성 할 수 없으며 서버가 기본 표현을 제공하지 않음을 나타냅니다.</p>
</blockquote>
<p>라고 한다.</p>
<p><a href="https://velog.io/@_koiil/406-%EC%97%90%EB%9F%AC%EB%8A%94-%EB%AD%94%EA%B0%80%EC%9A%94">https://velog.io/@_koiil/406-%EC%97%90%EB%9F%AC%EB%8A%94-%EB%AD%94%EA%B0%80%EC%9A%94</a></p>
<p><a href="https://kth990303.tistory.com/304">https://kth990303.tistory.com/304</a>
수 많은 정보를 찾아보니 Java Object를 Json 형태로 담기위해 Jackson 라이브러리에서 각 필드에 getter 메서드와 기본 생성자가 꼭 필요한데 이것이 없어서 나는 오류라는 것을 알게 되었다.</p>
<h2 id="jackson-라이브러리">Jackson 라이브러리</h2>
<p>이것은 위에서 말했듯이 Json으로 변환해서 리턴하기 위해서 필요한 라이브러리로, 스프링 부트에는 기본적으로 들어있는 라이브러리이기 때문에 따로 디펜던시를 추가해줄 필요가 없다. 그렇다면 getter만 추가해주면 되겠네? 근데 확인해보니 내 코드에는 getter가 잘 있더라...</p>
<h1 id="pathvariable-email">@PathVariable email</h1>
<p>그럼 다른 곳에 문제가 있는 건가 싶어서 다시 찾아보게 됬는데, @PathVariable로 받고 있는 이메일 값은 잘 가져오는 걸까 궁금해서 로그를 찍어보니
/api/sample@gmail.com을 호출하면 406을 띄우면서 email을 로그로 찍어보면 sample@gmail만 가져오네???🙄</p>
<p>아마 &#39;.&#39;뒷부분을 확장자 처럼 읽어서 잘라버리는 건가?</p>
<p>@PathVariable email로만 찾아봐도 나랑 같은 문제를 겪은 사람들이 많았다.</p>
<p>이 문제의 해결 방법으로는 두가지 방법이 있다고 한다.
첫번째 방법으로는 API uri를 약간 변형해서 해결하는 방법이고, 두번째 방법은 기존 API uri를 그대로 유지하면서 문제를 해결하는 방법이다.</p>
<h2 id="첫번째-방법">첫번째 방법</h2>
<p>그냥 @PathVariable 이메일 뒤에 &#39;/&#39;를 붙여주면 &#39;.&#39;에서 끊지 않고 통째로 읽을 수 있게 된다.</p>
<pre><code class="language-java">@GetMapping(&quot;/confirm/{email}/&quot;)</code></pre>
<p>간단하지?</p>
<p>하지만 뒤에 경로나 값이 더 있는 것도 아닌데 &#39;/&#39;를 붙이는 것보다 그냥 @PathVariable 이메일로 uri가 끝나는게 바람직 할 것 같아서 나는 다음 방법을 사용하였다!</p>
<h2 id="두번째-방법">두번째 방법</h2>
<p>@PathVariable 이메일 경로를 유지하기 위해 {email}에 &#39;:.+&#39;를 붙여주는 것이라고 한다.</p>
<pre><code class="language-java">@GetMapping(&quot;/confirm/{email:.+}&quot;)</code></pre>
<p>이렇게 하면 제대로 &#39;.&#39;이 들어간 email을 읽어올 수 있는데, 이렇게 해도 406 에러가 계속 발생하는 경우도 있다고 한다. </p>
<h3 id="스프링-설정-수정---경로-확장">스프링 설정 수정 - 경로 확장</h3>
<p>만약 이래도 406 에러가 발생하면 스프링 설정을 하나 추가해줘야 한다고 한다.</p>
<p>WebMvcConfigurer를 상속하는 곳에 </p>
<pre><code class="language-java">@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    configurer.favorPathExtension(false);
}</code></pre>
<p>위 메서드를 재정의 해주면 된다. 
이렇게 하면 favorPathExtension(false)로 경로 확장을 할 수 있어 이메일 값을 정상적으로 읽을 수 있지만, favorPathExtension(boolean)은 스프링 5.2.4부터는 더 이상 사용하지 않는다고하니 더 공부하고 확인해볼 필요가 있을 것 같다.
<a href="https://www.baeldung.com/spring-mvc-content-negotiation-json-xml">https://www.baeldung.com/spring-mvc-content-negotiation-json-xml</a></p>
<p><a href="https://medium.com/@saishav_io/error-406-while-using-and-email-address-as-a-path-variable-in-spring-boot-8caaefc17c7b">https://medium.com/@saishav_io/error-406-while-using-and-email-address-as-a-path-variable-in-spring-boot-8caaefc17c7b</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 API 공통 응답 처리하기]]></title>
            <link>https://velog.io/@ssol_916/%EC%8A%A4%ED%94%84%EB%A7%81-API-%EA%B3%B5%ED%86%B5-%EC%9D%91%EB%8B%B5-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ssol_916/%EC%8A%A4%ED%94%84%EB%A7%81-API-%EA%B3%B5%ED%86%B5-%EC%9D%91%EB%8B%B5-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 17 Jan 2023 00:31:03 GMT</pubDate>
            <description><![CDATA[<h1 id="api-공통-응답-포맷의-필요성">API 공통 응답 포맷의 필요성</h1>
<p>스프링에서 API 응답 방식은 보통 ResponseEntity 방식을 많이 사용하곤 한다. 데이터가 있을 때에는 문제 없이 잘 내려가지만 예외가 발생한다던가 하면 json으로 내려가지 않는 것을 본 적 있을 것이다.</p>
<p>일반 ResponseEntity 형식으로 반환하면 예외가 발생하는 경우 body가 json이 아닌 PlainText로 내려간다.</p>
<p>즉, <strong>예외가 발생했을 때 응답의 모양이 달라지는 것!!</strong></p>
<p>그리고 API의 작동 성공/실패 여부나 예외 에러메시지를 알 수 없이 DTO만 반환하면 서버 로그를 확인하기 전까진 아무 정보도 알 수 없을 것이다.</p>
<p>응답 데이터를 전달받는 주체가 사용하기 편하도록 성공했을 때와 실패했을 때 어떠한 처리 결과에도 동일한 포맷의 응답을 리턴하도록 통일시킬 필요가 있다.
또 API의 성공/실패에 따른 상태 메시지나 코드를 내려주게 되면 더욱 편하겠지?(HTTP 상태코드 말고 프로젝트 내에서 정한 약속 코드 같은 것 말이다.)</p>
<h1 id="공통-응답-필드-만들기">공통 응답 필드 만들기</h1>
<p>개발자마다 선호하는 공통 응답 필드는 다를 수 있다.
이번 포스트에선 status, data, message를 사용해보겠다.</p>
<p><strong>status</strong>: 응답 상태를 String으로 표시(Success, error 둘 중 하나 리턴)
<strong>data</strong>: 응답 결과 json(success일 경우 응답 데이터, error일 경우엔 null)
<strong>message</strong>: 예외일 경우 에러메시지 표시</p>
<p>HTTP 상태코드는 헤더에서 가져올 수 있으므로 따로 넣지 않았다.</p>
<h3 id="공통-응답-클래스-예시">공통 응답 클래스 예시</h3>
<pre><code class="language-java">package com.test.apihandler.utils;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ApiResponse&lt;T&gt; {

    private static final String SUCCESS_STATUS = &quot;success&quot;;
    private static final String FAIL_STATUS = &quot;fail&quot;;
    private static final String ERROR_STATUS = &quot;error&quot;;

    private String status;
    private T data;
    private String message;

    public static &lt;T&gt; ApiResponse&lt;T&gt; successResponse(T data) {
        return new ApiResponse&lt;&gt;(SUCCESS_STATUS, data, null);
    }

    public static ApiResponse&lt;?&gt; successWithNoContent() {
        return new ApiResponse&lt;&gt;(SUCCESS_STATUS, null, null);
    }

    public static ApiResponse&lt;?&gt; failResponse(BindingResult bindingResult) {
        Map&lt;String, String&gt; errors = new HashMap&lt;&gt;();

        List&lt;ObjectError&gt; allErrors = bindingResult.getAllErrors();
        for (ObjectError error : allErrors) {
            if (error instanceof FieldError) {
                errors.put(((FieldError) error).getField(), error.getDefaultMessage());
            } else {
                errors.put(error.getObjectName(), error.getDefaultMessage());
            }
        }
        return new ApiResponse&lt;&gt;(FAIL_STATUS, errors, null);
    }

    public static ApiResponse&lt;?&gt; errorResponse(String message) {
        return new ApiResponse&lt;&gt;(ERROR_STATUS, null, message);
    }

    private ApiResponse(String status, T data, String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }
}</code></pre>
<p>공통 응답 클래스인 ApiResponse를 만들었다.
data 필드에는 어떠한 타입이라도 들어갈 수 있도록 제네릭을 사용해주자.</p>
<h3 id="예외처리-핸들링-클래스-예시">예외처리 핸들링 클래스 예시</h3>
<pre><code class="language-java">package com.test.apihandler.utils.exception;

import com.openeg.openegscts.utils.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@RestControllerAdvice(basePackages = {&quot;com.test.apihandler&quot;})
public class ApiExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;ApiResponse&lt;?&gt;&gt; handleExceptions(RuntimeException exception) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.errorResponse(exception.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity&lt;ApiResponse&lt;?&gt;&gt; handleValidationExceptions(BindingResult bindingResult) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.failResponse(bindingResult));
    }
}</code></pre>
<h3 id="컨트롤러-사용-예시">컨트롤러 사용 예시</h3>
<pre><code class="language-java">@PutMapping(&quot;/info&quot;)
public ApiResponse&lt;UserDto&gt; editUserInfo(@RequestBody EditUserInfoDto editUserInfoDto) throws Exception {
    iAdminUserService.editUserInfo(editUserInfoDto);
    return ApiResponse.successResponse(UserDto.of(iAdminUserService.findUserInfo(editUserInfoDto.getUserId())));
}</code></pre>
<h3 id="서비스-사용-예시">서비스 사용 예시</h3>
<pre><code class="language-java">@Override
public boolean editUserInfo(EditUserInfoDto editUserInfoDto) throws Exception {
    boolean result = iAdminUserMapper.editUserInfo(editUserInfoDto);
    if (!result) {
        throw new BindingException(&quot;id:&#39;&quot; + editUserInfoDto.getId + &quot;&#39; 업데이트 실패&quot;);
    }
    return true;
}</code></pre>
<h2 id="응답-결과-테스트">응답 결과 테스트</h2>
<p>위와 같은 유저 수정 API를 만들어서 공통 응답을 테스트 해보자.
유저 수정이 성공했을 경우엔 수정된 UserDto가 공통 응답 클래스의 data에 싸여 나가게 될 것이고, 유저 수정이 실패했을 경우 공통응답 클래스의 message에 RuntimeException 에러메시지가 리턴된다.</p>
<h3 id="성공-응답-결과">성공 응답 결과</h3>
<pre><code class="language-json">{
    &quot;status&quot;: &quot;success&quot;,
    &quot;data&quot;: {
        &quot;userId&quot;: &quot;student01&quot;,
        &quot;name&quot;: &quot;김두한&quot;,
        &quot;email&quot;: &quot;fourdollars@yain.com&quot;,
        &quot;phone&quot;: &quot;010-4444-4444&quot;,
        &quot;type&quot;: 1,
        &quot;regDate&quot;: &quot;2022-12-21 01:33:01&quot;,
        &quot;groupId&quot;: 13,
        &quot;classId&quot;: 6,
        &quot;status&quot;: 1,
        &quot;expireYn&quot;: &quot;N&quot;,
        &quot;cloudAccount&quot;: {
            &quot;id&quot;: 178,
            &quot;username&quot;: &quot;am01-001&quot;,
            &quot;password&quot;: &quot;RRpJ024&#39;iia+b=G&quot;,
            &quot;url&quot;: &quot;https://1234.signin.aws.amazon.com/console&quot;,
            &quot;accessKeyId&quot;: &quot;1234567890&quot;,
            &quot;clientSecret&quot;: &quot;-&quot;,
            &quot;regDate&quot;: &quot;2023-01-11 04:13:17&quot;
        }
    },
    &quot;message&quot;: null
}</code></pre>
<h3 id="실패-응답-결과">실패 응답 결과</h3>
<pre><code class="language-json">{
    &quot;status&quot;: &quot;error&quot;,
    &quot;data&quot;: null,
    &quot;message&quot;: &quot;id: &#39;student01&#39; 업데이트 실패&quot;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github Action으로 CI/CD 구성하기]]></title>
            <link>https://velog.io/@ssol_916/Github-Action%EC%9C%BC%EB%A1%9C-CICD-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ssol_916/Github-Action%EC%9C%BC%EB%A1%9C-CICD-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 08 Dec 2022 01:03:46 GMT</pubDate>
            <description><![CDATA[<p>개발자는 반복적인 작업을 싫어하는 사람들이라고 한다.
뉴비 개발자인 나도 귀찮은 걸 싫어한다. 똑같은 행위를 계속 반복해야 한다면 이것을 시스템으로 자동화 해버리면 되지 않을까?
그래서 나온 것이 CI/CD이다.</p>
<h1 id="cicd">CI/CD</h1>
<p>개발 작업하면서 개발서버, 운영서버에 소스코드를 통합하고, 빌드하고, 배포하는 시간만 따져도 꽤 나올 것이다. 이 시간이 일주일, 1달, 1년동안 모이고 모이면 엄청나게 아깝잖아?
그래서 요즘에는 다들 CI/CD로 지속적인 통합, 지속적인 배포를 통해 
각자 개발 후 <strong>통합 ➝ 테스트 ➝ 배포</strong>까지의 애플리케이션 라이프 사이클을 자동화하고 모니터링 하고 있다.
즉, CI/CD는 반복되는 위 과정을 자동화해서 테스트까지 마치고 문제가 없을 때 배포까지 되도록 하는 것이다.
그래서 귀찮을걸 싫어하는 나도 회사 프로젝트에 CI/CD를 적용하기로 했다.
<br></p>
<p>프로젝트에 CI/CD를 적용하려면 우선 프로젝트가 어떻게 빌드되고 배포되는지 과정부터 알아야 겠지?
현재 프로젝트는 하나의 인스턴스에 FE와 BE, DB가 컨테이너로 올라가 있는 구조라서 </p>
<blockquote>
<ol>
<li>ssh로 서버에 접속해서 </li>
<li>소스코드를 pull 받아 </li>
<li>프로젝트 build</li>
<li>Docker build 한 뒤, </li>
<li>docker-compose로 실행</li>
</ol>
</blockquote>
<p>방식을 사용하고 있다.
<br></p>
<p>원래는 이 과정을 모두 서버에 직접 접속해 수동으로 해야만 했는데 
CI/CD를 구축하면 문제가 없는 코드는 알아서 통합되고 빌드되어 배포까지 된다는 말씀.</p>
<p>그럼 바로 CI/CD를 위한 툴을 찾아보자.</p>
<h2 id="cicd-툴">CI/CD 툴</h2>
<h3 id="jenkins">Jenkins</h3>
<p>CI/CD를 위한 툴에는 여러 종류가 있는데 가장 유명한 툴로는 _<strong>Jenkins</strong>_가 있다.
<img src=https://www.jenkins.io/images/logos/jenkins/jenkins.svg></p>
<p>무료이면서 일상적인 개발 작업을 자동화할 뿐 아니라 파이프라인(Pipeline)을 사용해 거의 모든 언어의 조합과 소스코드 리포지토리에 대한 지속적인 통합과 지속적인 전달 환경을 구축하기 위한 간단한 방법을 제공하고 있다.</p>
<p>프로젝트의 표준 컴파일 환경에서 컴파일 오류 검출해주고, 자동화 테스트도 수행해주며, 프로파일링을 통해 소스 변경에 따른 성능의 변화도 감시할 수 있다. 그리고 개발 업무를 도와주는 많은 플러그인을 가지고 있다.(소나큐브 등)
그리고 많은 사람들이 이용하기 때문에 인터넷에 각종 문서나 참고 자료도 많다는 것도 장점이다.</p>
<p>하지만 이렇게 마냥 장점만 있지는 않은데, 서버에 따로 설치가 필요하며 호스팅을 하나부터 열까지 관리해야해서 비용이 좀 드는 편이다.
<br></p>
<p>나도 예전에 토이 프로젝트를 하면서 젠킨스 서버를 만들어서 CI/CD를 구축한 적이 있어서 이번에도 젠킨스를 사용하려 했는데...</p>
<h3 id="github-actions">Github Actions</h3>
<p>요즘은 Github에 Github Actions라고 자동화 도구가 나왔더라고?
공부하려고 좀 찾아봤더니 카카오 웹툰에서도 사용 중인 툴이면서 젠킨스와는 다르게 설치나 세팅이 필요없이 깃허브를 통해 바로 쓸수 있는게 장점이었다.</p>
<p>레포지토리마다 yaml을 사용해 자동화 작업을 수행할 흐름인 <strong>workflow</strong>를 설정할 수 있는데 레포지토리마다 최대 20개까지 설정이 가능하며, 이 workflow는 깃허브에서 발생하는 push, merge 같은 이벤트를 기반으로 작동하게 된다. 
Workflow는 <strong>Runners</strong>라고 불리는 Github에서 호스팅 하는 Linux, macOS, Windows 환경에서 실행된다.</p>
<p>게다가 Github의 Market Place에는 여러 사람들이 공유한 workflow가 올라와 있으며, 자신이 직접 만든 workflow도 공유할 수 있다.
템플릿화 되어있는 workflow 스크립트라니 완전 개꿀이지 않은가?
<br></p>
<p>그외 툴로 Travis CI나 Circle CI 처럼 좋은 유료 서비스도 있긴 하지만...</p>
<h2 id="github-actions으로-정한-이유">Github Actions으로 정한 이유</h2>
<blockquote>
<p>뭐 일단 당연히 무료이니까!
그리고 Jenkins보다 구축하기 편해보이니까!!!</p>
</blockquote>
<p>예전에 Jenkins 서버를 구축하는데에도 고생을 했었고 AWS 파이프라인을 짜는 데에도 애를 먹었었거든. 
일단 빠르게 구축하고 개발 우선 해야 하는 상황이니 빠르게 구축할 수 있고, 만드는 수고도 덜 들어가는 툴로 정하게 되었다.</p>
<h1 id="내-프로젝트의-cicd-프로세스">내 프로젝트의 CI/CD 프로세스</h1>
<p>이건 어디까지나 내 프로젝트에 적용하기 위해 사용한 방법으로 ssh를 사용하는 프로젝트에는 참고가 되겠지만 나머지 경우에는 다른 블로그를 찾아보는게 빠를 것이다.</p>
<p>위에 설명한 프로젝트 배포 방식에서 최대한 프로세스 변경 없이 CI/CD를 적용하자면</p>
<blockquote>
<ol>
<li>소스코드가 github에 push될 때를 트리거로
(또는 특정 브랜치에 push, merge 될때만)</li>
<li>배포할 서버에 ssh 접속</li>
<li>프로젝트 경로로 이동 후 push 된 소스코드 pull</li>
<li>프로젝트 build와 docker build가 묶여있는 쉘스크립트 파일을 실행</li>
<li>프로젝트 build, 도커 build 모두 성공시에 docker-compose up -d --build 실행
 (하나라도 실패시 그대로 종료)</li>
<li>위 과정이 종료되면 workflow 결과를 Slack 알림 채널에 메시지로 전송</li>
</ol>
</blockquote>
<p>이렇게 하면 될 것이다.</p>
<h2 id="사전-작업">사전 작업</h2>
<p>우선 CI/CD를 구축하기 전에 사전 준비를 해보자.
Github Action으로 서버에 ssh 접속 하는 yaml 스크립트를 짜야하는데, 위에 Github Action을 소개할 때 다른 사람들이 만들어 놓은 workflow를 Market Place에서 사용할 수 있다고 한거 기억나지?
Market Place에서 ssh 접속할 때 가장 많이 사용되는 appleboy의 ssh remote command를 사용해보자.
<a href="https://github.com/marketplace/actions/ssh-remote-commands">https://github.com/marketplace/actions/ssh-remote-commands</a></p>
<p>ssh remote command 액션의 정보를 찾아보니 password를 이용한 접속보다는 key를 이용하는 것이 문제가 생길 확률이 더 적다고 한다. 그래서 기존 password로 ssh 접속 방식에서 key 방식으로 변경을 하기로 하였다.</p>
<p>ssh key 방식으로 접속할 수 있게 세팅해보자.</p>
<h3 id="ssh-keygen으로-서버-ssh-접속용-키-발급받기">ssh-keygen으로 서버 ssh 접속용 키 발급받기</h3>
<p>ssh remote command 액션의 문서에 있는 ssh 접속용 key 발급 명령어를 사용해 public키와 private키를 발급받는다.</p>
<pre><code class="language-bash">ssh-keygen -t rsa -b 4096 -C &quot;example@gmail.com&quot;</code></pre>
<p>터미널에 위 명령어를 사용하면 추가로 3번 묻는다.
1번째는 키를 저장할 경로( 기본값 : $HOME/.ssh/id_rsa)
2번째는 passphrase (추가로 사용할 암호, 기본값 없음)
3번째는 passphrase 확인</p>
<p>그냥 엔터 3번 눌러서 passphrase 없이 기본 경로에 저장하도록 하고 생성된 공개 키와 비밀키 중 공개 키를 서버에 넣어줘야 한다.
서버에 접속해서 홈(/) 경로에 있는 .ssh 폴더안에 authorized_keys 파일을 생성하고 이 안에 공개 키 값을 넣어주면 된다. 만약 홈 경로에 .ssh 폴더를 생성하고 넣어주면 된다.
(공개 키 넣을 때 주의 할 점!!!!!!! 절대 마지막에 엔터가 들어가면 안된다. 스페이스나 엔터 등 공개 키 값 외에는 다른 거 넣지 않기)</p>
<p>비밀 키는 ssh 접속할 때 사용하면 되는데 이것을 스크립트에 그대로 노출시키면 내 서버는 그날로 공공재가 되는 날이다.</p>
<p>그렇다면 비밀 값들은 어떻게 처리해야할까?</p>
<h3 id="github-action의-secrets">Github Action의 secrets</h3>
<p>이런 상황을 해결하기 위해 당연히 Github Action에도 암호화 처리가 다 되어있지!
<img src="https://velog.velcdn.com/images/ssol_916/post/9afe470c-146c-4eae-b136-4d36e6804636/image.png" alt=""></p>
<p>깃허브의 레포지토리 세팅에서 secrets에 있는 Actions로 가보면 암호화 될 값을 넣어주는 곳이 있다.
<img src="https://velog.velcdn.com/images/ssol_916/post/17b4bec6-fb57-4f33-9a96-0172bd1827de/image.png" alt=""></p>
<p>여기에 비밀키나 서버 ip, 계정 명 등을 넣어서 보호할 수 있다.
SSH 접속에 필요한 값은 모두 이곳에 넣어서 숨기자.</p>
<ul>
<li>서버 ip</li>
<li>계정 명</li>
<li>비밀키
(비밀키를 넣을 때에는 앞뒤에 있는 -----BEGIN OPENSSH PRIVATE KEY-----와 -----END OPENSSH PRIVATE KEY-----부분까지 모두 넣어줘야 한다.)</li>
<li>포트 번호</li>
</ul>
<p>값은 키와 벨류 형태로 들어가게 되며, 이렇게 등록한 secrets는 <strong>${{ secrets.XXX }}</strong> 이렇게 키값으로 사용할 수 있다.</p>
<p>이제 사전 준비는 끝났으니 자동화 스크립트를 작성해보자.</p>
<h2 id="workflow-yaml-작성하기">Workflow yaml 작성하기</h2>
<p>Github Actions는 프로젝트 최상위에 .github/workflows 폴더 안에 있는 yml을 읽어서 작동하도록 되어있다.
이 yml 파일을 생성하는 것은 IDE에서 바로 작성해도 되고, 깃허브 레포지토리에 있는 Actions 탭을 사용하여 주어진 템플릿으로 생성할 수 도 있다.</p>
<h3 id="통합과-빌드-그리고-배포">통합과 빌드 그리고 배포</h3>
<pre><code class="language-yaml">name: ssh for dev deploy # workflow의 이름을 지정해준다.

on: # 작동 트리거로 설정할 부분을 작성하는 곳이다.
  push: # 이 스크립트는 개발서버용 이므로 dev 브렌치에 push될 때 작동하도록 해두었다.
    branches: [ dev ]

# jobs에선 action의 단계(step)를 설정할 수 있다. 
# 여러 개의 job을 사용할 수 있고, job끼리 서로 정보를 교환할 수도 있다.
jobs: 
  build:
    name: Build # job의 이름을 지정해 준다.
    runs-on: ubuntu-latest # job을 실행할 환경을 정해준다.

    steps:
      # Github Actions는 해당 프로젝트를 리눅스 환경에 checkout하고 나서 실행한다.
      # 꼭 필요한 과정!
      # 누가 만들어 놓은 Action을 사용할 때에는 uses 키워드를 사용한다.
      - uses: actions/checkout@v2 

      # step의 이름을 지정해준다.
      - name: SSH Remote Commands
      # 위에 말했던 appleboy의 Action을 사용
        uses: appleboy/ssh-action@v0.1.4
        # with라는 키워드로 Action에 값을 전달할 수 있다.
        # 아까 설정했던 secrets를 사용해서 값을 가져오자.
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          # ssh 연결이 아무리 늦어도 20초 정도면 된다. 
          # 이 이상 끌게되면 사실상 접속 실패이므로 40초 타임아웃을 걸어두자
          timeout: 40s 

          # ssh 접속이 되면 실행할 스크립트
          script: |
            echo &quot;#START&quot;
            cd 내 프로젝트 경로

            echo &quot;############# GIT PULL #############&quot;
            pass=$(sudo git pull origin dev)
            echo $pass
            if [ -n &quot;$pass&quot; ]; then 
              echo &quot;############# SH BACK-DEPLOY.SH #############&quot;
              build=&#39;Successfully built&#39;
              pass2=$(sh back-deploy.sh)
              echo $pass2
              if [[ &quot;${pass2}&quot; == *&quot;${build}&quot;* ]]; then 
                echo &quot;############# DOCKER-COMPOSE UP #############&quot;
                sudo docker-compose up -d --build
              else 
                echo &quot;############## Build Fail!! ##############&quot;
                exit 1;
              fi
            else
              echo &quot;############## git pull: Error ##############&quot;
              exit 1;
            fi</code></pre>
<p>ssh 접속 후 실행할 스크립트에는 소스코드를 pull을 시도하고 오류가 발생한다면 exit 1로 오류 상태를 리턴하며 종료시키고, 오류가 발생하지 않는다면 프로젝트 빌드와 도커 빌드 명령어가 있는 sh 파일을 실행시킨다.
(bash에서 0은 성공인 종료 상태를 나타내고 1~255는 오류코드를 나타낸다.)
빌드 과정에서 오류가 발생하면 역시 exit 1로 오류 상태를 리턴하며 종료. 빌드 오류가 발생하지 않으면 docker-compose up을 실행한다.</p>
<p>이렇게 하면 dev 브렌치에 push가 되거나 merge가 될 때마다 코드를 통합하고, 빌드, 배포까지 자동으로 이뤄지게 된다.
이 과정은 깃허브 레포지토리의 Actions 탭에서 실시간 로그도 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/9230353b-0890-4df3-8231-952504b8cd39/image.png" alt=""></p>
<p>트리거를 작동시키면 아주 작동이 잘 되는 것을 확인할 수 있다! 
secrets로 넣은 값은 로그에서 ***로 표시되고 만약 실패시 어느 부분에서 실패했는지, 무슨 이유 때문인지도 로그에 모두 기록된다.👍</p>
<p>이렇게 pull, 빌드, 배포 과정까지 모두 자동화가 되어 엄청 편해졌긴 하지만 이 자동화 실행 결과가 성공했는지 실패했는지는 깃허브에 들어가야 알 수 있다.
진정한 자동화 마무리를 하려면 실행이 끝나고 나면 결과 상태까지 알림으로 보내도록 해서 손가락 하나 까딱 안하고 확인까지 할 수 있어야겠지?</p>
<h3 id="slack으로-알림-보내기">Slack으로 알림 보내기</h3>
<p>push 후 결과 상태까지 바로 알 수 있도록 Slack에 웹훅으로 알림을 보내는 액션을 만들어주면 된다.</p>
<p>.github/actions 경로에 slack-notify 폴더를 만들고 그 안에 action.yml을 생성해주자.</p>
<pre><code class="language-yaml"># 액션의 이름은 slack-notify
name: &#39;slack-notify&#39;

# input으로 받을 값들
inputs:
  status:
      # 필수 값을 정해줄 수 있는데, status는 필수 옵션을 제거하고
      # 값이 들어오지 않는다면 기본 값으로 failure를 사용하도록 한다.
    required: false
    default: &#39;failure&#39;
  slack_incoming_url:
    required: true

runs:
  # using: composite 키워드는 액션을 직접 만들어 사용한다는 의미
  using: &#39;composite&#39;
  steps:
    - name: Send slack
      # shell 스크립트를 사용할 건데 bash 쉘을 사용하도록 설정했다.
      shell: bash
      # run 뒤에 |를 사용해서 여러 줄의 스크립트를 사용할 수 있다.
      run: |
          # 전달받은 값을 이용해서 성공/실패를 판단하고 그에 따른 이모티콘 설정
        if [ &quot;${{ inputs.status }}&quot; = &quot;success&quot; ]; then
          EMOTICON=&quot;✅&quot;
        else
          EMOTICON=&quot;⛔️&quot;
        fi

        # ${GITHBU_REPOSITORY}, ${GITHUB_WORKFLOW}, ${GITHUB_RUN_ID} ..
        # 이런 값들은 GitHub Actions에서 제공하는 환경변수 값들이다.
        # 저는 환경변수들을 이용해서 슬랙 알림이 왔을 때 어떤 부분에서 실패했는지 
        # GitHub 레포지토리와 workflow 링크를 넣어주도록 하자.
        MSG=&quot;{ \&quot;text\&quot;:\&quot;&gt;${EMOTICON} workflow (&lt;https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}|${GITHUB_WORKFLOW}&gt;) in &lt;https://github.com/${GITHUB_REPOSITORY}|${GITHUB_REPOSITORY}&gt;\n&gt;&lt;https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}/checks|${GITHUB_JOB}&gt; job ${{ inputs.status }}, branch=\`${GITHUB_REF#refs/heads/}\`\&quot;}&quot;

        # Slack에 보낼 메시지 내용
        curl -X POST -H &#39;Content-type: application/json&#39; --data &quot;${MSG}&quot; &quot;${{ inputs.slack_incoming_url }}&quot;</code></pre>
<p>이제 만든 slack 메시지 전송 액션을 원래 사용하던 깃헙 액션 yaml의 뒷 부분에 붙여주기만 하면 된다.</p>
<pre><code class="language-yaml">...
              echo &quot;############## git pull: Error ##############&quot;
              exit 1;
            fi

      # 위에서 만든 Slack 알림 액션을 기존 deploy.yml 액션 아래에 이어서 붙여주자.
      # 실패시 Slack에 알림을 보낼 step 이름
      - name: Send slack when failed
        # 실패 값이 들어오면
        if: ${{ failure() }}
        # 해당 경로에 있는 action.yml 액션을 사용할건데
        uses: ./.github/actions/slack-notify
        with:
          # Slack 웹훅 주소를 액션에 전달해준다.
          slack_incoming_url: ${{ secrets.SLACK_INCOMING_URL }}

      # 성공시 Slack에 알림을 보낼 step 이름
      - name: Send slack if completed
        # 성공일때에만
        if: ${{ success() }}
        # 해당 경로에 있는 action.yml 액션을 사용할건데
        uses: ./.github/actions/slack-notify
        with:
          # success 값과 Slack 웹훅 주소를 액션에 전달해준다.
          status: success
          slack_incoming_url: ${{ secrets.SLACK_INCOMING_URL }}</code></pre>
<p>슬랙 채널 웹훅 링크는 접속 키처럼 공개되면 안되기 때문에 secrets으로 넣어주자.</p>
<p>이제 트리거 브렌치에 push를 하면
<img src="https://velog.velcdn.com/images/ssol_916/post/6c3b8a55-0ead-4f6f-aa2e-d6e6ae563c03/image.png" alt=""></p>
<img src = "https://w.namu.la/s/3dde581b404a3b6e52ceeb2fc8ba5880d826d56ac6890dd0fea574a47b1bd95e83d69d505a1cfdc0a76ff7564753f123ce6e811ce7f30cce56ab8c219f99602839b912f8d8346706a31ca7fe997f1bda96d88259f2ec1a5f16dbabda7e2aaf43">

<p>이제부터는 배포하려고 컴퓨터 앞에 계속 앉아있을 필요 없이 소스코드를 pull이나 merge만 해두고 커피 한잔 내리러 갔다오면 알아서 결과 보고까지 받을 수 있게 되었다.</p>
<p>나중에 ECS를 이용한 AWS 3tier 아키텍쳐로 변경하게 되면 workflow를 손봐야 겠지만 당분간 사용하기에는 문제 없을 것 같다.</p>
<p>GitHub CLI를 이용하면 굳이 push를 하지 않더라도 repository에 있는 브랜치의 GitHub Actions를 실행할 수 있다고 하니 나중에 이것도 실험해봐야지.
<a href="https://github.blog/2021-04-15-work-with-github-actions-in-your-terminal-with-github-cli/">https://github.blog/2021-04-15-work-with-github-actions-in-your-terminal-with-github-cli/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Logback 사용하기 -1-]]></title>
            <link>https://velog.io/@ssol_916/Logback</link>
            <guid>https://velog.io/@ssol_916/Logback</guid>
            <pubDate>Tue, 27 Sep 2022 02:45:34 GMT</pubDate>
            <description><![CDATA[<p>최근 프로젝트와 외부활동 이것 저것 하다보니 바쁘다는 핑계 반, 귀찮다는 핑계 반으로 블로그 정리를 안했었다... 과거의 멍청했던 나를 반성하며 그동안 프로젝트에 적용하면서 노션에만 정리해뒀던 내용을 다시 블로그에 정리해보려 한다.
<br></p>
<p>모니터에 이상한 글자들이 주르륵 올라가고 그것을 유심히 보는 해커 또는 프로그래머. 아마 영화에서 그런 장면 한번 쯤은 본 적 있을 것이다. 여기서 올라가는 글자들이 대부분 로그이다.</p>
<p>서비스를 개발하는 단계이든, 운영중인 단계이든 로깅을 하는 작업은 매우 중요하다.
이 로그를 통해서 프로그램이 어떻게 작동하고 있는지, 어떤 어디서 오류가 발생했는지 쉽게 파악할 수 있기 때문.</p>
<br>

<pre><code class="language-java">System.out.println(&quot;Hello, world&quot;);
System.err.println(&quot;Error!!&quot;)</code></pre>
<p>자바 공부를 처음 시작하고 Hello, world 찍을 때 Sysyem.out를 사용한 경험이 있을 것이다. 이 방법으로 로그를 찍으면 될까??</p>
<p align="center">
<img src="https://velog.velcdn.com/images/ssol_916/post/fb82ab57-3c24-464b-8b86-89ed2ca59279/image.jpeg" width=30% alt="X">
</p>
Sysyem.out은 상세한 로그를 찍기도 힘들고 로그 레벨을 관리, 로그를 제어하거나 필터링 할 수가 없다.
그렇다면 Spring boot에서는 어떤 방식으로 로그 처리를 할 수 있을까?


<h1 id="logback이란">Logback이란?</h1>
<p>Logback은 오픈소스 로깅 프레임워크이며 SLF4J의 구현체이자, 스프링부트에 기본으로 내장되어 있는 로깅 라이브러리이다.
log4j보다 좋은 성능을 보여준다고 한다.(log4j는 2015년에 지원을 중단했으니 당연한 결과겠지만)
그래서 대부분 log4j2와 logback 중 자신의 프로젝트에 맞는 것을 선택해서 사용하게 된다.</p>
<p>log4j2는 얼마전에 큰 보안 이슈가 터졌었는데 logback을 사용해 로깅을 한 시스템들은 해당 이슈에 대응할 필요가 없었을 것이다.
물론 log4j2는 멀티 쓰레드 환경에서 비동기 로 처리가 아주 빠르다는 장점이 있어서 보안 취약점이 개선된 지금은 아무 문제 없이 선택해도 된다.</p>
<p>하지만 오늘의 주제는 logback이므로 logback에 대해서만 다뤄보도록 하겠다.
우선 log4j2와 logback은 slf4j의 구현체라고 했는데 이 slf4j가 무엇일까?</p>
<h2 id="slf4j">SLF4J??</h2>
<p>slf4j는 여러 로깅 라이브러리들을 하나의 통일된 방식으로 사용하도록 방법을 제공하기 위한 것이다.</p>
<p>즉, 로깅 추상 레이어를 제공하는 <strong>인터페이스</strong>이다.</p>
<p>이 slf4j 덕분에 애플리케이션은 어떤 로깅 라이브러리를 사용하던 같은 방법으로 로그를 남길 수 있는 것.
그래서 로그 라이브러리를 교체하는 일이 발생하더라도 애플리케이션의 코드가 변경될 필요는 없다.</p>
<h2 id="slf4j의-구현체-중-하나-logback">SLF4J의 구현체 중 하나, Logback</h2>
<p>이제 logback을 설정하는 방법에 대해 알아보자.</p>
<h3 id="appender">Appender</h3>
<p>Appender는 어디에 어떤 포멧으로 로그를 남길지를 설정하는 부분이다.</p>
<ul>
<li>ConsoleAppender: 콘솔에 로그를 어떤 포멧으로 남길지 설정할 수 있다.</li>
<li>FileAppender: 파일에 로그를 어떤 포멧으로 남길지 설정할 수 있다.</li>
<li>RollingFileAppender: 로그의 양이 많아지면 하나의 파일로 관리하기 어렵겠지? 이러한 문제 때문에 하루 단위로 로그 파일을 관리할 때 설정한다.</li>
</ul>
<p>위 3가지 Appender의 Pattern 요소에는 출력하고 싶은 포멧을 적는데, 보통 날짜와 시간, 로그의 레벨을 기록하는 편이다.</p>
<p>RollingFileAppender의 rollingPolicy 옵션에는 파일이 언제 백업될지 설정할 수 있다.
하루단위 로그 파일이 생성되며, maxHistory 옵션 개수만큼 생성되고 해당 개수를 초과하면 이전 로그 파일은 삭제된다.</p>
<h3 id="level-logger">level, logger</h3>
<p>로그 레벨은</p>
<ol>
<li>error</li>
<li>warn</li>
<li>info</li>
<li>debug</li>
<li>trace</li>
</ol>
<p>가 있는데, 위로 갈 수록 레벨이 높은 로그이다.</p>
<p>logger는 실제 로그 기능을 수행하는 객체로 각 Logger마다 &quot;name&quot;을 통해 구분한다. 최상위 로거인 Root Logger를 설정하면 이를 계층적으로 어떤 패키지 이하의 클래스에서는 어떤 레벨 이상의 로그만 출력할지 설정 할 수 있다.</p>
<p>class 에서 로그를 출력하는데 사용된 logger가 존재하지 않는다면, 부모 로거를 찾는다.</p>
<p>여기서 debug 이하 레벨은 주로 개발 과정에서만 쓰이게 되고, error 레벨은 애플리케이션이 멈출 수 있는 치명적인 에러인 경우가 많으므로 잘 쓰이지 않는다고 한다.
그래서 현재 진행중인 프로젝트에 로그백을 세팅할 때에도 개발 과정에 확인이 필요한 로그는 debug를 사용했고, 그 외에는 주로 info 레벨과 warn 레벨을 사용했다.</p>
<h3 id="로그-출력-메서드">로그 출력 메서드</h3>
<pre><code class="language-java">logger.info(&quot;{} {} 출력&quot;, &quot;값1&quot;, &quot;값2&quot;);</code></pre>
<p>주의할 점은 문자열을 연결하기 위해 &#39;+&#39; 를 사용하면 안된다는 점이다.</p>
<p>&#39;+&#39;를 사용하면 Sysyem.out.print처럼 문자열 연결을 되지만 문자열 연산이 먼저 일어나서 문자열 연산만큼의 성능 악화가 발생할 수 있다.
그래서 문자열을 하나로 길게 적되 {}를 사용해서 변수가 들어갈 곳을 지정해주고 ,(콤마) 뒤에 순서대로 변수를 넣어주면 된다.</p>
<h1 id="로그백-설정하기">로그백 설정하기</h1>
<p>위에서 말한 Appender, logger등을 사용해서 필요한 설정을 해주어야 하는데, 검색을 해보아도 아마 대부분 xml 파일을 이용해서 설정하는 예제일 것이다. 하지만 이 설정 파일은 당연히 자바 코드로도 설정이 가능하다.</p>
<p>나는 현재 프로젝트에 자바 코드로 설정을 했는데, xml 방식은 다른 블로그에 정리된 예제가 많이 있기 때문에 자바 코드로 설정하는 방법을 소개하려 한다.</p>
<p>우선 xml 방식을 살펴보자.</p>
<h2 id="xml-방식">xml 방식</h2>
<p>logback.xml을 사용할 수도, logback-spring.xml을 사용할 수도 있는데 Spring boot 에서는 logback.xml로 설정하면 스프링 부트에대한 설정 전에 로그백 설정이 되므로 제어 할 수가 없다고 한다.
따라서 xml방식을 사용할 땐 logback-spring.xml을 이용하도록 하자.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; ?&gt;
&lt;configuration&gt;
    &lt;conversionRule conversionWord=&quot;clr&quot; converterClass=&quot;org.springframework.boot.logging.logback.ColorConverter&quot; /&gt;

    &lt;springProperty name=&quot;SLACK_WEBHOOK_URI&quot; source=&quot;logging.slack.webhook-uri&quot;/&gt;
    &lt;appender name=&quot;SLACK&quot; class=&quot;com.github.maricn.logback.SlackAppender&quot;&gt;
        &lt;webhookUri&gt;${SLACK_WEBHOOK_URI}&lt;/webhookUri&gt;
        &lt;layout class=&quot;ch.qos.logback.classic.PatternLayout&quot;&gt;
            &lt;pattern&gt;%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n&lt;/pattern&gt;
        &lt;/layout&gt;
        &lt;username&gt;Sslc-Server-log&lt;/username&gt;
        &lt;iconEmoji&gt;:stuck_out_tongue_winking_eye:&lt;/iconEmoji&gt;
        &lt;colorCoding&gt;true&lt;/colorCoding&gt;
    &lt;/appender&gt;

    &lt;!-- Console appender 설정 --&gt;
    &lt;appender name=&quot;Console&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&gt;
        &lt;encoder&gt;
            &lt;Pattern&gt;%d %-5level %logger{35} - %msg%n&lt;/Pattern&gt;
        &lt;/encoder&gt;
    &lt;/appender&gt;

    &lt;appender name=&quot;ASYNC_SLACK&quot; class=&quot;ch.qos.logback.classic.AsyncAppender&quot;&gt;
        &lt;appender-ref ref=&quot;SLACK&quot;/&gt;
        &lt;filter class=&quot;ch.qos.logback.classic.filter.ThresholdFilter&quot;&gt;
            &lt;level&gt;ERROR&lt;/level&gt;
        &lt;/filter&gt;
    &lt;/appender&gt;

    &lt;root level=&quot;INFO&quot;&gt;
        &lt;appender-ref ref=&quot;Console&quot;/&gt;
        &lt;appender-ref ref=&quot;ASYNC_SLACK&quot;/&gt;
    &lt;/root&gt;
&lt;/configuration&gt;</code></pre>
<p>위 xml 파일은 콘솔 로그를 패턴을 설정하는 부분과 slack-appender 라이브러리를 사용해 슬랙으로 에러 로그만 필터링해서 보내기 위한 설정 부분이다.</p>
<p>개인적으로 xml 파일 형식을 별로 좋아하지 않아 가능한 최소한으로 사용하자는 생각을 갖고 있기 때문에 위 코드는 정말 맘에 들지 않는다. 굳이 xml로 써야할 이유도 없고...</p>
<p>그렇다면 자바 코드로 설정을 하려면 어떻게 사용해야 할까?</p>
<h2 id="java-코드-방식">Java 코드 방식</h2>
<p>이번에는 자바 코드 방식으로 콘솔 로그 패턴을 지정하는 것과 로그 필터링을 해서 롤링 후 로그파일로 저장하는 것을 알아보겠다.</p>
<h3 id="console-appender">console Appender</h3>
<p>우선 콘솔 로그 패턴을 지정하는 방식을 보자.</p>
<pre><code class="language-java">@Configuration
public class LogBackConfig {

    // 공통 필드, 어펜더 별 설정을 달리 할 경우 지역변수로 변경 하면 됨
    private final LoggerContext logCtx = (LoggerContext) LoggerFactory.getILoggerFactory();

    private final String pattern = &quot;%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n&quot;;

    // 어펜더 목록, 다른 어펜더가 필요할 경우 추가하면 됨
    private ConsoleAppender&lt;ILoggingEvent&gt; consoleAppender;

    @Bean
    public void logConfig() {
        consoleAppender = getLogConsoleAppender();
        createLoggers();
    }

    private void createLogger(String loggerName, Level logLevel, Boolean additive) {
        Logger logger = logCtx.getLogger(loggerName);

        logger.setAdditive(additive);
        logger.setLevel(logLevel);
        logger.addAppender(consoleAppender);
    }

    private void createLoggers() {
        // 로거 이름, 로깅 레벨, 상위 로깅 설정 상속 여부 설정
        createLogger(&quot;root&quot;, INFO, true);
        createLogger(&quot;jdbc&quot;, OFF, false);
        createLogger(&quot;jdbc.sqlonly&quot;, DEBUG, false);
        createLogger(&quot;jdbc.sqltiming&quot;, DEBUG, false);
        createLogger(&quot;{패키지 경로}&quot;, INFO, false);
        createLogger(&quot;{패키지 경로}.*.controller&quot;, DEBUG, false);
        createLogger(&quot;{패키지 경로}.*.service&quot;, WARN, false);
        createLogger(&quot;{패키지 경로}.*.repository&quot;, INFO, false);
        createLogger(&quot;{패키지 경로}.*.security&quot;, DEBUG, false);
    }

    /**
     * 콘솔 로그 어펜더 생성
     * @return 콘솔 로그 어펜더
     */
    private ConsoleAppender&lt;ILoggingEvent&gt; getLogConsoleAppender() {
        final String appenderName = &quot;STDOUT&quot;;

        PatternLayoutEncoder consoleLogEncoder = createLogEncoder(pattern);
        return createLogConsoleAppender(appenderName, consoleLogEncoder);
    }


    private PatternLayoutEncoder createLogEncoder(String pattern) {
        PatternLayoutEncoder encoder = new PatternLayoutEncoder();
        encoder.setContext(logCtx);
        encoder.setPattern(pattern);
        encoder.start();
        return encoder;
    }

    private ConsoleAppender&lt;ILoggingEvent&gt; createLogConsoleAppender(String appenderName, PatternLayoutEncoder consoleLogEncoder) {
        ConsoleAppender&lt;ILoggingEvent&gt; logConsoleAppender = new ConsoleAppender&lt;&gt;();
        logConsoleAppender.setName(appenderName);
        logConsoleAppender.setContext(logCtx);
        logConsoleAppender.setEncoder(consoleLogEncoder);
        logConsoleAppender.start();
        return logConsoleAppender;
    }
}</code></pre>
<p>설정 클래스이므로 @Configuration을 붙여서 생성해주고
logger에 로깅 레벨을 지정. 인코더에 로깅 패턴을 지정해 준 뒤, 빈으로 등록해준다.</p>
<p><img src="https://velog.velcdn.com/images/ssol_916/post/6a85b16f-bf57-44d5-b8c7-ac8f5e21b637/image.png" alt=""></p>
<p>위 설정으로 실제 애플리케이션을 작동시켜서 로그를 찍고 확인을 해보면 내가 설정한 패턴대로 로그가 잘 나오는 것을 확인해볼 수 있다.(로그에 색깔 지정도 가능. 패턴에서 쓰레드와 로그 레벨에 색깔을 지정해주었다.)</p>
<p>위 로그는 인터셉터를 통해 요청이 들어올 때와 어느 곳에서 어떤 메서드가 처리되는지 확인하기 위해 로그를 찍어 둔 것이다.</p>
<h3 id="rolling-appender">Rolling Appender</h3>
<p>다음은 이 로그들을 RollingAppender를 통해 일 단위로 로그 파일로 저장하는 것을 해보자.</p>
<p>[계속 작성중...]</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT를 이해하기 전에 알아보는 세션]]></title>
            <link>https://velog.io/@ssol_916/JWT%EB%A5%BC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-%EC%A0%84%EC%97%90-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%84%B8%EC%85%98</link>
            <guid>https://velog.io/@ssol_916/JWT%EB%A5%BC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-%EC%A0%84%EC%97%90-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%84%B8%EC%85%98</guid>
            <pubDate>Tue, 09 Aug 2022 14:54:11 GMT</pubDate>
            <description><![CDATA[<p>JWT란?</p>
<ul>
<li>JSON Web Token</li>
</ul>
<p>JWT는 어디 사용되고, 어떻게 사용되는가?
하지만 JWT를 알아보기 전에 우선 세션부터 알아보자.</p>
<h1 id="세션session">세션(Session)</h1>
<blockquote>
<p>웹 사이트의 여러 페이지에 걸쳐 사용되는 사용자 정보를 저장하는 방법 중 하나로 <strong>웹 서버가 세션 아이디 파일을 만들어 서비스가 돌아가고 있는 서버에 저장을 하는 것</strong>을 말한다.</p>
</blockquote>
<p>유저가 웹 브라우저를 켜고 <a href="http://www.naver.com%EC%9D%84">www.naver.com을</a> 입력했다고 하자. (GET 방식)</p>
<p>서버는 해당 주소에 맞는 컨트롤러의 메서드를 찾는다. 그리고 그 메서드에서 메인 페이지에 맞는 html 파일을 리턴해준다.</p>
<p>이때 http 헤더에 어떤 것을 달고 보낸다. 뭘 달아서 보내나?
바로 쿠키를 담아서 보내는데 이 쿠키에 세션 ID를 생성해서 담아 보낸다.
(여기서 세션ID의 예시로 1234라고 하겠다.)</p>
<p>웹 브라우저는 이 세션 ID를 받아서 자동으로 쿠키라는 저장 영역에 세션 ID(1234)를 담는다.</p>
<p>이것이 언제 만들어지느냐? 바로 최초 요청시 만들어진다.</p>
<p>두번째 요청부터는 헤더에 세션 ID(1234)를 달고 간다. (첫 요청처럼 서버에서 세션 ID를 새로 생성하는 것이 아니라 기존 것 1234를 서버가 그대로 돌려주는 것)</p>
<h3 id="세션-id의-역할은-무엇인가">세션 ID의 역할은 무엇인가?</h3>
<p>예를 들어보자면 기억력이 좋지 못한 친구집에 놀러갔다고 하자. 그 친구는 상대가 집에 방문한적이 있는지 없는지 구분하기 위해 카드를 나눠줘서 기억을 하는 친구이다. 내가 그 친구집에 첫 방문을 하면 “너 우리집에 처음왔지? 이 카드 받아”하고 처음 방문한 사람에게 나눠주는 카드를 준다. 두번째 방문할 때에 이 카드를 들고가지 않으면 친구가 “너 우리집에 처음온거야?”하고 물어볼 것이다. 그 친구는 카드가 없으면 이전에 방문했던 것은 기억을 하지 못하는 친구이기 때문에 재방문인지 구분을 위해 방문 카드가 필요한 것이다. 
재방문 때 카드를 보여주면 그 친구는 내가 저번에 방문 했었구나하고 알 수 있다.</p>
<p>여기서 이 기억력이 나쁜 친구가 바로 &#39;서버&#39;이고 방문 카드가 바로 &#39;세션&#39;, 그리고 카드에 적힌 번호가 &#39;세션 ID&#39;이다. 서버는 방문 카드를 들고가지 않으면 처음 방문한건지, 재방문인지 구분을 하지 못한다. 그래서 서버는 처음 방문할 때 무조건 카드를 발급해준다. 그리고 재방문시 카드를 들고오면 재방문이라는 것을 인식한다.</p>
<p>그렇다면 한번도 방문한적 없는 친구가 카드를 위조해서 들고가면 서버는 구분하지 못하겠지? </p>
<p>이것을 해결하기 위해서 서버는 카드를 만들어줄 때마다 목록을 만들어 둔다. 첫번째로 방문한 친구 철수에게는 1234라는 카드를 발급해주면서 목록에 기록해두고, 첫번째 방문한 영희에게는 7890이라는 카드를 발급해주면서 목록에 기록해둔다. 
그래서 철수가 아닌 사람이 서버가 발급한적 없는 6666이라는 위조 카드를 들고 저번에 방문했던 사람이라고 속여도 6666이라는 정보가 목록에 없으니 서버는 이 카드가 위조됬다는 것을 알아차릴 수 있는 것이다.</p>
<h3 id="이-세션-id-로직을-정리하면">이 세션 ID 로직을 정리하면</h3>
<p>클라이언트가 최초 리퀘스트를 할 때, </p>
<ol>
<li>서버는 세션이라는 저장소에 세션ID를 하나 만든다.</li>
<li>서버에서 세션 저장소에 세션ID를 만들 때 세션ID에 딸린 작은 저장소가 하나 생긴다.</li>
<li>클라이언트에 응답을 해줄 때 헤더에 세션ID를 넣어서 돌려준다.</li>
<li>클라이언트는 헤더에 있는 이 세션ID를 받아서 웹 브라우저에 저장한다.</li>
<li>이제 클라이언트에서 아이디와 비밀번호를 담아서 로그인 요청을 하면 </li>
<li>서버는 이것을 DB에서 확인해서 정상이면 서버 세션 저장소의 세션ID에 딸린 작은 저장소에 유저 정보를 저장한다.</li>
<li>그리고 로그인이 되면 보통 메인 페이지를 리턴해준다.</li>
<li>클라이언트가 유저 정보를 요청(세션 ID를 들고 요청)하면 </li>
<li>서버는 해당 세션ID가 있는지 확인. 그 세션ID에 값이 있는지 확인하고 유저 정보가 있으면 </li>
<li>DB에서 데이터를 가져와서</li>
<li>이 데이터를 클라이언트에 리턴한다.</li>
</ol>
<p>세션이 없으면 이 로직이 계속 반복 되는 것.</p>
<h3 id="세션은-언제-사라지나">세션은 언제 사라지나?</h3>
<ul>
<li>세션을 서버쪽에 날릴 때.(=서버가 들고 있는 카드 목록을 지울 때) 특정 시간이 지나면 사라지는데 보통 30분이 지나면 사라지게 설정하는 경우가 많다.</li>
<li>사용자가 브라우저를 종료할 때.(브라우저를 종료하면 브라우저에 저장되어 있는 세션 값이 지워짐) 서버에는 카드 목록이 살아있지만 요청을 보낼때 카드를 들고가지 않기 때문에 서버 입장에서는 첫방문 요청으로 받아들여 새로 카드를 발급해주게 된다.</li>
</ul>
<h3 id="그래서-세션을-통해서-뭘-할-수-있는-것인가">그래서 세션을 통해서 뭘 할 수 있는 것인가?</h3>
<p>사용자의 인증을 할 수 있고 세션을 통해서 어떤 민감한 정보에 접근을 할 때, 세션이 있는지 확인을 해서 값이 있으면 그 사람에 대한 정보를 응답해줄 수 있다.</p>
<h3 id="세션의-단점은-그리고-해결법은">세션의 단점은?? 그리고 해결법은??</h3>
<p>서버는 클라이언트에서 요청할 때 응답을 해줘야 하는데 이 요청이 1000명이라고 생각해보자.</p>
<p>동접 300명을 처리할 수 있는 서버에 1000명의 요청이 들어오면 허용량인 300명을 처리할 동안 나머지 700명이 기다려야만 한다. 그래서 보통 동접이 많은 대형 사이트는 300짜리 서버를 3개를 운용해서 한 서버가 포화되면 다른 서버로 보내고 이 서버도 포화되면 또 다른 서버로 보내는 형태로 사용한다.</p>
<p>이렇게 서버가 포화될 때 다른 서버를 이용해 부하를 분산시키는 방식을 ‘<strong>로드 밸런싱</strong>&#39;이라고 한다.</p>
<img src="https://github.com/emsthf/Today-I-Learned/blob/main/imgs/2022_08/09_session.png?raw=true">

<p>그런데 이러한 로드 밸런싱 환경에서 1번 서버에 요청을 보내 세션을 생성했는데, 재요청을 보낼때 1번 서버가 포화가 되서 다른 서버로 연결이 되면 어떻게 될까? 다른 서버의 입장에서는 이 클라이언트는 첫 방문자로 인식하게 된다. 왜? 다른 서버의 세션 저장소에는 해당 클라이언트의 세션 정보가 없기 때문. </p>
<p>이 문제의 해결 방법으로 <strong>Sticky Session</strong>이라는 것을 적용해서 클라이언트의 요청은 세션을 생성한 서버에만 전송하도록 하는 방법이 있다.</p>
<p>두번째 해결 방법은 생성된 세션을 모든 서버에 복제를 하는 방법이 있다.</p>
<p>하지만 이러한 방법은 모두 귀찮은 방법이다.</p>
<p>그래서 또 다른 방법으로는 모든 서버들이 하나의 DB에 세션 값을 넣어놓고 공유해서 사용하는 방법이 있다. 
하지만 이 방법도 단점이 있는데, 서버의 세션 정보는 원래 서버 메모리에 접근해서 데이터를 가져오는 것이라 엄청 빠른 처리가 되는데 이 방법을 사용하면 메모리가 아닌 하드디스크에서 정보를 뒤져야 하기 때문에 엄청난 속도 저하가 발생하게 된다.</p>
<blockquote>
<p>CPU가 데이터를 요청하면 이 요청은 바로 하드디스크로 가지 않고 먼저 RAM으로 간다. 하드디스크는 느리기 때문에 RAM을 먼저 뒤지는 것.
만약 RAM에 찾는 데이터가 없다면 그제서야 하드디스크를 뒤져서 데이터를 찾는다. 하드디스크에서 데이터를 찾으면 RAM에 찾은 데이터를 보내고, RAM에서 CPU에게 데이터를 보내게 된다.
그 다음 요청에도 같은 데이터를 요청하게 되면 데이터가 RAM에 남아 있기 때문에(캐싱) 하드디스크로 가지 않아서 I/O가 일어나지 않는다.
즉, 하드디스크로 간다는 것은 I/O가 일어난 다는 것. I/O가 일어나는 순간 속도가 100만배 정도 느려진다.</p>
</blockquote>
<p>이 공유 세션 DB를 뒤지는 방식은 너무 느리기 때문에 그냥 DB를 사용하는 대신 <strong>메모리 서버</strong>를 사용한다.</p>
<blockquote>
<p>메모리 서버는 하드디스크는 없고 RAM만 존재. 전기적 신호로 접근해 I/O가 일어나지 않아 빠르게 탐색 가능. 메모리 서버 중 대표적인 것이 &#39;Redis&#39;</p>
</blockquote>
<p>이 세션의 문제점을 해결하기 위해서 JWT를 사용하는 것.</p>
<p>어떻게 해결하고 언제 쓰는가?
그것은 다음 포스트에서 알아보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[property 암/복호화를 위한 Jasypt 적용기 -2-]]></title>
            <link>https://velog.io/@ssol_916/%EC%95%94%EB%B3%B5%ED%98%B8%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Jasypt-%EC%A0%81%EC%9A%A9%EA%B8%B0-2-</link>
            <guid>https://velog.io/@ssol_916/%EC%95%94%EB%B3%B5%ED%98%B8%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Jasypt-%EC%A0%81%EC%9A%A9%EA%B8%B0-2-</guid>
            <pubDate>Wed, 29 Jun 2022 04:02:34 GMT</pubDate>
            <description><![CDATA[<h1 id="문제-발생">문제 발생!!</h1>
<p>이전 글에 썼던대로 프로퍼티 암/복호화를 할 때 기존 Jasypt 알고리즘이 아닌 <code>PBEWithSHA256And128BitAES-CBC-BC</code>를 사용하니 로컬에서는 아주 잘 작동하는데, 테스트 서버에 올려보니 복호화가 안되는 문제 발생…</p>
<p>왜지??🧐</p>
<p>검색해보니 Jasypt 3.0.0 이후 버전 부터 기본 알고리즘이 바뀌면서 알고리즘 차이로 인한 문제가 자주 발생한 것처럼 보인다.</p>
<p><a href="https://github.com/ulisesbocchio/jasypt-spring-boot#update-11242019-version-300-release-includes">GitHub - ulisesbocchio/jasypt-spring-boot: Jasypt integration for Spring boot</a></p>
<p>jasypt 버전 업데이트 정보를 확인해보니</p>
<ul>
<li>3.0.0 버전 부터 <code>PBEWithMD5AndDES</code> → <code>PBEWITHHMACSHA512ANDAES_256</code>로 알고리즘 변경이 됨</li>
<li>하지만 복호화시 알고리즘 이슈, 기존 적용 시스템 알고리즘 유지 등의 이유로 기존 알고리즘을 많이 사용하는 것 같더라</li>
</ul>
<h2 id="해결-방법">해결 방법</h2>
<p>jasypt 버전을 2.0번대로 낮추면 해당 이슈가 발생하지 않는다고 하는데 그렇게 하기는 싫어서 3.0번대를 유지하면서 해결할 방법을 찾았다.</p>
<pre><code class="language-yaml">jasypt:
    encryptor:
        algorithm: PBEWithMD5AndDES
        iv-generator-classname: org.jasypt.iv.NoIvGenerator</code></pre>
<p>위 깃헙 링크에 있는 버전 업데이트 정보에는 3.0.0 이상에서 기존 알고리즘을 그대로 사용하기 위해서는 yml에 다음 속성을 사용해야 한다고 한다.</p>
<p>알고리즘과 iv 제네레이터 클래스네임을 property로 받아와야 한다면 </p>
<pre><code class="language-yaml">jasypt:
    encryptor:
        algorithm: PBEWithMD5AndDES
        iv-generator-classname: org.jasypt.iv.NoIvGenerator
        password: ${JASYPT_PASSWORD}</code></pre>
<p>이참에 패스워드도 추가해 외부 환경변수로 주입받아 property로 사용하기로 했다.</p>
<h1 id="환경변수로-받는-여러-방법들">환경변수로 받는 여러 방법들</h1>
<h2 id="docker-compose로-환경변수-주입하기">docker-compose로 환경변수 주입하기</h2>
<p>우리 회사 테스트 서버와 운영 서버는 docker-compose로 돌리고 있으므로
docker-compose로 환경변수 파일(.env)파일을 주입받아 외부에서 패스워드 키 값을 받아오도록 했다.</p>
<pre><code class="language-yaml">back:
    image: back
    container_name: back
    ports:
            - 10000:10000
    volumes:
            - /var/run/docker.sock:/var/run/docker.sock
            - /[로그를 저장할 경로]/logs:/[로그를 저장할 볼륨 경로]/logs

            ... [기타 해당 컨테이너 볼륨 경로들]

    env_file: ./back/my-back/.env
    networks:
            - default
            - backend
    restart: always</code></pre>
<p>env_file로 환경파일이 있는 경로를 잡아주도록 하자.</p>
<pre><code class="language-yaml">JASYPT_PASSWORD=password</code></pre>
<p>해당 경로에 .env 파일을 위와 같이 만들어주고</p>
<pre><code class="language-java">package com.openeg.openegscts.utils;

import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties;
import org.jasypt.encryption.StringEncryptor;
import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableEncryptableProperties
public class JasyptConfigDES {

    @Bean(&quot;jasyptEncryptor&quot;)
    public StringEncryptor stringEncryptor() {
        PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
        SimpleStringPBEConfig config = new SimpleStringPBEConfig();

        config.setKeyObtentionIterations(&quot;1000&quot;);
        config.setPoolSize(&quot;1&quot;);
        config.setProviderName(&quot;SunJCE&quot;);
        config.setSaltGeneratorClassName(&quot;org.jasypt.salt.RandomSaltGenerator&quot;);
        config.setStringOutputType(&quot;base64&quot;);
        encryptor.setConfig(config);

        return encryptor;
    }
}</code></pre>
<p>SimpleStringPBEConfig 클래스를 생성해 yml파일에 있는 데이터를 제외한 config 설정을 채워주면 되는데
yml에 사용 알고리즘과 패스워드, iv generator class name은 입력했으니 이것들을 제외한 나머지 설정을 config 파일에 세팅해주면 된다.</p>
<p>이 세팅을 PooledPBEStringEncryptor에 setConfig로 넣어주면 준비 끝!!</p>
<p>기존 버전에 쓰던 알고리즘을 사용하므로 다음 <a href="https://www.devglan.com/online-tools/jasypt-online-encryption-decryption">링크</a>를 이용해 패스워드 키만 알고 있으면 온라인 암/복호화도 가능하고 서버에서도 password를 환경파일로 받아와 잘 작동하게 된다.</p>
<p><span style="color:indianred"><strong>.env파일은 까먹지말고 꼭 gitignore 처리를 해주자!!!</strong></span></p>
<br>

<p>위 방법 말고도 어떤 것이 있을까?
만약 로컬에서 컨테이너가 아닌 백엔드 단일 프로젝트로 돌린다면 ??</p>
<h2 id="mac-시스템-환경변수-설정하기">Mac 시스템 환경변수 설정하기</h2>
<p>시스템에 환경변수를 박아넣어서 시스템에서 직접 불러오게 하는 방법이 있다.
물론 Windows, Mac, Linux 모두 가능하다.
나는 주로 Mac을 사용하므로 Mac의 시스템 환경변수로 패스워드를 받아오도록 해보겠다.</p>
<p>터미널에 <code>env</code>를 입력하면 mac의 시스템 환경변수를 확인할 수 있다.</p>
<pre><code class="language-bash"># Mac 환경변수 확인
env</code></pre>
<p>이 환경변수를 수정하거나 추가하는 방법은 home경로에 있는 <code>.bash_profile</code>에서 환경변수를 수정하거나 추가해주면 되는데</p>
<pre><code class="language-bash"># 환경변수 등록하는 파일 열기(open으로 열어도 되고, vi나 vs code로 열어도 상관 없다.)
open .bash_profile</code></pre>
<p>환경변수를 추가하는 방법은 <code>export</code>를 사용하면 된다.</p>
<pre><code class="language-bash"># .bash_profile 파일 내부에
# JASYPT_PASSWORD라는 환경변수에 password라는 문자열을 할당
export JASYPT_PASSWORD=&quot;password&quot;</code></pre>
<p>그리고 터미널 재시작</p>
<p>만약 이렇게 하고도 환경변수 조회에서 새로 등록한 환경변수가 보이지 않는다면 <code>.zprofile</code>에도 등록을 해주면 된다.</p>
<pre><code class="language-bash"># 위 순서로 환경변수를 등록했는데도 env로 새로 등록한 환경변수가 확인 안된다면
## .zprofile을 열어서 .bash_profile에 새로 등록했던 환경변수를 똑같이 추가해주면 된다.
open .zprofile

# .zprofile 파일 내부에
# JASYPT_PASSWORD라는 환경변수에 password라는 문자열을 할당
export JASYPT_PASSWORD=&quot;password&quot;</code></pre>
<p>터미널을 재시작 후 환경변수를 조회해보면 새로 등록해준 환경변수가 나오는 것을 볼 수 있을것이다.</p>
<p><img src="https://github.com/emsthf/Today-I-Learned/blob/main/imgs/2022_06/29_1.png?raw=true" alt="env로 환경변수 확인 결과"></p>
<h2 id="컨테이너-환경변수-주입하기">컨테이너 환경변수 주입하기</h2>
<p>만약 로컬에서 그냥 프로젝트를 돌리는 것도 아니고 compose를 사용하지 않으면서 컨테이너만 사용해서 실행을 한다면 컨테이너를 올릴 때 환경변수를 주입해주는 방법이 있다.</p>
<pre><code class="language-bash">docker run -d -p 8080:8080 -e [환경 변수]=[값] [docker image]:[tag]</code></pre>
<p>이렇게 docker run을 할 때 <code>-e</code> 또는 <code>—env</code> 옵션을 사용해서 환경변수를 주입해줄 수 있다.</p>
<br>

<p>이것 외에도 터미널에 직접 환경 변수를 export 하여 재부팅하면 사라지는 일회성 환경변수를 만들어 넘기는 등 여러 방법들이 있는데 적절한 방법을 찾아서 사용하면 된다.</p>
<p>프로퍼티 암호화… 참 쉽죠?😎</p>
]]></description>
        </item>
    </channel>
</rss>