<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>빠르지만 조급하지 않게</title>
        <link>https://velog.io/</link>
        <description>내 기억보단 내가 작성한 기록을 보자..</description>
        <lastBuildDate>Sun, 20 Jul 2025 18:32:39 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>빠르지만 조급하지 않게</title>
            <url>https://velog.velcdn.com/images/sung_c/profile/860bd057-377f-446e-8a4a-1cd0c01c8c23/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 빠르지만 조급하지 않게. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sung_c" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Spring] 웹소켓으로 양방향 통신 기능 구현]]></title>
            <link>https://velog.io/@sung_c/Spring-%EC%9B%B9%EC%86%8C%EC%BC%93%EC%9C%BC%EB%A1%9C-%EC%96%91%EB%B0%A9%ED%96%A5-%ED%86%B5%EC%8B%A0-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@sung_c/Spring-%EC%9B%B9%EC%86%8C%EC%BC%93%EC%9C%BC%EB%A1%9C-%EC%96%91%EB%B0%A9%ED%96%A5-%ED%86%B5%EC%8B%A0-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 20 Jul 2025 18:32:39 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>오픈카톡방을 많이 사용하고 있는데, 이를 가능하게 해주는 WebSocket을 공부하게 되었다. 추후 실시간 공매도 거래나 관련한 실시간 통신에 대해 업무를 맡을 수도 있으니..</p>
<h2 id="본론">본론</h2>
<p> 웹 환경에서 우리는 <code>HTTP 프로토콜을 통해 요청과 응답의 구조</code>로 서비스를 이용하고 있다. 그렇다면 HTTP 통신을 사용하고 있는데, 왜 또다른 Socket 통신을 사용할까?</p>
<blockquote>
<p>HTTP 통신의 특징</p>
</blockquote>
<ul>
<li><code>비연결성</code> : 커넥션을 맺고 요청 이후 응답을 받으면 연결을 끊는다.</li>
<li><code>무상태성</code> : 서버가 클라이언트의 상태를 가지고 있지 않는다.</li>
<li><code>단방향</code> : 클라이언트는 서버에 요청을 하고, 서버로부터 응답을 받을 수만 있다.</li>
</ul>
<p>위 3가지 특징을 살펴보면, HTTP 통신은 커넥션을 맺었다가 응답 이후 <code>끊기를 반복하고</code>, 양방향이 아닌 <code>서버로부터 단방향으로</code> 응답을 받는 통신이기 때문에 양방향으로 <strong>실시간 통신에 적합하지 않음을 알 수 있다.</strong></p>
<p>즉, 우리가 자주 사용하는 카카오톡에서 대화방이나 혹은 주식 거래할 때 매도, 매수 거래를 채결하는 실시간성 서비스에는 HTTP 통신이 적합하지 않음을 알 수 있다.</p>
<blockquote>
<p>WebSocket이란 </p>
</blockquote>
<p>클라이언트와 서버 간에 지속적인 양방향 통신을 가능하게 하는 프로토콜</p>
<ul>
<li><code>Full-Duplex</code> : 클라이언트와 서버가 양방향으로 실시간 데이터를 주고받을 수 있다.</li>
<li><code>지속 연결</code> : 한번 연결하면 끊기지 않고 커넥션이 유지된다.</li>
<li><code>기반</code> : TCP 위에서 동작한다.</li>
<li><code>경량</code> : HTTP보다 헤더가 작고 가볍다. 즉, 속도가 빠르다.</li>
</ul>
<br>

<p>⚡ 채팅과 같이 양방향 실시간 통신에 자주 사용되는 통신 프로토콜로 이해하면 된다. </p>
<h3 id="springwebsocket-구현">SpringWebSocket 구현</h3>
<p>Springboot에서 WebSocket 라이브러리를 지원해주기때문에 사용하면된다. 간단하게 입출력을 위해 화면은 html, js로 구성했다.</p>
<blockquote>
<p>build.gradle</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/70c0e726-d8f3-430d-9010-3c0aa26f9c5a/image.png" alt=""></p>
<p>의존성 부분에서 눈여겨봐야할 부분은 stomp-websocket부분이다.</p>
<blockquote>
<p>STOMP란?</p>
</blockquote>
<p><strong>텍스트 메시지 형식 정의를 위한 애플리케이션 레벨 메시지 프로토콜</strong></p>
<p>WebSocket은 OSI 7계층 중 애플리케이션 레벨에서 동작하는데, <code>해당 계층위에 binary, text로 메시지를 주고받을 수 있도록 하는 프로토콜</code>이다. </p>
<p>갑자기 무슨 프로토콜 설명이야라고 생각했는데, <code>Spring Websocket은 통신을 담당하고</code>, <code>STOMP는 그 위에서 메시지 구조와 규칙을 담당</code>하는 이른바 상호보완 관계라서 알고있어야하는 개념이다.</p>
<blockquote>
<p>STOMP 구조</p>
</blockquote>
<ul>
<li>HTTP Request, Response보다 매우매우 간단하게 경량화돼있는 것을 볼 수 있다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/52c23d84-dd82-4e6b-baeb-0a2dd07521d3/image.png" alt=""></p>
<p>우리가 흔히 최신기술로 알고있는 메시지큐(Kafka, RabbitMQ)를 사용해서 메시지를 라우팅하고, pub/sub형태로 통신할 수 있게 해주는 것이 SMTOP이다.</p>
<blockquote>
<p>WebSocket config</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/6f67093b-9b3f-4e9f-beb6-dcc2e87caa2b/image.png" alt=""></p>
<ul>
<li>configureMessageBroker 메서드에 작성한 주석을 통해 알 수 있듯, /topic 경로에 발행한 메세지는 구독자들에게 전달 되고, <code>app 경로에 발행한 메세지는 가공을 거쳐</code> 메세지 브로커로 전달되어 궁극적으로 구독자들에게 전달된다.</li>
</ul>
<p>/topic과 /app 라우팅 과정을 도식화하면 다음과 같다.
<img src="https://velog.velcdn.com/images/sung_c/post/4610f406-d7ff-4d9e-aebd-86a601237288/image.png" alt=""></p>
<p>결국 /app으로 전송한 데이터는 가공되어서 /topic으로 라우팅되어서 메세지 브로커에 도착한다. 이후 메세지 브로커는 consumer들에게 데이터를 전달하는 구조로 이해하면된다.</p>
<blockquote>
<p>controller</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/ca8a13a6-074d-4544-8845-6ed74a9f06e0/image.png" alt=""></p>
<p><code>@MessageMapping</code> 어노테이션은 우리가 RestController를 구현하면서 사용했던 @RequestMapping과 유사하다고 느꼈다.
해당 어노테이션은 <code>/app/hello</code> 경로로 들어온 메세지를 greeting 메서드에서 <strong>가공하여</strong> /topic/greetings 경로로 라우팅하는 컨트롤러 코드다.</p>
<p>위 그림에서도 봐서 이해했겠지만, 간략하게 재설명하자면 /app은 수신지점이라고 생각하고, /topic은 송신 지점이라고 생각하면 좀 더 이해가 쉬울거 같다.</p>
<blockquote>
<p>실행 화면</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/17b41d87-2c0e-401e-88b8-ba23a911ae9d/image.png" alt=""></p>
<p>connect를 통해 먼저 tcp 연결을 수립하고, 이름을 작성 후  send를 누르면, </p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/3186931c-8298-44db-8098-62c31f62b845/image.png" alt=""></p>
<p>이렇게 실시간으로 전달되는 것을 확인할 수 있다. HTTP 통신이었다면, <strong>ajax 비동기 호출을 하거나 새로고침</strong>을 했어야했을텐데 허허</p>
<h2 id="결론">결론</h2>
<p>간단하게나마 Spring WebSocket으로 실시간 통신을 구현해봤다. </p>
<p>중요한건 연결을 맺어서 통신을 하는게 맞지만, 그사이에 <code>spring에 내장돼있는 메시지 브로커</code>가 pub/sub형태로 <code>구독자들에게 메세지를 전달</code>해주는 사실이다. </p>
<p>추후 채팅서버를 만들어보고자 한다.</p>
<blockquote>
<p>출처</p>
</blockquote>
<p><a href="https://growth-coder.tistory.com/157">https://growth-coder.tistory.com/157</a>
<a href="https://adjh54.tistory.com/573">https://adjh54.tistory.com/573</a>
<a href="https://www.youtube.com/watch?v=rvss-_t6gzg&amp;ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC">https://www.youtube.com/watch?v=rvss-_t6gzg&amp;ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC</a>
<a href="https://spring.io/guides/gs/messaging-stomp-websocket">https://spring.io/guides/gs/messaging-stomp-websocket</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 동시성 어떻게 처리할까]]></title>
            <link>https://velog.io/@sung_c/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@sung_c/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sun, 29 Dec 2024 14:09:40 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트에서 좋아요 기능을 구현하면서, 동시성 이슈를 마주했다. 어떤 상황에서 발생했고 어떻게 해결했는지 과정을 기록하고자 한다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>문제상황</p>
</blockquote>
<p> <code>4개의 쓰레드로 동시성 테스트를 수행</code>했다. 당연히 4번의 좋아요 API 요청이 발생했기 때문에 좋아요는 4개가 나와야한다. 하지만 좋아요 개수 1개로 테스트가 종료되었다.
<img src="https://velog.velcdn.com/images/sung_c/post/46f691aa-b9df-4b96-b039-2140e62d57da/image.png" alt=""></p>
<p>테스트 수행 로그를 확인해봤는데, 포스트에 대해 좋아요를 증가시키는 쿼리는 정상적으로 질의된 것을 알 수 있었다.
<img src="https://velog.velcdn.com/images/sung_c/post/647c0421-4252-4db0-99de-a2f2d89e421f/image.png" alt=""></p>
<blockquote>
<p>테스트 코드</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/de066838-10a5-491a-b1b8-fa48b130287b/image.png" alt=""></p>
<blockquote>
<p>동시성 문제의 원인</p>
</blockquote>
<p>여러 스레드가 동시에 같은 게시글에 대해 좋아요를 누르면 likeCount(좋아요 수)를 동시에 읽고 수정하려고 한다.
예로, 사용자인 A와 B가 &quot;인증&quot;이라는 post에 대해서 동시에 좋아요 요청을 했다.</p>
<ol>
<li>A가 점유한 트랜잭션에서는 post를 조회했을 때 &quot;인증&quot;의 likeCount는 0이었는데, 1을 증가시켜 1이 되었다.</li>
<li>B 또한, 동일하게 post를 조회해서 1을 증가시켜서 1이 되었다.</li>
</ol>
<p>2번의 좋아요 요청이 수행되었지만, likeCount는 1이 된다.</p>
<p>MariaDB는 기본 트랜잭션 격리수준은 <code>REPEATBLE_READ</code>다. </p>
<ul>
<li>해당 전략은 다른 트랜잭션에서 데이터가 변경되더라도 현재 트랜잭션에서는 읽는 데이터는 영향을 받지 않는다.</li>
</ul>
<p>_따라서 모든 트랜잭션이 종료되고, 하나의 좋아요 요청에 대한 업데이트 쿼리만 수행_된다는 것을 알 수 있다.</p>
<p>그렇다면 격리 수준을 높힌다면, 4만큼의 좋아요 개수가 증가하게할 수 있을까?</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/73ca757b-71f7-4927-9970-ea1a62390a51/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/8981f14a-c067-4c5f-bda6-d6d912d7dae3/image.png" alt=""></p>
<p>SERIALIZABLE 격리 수준에서, 데이터를 읽을 때, 데이터베이스는 일반적으로 <code>공유 잠금</code>을 설정한다.
트랜잭션이 해당 데이터를 읽을 수 있도록 허용하지만, 쓰기 작업은 차단한다.</p>
<p>즉, 트랜잭션이 진행하는 동안 다른 트랜잭션이 <code>해당 데이터를 WRITE은 할 수 없지만, READ는 가능</code>하기 때문에 위 문제가 동일하게 발생한다.</p>
<p>따라서, <code>베타 잠금</code>을 통해 위 문제를 해결해야겠다고 생각했다.</p>
<blockquote>
<p>해결방법</p>
</blockquote>
<ol>
<li>Lock<ul>
<li>낙관적 락</li>
<li>비관적 락</li>
</ul>
<ol start="2">
<li>likeCount 변수를 없애고 조인 쿼리를 통해서 좋아요 개수 조회</li>
</ol>
</li>
</ol>
<p>처음 좋아요 기능을 기획하고 ERD 모델링 하면서 성능에 대한 고민을 거쳤다. likeCount를 사용하지 않고, like 테이블에서 count 쿼리를 질의해 좋아요 개수를 조회로 방향성을 정했었다. 그러나, <code>조인 쿼리와 count 질의를 쿼리하는 것으로 성능 이슈</code>가 필연적으로 발생할 수 밖에 없다고 생각해, 2번을 제외했었다.</p>
<p>낙관적 락은 <code>실제 데이터 충돌이 발생하지 않을 것이라 가정</code>하고 사용하는 방법이고, 또 <code>추가적인 오류에 대한 핸들링이 필요하다</code>는 점 때문에 비관적 락을 선택하게 되었다.</p>
<blockquote>
<p>적용</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/d05993c5-401c-4a69-84aa-d88b32367669/image.png" alt=""></p>
<p>@Lock 어노테이션을 사용하여, 비관적 락 옵션을 추가한다. WRITE의 경우, 데이터의 읽기/쓰기 요청 시 쓰기 락이 발생해서, 트랜잭션이 수행 중인 경우, 대기 상태에 들어가고 앞선 트랜잭션이 종료되면, 수행한다. 또한, 대기 시간이 늘어져 데드락 발생을 대비해서, 5초간의 타임아웃 시간을 지정했다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/7248cd30-729c-4efb-889d-9d9c0c69d8ec/image.png" alt=""></p>
<h2 id="결과">결과</h2>
<p> 성공적으로 4개의 좋아요 요청에 대해서 4만큼의 좋아요 수가 증가했다.</p>
<p> 한편, 소요시간이 약 <code>1.3022초</code>가 소요된다. 고작 4개의 요청에 대해서 이정도 성능이라면 개선이 필요하다..</p>
<p> 뿐만 아니라, 트랜잭션의 순서, 타임아웃, 트랜잭션 최소화 이슈로 <code>데드락</code>이 발생할 수 있다. 추가적인 방법을 공부하고 고민해봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 캐시스탬피드 어떻게 대응할까]]></title>
            <link>https://velog.io/@sung_c/Spring-%EC%BA%90%EC%8B%9C%EC%8A%A4%ED%83%AC%ED%94%BC%EB%93%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8C%80%EC%9D%91%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@sung_c/Spring-%EC%BA%90%EC%8B%9C%EC%8A%A4%ED%83%AC%ED%94%BC%EB%93%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8C%80%EC%9D%91%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 20 Dec 2024 01:45:47 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>현재 <code>일일 미션 인증</code> 서비스는 로그인 이후 넘어가는 메인페이지로, 미션 리스트를 보여주고 있다. 즉, 사용자들이 접속할 때마다 미션 리스트에 대한 조회 쿼리를 api 서버가 수행하기 때문에, DB I/O 부하를 낮추고, 효율적으로 리소스를 사용하기 위해서 <code>Redis로 글로벌 캐싱</code>을 사용하고 있다.</p>
