<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>c_mungi.log</title>
        <link>https://velog.io/</link>
        <description>백엔드 개발자의 수집상자</description>
        <lastBuildDate>Sun, 12 Apr 2026 13:27:19 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>c_mungi.log</title>
            <url>https://velog.velcdn.com/images/c_mungi/profile/54759352-1235-4d42-b51b-55a8581ebc44/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. c_mungi.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/c_mungi" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Cache] 캐시 성능 비교]]></title>
            <link>https://velog.io/@c_mungi/Cache-%EC%BA%90%EC%8B%9C-%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90</link>
            <guid>https://velog.io/@c_mungi/Cache-%EC%BA%90%EC%8B%9C-%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90</guid>
            <pubDate>Sun, 12 Apr 2026 13:27:19 GMT</pubDate>
            <description><![CDATA[<h1 id="1-캐시-성능-비교">1 캐시 성능 비교</h1>
<p>현재 실무에서 많이 느낀건 서비스의 특수성이라는 영향도 있지만 응답속도가 너무 느렸다.</p>
<p>분명히 Redis의 Cache를 사용하고 있는데 왜 이렇게 느릴까라는 생각을 했고
그 이유를 조금이라도 더 알아 보고 다양한 Cache 중 대표적인 Caffeine과 Redis그리고 캐시를 사용하지 않는 환경까지 3개를 비교해 어느 상황에 사용하면 좋을지 정리하고자 기록을 남기고자 한다.</p>
<hr>
<h1 id="2-cache란">2 Cache란</h1>
<blockquote>
<p>자주 사용하는 데이터나 값을 미리 복사해 놓는 임시 장소이다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/13e0d331-8e56-4dfe-bf41-6b6e092a0968/image.png" alt="">
출처 : <a href="https://www.upguard.com/blog/cache">https://www.upguard.com/blog/cache</a></p>
<p>위의 이미지의 저장공간 계층 구조에서 확인할 수 있듯이, 캐시는 저장 공간이 작고 비용이 비싼 대신 빠른 성능을 제공한다.</p>
<p>그렇다면 다음과 같은 상황에서 캐시를 활용하는 것이 가장 가성비가 좋다는 의미이다.</p>
<blockquote>
<ul>
<li>값이 자주 바뀌지 않고 자주 호출되는 값인지</li>
<li>DB를 통해 조회해서 가져오는 응답 시간이 오래 걸리는지</li>
</ul>
</blockquote>
<hr>
<h3 id="값이-자주-바뀌지-않고-자주-호출되는-값은-왜-캐시를-활용하면-좋을까">값이 자주 바뀌지 않고 자주 호출되는 값은 왜 캐시를 활용하면 좋을까.</h3>
<p>위의 상황은 결국 동일한 결과에 대해 동일한 연산이 자주 발생된다는 의미이다.
어차피 결과가 동일하기 때문에 캐시에 저장해두면, 굳이 연산을 하지 않고 해당 결과를 바로 반환하면 되기 때문이다.</p>
<hr>
<h3 id="db를-통해-조회해서-가져오는-응답-시간이-오래-걸리는-상황에선-왜-캐시를-활용하면-좋을까">DB를 통해 조회해서 가져오는 응답 시간이 오래 걸리는 상황에선 왜 캐시를 활용하면 좋을까.</h3>
<p>응답 속도의 문제이다. 응답 속도는 여러 원인으로 인해 느려질 수 있다.
대표적으로는 외부 시스템 호출 비용이 클 때와 트래픽이 급증하는 경우가 있다.
외부 시스템 호출 비용이 크면 네트워크 왕복, 디스크 I/O, 쿼리 파싱 등 여러 단계를 거치기 때문에 수십~수백 ms씩 걸리는 반면 캐시를 통해 읽는다면 1ms 이하로도 줄일 수 있기 때문이다.
즉, 병목이 발생할 수 있는 구간을 건너뛰기가 가능하기에 캐시를 활용하는 방법을 도입할 수 있다.</p>
<p>트래픽이 급증하는 경우도 동일하다. 트래픽이 급증하는 상황에서 모든 요청이 백엔드에 쏟아지는 경우 과부하나 장애로 이어지기도 한다. 이 때 캐시를 통해 트래픽을 제일 앞단에서 처리하도록 한다면 백엔드에게 쏟아지는 부하를 크게 줄일 수 있다. </p>
<hr>
<h1 id="3-cache의-종류">3 Cache의 종류</h1>
<p>캐시의 종류는 다양한다. Caffeine, Redis뿐만 아니라 CDN 캐시, 브라우저 캐시 등등 다양하지만 이번에는 백엔드에서 자주 활용되는 캐시 중 Caffeine과 Redis에 대해서만 기록하고자 한다.</p>
<hr>
<h2 id="3-1-caffeine-캐시">3-1 Caffeine 캐시</h2>
<p>Caffeine 캐시는 JVM 힙 메모리 안에 데이터를 저장하는 Java 전용 캐시 라이브러리이다.</p>
<h3 id="3-1-1-동작-방식">3-1-1 동작 방식</h3>
<p>애플리케이션 프로세스 내부 메모리에 직접 저장한다. 네트워크 없이 메모리 주소 참조만으로 데이터를 읽어오기 때문에 속도가 극도로 빠르다.</p>
<h3 id="3-1-2-장점">3-1-2 장점</h3>
<ul>
<li>속도가 가장 빠름 : 네트워크 I/O가 전혀 없어 나노초~마이크로초 단위 응답</li>
<li>설정이 간단 : 의존성 추가 후 코드 몇 줄로 바로 사용 가능</li>
<li>자동 메모리 관리 : W-TinyLFU 알고리즘으로 히트율이 높은 데이터를 자동으로 유지</li>
<li>별도 인프라 불필요 : Redis 서버 없이 앱 단독으로 동작</li>
</ul>
<h3 id="3-1-3-단점">3-1-3 단점</h3>
<ul>
<li>서버 간 데이터 공유 불가 : 인스턴스가 2대이면 캐시가 2개로 분리되어 일관성이 깨짐</li>
<li>앱 재시작 시 데이터 소멸 : 휘발성이라 영속성 없음</li>
<li>메모리 제약 : JVM 힙을 공유하므로 저장 용량이 제한적</li>
<li>Java/JVM 전용 : 다른 언어 서버와 공유 불가</li>
</ul>
<hr>
<h2 id="3-2-redis-캐시">3-2 Redis 캐시</h2>
<p>Redis 캐시는 네트워크로 접근하는 형태로 외부 독립적인 인메모리 데이터 저장소이다.</p>
<h3 id="3-2-1-동작-방식">3-2-1 동작 방식</h3>
<p>별도 Redis 서버에 데이터를 저장하고, 애플리케이션은 네트워크(TCP)를 통해 읽고 쓴다. 여러 서버가 같은 Redis를 바라보기 때문에 데이터가 공유된다.</p>
<h3 id="3-2-2-장점">3-2-2 장점</h3>
<ul>
<li>서버 간 캐시 공유 : 여러 인스턴스가 동일한 데이터를 바라봄 (일관성 보장)</li>
<li>다양한 자료구조 지원 : String, Hash, List, Set, Sorted Set, Pub/Sub 등</li>
<li>영속성 옵션 : RDB/AOF로 디스크에 저장 가능, 재시작 후 복구 가능</li>
<li>대용량 저장 가능 : 서버 메모리 한도까지 자유롭게 확장</li>
<li>언어 무관 : Node.js, Python, Go 등 어떤 스택이든 사용 가능</li>
</ul>
<h3 id="3-2-3-단점">3-2-3 단점</h3>
<ul>
<li>네트워크 레이턴시 존재 : 아무리 빨라도 0.5~1ms 이상 소요</li>
<li>인프라 관리 필요 : 별도 서버 운영, 장애 대응, 모니터링 필요</li>
<li>비용 발생 : 클라우드 사용 시 인스턴스 비용 추가</li>
<li>단일 장애점 위험 : Redis 서버 다운 시 전체 캐시 영향 (Cluster/Sentinel로 완화 가능)</li>
</ul>
<h2 id="3-3-장단점-비교">3-3 장단점 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Caffeine</th>
<th>Redis</th>
</tr>
</thead>
<tbody><tr>
<td><strong>저장 위치</strong></td>
<td>JVM 힙 내부 (로컬)</td>
<td>외부 독립 서버</td>
</tr>
<tr>
<td><strong>응답 속도</strong></td>
<td>✅ 나노초~마이크로초 (네트워크 없음)</td>
<td>⚠️ 0.5~수 ms (네트워크 경유)</td>
</tr>
<tr>
<td><strong>서버 간 공유</strong></td>
<td>❌ 인스턴스마다 캐시가 분리됨</td>
<td>✅ 모든 서버가 동일 캐시 공유</td>
</tr>
<tr>
<td><strong>데이터 일관성</strong></td>
<td>❌ 다중 서버 환경에서 불일치 가능</td>
<td>✅ 단일 저장소로 일관성 보장</td>
</tr>
<tr>
<td><strong>영속성</strong></td>
<td>❌ 앱 재시작 시 데이터 소멸</td>
<td>✅ RDB/AOF로 디스크 저장 가능</td>
</tr>
<tr>
<td><strong>저장 용량</strong></td>
<td>⚠️ JVM 힙 크기에 제한됨</td>
<td>✅ 서버 메모리 한도까지 확장 가능</td>
</tr>
<tr>
<td><strong>자료구조</strong></td>
<td>Key-Value만 지원</td>
<td>✅ String, Hash, List, Set, Sorted Set 등</td>
</tr>
<tr>
<td><strong>만료 정책</strong></td>
<td>TTL, 최대 크기, 참조 기반 제거</td>
<td>TTL, LRU, LFU, 수동 삭제 등</td>
</tr>
<tr>
<td><strong>인프라 구성</strong></td>
<td>✅ 별도 서버 불필요</td>
<td>❌ Redis 서버 별도 운영 필요</td>
</tr>
<tr>
<td><strong>설정 복잡도</strong></td>
<td>✅ 의존성 추가 후 코드 몇 줄로 완성</td>
<td>⚠️ 서버 설치, 연결 설정, 운영 필요</td>
</tr>
<tr>
<td><strong>운영 비용</strong></td>
<td>✅ 추가 비용 없음</td>
<td>❌ 서버 운영 또는 클라우드 비용 발생</td>
</tr>
<tr>
<td><strong>장애 영향</strong></td>
<td>✅ 앱과 생사를 같이 함 (별도 장애 없음)</td>
<td>❌ Redis 다운 시 캐시 전체 영향</td>
</tr>
<tr>
<td><strong>고가용성</strong></td>
<td>해당 없음</td>
<td>Sentinel / Cluster로 구성 가능</td>
</tr>
<tr>
<td><strong>언어 지원</strong></td>
<td>❌ Java / JVM 전용</td>
<td>✅ 언어 무관 (Node, Python, Go 등)</td>
</tr>
<tr>
<td><strong>Pub/Sub 지원</strong></td>
<td>❌</td>
<td>✅ 메시지 브로커 역할도 가능</td>
</tr>
<tr>
<td><strong>모니터링</strong></td>
<td>⚠️ 제한적 (JMX, Micrometer 등)</td>
<td>✅ Redis CLI, RedisInsight 등 풍부한 도구</td>
</tr>
</tbody></table>
<hr>
<h1 id="4-캐시-밴치마크-실습">4 캐시 밴치마크 실습</h1>
<h2 id="4-1-기술-스택">4-1 기술 스택</h2>
<h3 id="4-1-1-언어--프레임워크">4-1-1 언어 &amp; 프레임워크</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Java</td>
<td>17</td>
<td>메인 언어</td>
</tr>
<tr>
<td>Spring Boot</td>
<td>4.0.5</td>
<td>애플리케이션 프레임워크</td>
</tr>
<tr>
<td>Spring Web MVC</td>
<td>(Boot 관리)</td>
<td>REST API (<code>GET /*/products/{id}</code>)</td>
</tr>
<tr>
<td>Spring Data JPA</td>
<td>(Boot 관리)</td>
<td>ORM / DB 접근 레이어</td>
</tr>
<tr>
<td>Spring Cache</td>
<td>(Boot 관리)</td>
<td>캐시 추상화 인프라</td>
</tr>
<tr>
<td>Spring Session Data Redis</td>
<td>(Boot 관리)</td>
<td>Redis 연결 및 세션 관리</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-1-2-캐시">4-1-2 캐시</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Caffeine</td>
<td>3.1.8</td>
<td>L1 로컬 인메모리 캐시 (JVM 힙)</td>
</tr>
<tr>
<td>Redis</td>
<td>7-alpine</td>
<td>L2 분산 캐시 (외부 프로세스, TCP)</td>
</tr>
<tr>
<td>NoCacheStrategy</td>
<td>—</td>
<td>베이스라인 (캐시 없음, 매 요청 DB 조회)</td>
</tr>
</tbody></table>
<p>캐시 전략은 <code>CacheStrategy</code> 인터페이스로 추상화하고, <code>ProductController</code>에서 <code>@Qualifier</code>로
3종을 동시 활성화하여 엔드포인트 경로(<code>/caffeine/</code>, <code>/redis/</code>, <code>/nocache/</code>)로 분기.</p>
<hr>
<h3 id="4-1-3-데이터베이스">4-1-3 데이터베이스</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>H2</td>
<td>(Boot 관리)</td>
<td>인메모리 DB (개발/테스트 전용)</td>
</tr>
<tr>
<td>Hibernate</td>
<td>(Boot 관리)</td>
<td>JPA 구현체</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-1-4-직렬화">4-1-4 직렬화</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Jackson Databind</td>
<td>(Boot 관리)</td>
<td>Redis 저장 시 Product → JSON 직렬화</td>
</tr>
</tbody></table>
<p>Redis 직렬화는 deprecated된 <code>GenericJackson2JsonRedisSerializer</code> 대신
<code>ObjectMapper</code>를 직접 사용하는 커스텀 <code>RedisSerializer&lt;Product&gt;</code> 구현.</p>
<hr>
<h3 id="4-1-5-모니터링--운영">4-1-5 모니터링 &amp; 운영</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Spring Actuator</td>
<td>(Boot 관리)</td>
<td><code>/actuator/metrics</code>, <code>/actuator/health</code> 등 엔드 포인트 노출</td>
</tr>
<tr>
<td>Micrometer</td>
<td>(Boot 관리)</td>
<td>메트릭 수집 추상화 레이어</td>
</tr>
<tr>
<td>Micrometer Prometheus Registry</td>
<td>(Boot 관리)</td>
<td>Actuator 메트릭 → Prometheus 포맷 변환</td>
</tr>
<tr>
<td>Prometheus</td>
<td>latest</td>
<td>시계열 메트릭 저장</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-1-6-부하-테스트">4-1-6 부하 테스트</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>k6</td>
<td>1.7.1</td>
<td>부하 생성 + JSON 리포트 출력</td>
</tr>
<tr>
<td>Python 3</td>
<td>3.8.6</td>
<td>k6 JSON 파싱 후 CSV/콘솔 출력 (<code>parse_results.py</code>)</td>
</tr>
</tbody></table>
<p>k6 스크립트는 warmup + load 6개 시나리오(전략별 각 2개)를 단일 실행으로 순차 처리.</p>
<hr>
<h3 id="4-1-7-인프라--컨테이너">4-1-7 인프라 &amp; 컨테이너</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Docker</td>
<td>Redis, Prometheus, Grafana 컨테이너 실행</td>
</tr>
<tr>
<td>Docker Compose</td>
<td>인프라 서비스 일괄 관리 (<code>redis</code>, <code>prometheus</code>)</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-1-8-개발-도구">4-1-8 개발 도구</h3>
<table>
<thead>
<tr>
<th>기술</th>
<th>버전</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Gradle</td>
<td>9.4.1</td>
<td>빌드 도구</td>
</tr>
<tr>
<td>Lombok</td>
<td>(Boot 관리)</td>
<td>보일러플레이트 코드 생성 (<code>@Getter</code>,<code>@RequiredArgsConstructor</code> 등)</td>
</tr>
<tr>
<td>Spring DevTools</td>
<td>(Boot 관리)</td>
<td>개발 중 핫 리로드</td>
</tr>
<tr>
<td>Testcontainers</td>
<td>(Boot 관리)</td>
<td>테스트 시 Redis 컨테이너 자동 기동</td>
</tr>
<tr>
<td>JUnit 5</td>
<td>(Boot 관리)</td>
<td>단위 / 통합 테스트</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-1-9-설계-패턴">4-1-9 설계 패턴</h3>
<table>
<thead>
<tr>
<th>패턴</th>
<th>적용 위치</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>Strategy Pattern</td>
<td><code>CacheStrategy</code> 인터페이스</td>
<td>캐시 구현체 교체 가능하도록 추상화</td>
</tr>
<tr>
<td>Qualifier Injection</td>
<td><code>ProductController</code></td>
<td><code>@Qualifier</code>로 3종 전략 빈을 동시 주입</td>
</tr>
<tr>
<td>Command Pattern</td>
<td><code>DataSeeder</code></td>
<td>앱 시작 시 10만 건 자동 시딩 (<code>CommandLineRunner</code>)</td>
</tr>
</tbody></table>
<hr>
<h1 id="5-핵심-로직">5 핵심 로직</h1>
<h2 id="5-1-strategy-추상화-및-구현화">5-1 Strategy 추상화 및 구현화</h2>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/83448f5f-2b21-45ef-a0d9-75127e0d519a/image.png" alt=""></p>
<ul>
<li><p>get : 캐시에 저장된 데이터 조회</p>
</li>
<li><p>put : 데이터 캐싱</p>
</li>
<li><p>evict : id를 통해 캐시에서 데이터 삭제</p>
</li>
</ul>
<hr>
<h3 id="5-1-1-caffeinestrategy">5-1-1 CaffeineStrategy</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/b204ced4-6dbf-4be1-9e27-9474ccb856a5/image.png" alt=""></p>
<hr>
<h3 id="5-1-2-redisstrategy">5-1-2 RedisStrategy</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/e0460524-877e-4875-be6f-efd992543d38/image.png" alt=""></p>
<hr>
<h3 id="5-1-3-nocachestratey">5-1-3 NoCacheStratey</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/6f083d5a-5a93-4ade-8d66-962a1225a169/image.png" alt=""></p>
<p>어느 동작도 하지 않지만, 컨트롤러의 각 API가 fetch라는 동일 메서드를 호출하기 때문에
작성하였고 get메서드에 의해 EMPTY의 데이터가 반환되어 DB에 직접 조회하도록 유도</p>
<hr>
<h2 id="5-2-controller--service">5-2 Controller &amp; Service</h2>
<h3 id="5-2-1-controller">5-2-1 Controller</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/0bd97e6f-04a7-464f-b30a-d88dca8091f1/image.png" alt=""></p>
<p><code>@Qualifer</code> 어노테이션을 통해 3종 전략 빈을 동시 주입하고, 각 API는 fetch 메서드를 호출
fetch 메서드에서는 strategy를 통해 값을 조회하고 없다면 service를 통해 DB조회한 결과를 반환.
마지막으로 <code>strategy.put</code> 메서드로 캐싱</p>
<h3 id="5-2-2-service">5-2-2 Service</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/41e5d2e8-0eac-445c-ab74-c46cf3d4d039/image.png" alt=""></p>
<p>Service의 경우 DB 조회에 대한 로직만 필요하므로 최소환의 비즈니스 로직만 작성</p>
<h1 id="6-테스트">6 테스트</h1>
<hr>
<h2 id="6-1-테스트-방법">6-1 테스트 방법</h2>
<p>테스트는 2가지를 준비했다. 첫 번째로는 Postman을 통한 API 직접 호출
두 번째는 K6를 활용한 부하 테스트를 통한 캐시 성능 밴치마크</p>
<hr>
<h2 id="6-2-api-호출-테스트">6-2 API 호출 테스트</h2>
<p>복잡한 비즈니스 로직이 없어 API 호출을 하는 것만으로는 성능을 확인하기 어렵기 때문에 Service 로직에서 반복문을 추가해 반환 속도를 의도적으로 늦춰서 실행한다.</p>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/24b8e820-b0c5-479d-b05c-3fed17bec88a/image.png" alt=""></p>
<h3 id="6-2-1-no-cache">6-2-1 No-Cache</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/66dca8d4-d3b0-4569-8eb6-0cef2be3c552/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/473922ff-55ba-4c9d-891d-6072b763361c/image.png" alt=""></p>
<p>No-Cache의 경우 524ms와 189ms라는 비교적 늦은 응답속도를 확인할 수 있다.</p>
<hr>
<h3 id="6-2-2-caffeine">6-2-2 Caffeine</h3>
<ul>
<li><p>Cache Miss
<img src="https://velog.velcdn.com/images/c_mungi/post/075033e7-0fed-448e-bf26-dbbf7960d60f/image.png" alt=""></p>
</li>
<li><p>Cache Hit
<img src="https://velog.velcdn.com/images/c_mungi/post/76793412-5a17-4ea7-8598-5d5432b09fba/image.png" alt=""></p>
</li>
</ul>
<p>Caffeine의 경우 Cache Miss에서는 234ms를 보여주지만 Cache Hit일 때는 5ms라는 빠른 응답 속도를 나타낸다.</p>
<hr>
<h3 id="6-2-3-redis">6-2-3 Redis</h3>
<ul>
<li>Cache Miss
<img src="https://velog.velcdn.com/images/c_mungi/post/030aa2c4-61fc-433f-9d06-27833f9c4222/image.png" alt=""></li>
</ul>
<ul>
<li>Cache Hit
<img src="https://velog.velcdn.com/images/c_mungi/post/a8f31850-b713-4dfe-8791-1a35cb0e3a07/image.png" alt=""></li>
</ul>
<p>Redis의 경우도 1.27s에서 8ms로 응답 속도가 매우 빠르게 향상된 것을 확인할 수 있었다.</p>
<hr>
<h1 id="7-부하-테스트">7 부하 테스트</h1>
<h2 id="7-1-k6의-부하테스트-목적">7-1 K6의 부하테스트 목적</h2>
<p>동일한 애플리케이션 인스턴스에서 캐시 전략 3종을 공정한 조건 아래 비교하여, 전략 선택이 성능에 미치는 영향을 수치로 증명하는 것이 목표였다.</p>
<h3 id="7-1-1-측정-지표">7-1-1 측정 지표</h3>
<p>TPS — 초당 처리 요청 수 (전략 간 처리량 비교)
p50 — 전체 요청의 중간값 응답시간
p95 — 상위 5% 느린 요청의 응답시간 (서비스 안정성 기준)
p99 — 최악 케이스 응답시간
error_rate — 비정상 응답 비율 (목표 &lt; 1%)</p>
<h3 id="7-1-2-시나리오-설계">7-1-2 시나리오 설계</h3>
<table>
<thead>
<tr>
<th>구간</th>
<th>vus</th>
<th>시간</th>
<th>목적</th>
</tr>
</thead>
<tbody><tr>
<td>warmup</td>
<td>10</td>
<td>30s</td>
<td>캐시 초기 적재 + JVM 워밍업</td>
</tr>
<tr>
<td>load</td>
<td>100</td>
<td>60s</td>
<td>실제 부하 조건에서 전략 간 비교</td>
</tr>
</tbody></table>
<h2 id="7-2-k6-부하테스트-결과">7-2 K6 부하테스트 결과</h2>
<h3 id="7-2-1-load-시나리오-기준-vus100-60s">7-2-1 Load 시나리오 기준 (vus=100, 60s)</h3>
<table>
<thead>
<tr>
<th>전략</th>
<th>총 요청</th>
<th>TPS</th>
<th>avg</th>
<th>p50</th>
<th>p95</th>
<th>p99</th>
<th>max</th>
<th>에러</th>
</tr>
</thead>
<tbody><tr>
<td>Caffeine</td>
<td>58,713</td>
<td>978.5</td>
<td>1.6ms</td>
<td>1.2ms</td>
<td>3.5ms</td>
<td>7.0ms</td>
<td>37.0ms</td>
<td>0</td>
</tr>
<tr>
<td>No-Cache</td>
<td>57,978</td>
<td>966.3</td>
<td>2.3ms</td>
<td>1.6ms</td>
<td>6.6ms</td>
<td>17.1ms</td>
<td>46.2ms</td>
<td>0</td>
</tr>
<tr>
<td>Redis</td>
<td>56,441</td>
<td>940.7</td>
<td>5.8ms</td>
<td>4.2ms</td>
<td>15.3ms</td>
<td>24.4ms</td>
<td>108.2ms</td>
<td>0</td>
</tr>
</tbody></table>
<hr>
<h3 id="7-2-2-서버-처리-시간-ttfb--http_req_waiting-load-기준">7-2-2 서버 처리 시간 TTFB — http_req_waiting (Load 기준)</h3>
<table>
<thead>
<tr>
<th>전략</th>
<th>avg</th>
<th>p50</th>
<th>p95</th>
<th>p99</th>
</tr>
</thead>
<tbody><tr>
<td>Caffeine</td>
<td>1.3ms</td>
<td>1.1ms</td>
<td>2.9ms</td>
<td>5.2ms</td>
</tr>
<tr>
<td>No-Cache</td>
<td>1.7ms</td>
<td>1.2ms</td>
<td>4.0ms</td>
<td>12.3ms</td>
</tr>
<tr>
<td>Redis</td>
<td>5.5ms</td>
<td>4.0ms</td>
<td>14.8ms</td>
<td>23.7ms</td>
</tr>
</tbody></table>
<h3 id="7-2-3-결론">7-2-3 결론</h3>
<ol>
<li><p>Caffeine이 가장 빠름</p>
<blockquote>
<p>p50 기준 1.2ms로 3종 중 가장 빠르다. JVM 힙에서 직접 반환하므로 네트워크 비용이 전혀 없다. p99도 7.0ms로 안정적.</p>
</blockquote>
</li>
<li><p>NoCache가 Redis보다 빠르게 나왔다 — H2 인메모리 특성</p>
<blockquote>
<p>NoCacheStrategy(p50 1.6ms)가 Redis(p50 4.2ms)보다 빠른 것은 이상해 보이지만, 사용 중인 DB가 H2 인메모리이기 때문이다. H2는 프로세스 내부 메모리에서 직접 조회하므로 TCP 통신이 없다. PostgreSQL 같은 외부 DB를 사용했다면 순위가 달라졌을 것이다.</p>
</blockquote>
</li>
<li><p>Redis가 가장 느리게 나왔다 — TCP + 직렬화 비용</p>
<blockquote>
<p>p50 4.2ms, p95 15.3ms로 Caffeine의 3~4배 수준. TTFB 기준 avg 5.5ms는 전부 네트워크 RTT + JSON 직렬화/역직렬화 오버헤드로 보인다.</p>
</blockquote>
</li>
<li><p>TPS는 셋 다 비슷 — 병목이 캐시가 아님</p>
<blockquote>
<p>TPS가 940~978로 거의 동일한 이유는 k6 스크립트의 sleep(0.1) 때문이다. vus 100 × (1/0.1) = 최대 1,000 TPS가 이론적 상한이며, 실제 수치가 이에 근접하고 있다. 즉 현재 병목은 캐시가 아닌 k6의 요청 간격이다.</p>
</blockquote>
</li>
<li><p>에러율 0% — 안정성 확인</p>
</li>
</ol>
<p>H2를 사용했기에 원했던 결과와 차이가 나지만 그래도 어떠한 상황에서 어떠한 기술 스택을 사용하면 좀 더 좋을 수 있을지 알게되었다고 생각한다.</p>
<hr>
<h1 id="github">GitHub</h1>
<blockquote>
<p><a href="https://github.com/Mungi-Cheon/Cache-Benchmark">https://github.com/Mungi-Cheon/Cache-Benchmark</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[2025년 58회 SQLD 합격 후기]]></title>
            <link>https://velog.io/@c_mungi/2025%EB%85%84-58%ED%9A%8C-SQLD-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@c_mungi/2025%EB%85%84-58%ED%9A%8C-SQLD-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Fri, 12 Sep 2025 08:21:45 GMT</pubDate>
            <description><![CDATA[<h2 id="시험-결과">시험 결과</h2>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/58f0bc6d-96ea-4da8-bc09-6539755fe29b/image.png" alt=""></p>
<hr>
<h2 id="준비-시간">준비 시간</h2>
<blockquote>
<p>1개월</p>
</blockquote>
<p>개인사 및 다른 공부들도 있었기에 격일이라도 최대한 1,2시간씩 공부했다.
풀타임으로 공부를 했어도 넉넉히 1~2주면 충분하지 않을까 싶다.
(같이 본 아는 동생은 3일 공부하고 합격...)</p>
<hr>
<h2 id="공부-방법">공부 방법</h2>
<h3 id="1주차---2주차">1주차 - 2주차</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/5a0faa31-7121-49d1-81ea-dfa575610e9e/image.png" alt=""></p>
<p>정독과 동시에 아래 내용으로 요약해 PDF파일로 만들었다.</p>
<blockquote>
<p>개념 요약, 두음어 만들기, 함수 정의 및 동작 원리, 함수 예시</p>
</blockquote>
<p>아래는 실제 요약한 내용의 일부이다.
<img src="https://velog.velcdn.com/images/c_mungi/post/2bb163e8-f69d-4904-b45d-6ae7eddb53b9/image.png" alt=""></p>
<p>네이버 스마트 스토어같은데 보면 저렴한 가격에 요약본 판매를 하는데 굳이 직접 요약한 이유는 다음과 같다.</p>
<ol>
<li>직접 요약을 하면서 내용을 외우거나 이해하고자 했다.</li>
<li>다른 공부를 포함한 개인 스케줄이 있어 어디서든 볼 수 있게 조금이나마 시간을 활용하고 싶었다.</li>
</ol>
<h3 id="3주차---4주차">3주차 - 4주차</h3>
<p>3,4주차부터는 일명 노랭이 책이라고 불리는 아래 책의 문제만 풀었다.</p>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/e1eaab84-c783-4a1e-90b0-c14cab7e45a4/image.png" alt=""></p>
<p>풀면서 모르는 문제, 헷갈리는 문제들은 해설을 보거나 PDF로 요약한 내용을 다시 보고 풀었다.
그래도 안된다면 GPT를 통해 Step별로 동작 원리를 나타내도록 한 뒤 동작 흐름을 통해서 이해했다.
(사실 노랭이 해설본은.. 친절하지 않아서 응? 할 때도 있었다. 이걸 해결 해준건 갓 GPT )</p>
<p>그리고 틀렸던 문제들은 책에 메모한 내용이나 번호에 체크했던 걸 다 지우고 다시 푸는 식으로 했다.</p>
<hr>
<h3 id="시험-전날-그리고-시험-당일">시험 전날 그리고 시험 당일</h3>
<p>시험 전날에는 미리 PDF내용을 인쇄해두고 다시 훑어 보면서 개념을 정리했다.</p>
<p>당일에는 프린트물을 들고가 만들어둔 두음어를 다시 암기하고 시험을 봤는데</p>
<p>개인적으로는 90분동안 50문제를 풀어야 한다는 것이 생각보다 시간적 부담감이 컸다.</p>
<p>그래서 시험 시작 5분동안 문제를 훑어보면서 지문이 많거나 Window 함수 관련 문제이거나, 복잡해보이는 문제에 체크 표시를 했다.</p>
<p>그리고 30분동안 체크를 안한 문제 즉, 비교적 쉬운 문제들을 빠르게 풀고 OMR 마킹할 10분을 제외한 나머지 45분은 체크한 문제를 푸는 방향으로 접근했다.</p>
<p>이런 시간 분배를 통해 시험을 좀 더 효율적으로 치루지 않았나 싶다.</p>
<p>또한 난이도는 <code>&#39;이걸 어떻게 풀어..?&#39;</code> 할 정도로 막연하게 어렵다고 느껴지는 문제들은 다행히 없었다.</p>
<p>조금 복잡하거나 헷갈리는 문제들은 있었지만 대부분 무난하게 풀 수 있었다.
(SQLD 관련 커뮤니티에서는 난이도가 전회차 보다 어렵다고 해서 오히려 놀람..)</p>
<hr>
<h2 id="감상">감상</h2>
<p>다행히 원트에 합격했지만 나름 피말리는 한달이었던 것 같다.
개인 스케줄 처리, 개인 공부, 입사 지원 등등 물론 지금도 현재 진행형이지만..
이제 이력서에 SQLD라고 한 줄 추가할 수 있게 된걸 위안으로 삼아보려 한다.
올해 안으로는 좋은 일이 있길 바래본다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Node.JS : 블로그 구현하기_Controller/Service [完]]]></title>
            <link>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0ControllerService</link>
            <guid>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0ControllerService</guid>
            <pubDate>Sun, 08 Jun 2025 06:41:09 GMT</pubDate>
            <description><![CDATA[<h2 id="1-시작">1. 시작</h2>
<p>이전 포스트에서 Router를 구현하였다. 이제 남은 항목은 Post, Admin도메인의 Controller, Service로직만을 남겨두고 있고, 이번 포스트가 블로그 구현 프로젝트의 마지막이 될 것 같다.</p>
<hr>
<h2 id="2-post-controller--service">2. Post Controller / Service</h2>
<p>먼저 게시글 관련 Controller와 Service 로직을 구현하고자 한다.</p>
<h3 id="2-1-postcontrollerjs">2-1. postController.js</h3>
<p>8가지의 Post API를 구현할 것이다.</p>
<ul>
<li>getAllPost : 전체 게시글을 조회 API</li>
<li>getPostDetail : 특정 게시글을 조회 API</li>
<li>getAddPostPage : 게시글 작성 페이지</li>
<li>addPost : 게시글 작성 API</li>
<li>editPost : 게시글 수정 API</li>
<li>deletePost : 게시글 삭제 API</li>
</ul>
<pre><code class="language-js">const postService = require(&quot;../service/postService.js&quot;);
const mainLayout = &quot;../views/layouts/main.ejs&quot;;
const adminLayout = &quot;../views/layouts/admin-login.ejs&quot;;
const { isAdmin } = require(&quot;../utils/authUtil&quot;);

const getAllPost = async (req, res) =&gt; {
    const layout = isAdmin(req) ? adminLayout : mainLayout;
    const locals = { title: &quot;Home&quot; };
    const data = await postService.fetchAllPosts();
    res.render(&quot;post/index&quot;, { locals, data, layout: layout });
};

const getPostDetail = async (req, res) =&gt; {
    const layout = isAdmin(req) ? adminLayout : mainLayout;
    const data = await postService.fetchPostById(req.params.id);
    res.render(&quot;post/post&quot;, { data, layout });
};

const getAllPostByAdmin = async (req, res) =&gt; {
    const locals = { title: &quot;Posts&quot; };
    const data = await postService.fetchAllPosts();
    res.render(&quot;admin/allPosts&quot;, { locals, data, layout: adminLayout });
};

const addPost = async (req, res) =&gt; {
    const { title, body } = req.body;
    if (!title || !body) {
        return res.send(&quot;&#39;필수 항목목이 입력되지 않았습니다.&#39;&quot;);
    }
    await postService.createPost(title, body);
    res.redirect(&quot;/allPosts&quot;);
};

const editPost = async (req, res) =&gt; {
    await postService.updatePost(req.params.id, req.body);
    res.redirect(&quot;/allPosts&quot;);
};

const deletePost = async (req, res) =&gt; {
    await postService.deletePost(req.params.id);
    res.redirect(&quot;/allPosts&quot;);
};

module.exports = {
    getAllPost,
    getPostDetail,
    getAllPostByAdmin,
    addPost,
    editPost,
    deletePost,
};</code></pre>
<h3 id="2-2-postservicejs">2-2. postService.js</h3>
<pre><code class="language-js">const Post = require(&quot;../models/Post&quot;);

/**
 * 게시글 전체 조회
 */
const fetchAllPosts = async () =&gt; {
    const posts = await Post.find().sort({ createdAt: -1 }); // 내림차순으로 정렬
      // 기존 post객체에 formattedDate필드를 추가한 새로운 post객체로 만들어 반환
    return posts.map(post =&gt; ({
        ...post._doc,
        formattedDate: post.createdAt.toISOString().split(&#39;T&#39;)[0],
    }));
};

/**
 * 특정 게시글 조회
 * @param {string} id
 */
const fetchPostById = async (id) =&gt; {
    return await Post.findOne({ _id: id });
};

const createPost = async (title, body) =&gt; {
    await Post.create({ title, body });
};

const updatePost = async (id, body) =&gt; {
    await Post.findByIdAndUpdate(id, {
        title: body.title,
        body: body.body,
        createdAt: Date.now(),
    });
};

const deletePost = async (id) =&gt; {
    await Post.deleteOne({ _id: id });
};

module.exports = {
    fetchAllPosts,
    fetchPostById,
    createPost,
    updatePost,
    deletePost
};</code></pre>
<hr>
<h2 id="3-admin-controller--service">3. Admin Controller / Service</h2>
<h3 id="3-1-admincontrollerjs">3-1. adminController.js</h3>
<p>5가지의 Admin API를 구현할 것이다.</p>
<ul>
<li>getAdminPage : 관리자 페이지 랜더링</li>
<li>getAddPostPage : 게시글 작성 페이지 랜더링</li>
<li>getAllPosts : 전체 게시글 조회 </li>
<li>login : 로그인</li>
<li>logout : 로그아웃</li>
</ul>
<pre><code class="language-js">const adminService = require(&quot;../service/adminService&quot;);
const { isAdmin } = require(&quot;../utils/authUtil&quot;);

const login = async (req, res) =&gt; {
    const { username, password } = req.body;
    const result = await adminService.login(username, password);

    if (!result.success) {
        return res.status(401).json({ message: result.message });
    }

    res.cookie(&quot;token&quot;, result.token, { httpOnly: true });
    res.redirect(&quot;/allPosts&quot;);
};

const logout = (req, res) =&gt; {
    adminService.logout(res);
    res.redirect(&quot;/&quot;);
};

module.exports = {
    login,
    logout,
};</code></pre>
<h3 id="3-2-adminservicejs">3-2. adminService.js</h3>
<pre><code class="language-js">const User = require(&quot;../models/User&quot;);
const bcrypt = require(&quot;bcrypt&quot;);
const jwt = require(&quot;jsonwebtoken&quot;);
const jwtSecret = process.env.JWT_SECRET;
const jwtAccessTtl = process.env.JWT_ACCESS_TTL;

const login = async (username, password) =&gt; {
    const user = await User.findOne({ username });
    if (!user) {
        return { success: false, message: &quot;일치하는 사용자가 없습니다.&quot; };
    }

    const isValidPassword = await bcrypt.compare(password, user.password);
    if (!isValidPassword) {
        return { success: false, message: &quot;일치하는 사용자가 없습니다.&quot; };
    }

      // jwt에 .env에 지정한 SecretKey와 TTL을 설정해서 발급
    const token = jwt.sign({ userId: user._id }, jwtSecret,  { expiresIn: jwtAccessTtl });
    return { success: true, token };
};

const logout = (res) =&gt; {
    res.clearCookie(&quot;token&quot;);
}

module.exports = {
    login,
    logout
};</code></pre>
<h2 id="4-마무리">4. 마무리</h2>
<p>처음 다뤄본 Node.js였지만 기본 베이스가 JavaScript이라 그런지 비교적 이해하기 쉬웠다.</p>
<p>특히, 이제껏 Java&amp;Spring을 주로 써왔기에 가장 크게 체감된건 Node.js &amp; Express가 Java&amp;Spring보다 프레임 구조나 애플리케이션 시작 등 많은 부분이 가볍다 라는 점이다.</p>
<p>아마 그만큼 규모가 큰 서비스나 복잡한 연산이 많이 필요한 상황에서는 Java&amp;Spring이 더 안정적이지 않을까 라는 생각이 든다. </p>
<p>그래서 Node.js의 경우는 빠르게 개발하고자 할 때 사용하기엔 편할것 같다는 느낌이다.</p>
<p>다만, 아직까진 기본적인 문법과 express 프레임워크를 맛보는 단계로 수행했기에 깊이 있는 이해는 못했기에 다른 분들의 블로그들이나 공식 문서를 찾아보면서 Express의 동작원리도 보고 실제 배포까지 해볼 수 있는 프로젝트도 고민해봐야 할 듯 하다.</p>
<p>물론.. 구직활동과 자격증 공부도 꾸준히 해야하기 때문에 프로젝트는 당장 실행하는건 여건상 힘들겠지만 천천히라도 하고자 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Node.JS : 블로그 구현하기_Router 작성]]></title>
            <link>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Router-%EC%9E%91%EC%84%B1</link>
            <guid>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0Router-%EC%9E%91%EC%84%B1</guid>
            <pubDate>Sun, 08 Jun 2025 03:55:28 GMT</pubDate>
            <description><![CDATA[<h2 id="1-시작">1. 시작</h2>
<p>이전 포스트에서는 DB연동, View 및 Layout을 구현했다.
이제 남은 항목들은 도메인의 Router, Controller, Service 로직만을 앞두고 있다.
이번 포스트에서는 다음과 같이 작성한 Router 로직을 기록하고자 한다.</p>
<ul>
<li><code>routes</code><ul>
<li>postRouter.js : 게시글 관련 Router</li>
<li>adminRouter.js : 관리자 관련 Router</li>
</ul>
</li>
</ul>
<h2 id="2-postrouterjs">2. postRouter.js</h2>
<p>postRouter에서는 요청 받은 <code>endpoint</code>에 맞춰 알맞은 게시글 관련 데이터를 보내줄 수 있도록 연결한다.</p>
<p>요청 받는 <code>endpoint</code>는 다음과 같다.</p>
<ul>
<li>/, /home <ul>
<li>GET : 메인 페이지에 표시할 게시글 리스트 조회</li>
</ul>
</li>
<li>/posts/:id<ul>
<li>GET : 특정 게시글의 상세 조회</li>
</ul>
</li>
<li>/allPosts<ul>
<li>GET : 게시글 조회 API</li>
</ul>
</li>
<li>/add<ul>
<li>GET : 게시글 작성 페이지로 랜더링</li>
<li>POST : 게시글 작성 API</li>
</ul>
</li>
<li>/edit/:id<ul>
<li>GET : 게시글 수정 페이지로 랜더링</li>
<li>PUT : 게시글 수정 API</li>
</ul>
</li>
<li>/delete/:id<ul>
<li>DELETE : 게시글 삭제 API</li>
</ul>
</li>
</ul>
<pre><code class="language-js">const express = require(&quot;express&quot;);
const asyncHandler = require(&quot;express-async-handler&quot;);
const router = express.Router();
const postController = require(&quot;../controllers/postController&quot;);
const { checkLogin } = require(&quot;../utils/authUtil&quot;);
const adminLayout = &quot;../views/layouts/admin-login.ejs&quot;;

router.get([&quot;/&quot;, &quot;/home&quot;], asyncHandler(postController.getAllPost));

router.get(&quot;/posts/:id&quot;, asyncHandler(postController.getPostDetail));

router.get(&quot;/allPosts&quot;, checkLogin, asyncHandler(postController.getAllPostByAdmin));

router.route(&quot;/add&quot;)
    .get(checkLogin, (req, res) =&gt; {
        const locals = { title: &quot;게시물 작성&quot; };
        res.render(&quot;admin/add&quot;, { locals, layout: adminLayout });
    })
    .post(checkLogin, asyncHandler(postController.addPost));

router.route(&quot;/edit/:id&quot;)
    .get(checkLogin, async (req, res) =&gt; {
        const locals = { title: &quot;게시물 수정&quot; };
        const data = await postService.fetchPostById(req.params.id);
        res.render(&quot;admin/edit&quot;, { locals, data, layout: adminLayout });
    })
    .put(checkLogin, asyncHandler(postController.editPost));

router.delete(&quot;/delete/:id&quot;, checkLogin, asyncHandler(postController.deletePost));

module.exports = router;</code></pre>
<h2 id="3-adminrouterjs">3. adminRouter.js</h2>
<p>adminRouter에서 요청 받는 <code>endpoint</code>는 다음과 같다.</p>
<ul>
<li>/admin <ul>
<li>GET : 로그인 페이지로 랜더링</li>
<li>POST : 로그인 API</li>
</ul>
</li>
<li>/logout<ul>
<li>GET : 로그아웃 API</li>
</ul>
</li>
<li>/about<ul>
<li>GET : About 페이지로 랜더링</li>
</ul>
</li>
</ul>
<pre><code class="language-js">const express = require(&quot;express&quot;);
const router = express.Router();
const asyncHandler = require(&quot;express-async-handler&quot;);
const adminController = require(&quot;../controllers/adminController&quot;);
const { checkLogin } = require(&quot;../utils/authUtil&quot;);
const adminLoginLayout = &quot;../views/layouts/admin-login.ejs&quot;;
const adminNoLoginLayout = &quot;../views/layouts/admin-nologin.ejs&quot;;
const mainLayout = &quot;../views/layouts/main.ejs&quot;;

router.route(&quot;/admin&quot;)
    .get((req, res) =&gt; {
        const locals = { title: &quot;관리자 페이지&quot; };
        res.render(&quot;admin/login&quot;, { locals, layout: adminNoLoginLayout });
    })
    .post(asyncHandler(adminController.login));

router.route(&quot;/logout&quot;).get(adminController.logout);

router.route(&quot;/about&quot;).get((req, res) =&gt; {
    const layout = isAdmin(req) ? adminLoginLayout : mainLayout;
    const locals = { title: &quot;About&quot; };
    res.render(&quot;admin/about&quot;, { locals, layout: layout });
});

module.exports = router;</code></pre>
<h2 id="4-util-클래스-작성">4. Util 클래스 작성</h2>
<p>현재 상황에서 URL로 직접 관리자 페이지로 접속을 시도하면 어떠한 인증도 없이 관리자 페이지에 접속이 될 것이다.
당연히 NG인 상황이기에 인증을 거쳐야만 관리자 페이지에 접속할 수 있도록 해야한다.
다만, 현업에서 사용하는 방법은 훨씬 다양하고 복잡하므로 아래의 인증 로직은 어디까지나 참고정도로만 하면 될 것 같다.</p>
<ul>
<li><code>utiles</code><ul>
<li>authUtil.js</li>
</ul>
</li>
</ul>
<h3 id="4-1-authutiljs">4-1. authUtil.js</h3>
<pre><code class="language-js">const jwt = require(&quot;jsonwebtoken&quot;);
const jwtSecret = process.env.JWT_SECRET;

const checkLogin = (req, res, next) =&gt; {
    const token = req.cookies.token;
    if (!token) {
        return res.redirect(&quot;/admin&quot;);
    }
    try {
        const payload = jwt.verify(token, jwtSecret);
        req.userId = payload.userId;
        next();
    } catch (error) {
        return res.redirect(&quot;/admin&quot;);
    }
};

const isAdmin = (req) =&gt; {
    const token = req.cookies?.token;

    if (!token) {
        return false;
    }

    try {
        jwt.verify(token, jwtSecret);
        return true;
    } catch (e) {
        return false;
    }
};

module.exports = { checkLogin,isAdmin };</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Node.JS : 블로그 구현하기_View 작성]]></title>
            <link>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0View-%EC%9E%91%EC%84%B1</link>
            <guid>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0View-%EC%9E%91%EC%84%B1</guid>
            <pubDate>Sun, 08 Jun 2025 02:59:20 GMT</pubDate>
            <description><![CDATA[<h2 id="1시작">1.시작</h2>
<p>이번 포스트에서는 View를 작성하고자 한다.
인프런의 <a href="https://www.inflearn.com/course/do-it-nodejs-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%9E%85%EB%AC%B8">Do it! Node.js 프로그래밍 입문</a> 강의에서 제공하는 HTML를 EJS로 변환해 View를 작성되었다.</p>
<hr>
<h2 id="2-ejs란">2. EJS란</h2>
<p>EJS는 Embedded JavaScript Template의 약어로 템플릿 엔진 모듈이다. 템플릿 엔진 모듈은 서버에서 DB 혹은 API에서 가져온 데이터를 미리 정의된 Template에 맞춰 대입하고, html를 랜더링해 클라이언트에 원하는 정보를 전달해주는 역할을 한다.</p>
<hr>
<h2 id="3-layout-작성">3. Layout 작성</h2>
<p>메인 페이지, 관리자 페이지의 Header와 Footer를 보여줄 레이아웃을 <code>views/layouts</code> 폴더에 작성한다.
작성해야할 레이아웃의 목록은 다음과 같다. </p>
<ul>
<li>main.ejs : 메인 페이지의 레이아웃</li>
<li>admin-login.ejs : 관리자 페이지의 레이아웃</li>
<li>admin-nologin.ejs : 로그인 페이지의 레이아웃</li>
</ul>
<h3 id="3-1-mainejs">3-1. main.ejs</h3>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;

&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=edge&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;&lt;%= locals.title %&gt;&lt;/title&gt;
    &lt;meta name=&quot;description&quot; content=&quot;My first application using Node.js, Express and MongoDB&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/style.css&quot;&gt;
&lt;/head&gt;

&lt;body&gt;

    &lt;div class=&quot;container&quot;&gt;

        &lt;!-- 헤더 : 로고, 상단 메뉴, 로그인 --&gt;
        &lt;header class=&quot;header&quot;&gt;
            &lt;!-- 로고 --&gt;
            &lt;a href=&quot;/&quot; class=&quot;header-logo&quot;&gt;c.mungi의 블로그&lt;/a&gt;

            &lt;!-- 상단 메뉴 --&gt;
            &lt;nav class=&quot;header-nav&quot;&gt;
                &lt;ul&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/home&quot;&gt;Home&lt;/a&gt;
                    &lt;/li&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/about&quot;&gt;About&lt;/a&gt;
                    &lt;/li&gt;
                &lt;/ul&gt;
            &lt;/nav&gt;

            &lt;!-- 관리자 로그인 --&gt;
            &lt;div class=&quot;header-button&quot;&gt;
                &lt;a href=&quot;/admin&quot;&gt;관리자 로그인&lt;/a&gt;
            &lt;/div&gt;
        &lt;/header&gt;

        &lt;!-- 메인 : 실제 내용이 들어갈 부분 --&gt;
        &lt;main class=&quot;main&quot;&gt;
            &lt;%- body %&gt;
        &lt;/main&gt;
    &lt;/div&gt;

&lt;/body&gt;

&lt;/html&gt;</code></pre>
<h3 id="3-2-admin-loginejs">3-2. admin-login.ejs</h3>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;

&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=edge&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;&lt;%= locals.title %&gt;&lt;/title&gt;
    &lt;meta name=&quot;description&quot; content=&quot;My first application using Node.js, Express and MongoDB&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/style.css&quot;&gt;
&lt;/head&gt;

&lt;body&gt;

    &lt;div class=&quot;container&quot;&gt;

        &lt;!-- 헤더 : 로고, 상단 메뉴, 로그인 --&gt;
        &lt;header class=&quot;header&quot;&gt;
            &lt;!-- 로고 --&gt;
            &lt;a href=&quot;/&quot; class=&quot;header-logo&quot;&gt;Admin Page&lt;/a&gt;

            &lt;!-- 상단 메뉴 --&gt;
            &lt;nav class=&quot;header-nav&quot;&gt;
                &lt;ul&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/home&quot;&gt;Home&lt;/a&gt;
                    &lt;/li&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/about&quot;&gt;About&lt;/a&gt;
                    &lt;/li&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/allPosts&quot;&gt;Admin&lt;/a&gt;
                    &lt;/li&gt;
                &lt;/ul&gt;
            &lt;/nav&gt;

            &lt;!-- 관리자 로그아웃 --&gt;
            &lt;div class=&quot;header-button&quot;&gt;
                &lt;a href=&quot;/logout&quot;&gt;관리자 로그아웃&lt;/a&gt;
            &lt;/div&gt;
        &lt;/header&gt;

        &lt;!-- 메인 : 실제 내용이 들어갈 부분 --&gt;
        &lt;main class=&quot;main&quot;&gt;
            &lt;%- body %&gt;
        &lt;/main&gt;
    &lt;/div&gt;

&lt;/body&gt;

&lt;/html&gt;</code></pre>
<h3 id="3-3-admin-nologinejs">3-3. admin-nologin.ejs</h3>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;

&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=edge&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;&lt;%= locals.title %&gt;&lt;/title&gt;
    &lt;meta name=&quot;description&quot; content=&quot;My first application using Node.js, Express and MongoDB&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/style.css&quot;&gt;
&lt;/head&gt;

&lt;body&gt;

    &lt;div class=&quot;container&quot;&gt;

        &lt;!-- 헤더 : 로고, 상단 메뉴, 로그인 --&gt;
        &lt;header class=&quot;header&quot;&gt;
            &lt;!-- 로고 --&gt;
            &lt;a href=&quot;/&quot; class=&quot;header-logo&quot;&gt;Admin Page&lt;/a&gt;

            &lt;!-- 상단 메뉴 --&gt;
            &lt;nav class=&quot;header-nav&quot;&gt;
                &lt;ul&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/home&quot;&gt;Home&lt;/a&gt;
                    &lt;/li&gt;
                    &lt;li&gt;
                        &lt;a href=&quot;/about&quot;&gt;About&lt;/a&gt;
                    &lt;/li&gt;
                &lt;/ul&gt;
            &lt;/nav&gt;
        &lt;/header&gt;

        &lt;!-- 메인 : 실제 내용이 들어갈 부분 --&gt;
        &lt;main class=&quot;main&quot;&gt;
            &lt;%- body %&gt;
        &lt;/main&gt;
    &lt;/div&gt;

&lt;/body&gt;

&lt;/html&gt;</code></pre>
<hr>
<h2 id="4-view-작성">4. View 작성</h2>
<p>View들은 각 <code>views/post</code>, <code>views/admin</code>에 다음과 같이 작성한다.</p>
<ul>
<li>views/post<ul>
<li>index.ejs : 메인 페이지</li>
<li>post.ejs : 상세 페이지</li>
</ul>
</li>
<li>views/admin<ul>
<li>login.ejs : 로그인 페이지</li>
<li>allPosts.ejs : 관리자 페이지</li>
<li>add.ejs : 게시글 작성 페이지</li>
<li>edit.ejs : 게시글 수정 페이지</li>
<li>about.ejs : About 페이지</li>
</ul>
</li>
</ul>
<h3 id="4-1-게시글-페이지-관련-view">4-1. 게시글 페이지 관련 View</h3>
<h4 id="4-1-1-indexejs">4-1-1. index.ejs</h4>
<pre><code class="language-html">&lt;!-- 상단 소개글, 히어로 이미지 --&gt;
&lt;div class=&quot;top&quot;&gt;
    &lt;h1 class=&quot;top-heading&quot;&gt;하루하루 스터디&lt;/h1&gt;
    &lt;p class=&quot;top-body&quot;&gt;매일 1시간씩 공부한 내용을 기록하고 있습니다.&lt;/p&gt;
&lt;/div&gt;

&lt;img src=&quot;/img/top-hero.jpg&quot; alt=&quot;노트에 기록하는 모습&quot; class=&quot;hero-image&quot; width=&quot;840&quot; height=&quot;400&quot;&gt;

&lt;!-- 최근 게시물 --&gt;
&lt;section class=&quot;articles&quot;&gt;
    &lt;h2 class=&quot;articles-heading&quot;&gt;최근 게시물&lt;/h2&gt;
    &lt;ul class=&quot;article-ul&quot;&gt;
        &lt;% data.forEach( post =&gt; { %&gt;
        &lt;li&gt;
            &lt;a href=&quot;/posts/&lt;%= post._id %&gt;&quot;&gt; 
                &lt;span&gt;&lt;%= post.title %&gt;&lt;/span&gt;
                &lt;span class=&quot;article-list-date&quot;&gt;&lt;%= post.createdAt.toDateString()%&gt;&lt;/span&gt;
            &lt;/a&gt;
        &lt;/li&gt;
        &lt;% }) %&gt;
    &lt;/ul&gt;
&lt;/section&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/dbb89e22-e6d9-46a4-8c98-1b85f4f29317/image.png">


<h4 id="4-1-2-postejs">4-1-2. post.ejs</h4>
<pre><code class="language-html">&lt;h1&gt;&lt;%= data.title%&gt;&lt;/h1&gt;
&lt;article class=&quot;article&quot;&gt;
    &lt;%= data.body %&gt;
&lt;/article&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/29a586f3-2792-40ee-8208-94920ed5d42f/image.png">

<h3 id="4-2-관리자-페이지-관련-view">4-2. 관리자 페이지 관련 View</h3>
<h4 id="4-2-1-loginejs">4-2-1. login.ejs</h4>
<pre><code class="language-html">&lt;h3&gt;로그인&lt;/h3&gt;
&lt;form action=&quot;/admin&quot; method=&quot;POST&quot;&gt;
    &lt;label for=&quot;username&quot;&gt;&lt;b&gt;사용자 이름&lt;/b&gt;&lt;/label&gt;
    &lt;input type=&quot;text&quot; name=&quot;username&quot; id=&quot;username&quot;&gt;

    &lt;label for=&quot;password&quot;&gt;&lt;b&gt;비밀번호&lt;/b&gt;&lt;/label&gt;
    &lt;input type=&quot;password&quot; name=&quot;password&quot; id=&quot;password&quot;&gt;

    &lt;input type=&quot;submit&quot; value=&quot;로그인&quot; class=&quot;btn&quot;&gt;
&lt;/form&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/de8c108f-e97b-498b-9a77-726dd5d134fa/image.png">


<h4 id="4-2-2-allpostsejs">4-2-2. allPosts.ejs</h4>
<pre><code class="language-html">&lt;div class=&quot;admin-title&quot;&gt;
    &lt;h2&gt;&lt;%= locals.title%&gt;&lt;/h2&gt;
    &lt;a href=&quot;/add&quot; class=&quot;button&quot;&gt;+ 새 게시물&lt;/a&gt;
&lt;/div&gt;

&lt;ul class=&quot;admin-posts&quot;&gt;
    &lt;% data.forEach(post =&gt; { %&gt;  &lt;!-- Post 데이터들을 표시 --&gt;
        &lt;li&gt;
            &lt;a href=&quot;/posts/&lt;%=post._id%&gt;&quot;&gt;
                &lt;%= post.title %&gt;
            &lt;/a&gt;
            &lt;label&gt;&lt;%= post.formattedDate %&gt;&lt;/label&gt;
            &lt;div class=&quot;admin-post-controls&quot;&gt;
                &lt;a href=&quot;/edit/&lt;%=post._id%&gt;&quot; class=&quot;btn&quot;&gt;편집&lt;/a&gt;
                &lt;form action=&quot;/delete/&lt;%=post._id%&gt;?_method=DELETE&quot; method=&quot;POST&quot;&gt;
                    &lt;input type=&quot;submit&quot; value=&quot;삭제&quot; class=&quot;btn-delete btn&quot;&gt;
                &lt;/form&gt;
            &lt;/div&gt;
        &lt;/li&gt;
    &lt;% }) %&gt; &lt;!-- 여기 까지 --&gt;
&lt;/ul&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/8b3a6a7b-d4a4-4619-9b7f-4a45858eb86e/image.png">


<h4 id="4-2-3-addejs">4-2-3. add.ejs</h4>
<pre><code class="language-html">&lt;a href=&quot;/allPosts&quot;&gt;&amp;larr; 뒤로&lt;/a&gt;
&lt;div class=&quot;admin-title&quot;&gt;
    &lt;h2&gt;&lt;%= locals.title %&gt;&lt;/h2&gt;
&lt;/div&gt;

&lt;form action=&quot;/add&quot; method=&quot;POST&quot;&gt;
    &lt;label for=&quot;title&quot;&gt;&lt;b&gt;제목&lt;/b&gt;&lt;/label&gt;
    &lt;input type=&quot;text&quot; placeholder=&quot;게시물 제목&quot; name=&quot;title&quot; id=&quot;title&quot;&gt;

    &lt;label for=&quot;body&quot;&gt;&lt;b&gt;내용&lt;/b&gt;&lt;/label&gt;
    &lt;textarea placeholder=&quot;게시물 내용&quot; name=&quot;body&quot; id=&quot;body&quot; cols=&quot;50&quot; rows=&quot;10&quot;&gt;&lt;/textarea&gt;

    &lt;input type=&quot;submit&quot; value=&quot;등록&quot; class=&quot;btn&quot;&gt;
&lt;/form&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/d25b019f-3cba-41f3-912d-a65423264532/image.png">

<h4 id="4-2-4-editejs">4-2-4. edit.ejs</h4>
<pre><code class="language-html">&lt;a href=&quot;/allPosts&quot;&gt;&amp;larr; 뒤로&lt;/a&gt;
&lt;div class=&quot;admin-title&quot;&gt;
    &lt;h2&gt;&lt;%= locals.title %&gt;&lt;/h2&gt;
    &lt;form action=&quot;/delete/&lt;%=data._id%&gt;?_method=DELETE&quot; method=&quot;POST&quot;&gt;
        &lt;input type=&quot;submit&quot; value=&quot;삭제&quot; class=&quot;btn btn-delete&quot;&gt;
    &lt;/form&gt;
&lt;/div&gt;

&lt;form action=&quot;/edit/&lt;%= data._id %&gt;?_method=PUT&quot; method=&quot;POST&quot;&gt;
    &lt;label for=&quot;title&quot;&gt;&lt;b&gt;제목&lt;/b&gt;&lt;/label&gt;
    &lt;input type=&quot;text&quot; name=&quot;title&quot; id=&quot;title&quot; value=&quot;&lt;%=data.title%&gt;&quot;&gt;

    &lt;label for=&quot;body&quot;&gt;&lt;b&gt;내용&lt;/b&gt;&lt;/label&gt;
    &lt;textarea name=&quot;body&quot; id=&quot;body&quot; cols=&quot;50&quot; rows=&quot;10&quot;&gt;
        &lt;%=data.body %&gt;
    &lt;/textarea&gt;

    &lt;input type=&quot;submit&quot; value=&quot;수정&quot; class=&quot;btn&quot;&gt;
&lt;/form&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/d3e08c2b-4e57-43fa-b143-64b4545f8be2/image.png">

<h4 id="4-2-5-aboutejs">4-2-5. about.ejs</h4>
<pre><code class="language-html">&lt;h1&gt;About&lt;/h1&gt;

&lt;b&gt;이 블로그는 Node.JS 학습을 위해 제작되었습니다.&lt;/b&gt;</code></pre>
<img width=700 height=500 src="https://velog.velcdn.com/images/c_mungi/post/84254107-07af-45eb-9c4c-8815926e249d/image.png">]]></description>
        </item>
        <item>
            <title><![CDATA[Node.JS : 블로그 구현하기_DB연결]]></title>
            <link>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1</link>
            <guid>https://velog.io/@c_mungi/Node.JS-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1</guid>
            <pubDate>Thu, 05 Jun 2025 16:53:08 GMT</pubDate>
            <description><![CDATA[<h2 id="1-시작">1. 시작</h2>
<p>이전 포스트를 끝으로 약 2개월정도 시간이 흘렀다.
2개월동안은 재취업 준비겸 자격증 공부를 주로 했다.(정처기 실기 1회.. 극악..)</p>
<p>그래도 Node.js를 더 까먹기 전에 빠르게 마무리 하고자 블로그를 구현하는 프로젝트를 시작하고자 한다.</p>
<p>다만, Node.JS와 Express에 대한 지식이 낮은 관계로 인프런의 <a href="https://www.inflearn.com/course/do-it-nodejs-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%9E%85%EB%AC%B8">Do it! Node.js 프로그래밍 입문</a>를 보면서 구현하였고, 그 과정을 블로그에 녹여보고자 한다.</p>
<hr>
<h2 id="2-블로그-구현-내용">2. 블로그 구현 내용</h2>
<p>아래와 같은 기능들을 구현하고자 한다.</p>
<ul>
<li>메인 화면 (비 로그인)<ul>
<li>최신 작성 게시글 리스트 조회</li>
<li>게시글 상세 조회</li>
</ul>
</li>
<li>로그인 화면<ul>
<li>아이디, 비밀번호를 통한 관리자 로그인 기능</li>
</ul>
</li>
<li>관리자(User) 화면<ul>
<li>게시글(Post) 작성 </li>
<li>게시글(Post) 수정</li>
<li>게시글(Post) 삭제</li>
</ul>
</li>
</ul>
<p><code>관리자 계정에 대한 등록 기능은 필요하지 않아 임시로 로직을 작성해서 등록했다.</code></p>
<hr>
<h2 id="3-사용-기술-스택">3. 사용 기술 스택</h2>
<ul>
<li>Back-end<ul>
<li>Node.js</li>
</ul>
</li>
<li>F/W<ul>
<li>Express</li>
</ul>
</li>
<li>Template<ul>
<li>EJS : HTML에 JS문법이 삽입 가능한 템플릿 엔진</li>
</ul>
</li>
<li>Library <ul>
<li>dotenv : .env의 환경변수를 process.env로 로드 가능한 라이브러리</li>
<li>express-async-handler : 비동기식 요청 핸들러를 처리하기 위한 미들웨어</li>
<li>express-ejs-layouts : EJS 템플릿에서 레이아웃 기능을 지원</li>
<li>method-override : form에서 PUT과 DELETE 메소드를 사용할 수 있게하는 라이브러리</li>
<li>mongoose : Mongo DB와 연동하는 ODM 라이브러리</li>
<li>bcrypt : 비밀번호 암호화 및 해시 비교 라이브러리</li>
<li>cookie-parser : Node.js 환경에서 쿠키를 쉽게 사용할 수 있도록 도와주는 미들웨어</li>
<li>jsonwebtoken : JWT</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-프로젝트-구성">4. 프로젝트 구성</h2>
<img width=500 src="https://velog.velcdn.com/images/c_mungi/post/f797319b-cb5f-45e1-b8a0-b83bcf46c6fa/image.png">


<ul>
<li>config : DB 연동 설정 관리</li>
<li>controllers : Admin, Post 도메인 Controller 레이어 관리</li>
<li>models : DB Schema 관리</li>
<li>public : css, image 등 정적 파일 관리</li>
<li>routes : Admin, Post 도메인의 Route 레이어 관리</li>
<li>service : Admin, Post 도메인의 Service 레이어 관리</li>
<li>utils : Util 로직 관리</li>
<li>views : ejs 파일 관리<ul>
<li>admin : Admin 페이지 관련 ejs 파일 관리</li>
<li>layouts : Admin, Post 페이지 관련 레이아웃 ejs 파일 관리</li>
<li>post : Post 페이지 관련 ejs 파일 관리</li>
</ul>
</li>
<li>.env : 환경 설정 파일</li>
<li>app.js : 엔트리 포인트 파일</li>
</ul>
<hr>
<h2 id="5-appjs-로직-작성">5. app.js 로직 작성</h2>
<p>app.js 파일을 생성한 후 다음과 같은 로직을 작성한다.
아래의 로직은 완성본의 일부분이므로 이후 db 및 layout 설정 등에 따라서 로직이 추가될 예정이다.</p>
<pre><code class="language-js">require(&quot;dotenv&quot;).config();
const express = require(&quot;express&quot;);

const app = express();
const port = process.env.PORT || 3000;

app.use(express.static(&quot;public&quot;));

app.listen(port, () =&gt; {
    console.log(`App listening on port ${port}`);
});</code></pre>
<hr>
<h2 id="6-db-연동--mongodb-">6. DB 연동 ( MongoDB )</h2>
<h3 id="6-1-env-환경-변수-설정">6-1. .env 환경 변수 설정</h3>
<p>기본적인 MongoDB 연동 방법은 이전 포스트에 적었으므로 링크만 살짝..</p>
<blockquote>
<p><a href="https://velog.io/@c_mungi/Node.js-DB-Connection">Node.js : DB Connection</a></p>
</blockquote>
<p>먼저 아래와 같이 MongoDB 플러그인에서 커넥션을 대상으로 우클릭을 한 뒤, <code>Copy Connection String</code>을 클릭하면 DB_URI가 복사한다.
<img width=500 src="https://velog.velcdn.com/images/c_mungi/post/acd82c09-ca58-44dd-83d8-79b45c8dd9e7/image.png"></p>
<p>.env파일을 생성하고 다음과 같이 설정한다. 
(본인의 경우 MONGODB_URI라는 Key로 설정했으나 Key 이름은 마음대로 정해도 상관없다.)</p>
<img width=800 src="https://velog.velcdn.com/images/c_mungi/post/13966721-e6dc-4681-a603-75f4c8f31a92/image.png">

<h3 id="6-2-db-config-설정">6-2. DB Config 설정</h3>
<p>config 폴더에 db.js 파일을 생성하고 다음과 같이 DB 연결 로직을 작성한다.</p>
<pre><code class="language-js">const mongoose = require(&quot;mongoose&quot;);
const asyncHandler = require(&quot;express-async-handler&quot;);
require(&quot;dotenv&quot;).config();

const connectDb = asyncHandler( async () =&gt;{
    const connect = await mongoose.connect(process.env.MONGODB_URI);
    console.log(`DB Connected: ${connect.connection.host}`);
})

module.exports = connectDb;
</code></pre>
<h3 id="6-3-appjs-로직-작성">6-3. app.js 로직 작성</h3>
<p>다음과 같이 db관련 로직을 추가 작성한다.</p>
<pre><code class="language-js">require(&quot;dotenv&quot;).config();
const express = require(&quot;express&quot;);
const connectDb = require(&quot;./config/db&quot;); // 추가

connectDb(); // 추가

const app = express();
const port = process.env.PORT || 3000;

app.use(express.static(&quot;public&quot;));

app.listen(port, () =&gt; {
    console.log(`App listening on port ${port}`);
});</code></pre>
<p>문제없이 연동된다면 어플리케이션을 실행했을 때 다음과 같은 로그가 출력된다.</p>
<img width=800 src="https://velog.velcdn.com/images/c_mungi/post/b03ff032-e295-4c2c-a779-73882e5763a2/image.png">

<h3 id="6-4-db-schema-작성">6-4. DB Schema 작성</h3>
<p>관리자(User) 도메인과 게시글(Post) 도메인에 대한 스키마를 <code>models</code> 폴더 내에 다음과 같이 작성한다.</p>
<h4 id="user-스키마">User 스키마</h4>
<pre><code class="language-js">const mongoose = require(&quot;mongoose&quot;);

const userSchema = new mongoose.Schema({
    username: {
        type: String,
        required: true,
        unique: true
    },
    password: {
        type: String,
        required: true
    }
});

module.exports = mongoose.model(&quot;User&quot;, userSchema);</code></pre>
<h4 id="post-스키마">Post 스키마</h4>
<pre><code class="language-js">const mongoose = require(&quot;mongoose&quot;);

const PostSchema = new mongoose.Schema({
    title: {
        type: String,
        required: true
    },
    body: {
        type: String,
        required: true
    },
    createdAt: {
        type: Date,
        default: Date.now()
    }
});

module.exports = mongoose.model(&quot;Post&quot;, PostSchema);</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Node.js : DB Connection]]></title>
            <link>https://velog.io/@c_mungi/Node.js-DB-Connection</link>
            <guid>https://velog.io/@c_mungi/Node.js-DB-Connection</guid>
            <pubDate>Sat, 05 Apr 2025 15:25:01 GMT</pubDate>
            <description><![CDATA[<h2 id="1-mongodb-connection">1. MongoDB Connection</h2>
<h3 id="1-1-httpsmongodbcom-에-접속해-회원가입로그인을-하고-clusters를-생성한다">1-1. <a href="https://mongodb.com/">https://mongodb.com/</a> 에 접속해 회원가입/로그인을 하고 Clusters를 생성한다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/12bf9a51-a1f9-4480-b0e2-91427fd8cc37/image.png" alt=""></p>
<h3 id="1-2-connect-버튼을-누르고-mongodb-for-vs-code을-누른다">1-2. Connect 버튼을 누르고 MongoDB for VS Code을 누른다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/6ce0ba1d-c2f4-4828-89b7-fd4e4e5d6c27/image.png" alt=""></p>
<h3 id="1-3-connect-to-your-mongodb-deployment의-내용을-복사한다">1-3. Connect to your MongoDB deployment의 내용을 복사한다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/85a70196-9d0e-4edb-ba45-51d6fa7d01c5/image.png" alt=""></p>
<h3 id="1-4-vs-code로-돌아와-mongodb확장-프로그램을-설치한다">1-4. VS Code로 돌아와 MongoDB확장 프로그램을 설치한다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/7c3361a0-4b32-422f-baa7-dc1c38350e1e/image.png" alt=""></p>
<h3 id="1-5-mongodb를-선택하고-connection-string의-connect를-클릭한다">1-5. MongoDB를 선택하고 Connection String의 Connect를 클릭한다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/169329af-732a-4fbd-b8d0-18b13dc9d5cf/image.png" alt=""></p>
<h3 id="1-6-vs-code의-위에-입력폼이-나타나는데-3에서-복사한-내용을-붙여넣는다">1-6. VS Code의 위에 입력폼이 나타나는데 3에서 복사한 내용을 붙여넣는다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/1ba9f5d3-660b-4dd1-92bf-63ae80daba2b/image.png" alt=""></p>
<h3 id="1-7-붙여넣은-내용-중에-db_password-라는-문장을-1번에서-생성한-clusters의-패스워드를-입력하고-enter키를-누른다-문제없이-성공했다면-db-connection이-추가되어-있을-것이다">1-7. 붙여넣은 내용 중에 <db_password> 라는 문장을 1번에서 생성한 Clusters의 패스워드를 입력하고 Enter키를 누른다. 문제없이 성공했다면 DB Connection이 추가되어 있을 것이다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/19e41f98-18ec-42f7-9776-e9fd22146ebe/image.png" alt=""></p>
<hr>
<h2 id="2-mysql-connection">2. MySQL Connection</h2>
<h3 id="2-1-mysql-connection의-경우-npm으로-설치해야한다">2-1. mySQL Connection의 경우 npm으로 설치해야한다.</h3>
<pre><code>npm install --save mysql2</code></pre><h3 id="2-2-설치가-완료되면-packagejson의-dependencies항목에-mysql2가-추가된다">2-2. 설치가 완료되면 package.json의 dependencies항목에 mysql2가 추가된다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/27e6ef87-2e5c-4f37-b7e2-ae85e1b59322/image.png" alt=""></p>
<h3 id="2-3-mysql-connection할-코드를-작성하고-모듈화한다">2-3. MySQL Connection할 코드를 작성하고 모듈화한다.</h3>
<pre><code class="language-js">const mysql = require(&#39;mysql2/promise&#39;);
require(&#39;dotenv&#39;).config();

// async/await 으로 DB 연결을 비동기 처리
const dbConnect = async () =&gt; {
  try {
    const connection = await mysql.createConnection({
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: process.env.DB_NAME,
    });
    console.log(&quot;MySQL Connected.&quot;);
    return connection;
  } catch (err) {
    console.log(err);
  }
};

module.exports = dbConnect;</code></pre>
<p>.env</p>
<pre><code class="language-properties">DB_HOST=localhost
DB_USER=root
DB_PASS=password
DB_NAME=schema</code></pre>
<h3 id="2-4-appjs-또는-indexjs에-모듈화한-dbconnection을-인스턴스-생성해-함수를-호출한다">2-4. app.js 또는 index.js에 모듈화한 dbConnection을 인스턴스 생성해 함수를 호출한다.</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/8c421eca-304d-43a9-afaf-99fb0f3698e3/image.png" alt=""></p>
<h3 id="2-5-결과-확인">2-5. 결과 확인</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/26bf6c2d-0508-4069-aee0-e914992f17fb/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Node.js : Express]]></title>
            <link>https://velog.io/@c_mungi/Node.js-Express</link>
            <guid>https://velog.io/@c_mungi/Node.js-Express</guid>
            <pubDate>Fri, 04 Apr 2025 12:28:24 GMT</pubDate>
            <description><![CDATA[<h2 id="1-express란">1. Express란</h2>
<blockquote>
<p>Express는 웹 및 모바일 애플리케이션을 위한 일련의 강력한 기능을 제공하는 간결하고 유연한 Node.js 웹 애플리케이션 <strong>프레임워크</strong>이다. </p>
</blockquote>
<p>Node.js는 표준 웹서버 프레임워크로 불려질 만큼 많은 곳에서 사용하고 있다.</p>
<hr>
<h2 id="2-nodejs와-express의-관계">2. Node.js와 Express의 관계</h2>
<blockquote>
<p>Node.js는 Chrome의 V8엔진을 이용하여 JavaScript로 브라우저가 아니라 서버를 구축하고, 서버에서 JavaScript가 작동되도록 해주는 런타임 환경 플랫폼이다. </p>
</blockquote>
<hr>
<h2 id="3-그래서-왜-사용하는가">3. 그래서 왜 사용하는가?</h2>
<ul>
<li>미들웨어 기반 프레임워크로, 기존의 Node.js의 HTTP 모듈보다 훨씬 간결하게 서버를 구축할 수 있다.</li>
<li>간단한 라우팅 시스템을 제공하여 URL 별로 적절한 처리를 쉽게 설정할 수 있다.</li>
<li>미들웨어 시스템을 통해 요청과 응답을 중간에 가공할 수 있다. </li>
<li>다양한 서드파티 미들웨어와 함께 사용할 수 있으며, JWT 인증, 세션 관리, 보안, CORS, 파일 업로드 등을 쉽게 구현할 수 있다.</li>
<li>RESTful API 개발에 최적화되어 있어, 프론트엔드(React, Vue, Angular)와 연동하여 백엔드 API 서버를 쉽게 구축할 수 있다.</li>
</ul>
<hr>
<h2 id="4-express-사용하기">4. Express 사용하기</h2>
<h3 id="4-1-express-module-설치">4-1. Express module 설치</h3>
<pre><code>npm i express</code></pre><p>console에 위의 명령어를 실행하면 node_modules이 생성된다.
<img src="https://velog.velcdn.com/images/c_mungi/post/b14486e2-a69e-4d8c-a8f6-dce6f721bc73/image.png" alt=""></p>
<p>또한, pakage.json의 dependencies항목에 express가 추가된다.
<img src="https://velog.velcdn.com/images/c_mungi/post/9d250376-0ed8-4566-b349-bdd3ea751ff9/image.png" alt=""></p>
<h3 id="4-2-기본-사용-방법">4-2. 기본 사용 방법</h3>
<pre><code class="language-js">const express = require(&quot;express&quot;); // require모듈로 express모듈을 import한다.

const app = express(); // express instance 생성

app.use(express.json()); // json type의 body를 파싱하도록 미들웨어 설정
app.use(express.urlencoded({extended:true})); // HTML 폼에서 전송된 데이터를 해석하는 미들웨어 설정

app.get(&quot;/&quot;, (req, res)=&gt;{
    res.send(&#39;Hello, Node!&#39;);
});

app.listen(3000, () =&gt; {
    console.log(&#39;서버 실행 중&#39;);
});</code></pre>
<h3 id="4-3-실행-결과">4-3. 실행 결과</h3>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/1fd1d082-6f49-4e3a-bfea-dd41f89f2308/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Batch 공부 (feat. 숙박 정보)_vol.2]]></title>
            <link>https://velog.io/@c_mungi/Spring-Batch-%EA%B3%B5%EB%B6%80-feat.-%EC%88%99%EB%B0%95-%EC%A0%95%EB%B3%B4vol.2</link>
            <guid>https://velog.io/@c_mungi/Spring-Batch-%EA%B3%B5%EB%B6%80-feat.-%EC%88%99%EB%B0%95-%EC%A0%95%EB%B3%B4vol.2</guid>
            <pubDate>Sat, 29 Mar 2025 14:32:10 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@c_mungi/Spring-Batch-%EA%B3%B5%EB%B6%80-feat.-%EC%88%99%EB%B0%95-%EC%A0%95%EB%B3%B4vol.1">이전 포스트</a> 에서는 Spring Batch에 대해 정리해봤다.</p>
<p>이번 포스트는 Open API를 통해 숙박 정보를 Json으로 받아 간략하게 가공한 뒤, DB에 저장하는 Batch 실습을 정리해보고자 한다.</p>
<p>사용한 숙박 정보API는 아래 공공API를 사용했다.
<a href="https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15101578">https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15101578</a></p>
<p>신청만 하면 바로 승인되니 기다릴 필요도 없고 가이드도 잘 나와있어서 사용하기 편했다.(다만,, response의 field값이 없는 경우가 좀 많다..)</p>
<h2 id="1-개발-환경">1. 개발 환경</h2>
<blockquote>
<ul>
<li>Language</li>
</ul>
</blockquote>
<ul>
<li>Java 17<ul>
<li>Framework</li>
</ul>
</li>
<li>Spring Boot 3.4.3</li>
<li>Spring Batch 5.2.0<ul>
<li>Library</li>
</ul>
</li>
<li>Spring Data JPA</li>
<li>Lombok<ul>
<li>DataBase</li>
</ul>
</li>
<li>MySQL 8.0.37<ul>
<li>OS &amp; IDE</li>
</ul>
</li>
<li>Windows 11</li>
<li>IntelliJ 2024.3.5</li>
</ul>
<hr>
<h2 id="2-개발-기간">2. 개발 기간</h2>
<ul>
<li>2025.03.12 - 2025-03-27 (2주)
원래 목표는 3일이었다.. (1일 공부, 1일 구현 및 테스트, 1일 리팩토링)
하지만 이력서 준비와 개인사로 인해 2주나 걸리고 말았다..🥲</li>
</ul>
<hr>
<h2 id="3-다중-db-연결">3. 다중 DB 연결</h2>
<pre><code>spring.batch.job.enabled=false
spring.batch.jdbc.initialize-schema=never
spring.batch.jdbc.schema=classpath:org/springframework/batch/core/schema-mysql.sql

spring.datasource-meta.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource-meta.jdbc-url=jdbc:mysql://localhost:3307/meta_batch
spring.datasource-meta.username=DB유저
spring.datasource-meta.password=패스워드

spring.datasource-data.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource-data.jdbc-url=jdbc:mysql://localhost:3307/stay_info
spring.datasource-data.username=DB유저
spring.datasource-data.password=패스워드</code></pre><table>
<thead>
<tr>
<th align="left">필드</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">spring.batch.job.enabled</td>
<td align="left">애플리케이션 실행 시 batch를 시작 유무</td>
</tr>
<tr>
<td align="left"></td>
<td align="left">true : 시작</td>
</tr>
<tr>
<td align="left"></td>
<td align="left">false : 시작 하지 않음</td>
</tr>
<tr>
<td align="left">spring.batch.jdbc.initialize-schema</td>
<td align="left">스키마 자동생성</td>
</tr>
<tr>
<td align="left"></td>
<td align="left">ALWAYS: 스크립트 항상 생성</td>
</tr>
<tr>
<td align="left"></td>
<td align="left">EMBEDDED: 내장 DB일 때만 실행되며 스키마가 자동 생성됨 (Default)</td>
</tr>
<tr>
<td align="left"></td>
<td align="left">NEVER: 스크립트를 항상 실행하지 않음</td>
</tr>
<tr>
<td align="left">spring.batch.jdbc.schema</td>
<td align="left">Meta 테이블 생성 SQL 지정</td>
</tr>
<tr>
<td align="left"></td>
<td align="left">org/springframework/batch/core/schema-mysql.sql 를 지정하면 됨</td>
</tr>
<tr>
<td align="left">spring.datasource-meta ...</td>
<td align="left">Meta 테이블을 작성할 Schema 지정</td>
</tr>
<tr>
<td align="left">spring.datasource-data ...</td>
<td align="left">Data 테이블을 작성할 Schema 지정</td>
</tr>
</tbody></table>
<h3 id="3-1-db-config">3-1. DB Config</h3>
<h4 id="3-1-1-meta-db-config">3-1-1. Meta DB Config</h4>
<pre><code class="language-java">@Configuration
public class MetaDBConfig {

    @Primary
    @Bean
    @ConfigurationProperties(prefix = &quot;spring.datasource-meta&quot;)
    public DataSource metaDbSource(){
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean
    public PlatformTransactionManager metaTransactionManager(){
        return new DataSourceTransactionManager(metaDbSource());
    }
}</code></pre>
<p>스프링 부트에서 2개 이상 DB를 연결하려면 Config클래스 작성이 필수적이다. 
그리고 충돌을 방지하기 위해 다중 DB를 연결하게 되면 어느 DB를 우선 해야하는지 정해야한다.</p>
<p>그렇지 않으면 어느 DB에 데이터를 저장하거나 읽거나 하는 등 작업을 할 수 없기에 에러가 발생하게 된다.</p>
<p>우선 순위를 지정하기 위해서는 @Primary 어노테이션을 사용하면 된다.</p>
<p>@ConfigurationProperties는 prefix로 지정해둔 경로의 값을 불러오는 어노테이션이다.
여기선 위에서 작성한 application.properties의 spring.datasource-meta 로 시작하는 변수의 값들을 불러오게 된다.</p>
<p>읽어온 값들을 DataSource로 반환하면 Meta 테이블의 스키마에 대한 데이터소스가 Bean으로 등록된다.</p>
<p>PlatformTransactionManager도 동일하게 충돌을 방지하기 위해 @Primary 어노테이션을 붙히고
metaDbSource를 매개변수로 지정해 생성해준다.</p>
<hr>
<h4 id="3-1-2-data-db-config">3-1-2. Data DB Config</h4>
<pre><code class="language-java">
@Configuration
@EnableJpaRepositories(
    basePackages = &quot;com.study.stay.repository&quot;,
    entityManagerFactoryRef = &quot;dataEntityManager&quot;,
    transactionManagerRef = &quot;dataTransactionManager&quot;
)
public class StayDBConfig {

    @Bean
    @ConfigurationProperties(prefix = &quot;spring.datasource-data&quot;)
    public DataSource dataDBSource(){
        return DataSourceBuilder.create().build();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean dataEntityManager(){
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataDBSource());
        em.setPackagesToScan(new String[]{&quot;com.study.stay.entity&quot;});
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());

        HashMap&lt;String, Object&gt; properties = new HashMap&lt;&gt;();
        properties.put(&quot;hibernate.dialect&quot;, &quot;org.hibernate.dialect.MySQLDialect&quot;);
        properties.put(&quot;hibernate.hbm2ddl.auto&quot;, &quot;update&quot;);
        properties.put(&quot;hibernate.show_sql&quot;, &quot;true&quot;);
        em.setJpaPropertyMap(properties);
        return em;
    }

    @Bean
    public PlatformTransactionManager dataTransactionManager(){
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(dataEntityManager().getObject());
        return transactionManager;
    }
}</code></pre>
<p>Meta DB Config와 달리 설정해야할 내용이 조금 더 많다.
먼저 실제 Data가 저장되거나 하기 때문에 @EnableJpaRepositories 어노테이션을 붙히고 repository의 경로와 참조할 EntityManager와 TransactionManager의 메서드명을 지정한다.
해당 메서드 명은 Bean으로 만든 메서드이다.</p>
<p>DataSource는 MetaDBConfig와 동일하게 작성한다. 다만 prefix만 맞게끔 수정하면 된다.</p>
<p>LocalContainerEntityManagerFactoryBean은 위의 @EnableJpaRepositories에 작성했던 EntityManager와 동일한 이름의 메서드를 만들면된다.</p>
<pre><code class="language-java">em.setPackagesToScan(new String[]{&quot;com.study.stay.entity&quot;});</code></pre>
<p>위의 내용은 해당 경로의 Entity Class들을 스캔한다.</p>
<p>아래 properteis의 경우, 다중 DB로 연결하게 되면 변수 설정으로는 어떠한 DB에 대한 설정인지 알 수 없기에 이 Config에 등록한다.</p>
<p>PlatformTransactionManager의 경우 JpaTransactionManager를 초기화 해서 반환해준다.</p>
<hr>
<h3 id="4-batch-작성">4. Batch 작성</h3>
<p>(※ Batch 작성에 앞서 Entity의 내용들은 따로 적지 않습니다. 보고 싶으신 분들은 GitHub의 Repository를 봐주시면 감사하겠습니다. <a href="https://github.com/Mungi-Cheon/Batch">GitHub</a> )</p>
<p>JobBuilder를 통해 <code>stayInfoJob</code> 이름을 가지며 <code>stayInfoStep</code>이라는 Step을 실행하는 형태의 Job을 생성한다.</p>
<p><code>incrementer</code>는 작업이 여러번 실행될 때마다 고유한 실행 ID를 증가시키기 위해 사용한다. </p>
<pre><code class="language-java">    @Bean
    public Job stayInfoJob(JobRepository jobRepository, Step stayInfoStep) {
        return new JobBuilder(&quot;stayInfoJob&quot;, jobRepository)
            .incrementer(new RunIdIncrementer())
            .start(stayInfoStep)
            .build();
    }</code></pre>
<pre><code class="language-java">    @Bean
    public Step stayInfoStep(
        JobRepository jobRepository,
        PlatformTransactionManager transactionManager,
        StepExecutionListener stayInfoStepListener,
        RepositoryItemWriter&lt;StayInfo&gt; stayInfoWriter
    ) {
        return new StepBuilder(&quot;stayInfoStep&quot;, jobRepository) // Step 이름 지정
            .&lt;OpenApiResponse, StayInfo&gt;chunk(10, transactionManager) // Chunk 기반 10개씩 나눠서 실행
            .reader(apiResponseReader()) // reader 지정
            .processor(stayInfoProcessor()) // processor 지정
            .writer(stayInfoWriter) // writer 지정
            .listener(stayInfoStepListener) // listener 지정 필요없으면 안해도 됨.
            .build();
    }</code></pre>
<p>Step의 read - processor - writer단계이다. 아래 Processor와 Writer와 함께 대부분 익명함수를 사용한다고 한다.
로직이 길거나 복잡하면 클래스로 만들어서 사용하면 좋다. 자세한 코드 내용은 후술하겠다.</p>
<pre><code class="language-java">    @Bean
    public ItemReader&lt;OpenApiResponse&gt; apiResponseReader() {
        return new StayInfoReader();
    }</code></pre>
<pre><code class="language-java">    @Bean
    public ItemProcessor&lt;OpenApiResponse, StayInfo&gt; stayInfoProcessor() {
        return new StayInfoProcessor();
    }</code></pre>
<pre><code class="language-java">    @Bean
    public RepositoryItemWriter&lt;StayInfo&gt; stayInfoWriter() {
        return new RepositoryItemWriterBuilder&lt;StayInfo&gt;()
            .repository(stayInfoRepository)
            .methodName(&quot;save&quot;)
            .build();
    }</code></pre>
<pre><code class="language-java">    @Bean
    public StayInfoStepListener stayInfoStepListener() {
        return new StayInfoStepListener(); // Open Api의 request에서 pageNo 필드 값을 증가시키기 위해 지정
    }</code></pre>
<h3 id="4-1-reader">4-1. Reader</h3>
<pre><code class="language-java">@Component
@Slf4j
public class StayInfoReader implements ItemReader&lt;OpenApiResponse&gt; {

    @Value(&quot;${api.uri}&quot;)
    private String apiUri;
    @Value(&quot;${api.auth-key}&quot;)
    private String authKey;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private RestTemplate restTemplate;
    private static final String KEY_PAGE_NO = &quot;pageNo&quot;;
    private int pageNo = 1;

  /*
   * Listener과 관련된 내용으로, pageNo값을 증가 시키기위해 ExceutionContext를 사용했다. 
   * context에 Key가 존재하면 해당 Value를 pageNo에 대입해 사용한다.
   */
    @BeforeStep
    public void beforeStep(StepExecution stepExecution){
        ExecutionContext context = stepExecution.getExecutionContext();

        if(context.containsKey(KEY_PAGE_NO)){
            pageNo = context.getInt(KEY_PAGE_NO);
        }else{
            context.put(KEY_PAGE_NO, pageNo);
        }
    }

    @Override
    public OpenApiResponse read() throws Exception{
        return getAllStayInfo();
    }

   /*
    *아래 내용은 OpenApi를 통해 Response를 읽어오는 내용이다.
    */
    public OpenApiResponse getAllStayInfo(){

        log.info(&quot;stay info read start.&quot;);

        try {
            URI uri = new URI(apiUri + &quot;?serviceKey=&quot; + authKey
                + &quot;&amp;numOfRows=&quot; + 1000 + &quot;&amp;pageNo=&quot; + pageNo + &quot;&amp;MobileOS=AND&amp;MobileApp=TestApp&amp;_type=json&quot;);

            ResponseEntity&lt;String&gt; responseEntity = restTemplate.getForEntity(uri, String.class);

            if(responseEntity.getStatusCode() == HttpStatus.OK){
                log.info(&quot;stay info read complete.&quot;);
                return objectMapper.readValue(responseEntity.getBody(), OpenApiResponse.class);
            }
        } catch (URISyntaxException | JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        return new OpenApiResponse();
    }
}</code></pre>
<h3 id="4-2-processor">4-2. Processor</h3>
<p>Processor는 필요없으면 안해도 상관없지만 Open API의 Response에 사용하지 않을 값들을 삭제하는 가공 처리를 하고 Entity의 형태로 반환했다.</p>
<pre><code class="language-java">@Slf4j
public class StayInfoProcessor implements ItemProcessor&lt;OpenApiResponse, StayInfo&gt; {

    private Iterator&lt;StayInfo&gt; stayInfoIterator;

    @Override
    public StayInfo process(OpenApiResponse response) {
        log.info(&quot;process start.&quot;);
        List&lt;Item&gt; itemList = response.getResponse().body().items().item();

        stayInfoIterator = itemList.stream()
            .map(item -&gt; StayInfo.from(
                item.title(),
                item.addr1(),
                item.addr2(),
                item.areacode(),
                item.sigungucode(),
                item.firstimage(),
                item.firstimage2(),
                item.mapy(),
                item.mapx(),
                item.mlevel(),
                item.tel(),
                item.likeCount(),
                item.rating()
            ))
            .iterator();

        log.info(&quot;process complete.&quot;);

         if( stayInfoIterator.hasNext() ){
          return stayInfoIterator.next();
        }
        return null;
    }
}</code></pre>
<h3 id="4-3-listener">4-3 Listener</h3>
<p>StepExecution의 경우 Step간의 정보 공유는 되지 않지만 아래 내용은 같은 Step 내부에서 동작하기에 pageNo를 증가 시킬 수 있다.</p>
<pre><code class="language-java">public class StayInfoStepListener implements StepExecutionListener {

    @Override
    public void beforeStep(StepExecution stepExecution) {
    }

    @Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        ExecutionContext executionContext = stepExecution.getExecutionContext();
        int pageNo = executionContext.getInt(&quot;pageNo&quot;, 1);
        executionContext.put(&quot;pageNo&quot;, pageNo + 1);
        return ExitStatus.COMPLETED;
    }
}</code></pre>
<hr>
<h3 id="5-scheduler">5. Scheduler</h3>
<p>아래는 스케줄러로 1분마다 Batch를 실행시키는 형태이다.
@Scheduler 외에도 quartz scheduler로 Batch를 자동 실행할 수 있다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class BatchScheduler {

    private final JobLauncher jobLauncher;
    private final Job stayInfoJob;

    @Scheduled(cron = &quot;0 0/1 * * * ?&quot;)
    public void runJob() {
        try {
            log.info(&quot;Batch job started.&quot;);

            SimpleDateFormat dateFormat = new SimpleDateFormat(&quot;yyyy-MM-dd HH-mm-ss&quot;);
            String date = dateFormat.format(new Date());

            JobParameters jobParameters = new JobParametersBuilder()
                .addString(&quot;date&quot;, date)
                .toJobParameters();

            log.info(&quot;Batch job is start.&quot;);
            jobLauncher.run(stayInfoJob, jobParameters);

            log.info(&quot;Batch job completed.&quot;);
        } catch (Exception e) {
            log.error(&quot;Batch job failed.&quot;, e);
        }
    }
}</code></pre>
<h2 id="6-실행-결과">6. 실행 결과</h2>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/0bc79070-e9d0-4f36-bcd2-77c6aa326c5f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/71a0e10c-bc97-425b-8323-dd981bd0cca8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/2047486e-46e9-40b1-9be2-bf7a2c30a236/image.png" alt=""></p>
<h2 id="7-회고">7. 회고</h2>
<p>사용만 하던 Batch를 간략하게나마 정리할 수 있는 시간이 되었다.
FastCampus에서 OpenAPI를 활용했을 때 Batch를 사용할까 라는 생각을 하긴 했었지만
담당 업무부터 팀장 대리역할을 하느라 Batch라는 생각이 바로 사라졌던 기억이 좀 아쉽다.</p>
<p>조만간 Batch 전략들에 대해 조금 더 알아보고자 한다.
수많은 데이터들을 일괄처리 하는 만큼 분명 성능 저하 등 개선이 필요한 이슈들이 있을텐데
어떻게 개선하면 좋을지 고민을 해보면 실무에서도 좋은 결과를 낼 수 있지 않을까 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Batch 공부 (feat. 숙박 정보)_vol.1]]></title>
            <link>https://velog.io/@c_mungi/Spring-Batch-%EA%B3%B5%EB%B6%80-feat.-%EC%88%99%EB%B0%95-%EC%A0%95%EB%B3%B4vol.1</link>
            <guid>https://velog.io/@c_mungi/Spring-Batch-%EA%B3%B5%EB%B6%80-feat.-%EC%88%99%EB%B0%95-%EC%A0%95%EB%B3%B4vol.1</guid>
            <pubDate>Sat, 29 Mar 2025 07:27:22 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가기에-앞서">들어가기에 앞서..</h3>
<blockquote>
<p>실무에서 특정 테이블에 있는 레코드를 읽어와 재가공해 이력 테이블로 삽입하는 업무를 맡아 Batch를 활용해 처리한 경험이 있습니다.
하지만 당시에는 빠르게 처리하는 것만 생각해서 Batch 구조와 원리에 대해 신경을 쓰지 않았습니다..
이제라도 Batch에 대해 공부하고 기록을 남기고자 합니다.</p>
</blockquote>
<hr>
<h2 id="1-spring-batch란">1. Spring Batch란</h2>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/9ccaf715-b3d3-44b2-857d-e7c1be161cc2/image.png" alt=""></p>
<p>위의 첨부내용처럼 Batch는 일괄 처리 라는 단어적 의미를 갖고 있다.
Spring Batch는 Batch Processing을 기반으로 로깅, 추적, 트랜잭션 관리, 작업 처리 통계, 작업 재시작, 건너뛰기, 리소스 관리를 포함하여 대량의 레코드를 처리하는 데 필수적인 재사용 가능한 기능을 제공한다. (출처 : <a href="https://spring.io/projects/spring-batch">https://spring.io/projects/spring-batch</a>)</p>
<hr>
<h2 id="2-spring-batch-구조">2. Spring Batch 구조</h2>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/bf104e93-f707-4f5b-941d-21e244e728b3/image.png" alt=""></p>
<p>Flow를 확인해보면 Job Scheduler에서 지정된 일정에 맞춰 JobLauncher를 실행한다. JobLauncher에서는 Job을 실행시키고 Job에서는 Step을 실행한다.
Step은 Tasklet과 Chunk기반으로 이루어져 있다. (위의 Flow는 Chunk기반이다.)
Read ~ Write의 처리가 완료된 이후 다음 Step이 있다면 작업을 시작하고 없는 경우 Batch 작업을 종료한다.</p>
<hr>
<h3 id="2-1-job">2-1. Job</h3>
<blockquote>
<p>배치 처리 과정을 담은 하나의 단위로 즉, 배치 작업 자체를 의미한다.
Job에는 1개 이상의 Step이 포함되어야 한다.
구현체로써는 SimpleJob과 FlowJob 등이 제공된다.</p>
</blockquote>
<h4 id="2-1-1-joblauncher">2-1-1. JobLauncher</h4>
<ul>
<li>Job과 JobParameter를 사용해 Job을 실행시키는 객체.</li>
<li>Batch 처리를 완료한 후 Client에 JobExecution을 리턴한다. (비동기도 가능)
<img src="https://velog.velcdn.com/images/c_mungi/post/1b58820c-3c66-4d8f-8340-f2e7e17d5794/image.png" alt=""></li>
</ul>
<h4 id="2-1-2-jobinstance">2-1-2. JobInstance</h4>
<ul>
<li>Job의 논리적 실행 단위 객체로 고유하게 식별 가능한 작업 실행.</li>
<li>Job Name, Job Parameter 조합으로 인스턴스를 생성</li>
<li>동일한 Job Name, Job Parameter의 조합은 단 1개의 인스턴스만 생성 가능</li>
<li>Job : JobInstance는 1:N의 관계</li>
</ul>
<h4 id="2-1-3-jobparameters">2-1-3. JobParameters</h4>
<ul>
<li>Job 실행 시 사용되는 Parameter</li>
<li>여러 개의 JobInstance를 구분하기 위한 용도이다.</li>
<li>JobParameters : JobInstance는 1:1의 관계</li>
</ul>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/40c91514-5688-4f20-801c-19a1bf14b951/image.png" alt=""></p>
<h4 id="2-1-4-jobexecution">2-1-4. JobExecution</h4>
<ul>
<li>JobInstance의 실행 시도 객체</li>
<li>JobInstance의 실행 상태, 시작 및 종료, 생성 시간 등 정보를 저장</li>
<li>ExitStatus가 FAILED일 경우 재실행 가능, COMPLITED인 경우 재실행 불가</li>
<li>JobInstance : JobExecution은 1:N의 관계</li>
</ul>
<h4 id="2-1-5-jobrepository">2-1-5. JobRepository</h4>
<ul>
<li>모든 Batch 정보를 가지고 있는 메타 데이터 저장소</li>
<li>Job이 실행되면 JobRepository에 JobExecution과 StepExecution을 생성한다.<ul>
<li>이후 JobRepository에서 Execution의 정보들을 DB에 저장 및 조회해 사용한다.</li>
</ul>
</li>
</ul>
<blockquote>
<p>Job에 대한 자세한 내용은 아래 블로그를 참조하면 좋을 듯합니다.
<a href="https://hoestory.tistory.com/40">https://hoestory.tistory.com/40</a></p>
</blockquote>
<hr>
<h3 id="2-2-execution-context">2-2. Execution Context</h3>
<ul>
<li>Framework에서 Execution의 상태를 저장하고 공유하는 객체</li>
<li>Job의 재시작 시 이미 처리된 데이터는 스킵하고 이후 수행할 때 상태 정보를 활용한다.</li>
<li>종류로는 JobExecution과 StepExcution이 있다.<ul>
<li>JobExecution : 각 Job에 생성되고 Job간의 공유는 불가능하다. 단, Job내부의 Step간의 공유는 가능하다.</li>
<li>StepExcution : 각 Step에 생성되고 Step간의 공유는 불가능하다.</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-3-stepexecution">2-3. StepExecution</h3>
<ul>
<li>Step의 실행 시도 객체</li>
<li>Step별로 StepExecution이 생성된다.</li>
<li>JobExecution에 저장되는 정보 외에 Read, Write, Commit 수 등의 정보가 저장된다.</li>
<li>Job이 재시작 되더라도 이미 성공한 Step은 스킵, 실패한 Step만 실행된다.</li>
<li>Step의 StepExecution이 하나라도 실패한다면 JobExecution은 실패 처리가 된다.</li>
</ul>
<hr>
<h3 id="2-4-step">2-4. Step</h3>
<ul>
<li>Batch Job을 구성하는 하나의 실행 단위</li>
<li>하나의 Job은 여러 개의 Step으로 구성될 수 있다.</li>
<li>Step은 Reader, Processor, Writer로 구성되어 있다.</li>
<li>StepExecution을 통해 실행 상태 및 결과를 관리한다.</li>
<li>구현체로는 다음과 같다.<ul>
<li>TaskletStep : 가장 기본이 되는 클래스. Tasklet 타입이 구현체를 제어</li>
<li>PartitionStep : 멀티 스레드 방식으로 Step을 여러 개 분리해서 실행</li>
<li>JobStep : Step 내에서 Job을 실행</li>
<li>FlowStep : Flow를 실행하는 Step</li>
</ul>
</li>
</ul>
<h3 id="2-4-1-tasklet">2-4-1 Tasklet</h3>
<blockquote>
<ul>
<li>Tasklet기반 Step은 Batch의 Step단계에서 단일 Task를 수행한다.</li>
</ul>
</blockquote>
<ul>
<li>TaskletStep에 의해 반복 실행되며 반환 값에 따라 실행 및 종료된다.<ul>
<li>RepeatStatus.CONTINUABLE : 반복</li>
<li>RepeatStatus.FINISHED : 종료</li>
</ul>
</li>
<li>단일 Task를 수행하기 때문에 Step과 Tasklet은 1:1의 관계를 가진다.</li>
</ul>
<p> <img src="https://velog.velcdn.com/images/c_mungi/post/d85fcd83-55b1-4b38-9019-9b5fe2035fca/image.png" alt=""></p>
<h3 id="2-4-2-chunk">2-4-2 Chunk</h3>
<blockquote>
<ul>
<li>Chunk기반 Step은 Batch의 Step단계에서 처리할 레코드(한 덩어리)를 n개씩 나눠 수행한다.</li>
</ul>
</blockquote>
<ul>
<li>ItemReader, ItemProcessor, ItemWriter를 사용하며, ChunkOrientedTasklet가 제공된다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/07eb7569-2a9a-4f6f-b9db-e141ca5f7fa3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/9c706047-954b-417f-885c-ddcff8ad0d8e/image.png" alt=""></p>
<h3 id="2-4-2-1-itemreader">2-4-2-1. ItemReader</h3>
<p>ItemReader는 Chunk기반에서 처리할 데이터를 읽어오는 Interface이다.
구현체로는 다음과 같다.</p>
<ul>
<li>JDBC<ul>
<li>Cursor : JdbcCursorItemReader</li>
<li>Paging : JdbcPagingItemReader</li>
</ul>
</li>
<li>JPA<ul>
<li>Cursor : JpaCursorItemReader</li>
<li>Paging : JpaPagingItemReader</li>
</ul>
</li>
<li>Txt, CSV 등 : FlatFileItemReader</li>
<li>Json : JsonItemReader</li>
<li>복수의 파일 조합 : MultiResourceItemReader</li>
<li>Thread : SynchronizeditemStreamReader</li>
<li>Custom : CustomItemReader</li>
</ul>
<p>위의 JDBC와 JAP에서 Cursor와 Paging은 데이터를 조회하는 방식이다.</p>
<blockquote>
<ul>
<li>Cursor</li>
</ul>
</blockquote>
<ul>
<li><p>현재 행에 Cursor를 유지하며 다음 데이터를 호출할 시, 다음 행으로 Cursor를 이동하며 데이터 반환을 이루는 형태의 Streaming I/O</p>
</li>
<li><p>DB Connection이 연결되면 Batch가 완료될 때까지 데이터를 읽어온다.</p>
<ul>
<li>따라서, DB Connection과 메모리가 충분해야 한다는 제한이 있다.</li>
</ul>
</li>
<li><p>Thread-safe하지 않다.</p>
<ul>
<li>Paging</li>
</ul>
</li>
<li><p>페이지 단위로 데이터를 조회하는 방식이며, Page Size만큼 한번에 데이터를 가지고 온다.</p>
</li>
<li><p>한 페이지를 읽을 때마다 DB Connection을 맺고 끊음</p>
</li>
<li><p>Cursor와 다르게 페이지 단위로 가져오기 때문에 메모리 사용량이 적다는 이점이 있다.</p>
</li>
<li><p>다만, 데이터 정렬이 되어 있어야 문제 없이 사용할 수 있다.</p>
</li>
<li><p>Thread-safe하다.</p>
<p>정리한 걸 보았을 때는 Cursor보다 Paging방법이 여러모로 장점이 많아보인다.
하지만, DB Connection과 메모리가 충분하다면 Paging보다 Cursor방식이 더 빠르기도 한다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/c_mungi/post/799463db-7854-47c9-a330-64811f23c85e/image.png" alt=""></p>
<h3 id="2-4-2-2-itemprocessor">2-4-2-2. ItemProcessor</h3>
<p>ItemProccessor는 Chunk 단위로 읽어온 데이터를 필요에 따라 가공을 하는 단계이다.
가공을 통해 원하는 타입으로 데이터를 변환하거나 필터링하여 전달 가능 하다.</p>
<h3 id="2-4-2-3-itemwriter">2-4-2-3. ItemWriter</h3>
<p>ItemWriter는 Batch에서 출력 작업을 담당한다.
Processor에서 가공된 데이터를 DB에 저장하거나, 원하는 포맷(TXT, CSV, JSON 등)에 맞춰 출력도 가능하다.</p>
<p>출력이 완료되면 Transaction이 종료되고 새로운 Chunk 단위의 프로세스로 이동해 작업을 이어한다.</p>
<p>구현체로는 다음과 같다.</p>
<blockquote>
<ul>
<li>TXT, CSV 등 파일 : FlatFileItemWriter</li>
</ul>
</blockquote>
<ul>
<li>Json : JsonFileItemWriter</li>
<li>JDBC : JdbcBatchItemWriter</li>
<li>JPA : JpaItemWriter</li>
<li>Custom : CustomItemWriter</li>
</ul>
<hr>
<h3 id="2-5-meta-table">2-5. Meta Table</h3>
<p> 위의 내용도 매우 중요하지만 이번 공부에 있어서 개인적으로 Meta Table이 Spring Batch에서 가장 중요하다고 생각했다.
 사실상 위의 내용들은 개념 정도로 알고 Batch 전략에 맞춰서 또는 해야할 작업에 맞춰서 활용하면 된다.
 하지만 작업을 실패하더라도 다시 실행한다던지, 이미 처리된 내용을 스킵하는 등 이러한 정보를 어디에 저장하고 어디서 가져오는지 이런 판단 요소들이 저장된 곳이 바로 Meta Table이기 때문이다.</p>
<h4 id="2-5-1-meta-table이란">2-5-1. Meta Table이란</h4>
<blockquote>
<p>스프링 배치 메타데이터 테이블은 Java에서 이를 나타내는 도메인 객체와 밀접하게 일치합니다. 예를 들어, JobInstance, JobExecution, JobParameters, StepExecution은 각각 BATCH_JOB_INSTANCE, BATCH_JOB_EXECION, BATCH_JOB_EXECION_PARAMS, BATCH_STEP_EXECION에 매핑됩니다. ExecutionContext는 BATCH_JOB_EXECONT와 BATCH_STEP_EXECONT에 매핑됩니다. JobRepository는 각 Java 객체를 올바른 테이블에 저장하고 저장하는 역할을 합니다. 이 부록에서는 메타데이터 테이블을 만들 때 내린 많은 설계 결정과 함께 자세히 설명합니다. 이 부록에서 설명하는 다양한 테이블 생성 문장을 볼 때, 사용되는 데이터 유형은 가능한 한 일반적이라는 점에 유의하세요. 스프링 배치는 많은 스키마를 예시로 제공합니다. 각 데이터베이스 공급업체가 데이터 유형을 처리하는 방식의 차이로 인해 모두 다양한 데이터 유형을 가지고 있습니다. 다음 이미지는 여섯 개의 테이블에 대한 ERD 모델과 그들 간의 관계를 보여줍니다 - <a href="https://docs.spring.io/spring-batch/reference/schema-appendix.html">Spring Boot 공식 문서</a> -</p>
</blockquote>
<h4 id="2-5-2-meta-table-erd">2-5-2. Meta Table ERD</h4>
<p> <img src="https://velog.velcdn.com/images/c_mungi/post/33eafe05-9549-40be-8475-17aa0924225f/image.png" alt=""></p>
<p><strong>BATCH_JOB_INSTANCE</strong></p>
<ul>
<li>특정 Job 인스턴스의 고유성 관리<ul>
<li>Job Name, Job Parameter 조합으로 인스턴스를 생성</li>
<li>동일한 Job Name, Job Parameter의 조합은 단 1개의 인스턴스만 생성 가능</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th align="left">JOB_INSTANCE_ID</th>
<th>PK</th>
</tr>
</thead>
<tbody><tr>
<td align="left">VERSION</td>
<td>해당 레코드에 update 될때마다 1씩 증가 (낙관적 락에 사용)</td>
</tr>
<tr>
<td align="left">JOB_NAME</td>
<td>실행된 잡의 이름</td>
</tr>
<tr>
<td align="left">JOB_KEY</td>
<td>잡 이름과 잡 파라미터의 해시 값으로, JobInstance를 고유하게 식별하는 데 사용되는 값</td>
</tr>
</tbody></table>
<hr>
<p><strong>BATCH_JOB_EXECUTION</strong></p>
<ul>
<li>Job의 실행 정보를 저장<ul>
<li>Job 생성 시간, 시작 시간, 종료 시간, 실행 상태, 실패 메세지 등을 관리</li>
<li>Job이 실행될 때마다 생성</li>
</ul>
</li>
</ul>
<table>
<thead>
<tr>
<th align="left">컬럼 이름</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">JOB_EXECUTION_ID</td>
<td align="left">PK</td>
</tr>
<tr>
<td align="left">VERSION</td>
<td align="left">해당 레코드에 update 될때마다 1씩 증가 (낙관적 락에 사용)</td>
</tr>
<tr>
<td align="left">JOB_INSTANCE_ID</td>
<td align="left">BATCH_JOB_INSTANCE 테이블을 참조하는 FK</td>
</tr>
<tr>
<td align="left">CREATE_TIME</td>
<td align="left">레코드가 생성된 시간</td>
</tr>
<tr>
<td align="left">START_TIME</td>
<td align="left">잡 실행이 시작된 시간</td>
</tr>
<tr>
<td align="left">END_TIME</td>
<td align="left">잡 실행이 완료된 시간</td>
</tr>
<tr>
<td align="left">STATUS</td>
<td align="left">잡 실행의 배치 상태</td>
</tr>
<tr>
<td align="left">EXIT_CODE</td>
<td align="left">잡 실행의 종료 코드</td>
</tr>
<tr>
<td align="left">EXIT_MESSAGE</td>
<td align="left">EXIT_CODE와 관련된 메시지나 Stack Trace</td>
</tr>
<tr>
<td align="left">LAST_UPDATED</td>
<td align="left">레코드가 마지막으로 갱신된 시간</td>
</tr>
</tbody></table>
<hr>
<p><strong>BATCH_STEP_EXECUTION</strong></p>
<ul>
<li>각 Step의 실행 정보를 저장</li>
</ul>
<table>
<thead>
<tr>
<th align="left">컬럼 이름</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">JOB_EXECUTION_ID</td>
<td align="left">PK</td>
</tr>
<tr>
<td align="left">VERSION</td>
<td align="left">해당 레코드에 update 될때마다 1씩 증가 (낙관적 락에 사용)</td>
</tr>
<tr>
<td align="left">JOB_INSTANCE_ID</td>
<td align="left">BATCH_JOB_INSTANCE 테이블을 참조하는 FK</td>
</tr>
<tr>
<td align="left">CREATE_TIME</td>
<td align="left">레코드가 생성된 시간</td>
</tr>
<tr>
<td align="left">START_TIME</td>
<td align="left">잡 실행이 시작된 시간</td>
</tr>
<tr>
<td align="left">END_TIME</td>
<td align="left">잡 실행이 완료된 시간</td>
</tr>
<tr>
<td align="left">STATUS</td>
<td align="left">잡 실행의 배치 상태</td>
</tr>
<tr>
<td align="left">EXIT_CODE</td>
<td align="left">잡 실행의 종료 코드</td>
</tr>
<tr>
<td align="left">EXIT_MESSAGE</td>
<td align="left">EXIT_CODE와 관련된 메시지나 Stack Trace</td>
</tr>
<tr>
<td align="left">LAST_UPDATED</td>
<td align="left">레코드가 마지막으로 갱신된 시간</td>
</tr>
</tbody></table>
<hr>
<p><strong>BATCH_JOB_EXECUTION_PARAMS</strong></p>
<ul>
<li>Job과 함께 실행되는 JobParameter의 정보를 관리</li>
</ul>
<table>
<thead>
<tr>
<th align="left">컬럼 이름</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">JOB_EXECUTION_ID</td>
<td align="left">PK</td>
</tr>
<tr>
<td align="left">TYPE_CODE</td>
<td align="left">파라미터 값의 타입을 나타내는 문자열</td>
</tr>
<tr>
<td align="left">KEY_NAME</td>
<td align="left">파라미터 이름</td>
</tr>
<tr>
<td align="left">STRING_VAL</td>
<td align="left">타입이 String인 경우 파라미터의 값</td>
</tr>
<tr>
<td align="left">DATE_VAL</td>
<td align="left">타입이 Date인 경우 파라미터의 값</td>
</tr>
<tr>
<td align="left">LONG_VAL</td>
<td align="left">타입이 Long인 경우 파라미터의 값</td>
</tr>
<tr>
<td align="left">DOUBLE_VAL</td>
<td align="left">타입이 Double인 경우 파라미터의 값</td>
</tr>
<tr>
<td align="left">IDENTIFYING</td>
<td align="left">파라미터가 식별되는지 여부를 나타내는 플래그</td>
</tr>
</tbody></table>
<hr>
<p><strong>BATCH_JOB_EXECUTION_CONTEXT</strong></p>
<ul>
<li>Job의 각 Step 실행 상태를 저장</li>
<li>모든 Step의 상태를 통합 관리</li>
</ul>
<table>
<thead>
<tr>
<th align="left">컬럼 이름</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">JOB_EXECUTION_ID</td>
<td align="left">PK</td>
</tr>
<tr>
<td align="left">SHORT_CONTEXT</td>
<td align="left">트림(trim) 처리된 SERIALIZED_CONTEXT</td>
</tr>
<tr>
<td align="left">SERIALIZED_CONTEXT</td>
<td align="left">직렬화된 ExecutionContext</td>
</tr>
</tbody></table>
<hr>
<p><strong>BATCH_STEP_EXECUTION_CONTEXT</strong></p>
<ul>
<li>각 Step의 실행 상태를 저장</li>
<li>스탭 실행의 메모리 상태를 직렬화하여 저장</li>
</ul>
<table>
<thead>
<tr>
<th align="left">컬럼 이름</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">STEP_EXECUTION_ID</td>
<td align="left">PK</td>
</tr>
<tr>
<td align="left">SHORT_CONTEXT</td>
<td align="left">트림 처리된 SERIALIZED_CONTEXT</td>
</tr>
<tr>
<td align="left">SERIALIZED_CONTEXT</td>
<td align="left">직렬화된 ExecutionContext</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] SAGA Pattern 적용하기]]></title>
            <link>https://velog.io/@c_mungi/MSA-SAGA-Pattern-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@c_mungi/MSA-SAGA-Pattern-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 01 Oct 2024 16:29:52 GMT</pubDate>
            <description><![CDATA[<h1 id="saga-pattern이란">SAGA Pattern이란</h1>
<p>SAGA Pattern을 설명하기 이전에 기존 모놀리식 아키텍처에서는 트랜잭션을 어떻게 처리를 했는지 또 마이크로 서비스 아키텍처에서 널리 사용되는 패턴 중 하나인 2PC에 대해 짚고 넘어가겠습니다.</p>
<h2 id="1-모놀리식-아키텍처에서의-트랜잭션-처리">1. 모놀리식 아키텍처에서의 트랜잭션 처리</h2>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/cf9c0d18-9343-4ca1-be40-009899b1cef3/image.png">

<blockquote>
<p>이미지 출처 : <a href="https://blog.bitsrc.io/distributed-transactions-in-microservices-d07aba281f90">https://blog.bitsrc.io/distributed-transactions-in-microservices-d07aba281f90</a></p>
</blockquote>
<p>이와 같이 모놀리식 아키텍처에서는 요청을 보내면 DB에서 트랜잭션이 생성해 상태 확인이나 요청처리를 수행합니다. 수행이 정상적으로 이루어졌다면 해당 리스폰스가 반환이 되겠지만 이 과정에서 실패하게 된다면, 해당 트랜잭션은 롤백됩니다.</p>
<h2 id="2-마이크로-서비스-아키텍처에서의-트랜잭션-처리">2. 마이크로 서비스 아키텍처에서의 트랜잭션 처리</h2>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/07d0ed42-b122-4012-9f28-fedcb7382429/image.png">

<p>MSA에서는 독립된 서비스들은 각자 별도의 DB를 구성되어 있습니다. 그렇기에 요청을 보내면 2개 이상의 서비스가 호출이 필요할 수 있는 상황이 생기고 각 DB에서 해당 요청 사항에 대한 처리를 해줘야 합니다.</p>
<p>하지만 독립된 서비스인만큼 ACID 트랜잭션을 유지하기 힘듭니다. </p>
<p>특히, <code>transaction-1</code>이 실패한 경우 <code>transaction-2</code>는 어떻게 처리할지 등 고려해야 합니다. </p>
<p>이러한 고려점에 대한 방안으로 2PC, SAGA Pattern이 있습니다.ㅣ</p>
<h2 id="2-1-2pc">2-1. 2PC</h2>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/a7b67666-247b-41a7-8623-453a0413792e/image.png">

<blockquote>
<p>이미지 출처 : <a href="https://timilearning.com/posts/mit-6.824/lecture-12-distributed-transactions/">https://timilearning.com/posts/mit-6.824/lecture-12-distributed-transactions/</a>
원본 출처 : Martin Kleppmann -  <a href="https://www.yes24.com/Product/Goods/59566585">데이터 중심 애플리케이션 설계</a></p>
</blockquote>
<p>Two-Phase Commit(2PC)는 분산 트랜잭션에서 원자성을 보장하는데 사용되는 프로토콜입니다.
분산 트랜잭션의 경우 2PC는 다음과 같이 동작합니다.</p>
<ul>
<li>DB는 트랜잭션을 담당하는 트랜잭션 코디네이터 라는 또 다른 엔티티를 추가합니다.</li>
<li>트랜잭션에 참여하는 다른 모든 서버를 참가자 라고 합니다.</li>
<li>트랜잭션 코디네이터는 먼저 트랜잭션의 쓰기 권한을 참여자에게 위임합니다. 각 참여자는 원래 트랜잭션에서 중첩된 트랜잭션을 만들고, Lock을 유지해야 할 수 있는 작업을 실행하고, 코디네이터에게 확인을 보냅니다.</li>
<li>코디네이터가 확인 메시지를 받으면 프로토콜의 첫 번째 단계가 시작됩니다. 이 단계에서 코디네이터는 참가자에게 PREPARE 메시지를 보냅니다. 그런 다음 각 참가자는 중첩된 트랜잭션의 결과에 따라 트랜잭션을 커밋하거나 중단할 준비가 되었는지 코디네이터에게 알려서 응답합니다.</li>
<li>참가자 중 누구라도 중단 메시지로 응답 하면 코디네이터는 전체 트랜잭션을 중단하기로 결정합니다. 코디네이터는 모든 참가자가 커밋할 준비가 된 경우에만 트랜잭션을 커밋합니다. 두 번째 단계는 코디네이터가 이러한 조건에 따라 전체 트랜잭션에 대한 COMMITTED 또는 ABORTED 레코드를 생성하고 해당 결과를 내구성 있는 로그에 저장할 때 시작됩니다. 그런 다음 해당 결정을 전체 트랜잭션의 결과로 참가자 노드에 전달합니다.</li>
</ul>
<p>여기서 2PC는 다음과 같은 약속이 있습니다. 이러한 약속을 통해 2PC의 원자성이 보장됩니다.</p>
<ol>
<li>참가자가 YES를 응답했을 때 이후엔 반드시 커밋을 해야한다.</li>
<li>코디네이터가 결정을 하면 반드시 결정을 강제해야한다.</li>
</ol>
<h3 id="단점">단점</h3>
<p>2PC의 가장 큰 단점은 코디네이터가 참가자에게 결과를 전달하기 전에 실패하면 참가자가 대기 상태에 갇힐 수 있다는 것입니다. 커밋할 준비가 되었다고 표시한 참가자는 다른 참가자가 중단할 준비가 되어 있을 수 있으므로 스스로 트랜잭션의 결과를 결정할 수 없습니다. 또한, 갇힌 참가자는 코디네이터가 충돌하기 전에 다른 참가자에게 커밋 메시지를 보냈을 수 있으므로 스스로 트랜잭션을 중단할지 결정할 수 없습니다.</p>
<p>이 방법은 참가자가 대기 상태에 갇혀 있는 동안 공유 객체에 대한 Lock을 유지할 수 있으므로 이상적이지 않습니다. 이로 인해 다른 트랜잭션이 진행되지 못할 수 있습니다.</p>
<h2 id="2-2-saga-pattern">2-2. SAGA Pattern</h2>
<p>SAGA는 일련의 로컬 트랜잭션입니다. 각 로컬 트랜잭션은 익숙한 ACID 트랜잭션 프레임워크를 사용하여 로컬 데이터베이스를 업데이트하고 이벤트를 게시하여 사가의 다음 로컬 트랜잭션을 트리거합니다. 로컬 트랜잭션이 실패하면 사가는 일련의 보상 트랜잭션을 실행하여 이전 로컬 트랜잭션에서 완료한 변경 사항을 취소합니다.</p>
<p>이는 비동기적이며 일관된 트랜잭션 방식으로, 분산 트랜잭션이 관련 마이크로서비스의 비동기 트랜잭션에 의해 실행되는 전형적인 마이크로서비스 애플리케이션 아키텍처와 매우 유사합니다.</p>
<p>SAGA 메시지의 비동기적인 특성의 큰 장점은 SAGA의 일부 참가자가 일시적으로 사용 불가능하더라도 모든 단계가 실행되도록 보장한다는 점입니다. 또한, 다른 마이크로서비스나 객체를 방해하지 않고 장기적인 트랜잭션도 처리할 수 있습니다.</p>
<p>또한 SAGA Pattern에는 Orchestration과 Choreography 2종류가 존재합니다.</p>
<h3 id="2-2-1-orchestration-saga-pattern">2-2-1. Orchestration SAGA Pattern</h3>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/5e19b8f7-98d7-4be4-bcfd-88f2b65b9acc/image.png">

<blockquote>
<p>이미지 출처 : <a href="https://medium.com/cloud-native-daily/microservices-patterns-part-04-saga-pattern-a7f85d8d4aa3">https://medium.com/cloud-native-daily/microservices-patterns-part-04-saga-pattern-a7f85d8d4aa3</a></p>
</blockquote>
<p>Orchestration SAGA Pattern은 분산 트랜잭션을 관리하는 방식으로, 중앙 조정자(Orchestrator)가 각 서비스의 작업을 순차적으로 지시하고 관리하는 패턴입니다. 중앙 조정자가 트랜잭션의 전체 흐름을 제어하며, 각 서비스는 중앙 조정자로부터 명령을 받아 수행합니다.</p>
<p>그림에서 볼 수 있듯이, Micro service 01에 구현된 <code>SAGA Orchestrator</code>가 SAGA 트랜잭션(예: 주문 생성 SAGA)을 시작합니다. 앞서 설명한 것처럼, 상호작용 방식은 비동기 요청/응답 방식이며, 요청은 &quot;명령 메시지&quot;로 전달됩니다. 비동기 요청/응답 방식을 사용하기 때문에, 메시지 브로커 내에서 별도의 요청 및 응답 채널을 사용합니다.</p>
<p>Micro service 01이 create() 요청을 받으면, <code>SAGA Orchestrator</code>가 생성됩니다. 이 Orchestrator는 전체 승인이 완료될 때까지 서비스 요청을 <code>PENDING</code> 상태로 설정합니다. 이 과정에서 SAGA Orchestrator는 Micro service 2와 3에 명령 요청을 보내고, 메시지 브로커의 SAGA Orchestrator 응답 채널을 통해 응답을 받습니다. 두 응답의 결과에 따라 Orchestrator는 요청을 승인하거나 거부하게 됩니다.</p>
<h4 id="장점">장점</h4>
<ul>
<li><p>더 간단한 의존성: Orchestrator가 항상 SAGA 참가자들을 호출하고, 그 반대는 발생하지 않으므로 순환 의존성이 없습니다.</p>
</li>
<li><p>결합도 감소: Choreography SAGA Pattern과는 달리, 다른 SAGA 참가자들이 발행하는 이벤트나 구현된 비즈니스 로직에 대해 알 필요가 없습니다. 이렇게 하면 결합도가 낮아지고 비즈니스 로직이 훨씬 단순해집니다.</p>
</li>
</ul>
<h4 id="단점-1">단점</h4>
<ul>
<li><p>Orchestrator 수준에서의 비즈니스 로직 감소: SAGA 오케스트레이터 내에 비즈니스 로직을 포함하지 않도록 하여 더 낮은 결합도를 유지하는 것이 좋습니다. 관련 서비스 외부에 비즈니스 로직을 두는 것은 바람직하지 않으며, Orchestrator 비즈니스 로직을 포함하는 것은 권장되지 않습니다.</p>
</li>
<li><p>격리성 부족: 전반적으로 마이크로서비스 아키텍처는 전통적인 ACID 트랜잭션에 비해 격리성이 부족합니다. 이는 SAGA 참가자들이 전체 트랜잭션이 완료되기 전에 로컬 트랜잭션으로 변경 사항을 커밋하기 때문에 발생합니다. 이로 인해 데이터베이스 레벨에서 불일치가 생길 수 있습니다.</p>
</li>
<li><p>단일 장애 지점: 중앙 조정자가 실패하면 전체 트랜잭션 흐름이 중단될 수 있습니다.</p>
</li>
</ul>
<h3 id="2-2-2-choreography-saga-pattern">2-2-2. Choreography SAGA Pattern</h3>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/0ceb31ce-32f5-4c1a-b830-c980f05077d8/image.png">

<blockquote>
<p>이미지 출처 : <a href="https://medium.com/cloud-native-daily/microservices-patterns-part-04-saga-pattern-a7f85d8d4aa3">https://medium.com/cloud-native-daily/microservices-patterns-part-04-saga-pattern-a7f85d8d4aa3</a></p>
</blockquote>
<p>Choreography SAGA Pattern의 방식에서는 Orchestration SAGA와 달리, SAGA 참가자들에게 무엇을 해야 할지 지시하는 중앙 조정자가 없습니다. SAGA 참가자들은 서로의 이벤트를 구독하고 그에 맞게 대응합니다. 이에 따라 롤백에 대한 책임도 각 SAGA 참가자들에게 있습니다.</p>
<h4 id="장점-1">장점</h4>
<ul>
<li><p>Orchestration SAGA Pattern에 비해 로직을 구성하기 편합니다.</p>
</li>
<li><p>비즈니스 흐름의 자연스러운 표현: 이벤트 기반의 상호작용을 통해 비즈니스 흐름을 자연스럽게 표현할 수 있어, 시스템의 가독성이 향상됩니다.</p>
</li>
</ul>
<h4 id="단점-2">단점</h4>
<ul>
<li><p>흐름 이해의 어려움: 일반적으로 이 방식은 SAGA 구현을 서비스 간에 분산시킵니다. 따라서 SAGA 흐름을 정의할 중앙집중적인 장소가 없습니다.</p>
</li>
<li><p>서비스 간 순환 의존성: SAGA 참가자 간에 순환 의존성이 발생할 가능성이 있으며, 예를 들어 Micro service 01 → Micro service 02 → Micro service 01과 같은 형태가 될 수 있습니다.</p>
</li>
<li><p>높은 결합도의 위험: 자신들에게 영향을 미치는 모든 이벤트를 구독해야 하므로 서비스 간 결합도가 높아질 위험이 있습니다.</p>
</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<p>Chreography나 Orchestration중 어떤게 좋냐 라기보단 상황에 맞춰서 적용하는 것이 가장 좋습니다. </p>
<p>Chreography는 다음과 같은 이유로 규모가 작은 애플리케이션에 적용하기 좋습니다.</p>
<ul>
<li><p>규모가 작으면 흐름을 읽기 힘들정도로 복잡해지는 경우가 없습니다.</p>
</li>
<li><p>서비스들의 이벤트를 서로 구독하고 이벤트 처리를 해주면 되기에 로직자체는 비교적 간단합니다.</p>
</li>
</ul>
<p>Orchestration의 경우는 대규모 애플리케이션이 좀 더 적용하기 좋습니다.</p>
<ul>
<li><p>Orchestrator가 트랜잭션의 흐름을 관리하게 되므로, 복잡한 비즈니스 로직을 효과적으로 관리할 수 있습니다.</p>
</li>
<li><p>Orchestrator가 오류를 중앙에서 처리할 수 있어 일관된 오류 처리가 가능합니다.</p>
</li>
</ul>
<hr>
<h1 id="choreography-saga-pattern-적용하기">Choreography SAGA Pattern 적용하기</h1>
<p>저의 경우 위에 언급했듯이 규모가 작은 애플리케이션이기에 Chreography SAGA Pattern을 적용하기로 했습니다. 그외의 이유로는 Orchestrator를 구현할 만큼 시간적 여유가 많지 않다라는 점과 당장 적용하기에는 Orchestration SAGA Pattern의 학습 곡선이 높다는 이유가 있었습니다.</p>
<h2 id="producer">Producer</h2>
<h3 id="1-configuration-작성">1. Configuration 작성</h3>
<pre><code class="language-java">@Configuration
public class KafkaProducerConfig {

    @Value(&quot;${spring.kafka.producer.bootstrap-servers}&quot;)
    private String bootstrapServers;
    @Value(&quot;${spring.kafka.producer.key-serializer}&quot;)
    private String keySerializer;
    @Value(&quot;${spring.kafka.producer.value-serializer}&quot;)
    private String valueSerializer;

    @Bean
    public ProducerFactory&lt;String, Object&gt; producerFactory() {
        Map&lt;String, Object&gt; configMap = new HashMap&lt;&gt;();
        configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, keySerializer);
        configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, valueSerializer);
        return new DefaultKafkaProducerFactory&lt;&gt;(configMap);
    }

    @Bean
    public KafkaTemplate&lt;String, Object&gt; kafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(producerFactory());
    }
}</code></pre>
<h3 id="2-dto-작성하기">2. Dto 작성하기</h3>
<p><strong>ReservationMessageResponse.java</strong></p>
<pre><code class="language-java">public record ReservationMessageResponse(@JsonProperty(&quot;reservationId&quot;) Long reservationId,
                                         @JsonProperty(&quot;status&quot;) ReservationStatus status,
                                         @JsonProperty(&quot;message&quot;) String message) {

} ,</code></pre>
<p><strong>ReservationStatus.java</strong></p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public enum ReservationStatus {

    PENDING(&quot;예약 대기&quot;),
    SUCCESS(&quot;예약 성공&quot;),
    CANCEL(&quot;예약 취소&quot;),
    FAILURE(&quot;예약 실패&quot;);

    private final String message;
}</code></pre>
<h3 id="3-producer-관련-클래스-작성하기">3. Producer 관련 클래스 작성하기</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class TransactionProducer {

    private static final String TOPIC_RESERVATION_TRANSACTION_RESULT = &quot;reservation-transaction-result&quot;;
    private final KafkaTemplate&lt;String, Object&gt; kafkaTemplate;
    private final ObjectMapper objectMapper;

    public void sendTransactionResult(final ReservationMessageResponse responseMessage)
        throws JsonProcessingException {
        final String message = objectMapper.writeValueAsString(responseMessage);
        kafkaTemplate.send(TOPIC_RESERVATION_TRANSACTION_RESULT, message);
    }

}</code></pre>
<h3 id="4-기존-consumer-로직-수정">4. 기존 Consumer 로직 수정</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class ReservationConsumer {

    private final RoomService roomService;
    private final TransactionProducer transactionProducer;
    private final ObjectMapper objectMapper;

    @KafkaListener(topics = &quot;room-reserve&quot;, groupId = &quot;reservation-group&quot;)
    public void consumeReservationEvent(final String reservationMessage)
        throws JsonProcessingException {
        final ReservationMessage message = objectMapper.readValue(reservationMessage,
            ReservationMessage.class);

        try {
            roomService.decreaseCountByOne(message.roomId(), message.checkInDate(),
                message.checkOutDate());
            final ReservationMessageResponse response = new ReservationMessageResponse(
                message.reservationId(), ReservationStatus.SUCCESS,
                StringUtils.EMPTY);
            transactionProducer.sendTransactionResult(response);

        } catch (Exception e) {
            final ReservationMessageResponse response = new ReservationMessageResponse(
                message.reservationId(), ReservationStatus.FAILURE,
                e.getMessage());
            transactionProducer.sendTransactionResult(response);
        }
    }

    ...

}</code></pre>
<h3 id="5-room-devyml">5. room-dev.yml</h3>
<pre><code class="language-shell">## msa server-dev
server:
  port: 8084

## db
spring:

    ...

  kafka:
    ## kafka consumer
    consumer:
      bootstrap-servers: localhost:9092
      group-id: reservation-group
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

    ## kafka producer
    producer:
      bootstrap-servers: localhost:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer</code></pre>
<h2 id="consumer">Consumer</h2>
<h3 id="1-configuration-작성-1">1. Configuration 작성</h3>
<pre><code class="language-java">@Configuration
public class KafkaConsumerConfig {

    @Value(&quot;${spring.kafka.consumer.bootstrap-servers}&quot;)
    private String bootstrapServers;
    @Value(&quot;${spring.kafka.consumer.group-id}&quot;)
    private String groupId;
    @Value(&quot;${spring.kafka.consumer.key-deserializer}&quot;)
    private String keyDeserializer;
    @Value(&quot;${spring.kafka.consumer.value-deserializer}&quot;)
    private String valueDeserializer;

    @Bean
    public ConsumerFactory&lt;String, Object&gt; consumerFactory() {
        Map&lt;String, Object&gt; configMap = new HashMap&lt;&gt;();
        configMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        configMap.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        configMap.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, keyDeserializer);
        configMap.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, valueDeserializer);
        return new DefaultKafkaConsumerFactory&lt;&gt;(configMap);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, Object&gt; kafkaListenerContainerFactory() {
        final ConcurrentKafkaListenerContainerFactory&lt;String, Object&gt; factory = new ConcurrentKafkaListenerContainerFactory&lt;&gt;();
        factory.setConsumerFactory(consumerFactory());
        return factory;
    }
}</code></pre>
<h3 id="2-dto-작성하기-1">2. Dto 작성하기</h3>
<p><strong>ReservationMessageResponse.java</strong></p>
<pre><code class="language-java">public record ReservationMessageResponse(@JsonProperty(&quot;reservationId&quot;) Long reservationId,
                                         @JsonProperty(&quot;status&quot;) ReservationStatus status,
                                         @JsonProperty(&quot;message&quot;) String message) {

}</code></pre>
<p><strong>ReservationStatus.java</strong></p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public enum ReservationStatus {

    PENDING(&quot;예약 대기&quot;),
    SUCCESS(&quot;예약 성공&quot;),
    CANCEL(&quot;예약 취소&quot;),
    FAILURE(&quot;예약 실패&quot;);

    private final String message;
}</code></pre>
<h3 id="3-consumer-관련-클래스-작성하기">3. Consumer 관련 클래스 작성하기</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class TransactionConsumer {

    private final ReservationRepository reservationRepository;
    private final ObjectMapper objectMapper;

    @Transactional
    @KafkaListener(topics = &quot;reservation-transaction-result&quot;, groupId = &quot;reservation-transaction-result-group&quot;)
    public void consumeReservationTransactionResultEvent(final String roomResponseMessage)
        throws JsonProcessingException {
        final ReservationMessageResponse response = objectMapper.readValue(roomResponseMessage,
            ReservationMessageResponse.class);

        if (response.status() == ReservationStatus.FAILURE) {
            reservationRepository.updateStatusToFailureById(response.reservationId());
            log.info(&quot;Reservation transaction failed with Id And Message. ID : {}, MESSAGE {}&quot;,
                response.reservationId(), response.message());
            return;
        } else if (response.status() == ReservationStatus.CANCEL) {
            reservationRepository.deleteById(response.reservationId());
            log.info(&quot;Reservation cancel transaction with Id And Message. ID : {}, MESSAGE {}&quot;,
                response.reservationId(), response.message());
            return;
        }
        reservationRepository.updateStatusToSuccessById(response.reservationId());
    }
}</code></pre>
<h3 id="4-reservation-devyml">4. reservation-dev.yml</h3>
<pre><code class="language-shell">## msa server-dev
server:
  port: 8083