<h2 id="본론">본론</h2>
<p>캐시 데이터는 메모리의 한정된 저장 공간을 효율적으로 사용하기 위해서와 데이터의 일관성을 이유로, <code>TTL설정해서 데이터를 갱신</code>한다. 개발하던 중, 문득 &#39;그럼 <code>TTL이 만료되서 캐시 데이터 갱신이 일어나는 시점에, 사용자가 동시에 접속</code>하면 어떡하지&#39;라는 생각이 들었다...</p>
<p>이러한 현상을 캐시 스탬피드라고 한다.</p>
<blockquote>
<p><strong>캐시 스탬피드</strong>
캐시가 만료된 상태로 요청이 몰리면, 순간적으로 DB에 동일한 데이터를 조회한 뒤 캐시 시스템에 중복된 쓰기 요청이 몰리는 현상</p>
</blockquote>
<p>쓰기 요청이 몰리는 현상도 위험성이 크지만, 동일한 조회 쿼리를 DB에 질의할 때 <code>DB 서버에 대한 부하가 가중된다</code>는 점, 또 <code>동일한 쿼리가 수백개, 수천개가 수행</code>될 수 있기 때문에 이러한 부분에서 개선의 필요성을 느꼈다.</p>
<p>이를 해결하기 위한 방법으로 캐시가 만료되기 전 배치로 재생성할 수 있는 등이 있는데, <code>PER 알고리즘</code>이라는 키워드를 접했다.</p>
<blockquote>
<p><strong>PER(Probablistic Early Recomputation) 알고리즘</strong>
캐시 유효시간이 만료되기 전 일정 확률로 캐시를 재연산하는 방식</p>
</blockquote>
<ul>
<li>장점</li>
</ul>
<ol>
<li>시스템 부하 분산<ul>
<li>캐시 만료가 동일한 시점에 집중되지 않고, 랜덤하게 확률적으로 분산되기 때문에 특정 순간에 발생할 수 있는 시스템 부하 완화</li>
</ul>
</li>
<li>효율적 리소스 사용<ul>
<li>만료 시점 이전에 캐시를 확률적으로 갱신하므로, 필요하지 않은 캐시 갱신을 줄이면서도 일정 수준의 데이터 신뢰성 유지</li>
</ul>
</li>
</ol>
<ul>
<li>참고 : <a href="https://blog.hwahae.co.kr/all/tech/14003">https://blog.hwahae.co.kr/all/tech/14003</a></li>
</ul>
<h3 id="적용">적용</h3>
<p>Redis의 @cacheable, @cacheEvict 등 어노테이션이 AOP를 기반으로 동작하고 있고, 또 cache 클래스를 생성해서 기존의 서비스 계층에 의존성을 추가해야 하는 점 때문에, @Cacheable에 대한 AOP를 커스텀하여 작성했다.</p>
<blockquote>
<p>PER 알고리즘 코드 작성</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/25f8402c-660b-48ce-ac72-0054dab1db2d/image.png" alt=""></p>
<ul>
<li><p>핵심 설명</p>
</li>
<li><p><em>갱신 확률 = 기본 확률 × (경과 시간 ÷ 만료 시간)*</em></p>
</li>
<li><p><strong>기본확률</strong>(RECOMPUTE_PROBABILITY) : 처음에 설정된 갱신 확률인데, 개발자가 값을 높게 부여할수록 갱신 빈도수는 감소하고, 반대는 역이다.</p>
</li>
<li><p><strong>경과 시간</strong>(elapsed Time) : 캐시를 마지막으로 갱신하고 얼마나 시간이 지났는지, 현재 시간과 - 연산 수행을 통해서 값을 가져온다.</p>
</li>
<li><p><strong>비율 계산</strong>(elapsedTime ÷ EXPIRATION_TIME) : 경과 시간을 만료 시간으로 나누면, 현재까지 얼마나 시간이 흘렀는지 비율을 구할 수 있다.</p>
</li>
</ul>
<p>즉, <em>기본 확률에 비율을 곱해, 시간이 흐를수록 갱신 확률이 점점 커지는 것이 해당 알고리즘이 핵심이다.</em></p>
<p>위 알고리즘 연산을 수행하는데 있어, 캐시가 마지막 갱신 시각을 가지고 있어야하기 때문에, CacheData 클래스를 작성했다.</p>
<p>마지막으로, 갱신 확률과 0.1 ~ 0.5 사이의 랜덤값과 비교해서 갱신하도록 코드를 작성했다.</p>
<p>추가적으로, 캐시 만료 시간을 Redis에서 꺼내오는 만큼 허점이 있는데, TTL 코드 부분을 보자.</p>
<blockquote>
<p>허점 개선 사항</p>
</blockquote>
<pre><code class="language-java">Long ttl = redisTemplate.getExpire(cacheable.cacheNames()[0], TimeUnit.SECONDS);
        if (ttl == null || ttl &lt;= 0) {
            return true;
        }

        long bufferedTtl = Math.max(ttl - 5, 0);</code></pre>
<p><code>5초의 버퍼</code>를 거는 로직이다.
캐시의 만료시간을 조회하고, <code>로직을 수행하는 중 캐시가 만료되는 것을 방지</code>한다.
이로 인해 캐시 만료로 캐시 미스가 발생하지 않도록 예방할 수 있다.</p>
<p>정리하자면, TTL이 남아 있지만, 5초 이하로 줄어들면 데이터 갱신을 시도해 캐시 히트율을 높히는 코드라고 할 수 있겠다.</p>
<h3 id="이전과-비교">이전과 비교</h3>
<p>Jmeter를 활용한 부하 테스트로 PER 알고리즘 도입 전과 후를 비교해보았다.
<img src="https://velog.velcdn.com/images/sung_c/post/33c429d7-f6a1-440d-9f7f-1ef56187d0ad/image.png" alt=""></p>
<p>100개의 쓰레드, 30초당 요청, 5분동안 지속하도록 Thread Group을 설정했다.</p>
<ul>
<li>캐시 데이터 조회에 대한 TPS 그래프
<img src="https://velog.velcdn.com/images/sung_c/post/dfd8e574-1549-4eab-a7ed-0d62a2bc73bb/image.png" alt=""></li>
</ul>
<p>이전에는 ,캐시가 갱신하는 시점에 캐시 미스가 발생해서 TPS가 800까지도 떨어지는 구간이 있었다.</p>
<p>적용 후
<img src="https://velog.velcdn.com/images/sung_c/post/646ca6a2-c4f5-4f14-9e0d-94bf52136e34/image.png" alt=""></p>
<p>비록 1200 TPS으로 낮은 수치를 기록하지만, 캐시 미스를 어느정도 방어해서 TPS가 튀는 정도가 이전과 비교해서 좋아졌다.</p>
<h2 id="결론">결론</h2>
<p>외부 의존성도 추가하지 않아도 되고, 대규모 서비스에서 어플리케이션이 동시에 캐시를 갱신하는 케이스로 인한 캐시 미스도 방지할 수 있는 효율적인 리소스 사용을 도모하는 알고리즘이라고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring Batch 오류가 났을 때 어떻게 처리할까?]]></title>
            <link>https://velog.io/@sung_c/Spring-Spring-Batch-%EC%98%A4%EB%A5%98%EA%B0%80-%EB%82%AC%EC%9D%84-%EB%95%8C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@sung_c/Spring-Spring-Batch-%EC%98%A4%EB%A5%98%EA%B0%80-%EB%82%AC%EC%9D%84-%EB%95%8C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Tue, 17 Dec 2024 07:58:56 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트에서 03시마다 미션을 종료하고, 인증글 미제출 유저를 강제퇴장처리하는 배치를 수행하고 있다. <code>배치가 돌면서 오류가 발생했을 때, 이를 어떻게 대처할지</code>를 고민하고 적용한 결과를 기록하고자 한다.</p>
<h2 id="본론">본론</h2>
<p>서론에서 언급한 배치 수행 내용은 boolean 타입의 필드를 update하는 쓰기 작업을 수행한다. 다른 레퍼런스를 찾아보면서, CSV 파일을 읽어들이는 경우 <code>데이터의 유효성 검증에 실패한 경우, skip api를 통해</code> 해당 아이템을 건너뛰고 Reader를 처리하는 경우를 많이 보았다.</p>
<p>우리 서비스에서 관리되는 DB 내부 데이터는 저장되기 전 애플리케이션단에서 유효성 검사를 통과한 값들만 저장되기 때문에, 데이터의 유효성을 이유로 skip을 사용할 필요는 없어보였다.</p>
<p>다만, DB서버로 RDS를 사용하고 있기 때문에, <code>네트워크 이슈</code>를 이유로 DB 연결이 끊어질 수 있고, 또 <code>입출력 이슈가 발생</code>할 수 있어 <code>재시도</code>하도록 step을 구성하는게 적절해보였다.</p>
<blockquote>
<p>Step 구성</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/f01b91ce-b6f6-450a-a217-ab3bca116683/image.png" alt=""></p>
<p><code>falutTolerant</code>는 건너뛰거나 재처리하는 기능을 지원하는 API인데, <code>배치처리의 내결함성</code>을 지원해준다. 
위 API는 retryPolicy나 template에 대한 커스텀없이, retryPolicy(), retryAttempt() 등 간단한 설정으로 retry 로직을 구현할 수 있다.</p>
<p>그러나, <code>코드 가독성</code>(step에 계속 설정값이 붙는게 싫음), 그리고 오류가 발생하거나, 재시도할 때 <code>로그를 커스텀하여 남기기 위해</code> retryTemplate을 커스텀하여 재시도 로직을 구현하기로 결정했다.</p>
<blockquote>
<p>RetryTemplate</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/c140df68-2d2d-4525-8d85-1ab2d0ee8444/image.png" alt=""></p>
<p>Chunk 방식에서 retry 로직이 적용되면, RetryTemplate.execute()안에서 실행된다.
그래서 retryTemplate을 @Bean으로 등록해서, fixedBackOffPolicy를 통해 <code>재시도 간 5초의 대기시간을 갖도록</code> 구성했다. 또한, retryPolicy를 통해 <code>최대 3번까지 재시도하도록</code> 커스텀했다</p>
<blockquote>
<p>RetryPolicy</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/afca0abe-dd32-4320-995c-7158b9b73ff9/image.png" alt=""></p>
<p>서론에서도 언급했다시피, 네트워크 이슈, DB 연결 이슈로 발생할 Exception 종류를 담도록 retryPolicy를 구성했다. </p>
<blockquote>
<p>CustomRetryListener</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/ce03a3c5-9106-4e4e-9906-c23ab7554dbf/image.png" alt=""></p>
<p><code>exception이 발생했을 때</code>, <code>retry를 시도할 때</code>, <code>재시도 횟수가 초과했을 때</code> 로그를 남기기 위해 customRetryListener를 작성했다.</p>
<blockquote>
<p>ItemProcessor</p>
</blockquote>
<p>processor에서 금일이 해당 미션의 제출 필수요일인지, 그렇다면 참여자는 금일 인증글을 제출 했는지의 복잡한 검증을 처리하기 때문에, processor에서 retry 로직을 우선 적용했다.
<img src="https://velog.velcdn.com/images/sung_c/post/8167b418-d8c6-409f-bff3-2217ad43fafc/image.png" alt=""></p>
<h2 id="결론">결론</h2>
<p>아직 운영 간 배치 수행한적이 없는데, retryPolicy에서 명시한 exception말고도 잠재적인 이슈가 생길거라고 예상한다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] SSE로 알림 기능 구현하기 with Kafka - (2)]]></title>
            <link>https://velog.io/@sung_c/Spring-SSE%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2</link>
            <guid>https://velog.io/@sung_c/Spring-SSE%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2</guid>
            <pubDate>Wed, 27 Nov 2024 06:34:03 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트에서 <a href="https://velog.io/@sung_c/Spring-SSE%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1">SSE를 활용해 실시간 푸시 알림 기능</a>을 구현했었다.
<code>알림 전송에서의 안정성과 신뢰성을 높히기 위해</code> 여러 방면을 고민하다가, Kafka를 도입하게 되었다. Kafka &amp; SSE를 통해 구현한 실시간 이벤트 스트리밍 도입 과정을 기록하고자 한다.
Kafka가 무엇인지는 <a href="https://velog.io/@sung_c/Kafka-Kafka%EB%9E%80">이전의 포스팅</a>을 참고하면 된다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>Kafka 도입하게 된 배경</p>
</blockquote>
<p>SSE(Server-Sent-Event)의 가장 큰 특징은 <code>클라이언트와 서버가 커넥션을 유지</code>한다는 점이다. 다수의 클라이언트가 연결될 경우, 서버가 모든 연결을 관리하기 때문에 서버 부하가 증가할 수 있다.</p>
<p>또한, 클라이언트가 네트워크 문제로 연결이 끊겼을 때, 손실된 데이터, 누락된 이벤트를 처리하는 로직을 별도로 구현해야한다.</p>
<p>위 2가지 이슈를 보완하기 위해 실시간 이벤트 스트리밍의 대명사로 알려진 Kafka를 도입하게 되었다.</p>
<blockquote>
<p>Kafka 도입으로 얻는 이점</p>
</blockquote>
<ul>
<li>서버 부하 해결
SSE 통신과 더불어, 미션에 신규 참여자가 발생하는 상황을 예로 들어보겠다. 이런 경우, 참여중인 다른 참여자들에게 푸시 알림을 전송해야한다. 하지만, kafka를 통해 <code>푸시 알림 생성 이벤트를 publish</code>만 하면 된다. 같은 WAS에서 이벤트를 consume하지만, <code>비동기로 빠르게 효율적으로 처리</code>하기 때문에, 서버 부하 부담을 줄여줄 수 있다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/16c886c5-c3a6-4ccd-a403-f01ad943909f/image.png" alt=""></p>
<ul>
<li>데이터 손실 및 이벤트 누락 처리
클라이언트의 네트워크 문제로 연결이 끊어졌을 때에도, 메세지 브로커에서 메세지를 보관하고 있기 때문에, 다시 연결되었을 때 위의 이슈를 핸들링할 수 있다.</li>
</ul>
<h2 id="적용">적용</h2>
<p>필자는 Docker를 통해 kafka, zookeeper 컨테이너를 운용하고 있다.</p>
<blockquote>
<p>docker-compose.yml</p>
</blockquote>
<pre><code class="language-yml">version: &#39;2&#39;
services:
    zookeeper:
      image: wurstmeister/zookeeper
      container_name: zookeeper
      ports:
        - &quot;2181:2181&quot;
    kafka:
      image: wurstmeister/kafka:2.12-2.5.0
      container_name: kafka
      ports:
        - &quot;9092:9092&quot;
      environment:
        KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
        KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      volumes:
          - /var/run/docker.sock:/var/run/docker.sock</code></pre>
<p><del>docker-compose 파일을 작성할 때는 항상 띄어쓰기를 주의해야한다.</del></p>
<blockquote>
<p>의존성 추가</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/4900a535-9891-4912-b774-462a53d5b084/image.png" alt=""></p>
<blockquote>
<p>ProducerConfig</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/7081c26f-7851-4cb3-8827-b17a7d529ad5/image.png" alt=""></p>
<p>브로커 컨테이너를 ec2 인스턴스에 운용하고 있어서 ec2 dns 주소로 작성했다.
로컬 환경의 경우, localhost로 작성하면된다.</p>
<p>KafkaTemplate&lt;String, ?&gt; ?는 제네릭을 뜻하는게 아니고, <code>메시지를 produce할 때 사용할 DTO, Record</code> 타입의 객체를 많이 쓰는 것 같다.</p>
<blockquote>
<p>ConsumerConfig</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/6df4f9f4-1f6e-4114-a1e5-31ae7ad54f63/image.png" alt=""></p>
<p>addTrustPackages를 추가하지 않았을 때는, untrusted packages~ 라는 에러를 로그에서 봐서 구글링해보니 저렇게 명시해줘야 하는 걸 알게되었다.</p>
<blockquote>
<p>메시지 전송을 위한 NotifyDto</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/0ee0c88e-37c6-49b7-b23b-68df32bbe59a/image.png" alt=""></p>
<p>푸시 알림 이벤트를 발행하는 케이스는 특정 유저가 미션에 참여한 경우, 인증글을 작성한 경우지만, 추후 확장성을 위해 <code>NotificationType을 Enum으로 선언</code>했다.</p>
<blockquote>
<p>메시지 produce service</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/a4837cea-ac71-4250-9e3c-29a228f64294/image.png" alt=""></p>
<blockquote>
<p>메세지 cosumer service</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/fd61e7c1-0328-4973-99d7-ac93365b8001/image.png" alt=""></p>
<p><code>@kafkaListners 어노테이션</code>을 통해 메세지 브로커에 들어있는 notify 토픽의 메세지를 consumer하는 서비스 로직이다.</p>
<p>연결이 수립되어 있는 클라이언트로는 푸시 알람과 함께 DB에 저장하고, 연결되어 있지 않은 유저는 알림 없이, DB에 알림을 저장하도록 구현했다.</p>
<blockquote>
<p>이벤트 발행 메서드를 호출하는 service</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/02cb56b8-7405-4a67-a84f-24ae626c9f9c/image.png" alt=""></p>
<p><code>포스트가 작성되었을 때</code>, 작성한 당사자를 제외하고 해당 미션에 참여자 전체에게 푸시 알림, 알림 내역을 저장하는 <code>이벤트를 발행하도록</code> 작성했다.</p>
<h3 id="참고">참고</h3>
<p>강의 : <a href="https://www.youtube.com/watch?v=SqVfCyfCJqw&amp;t=139s">https://www.youtube.com/watch?v=SqVfCyfCJqw&amp;t=139s</a></p>
<p><a href="https://hstory0208.tistory.com/entry/Spring-kafka%EC%99%80-SSEServer-Send-Event%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1-%EB%B0%A9%EB%B2%95">https://hstory0208.tistory.com/entry/Spring-kafka%EC%99%80-SSEServer-Send-Event%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC-%EC%A0%84%EC%86%A1-%EB%B0%A9%EB%B2%95</a></p>
<p><a href="https://ryusunny.tistory.com/122">https://ryusunny.tistory.com/122</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] SSE로 알림 기능 구현하기 - (1)]]></title>
            <link>https://velog.io/@sung_c/Spring-SSE%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1</link>
            <guid>https://velog.io/@sung_c/Spring-SSE%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1</guid>
            <pubDate>Mon, 25 Nov 2024 08:18:23 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트에서 푸시 알림 기능을 도입하게 되었다.
기술 스택 채택과 적용 과정에 대해서 기록하고자 한다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>알림 기능 요구사항</p>
</blockquote>
<ul>
<li>참여중인 미션에 <code>다른 유저가 참여</code>한 경우</li>
<li>참여중인 미션에 <code>다른 유저가 인증글을 작성</code>한 경우</li>
<li>필수 인증 제츨 요일에 <code>미제출 상태</code>인 경우</li>
</ul>
<p>3가지 기능 모두 공통적으로 클라이언트에서 알림을 발행하는 케이스 없이,<code>서버에서 단방향</code>으로 데이터를 내려준다. 또한, 실시간 이벤트 스트리밍이 요구된다.</p>
<h3 id="통신-방식-비교">통신 방식 비교</h3>
<blockquote>
<p>Polling</p>
</blockquote>
<p>클라이언트가 <code>주기적으로 서버로 요청을 보내는</code> 방법이다. 일정 시간마다 서버에 요청을 보내 데이터가 갱신되었는지 확인하고, 갱신되었다면 데이터를 응답 받는 방법이다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/f9a34ec3-8077-49c3-bf3c-69f04361d263/image.png" alt=""></p>
<ul>
<li><code>연결을 끊고, 다시 연결하기를 반복하기 때문에 불필요한 트래픽</code>이 많이 발생한다는 단점이 있다.</li>
</ul>
<blockquote>
<p>Long Polling</p>
</blockquote>
<p> 기존의 Polling 방식에서 개선되어, 요청을 보내고 <code>서버에서 변경이 일어날 때까지 대기</code>하는 방법이다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/b65fc402-c518-4cbf-ba5b-35509147ec9e/image.png" alt=""></p>
<p>변경 사항이 있을 때까지, 커넥션을 유지하다가 응답을 받으면 끊고, 다시 연결 요청을 한다.
즉, 상태가 빈번하게 바뀐다면 polling에서의 단점을 그대로 가진다.</p>
<blockquote>
<p>SSE</p>
</blockquote>
<p>서버와 <code>연결을 맺으면 일정 시간동안 서버에서 변경이 일어날 때마다</code> 데이터를 전달받는 방법이다.
<img src="https://velog.velcdn.com/images/sung_c/post/1db4b4d9-0bf4-4285-91dc-a73ef24c6e31/image.png" alt=""></p>
<p>Long Polling 방식과 유사하지만, 데이터의 갱신이 발생하더라도 연결을 끊는 것이 아니라 일정 시간동안 연결을 유지하는 차이점을 가지고 있다.</p>
<blockquote>
<p>Web Socket</p>
</blockquote>
<p>대표적인 <code>양방향 통신 방법</code>으로, 주로 채팅 서비스에 많이 이용된다.
클라이언트와 서버가 연결을 유지하면서 데이터를 주고 받는 통신 방식이다.</p>
<h3 id="sse로-결정한-이유">SSE로 결정한 이유</h3>
<p>우리 서비스는 클라이언트가 서버로 데이터를 전송하지 않기 때문에, 단방향 통신 방식을 고려하게 되었다. Polling, Long Polling 방식은 요청과 연결이 빈번하기 때문에 리소스 낭비를 초래할 수 있을 뿐더러, SSE 방식은 <code>별도의 의존성 추가가 필요 없고</code>, <code>러닝 커브가 가볍다</code>고 판단해서 sse로 결정하게 되었다.</p>
<p>SSE는 지속적인 연결을 유지하기 때문에, <code>네트워크 리소스를 소모</code>하고, 연결을 유지하기 때문에 <code>서버의 처리 부하를 증가</code>시킬 수 있기 때문에, 이러한 부분을 경계해야한다.</p>
<h2 id="sse-적용하기">SSE 적용하기</h2>
<blockquote>
<p>Notification Entity</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/ce0faf98-a9c4-47b6-b8af-11ef99c97409/image.png" alt=""></p>
<blockquote>
<p>EmitterRepository</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/d810d06a-9195-40dd-aecb-b6d804fa0a31/image.png" alt=""></p>
<p>기술 블로그를 찾아보니, 동시성 이슈때문에 <code>ConcurrentHashMap</code>을 많이 사용하더라. 이번 기회에 몰랐던 자료구조를 활용해보게 되었다.</p>
<p>EmitterRepository는 JPARepository를 사용하지 않고, <code>메모리 상에서 map을 관리</code>한다. 그렇기 때문에 생성, 삭제, 조회 메서드를 직접 구현했다.</p>
<blockquote>
<p>NotifyRepository</p>
</blockquote>
<p>인스타그램에서 알림을 확인하는 기능에서 착안하여, <code>알림을 DB에서 관리</code>하기 위해 JPARepository를 상속받았다.</p>
<blockquote>
<p>NotifyController</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/6e18a472-f4a4-4c9f-829d-997610467e27/image.png" alt=""></p>
<p>클라이언트가 {domain}/api/v1/notify/subscribe로 접근하면, 서버와 연결을 요청하는 API다.</p>
<blockquote>
<p>비즈니스 로직 - emitter 생성</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/28a3d8e9-e3c5-4bb6-8159-c9d677f005bb/image.png" alt=""></p>
<blockquote>
<p>비즈니스 로직 - subscribe</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/5652d511-9efd-4b33-b40e-526b636fda93/image.png" alt=""></p>
<p>위 create 메서드를 호출하여 emitter 객체를 생성하고 커넥션을 맺는 로직이다. 
처음 SSE 커넥션을 맺을 때, <code>아무런 이벤트도 보내지 않으면 재연결 요청을 보낼 때나, 연결 요청 자체에서 오류가 발생</code>한다고 한다. 이런 이유로, 처음 emitter를 생성하면서 더미데이터를 담아서 전송하도록 구현했다.</p>
<blockquote>
<p>send</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/decc8c25-2092-4b0e-ab00-d187544272cf/image.png" alt=""></p>
<p>다른 도메인 서비스에서 send 메서드를 호출한다. 인증글이 새로 작성된 시점에, 서비스를 이용중이지 않은 사용자들이 있을 수 있다. 그렇기 때문에 NPE를 예방하기 위해, 조건문에서 DB에 저장하고 return하도록 작성했다.</p>
<h3 id="적용-결과">적용 결과</h3>
<blockquote>
<p>subscribe api 호출로 커넥션 수립</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/da4f3eee-5b38-4a26-ab38-8ebdd6d86234/image.png" alt=""></p>
<blockquote>
<p>인증글 새로 작성 시, 해당 미션 참여자들에게 알림</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/5a3c9a58-5c76-447f-adde-bc2922e99304/image.png" alt=""></p>
<p>인코딩 이슈가 있긴하지만, DB 저장은 정상적으로 이루어졌다.
<img src="https://velog.velcdn.com/images/sung_c/post/f5127016-ab88-45dd-b2b7-09dd0d9286df/image.png" alt=""></p>
<h3 id="추가해야할-것들">추가해야할 것들</h3>
<p>postService에서 notify를 호출하는 결합도가 높은 코드를 리팩토링하고, 비동기로 메시지큐를 통해 성능을 개선하고자 한다.</p>
<h3 id="참고">참고</h3>
<p><a href="https://velog.io/@wnguswn7/Project-SseEmitter%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#%EF%B8%8F-sse--server-sent-events-">https://velog.io/@wnguswn7/Project-SseEmitter%EB%A1%9C-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#%EF%B8%8F-sse--server-sent-events-</a></p>
<p><a href="https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/">https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] 서킷브레이커 패턴]]></title>
            <link>https://velog.io/@sung_c/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-%EC%84%9C%ED%82%B7%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@sung_c/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-%EC%84%9C%ED%82%B7%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Thu, 14 Nov 2024 08:53:13 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>최근 서비스 규모가 발달함에 따라 대부분의 서비스들이 MSA로 전환되고 있다. <code>MSA와 같은 분산 환경에서는 서비스가 다른 서비스를 동기 호출하는데, 이때 실패할 가능성이 항상 존재한다.</code></p>
<p>한 서비스에 장애가 발생하고, 다른 서비스가 장애가 발생한 서비스를 호출하는 구조일 때, <code>에러를 응답받거나</code>, <code>타임아웃 시점까지 응답을 받지 못하고 대기</code>하면서, <code>쓰레드가 고갈되어 장애를 유발</code>할 수 있다. 뿐만 아니라, 사용자 대기가 쌓이고 latency가 누적되면, 서버 부하로 인해 서비스 다운까지의 대형사고가 발생할 수 있다.</p>
<h3 id="서킷-브레이커-패턴의-개념">서킷 브레이커 패턴의 개념</h3>
<blockquote>
<p>서킷 브레이커란 ❓</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/e7aebb62-b150-4aea-be70-0ac22d36f41b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/860b89b5-f3a2-425a-9d23-976bd257839f/image.png" alt=""></p>
<p>서킷 브레이커는 서로 다른 시스템 간의 연동 시 <code>장애전파 차단</code>을 목적으로 하는 기술이다.
연동 시 이상을 감지하고, 이상이 발생하면 연동을 차단하고, 이후 이상이 회복되면 자동으로 다시 연동하기 위한 기술이다.</p>
<p>서킷브레이커는 3가지의 상태에 따라 서로 다른 동작을 한다. </p>
<ul>
<li><code>OPEN</code></li>
<li><code>CLOSED</code></li>
<li><code>HALF_OPEN</code></li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/2d0d3ad8-7faf-45f9-88b3-ba72bc8c9da6/image.png" alt=""></p>
<p>보통 상태로는 정상적으로 호출되고 응답을 주는 CLOSED 상태, 문제 발생이 감지된 OPEN, HALF OPEN 상태가 있다.</p>
<p>서킷브레이커는 슬라이딩 윈도우를 사용하여 상태의 변화여부를 결정한다. 슬라이딩 윈도우는 횟수 방식과 시간 방식으로 나뉜다.</p>
<p>개발자가 설정한 config에 따라 슬라이딩 윈도우 안에서 <code>허용 기준을 넘으면 상태를 OPEN으로 변경</code>한다. <code>OPEN 상태에서는 연동된 시스템 호출을 시도하지 않으며</code>, 바로 호출 실패 Exception을 발생시키거나 정해진 fallback 동작을 수행한다.</p>
<p>OPEN 이후 설정한 시간이 지나면 HALF_OPEN 상태로 변경되며, 호출이 정상화되었는지 다시 한번 실패 확률로 확인한다. 정상화되었다고 판단되면, CLOSED 상태로 변경되어 정상적으로 연동이 수행된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] S3 PresignedUrl 전환하기]]></title>
            <link>https://velog.io/@sung_c/Spring-S3-PresignedUrl-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sung_c/Spring-S3-PresignedUrl-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 30 Oct 2024 05:24:28 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트를 수행하면서, 미션과 인증글이 생성될 때 이미지 파일을 저장하는데, <code>서버 리소스 절약, 서버 부하 감소</code>를 이유로 presignedUrl을 적용하게 되었다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>기존 이미지 파일 저장 흐름</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/5dd8e662-194e-4362-963b-cb555536b510/image.png" alt=""></p>