## db
spring:

  ...

  kafka:
    ## kafka producer
    producer:
      bootstrap-servers: localhost:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

    ## kafka consumer
    consumer:
      bootstrap-servers: localhost:9092
      group-id: reservation-transaction-result-group
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] Kafka 적용하기]]></title>
            <link>https://velog.io/@c_mungi/MSA-Kafka-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@c_mungi/MSA-Kafka-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 28 Sep 2024 15:48:32 GMT</pubDate>
            <description><![CDATA[<h1 id="kafka란">Kafka란</h1>
<p>Kafka는 실시간으로 기록 스트림을 게시, 구독, 저장 및 처리할 수 있는 분산형 데이터 스트리밍 플랫폼입니다. 여러 소스에서 데이터 스트림을 처리하고 여러 사용자에게 전달하도록 설계되었습니다.</p>
<p>Kafka는 전통적인 엔터프라이즈 메시징 시스템의 대안입니다. 하루에 1조 4천억 건의 메시지를 처리하기 위해 LinkedIn이 개발한 내부 시스템으로 시작했으나, 현재 이는 다양한 기업의 요구 사항을 지원하는 애플리케이션을 갖춘 오픈소스 데이터 스트리밍 솔루션이 되었습니다.</p>
<img width=600 src="https://velog.velcdn.com/images/c_mungi/post/86142f24-9fb1-452c-8419-db67bc2613ec/image.png">

<blockquote>
<p>이미지 출처 : <a href="https://securityboulevard.com/2024/01/what-is-kafka/">https://securityboulevard.com/2024/01/what-is-kafka/</a></p>
</blockquote>
<h1 id="kafka-적용하기">Kafka 적용하기</h1>
<p>패스트 캠퍼스 기업연계 파이널 프로젝트를 드랍하고 개인 프로젝트로 진행을 했기에 아쉽게도 서버비용 지원이 없습니다. 맘같아선 Kafaka Cluster로 구현해 좀 더 제대로 사용해보고 싶었지만 싱글 노드로나마 구축했습니다.</p>
<h2 id="producer">Producer</h2>
<h3 id="1-의존성-추가">1. 의존성 추가</h3>
<pre><code class="language-shell">dependencies {

    ...

    // kafka
    implementation &#39;org.springframework.kafka:spring-kafka&#39;
    testImplementation &#39;org.springframework.kafka:spring-kafka-test&#39;
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}</code></pre>
<h3 id="2-configuration-클래스-작성">2. Configuration 클래스 작성</h3>
<pre><code class="language-java">@Configuration
public class KafkaProducerConfig {

    @Value(&quot;${spring.kafka.producer.bootstrap-servers}&quot;)
    private String bootstrapServers;
    @Value(&quot;${spring.kafka.producer.key-serializer}&quot;)
    private String keySerializer;
    @Value(&quot;${spring.kafka.producer.value-serializer}&quot;)
    private String valueSerializer;

    @Bean
    public ProducerFactory&lt;String, Object&gt; producerFactory() {
        Map&lt;String, Object&gt; configMap = new HashMap&lt;&gt;();
        configMap.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        configMap.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, keySerializer);
        configMap.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, valueSerializer);
        return new DefaultKafkaProducerFactory&lt;&gt;(configMap);
    }

    @Bean
    public KafkaTemplate&lt;String, Object&gt; kafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(producerFactory());
    }
}</code></pre>
<p>환경 변수로 받아온 값들을 ConfigMap에 저장해 KafkaProducerFactory를 생성하고 KafkaTemplate Bean에 담아 반환하도록 합니다.</p>
<h3 id="3-dto-클래스-작성">3. Dto 클래스 작성</h3>
<p>아래 Dto는 Producer와 Consumer 동일하게 작성합니다.</p>
<pre><code class="language-java">public record ReservationMessage(@JsonProperty(&quot;reservationId&quot;) Long reservationId,
                                 @JsonProperty(&quot;roomId&quot;) Long roomId,
                                 @JsonProperty(&quot;checkInDate&quot;) LocalDate checkInDate,
                                 @JsonProperty(&quot;checkOutDate&quot;) LocalDate checkOutDate) {

}</code></pre>
<p>객실 서비스에서 데이터 조회 및 처리를 하기위해 해당 객실ID와 체크인, 체크아웃으로 특정 할 수 있도록 지정했습니다.</p>
<h3 id="4-producer-관련-클래스-작성">4. Producer 관련 클래스 작성</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class ReservationProducer {

    private static final String RESERVATION_TOPIC = &quot;room-reserve&quot;;
    private static final String CANCEL_TOPIC = &quot;room-cancel&quot;;

    private final KafkaTemplate&lt;String, Object&gt; kafkaTemplate;
    private final ObjectMapper objectMapper;

    public void sendReservation(final Long reservationId, final Long roomId,
        final LocalDate checkIn,
        final LocalDate checkOut) throws JsonProcessingException {
        final ReservationMessage message = new ReservationMessage(reservationId, roomId, checkIn,
            checkOut);
        kafkaTemplate.send(RESERVATION_TOPIC, objectMapper.writeValueAsString(message));
    }

    public void sendCancelReservation(final Long reservationId, final Long roomId,
        final LocalDate checkIn,
        final LocalDate checkOut) throws JsonProcessingException {
        final ReservationMessage message = new ReservationMessage(reservationId, roomId,
            checkIn,
            checkOut);
        kafkaTemplate.send(CANCEL_TOPIC, objectMapper.writeValueAsString(message));
    }
}</code></pre>
<p>Producer에는 두 가지 토픽(예약, 예약 취소)에 대한 이벤트를 발행합니다.</p>
<p>이 때 ReservationMessage Dto에 필요한 내용을 담아 토픽을 설정한 후 메세지로 보내줍니다.</p>
<h3 id="5-예약-서비스---service-로직-추가">5. 예약 서비스 - Service 로직 추가</h3>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationProducer reservationProducer;

    @Transactional
    public ReservationResponse reserve(ReservationRequest request,
        Long memberId) throws JsonProcessingException {

        ...

        Reservation saved = reservationRepository.save(reservation);

        reservationProducer.sendReservation(saved.getId(), request.getRoomId(), checkInDate,
            checkOutDate);

        return ReservationResponse.from(saved);
    }
}</code></pre>
<p>예약 서비스 레이어에서 ReservationProducer를 의존성 주입해 전역변수로 선언하고
reserve 메서드에서 sendReservation 메서드를 호출해 처리했습니다.</p>
<h3 id="6-applicationyml">6. application.yml</h3>
<pre><code class="language-shell">spring:
  application:
    name: ${RESERVATION_APP_NAME}
  profiles:
    active: ${APP_PROFILE}
  config:
    import: optional:configserver:${CONFIG_SERVER_URI}</code></pre>