<p>기존에는 클라이언트로부터 API 요청이 들어오면, 다른 리소스를 저장하는 것처럼 당연하게도 서버가 s3에 업로드하고 imageUrl을 저장했다.</p>
<h3 id="⚡-문제점">⚡ 문제점</h3>
<p>파일 업로드는 JSON 데이터를 주고 받는 <code>일반 API 요청에 비해 훨씬 큰 부하를 발생시키는 작업</code>이다. </p>
<ul>
<li><p>클라이언트가 파일 업로드 요청을 보내고, <code>서버가 S3에 업로드까지의 시간이 길어지므로, 응답 시간이 증가한다</code>. 이는 커넥션 풀이 늘어지는 것에 영향을 줄 수 있고, <code>병목 현상의 원인</code>이 될 수 있다.</p>
</li>
<li><p>클라이언트가 업로드한 파일이 서버를 거쳐 s3로 전송되는 과정에서 <code>불필요한 네트워크 대역폭이 소모된다.</code> 이는 다른 클라이언트 요청 처리에 필요한 대역폭을 줄이는 결과를 초래할 수 있다.</p>
</li>
</ul>
<h4 id="종합적으로-기존-방식을-유지하면-서버의-리소스-소모가-증가되고-성능이-저하될-가능성이-높고-이는-결과적으로-전체-시스템의-효율성과-안전성에-부정적인-영향을-초래할수-있다">종합적으로 기존 방식을 유지하면, 서버의 리소스 소모가 증가되고 성능이 저하될 가능성이 높고, 이는 결과적으로 전체 시스템의 효율성과 안전성에 부정적인 영향을 초래할수 있다.</h4>
<p>이러한 문제로, 대안점을 찾다가 presignedUrl을 도입하게 되었다.</p>
<blockquote>
<p>PresignedUrl 이란 ❓</p>
</blockquote>
<p>다른 사람이 <code>AWS 보안 자격 증명이나 권한이 없어도 Amazon S3 버킷에 객체를 업로드</code>할 수 있게 허용해주는 URL입니다. S3의 접근 정책이나 권한 정책과 관계없이 <code>특정 유효기간에 S3에 PUT, GET이 가능</code>하다.</p>
<ul>
<li><p>Pre-Signed Url을 통해 <code>허용된 기간과 범위안에서만 접근이 가능하게 제어</code>할 수 있다. 이를 통해 무단 엑세세를 방지하고, 보안을 강화할 수 있다.</p>
</li>
<li><p>Pre-Signed Url을 통해 클라이언트에게 파일 업로드 권한 url을 부여해, <code>클라이언트가 서버 리소스를 거치지 않고 S3에 직접 파일 업로드를 수행</code>할 수 있다. 이를 통해 서버 리소스를 절약하고, 불필요한 네트워크 대역폭을 소모하지 않는 성능상 이점을 누릴 수 있다.</p>
</li>
</ul>
<blockquote>
<p>PresignedUrl 흐름</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/a610e806-6a6c-4dec-90ed-784858aa146b/image.png" alt=""></p>
<h3 id="🧨적용하기">🧨적용하기</h3>
<blockquote>
<p>build.gradle에 AWS SDK 의존성 추가 🔧</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/89fa0766-6b0e-4144-a16c-428ef5c22c9a/image.png" alt=""></p>
<blockquote>
<p>S3config 파일 작성</p>
</blockquote>
<p>s3에 접근권한이 있는 accessKey, secretKey를 기반으로 <code>credential을 만들어 S3Presigner 객체를 생성</code>한다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/979cabd4-6d49-4b90-9501-fa1462136a39/image.png" alt=""></p>
<blockquote>
<p>ImageService 작성</p>
</blockquote>
<p>config파일에서 명시한 presigner bean 의존성을 추가한다.</p>
<p>이미지 업로드, 조회를 위한 get과 put 방식의 presignedurl을 생성하는 메서드를 작성한다.
<img src="https://velog.velcdn.com/images/sung_c/post/7f2ea092-55c5-4556-bdbd-f9ec5b05d4c8/image.png" alt=""></p>
<blockquote>
<p>ImageController 작성</p>
</blockquote>
<p>샘플코드로 작성해보았다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/03f6a3c5-e531-4228-926f-fc77d7826102/image.png" alt=""></p>
<h3 id="💻-테스트해보기">💻 테스트해보기</h3>
<blockquote>
<p>흐름 ⏳</p>
</blockquote>
<p>먼저 클라이언트는 업로드하고자 하는 파일명을 requestParam으로 api를 요청한다.
그럼 서버는<code>s3에 서명을 인가받은 url 경로를 응답</code>해준다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/fbb5061c-d433-4758-bca6-536ea973d760/image.png" alt=""></p>
<p>다음으로, 클라이언트는 서버로부터 응답받은 <code>url에 binary타입으로 이미지를 첨부해 put 요청을 날린다.</code></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/64c20897-e306-49c8-b8cc-5fd9b1fed0a5/image.png" alt=""></p>
<p>마지막으로, 클라이언트는 업로드한 파일 경로를 서버에게 전달해, 서버는 해당 경로를 DB에 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/1c5cb203-19f9-4b88-8c9b-dedbdbef13b5/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Test Container를 통한 Repository 계층 테스트하기]]></title>
            <link>https://velog.io/@sung_c/Spring-Test-Container%EB%A5%BC-%ED%86%B5%ED%95%9C-Repository-%EA%B3%84%EC%B8%B5-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sung_c/Spring-Test-Container%EB%A5%BC-%ED%86%B5%ED%95%9C-Repository-%EA%B3%84%EC%B8%B5-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 24 Oct 2024 07:13:17 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>최근 테스트 코드를 작성하면서, 이전에 적용했던 <a href="https://velog.io/@sung_c/Spring-In-memery-%EB%B0%A9%EC%8B%9D%EC%9D%84-%ED%86%B5%ED%95%9C-JPA-Repository-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8">인메모리 DB를 기반으로 테스트 하는 방식</a>에 의문점을 가지게 되었다.</p>
<p>Repository 계층의 역할은 DB와 의존성을 맺어서 데이터를 주거니 받거니한다. 그러나, Mock이나 InmemoryDB를 통해 테스트를 독립적으로 수행한다면, <code>운영 환경 DB인 MariaDB와 트랜잭션 처리 방식의 다른점, 제약 조건 및 인덱스가 다름으로 인해 신뢰성이 낮은 테스트 결과</code>를 얻을 수 있다. 이렇게 되면, 오류를 사전에 방지하기 위해서인 <code>테스트의 목적을 충족시키지 못하는거 아닐까</code>라는 생각이 들었다. 덧붙여서, 배포환경과 로컬환경의 <code>DB 내부 데이터가 다르면, 결국 테스트 커버리지의 범위가 상이할 수 밖에 없다</code>라고 생각이 들었다.</p>
<p>그래서 실무에서는 어떻게 테스트 환경을 운영하는지 개발자 단톡방에서 물어봤다.</p>
<img src="https://velog.velcdn.com/images/sung_c/post/205488e1-7d0d-4d0d-a332-0346da530c3c/image.png" width="40%" height="20%">

<h2 id="본론">본론</h2>
<p>테스트를 수행하는데 있어서, 가장 중요한 점은 <code>멱등성</code>, <code>편의성</code>이라고 생각했다.</p>
<p>누가, 어떤 환경에서라도 테스트를 수행했을 때 동일한 결과가 나와야한다. 또한, 최대한 운영 환경과 유사한 조건으로 테스트 환경을 구축해야 한다. 그리고, 현재는 백엔드 서버를 혼자 개발하고 있지만, 팀원들과 협업하는 과정에서 테스트를 위해 로컬에 MariaDB를 설치해야한다.</p>
<p>위 3가지 이유로, 기존의 InMemeoryDB에서 벗어나, Test Container를 도입하기로 결정했다.</p>
<blockquote>
<p>Test Container의 특징</p>
</blockquote>
<ul>
<li>테스트 컨테이너는 Junit을 지원하는 Java 라이브러리이다.</li>
<li>도커 컨테이너 기반의 일회용 인스턴스를 제공한다.</li>
<li>각종 DBMS의 이미지를 활용할 수 있다.</li>
</ul>
<h3 id="test-container-적용하기">Test Container 적용하기</h3>
<blockquote>
<p>Gradle 의존성 추가</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/21188005-04f2-4da6-906c-87392b864b90/image.png" alt=""></p>
<blockquote>
<p>application-test.properties 파일 작성</p>
</blockquote>
<pre><code class="language-shell">spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.url=jdbc:tc:mariadb:10.11://test
spring.datasource.username={}
spring.datasource.password={}</code></pre>
<p>port 기입이 생략되었는데, 그 이유는 컨테이너를 생성하고 구동할 때, 미사용중인 포트를 JVM에서 알아서 설정해준다.</p>
<blockquote>
<p>Container 관련 config 작성</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/ce60ee1d-ce89-478f-8cd2-913f3ddb77b4/image.png" alt=""></p>
<p><code>@DynamicPropertySource</code> : 동적으로 프로퍼티를 설정할 때 사용하는 어노테이션으로,
사실 application-test.properties를 통해 명시했기 때문에, 작성하지 않아도 된다.</p>
<p>그리고, MariaDBContainer 객체를 생성할 때, <code>static을 붙이면, 테스트 수행 이후 종료가 되도, 컨테이너를 다음에 재사용</code>한다.</p>
<p>JPA의 GenerateValue와 같은 <code>sequence 이슈</code>가 있기에 참고해야한다!</p>
<blockquote>
<p>테스트하려는 RepositoryTest에 사용하려는 프로퍼티 명시</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/bfa06396-f07c-425a-99e9-53bb758fd437/image.png" alt=""></p>
<blockquote>
<p>테스트를 위한 더미데이터 세팅</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/27a75e53-f5c0-4ad3-b2fe-922c7904d02d/image.png" alt=""></p>
<p>datq.sql로 어플리케이션 구동시, Insert 쿼리문을 통해 데이터를 삽입하는 방식과 위처럼 더미데이터를 세팅하는 방식을 고민하다가, <code>시퀀스 이슈</code>와 <code>영속성 관련 테스트</code>를 위해 위처럼 적용했다.</p>
<blockquote>
<p>테스트 수행</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/2a50df2b-7f78-4426-91b4-c2dae736007e/image.png" alt=""></p>
<p>테스트가 시작되면, MariaDB 컨테이너 이미지 유무를 검증하고, 없다면 image를 다운받는다. 다운받은 image를 기반으로 test container를 구동하고, 테스트가 종료되면 컨테이너도 내려간다.</p>
<blockquote>
<p>동작중인 Test Container
<img src="https://velog.velcdn.com/images/sung_c/post/52c3bb77-a7dc-41e4-9595-7593ccdc76c0/image.png" alt=""></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/61256212-3cb9-4d9b-a55f-75874be4e12f/image.png" alt=""></p>
<p>테스트가 정상적으로 수행되었다. 운영환경과 같은 RDMBS를 사용했고, 이후에는 배포 환경의  데이터를 일부 가져와서 테스트를 수행하려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] In memery 방식을 통한 JPA Repository 단위 테스트]]></title>
            <link>https://velog.io/@sung_c/Spring-In-memery-%EB%B0%A9%EC%8B%9D%EC%9D%84-%ED%86%B5%ED%95%9C-JPA-Repository-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@sung_c/Spring-In-memery-%EB%B0%A9%EC%8B%9D%EC%9D%84-%ED%86%B5%ED%95%9C-JPA-Repository-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Tue, 08 Oct 2024 08:29:33 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p>사이드 프로젝트 수행 중, QueryDSL로 작성한 동적 쿼리에 대해 단위 테스트 코드를 작성하게 되었다. 로컬DB를 통해 단위 테스트를 정상 통과하는 것을 확인하고, CI/CD를 통한 배포 중 오류에 부딪혔다.
<img src="https://velog.velcdn.com/images/sung_c/post/733a6925-15f5-41aa-9627-f4e456aa4d77/image.png" alt=""></p>
<p>운영 서버에서는 RDS에 올라간 DB로 Repositort 계층 단위테스트를 수행하는데, 내부 데이터가 달라서 테스트를 통과하지 못하는 문제였다.</p>
<h1 id="본론">본론</h1>
<p>💡 테스트 환경을 독립적으로 구성하는 것을 고려하게 되었다.</p>
<blockquote>
<p>In-Memory DB를 이용한 테스트 환경 구축</p>
</blockquote>
<ul>
<li><p>장점</p>
<ul>
<li>메모리상에서 DB와 관련 작업을 처리하기 때문에 빠른 속도</li>
<li>테스트 안에서만 독립적으로 수행되기 때문에 다른 것에 영향 받지 않음</li>
<li>실행이 완료된 테스트는 모두 휘발되어 이후 테스트에 영향을 주지 않음</li>
</ul>
</li>
<li><p>단점</p>
<ul>
<li>실제 환경과 동작되는 구성이 달라 모든 상황에 대해 완벽한 테스트를 했다고 볼 수 없음</li>
</ul>
</li>
</ul>
<h3 id="기술적인-고민">기술적인 고민</h3>
<p>빠른 속도를 기반으로 테스트를 수행하는 것보다, 정확하게, 다양한 환경에서의 테스트가 더 중요하다고 생각했다.</p>
<blockquote>
<p>⚡ 그럼에도 InMemoryDB를 적용하게 된 이유</p>
</blockquote>
<ol>
<li>언제, 어디서, 모든 시점과 환경을 고려하여 테스트를 수행하는 것은 불가능에 가깝다.</li>
<li>당장 로컬 환경과 배포 환경만 비교하더라도, 운영체제가 다르고, 사용하는 DB도 다르고, 운영 데이터도 다르다.</li>
<li>위와 같은 이유로, 이미 개발 환경과, 배포 환경이 다르기 때문에 독립적으로 테스트 환경을 구성하는 것이 이점이 많겠다라고 판단해서 도입하게되었다.</li>
</ol>
<h3 id="in-memory-db-환경-설정">In-Memory DB 환경 설정</h3>
<ol>
<li><p>dependencies 추가
<img src="https://velog.velcdn.com/images/sung_c/post/c21ba075-dd72-4ae5-bb81-c5106c105ce3/image.png" alt=""></p>
</li>
<li><p>properties 파일 작성
<img src="https://velog.velcdn.com/images/sung_c/post/effe1d52-ce0f-4092-8535-661f6adbd404/image.png" alt=""></p>
</li>
</ol>
<ol start="3">
<li>@DataJpaTest, @ActiveProfiles 어노테이션 선언
<img src="https://velog.velcdn.com/images/sung_c/post/eca2e4b0-2004-48a9-8af2-0da0ebcac224/image.png" alt=""></li>
</ol>
<blockquote>
<p>테스트 수행</p>
</blockquote>
<p>InMemory DB를 사용하기 때문에, 테스트 수행 전 더미데이터를 삽입하고, 영속성 객체를 담아둔다.
<img src="https://velog.velcdn.com/images/sung_c/post/c316e6da-934e-41a1-b7b1-1dd625c013a9/image.png" alt=""></p>
<blockquote>
<p>테스트 수행 결과</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/b717b1db-9df3-4731-8152-cdf7fe486c44/image.png" alt=""></p>
<p>로컬에서는 정상적으로 수행되었다! 😆</p>
<p>그렇다면, 이제 대망의 CI/CD 파이프라인을 통한 배포 서버를 확인해보자..
<img src="https://velog.velcdn.com/images/sung_c/post/4604990f-08be-4fe3-9cc2-1a1cb411339e/image.png" alt=""></p>
<p>성공적으로 테스트를 통과했다.</p>
<blockquote>
<p>추가 트러블 슈팅</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/2b37b364-0188-4914-aa7f-3e4dadc3c7cd/image.png" alt=""></p>
<p>위 이슈는 <code>예약된 키워드를 식별자로 사용해서 발생하는 것</code>이라고 한다.</p>
<p>H2 2.X.X버전에서 <code>‘user’</code>라는 키워드가 h2의 예약어로 사용되는 것이다.</p>
<ol>
<li>테이블 명을 member로 수정한다</li>
<li>application.yml(properties) 파일에 <code>spring.datasource.url</code>을 <code>jdbc:h2:mem:testdb;NON_KEYWORDS=USER</code>와 같이 <code>NON_KEYWORDS=USER</code>를 추가해서 h2에게 예약어가 아님을 명시해준다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Junit- Multipartfile과 DTO를 전달받는 API 단위 테스트]]></title>
            <link>https://velog.io/@sung_c/Spring-Junit-Multipartfile%EA%B3%BC-DTO%EB%A5%BC-%EC%A0%84%EB%8B%AC%EB%B0%9B%EB%8A%94-API-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@sung_c/Spring-Junit-Multipartfile%EA%B3%BC-DTO%EB%A5%BC-%EC%A0%84%EB%8B%AC%EB%B0%9B%EB%8A%94-API-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Tue, 24 Sep 2024 04:27:54 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p> 테스트코드를 작성하면서, form-data타입으로 DTO 객체와 Multipartfile 객체를 받는 컨트롤러 계층에서 마주한 이슈와 해결과정을 기록하고자 한다.</p>
<h1 id="본론">본론</h1>
<p> 사용자의 프로필을 수정하는 컨트롤러에서, 수정할 닉네임은 DTO 객체로, 프로필 사진은 Multipartfile로 받아서 서비스계층의 메서드를 호출한다.</p>
<blockquote>
<p>UserController</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/137d3094-7752-4bd0-9e96-177e024bcdf5/image.png" alt=""></p>
<ul>
<li>해당 컨트롤러의 API의 테스트를 수행하기 위해, <code>DTO 객체를 objectMapper를 통해 DTO -> string -> multipartfile 객체로 전환</code>하여, 테스트코드를 작성하는 방법을 탐색할 수 있었다.</li>
</ul>
<ol>
<li>먼저 Stub 객체를 생성한다.</li>
</ol>
<blockquote>
<p>UserObjectFixture</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/8af4bfe7-f3b2-4e30-81a0-8ac6f95a7504/image.png" alt=""></p>
<ol start="2">
<li>위에서 말했듯, DTO를 Multipartfile로 전환한다.</li>
</ol>
<pre><code class="language-java">MockMultipartFile file = new MockMultipartFile(&quot;file&quot;, fileName, contentType, &quot;test data&quot;.getBytes(StandardCharsets.UTF_8));