<h3 id="7-accommodation-devyml--config-server-">7. accommodation-dev.yml ( Config Server )</h3>
<pre><code class="language-shell">## msa server-dev
server:
  port: 8083

  ...

  kafka:
    ## kafka producer
    producer:
      bootstrap-servers: localhost:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer</code></pre>
<h2 id="consumer">Consumer</h2>
<h3 id="1-의존성-추가-1">1. 의존성 추가</h3>
<pre><code class="language-shell">dependencies {

    ...

    // kafka
    implementation &#39;org.springframework.kafka:spring-kafka&#39;
    testImplementation &#39;org.springframework.kafka:spring-kafka-test&#39;
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}</code></pre>
<h3 id="2-configuration-클래스-작성-1">2. Configuration 클래스 작성</h3>
<pre><code class="language-java">@Configuration
public class KafkaConsumerConfig {

    @Value(&quot;${spring.kafka.consumer.bootstrap-servers}&quot;)
    private String bootstrapServers;
    @Value(&quot;${spring.kafka.consumer.group-id}&quot;)
    private String groupId;
    @Value(&quot;${spring.kafka.consumer.key-deserializer}&quot;)
    private String keyDeserializer;
    @Value(&quot;${spring.kafka.consumer.value-deserializer}&quot;)
    private String valueDeserializer;

    @Bean
    public ConsumerFactory&lt;String, Object&gt; consumerFactory() {
        Map&lt;String, Object&gt; configMap = new HashMap&lt;&gt;();
        configMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        configMap.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        configMap.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, keyDeserializer);
        configMap.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, valueDeserializer);
        return new DefaultKafkaConsumerFactory&lt;&gt;(configMap);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, Object&gt; kafkaListenerContainerFactory() {
        final ConcurrentKafkaListenerContainerFactory&lt;String, Object&gt; factory = new ConcurrentKafkaListenerContainerFactory&lt;&gt;();
        factory.setConsumerFactory(consumerFactory());
        return factory;
    }
}</code></pre>
<p>Producer와 다르게 Consumer에서는 Group에 대한 내용이 등장하는데 동일한 토픽을 구독하고 있는 컨슈머의 집합입니다. 예를들어 어느 상품의 주문이 생성되었다는 이벤트가 발행되면 해당 상품 주문이라는 토픽의 컨슈머 그룹에 속해 있는 재고 관리 서비스, 결제 서비스 등이 각자 비즈니스 로직을 수행할 수 있습니다.</p>
<h3 id="3-dto-클래스-작성-1">3. Dto 클래스 작성</h3>
<p>Consumer에서도 Producer와 동일한 내용의 Message Dto를 작성합니다.</p>
<pre><code class="language-java">public record ReservationMessage(@JsonProperty(&quot;reservationId&quot;) Long reservationId,
                                 @JsonProperty(&quot;roomId&quot;) Long roomId,
                                 @JsonProperty(&quot;checkInDate&quot;) LocalDate checkInDate,
                                 @JsonProperty(&quot;checkOutDate&quot;) LocalDate checkOutDate) {

}</code></pre>
<h3 id="4-consumer-관련-클래스-작성">4. Consumer 관련 클래스 작성</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class ReservationConsumer {

    private final RoomService roomService;
    private final TransactionProducer transactionProducer;
    private final ObjectMapper objectMapper;

    @KafkaListener(topics = &quot;room-reserve&quot;, groupId = &quot;reservation-group&quot;)
    public void consumeReservationEvent(final String reservationMessage)
        throws JsonProcessingException {
        final ReservationMessage message = objectMapper.readValue(reservationMessage,
            ReservationMessage.class);

        try {
            roomService.decreaseCountByOne(message.roomId(), message.checkInDate(),
                message.checkOutDate());
            final ReservationMessageResponse response = new ReservationMessageResponse(
                message.reservationId(), ReservationStatus.SUCCESS,
                StringUtils.EMPTY);
            transactionProducer.sendTransactionResult(response);

        } catch (Exception e) {
            final ReservationMessageResponse response = new ReservationMessageResponse(
                message.reservationId(), ReservationStatus.FAILURE,
                e.getMessage());
            transactionProducer.sendTransactionResult(response);
        }
    }

    @KafkaListener(topics = &quot;room-cancel&quot;, groupId = &quot;reservation-group&quot;)
    public void consumeReservationCancelEvent(final String cancelMessage)
        throws JsonProcessingException {
        final ReservationMessage message = objectMapper.readValue(cancelMessage,
            ReservationMessage.class);

        roomService.increaseCountByOne(message.roomId(), message.checkInDate(),
            message.checkInDate());
        final ReservationMessageResponse response = new ReservationMessageResponse(
            message.reservationId(), ReservationStatus.CANCEL, StringUtils.EMPTY);
        transactionProducer.sendTransactionResult(response);
    }
}</code></pre>
<p>이벤트가 발행되어 메세지를 수신하게 되면 해당 메세지를 해석하고 관련된 비즈니스 로직을 수행하도록 합니다.</p>
<p>로직에서 소개하지 않은 내용들이 일부 있는데 이는 SAGA Pattern과 관련된 이야기로 이후에 다루도록 하겠습니다.</p>
<h3 id="5-applicationyml">5. application.yml</h3>
<pre><code class="language-shell">spring:
  application:
    name: ${ROOM_APP_NAME}
  profiles:
    active: ${APP_PROFILE}
  config:
    import: optional:configserver:${CONFIG_SERVER_URI}</code></pre>