MockMultipartFile requestDto = new MockMultipartFile(&quot;requestDto&quot;, &quot;request.json&quot;, &quot;application/json&quot;, mapper.writeValueAsBytes(updateRequest));
</code></pre>
<ol start="3">
<li>mockMvc로 테스트 수행 간, MockMultipartfile로 전환한 DTO, multipartfile 이미지를 파라미터로 넘겨준다.</li>
</ol>
<pre><code class="language-java"> mockMvc.perform(multipart(HttpMethod.PUT,&quot;/api/v1/user/profile&quot;)
                .file(file)
                .file(requestDto)
                        .with(csrf()))
                .andExpect(status().isOk())
                .andDo(print());
</code></pre>
<p><img src="https://velog.velcdn.com/images/sung_c/post/68d841be-af6e-48ef-a6d7-2e609dd70522/image.png" alt=""></p>
<p>테스트가 정상적으로 수행되었다.</p>
<blockquote>
<p>전체 코드</p>
</blockquote>
<img src="https://velog.velcdn.com/images/sung_c/post/009af661-c7c0-4f8a-89bd-e842ca94236e/image.png" width="2000" height="3000">






]]></description>
        </item>
        <item>
            <title><![CDATA[[Kafka] Kafka란?]]></title>
            <link>https://velog.io/@sung_c/Kafka-Kafka%EB%9E%80</link>
            <guid>https://velog.io/@sung_c/Kafka-Kafka%EB%9E%80</guid>
            <pubDate>Sat, 07 Sep 2024 11:18:28 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>RabbitMQ를 간단하게 다뤄보면서, 분산 메세지큐 기술에 관심을 가지게 되었다. 
엄밀하게 말하자면, 오늘 포스팅할 카프카는 메시지큐 기술이 아닌, 이벤트 브로커 기술이다. 그중에서도 가장 많이 쓰이는 Apache Kafka는 왜 쓰이는지 알아보자.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>메시지 지향 미들웨어(MOM)이란 ?</p>
</blockquote>
<ul>
<li>응용 소프트웨어간의 <code>비동기적 데이터 통신</code>을 위한 시스템</li>
<li>메시지 지향 미들웨어는 <CODE>메시지를 전달하는 과정에서 보관하거나 라우팅 및 변환</code>할 수 있다는 장점을 가진다.</li>
<li>즉 메시지큐는 FIFO(선입선출) 자료구조인 Queue를 통해 메시지 지향 미들웨어를 구현한 시스템이라고 보면 된다.</li>
</ul>
<blockquote>
<p>메시지 브로커 vs 이벤트 브로커</p>
</blockquote>
<ul>
<li><p><strong>메시지 브로커</strong> :</p>
<ul>
<li>메세지 브로커는 Producer가 생산한 메시지를 메세지 큐에 저장하고, 저장된 메시지를 Consumer가 가져가고, 메시지큐에서 삭제되는 구조다.</li>
</ul>
</li>
<li><p><strong>이벤트 브로커</strong> :</p>
<ul>
<li>메시지 브로커에서는 Consumer가 메시지를 가져가면 삭제되지만, <code>이벤트 브로커에서는 얼마든지 다시 소비</code>할 수 있다.</li>
</ul>
</li>
</ul>
<h3 id="✨-카프카란-">✨ 카프카란 ?</h3>
<p>카프카(Kafka)는 <code>파이프라인</code>, <code>스트리밍 분석</code>, 데이터 통합 및 미션 크리티컬 애플리케이션을 위해 설계된 고성능 분산 이벤트 스트리밍 플랫폼이다.</p>
<blockquote>
<p>카프카 도입 이전의 문제점</p>
</blockquote>
<ul>
<li>기존 애플리케이션과 DB가 end-to-end구조
-&gt; 파이프라인이 파편화되어 있고, 요구사항이 추가될 때마다, 데이터 시스템의 복잡도가 높아짐에 따라 파이프라인 관리가 어려워짐</li>
</ul>
<h4 id="이러한-문제를-해결하기-위해-모든-이벤트데이터의-흐름을-중앙에서-관리하는-시스템인-kafka가-개발되었다">이러한 문제를 해결하기 위해 모든 이벤트/데이터의 흐름을 중앙에서 관리하는 시스템인 Kafka가 개발되었다.</h4>
<h3 id="카프카-개념">카프카 개념</h3>
<blockquote>
<p>브로커</p>
</blockquote>
<ul>
<li>카프카 클라이언트와 데이터를 주고받기 위해 사용하는 주체</li>
<li><code>데이터를 분산 저장</code>하여 장애 발생 시 안정성 확보</li>
<li><code>1 : 1관계로 서버, 카프카 프로세스</code>가 매칭되는 프로세스</li>
<li>안정성을 위해 <code>3대 이상의 브로커 서버를 한 개의 클러스터로 묶어 운영</code></li>
<li>카프카 <strong>클러스터로 묶인 브로커들</strong>은 프로듀서가 보낸 데이터를 안전하게 분산 저장 및 복제하는 열할을 수행한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/d73e324a-efbc-43fc-b4c3-78ab492ee255/image.png" alt=""></p>
<blockquote>
<p>데이터 전송, 저장</p>
</blockquote>
<ul>
<li>Producer : 데이터 생산</li>
<li>Consumer : 데이터 소비</li>
<li>Broker : 프로듀소가 요청한 토픽의 파티션에 데이터를 저장하고, 컨슈머가 데이터를 요청하면 저장된 데이터를 전달하는 역할을 수행
프로듀로서부터 전달된 데이터는 <code>DB, Memory에 저장되는 것이 아닌 File System에 저장된다.</code></li>
</ul>
<blockquote>
<p>데이터 복제, 싱크</p>
</blockquote>
<p>카프카를 <code>장애 허용 시스템</code>으로 동작하도록 하는 원동력이다.</p>
<ul>
<li>데이터 복제는 파티션 단위로 이루어짐</li>
<li>복제된 파티션은 <code>리더, 팔로워</code>로 나뉘어져 관리된다.</li>
<li>리더 : 프로듀서, 컨슈머와 직접 통신하는 파티션</li>
<li>팔로워 : 나머지 복제 데이터</li>
</ul>
<p>리더 파티션을 가지고 있는 브로커에 에러가 발생하면, 팔로워 파티션 중 하나가 리더를 넘겨받는다.</p>
<blockquote>
<p>데이터 삭제</p>
</blockquote>
<ul>
<li>카프카는 컨슈머가 데이터를 가져가더라도 <strong>토픽의 데이터를 삭제하지 않는다</strong>. (메시지큐와 가장 큰 차이점이다.)</li>
<li>프로듀서나 컨슈머는 삭제를 요청하지 않는다. 오직 데이터 관리는 브로커가 수행한다.</li>
<li><code>데이터 삭제는 파일 단위(로그 세그먼트)로 이루어지기 때문에</code>, 일반적인 DB처럼 <code>특정 DB를 선발해서 삭제가 불가능하다.</code></li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/e770e742-c55f-4bce-acac-75236381b74a/image.png" alt=""></p>
<blockquote>
<p>토픽</p>
</blockquote>
<ul>
<li>토픽 삭제 -&gt; 데이터 삭제 -&gt; 파이프라인 중단</li>
<li>1개 이상의 파티션 보유</li>
</ul>
<blockquote>
<p>파티션</p>
</blockquote>
<ul>
<li>토픽은 1개 이상의 파티션</li>
<li>프로듀서가 보낸 데이터들은 파티션에 저장된다. 해당 데이터를 &quot;레코드&quot;라고 부른다.</li>
<li>카프카의 병렬 처리의 핵심</li>
<li>컨슈머의 처리량이 한정된 상황에서, 많은 레코드를 병렬로 처리하는 가장 좋은 방법은 <code>컨슈머의 개수를 늘려 스케일 아웃하는 것</code>이다.</li>
</ul>
<blockquote>
<p>컨슈머 그룹</p>
</blockquote>
<ul>
<li>컨슈머 그룹으로 묶은 컨슈머들은 토픽의 1개 이상의 파티션에 할당되어 데이터를 가져온다.</li>
<li>컨슈머 그룹의 개수 &lt;= 토픽의 파티션 개수</li>
</ul>
<blockquote>
<p>Offset 커밋</p>
</blockquote>
<ul>
<li>컨슈머는 카프카 브로커로부터 <code>데이터를 어디까지 가져갔는지</code> 커밋을 통해 기록</li>
<li>커밋 방법은 비명시 커밋과 명시 커밋으로 나뉜다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/1de31db3-8c18-4076-b9c6-2dc01734552e/image.png" alt=""></p>
<ul>
<li>비명시 커밋 : 일정 간격마다 자동으로 수행된다.</li>
<li>명시 커밋 : 사용자가 직접 커밋을 수행하는 방식으로, 데이터 유실과 중복을 허용하지 않는다. (데이터 정합성을 유지하는데 더 뛰어난 방법이다)</li>
</ul>
<blockquote>
<p>명시 커밋</p>
</blockquote>
<p>명시 커밋은 또 동기 오프셋 커밋과, 비동기 오프셋 커밋으로 나뉜다.</p>
<ul>
<li>동기 오프셋 커밋 : 브로커로 커밋 요청 이후 커밋이 완료되기까지 대기하는 방식
  -&gt; 완료 응답을 대기하기 때문에 처리량이 낮아 속도가 오래걸린다.</li>
<li>비동기 오프셋 커밋 : 대기하지 않고 callback 함수를 통해 결과를 얻는다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring-Security Test를 위한 @WithMockUser 커스터마이징]]></title>
            <link>https://velog.io/@sung_c/Spring-Spring-Security-Test%EB%A5%BC-%EC%9C%84%ED%95%9C-WithMockUser-%EC%BB%A4%EC%8A%A4%ED%84%B0%EB%A7%88%EC%9D%B4%EC%A7%95</link>
            <guid>https://velog.io/@sung_c/Spring-Spring-Security-Test%EB%A5%BC-%EC%9C%84%ED%95%9C-WithMockUser-%EC%BB%A4%EC%8A%A4%ED%84%B0%EB%A7%88%EC%9D%B4%EC%A7%95</guid>
            <pubDate>Tue, 27 Aug 2024 15:26:28 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>Spring Security + OAuth 2.0 + JWT로 인증·인가를 구현한 프로젝트에서 컨트롤러 단위 테스트 코드를 작성중 발생한 문제 상황이다. Controller에 도착하기 전 <code>Filter가 가로채서... /login 경로로 리다이렉트시켜 인증을 요구</code>한다.
<img src="https://velog.velcdn.com/images/sung_c/post/69e51e4c-d612-44f8-aae2-bbddcc97db14/image.png" alt=""></p>
<p> <code>Mock 객체를 SecurityContextHolder에 담는 방법</code>을 탐색하다가 해당 방법론을 적용하게 되었다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>@WithMockUser</p>
</blockquote>
<p>Spring Security를 적용한 프로젝트에서 가장 일반적으로 사용되는 어노테이션이다.</p>
<ul>
<li>username, password, roles를 Mock에 바인딩해 사용한다.</li>
<li>authentication말고도 principal에도 유저 정보를 바인딩해주고, SpringContext에도 올라간다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/43bc73c5-c4a8-47b9-ba70-7a0f20ab5dfa/image.png" alt=""></p>
<p>그러나 위 방법은 직접 만든 Authentication 인증정보에는 사용이 불가능하다.
<img src="https://velog.velcdn.com/images/sung_c/post/ebbe8a37-0c91-4503-aabb-faa47386b3b8/image.png" alt=""></p>
<p>인증, 인가를 구현하면서 <code>CustomOAuthUser 객체에 사용자 정보를 담고 SecurityContextHolder에 담도록 개발</code>했기 때문에, 위 어노테이션은 사용이 불가능하다..</p>
<blockquote>
<p>@WithUserDetails</p>
</blockquote>
<p>위 어노테이션과 차이점은 <code>UserDetails 객체를 조회해서 인증 정보를 만든다</code>는 점이다.
마찬가지로, CustomOAuthUser 객체를 통해 Authentication을 생성하지 않기 때문에, 이 방법도 적용이 불가능했다.</p>
<h2 id="해결-방법">해결 방법</h2>
<blockquote>
<h4 id="withsecuritycontext">@WithSecurityContext</h4>
</blockquote>
<ul>
<li>Authentication을 커스텀한 경우에 사용하는 방법이다.</li>
</ul>
<h3 id="순서">순서</h3>
<ol>
<li>@WithSecurityContext 어노테이션이 적용된 커스텀 어노테이션을 작성한다.<ul>
<li>SecurityContextFactory를 빈으로 주입받아서 SecurityContext를 만든다.</li>
</ul>
</li>
<li>SecurityContextFactory를 구현한다.</li>
</ol>
<blockquote>
<p>커스텀 어노테이션 작성 @WithMockCustomUser</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/65ea03f8-44ff-4878-a3b3-2242aaf8b1b5/image.png" alt=""></p>
<blockquote>
<p>SecurityContextFactory 클래스 생성</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/a2f11dbf-043e-4b02-b0e4-df20a708e76f/image.png" alt=""></p>
<p>OAuth2 인증정보로 사용자 객체를 생성할 때, UserDto -&gt; CustomOAuth2User 객체를 만들어 <code>Authentication을 생성</code>한다. 
생성한 Authentication을 <code>SecurityContext에 담는다.</code></p>
<blockquote>
<p>테스트 실행</p>
</blockquote>
<ul>
<li><p>사용자 정보 조회 Controller 테스트 코드
<img src="https://velog.velcdn.com/images/sung_c/post/3904a662-fe58-4090-a7de-93789ff26897/image.png" alt=""></p>
</li>
<li><p>실행 결과</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung_c/post/1af512ef-1306-470e-8652-2be0cccf3671/image.png" alt=""></p>
<p>위 @WithMockUser와의 차이점은 내가 <code>커스텀한 Authentication == CustomOAuth2User 객체가 바인딩되어서 정상적으로 filter를 통과</code>하고 controller에 대한 테스트가 정상 수행됐다는 점이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블 슈팅] JSON 직렬화 및 역직렬화]]></title>
            <link>https://velog.io/@sung_c/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-JSON-%EC%A7%81%EB%A0%AC%ED%99%94-%EB%B0%8F-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94</link>
            <guid>https://velog.io/@sung_c/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-JSON-%EC%A7%81%EB%A0%AC%ED%99%94-%EB%B0%8F-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94</guid>
            <pubDate>Wed, 21 Aug 2024 11:25:34 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트 수행 중, 팀의 FE 개발자 분이 디스코드로 메세지를 주셨다.