<h3 id="6-room-devyml--config-server-">6. room-dev.yml ( Config Server )</h3>
<pre><code class="language-shell">## msa server-dev
server:
  port: 8084

  ...

  kafka:
    ## kafka consumer
    consumer:
      bootstrap-servers: localhost:9092
      group-id: reservation-group
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
</code></pre>
<h1 id="마무리">마무리</h1>
<p>다음은 Choreography SAGA Pattern과 적용법에 대해 포스팅해보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] OpenFeign 사용하기]]></title>
            <link>https://velog.io/@c_mungi/MSA-OpenFeign-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@c_mungi/MSA-OpenFeign-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Sep 2024 08:26:15 GMT</pubDate>
            <description><![CDATA[<h1 id="openfeign이란">OpenFeign이란</h1>
<p>Spring Cloud OpenFeign은 Spring Cloud 프로젝트에 포함된 동기 통신 클라이언트로, 선언적 REST 클라이언트로서 웹 서비스 클라이언트 작성을 보다 쉽게 할 수 있습니다.</p>
<p>직접 RestTemplate을 호출해서 대상 서버에게 통신을 요청하는 기존 방식과는 달리 인터페이스로 선언만 해두면 자동으로 구현체가 생성되는 형식입니다.</p>
<h1 id="openfeign을-사용하는-이유">OpenFeign을 사용하는 이유</h1>
<p>프로젝트 기획상 숙소 조회시 체크인, 체크아웃, 인원수 등을 조건으로 조회하는 경우 객실 데이터가 필요합니다. 이 경우 어떻게 결합도를 낮춘 상태로 다른 서비스의 정보를 조회가 가능할까 고민을 했고, 그 결과 다음과 같은 방법들이 존재한다는 것을 알게 되었습니다.</p>
<ul>
<li><p>동기 방식</p>
<ul>
<li>OpenFeign</li>
<li>RestTemplate</li>
</ul>
</li>
<li><p>비동기 방식</p>
<ul>
<li>Spring WebClient</li>
</ul>
</li>
</ul>
<p>이 중 OpenFeign을 고른 기준은 다음과 같습니다.</p>
<ul>
<li>애플리케이션의 규모가 크다 / 작다 -&gt; 작다</li>
<li>당장 적용할 수 있을 정도로 학습 곡선이 낮은가 / 높은가 -&gt; 낮다</li>
</ul>
<p>이외에는 Spring Cloud환경에서 MSA를 구축하고 있는데 OpenFeign의 경우 Spring Cloud와의 통합을 지원하여 마이크로서비스 아키텍처에서 유용하다는 점에 선택을 했습니다.</p>
<h1 id="openfeign-적용하기">OpenFeign 적용하기</h1>
<h2 id="1-의존성-추가">1. 의존성 추가</h2>
<pre><code class="language-shell"> dependencies {

    --- 중략 ---

    // spring cloud openfeign client
    implementation &#39;org.springframework.cloud:spring-cloud-starter-openfeign&#39;

}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}</code></pre>
<p> 숙소 서비스에 openfeign client 의존성을 주입합니다.</p>
<h2 id="2-숙소와-객실간을-통신할-dto-작성">2. 숙소와 객실간을 통신할 Dto 작성</h2>
<p> 아래 작성된 ResponseDto의 경우 객실 서비스에도 동일한 Response가 필요합니다.</p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
@NoArgsConstructor
public class RoomDetailResponse {

    private Long id;

    private String name;

    private String accommodationName;

    private String description;

    private int totalPrice;

    private int price;

    private int numberOfStay;

    private int standardNumber;

    private int maximumNumber;

    private int roomCount;

    private String type;

    private List&lt;String&gt; roomImageList;

    private RoomOptionResponse productOption;
}</code></pre>
<h2 id="3-openfeign-client-클래스-작성">3. OpenFeign Client 클래스 작성</h2>
<pre><code class="language-java"> @FeignClient(value = &quot;room-service&quot;, url = &quot;${spring.cloud.openfeign.dest-room-url}&quot;)
public interface RoomClient {

    @GetMapping(&quot;/api/accommodation/{accommodationId}/room-details&quot;)
    List&lt;RoomDetailResponse&gt; getRoomDetailList(
        @PathVariable Long accommodationId,
        @RequestParam(required = false) LocalDate checkInDate,
        @RequestParam(required = false) LocalDate checkOutDate,
        @RequestParam(defaultValue = &quot;2&quot;) int personNumber
    );
}</code></pre>
<p><code>${spring.cloud.openfeign.dest-room-url}</code>은 해당 서비스의 host:port이므로 <a href="http://localhost:8084">http://localhost:8084</a> 와 같이 해당 서비스의 port까지 적으면 됩니다.</p>
<p>그리고 getRoomDetailList 메서드의 경우 객실 서비스에 실제 존재하는 API이고 숙소 서비스에서 해당 API에 요청을 보내 필요한 정보를 RoomDetailResponse Dto로 반환받게 됩니다.</p>
<h2 id="4-service-로직-작성">4. Service 로직 작성</h2>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class AccommodationService {

    private final AccommodationRepository accommodationRepository;
    private final AccommodationImageRepository accommodationImageRepository;
    private final RoomClient roomClient;

    --- 생략 ---
    private boolean hasValidRooms(Accommodation accommodation, LocalDate checkInDate,
        LocalDate checkOutDate, Integer personNumber) {
        List&lt;RoomDetailResponse&gt; roomEntityList = roomClient.getRoomDetailList(
            accommodation.getId(), checkInDate, checkOutDate, personNumber);

        return roomEntityList.stream()
            .anyMatch(room -&gt; room.getRoomCount() &gt; 0);
    }
}</code></pre>
<p>3에서 작성한 Client를 Service에 의존성 주입을 하고 해당 인터페이스의 메서드를 호출하면 됩니다.</p>
<p>※ 실제 사용되는 내용에 비해 Response에 담기는 정보가 많습니다만.. 이후 재사용 또는 확장을 염두해두고 작성했으니 양해바라겠습니다.</p>
<h2 id="5-openfeign-활성화">5. OpenFeign 활성화</h2>
<pre><code class="language-java">@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class AccommodationServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(AccommodationServiceApplication.class, args);
    }

}</code></pre>
<p> OpenFeign을 사용하기 위해선 메인 클래스에서 <code>@EnableFeignClients</code> 어노테이션을 추가해 활성화하면 됩니다.</p>
<h1 id="동작-확인">동작 확인</h1>
<p> 아래와 같은 조건으로 숙소 조회 API를 호출하겠습니다.
 <img width=700 src="https://velog.velcdn.com/images/c_mungi/post/d150e5c5-6e81-4165-95d8-b05f9d51365f/image.png"></p>
<p>숙소 조회 API가 호출되었을 때 OpenFeign Client 로직 결과를 확인해보면 정상적으로 통신이 되어 해당 Response Dto를 반환받은 것을 확인 할 수 있습니다.
 <img width=700 src="https://velog.velcdn.com/images/c_mungi/post/51314be1-5c12-40e1-b03b-e2b03ead82dd/image.png"></p>
<p>숙소 조회 API의 결과 입니다.
 <img width=700 src="https://velog.velcdn.com/images/c_mungi/post/07692e5e-6e88-4f0c-a796-7530a5021d83/image.png"></p>
<h1 id="마무리">마무리</h1>
<p>다음은 Kafka에 대해 포스팅해보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] Gateway Service]]></title>
            <link>https://velog.io/@c_mungi/MSA-Gateway-Service</link>
            <guid>https://velog.io/@c_mungi/MSA-Gateway-Service</guid>
            <pubDate>Thu, 26 Sep 2024 15:53:58 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-cloud-gateway란">Spring Cloud Gateway란</h1>
<p>Spring Framwork가 제공하는 라이브러리로 Spring WebFlux 또는 Spring WebMVC에 API Gateway를 구축하기 위해 사용됩니다.
Spring Cloud Gateway는 API로 라우팅하고 보안, 모니터링/메트릭, 복원성과 같은 횡단적 관심사를 제공합니다.</p>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/b8de00b1-3938-496e-ace7-4b336b9927b8/image.png"/>

<blockquote>
<p>이미지 출처 : <a href="https://kyhslam.tistory.com/entry/Spring-Cloud-Gateway-Load-Balancer">https://kyhslam.tistory.com/entry/Spring-Cloud-Gateway-Load-Balancer</a></p>
</blockquote>
<h1 id="spring-cloud-gateway-구축">Spring Cloud Gateway 구축</h1>
<p>클라이언트가 서비스를 이용하게 될 때 해당 유저가 인증된 유저인지 또는 특정 서비스를 이용할 권한을 가졌는지 판단을 하고 그 이후 서비스로 연결해주게 됩니다.</p>
<p>그래서 저는 Filter를 구현하고 Cookie로 내려보낸 Access Token을 가져와 유효성 검사를 실시 후 정상인 경우 memberId를 추출합니다. 추출한 memberId는 header에 <code>member-id</code>라는 이름으로 저장해 회원 정보가 필요한 서비스에서 해당 <code>member-id</code>를 이용할 수 있게 했습니다.</p>
<p>Gateway service에서 작성할 내용은 다음과 같습니다.</p>
<ul>
<li><p>필터 관련</p>
<ul>
<li>Global Filter</li>
<li>AuthorizationFilter</li>
</ul>
</li>
<li><p>Cookie &amp; JWT 관련</p>
<ul>
<li>CookieProvider</li>
<li>JwtProvider</li>
</ul>
</li>
<li><p>예외 관련</p>
<ul>
<li>GlobalExceptionHandler</li>
<li>ExceptionHandlerConfig</li>
<li>AuthException</li>
</ul>
</li>
<li><p>API Limiter 관련</p>
<ul>
<li>TokenKeyConfig</li>
</ul>
</li>
</ul>
<h2 id="1-의존성-주입">1. 의존성 주입</h2>
<p><strong>build.gradle</strong></p>
<pre><code class="language-shell">ext {
    set(&#39;springCloudVersion&#39;, &quot;2023.0.3&quot;)
}

dependencies {

    // jwt
    implementation &#39;io.jsonwebtoken:jjwt-api:0.11.2&#39;
    implementation &#39;io.jsonwebtoken:jjwt-impl:0.11.2&#39;
    implementation &#39;io.jsonwebtoken:jjwt-jackson:0.11.2&#39;

    // spring cloud config
    implementation &#39;org.springframework.cloud:spring-cloud-starter-config&#39;

    // spring cloud gateway
    implementation &#39;org.springframework.cloud:spring-cloud-starter-gateway&#39;

    // spring cloud eureka
    implementation &#39;org.springframework.cloud:spring-cloud-starter-netflix-eureka-client&#39;

    // dev
    compileOnly &#39;org.projectlombok:lombok&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;

}

dependencyManagement {
    imports {
        mavenBom &quot;org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}&quot;
    }
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}</code></pre>
<h2 id="applicationyml-설정">application.yml 설정</h2>
<pre><code class="language-shell">spring:
  application:
    name: ${GATEWAY_APP_NAME}
  profiles:
    active: ${APP_PROFILE}
  config:
    import: optional:configserver:${CONFIG_SERVER_URI}</code></pre>