JSON 직렬화 이슈로 3가지의 에러를 마주했고 해결 과정을 기록하고자 포스팅하게 되었다.</p>
<blockquote>
<p>Object Mapper란?</p>
</blockquote>
<ul>
<li>역직렬화 : JSON ➡️ Java</li>
<li>직렬화 : Java 객체 ➡️ JSON</li>
</ul>
<p>위 과정을 수행할 때 사용하는 JackSon의 라이브러리 클래스다.
<code>ObjectMapper가 객체를 역/직렬화 할 때 기본 생성자를 사용</code>한다.</p>
<p>따라서, 요청 혹은 응답 DTO 내부에 기본생성자가 존재하지 않으면, deletegate 또는 property 기반의 생성자를 찾는데 이게 없기 때문에 위 에러를 뱉는 것이다. (delegate, property 부분은 추가적으로 공부해봐야겠다)</p>
<h2 id="첫-번째-에러-해결">첫 번째 에러 해결</h2>
<h3 id="문제-상황">문제 상황</h3>
<pre><code>{
success: false,
code: 400,
data: [ ],
errors: {
message: &quot;Could not read JSON:Cannot construct instance of `dailymissionproject.demo.domain.mission.dto.response.MissionResponseDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (byte[])&quot;{&quot;@class&quot;:&quot;dailymissionproject.demo.domain.mission.dto.response.MissionResponseDto&quot;,&quot;title&quot;:&quot;string&quot;,&quot;content&quot;:&quot;string&quot;,&quot;imgUrl&quot;
 &quot;[2024,8,16],&quot;endDate&quot;[truncated 14 bytes]; line: 1, column: 85] &quot;
},</code></pre><p>위 ObjectMapper 설명을 통해 기본 생성자가 없기 때문에 발생한 것이다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p>응답 객체의 경우 <strong>불변 객체로 선언</strong>해두었기에, 기본 생성자를 만들어주는 <code>@NoargsConstructor</code> 어노테이션에 강제 옵션을 추가해서 첫 번째 에러를 해결했다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/a076bfd8-f4c4-4d92-8faf-1cd077d46c48/image.png" alt=""></p>
<h2 id="두-번째-에러-해결">두 번째 에러 해결</h2>
<h3 id="문제-상황-1">문제 상황</h3>
<pre><code>org.springframework.web.util.NestedServletException: Request processing failed; nested exception is org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion</code></pre><p>Jackson에서 <code>양방향 관계로 맺어진 객체는 무한 재귀가 발생하는 문제</code>가 있는데,
Mission과 MissionRule은 1:1관계로 서로 순환 참조를 하고 있다. cascade옵션도 걸려있는 상태다.</p>
<p>따라서 객체가 Json으로 직렬화하는 과정에서 Mission이 변환하면서 연관 관계로 매핑되어 있는 MissionRule의 참조를 수행하고, 다시 MissionRule은 Mission을 참조하는 <strong>Infinite Recursion</strong>에 빠지는 것이다.</p>
<h3 id="해결-방법-1">해결 방법</h3>
<p>이를 해결하기 위해서는, 양방향 매핑을 선언한 필드에 직렬화와 관련한 어노테이션을 선언해주어야 한다.</p>
<blockquote>
<p>@JsonManagedReference</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/9260867b-f47e-4b27-abd9-2ae1cee28f13/image.png" alt=""></p>
<blockquote>
<p>@JsonBackReference</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/b023bf76-938c-4835-8fca-98c90a6be68c/image.png" alt=""></p>
<p>해결이 될 줄 알았는데, 아래 에러를 마주했다..😭</p>
<h2 id="세-번째-에러-해결">세 번째 에러 해결</h2>
<p><img src="https://velog.velcdn.com/images/sung_c/post/56df28e0-7981-43a9-a4f9-ed8d116f2a1d/image.png" alt=""></p>
<h3 id="문제-상황-2">문제 상황</h3>
<p>Mission과 MissionRule은 1:1의 관계다. 다음 Mission의 코드를 보면 <strong>Lazy 로딩 옵션</strong>이 걸려있다.</p>
<blockquote>
<p>Mission</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/fa3e750c-2bbf-497a-95ef-660d8f69023a/image.png" alt=""></p>
<p>문제는 미션 상세 조회 요청이 들어왔을 때, 아래 MissionResponseDTO를 통해 정보를 반환하려 할 때 발생한다.
필드를 통해 볼 수 있듯, <code>MissionRule 도메인을 직접 반환하는 문제</code>가 있다..
Entity를 직접 반환하기보다, DTO로 변환하여 반환시켜야한다.</p>
<blockquote>
<p>MissionResponseDto</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/3feedfd2-872d-4f6b-974f-03aa10ead4d5/image.png" alt=""></p>
<blockquote>
<p>MissionService</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/afbd22f6-862f-49ba-ac28-fc31250fb0e1/image.png" alt=""></p>
<p>MissionResponseDto 객체를 생성하면서 MissionRule를 초기화하지 않는다.
➡️ FetchType.Lazy 옵션이기 때문에, </p>
<p>실제 MissionRule이 아닌 <strong>Proxy$G9BGsCil[\hibernateLazyInitializer] 가 담겨있다.</strong></p>
<p>따라서, <code>해당 Proxy를 Serialize</code> 시키려고 하니 에러가 발생한 것이다!</p>
<h3 id="해결방법">해결방법</h3>
<ul>
<li>DTO를 통해 초기화 시키는 방식</li>
</ul>
<blockquote>
<p>MissionRuleResponseDto</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/3ea4c987-032d-4150-a723-83c2af5c5fba/image.png" alt=""></p>
<p>of 정적팩토리 메서드를 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/f9bd4322-8442-4661-abd6-d745992312f9/image.png" alt=""></p>
<p>DTO 내부에서 MissionRule을 반환하면서 초기화를 수행하는 방식으로 리팩토링했다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/723a824a-42a0-4c8d-aad1-21ee090c22d2/image.png" alt=""></p>
<p>2번째 문제 이유인 <strong>엔티티 간 양방향 순환 참조</strong>는 <code>3번의 에러를 해결하는 과정에서, 엔티티가 아닌 DTO 초기화로 방향성을 잡았기 때문에, @Json 어노테이션을 사용하지 않아도 됩니다.<code></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블 슈팅] CORS 에러 ]]></title>
            <link>https://velog.io/@sung_c/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-CORS-%EC%97%90%EB%9F%AC</link>
            <guid>https://velog.io/@sung_c/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-CORS-%EC%97%90%EB%9F%AC</guid>
            <pubDate>Mon, 12 Aug 2024 14:11:57 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>프론트앤드 개발팀원과 백엔드 서버 API 연계 작업을 하던 중, 악명높은 CORS 에러에 직면했다. CORS 이슈가 무엇이고, 해결 과정에 대해 기록하고자 한다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p>CORS란 ❓</p>
</blockquote>
<p>CORS(Cross Origin Resource Sharing)의 약자로, &#39;교차 출처 리소스 공유&#39;를 의미한다.
즉, CORS란 <code>도메인이 다른 서버끼리 리소스를 주고 받을 시에 보안을 위해 설정된 정책</code>이다.</p>
<h3 id="cross-origin-판단-기준">Cross-Origin 판단 기준</h3>
<p><img src="https://velog.velcdn.com/images/sung_c/post/c3d96bf8-e8e2-47f4-8967-00b72f0e5dad/image.png" alt=""></p>
<p>두 개의 출처가 같다고 판단하는 로직은, 두 URL의 구성 요소 중 <code>Protocol</code>, <code>Host</code>, <code>Port</code> 이 3가지가 동일해야한다.
예시)</p>
<ul>
<li><a href="http://www.domain.com:8080">http://www.domain.com:8080</a> -&gt; 다른 출처</li>
<li><a href="https://www.domainA.com:3000">https://www.domainA.com:3000</a> -&gt; 당연히 다른 출처</li>
</ul>
<blockquote>
<p>CORS 정책 검사</p>
</blockquote>
<p>위를 판단하는 주체는 브라우저에 구현되어 있는 스펙이다. 즉, 우리가 CORS 정책을 위반하는 리소스를 구성해서 요청하더라도 우<strong>리의 서버는 정상적으로 응답을 하지만, 브라우저는 이 응답을 분석해서 CORS 정책 위반이라고 판단해 응답을 버리는 순서로 동작</strong>한다.</p>
<blockquote>
<p>CORS 트러블 슈팅 관련 글이 많은 이유</p>
</blockquote>
<p>해당 이유는 웹의 발달과 관련이 깊다. 예전에는 <code>FE, BE를 따로 구성하지 않고 한 번에 구성</code>해서 모든 처리가 같은 도메인 안에서 가능했다. 그러므로, 다른 출처로 요청을 보내는게 의심스러운 행위로 보일 수 밖에 없었다고 한다. <code>시간이 지나 이제는 클라이언트에서 API를 직접 호출하는 방식이 자연스러워지고</code>, 클라이언트와 API가 다른 도메인에 있는 경우가 많아서 CORS 정책이 생겼다. 
출처가 다르더라도, 요청과 응답을 주고받을 수  있도록 <code>서버에 리소스 호출이 허용된 출처(Origin)을 명시해 주는 방식</code>으로 말이다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/82f67e6e-cd0d-45fd-938b-0805e77a07e2/image.png" alt=""></p>
<p>스프링부트에서 전역으로 리소스 호출이 허용되는 출처를 명시해줄 수 있다.</p>
<blockquote>
<p>프로젝트에서의 이슈 발생</p>
</blockquote>
<p>현재 FE, BE는 각각 Oracle Cloud, AWS에 배포되어있는 상태다. <code>쿠키의 sameSite 정책</code> 즉, 같은 도메인에서 쿠키가 교환되어야하기 때문에, 이를 위해 FE, BE 서버 각각에 SSL 인증을 적용했다. - <a href="https://velog.io/@sung_c/DevOps-EC2-DNS-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%93%B1%EB%A1%9D-%EB%B0%8F-SSLACM-%EC%A0%81%EC%9A%A9">https://velog.io/@sung_c/DevOps-EC2-DNS-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%93%B1%EB%A1%9D-%EB%B0%8F-SSLACM-%EC%A0%81%EC%9A%A9</a></p>
<p>그런데, SSL인증과 CORS 설정하고, FE에서도 credential 정책을 설정해주었는데, 클라이언트가 서버로부터 쿠키 응답을 받지 못했다.</p>
<ul>
<li>React
<img src="https://velog.velcdn.com/images/sung_c/post/e76bd61c-1241-4383-a7ac-7249a97aba69/image.png" alt=""></li>
</ul>
<p>바로바로, 아까 이야기한 쿠키의 samesite 정책에서 옵션을 none으로 줘서 해결한다. 기존의 스프링에서 제공하는 cookie 객체는 samesite 옵션을 줄 수가 없어서, <code>responseCookie</code> 객체로 코드를 리팩토링했다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/51ac637f-ab4d-4860-a4e3-5be728440fff/image.png" alt=""></p>
<p>여기서 끝이 아니다. 해당 프로젝트에서는 Spring security로 인증, 인가를 처리하고 있다. 위의 cors 전역 설정은 <code>HttpRequest가 Controller에 들어오고나서</code>의 설정이여서 계속 500 에러를 리턴받았다.
따라서, <strong>filter단에서 cors 설정을 해줘야 한다.</strong></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/f621a95b-bc2d-4d88-abe6-d6cd8389bb13/image.png" alt=""></p>
<h2 id="결론">결론</h2>
<p><code>cors이 생긴 이유, 해결 방법</code> 그리고 <code>쿠키 정책</code>에 대해 알 수 있는 트러블 슈팅 경험이었다. 단순히 문제를 해결하는 것을 넘어, <strong>왜? 라는 관점에서 접근</strong>하니 아~ 이래서 이랬구나가 이해되는 부분이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 예외 처리 전략]]></title>
            <link>https://velog.io/@sung_c/Spring-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@sung_c/Spring-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Tue, 06 Aug 2024 13:13:14 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트를 수행하면서, FE 개발자분이 BE에 API 요청을 날릴 때, <code>에러가 발생하는 경우 각 도메인과 상황에 맞는 예외 처리를 적용</code>하기 위해 공통 예외 처리를 어떻게 하는지 찾아보고 적용하게 되었다.</p>
<blockquote>
<p><strong>Spring의 예외 처리 방법</strong></p>
</blockquote>
<p>Spring은 만들어질 때부터 에러처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 <code>기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정</code>이 되어있다.</p>
<h3 id="request-동작-과정">Request 동작 과정</h3>
<p><img src="https://velog.velcdn.com/images/sung_c/post/648973d6-b0d8-42a7-8047-6fb3bddded43/image.png" alt=""></p>
<h3 id="예외-발생-시-요청-전달-흐름">예외 발생 시 요청 전달 흐름</h3>
<p><img src="https://velog.velcdn.com/images/sung_c/post/54ecbced-12d6-4929-a748-af62cb4c44b2/image.png" alt=""></p>
<p>각각 컨트롤러, 필터, 인터셉터가 <code>두 번씩 호출되는 비효율적인 과정</code>과 더불어서, <code>기본 에러는 클라이언트에게 status 500 "Internel Server Error"로만 응답</code>하기 때문에, 클라이언트는 무슨 에러인지 구체적으로 파악할 수 없다.</p>
<p>이처럼 <strong>바람직하지 못한 에러 처리를 위해 별도의 예외처리 전략을 사용해야한다.</strong></p>
<blockquote>
<p>스프링이 제공하는 예외처리 방법</p>
</blockquote>
<h3 id="handlerexceptionresolver">HandlerExceptionResolver</h3>
<ul>
<li><p>에러 처리를 메인 로직으로부터 분리</p>
</li>
<li><p>대부분의 HandlerExceptionResolver는 <code>발생한 exception을 catch하고 HTTP 상태나 응답 메세지 등을 설정한다.</code></p>
</li>
<li><p>에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리한다.</p>
</li>
</ul>
<blockquote>
<p>Spring에서 ExceptionResolver를 동작시켜 에러를 처리할 때 사용하는 어노테이션</p>
</blockquote>
<h4 id="exceptionhandler">@ExceptionHandler</h4>
<ul>
<li>매우 유연하게 에러처리를 할 수 있는 방법 제공</li>
<li>에러 응답을 자유롭게 다룰 수 있다.</li>
<li>컨트롤러의 메소드에 해당 어노테이션 적용 가능하지만, <code>전역으로는 사용이 불가하다.</code></li>
<li><blockquote>
<p>전역으로 사용하기 위해서는 @ControllerAdvice(@RestControllerAdvice)을 사용해야한다.</p>
</blockquote>
</li>
</ul>
<h4 id="restcontrolleradvice">@RestControllerAdvice</h4>
<ul>
<li>@ExceptionHandler를 전역으로 사용할 수 있게 해준다.</li>
<li>@ControllerAdvice와의 차이점은 <code>에러 응답을 JSON으로 내려준다는 점이다.</code></li>
<li>사용방법 : 어노테이션을 선언해 전역으로 에러를 핸들링하는 Class를 만들어 사용한다.<br>

</li>
</ul>
<h2 id="예외-처리-적용">예외 처리 적용</h2>
<blockquote>
<p>CustomException 추상 클래스 생성</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/d5b189be-c38c-41b9-ae70-e1b85593d4dc/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/244ab92a-879c-4006-801b-be470f00fdb4/image.png" alt=""></p>
<p>각 도메인별로 Exception class를 만들어 사용자가 예외를 정의하게 될텐데, <code>에러 응답을 통일하기 위해</code> 추상 클래스를 만들고 하위에서 상속받아서 구현하도록 설계하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/fd4397b2-3136-40ad-8946-1d9b7ff9e7e0/image.png" alt="">
에러 코드 객체를 생성할 때, 에러 메세지와 HTTPStatus를 반환하는 인터페이스를 설계했습니다.
각 도메인별로 인터페이스를 상속받아, <code>사용자가 에러코드를 커스텀하되, 필수 정보에 접근할 수 있게 구성했습니다.</code></p>
<p>예시)
<img src="https://velog.velcdn.com/images/sung_c/post/c7f42420-5823-40aa-8695-77f9981c87c1/image.png" alt=""></p>
<p>마지막으로 <code>에러 상황에서 공통 응답을 반환하는 class</code>를 생성하겠습니다.</p>
<blockquote>
<p>공통 GlobalResponse</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/6492d44f-fc44-42db-ba59-9400ca4bb04b/image.png" alt=""></p>
<p>일부만 발췌하여 캡쳐했습니다..</p>
<p>아까 설명한 <code>@RestControllerAdvice에서 @ExceptionHandler를 통해 전역에 대한 예외 처리</code>를 수행할 수 있도록 handler를 생성하겠습니다.</p>
<blockquote>
<p><strong>GlobalHandler</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/95db4f39-4f72-452d-ae30-d2817fc7c78d/image.png" alt=""></p>
<p>마찬가지로 일부만 발췌하였습니다. </p>
<p>@ExceptionHandler가 처리하는 대상을 보면 <strong>아까 최상위 계층으로 선언한 추상 클래스인 AbstractCustomException 클래스</strong>입니다.
즉, AbstractCustomException를 상속받은 하위 계층 전부에서 Exception이 발생했을 때, <code>이 메서드가 에러 처리를 위임받아 공통 응답 객체를 만들어 response를 보냅니다.</code></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/26c1b04a-cce3-4a59-b1ac-b94060aa8c48/image.png" alt=""></p>
<p>이를 통해 이전에는 500 에러와, server error만 내뱉었던 에러 정보를 다음처럼 커스터마이징해 <code>클라이언트에게 구체적인 에러 정보를 안내할 수 있게 되었습니다.</code></p>
<h2 id="결론">결론</h2>
<p>Exception, ExceptionCode의 최상위 class를 설계하고 각 도메인에서 이를 상속받아 구현함으로써, <strong>코드 전체의 일관성을 향상</strong>할 수 있었습니다. 또한, 서비스 계층에서 에러 메세지를 throw할 때마다, 에러 메세지를 만들어서 서비스의 <strong>변경에서 취약한 점을 극복</strong>할 수 있었습니다. </p>
<p>마지막으로, fe 개발자가 swagger를 통해 api를 요청할 때 어떤 문제점이 있는지 WAS 로그를 보지 않고도 전달할 수 있어 <strong>협업의 효율성을 극대화</strong>할 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DevOps] EC2 - DNS 도메인 등록 및 SSL(ACM) 적용]]></title>
            <link>https://velog.io/@sung_c/DevOps-EC2-DNS-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%93%B1%EB%A1%9D-%EB%B0%8F-SSLACM-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@sung_c/DevOps-EC2-DNS-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%93%B1%EB%A1%9D-%EB%B0%8F-SSLACM-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Mon, 05 Aug 2024 14:19:18 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>사이드 프로젝트를 수행 중, 클라이언트단에서 서버로 api를 요청할 때 쿠키에 JWT 토큰을 함께 담아보내는데, 이를 주고받기 위해 SSL인증을 적용하게 되었다.
다만, <code>SSL인증시 DNS 등록이 선행되어야해서</code> 함께 등록하게 되었다.
😭 DNS 등록 2000원.. 아깝지 않다!</p>
<h2 id="dns-등록---가비아">DNS 등록 - 가비아</h2>
<p>해당 부분은 구매를 완료한 시점이라서 생략하겠습니다.</p>
<blockquote>
<p>DNS와 AWS Route53 레코드를 연결해야 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/4323f497-b2f7-4442-bd5c-7cf4c200e7fe/image.png" alt=""></p>
<p>NS레코드의 4개의 도메인 주소를 도메인 구매처(필자는 가비아)에 등록해야합니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/f2552161-335d-4192-94af-560c7bf83e9e/image.png" alt=""></p>
<p>여기까지하면 DNS 등록 절차는 끝났습니다.</p>
<p>이제 <strong>AWS Certificate Manager를 통해 SSL 인증서를 발급</strong>받겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/c3789dd2-0c36-4333-a7f3-3606f5ff0403/image.png" alt=""></p>
<p><CODE>완전히 정규화된 도메인 이름</code>에 구매한 DNS 도메인을 입력하고 요청하면 시간이 지나, SSL 인증서가 발급됩니다.</p>
<p>AWS에서 SSL 인증을 적용하는 방법이 2가지라고 들었는데, 그중 저는 <code>ALB에 SSL인증서를 적용해 80, 443 포트를 통해 접근 요청 url을 포트포워딩하는 방식</code>을 적용했습니다.</p>
<blockquote>
<p>ALB가 포트포워딩할 대상그룹 생성 (EC2 인스턴스)</p>
</blockquote>
<p>EC2 -&gt; 왼쪽 하단의 대상 그룹을 선택해 생성합니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/dbf4ceb3-8c11-4ee9-8b31-1fef848a8dd5/image.png" alt=""></p>
<p>저는 톰캣 Default 포트인 8080을 적용해서 대상그룹을 매핑했습니다.</p>
<p><strong>이때, 상태 검사 경로를 설정해줘야합니다.</strong>
<code>ALB가 Health Check</code>를 하는데 경로에 request를 보내 200을 response받아야 health check가 통과되기 때문에</p>
<pre><code class="language-java">@Controller
public class HealthController(){        //예시 코드

@GetMapping(&quot;health-check-url)
public String check(){
    return &quot;&quot;;
    }
}
</code></pre>
<p>많은 분들이 이렇게 임시로 컨트롤러 계층을 만들어 해당 경로를 설정하는 것 같습니다.</p>
<h2 id="alb-생성">ALB 생성</h2>
<p>이제 핵심인 Application Load Balancer를 생성해 대상그룹에 매핑하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/04817d84-39e5-4e0b-965b-b7304cbccd34/image.png" alt=""></p>
<p>네트워크 매핑에서 서브넷을 최소 2개 이상 설정해야합니다.</p>
<p>보안그룹은 default 보안그룹을 사용해도 되는데, <code>80, 443 인바운드 정책을 열어줘야합니다.</code></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/dbd0fe42-9e8a-4fa6-92fa-b0f5ae144b23/image.png" alt=""></p>
<p>대상그룹을 선택하고, (저는 생성 이후 캡쳐해서.. 구두로 설명하겠습니다)</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/b49a1b74-5488-4d6d-98e8-9835620e491b/image.png" alt=""></p>
<p>인증서 선택을 보면, 아까 전에 Certificate Manger에서 발급받은 SSL인증서를 선택할 수 있습니다. </p>
<p>거의 다왔습니다..!</p>
<p><strong>HTTP 리스너 규칙</strong>에서 대상그룹으로 포트포워딩하는 규칙을 추가해줍니다.
<img src="https://velog.velcdn.com/images/sung_c/post/4e4546d0-a719-4866-a0d6-467583b297a4/image.png" alt=""></p>
<p>위 리스너 규칙 설정을 통해 8080으로 들어오는 요청을 443 즉, http -&gt; https로 리다이렉션 시켜줄 수 있습니다.</p>
<br>


<p>다음으로 <strong>HTTPS 리스너 규칙</strong>에서 ALB가 EC2 인스턴스로 요청을 보내주는 규칙을 추가합니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/b866b410-a1ef-429d-b3c7-9fbe21f4575a/image.png" alt=""></p>
<p>마지막으로, 등록한 ALB를 Route53 서비스로 가서 레코드 등록해줍니다.
  <img src="https://velog.velcdn.com/images/sung_c/post/0d918017-6dbc-4e5f-8d70-40b97a032e27/image.png" alt=""></p>
<p>  A레코드에서 별칭탭에 설정한 ALB를 추가하면 끝입니다!</p>
<p> 이렇게 해서 DNS, SSL적용의 대여정이 끝났습니다 😀</p>
<p>   <strong>여담이지만, 3일간의 Route 53 사용료가 대략 3000원정도 나왔습니다..</strong></p>
  <br>

<p>  하단은 현재 수행 중인 사이드 프로젝트의 fe, be서버의 배포가 각기 다른 환경에서 이루어져 도메인이 다른 상황에서 쿠키를 주고받기 위해 cors설정을 추가한 것입니다.</p>
<blockquote>
<p>쿠키 정책</p>
</blockquote>
<p>FE와 BE가 각기 다른환경에서 배포가 수행되었기 때문에, 인증을 적용하기 위해서 쿠키에 JWT토큰을 발급하고, 이를 통해 인가를 수행하도록 프로젝트를 구상했다.</p>
<ul>
<li><strong>쿠키는 도메인에 종속적이다.</strong>
ex) &quot;example.com&quot;에서 생성된 쿠키는 &quot;example.com&quot;도메인과 &quot;<a href="http://www.example.com&quot;%EC%84%9C%EB%B8%8C%EB%8F%84%EB%A9%94%EC%9D%B8%EC%97%90%EC%84%9C">www.example.com&quot;서브도메인에서</a> 사용 가능하지만, anotherdomain.com도메인에서는 사용이 불가능하다.</li>
</ul>
<p>즉, <code>cross domain인 상황에서 CORS정책으로 쿠키같은 민감한 정보의 교환이 까다로워</code> 다른 도메인에 쿠키를 설정해주는 부분을 추가했다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/d494f2fe-dca1-455e-9fcb-6f87db707218/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DevOps] Github Action, Springboot, AWS EC2, Docker을 통한 CI/CD 구축]]></title>
            <link>https://velog.io/@sung_c/DevOps-Github-Action-Springboot-AWS-EC2-Docker%EC%9D%84-%ED%86%B5%ED%95%9C-CICD-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@sung_c/DevOps-Github-Action-Springboot-AWS-EC2-Docker%EC%9D%84-%ED%86%B5%ED%95%9C-CICD-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Tue, 30 Jul 2024 14:15:58 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>5월부터 수행했던 사이드 프로젝트의 백엔드 서버 초기 배포를 수행했다. 기존에는 <code>로컬에서 수동으로 Docker Image를 빌드하고 hub에 push한 뒤, ec2에 접속해서 pull하는 방식</code>으로 배포를 진행했었다. 자동배포를 찾아보다가 Github-Action을 활용한 레퍼런스가 많아 적용해보게 되었다.</p>
<h2 id="본론">본론</h2>
<blockquote>
<p><strong>개발환경</strong></p>
</blockquote>
<ul>
<li>Java 17</li>
<li>SpringBoot</li>
<li>AWS EC2 Amazon Ubuntu</li>
<li>Gradle</li>
<li>Github-Action</li>
</ul>
<blockquote>
<p>순서</p>
</blockquote>
<ol>
<li>AWS EC2 생성</li>
<li>AWS RDS 생성</li>
<li>ec2 docker 설치</li>
<li>Dockerfile 생성(Springboot Root 디렉토리에)</li>
<li>Github-action 설정</li>
</ol>
<p>1, 2, 3은 레퍼런스가 워낙 많고 해당 포스트의 핵심이 아니기 때문에 생략하겠습니다..</p>
<blockquote>
<p>CI/CD 아키텍처</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/5c23c22b-6900-47a9-9ec2-77787e8d7e30/image.png" alt=""></p>
<h3 id="dockerfile-생성하기">Dockerfile 생성하기</h3>
<pre><code class="language-shell">FROM openjdk:17-jdk
ARG JAR_FILE=build/libs/*.jar
ADD ${JAR_FILE} demo-0.0.1-SNAPSHOT.jar
ENTRYPOINT [&quot;java&quot;,&quot;-jar&quot;,&quot;/demo-0.0.1-SNAPSHOT.jar&quot;]</code></pre>
<h3 id="github-action-설정하기">Github action 설정하기</h3>
<p><img src="https://velog.velcdn.com/images/sung_c/post/c07ce632-5c70-46f6-9284-4b4e6301a134/image.png" alt=""></p>
<p><code>Java with Gradle</code> configure로 들어가면, Github-action에서 <code>gradle.yml</code> 파일을 default로 만들어줍니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/a527d115-9de4-4010-8a33-21e5bf4e722e/image.png" alt=""></p>
<p>해당 yml 파일에서 jar로 build하는 부분은 수정할 부분이 없는데, <code>.gradlew를 수행하는 사용자의 권한이 문제가 되어</code> permission denied 문제를 마주했습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/aa0b092e-0d4c-4854-9c17-c71fb2ef18b1/image.png" alt=""></p>
<p>해당 부분을 추가해 권한 부여하여 해결했습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/8d181b1f-6e3a-4fab-9161-90d03665c06a/image.png" alt=""></p>
<p>다음으로 Github action의 Secret 값을 바인딩하는 설정을 진행하겠습니다.</p>
<p>HOST : EC2의 탄력적 IP주소
APPLICATION : 저는 기존에 레포지토리에서 .gitignore에 application.properties파일을 설정해주었기에, 따로 secret에서 추가해주었습니다.
EC2_SSH_PRIVATE_KEY : EC2 인스턴스를 개설하면서 받은 .pem 키
DOCKER_NAME : 도커 사용자명
DOCKER_PASSWORD : 도커 비밀번호
DOCKER_HUB_TOKEN : 도커 허브 ACCESS Token</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/f305924a-8a81-469a-a9b4-872694f1d8c0/image.png" alt=""></p>
<blockquote>
<p><strong>gradle.yml</strong></p>
</blockquote>
<p>도움이 필요하신분들이 있을 것 같아, 설정 파일을 전부 올렸습니다.</p>
<pre><code class="language-shell">
name: Java CI with Gradle

on:
  push:
    branches: [ &quot;dev&quot; ]
  pull_request:
    branches: [ &quot;dev&quot; ]

jobs:
  build:

    runs-on: ubuntu-latest
    permissions:
      contents: read

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

    - name: Make application.properties
      run: |
        cd ./src/main/resources
        touch ./application.properties
        echo &quot;${{ secrets.APPLICATION }}&quot; &gt; ./application.properties

    # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies.
    # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
    - name: Setup Gradle
      uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0

    - name: Make gradlew executable
      run: chmod +x ./gradlew

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

    - name: Docker Login
      uses: docker/login-action@v2.2.0
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

    - name: Build and push Docker images
      uses: docker/build-push-action@v4.1.1
      with:
        context: .
        push: true
        tags: ${{ secrets.DOCKER_USERNAME }}/spring-boot-server

    - name: Deploy to AWS EC2
      uses: appleboy/ssh-action@v0.1.10
      with:
        host: ${{ secrets.HOST }}
        username: ubuntu
        key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} # pem key
        script: |
          docker pull ${{ secrets.DOCKER_USERNAME }}/spring-boot-server
          docker stop $(docker ps -a -q)
          docker run -d --log-driver=syslog -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/spring-boot-server
          docker rm $(docker ps --filter &#39;status=exited&#39; -a -q)
          docker image prune -a -f


  dependency-submission:

    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
    - uses: actions/checkout@v4
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: &#39;17&#39;
        distribution: &#39;temurin&#39;
</code></pre>
<p>저는 tomcat port로 8080을 사용하고 있어서 docker 컨테이너의 port 역시 8080으로 설정했습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/b24f3a98-f930-431e-a16b-ab7ec1608fd0/image.png" alt=""></p>
<p>성공적으로 자동배포를 구축했습니다!</p>
<blockquote>
<p>참고</p>
</blockquote>
<p><a href="https://sum-mit45.tistory.com/58">https://sum-mit45.tistory.com/58</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring Batch와 스케줄러 적용]]></title>
            <link>https://velog.io/@sung_c/Spring-Spring-Batch%EC%99%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@sung_c/Spring-Spring-Batch%EC%99%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Fri, 19 Jul 2024 07:24:07 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p>💡 일일 미션을 관리하는 사이드 프로젝트를 수행하면서, 현재 수행중이지 않은 미션을 비활성화하는 로직을 개발했다. 비활성화는 03시에 스케줄러를 통해 처리하기 위해 batch를 공부하다가, Spring batch와 quertz 라이브러리를 통한 스케줄러를 알게되어 적용하게 되었다.</p>
<h1 id="본론">본론</h1>
<blockquote>
<h4 id="spring-batch는-무엇이고-왜-사용하고-어떻게-동작할까">Spring batch는 무엇이고, 왜 사용하고, 어떻게 동작할까</h4>
</blockquote>
<ul>
<li><strong>Spring batch</strong>란 
-대량의 데이터를 처리하는 작업을 의미하며 이를 자동화하여 시스템의 부하를 줄이고 효율적인 데이터 처리를 가능하게 하는 프레임워크이다.<br></li>
<li><strong>스케줄러</strong>란
  -일정한 시간 간격으로 반복적으로 수행되거나 특정 시간에 수행되도록 예약해 놓은 작업을 자동으로 실행해 주는 시스템이다.</li>
</ul>
<blockquote>
<p>💡 배치 사용 목적</p>
</blockquote>
<ul>
<li>대량 데이터 처리</li>
<li>자동화된 작업 처리</li>
<li>분산 처리</li>
<li>재시도 및 로깅</li>
<li>데이터 분석</li>
</ul>
<blockquote>
<p>⚡ 스프링 배치 아키텍처</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/e4bfb93c-2d99-4b7c-a60c-1f04952c8415/image.png" alt=""></p>
<blockquote>
<p>Spring Bacth 용어</p>
</blockquote>
<ul>
<li><h4 id="jobrepository">JobRepository</h4>
</li>
<li><p><code>다양한 배치수행</code>과 <code>관련된 수치 데이터와 Job의 상태를 유지 및 관리</code></p>
</li>
<li><p>일반적으로 RDB 사용, <code>스프링 배치 내의 주요 컴포넌트 공유</code></p>
</li>
<li><p>실행된 Step, 현재 상태, Read&amp;Write 아이템 모두 이곳에 저장된다.</p>
</li>
<li><h4 id="job">Job</h4>
</li>
<li><p><code>Job은 배치 처리 과정을 하나의 단위로 만들어 표현한 객체, 여러 Step 인스턴스를 포함하는 컨테이너</code></p>
</li>
<li><p>Job이 실행될 때 스프링 배치의 많은 컴포넌트는 탄력성을 제공하기 위해 서로 상호작용</p>
</li>
<li><h4 id="joblauncher">JobLauncher</h4>
</li>
<li><p>Job을 실행하는 역할</p>
</li>
<li><p>Job의 재실행 가능 여부 검증, 실행 방법, 파라미터 유효성, 검증 수행</p>
</li>
<li><p><code>Job을 실행하면 해당 Job은 각 Step 실행</code></p>
</li>
<li><h4 id="step">Step</h4>
</li>
<li><p>Step은 Job의 하위 단계로서 <code>실제 배치 작업이 이루어지는 단위</code></p>
</li>
<li><p>한 개 이상의 Step으로 Job이 구성되며, 각 Step은 순차적으로 처리</p>
</li>
<li><p>각 Step 내부에서는 <code>ItemReader, ItemProcessor, ItemWriter를 사용하는 chunk 방식 또는 Tasklet 하나를 가진다.</code></p>
</li>
<li><h4 id="tasklet">Tasklet</h4>
</li>
<li><p>Step이 중지될 때까지 execute 메서드가 계속 반복해서 수행하고 수행할 때마다 독립적인 트랜잭션이 얻어진다.
<code>초기화</code>, <code>저장 프로시저 실행</code>, <code>알림 전송</code>와 같은 Job에서 일반적으로 사용된다.</p>
</li>
<li><h4 id="chunk">Chunk</h4>
</li>
<li><p>한번에 하나의 데이터(row)를 읽어 Chunk라는 덩어리를 만든 뒤, <code>Chunk 단위로 트랜잭션을 다루는 것</code></p>
</li>
<li><p><code>Chunk 단위로 트랜잭션을 수행</code>하기 때문에 실패할 경우엔 해당 Chunk 만큼만 롤백이 되고, 이전에 커밋된 트랜잭션 범위까지는 반영이 된다.
Chunk 기반 Step은 <code>ItemReader, ItemWriter, ItemProcessor라는 3개의 주요 부분으로 구성될 수 있다.</code></p>
</li>
</ul>
<hr>
<h2 id="스프링-배치-적용">스프링 배치 적용</h2>
<blockquote>
<p>Spring Batch Dependency 추가</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/96d35439-3c7c-4546-90aa-fc175fecef49/image.png" alt=""></p>
<p>Job을 구성하는 Step을 먼저 작성하겠습니다. 먼저 readStep()을 통해 비활성화할 미션 목록을 list형태로 가져오는 로직입니다.</p>
<blockquote>
<p>ReadJobStep</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/d579c7d2-d18c-458c-b3d9-0a96e952dd3c/image.png" alt=""></p>
<p>단일테스크인 <code>tasklet으로 전체 미션을 조회</code>하도록 구현했습니다.</p>
<p>이후 writeJob으로 서비스계층에서 구현한 end로직으로 사용자가 참여하지 않거나, 종료일자가 지난 미션을 비활성화하도록 step을 구성하겠습니다..</p>
<blockquote>
<p>WriteJobStep()</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/429789c7-ec7b-4fd1-a40c-8078e3771098/image.png" alt=""></p>
<p>마찬가지로 tasklet방식으로 파라미터로 전달받은 미션 목록들을 비활성화하도록 구현했습니다.</p>
<blockquote>
<p>endJob()</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/39afdfec-016f-46f4-8edd-c4169113a4d7/image.png" alt=""></p>
<p>위에서 작성한 <code>Read & Write step을 Job에 등록</code></p>
<p>이제 등록한 Job을 실행하는 스케줄러를 구현하겠습니다.</p>
<blockquote>
<p>Scheduler 등록</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/c9395982-a942-4e53-b459-f8f3527c4009/image.png" alt=""></p>
<p>JobRegistry와 JobLauncher 객체 의존성을 주입받고,
@Scheduled 어노테이션을 통해 배치가 수행되는 시간을 설정합니다.
<code>cron = "* * * * <span>3 * * * *"</code>코드로 <code>매일 새벽 03시에 배치가 수행되도록 설정했습니다.</code></p>
<p>jobParam의 현재시간 필드를 매겨변수로 jobLauncher를 통해 수행합니다.</p>
<hr>
<h3 id="배치-수행-결과">배치 수행 결과</h3>
<p>  로컬에서 테스트하고있기 때문에 10초마다 수행하도록 cron을 설정해 확인해보겠습니다.</p>
<blockquote>
<p>step1. read</p>
</blockquote>
<p>  <img src="https://velog.velcdn.com/images/sung_c/post/7b70a204-424e-4056-8a97-3142796f9aef/image.png" alt="">
  <img src="https://velog.velcdn.com/images/sung_c/post/4c40b3a9-af95-41a0-96ba-e2ec076c43df/image.png" alt=""></p>
<blockquote>
<p>step2. write(update)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/sung_c/post/e160295f-ffd0-4b3f-9160-a3e5bdf1cfef/image.png" alt=""></p>
<p>  <code>업데이트 쿼리가 정상적으로 나간 것</code>을 로그를 통해 확인할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring-Security, Oauth2 & JWT를 통한 인증·인가 -2]]></title>
            <link>https://velog.io/@sung_c/Spring-Spring-Security-Oauth2-JWT%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-2</link>
            <guid>https://velog.io/@sung_c/Spring-Spring-Security-Oauth2-JWT%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-2</guid>
            <pubDate>Fri, 05 Jul 2024 07:23:13 GMT</pubDate>
            <description><![CDATA[<h2 id="서론">서론</h2>
<p>이전 포스트에 이어서 필터, 핸들러, 엔티티를 구현해보겠습니다.</p>
<p>해당 게시글은 다음 강의를 참고하여 작성했습니다.</p>
<p><a href="https://www.youtube.com/watch?v=xsmKOo-sJ3c&amp;list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB&amp;ab_channel=%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9C%A0%EB%AF%B8">https://www.youtube.com/watch?v=xsmKOo-sJ3c&amp;list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB&amp;ab_channel=%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9C%A0%EB%AF%B8</a></p>
<h2 id="구현">구현</h2>
<h4 id="oauth2-상위-계층-interface를-만들어-하위-계층인-naver-google에서-상속받아-구현">OAuth2 상위 계층 Interface를 만들어 하위 계층인 Naver, Google에서 상속받아 구현</h4>
<blockquote>
<p>OAuth2Response</p>
</blockquote>
<pre><code class="language-java">public interface OAuth2Response {

    String getProvider();

    String getProviderId();

    String getEmail();

    String getName();

    String getProfileImage();
}</code></pre>
<blockquote>
<p>GoogleResponse, NaverResponse</p>
</blockquote>
<pre><code class="language-java">package dailymissionproject.demo.domain.auth.dto;

import java.util.Map;

public class GoogleResponse implements OAuth2Response {

    private final Map&lt;String, Object&gt; attribute;
    public GoogleResponse(Map&lt;String, Object&gt; attribute) {
        this.attribute = attribute;
    }

    @Override
    public String getProvider() {
        return &quot;google&quot;;
    }

    @Override
    public String getProviderId() {
        return attribute.get(&quot;sub&quot;).toString();
    }

    @Override
    public String getEmail() {
        return attribute.get(&quot;email&quot;).toString();
    }

    @Override
    public String getName() {
        return attribute.get(&quot;name&quot;).toString();
    }

    @Override
    public String getProfileImage() {
        return attribute.get(&quot;picture&quot;).toString();
    }
}</code></pre>
<hr>
<pre><code class="language-java">public class NaverResponse implements OAuth2Response{

    private final Map&lt;String, Object&gt; attribute;

    public NaverResponse(Map&lt;String, Object&gt; attribute) {
        this.attribute = (Map&lt;String, Object&gt;) attribute.get(&quot;response&quot;);
    }

    @Override
    public String getProvider() {
        return &quot;naver&quot;;
    }

    @Override
    public String getProviderId() {
        return attribute.get(&quot;id&quot;).toString();
    }

    @Override
    public String getEmail() {
        return attribute.get(&quot;email&quot;).toString();
    }

    @Override
    public String getName() {
        return attribute.get(&quot;name&quot;).toString();
    }

    @Override
    public String getProfileImage() {
        return attribute.get(&quot;profile_image&quot;).toString();
    }

}</code></pre>
<blockquote>
<p>User 엔티티</p>
</blockquote>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Builder
    public User(String name, String email, String picture, Role role){
        this.name = name;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    public User update(String name, String picture){
        this.name = name;
        this.picture = picture;
        return this;
    }
    public String getRoleKey(){
        return this.role.getKey();
    }
}</code></pre>
<blockquote>
<p>인가를 위한 Role 객체</p>
</blockquote>
<pre><code class="language-java">@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST(&quot;ROLE_GUEST&quot; , &quot;손님&quot;),
    USER(&quot;ROLE_USER&quot; , &quot;일반 사용자&quot;);

    private final String key;
    private final String title;
}</code></pre>
<p>Spring Security에서는 SecurityContextHolder라는 저장공간에 로그인한 사용자 정보를 Authentication 객체의 principal안에 담아둔다.</p>
<blockquote>
<p>Autentication객체에 담기 위한 OAuth2User class</p>
</blockquote>
<pre><code class="language-java">@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {

    private final UserDto userDto;

    @Override
    public Map&lt;String, Object&gt; getAttributes() {
        return null;
    }

    @Override
    public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
        Collection&lt;GrantedAuthority&gt; collection = new ArrayList&lt;&gt;();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userDto.getRole().toString();
            }
        });
        return collection;
    }

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

    public String getUsername(){
        return userDto.getUsername();
    }
}</code></pre>
<p>이제 jwt 토큰 발행과, jwt 토큰 검증하는 필터를 구현하겠습니다.</p>
<blockquote>
<p>JwtUtil</p>
</blockquote>
<pre><code class="language-java">@Component
public class JWTUtil {

    private SecretKey secretKey;

    public JWTUtil(@Value(&quot;${spring.jwt.secret}&quot;)String secret){
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String createJwt(String username, Role role, Long expireMs){

        return Jwts.builder()
                .claim(&quot;username&quot;, username)
                .claim(&quot;role&quot; , role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expireMs))
                .signWith(secretKey)
                .compact();
    }
    public String getUsername(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(&quot;username&quot;, String.class);
    }

    public String getRole(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(&quot;role&quot;, String.class);
    }

    public Boolean isExpired(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }
}</code></pre>
<p>createJwt 메서드에서 username(naver + id)와 role)을 인자로 application.properties에 등록한 secretKey를 통해 jwt 토큰을 발행하도록 구현했습니다.
추가로, isExpired를 통해 jwtFilter에서 만료시간을 검증하도록 구현했습니다. 💡</p>
<blockquote>
<p>로그인 성공 시, successHandler</p>
</blockquote>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JWTUtil jwtUtil;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
        throws IOException, ServletException {

        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();
        String username = customUserDetails.getUsername();

        Collection&lt;? extends GrantedAuthority&gt; authorities = authentication.getAuthorities();
        Iterator&lt;? extends GrantedAuthority&gt; iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        Role role = Role.valueOf(auth.getAuthority());

        String token = jwtUtil.createJwt(username, role,3600*60*60L);

        response.addCookie(createCookie(&quot;Authorization&quot;, token));
        response.sendRedirect(&quot;http://localhost:3000/&quot;);
    }

    private Cookie createCookie(String key, String value){

        Cookie cookie =  new Cookie(key, value);
        cookie.setMaxAge(60*60*60);
        cookie.setPath(&quot;/&quot;);
        cookie.setHttpOnly(true);
        return cookie;
    }
}</code></pre>
<p>jwt를 발행하고 cookie에 담아 응답하도록 구현했습니다.</p>
<p>마지막으로, api 요청할 때, 쿠키의 jwt토큰을 검증하는 로직을 구현하겠습니다.</p>
<blockquote>
<p>JWTFilter</p>
</blockquote>
<pre><code class="language-java">@RequiredArgsConstructor
@Slf4j
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

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

        String requestUri = request.getRequestURI();
        if(requestUri.matches(&quot;^\\/login(?:\\/.*)?$&quot;)){

            filterChain.doFilter(request, response);
            return;
        }

        if(requestUri.matches(&quot;^\\/oauth2(?:\\/.*)?$&quot;)){
            filterChain.doFilter(request, response);
            return;
        }

        String authorization = null;
        Cookie[] cookies = request.getCookies();
        log.info(&quot;{}&quot;, cookies);
        for(Cookie cookie : cookies){

            if(cookie.getName().equals(&quot;Authorization&quot;)){

                authorization = cookie.getValue();
            }
        }

        if(authorization == null){
            log.info(&quot;token is null&quot;);
            filterChain.doFilter(request, response);

            return;
        }

        String token = authorization;

        if(jwtUtil.isExpired(token)){
            log.info(&quot;token is expired&quot;);
            filterChain.doFilter(request, response);
            return;
        }

        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        UserDto userDto = new UserDto();
        userDto.setUsername(username);
        userDto.setRole(Role.valueOf(role));

        //인증 객체 담기
        CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDto);

        //시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}</code></pre>
<p>cookie key값 authorization에 해당하는 value를 검증합니다. 만료시간이 지났다면, fiterchian메서드에서 jwtExpired 에러메세지를 응답합니다.
토큰이 유효하다면, securityContextHolder에 유저 정보를 담도록 구현했습니다.</p>
<h3 id="테스트">테스트</h3>
<p>Rest API로만 개발되었기 때문에, Spring Security에서 제공하는 로그인 화면에서 oauth2 로그인을 수행하고 jwt토큰을 응답받아, postman에서 해당 토큰을 쿠키에 담아 api를 요청하는 순서로 진행했습니다.</p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/86c1ffb7-393d-4b6b-9218-17f0877d142d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/a76695cf-3dd4-4575-8b43-e484a0036bdb/image.png" alt="">
<img src="https://velog.velcdn.com/images/sung_c/post/d49ffbc3-52e5-456d-9c23-8abe85518c4b/image.png" alt="">
<img src="https://velog.velcdn.com/images/sung_c/post/647a13d4-a24d-4bf8-84af-27124af7506e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/7d6b1158-da55-4828-94d8-ab6245c64c8c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/20d3ae59-938d-44b0-a675-bb69269e71d5/image.png" alt=""></p>
<p>유저 정보를 조회하는 api를 요청 이후, 조회 쿼리가 정상적으로 나가는 것을 확인했습니다.
<img src="https://velog.velcdn.com/images/sung_c/post/db49d479-8775-49d0-8950-75bc7ec42eda/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung_c/post/badd0aaa-4ffb-4664-8a00-6e76022b8f60/image.png" alt=""></p>
<p>성공!</p>
]]></description>
        </item>
    </channel>
</rss>