<h2 id="3-필터-관련">3. 필터 관련</h2>
<h3 id="3-1-global-filter">3-1. Global Filter</h3>
<p>솔직히 Global Filter의 경우는 이번 프로젝트에선 없어도 되는 기능이었지만 전체적인 기능을 파악하자는 의미로 억지로 추가해보았습니다. 그래서 특별히 무언가를 한다기 보단 어떠한 리퀘스트가 발생했는지 리스폰스의 스테터스는 어땠는지 로그로 남기는 정도로만 사용했습니다.</p>
<pre><code class="language-java">@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory&lt;Config&gt; {

    public GlobalFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -&gt; {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info(&quot;Global Base Message: {}&quot;, config.getBaseMessage());

            if (config.isPreLogger()) {
                log.info(&quot;Global Filter Start. request id : {}, request path : {}&quot;, request.getId(),
                    request.getPath());
            }
            return chain.filter(exchange).then(Mono.fromRunnable(() -&gt; {

                if (config.isPostLogger()) {
                    log.info(&quot;Global Filter End: response status code -&gt; {}&quot;,
                        response.getStatusCode());
                }
            }));
        };
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Data
    public static class Config {

        private String baseMessage;
        private boolean preLogger;
        private boolean postLogger;
    }
}</code></pre>
<h3 id="3-2-authorizationfilter">3-2. AuthorizationFilter</h3>
<pre><code class="language-java">@Component
public class AuthorizationFilter extends AbstractGatewayFilterFactory&lt;Config&gt; {

    private final CookieProvider cookieProvider;
    private final JwtProvider jwtProvider;

    @Autowired
    public AuthorizationFilter(CookieProvider cookieProvider, JwtProvider jwtProvider) {
        super(Config.class);
        this.cookieProvider = cookieProvider;
        this.jwtProvider = jwtProvider;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -&gt; {

            ServerHttpRequest request = exchange.getRequest();
            String token = cookieProvider.getTokenFromCookies(request.getCookies());

            jwtProvider.validateToken(token, TokenType.ACCESS);

            Long memberId = jwtProvider.getMemberIdByToken(token, TokenType.ACCESS);

            ServerHttpRequest newRequest = request.mutate()
                .header(&quot;member-id&quot;, String.valueOf(memberId)).build();

            return chain.filter(exchange.mutate().request(newRequest).build());
        };
    }

    public static class Config {

    }
}</code></pre>
<p>request가 가지고 있는 Cookie에서 token을 추출해 해당 token에 문제 없는지 체크를 합니다.
문제가 없다면 token에 저장된 memberId를 추출해 header에 저장하고 request로 재생성합니다.ㅣ
이후 다음 filter에 재생성한 request를 담아 보내줍니다.</p>
<h2 id="4-cookie--jwt-관련">4. Cookie &amp; JWT 관련</h2>
<h3 id="4-1-cookieprovider">4-1. CookieProvider</h3>
<pre><code class="language-java">@Component
public class CookieProvider {

    public String getTokenFromCookies(MultiValueMap&lt;String, HttpCookie&gt; cookies) {
        if (cookies.isEmpty()) {
            return null;
        }
        return cookies.get(TokenType.ACCESS.getName()).stream().map(HttpCookie::getValue)
            .findAny()
            .orElse(null);
    }
}
</code></pre>
<p>CookieProvider에서는 Cookie의 이름을 특정해 해당 토큰을 반환하는 로직만 작성했습니다.</p>
<h3 id="4-2-jwtprovider">4-2. JwtProvider</h3>
<pre><code class="language-java">@Component
@Slf4j
@RequiredArgsConstructor
public class JwtProvider {

    @Value(&quot;${jwt.access-secret}&quot;)
    private String ACCESS_SECRET;
    @Value(&quot;${jwt.refresh-secret}&quot;)
    private String REFRESH_SECRET;

    private Key accessKey;
    private Key refreshKey;

    @PostConstruct
    public void init() {
        byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_SECRET);
        byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_SECRET);
        this.accessKey = Keys.hmacShaKeyFor(accessKeyBytes);
        this.refreshKey = Keys.hmacShaKeyFor(refreshKeyBytes);
    }

    public void validateToken(String token, TokenType type) {
        Key key = type.equals(TokenType.ACCESS) ? accessKey : refreshKey;
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException |
                 SignatureException | IllegalArgumentException ex) {
            throw new AuthException(ErrorType.TOKEN_AUTHORIZATION_FAIL);
        }

        validateTokenExpired(token, type);
    }

    private void validateTokenExpired(String token, TokenType type) {
        Date expiredDate = getExpired(token, type);
        if (expiredDate.before(new Date())) {
            throw new AuthException(ErrorType.TOKEN_EXPIRED);
        }
    }

    public Date getExpired(String token, TokenType type) {
        return getClaimsFromJwtToken(token, type).getExpiration();
    }

    public Long getMemberIdByToken(String token, TokenType type) {
        return getClaimsFromJwtToken(token, type).get(&quot;memberId&quot;, Long.class);
    }

    private Claims getClaimsFromJwtToken(String token, TokenType type) {
        Key key = type.equals(TokenType.ACCESS) ? accessKey : refreshKey;
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}</code></pre>
<p> JwtProvider에서는 Token의 내용을 가져오거나 Token의 유효성 검사를 실시합니다.</p>
<h2 id="5-예외-관련">5. 예외 관련</h2>
<h3 id="5-1-globalexceptionhandler">5-1. GlobalExceptionHandler</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public Mono&lt;Void&gt; handle(ServerWebExchange exchange, Throwable ex) {
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        Map&lt;String, Object&gt; responseBody = new HashMap&lt;&gt;();
        if (ex instanceof HttpStatusCodeException statusEx) {
            exchange.getResponse().setStatusCode(statusEx.getStatusCode());
            responseBody.put(&quot;status&quot;, statusEx.getStatusCode());
            responseBody.put(&quot;message&quot;, statusEx.getMessage());
        } else {
            exchange.getResponse()
                .setStatusCode(ErrorType.TOKEN_AUTHORIZATION_FAIL.getStatusCode());
            responseBody.put(&quot;status&quot;, ErrorType.TOKEN_AUTHORIZATION_FAIL.getStatusCode());
            responseBody.put(&quot;message&quot;, ex.getMessage());
        }

        DataBuffer wrap = null;
        try {
            byte[] bytes = objectMapper.writeValueAsBytes(responseBody);
            wrap = exchange.getResponse().bufferFactory().wrap(bytes);
        } catch (JsonProcessingException e) {
            log.error(&quot;fatal error : {}&quot;, e.getMessage());
        }

        return exchange.getResponse().writeWith(Flux.just(Objects.requireNonNull(wrap)));
    }
}</code></pre>
<p>GlobalExceptionHandler에서는 예외가 발생한 경우 responseBody를 생성해 결과를 반환하게 했습니다.</p>
<h3 id="5-2-exceptionhandlerconfig">5-2. ExceptionHandlerConfig</h3>
<pre><code class="language-java">@Configuration
public class ExceptionHandlerConfig {

    @Bean
    public ErrorWebExceptionHandler errorWebExceptionHandler() {
        return new GlobalExceptionHandler();
    }
}</code></pre>
<p>5-1에서 작성한 GlobalExceptionHandler를 Configuration에 Bean으로 등록했습니다. 필터링중 예외가 발생하게 된다면 Bean으로 등록한 GlobalExceptionHandler가 해당 예외를 캐치해 ResponseBody를 생성해 클라이언트로 반환하게 됩니다.</p>
<h3 id="5-3-authexception">5-3. AuthException</h3>
<pre><code class="language-java">public class AuthException extends HttpStatusCodeException {

    public AuthException(ErrorType errorType) {
        super(errorType.getStatusCode(), errorType.getMessage());
    }

    @Override
    public String getMessage() {
        return getStatusText();
    }
}</code></pre>
<p>커스터마이징한 Exception입니다. 의도적인 예외가 발생한 경우 AuthException으로 throw되고 GlobalExceptionHandler를 통해 ResponseBody로 생성됩니다.</p>
<h2 id="6-api-limiter">6. API Limiter</h2>
<h3 id="6-1-tokenkeyconfig">6-1 TokenKeyConfig</h3>
<pre><code class="language-java">@Configuration
public class TokenKeyConfig {

    @Bean
    public KeyResolver tokenKeyResolver() {
        return exchange -&gt; {
            var cookies = exchange.getRequest().getCookies().getFirst(TokenType.ACCESS.getName());
            if (cookies != null) {
                return Mono.just(cookies.getValue());
            } else {
                String clientIp = Objects.requireNonNull(exchange.getRequest().getRemoteAddress())
                    .getAddress()
                    .getHostAddress();
                String hashedIp = DigestUtils.sha256Hex(clientIp);
                return Mono.just(hashedIp);
            }
        };
    }
}
</code></pre>
<p>쿠키가 있는 경우 쿠키값을 Mono로 감싸서 반환하고 없는 경우 클라이언트의 IP를 해싱처리해 Mono로 감싸 반환했습니다. 이를 통해 API Limiter에서 중복된 요청 등 과도한 트래픽을 일정 부분 제한할 수 있도록 합니다.</p>
<h2 id="7-eureka-client-활성화">7. Eureka Client 활성화</h2>
<pre><code class="language-java">@EnableDiscoveryClient
@SpringBootApplication
public class GatewayServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(GatewayServiceApplication.class, args);
    }

}</code></pre>
<p>Eureka Client로 등록하기 위해 <code>@EnableDiscoveryClient</code> 어노테이션을 붙여줍니다.</p>
<h2 id="8-config-server의-설정-파일-작성">8. Config Server의 설정 파일 작성</h2>
<p><strong>gateway-dev.yml</strong></p>
<pre><code class="language-shell">server:
  port: 8080

spring:
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
        - name: RequestRateLimiter
          args:
            key-resolver: &quot;#{@tokenKeyResolver}&quot;
            redis-rate-limiter.replenishRate: 5
            redis-rate-limiter.burstCapacity: 30
            redis-rate-limiter.requestedTokens: 3
      globalcors:
        cors-configurations:
          &#39;[/**]&#39;:
            allowedOrigins:
              - &#39;http://localhost:8081&#39;
              - &#39;http://localhost:8082&#39;
              - &#39;http://localhost:8083&#39;
              - &#39;http://localhost:8084&#39;
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders: &#39;*&#39;
            allow-credentials: true
      routes:
        - id: accommodation
          uri: lb://ACCOMMODATION
          predicates:
            - Path=/api/accommodations/**

        - id: member
          uri: lb://MEMBER
          predicates:
            - Path=/api/auth/**

        - id: reservation
          uri: lb://RESERVATION
          predicates:
            - Path=/api/reservation/**
          filters:
            - name: AuthorizationFilter

        - id: room
          uri: lb://room
          predicates:
            - Path=/api/accommodation/{accommodationId}/rooms/**

jwt:
  access-secret: # access secret key
  refresh-secret: # refresh secret key
  issuer: test
  access-token-expired-time: 86400000
  refresh-token-expired-time: 604800000

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://${eureka-username}:${eureka-password}@localhost:8761/eureka
</code></pre>
<p>이 
위에서부터 하나씩 설명을 하자면 다음과 같습니다.</p>
<pre><code class="language-shell">      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
        - name: RequestRateLimiter
          args:
            key-resolver: &quot;#{@tokenKeyResolver}&quot;
            redis-rate-limiter.replenishRate: 5
            redis-rate-limiter.burstCapacity: 30
            redis-rate-limiter.requestedTokens: 1</code></pre>
<p>GlobalFilter 라는 클래스를 디폴트로 등록하고, arguments로써 baseMessage와, preLogger, postLogger라는 변수에 각각의 값을 지정합니다.</p>
<p>RequestRateLimiter는 서버가 클라이언트의 시간당 요청 횟수를 제한하는 것으로 서버 과부하를 막기위해 사용했습니다.</p>
<p><code>replenisRate</code> : 초당 채워지는 Token의 수
<code>burstCapacity</code> : 최대 허용되는 버스트 요청 수
<code>requestedTokens</code> : 요청을 처리할 때 소모되는 토큰의 수</p>
<pre><code class="language-shell">      globalcors:
        cors-configurations:
          &#39;[/**]&#39;:
            allowedOrigins:
              - &#39;http://localhost:8081&#39;
              - &#39;http://localhost:8082&#39;
              - &#39;http://localhost:8083&#39;
              - &#39;http://localhost:8084&#39;
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders: &#39;*&#39;
            allow-credentials: true</code></pre>
<p>이 부분은 CORS에 해당되는 이야기로, localhost의 8081~8084에에서 GET, POST, PUT등 메서드들의 요청을 허가 하겠다는 의미입니다.</p>
<pre><code class="language-shell">      routes:
        - id: accommodation
          uri: lb://ACCOMMODATION
          predicates:
            - Path=/api/accommodations/**

        - id: member
          uri: lb://MEMBER
          predicates:
            - Path=/api/auth/**

        - id: reservation
          uri: lb://RESERVATION
          predicates:
            - Path=/api/reservation/**
          filters:
            - name: AuthorizationFilter

        - id: room
          uri: lb://room
          predicates:
            - Path=/api/accommodation/{accommodationId}/rooms/**</code></pre>
<p>각 predicates의 값 이하의 path가 들어오면 해당 id의 uri로 라우팅해준다는 의미입니다.
그리고 reservation의 경우 filters가 추가되어 있는데 특정 서비스에만 filter를 추가하는 의미로 인증/인가 filter를 추가했습니다.
jwt와 eureka는 생략하겠습니다.</p>
<h1 id="동작-확인">동작 확인</h1>
<p>먼저 Config Server, Eureka Server를 실행한 뒤 Gateway Service를 실행합니다</p>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/67ada27f-7c98-4db9-803b-f62215a5064b/image.png">

<p>Gateway Service가 정상 실행하고 Eureka Server에 등록이 되어있다면 </p>
<p><a href="http://localhost:8761">http://localhost:8761</a> 에 접속해 Eureka Server의 username과 password를 입력해 로그인합니다.</p>
<p>그러면 아래와 같이 GATEWAY 서비스가 등록이 되어 있는 것을 볼 수 있습니다.
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/4592b087-b49f-4159-a8f4-1ba95d70f073/image.png"></p>
<p>이제 라우팅이 정상적으로 동작하는지 확인해보겠습니다.</p>
<p>저는 현재 Member Service의 port를 8082로 설정한 상태입니다.
Gateway가 없다면 Member Service를 이용하기 위해 localhost:8082로 요청을 해야하지만 Gateway가 라우팅을 해주므로 localhost:8080으로 요청을 보내면 됩니다.
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/1fdcd171-e996-41b3-b150-07dbd6108512/image.png"></p>
<h1 id="마무리">마무리</h1>
<p>다음은 OpenFeign에 대해 포스팅하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] Eureka Server 구축]]></title>
            <link>https://velog.io/@c_mungi/MSA-Eureka-Server-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@c_mungi/MSA-Eureka-Server-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Tue, 24 Sep 2024 14:26:01 GMT</pubDate>
            <description><![CDATA[<p>저번 포스트에 언급했듯이 이번엔 Eureka Server의 구축에 대한 이야기를 해보고자 합니다.</p>
<h1 id="eureka란">Eureka란</h1>
<p>Nefilx에서 제공하는 OSS Service Registry로 Eureka Server와 Eureka Client로 이루어져있습니다.
이 중 Eureka Server는 마이크로 서비스들의 정보를 발견해 Registry에 등록하고 로드밸런싱을 제공하는 미들웨어 서버입니다.</p>
<p><img width=700 src="https://velog.velcdn.com/images/c_mungi/post/41fbaa54-2277-43ca-84b3-5822eeefab7e/image.png"></img></p>
<blockquote>
<p>이미지 출처 : <a href="https://www.dineshonjava.com/implementing-microservice-registry-with-eureka/">https://www.dineshonjava.com/implementing-microservice-registry-with-eureka/</a></p>
</blockquote>
<h1 id="eureka-server-구축">Eureka Server 구축</h1>
<p>Eureka Server는 위에 언급했듯이 마이크로 서비스들의 정보를 Registry에 등록합니다.
이 때의 마이크로 서비스들은 Eureka Client로 정의됩니다.</p>
<p>Eureka Server는 Eureka Client의 Heartbeat를 수신해 해당하는 Eureka Client가 정상적으로 수행중인지 체크합니다.
Heartbeat가 정상적으로 수신되지 않는다면 Eureka Server는 Eureka Client가 수행 중이 아니라고 판단해 해당 Eureka Client의 정보를 Registry에서 삭제합니다.</p>
<h2 id="1-의존성-주입">1. 의존성 주입</h2>
<p><strong>build.gradle</strong></p>
<pre><code class="language-shell">ext {
    set(&#39;springCloudVersion&#39;, &quot;2023.0.3&quot;)
}

dependencies {

    // security
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
    testImplementation &#39;org.springframework.security:spring-security-test&#39;

    // dev
    compileOnly &#39;org.projectlombok:lombok&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;

    // spring cloud config
    implementation &#39;org.springframework.cloud:spring-cloud-starter-config&#39;

    // spring cloud eureka server
    implementation &#39;org.springframework.cloud:spring-cloud-starter-netflix-eureka-server&#39;
}

dependencyManagement {
    imports {
        mavenBom &quot;org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}&quot;
    }
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}</code></pre>
<p>위와 같이 저는 Eureka Server 의존성 외에 Security와 Spring Cloud Config를 추가로 의존성 주입했습니다.
이유는 다음과 같습니다.</p>
<p><strong>Spring Security</strong> : config server와 마찬가지로 Eureka Server에는 각 마이크로 서비스들의 정보가 등록이 되므로 보안상 중요 정보들이 다뤄지기 때문에 Security 의존성이 필요했습니다.</p>
<p><strong>Spring Cloud Config</strong> : Config Server를 통해 Eureka에 필요한 환경 변수들을 가져올 예정이므로 필요했습니다.</p>
<h2 id="2-applicationyml-수정">2. application.yml 수정</h2>
<p><strong>application.yml</strong></p>
<pre><code class="language-shell">spring:
  application:
    name: ${EUREKA_APP_NAME}
  profiles:
    active: ${APP_PROFILE}
  config:
    import: optional:configserver:${CONFIG_SERVER_URI}</code></pre>
<p><strong>${EUREKA_APP_NAME}</strong> : Eureka Server의 이름입니다. ${APP_PROFILE}과 조합해 설정 파일을 특정합니다.</p>
<p><strong>${APP_PROFILE}</strong> : dev 또는 prod와 같은 프로파일 이름입니다. ${EUREKA_APP_NAME}과 조합해 설정파일을 특정합니다.</p>
<p><strong>${CONFIG_SERVER_URI}</strong> : 설정 파일을 관리하고 있는 Config Server의 URI입니다. </p>
<pre><code>ex ) http://${CONFIG_USERNAME}:${CONFIG_PASSWORD}@localhost:8888</code></pre><blockquote>
<p>CONFIG_USERNAME과 CONFIG_PASSWORD는 이전 포스트를 확인해주세요.</p>
</blockquote>
<h2 id="3-로직-작성">3. 로직 작성</h2>
<p><strong>SecurityConfig.java</strong></p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
public class SecurityConfig implements WebMvcConfigurer {

    @Value(&quot;${eureka.username}&quot;)
    private String username;

    @Value(&quot;${eureka.password}&quot;)
    private String password;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests((auth) -&gt; auth.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .build();
    }

    @Bean
    public UserDetailsService userDetailsService() {

        UserDetails user = User.builder()
            .username(username)
            .password(bCryptPasswordEncoder().encode(password))
            .roles(&quot;ADMIN&quot;)
            .build();

        return new InMemoryUserDetailsManager(user);
    }</code></pre>
<p>Security의 구조는 Config Server와 동일하고 이전 포스트에서 말씀드렸듯이 프로젝트 기획에 따라 DB와의 연동해도 무방합니다.</p>
</br>

<p><strong>EurekaServerApplication.java</strong></p>
<p>아래와 같이 메인클래스에 <strong>@EnableEurekaServer</strong> 어노테이션을 붙혀 활성화합니다.</p>
<pre><code class="language-java">@EnableEurekaServer
@SpringBootApplication
public class EurekaServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}
</code></pre>
<h2 id="4-동작-확인">4. 동작 확인</h2>
<p>Eureka Server를 실행하기 위해선 Config Server가 먼저 실행되어야 합니다. 그 후 Eureka Server를 실행했을 때 문제가 없다면 아래와 같이 8761 포트 번호로 실행이 되는 것을 확인할 수 있습니다.</p>
<p><img width=900 src="https://velog.velcdn.com/images/c_mungi/post/82725020-1300-45c1-9fcf-b39b428844bb/image.png"></img></p>
<p>이후 웹에서 <a href="http://localhost:8761">http://localhost:8761</a> 로 접속해 User name과 Password를 입력하면 다음과 같은 페이지가 뜨면 정상적으로 Server가 동작하고 있는 것입니다.
다만 현재로썬 마이크로 서비스를 등록하지 않았기에 <strong>Instances currently registered with Eureka</strong> 부분이 공란인 것을 확인할 수 있습니다. 등록하게 된다면 해당 마이크로 서비스의 정보를 볼 수 있습니다.
<img width=900 src="https://velog.velcdn.com/images/c_mungi/post/a3189439-83b1-484e-a58a-39ca2b5d9a7a/image.png"></img></p>
<hr>
<p>다음은 게이트웨이 서비스에 대해 포스팅하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] Config Server 구축]]></title>
            <link>https://velog.io/@c_mungi/MSA-Config-Server-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@c_mungi/MSA-Config-Server-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Fri, 20 Sep 2024 11:33:59 GMT</pubDate>
            <description><![CDATA[<p>이번 포스트에서는 각 서비스들이 기동할 때 필요한 환경 변수들을 관리하는 Config Server에 대한 설명과 구축 방법에 대해 설명해보고자 합니다.</p>
<h1 id="config-server란">Config Server란</h1>
<p><img width=700 src="https://velog.velcdn.com/images/c_mungi/post/6231fcbb-4b49-4759-ab1a-c8cf704e9c1a/image.png"></img></p>
<pre><code>스프링 클라우드 구성은 분산 시스템에서 외부화된 구성을 위한 서버 및 클라이언트 측 지원을 제공합니다. 
Config 서버를 사용하면 애플리케이션의 환경 변수들을 관리할 수 있는 중앙 위치를 확보할 수 있습니다. 
클라이언트와 서버의 개념은 스프링 환경 및 속성소스 추상화와 동일하게 매핑되므로 스프링 애플리케이션과 
매우 잘 맞지만 모든 언어로 실행되는 모든 애플리케이션에서 사용할 수 있습니다. 
애플리케이션이 배포 파이프라인을 통해 개발자에서 테스트 및 프로덕션으로 이동할 때 해당 환경 간의 구성을 관리하고 
애플리케이션이 마이그레이션할 때 실행하는 데 필요한 모든 것을 갖추고 있는지 확인할 수 있습니다. 
서버 스토리지 백엔드의 기본 구현은 git을 사용하므로 레이블이 지정된 버전의 구성 환경을 쉽게 지원할 뿐만 아니라 
콘텐츠 관리를 위한 광범위한 도구에 액세스할 수 있습니다. 
대체 구현을 추가하고 스프링 구성으로 쉽게 연결할 수 있습니다.</code></pre><p>Config Server를 사용함으로써 애플리케이션들의 환경 변수를 한 곳에서 집중 관리가 가능하다고 공식 문서에 언급되어 있습니다. 이를 통해 각 애플리케이션이 기동할 때 Config Server로 요청을 하고 Config Server는 해당 애플리케이션의 환경 변수들을 보내줌으로써 애플리케이션의 실행을 지원하게 됩니다.</p>
<p>자세한 내용은 <a href="https://spring.io/projects/spring-cloud-config">Spring Config 공식 문서</a> 를 참고바랍니다.</p>
</br>

<h1 id="config-server-구축">Config server 구축</h1>
<p>이전 포스트에서 하위 모듈들을 구축하셨다면 아래와 같은 config-server 모듈이 존재할 것이고 오늘은 이 config-server를 구축해 이후 각 서비스들이 config-server를 통해 환경 변수를 조회 및 관리를 할 수 있도록 하겠습니다.
<img width=400 src="https://velog.velcdn.com/images/c_mungi/post/0cd07ec2-f2b5-4263-9c8f-ede9d12c3130/image.png"></img></p>
<p>구축해야 할 내용은 다음과 같습니다.</p>
<ol>
<li>의존성 주입 ( build.gradle )</li>
<li>로직 작성</li>
<li>config server용 git repository 생성</li>
<li>application.yml 수정</li>
<li>동작 확인</li>
</ol>
<h2 id="1-의존성-주입">1. 의존성 주입</h2>
<p>의존성은 lombok, Spring Cloud Config Server, Spring Security 3가지가 필요합니다.</p>
</br>

<p><strong>build.gradle</strong></p>
<pre><code class="language-shell">dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
    implementation &#39;org.springframework.cloud:spring-cloud-config-server&#39;
    compileOnly &#39;org.projectlombok:lombok&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}</code></pre>
<p>Security를 사용하는 이유는 다음과 같습니다.</p>
<p>각 애플리케이션의 환경 변수들을 관리하는 만큼 민감한 정보들을 다루게 됩니다. 민감한 정보들이란, DB 정보, 외부 API의 키 등입니다.</p>
<p>이처럼 보안이 중요한 요소가 있기에 Spring Security를 활용해 인증된 애플리케이션만 환경 변수들을 가져가도록 합니다.
또한 만일에 대비해 누가 언제 어떠한 설정에 접근했는지 추적을 할 수도 있습니다.</p>
<h2 id="2-로직-작성">2. 로직 작성</h2>
<p>로직은 Security에 대한 작성이 대부분입니다. SecurityConfig를 작성하겠습니다.
username과 password는 보안적인 대책이 필요하므로 따로 환경변수로 입력받게끔 했습니다.
필요하다면 프로젝트 기획에 따라 외부 인증서버와 연결해 관리자 계정을 관리하도록 하는 방법도 있지만 학습용이기에 확장하지 않고 사용하는 형태로 작성했습니다.</p>
<p><strong>SecurityConfig.java</strong></p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
public class SecurityConfig implements WebMvcConfigurer {

    @Value(&quot;${config.username}&quot;)
    private String username;

    @Value(&quot;${config.password}&quot;)
    private String password;

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests((auth) -&gt; auth.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .build();
    }

    @Bean
    public UserDetailsService userDetailsService() {

        UserDetails admin = User.builder()
            .username(username)
            .password(passwordEncoder().encode(password))
            .roles(&quot;ADMIN&quot;)
            .build();

        return new InMemoryUserDetailsManager(admin);
    }
}</code></pre>
<p>Security에 대한 작성은 여기까지가 전부이고 ConfigServer를 활성화하기 위해 main클래스에 @EnableConfigServer 어노테이션을 추가해야 합니다.</p>
<pre><code class="language-java">@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}</code></pre>
<h2 id="3-git-repository-생성">3. git repository 생성</h2>
<p>본래 Config Server에서 환경 변수들을 관리하는 방법은 여러가지 있습니다.</p>
<p><img width=700 src="https://velog.velcdn.com/images/c_mungi/post/11ab733f-492e-4da4-a6ab-0bf746cd0351/image.png"></img></p>
<blockquote>
<p>이미지 출처 : <a href="https://medium.com/javarevisited/centralized-configuration-for-microservices-using-spring-cloud-config-c7845cbccc34">https://medium.com/javarevisited/centralized-configuration-for-microservices-using-spring-cloud-config-c7845cbccc34</a></p>
</blockquote>
<p>위의 이미지와 같이 여러 방법들이 존재 하지만 Git 서비스를 가장 많이 사용한다고 합니다.</p>
<p>먼저 GitHub에서 다음 이미지와 같이 리포지토리를 생성을 하는데 꼭 Private로 만들어야 합니다.
위에서 언급했듯이 민감한 정보들을 다루니 Public 리포지토리를 만들어선 안됩니다.
이후, 비대칭 키를 통해서 리포지토리로 접속 할 예정입니다.
<img width=600 src="https://velog.velcdn.com/images/c_mungi/post/1632d96a-f8f4-4446-8ec8-d1d33831b19d/image.png"></img></p>
<h3 id="3-1-ssh-key-생성">3-1. SSH Key 생성</h3>
<p>Private로 만들어진 리포지토리를 외부에서 접속할 수 있도록 SSH Key를 생성해 위에서 생성한 리포지토리에 등록을 해야 합니다.</p>
<p>이때 쉘 환경에서 다음 명령어를 통해 생성하면 되는데 
Windows 환경의 경우 git bash, Mac 환경의 경우 Terminal을 사용하면 됩니다.</p>
<p>다만 Key를 생성할때엔 많은 사람들이 rsa알고리즘을 사용하기도 하는데 Github에서는 rsa 알고리즘에 대한 보안 취약점이 있어 SHA-1 Error가 발생한다고 합니다. 그래서 저는 ecdsa 알고리즘을 사용하고자 합니다.</p>
<pre><code class="language-shell">ssh-keygen -m PEM -t ecdsa -b 256 -C &quot;임의의 코멘트&quot;</code></pre>
<p>명령어를 실행하면 /사용자/.ssh 경로에 생성 됩니다. 
<img width=600 src="https://velog.velcdn.com/images/c_mungi/post/6ef03dd9-d00a-4a2f-8db0-7bfefb9484f7/image.png"></img></p>
<p>.ssh 경로가 어딘지 모르겠다면 아래 명령어를 실행하면 됩니다.</p>
<p>Mac의 경우</p>
<pre><code class="language-shell">echo $HOME/.ssh
</code></pre>
<p>Windows의 경우</p>
<pre><code class="language-shell">echo %userprofile%\.ssh</code></pre>
<p>생성된 SSH Key 중 pub 확장자의 파일에 저장된 키 내용을 복사해주세요.</p>
<p>이후 리포지토리의  Settings &gt; Security 탭의 Deploy keys &gt; Add deploy key 버튼 클릭해주시고 임의의 제목을 입력한 후 Key 란에 복사했던 내용을 붙여넣어 주세요.</p>
<p>저는 이 리포지토리에 설정 파일들에 대한 작성 및 수정이 있을 예정이라 아래 Allow write access 체크박스에 체크를 했습니다. ( <strong>체크를 하지 않는다면 IDE에서 파일 작성, 수정하고 PUSH하게 되면 작성 권한이 없어 에러가 발생합니다.</strong> )</p>
<p><img width=600 src="https://velog.velcdn.com/images/c_mungi/post/a0f25471-6069-479a-a91b-72a1ee4351ec/image.png"></img></p>
<p>Add key 버튼을 누르면 다음과 같이 키가 등록된 것을 확인 할 수 있습니다.
<img width=600 src="https://velog.velcdn.com/images/c_mungi/post/0d8b6287-2980-455c-960c-8e639ddbc6f8/image.png"></img></p>
<p>이후, Terminal 또는 Git bash에서 아래의 명령어를 실행하면 해당 SSH Key가 출력이 되는데 설정 파일(yml, properties)에 입력해야하니 현 단계에선 준비까지만 해주세요.</p>
<pre><code class="language-shell"># ex) cat id_ecdsa
cat 생성한 SSH Key 이름</code></pre>
<p>명령어를 실행하면 아래와 같은 값들이 출력이 됩니다.
<img width=600 src="https://velog.velcdn.com/images/c_mungi/post/b1c52da9-34c0-4ad5-99bb-7e282b3fb1fc/image.png"></img></p>
<h2 id="4-applicationyml-수정">4. application.yml 수정</h2>
<p>프로젝트 또는 모듈을 생성하면 src/main/resoruce 경로에 application.properties 라는 파일이 생성되어 있을텐데 편의상 application.yml로 수정했습니다.</p>
<pre><code class="language-yaml">server:
  port: ${CONFIG_SERVER_PORT} # 일반적으로 8888 포트가 많이 사용됩니다.

spring:
  application:
    name: ${CONFIG_APP_NAME}
  cloud:
    config:
      server:
        git:
          uri: ${CONFIG_GIT_URI} # Github의 ssh uri를 복사해서 붙여넣습니다
          search-paths: config-file/**
          default-label: main
          ignore-local-ssh-settings: true
          privateKey: |
            -----BEGIN EC PRIVATE KEY-----
            3번에서 cat 명령어로 출력한 값을 여기에 붙여넣습니다
            -----END EC PRIVATE KEY-----
          host-key: ${GIT_HOST_KEY}
          host-key-algorithm: ${GIT_HOST_ALGORITHM}
config:
  username: ${CONFIG_USERNAME}
  password: ${CONFIG_PASSWORD}</code></pre>
<p><strong>${CONFIG_SEVER_PORT}</strong> : 일반적으로 8888 포트 번호를 많이 사용합니다.</p>
<p><strong>${CONFIG_APP_NAME}</strong> : 임의의 문자열을 넣으셔도 되고 이름 설정을 하고 싶지 않으시다면 해당 설정을 삭제 하셔도 무방합니다.</p>
<p><strong>${CONFIG_GIT_URI}</strong> : 아래와 같이 SSH의 URI를 복사해 붙여넣습니다.</p>
<p><img width=350 src="https://velog.velcdn.com/images/c_mungi/post/66e6c3af-d2b3-4ea7-a93a-dc61657156f5/image.png"></img></p>
<p><strong>${GIT_HOST_KEY}</strong> : 아래 명령어를 실행해서 출력된 값을 base64로 인코딩해 붙여넣습니다. </p>
<pre><code class="language-shell">ssh-keyscan -t ssh-rsa github.com</code></pre>
<p><strong>${GIT_HOST_ALGORITHM}</strong> : ecdsa 알고리즘으로 키를 생성했기 때문에 <code>ecdsa-sha2-nistp256</code> 를 적습니다.</p>
<p><strong>${CONFIG_USERNAME}</strong>과 <strong>${CONFIG_PASSWORD}</strong> 의 경우는 필요에 따라 설정을 하셔도 되고 Security 로직 작성 내용에서 언급했듯이 프로젝트 기획에 따라 대응하면 됩니다.</p>
<h2 id="5-동작-확인">5. 동작 확인</h2>
<p>위에서 작성한 내용이 잘 실행 되는지 로컬 환경에서 Config Server 애플리케이션을 동작해본 결과는 다음과 같습니다.
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/4e569a32-8bd1-4add-a936-37c24cf8c8c2/image.png"></img></p>
<p>그리고 실제 웹에서 접속하게되면 입력한 경로에 맞는 설정 파일 내용들을 조회할 수 있습니다.
(※ 아래 이미지는 추후 서비스 애플리케이션의 설정파일들을 Config Server용 리포지토리에 등록하게 되면 확인 가능합니다.)</p>
<p><img width=700 src="https://velog.velcdn.com/images/c_mungi/post/5a336b30-d0e1-4b9f-a10a-3accba39ce54/image.png"></img>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/6e222fed-8b2a-4a09-ac81-10f9238a77fa/image.png"></img></p>
<h2 id="추가">추가</h2>
<p>동작 확인에서 언급했지만 각 서비스 애플리케이션의 설정파일들을 Config Server용 리포지토리에 등록을 해야하는데 다음과 같은 파일명 규칙에 맞춰 push해야만 합니다.</p>
<pre><code>저장소 이름-저장소 환경.properties 또는 저장소 이름-저장소 환경.yml 일것.</code></pre><p>위와 같은 규칙에 맞춰 설정 파일 이름이 되어야 아래와 같이 주소 변환이 이루어져 서버에서 동작됩니다.</p>
<pre><code>http:HOST:PORT/저장소 이름/저장소 환경</code></pre><p>예를들어 localhost:8888이고 yml의 이름을 <code>accommodation-prod.yml</code>인 경우
<code>http:localhost:8888/accommodation/prod</code> 로 접속이 가능하고 설정 내용들을 조회할 수 있습니다.</p>
</br>
다음은 Eureka Server 주제로 포스팅하겠습니다.]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] Spring Cloud 프로젝트 구성하기]]></title>
            <link>https://velog.io/@c_mungi/MSA-Spring-Cloud-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@c_mungi/MSA-Spring-Cloud-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 19 Sep 2024 12:44:28 GMT</pubDate>
            <description><![CDATA[<h2 id="이전-프로젝트의-구조">이전 프로젝트의 구조</h2>
<hr>
<p>미니 프로젝트에서는 프론트 3명, 백엔드 5명이 하나의 팀이 되어 프로젝트 수행을 했습니다.
아래는 당시 프로젝트의 구조이고 모놀리스 애플리케이션입니다.</p>
<pre><code>- 회원 도메인
  - 로그인
  - 회원 가입
  - 회원 탈퇴

- 숙소 도메인
  - 전체 조회( 조건 O )
  - 전체 조회( 조건 X )
  - 숙소 상세 조회

- 객실 도메인
  - 객실 조회 ( 조건 O )
  - 객실 상세 조회

- 예약 도메인
  - 예약 하기
  - 예약 조회
  - 예약 취소

- 찜 도메인
  - 찜 누르기
  - 찜 취소하기

- 장바구니 도메인
  - 장바구니 추가
  - 장바구니 조회
  - 장바구니 취소

- 리뷰 도메인
  - 리뷰 추가
  - 리뷰 조회
  - 리뷰 수정
  - 리뷰 삭제</code></pre><h2 id="spring-cloud-프로젝트-구성">Spring Cloud 프로젝트 구성</h2>
<hr>
<p>이번 프로젝트에서는 이벤트 기반 MSA가 중점이니 최소한의 필요 서비스들로만 구성했습니다.</p>
<pre><code>- Config 서비스

- Eureka 서비스

- Gateway 서비스
  - 로드 밸런스
  - 시큐리티 ( JWT 인증 / 인가 )

- 숙소 서비스
  - 숙소 전체 조회
  - 숙소 상세 조회

- 객실 서비스
  - 객실 전체 조회
  - 객실 상세 조회

- 회원 서비스
  - 회원 가입
  - 로그인
  - 로그아웃
  - 회원 탈퇴

- 예약 서비스
  - 예약 하기
  - 예약 조회
  - 예약 취소</code></pre></br>

<h2 id="멀티-모듈-프로젝트-생성">멀티 모듈 프로젝트 생성</h2>
<hr>
<h3 id="1-루트-모듈-생성">1. 루트 모듈 생성</h3>
<p>일단 루트 모듈이될 프로젝트 부터 생성을 하고 기존 코드들은 모두 하위 모듈로 구성할 것이기에 루트 모듈은 아래 작업만 진행합니다.</p>
<ul>
<li>새 프로젝트 생성</li>
<li>src 폴더 삭제</li>
<li>루트 모듈의 build.gradle 설정</li>
<li>settings.gradle에서 하위 모듈 추가</li>
</ul>
<p><img width=400 src="https://velog.velcdn.com/images/c_mungi/post/c8782c5d-0c63-4115-85e1-4d643d233fff/image.png"></img>
루트 모듈을 담당할 프로젝트 생성 후 src폴더 등을 삭제해 위와 같은 파일들만 존재하면 됩니다.</p>
</br>

<p><strong>build.gradle</strong></p>
<pre><code class="language-shell">plugins {
    id &#39;java&#39;
    id &#39;org.springframework.boot&#39; version &#39;3.3.2&#39;
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.6&#39;
}

allprojects {
    group = &#39;com.fp&#39;
    version = &#39;0.0.1-SNAPSHOT&#39;

    repositories {
        mavenCentral()
    }
}

subprojects {

    apply plugin: &#39;java&#39;
    apply plugin: &#39;org.springframework.boot&#39;
    apply plugin: &#39;io.spring.dependency-management&#39;
    apply plugin: &#39;java-test-fixtures&#39;

    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(17)
        }
    }

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    ext {
        set(&#39;springCloudVersion&#39;, &quot;2023.0.3&quot;)
    }

    dependencyManagement {
        imports {
            mavenBom &quot;org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}&quot;
        }
    }
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

bootJar {
    enabled = false
}
jar {
    enabled = false
}</code></pre>
</br>

<p><strong>setting.gradle</strong></p>
<pre><code class="language-shell">rootProject.name = &#39;msa&#39;
include &#39;config-server&#39;
include &#39;eureka-server&#39;
include &#39;gateway-service&#39;
include &#39;accommodation-service&#39;
include &#39;member-service&#39;
include &#39;reservation-service&#39;
include &#39;room-service&#39;</code></pre>
<h3 id="2-하위-모듈-생성">2. 하위 모듈 생성</h3>
<p>서비스를 담당할 하위 모듈들은 의존성을 제외하자면 설정이 동일하기 때문에 accommodation-service 모듈로 예시를 적겠습니다. 추후 추가 기능등을 위해 의존성이 추가될 예정 입니다.</p>
<br>

<p><strong>build.gradle</strong></p>
<pre><code class="language-shell">dependencies {

    // boot
    implementation &#39;org.springframework.boot:spring-boot-starter&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;

    // dev
    compileOnly &#39;org.projectlombok:lombok&#39;
    annotationProcessor &#39;org.projectlombok:lombok&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;

    // db
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    runtimeOnly &#39;com.mysql:mysql-connector-j&#39;
}

tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}

tasks.register(&quot;prepareKotlinBuildScriptModel&quot;) {}

bootJar {
    enabled = true
}

jar {
    enabled = false
}
</code></pre>
<p>위 처럼 필요 의존성을 추가한 하위 모듈들을 생성하게 되면 다음과 같은 구성이 만들어집니다.
<img width=400 src="https://velog.velcdn.com/images/c_mungi/post/d3ddb91e-8bd2-483f-9e3b-a18098a835c1/image.png"></img></p>
<p>다음 포스트는 config-server를 구축해 각 서비스들의 환경변수들을 관리하고 각 서비스들을 기동할 때 config-server가 관리하고 있는 환경변수들을 조회하는 내용을 적어보도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MSA] MSA Migration ]]></title>
            <link>https://velog.io/@c_mungi/MSA-MSA-Migration</link>
            <guid>https://velog.io/@c_mungi/MSA-MSA-Migration</guid>
            <pubDate>Tue, 20 Aug 2024 16:24:09 GMT</pubDate>
            <description><![CDATA[<h2 id="msa-시작">MSA 시작</h2>
<hr>
<p>패스트 캠퍼스 파이널 프로젝트의 RFP가 가고자 하는 방향과 달라 개인 프로젝트로 전환..🥲</p>
<p>개인 프로젝트를 어떠한 주제로 할지 고민하던 차에 미니 프로젝트의 결과물을 MSA 환경으로 마이그레이션을 한 후 Kafka를 통해 서비스간 통신을 구현하는 프로젝트 주제를 멘토님께 제안받아 시작되었습니다.</p>
</br>

<h2 id="monolith-architecture란">Monolith Architecture란</h2>
<hr>
<p>모놀리스 아키텍처란 MSA의 반대 되는 개념으로 이제껏 우리가 해왔던 방식을 뜻합니다.</p>
<p>여러 도메인(서비스)를 하나의 통합된 소프트웨어 시스템 내에서 관리를 하고 특정 도메인(서비스)를 수정하더라도 전체 시스템을 대상으로 배포를 해야 합니다.</p>
</br>

<h2 id="msa란">MSA란</h2>
<hr>
<p><code>Micro Service Architecture</code> 의 약자로 다음과 같이 설명이 가능합니다.</p>
<blockquote>
<p> <strong>소프트웨어 시스템을 여러 작은 독립적인 도메인(서비스)로 분할하여 개발하고 배포하는 방식</strong></p>
</blockquote>
<p>MSA는 각 도메인(서비스)별 소스 코드 수정 및 관리가 쉽고, 독립적이기 때문에 수정한 도메인(서비스)만 배포가 가능하며, 배포 시 전체 서비스 중단이 없다라는 특징을 지니고 있습니다.</p>
<p>또한, 독립적이기에 예기치 못한 장애가 발생하더라도 해당 도메인(서비스)에 국한되고, 전체적인 장애로 확장될 가능성이 매우 낮습니다. </p>
<p>마지막으로 시스템의 규모가 확장되더라도 해당 도메인(서비스)만 추가하면 되기에 스케일 아웃이 용이합니다.</p>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/29410435-bcc7-4a42-a46e-a3fd8498017a/image.png"></img></p>
<blockquote>
<p>MSA와 Monolithic의 비교.
출처 - <a href="https://metanetglobal.com/bbs/board.php?bo_table=tech&amp;wr_id=38">https://metanetglobal.com/bbs/board.php?bo_table=tech&amp;wr_id=38</a></p>
</blockquote>
</br>

<h2 id="msa-마이그레이션시-고려해야할-점">MSA 마이그레이션시 고려해야할 점</h2>
<hr>
<p>모놀리스 아키텍처라면 하나의 시스템에 각 도메인(서비스)들을 모아둔 형태이다 보니 의존성 주입을 하면 사실상 고민할 이유가 없는 내용이지만 MSA는 각 도메인(서비스)가 독립되어 있고 모놀리식 아키텍처와 같이 의존성 주입을 하게 된다면 주입한 의존성의 서비스에 장애가 발생할 시에 다른 서비스 또한 영향을 받게되므로 다음과 같은 사항에 대해 고려를 해야했습니다.</p>
<ol>
<li><p>외부의 요청에 대한 서비스 라우팅</p>
<ul>
<li>MSA에서는 도메인(서비스)별로 독립적인 시스템이 되었기에 외부 요청에 대한 처리가 필요합니다.</li>
<li>외부 요청이 발생하면 어느 도메인(서비스)의 요청인지 분기 처리를 해야하기 때문입니다.</li>
</ul>
</li>
</ol>
<blockquote>
<p>Spring Cloud Routing의 GateWay 의존성을 통해 분기 처리 가능.</p>
</blockquote>
<ol start="2">
<li><p>도메인(서비스)간의 통신</p>
<ul>
<li>A도메인(서비스)에서 B도메인(서비스)의 정보에 대한 CRUD 처리가 필요할 때 Request의 전달 방법이 필요합니다.</li>
</ul>
<blockquote>
<p>여러 기술 스택이 존재하지만 Kafka의 비동기 이벤트 Pub/Sub을 통해 구현 가능.</p>
</blockquote>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩테스트] 프로그래머스 Lv.2 게임 맵 최단거리]]></title>
            <link>https://velog.io/@c_mungi/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-Lv.2-%EA%B2%8C%EC%9E%84-%EB%A7%B5-%EC%B5%9C%EB%8B%A8%EA%B1%B0%EB%A6%AC</link>
            <guid>https://velog.io/@c_mungi/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-Lv.2-%EA%B2%8C%EC%9E%84-%EB%A7%B5-%EC%B5%9C%EB%8B%A8%EA%B1%B0%EB%A6%AC</guid>
            <pubDate>Sat, 13 Apr 2024 12:42:43 GMT</pubDate>
            <description><![CDATA[<h2 id="문제">문제</h2>
<hr>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/56255fb1-9096-4118-aeaf-850304c5f7b4/image.png"></img></p>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/01e38908-9e93-49db-a547-95c157c9a182/image.png"></img></p>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/c5405f54-c2e1-4c26-b3f4-df31eff89546/image.png"></img></p>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/aeb75aa9-9af3-4ce6-8778-72dcf24d19a8/image.png"></img></p>
<br>

<h3 id="풀이-과정">풀이 과정</h3>
<hr>
<ul>
<li>탐색에 관한 문제이기 때문에 BFS/DFS를 고려<ul>
<li>최단 거리를 찾아야 하는 문제이기에 BFS를 선택<ul>
<li>DFS의 경우 얻어진 해가 최단 경로가 된다는 보장이 없기 때문.</li>
</ul>
</li>
</ul>
</li>
<li>먼저 이동할 4방향에 대한 이동 거리를 상수로 표현</li>
<li>맵의 행과 열의 길이를 추출</li>
<li>boolean 2차원 배열을 생성해 방문했는지 체크</li>
<li>queue를 돌려 탐색</li>
</ul>
<br>

<h3 id="작성-코드">작성 코드</h3>
<hr>
<pre><code class="language-java">import java.util.*;
class Solution {
    private final static int[][] DIRECTIONS = new int[][]{{0,1},{0,-1},{1,0},{-1,0}};
    public int solution(int[][] maps) {

        int rowLength = maps.length;
        int colLength = maps[0].length;

        Queue&lt;int[]&gt; queue = new LinkedList&lt;&gt;();
        boolean[][] visited = new boolean[rowLength][colLength];

        // 첫번째 vertex 방문
        visited[0][0] = true;
        queue.offer(new int[]{0,0,1}); // 0,0 : 위치 | 1 : 카운트

        while(!queue.isEmpty()){
            int[] cur = queue.poll();

            if(cur[0] == rowLength - 1 &amp;&amp; cur[1] == colLength - 1){
                // 맵의 (n,m)위치에 도달한 경우 이동한 칸 수를 반환
                return cur[2];
            }

            for(int i = 0; i &lt; DIRECTIONS.length; i++){
                int nextRow = DIRECTIONS[i][0] + cur[0];
                int nextCol = DIRECTIONS[i][1] + cur[1];

                if(isValid(rowLength, colLength, nextRow, nextCol)){
                    if(!visited[nextRow][nextCol]&amp;&amp; maps[nextRow][nextCol] == 1){
                        queue.offer(new int[]{nextRow,nextCol, cur[2]+1});
                        visited[nextRow][nextCol] = true;
                    }
                }
            }
        }

        return -1;
    }

    // 이동할 위치가 맵의 밖으로 이동하지 않는지 체크
    private boolean isValid(int rowLength, int colLength, int nextRow, int nextCol){
        return nextRow &gt;= 0 &amp;&amp; nextRow &lt; rowLength &amp;&amp; nextCol &gt;= 0 &amp;&amp; nextCol &lt; colLength;
    }
}</code></pre>
<br>

<h3 id="실행-결과">실행 결과</h3>
<hr>
<p><img width=600 src="https://velog.velcdn.com/images/c_mungi/post/2ff559b9-06a9-42b4-ae58-d9addf94a590/image.png"></img></p>
<p><img width=600 src="https://velog.velcdn.com/images/c_mungi/post/62844fa9-551f-4f6d-9dee-5da7400dbc2e/image.png"></img></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩테스트] 프로그래머스 Lv.1 완주하지 못한 선수]]></title>
            <link>https://velog.io/@c_mungi/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-Lv.1-%EC%99%84%EC%A3%BC%ED%95%98%EC%A7%80-%EB%AA%BB%ED%95%9C-%EC%84%A0%EC%88%98</link>
            <guid>https://velog.io/@c_mungi/%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-Lv.1-%EC%99%84%EC%A3%BC%ED%95%98%EC%A7%80-%EB%AA%BB%ED%95%9C-%EC%84%A0%EC%88%98</guid>
            <pubDate>Sat, 13 Apr 2024 12:13:20 GMT</pubDate>
            <description><![CDATA[<h2 id="문제">문제</h2>
<hr>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/eb4c5cf9-0d37-49c7-9b49-7208fd5420be/image.png"></img></p>
<p><img width=800 src="https://velog.velcdn.com/images/c_mungi/post/a3173163-48ba-4d39-9bec-cb913c625afc/image.png"></img></p>
<br>

<h3 id="풀이-과정">풀이 과정</h3>
<hr>
<ul>
<li>참가 선수가 완주자 명단에 있는지 확인해야하므로 HashSet과 HashMap을 고려 <ul>
<li>HashSet의 경우 참가 선수를 Value로 담으면 되지만 제한사항의 <code>참가자 중에는 동명이인이 있을 수 있습니다.</code>라고 명시되어 있기에 HashSet으로 한다면 동명이인에 대한 체크가 불가능</li>
<li>HashMap의 경우 참가 선수를 Key로 담고 제한사항의 <code>참가자 중에는 동명이인이 있을 수 있습니다.</code>를 고려해 Value에 인원 수를 카운트한 정수를 담으면 동명이인에 대한 체크가 가능</li>
</ul>
</li>
<li>완주자 명단의 선수를 Key로 HashMap의 값을 추출해 1씩 빼기</li>
<li>HashMap에서 Value가 0이 아닌 선수가 완주하지 못한 선수로 판단해 반환.</li>
</ul>
<br>

<h3 id="작성-코드">작성 코드</h3>
<hr>
<pre><code class="language-java">import java.util.*;
class Solution {
    public String solution(String[] participant, String[] completion) {
        String answer = &quot;&quot;;

        HashMap&lt;String, Integer&gt; playerMap = new HashMap&lt;&gt;();
        for(String player : participant){
            // 동명이인이 없는 경우 디폴드 값(0) + 1
            // 동명이인이 있는 경우 저장된 값 + 1
            playerMap.put(player, playerMap.getOrDefault(player, 0)+1);
        }

        for(String player : completion){
            // 완료된 선수가 있는 경우 저장된 값 -1
            playerMap.put(player, playerMap.get(player)-1);
        }

        return playerMap.entrySet()
            .stream()
            .filter(entry -&gt; entry.getValue() != 0)
            .findFirst()
            .orElseThrow()
            .getKey();
    }
}</code></pre>
<br>

<h3 id="실행-결과">실행 결과</h3>
<hr>
<img width=700 src="https://velog.velcdn.com/images/c_mungi/post/a763b170-7520-40b8-8fc3-a1b2463a5507/image.png"/>
]]></description>
        </item>
    </channel>
</rss>